mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
Compare commits
12 commits
main
...
writable-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b76a90e871 | ||
|
|
69aa0277f5 | ||
|
|
9dd6d1ae6d | ||
|
|
61e40e917e | ||
|
|
29b8e80f3c | ||
|
|
2c3f086e98 | ||
|
|
6690d6bf7f | ||
|
|
61131f1fd7 | ||
|
|
ee51e0b82c | ||
|
|
5b252a2348 | ||
|
|
95bb5181b4 | ||
|
|
18c5bd1805 |
24 changed files with 393 additions and 62 deletions
|
|
@ -5,14 +5,9 @@ from http.cookies import SimpleCookie
|
|||
|
||||
@hookimpl
|
||||
def actor_from_request(datasette, request):
|
||||
cookies = SimpleCookie()
|
||||
cookies.load(
|
||||
dict(request.scope.get("headers") or []).get(b"cookie", b"").decode("utf-8")
|
||||
)
|
||||
if "ds_actor" not in cookies:
|
||||
if "ds_actor" not in request.cookies:
|
||||
return None
|
||||
ds_actor = cookies["ds_actor"].value
|
||||
try:
|
||||
return datasette.unsign(ds_actor, "actor")
|
||||
return datasette.unsign(request.cookies["ds_actor"], "actor")
|
||||
except BadSignature:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import collections
|
||||
import datetime
|
||||
import hashlib
|
||||
from http.cookies import SimpleCookie
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
|
|
@ -10,6 +11,7 @@ import sys
|
|||
import threading
|
||||
import traceback
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from concurrent import futures
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -30,6 +32,7 @@ from .views.special import (
|
|||
PatternPortfolioView,
|
||||
AuthTokenView,
|
||||
PermissionsDebugView,
|
||||
MessagesDebugView,
|
||||
)
|
||||
from .views.table import RowView, TableView
|
||||
from .renderer import json_renderer
|
||||
|
|
@ -156,6 +159,11 @@ async def favicon(scope, receive, send):
|
|||
|
||||
|
||||
class Datasette:
|
||||
# Message constants:
|
||||
INFO = 1
|
||||
WARNING = 2
|
||||
ERROR = 3
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
files,
|
||||
|
|
@ -289,7 +297,7 @@ class Datasette:
|
|||
pm.hook.prepare_jinja2_environment(env=self.jinja_env)
|
||||
|
||||
self._register_renderers()
|
||||
self.permission_checks = collections.deque(maxlen=30)
|
||||
self._permission_checks = collections.deque(maxlen=30)
|
||||
self._root_token = os.urandom(32).hex()
|
||||
|
||||
def sign(self, value, namespace="default"):
|
||||
|
|
@ -423,6 +431,38 @@ class Datasette:
|
|||
# pylint: disable=no-member
|
||||
pm.hook.prepare_connection(conn=conn, database=database, datasette=self)
|
||||
|
||||
def add_message(self, request, message, type=INFO):
|
||||
if not hasattr(request, "_messages"):
|
||||
request._messages = []
|
||||
request._messages_should_clear = False
|
||||
request._messages.append((message, type))
|
||||
|
||||
def _write_messages_to_response(self, request, response):
|
||||
if getattr(request, "_messages", None):
|
||||
# Set those messages
|
||||
cookie = SimpleCookie()
|
||||
cookie["ds_messages"] = self.sign(request._messages, "messages")
|
||||
cookie["ds_messages"]["path"] = "/"
|
||||
# TODO: Co-exist with existing set-cookie headers
|
||||
assert "set-cookie" not in response.headers
|
||||
response.headers["set-cookie"] = cookie.output(header="").lstrip()
|
||||
elif getattr(request, "_messages_should_clear", False):
|
||||
cookie = SimpleCookie()
|
||||
cookie["ds_messages"] = ""
|
||||
cookie["ds_messages"]["path"] = "/"
|
||||
# TODO: Co-exist with existing set-cookie headers
|
||||
assert "set-cookie" not in response.headers
|
||||
response.headers["set-cookie"] = cookie.output(header="").lstrip()
|
||||
|
||||
def _show_messages(self, request):
|
||||
if getattr(request, "_messages", None):
|
||||
request._messages_should_clear = True
|
||||
messages = request._messages
|
||||
request._messages = []
|
||||
return messages
|
||||
else:
|
||||
return []
|
||||
|
||||
async def permission_allowed(
|
||||
self, actor, action, resource_type=None, resource_identifier=None, default=False
|
||||
):
|
||||
|
|
@ -445,7 +485,7 @@ class Datasette:
|
|||
if result is None:
|
||||
result = default
|
||||
used_default = True
|
||||
self.permission_checks.append(
|
||||
self._permission_checks.append(
|
||||
{
|
||||
"when": datetime.datetime.utcnow().isoformat(),
|
||||
"actor": actor,
|
||||
|
|
@ -586,9 +626,9 @@ class Datasette:
|
|||
},
|
||||
}
|
||||
|
||||
def _plugins(self, show_all=False):
|
||||
def _plugins(self, request):
|
||||
ps = list(get_plugins())
|
||||
if not show_all:
|
||||
if not request.args.get("all"):
|
||||
ps = [p for p in ps if p["name"] not in DEFAULT_PLUGINS]
|
||||
return [
|
||||
{
|
||||
|
|
@ -596,6 +636,7 @@ class Datasette:
|
|||
"static": p["static_path"] is not None,
|
||||
"templates": p["templates_path"] is not None,
|
||||
"version": p.get("version"),
|
||||
"hooks": p["hooks"],
|
||||
}
|
||||
for p in ps
|
||||
]
|
||||
|
|
@ -783,7 +824,9 @@ class Datasette:
|
|||
r"/-/versions(?P<as_format>(\.json)?)$",
|
||||
)
|
||||
add_route(
|
||||
JsonDataView.as_asgi(self, "plugins.json", self._plugins),
|
||||
JsonDataView.as_asgi(
|
||||
self, "plugins.json", self._plugins, needs_request=True
|
||||
),
|
||||
r"/-/plugins(?P<as_format>(\.json)?)$",
|
||||
)
|
||||
add_route(
|
||||
|
|
@ -808,6 +851,9 @@ class Datasette:
|
|||
add_route(
|
||||
PermissionsDebugView.as_asgi(self), r"/-/permissions$",
|
||||
)
|
||||
add_route(
|
||||
MessagesDebugView.as_asgi(self), r"/-/messages$",
|
||||
)
|
||||
add_route(
|
||||
PatternPortfolioView.as_asgi(self), r"/-/patterns$",
|
||||
)
|
||||
|
|
@ -856,13 +902,15 @@ class DatasetteRouter(AsgiRouter):
|
|||
base_url = self.ds.config("base_url")
|
||||
if base_url != "/" and path.startswith(base_url):
|
||||
path = "/" + path[len(base_url) :]
|
||||
# Add a correlation ID
|
||||
scope_modifications = {"correlation_id": str(uuid.uuid4())}
|
||||
# Apply force_https_urls, if set
|
||||
if (
|
||||
self.ds.config("force_https_urls")
|
||||
and scope["type"] == "http"
|
||||
and scope.get("scheme") != "https"
|
||||
):
|
||||
scope = dict(scope, scheme="https")
|
||||
scope_modifications["scheme"] = "https"
|
||||
# Handle authentication
|
||||
actor = None
|
||||
for actor in pm.hook.actor_from_request(
|
||||
|
|
@ -874,7 +922,10 @@ class DatasetteRouter(AsgiRouter):
|
|||
actor = await actor
|
||||
if actor:
|
||||
break
|
||||
return await super().route_path(dict(scope, actor=actor), receive, send, path)
|
||||
scope_modifications["actor"] = actor
|
||||
return await super().route_path(
|
||||
dict(scope, **scope_modifications), receive, send, path
|
||||
)
|
||||
|
||||
async def handle_404(self, scope, receive, send, exception=None):
|
||||
# If URL has a trailing slash, redirect to URL without it
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ def get_plugins():
|
|||
"name": plugin.__name__,
|
||||
"static_path": static_path,
|
||||
"templates_path": templates_path,
|
||||
"hooks": [h.name for h in pm.get_hookcallers(plugin)],
|
||||
}
|
||||
distinfo = plugin_to_distinfo.get(plugin)
|
||||
if distinfo:
|
||||
|
|
|
|||
|
|
@ -351,3 +351,19 @@ p.zero-results {
|
|||
.type-float, .type-int {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.message-info {
|
||||
padding: 1em;
|
||||
border: 1px solid green;
|
||||
background-color: #c7fbc7;
|
||||
}
|
||||
.message-warning {
|
||||
padding: 1em;
|
||||
border: 1px solid #ae7100;
|
||||
background-color: #fbdda5;
|
||||
}
|
||||
.message-error {
|
||||
padding: 1em;
|
||||
border: 1px solid red;
|
||||
background-color: pink;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@
|
|||
<nav class="hd">{% block nav %}{% endblock %}</nav>
|
||||
|
||||
<div class="bd">
|
||||
{% block messages %}
|
||||
{% if show_messages %}
|
||||
{% for message, message_type in show_messages() %}
|
||||
<p class="message-{% if message_type == 1 %}info{% elif message_type == 2 %}warning{% elif message_type == 3 %}error{% endif %}">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
|
|
|||
26
datasette/templates/messages_debug.html
Normal file
26
datasette/templates/messages_debug.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug messages{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Debug messages</h1>
|
||||
|
||||
<p>Set a message:</p>
|
||||
|
||||
<form action="/-/messages" method="POST">
|
||||
<div>
|
||||
<input type="text" name="message" style="width: 40%">
|
||||
<div class="select-wrapper">
|
||||
<select name="message_type">
|
||||
<option>INFO</option>
|
||||
<option>WARNING</option>
|
||||
<option>ERROR</option>
|
||||
<option>all</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="submit" value="Add message">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -20,6 +20,12 @@
|
|||
<a href="/fixtures/attraction_characteristic">attraction_characteristic</a>
|
||||
</p>
|
||||
</nav>
|
||||
<h2 class="pattern-heading">Messages</h2>
|
||||
<div class="bd">
|
||||
<p class="message-info">Example message</p>
|
||||
<p class="message-warning">Example message</p>
|
||||
<p class="message-error">Example message</p>
|
||||
</div>
|
||||
<h2 class="pattern-heading">.bd for /</h2>
|
||||
<div class="bd">
|
||||
<h1>Datasette Fixtures</h1>
|
||||
|
|
|
|||
|
|
@ -27,11 +27,12 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or database }}</h1>
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
<form class="sql" action="{{ database_url(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="get">
|
||||
<form class="sql" action="{{ database_url(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_write %}post{% else %}get{% endif %}">
|
||||
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %} <span class="show-hide-sql">{% if hide_sql %}(<a href="{{ path_with_removed_args(request, {'_hide_sql': '1'}) }}">show</a>){% else %}(<a href="{{ path_with_added_args(request, {'_hide_sql': '1'}) }}">hide</a>){% endif %}</span></h3>
|
||||
{% if not hide_sql %}
|
||||
{% if editable and config.allow_sql %}
|
||||
|
|
@ -74,7 +75,12 @@
|
|||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="zero-results">0 results</p>
|
||||
{% if success_message %}
|
||||
<p class="success">{{ success_message }}</p>
|
||||
{% endif %}
|
||||
{% if not canned_write %}
|
||||
<p class="zero-results">0 results</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from mimetypes import guess_type
|
|||
from urllib.parse import parse_qs, urlunparse, parse_qsl
|
||||
from pathlib import Path
|
||||
from html import escape
|
||||
from http.cookies import SimpleCookie
|
||||
import re
|
||||
import aiofiles
|
||||
|
||||
|
|
@ -44,6 +45,12 @@ class Request:
|
|||
def host(self):
|
||||
return self.headers.get("host") or "localhost"
|
||||
|
||||
@property
|
||||
def cookies(self):
|
||||
cookies = SimpleCookie()
|
||||
cookies.load(self.headers.get("cookie", ""))
|
||||
return {key: value.value for key, value in cookies.items()}
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
if self.scope.get("raw_path") is not None:
|
||||
|
|
@ -173,9 +180,9 @@ class AsgiLifespan:
|
|||
|
||||
|
||||
class AsgiView:
|
||||
def dispatch_request(self, request, *args, **kwargs):
|
||||
async def dispatch_request(self, request, *args, **kwargs):
|
||||
handler = getattr(self, request.method.lower(), None)
|
||||
return handler(request, *args, **kwargs)
|
||||
return await handler(request, *args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def as_asgi(cls, *class_args, **class_kwargs):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
import csv
|
||||
import itertools
|
||||
from itsdangerous import BadSignature
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
|
@ -73,6 +74,20 @@ class BaseView(AsgiView):
|
|||
def database_color(self, database):
|
||||
return "ff0000"
|
||||
|
||||
async def dispatch_request(self, request, *args, **kwargs):
|
||||
# Populate request_messages if ds_messages cookie is present
|
||||
if self.ds:
|
||||
try:
|
||||
request._messages = self.ds.unsign(
|
||||
request.cookies.get("ds_messages", ""), "messages"
|
||||
)
|
||||
except BadSignature:
|
||||
pass
|
||||
response = await super().dispatch_request(request, *args, **kwargs)
|
||||
if self.ds:
|
||||
self.ds._write_messages_to_response(request, response)
|
||||
return response
|
||||
|
||||
async def render(self, templates, request, context=None):
|
||||
context = context or {}
|
||||
template = self.ds.jinja_env.select_template(templates)
|
||||
|
|
@ -81,6 +96,7 @@ class BaseView(AsgiView):
|
|||
**{
|
||||
"database_url": self.database_url,
|
||||
"database_color": self.database_color,
|
||||
"show_messages": lambda: self.ds._show_messages(request),
|
||||
"select_templates": [
|
||||
"{}{}".format(
|
||||
"*" if template_name == template.name else "", template_name
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ class QueryView(DataView):
|
|||
canned_query=None,
|
||||
metadata=None,
|
||||
_size=None,
|
||||
named_parameters=None,
|
||||
write=False,
|
||||
):
|
||||
params = {key: request.args.get(key) for key in request.args}
|
||||
if "sql" in params:
|
||||
|
|
@ -113,7 +115,7 @@ class QueryView(DataView):
|
|||
if "_shape" in params:
|
||||
params.pop("_shape")
|
||||
# Extract any :named parameters
|
||||
named_parameters = self.re_named_parameter.findall(sql)
|
||||
named_parameters = named_parameters or self.re_named_parameter.findall(sql)
|
||||
named_parameter_values = {
|
||||
named_parameter: params.get(named_parameter) or ""
|
||||
for named_parameter in named_parameters
|
||||
|
|
@ -129,12 +131,49 @@ class QueryView(DataView):
|
|||
extra_args["custom_time_limit"] = int(params["_timelimit"])
|
||||
if _size:
|
||||
extra_args["page_size"] = _size
|
||||
results = await self.ds.execute(
|
||||
database, sql, params, truncate=True, **extra_args
|
||||
)
|
||||
columns = [r[0] for r in results.description]
|
||||
|
||||
templates = ["query-{}.html".format(to_css_class(database)), "query.html"]
|
||||
|
||||
# Execute query - as write or as read
|
||||
if write:
|
||||
if request.method == "POST":
|
||||
params = await request.post_vars()
|
||||
write_ok = await self.ds.databases[database].execute_write(
|
||||
sql, params, block=True
|
||||
)
|
||||
return self.redirect(
|
||||
request, request.path + "?_success=Query+executed_successfully"
|
||||
)
|
||||
else:
|
||||
|
||||
async def extra_template():
|
||||
return {
|
||||
"request": request,
|
||||
"path_with_added_args": path_with_added_args,
|
||||
"path_with_removed_args": path_with_removed_args,
|
||||
"named_parameter_values": named_parameter_values,
|
||||
"canned_query": canned_query,
|
||||
"success_message": request.raw_args.get("_success") or "",
|
||||
"canned_write": True,
|
||||
}
|
||||
|
||||
return (
|
||||
{
|
||||
"database": database,
|
||||
"rows": [],
|
||||
"truncated": False,
|
||||
"columns": [],
|
||||
"query": {"sql": sql, "params": params},
|
||||
},
|
||||
extra_template,
|
||||
templates,
|
||||
)
|
||||
else: # Not a write
|
||||
results = await self.ds.execute(
|
||||
database, sql, params, truncate=True, **extra_args
|
||||
)
|
||||
columns = [r[0] for r in results.description]
|
||||
|
||||
if canned_query:
|
||||
templates.insert(
|
||||
0,
|
||||
|
|
|
|||
|
|
@ -92,5 +92,29 @@ class PermissionsDebugView(BaseView):
|
|||
return await self.render(
|
||||
["permissions_debug.html"],
|
||||
request,
|
||||
{"permission_checks": reversed(self.ds.permission_checks)},
|
||||
{"permission_checks": reversed(self.ds._permission_checks)},
|
||||
)
|
||||
|
||||
|
||||
class MessagesDebugView(BaseView):
|
||||
name = "messages_debug"
|
||||
|
||||
def __init__(self, datasette):
|
||||
self.ds = datasette
|
||||
|
||||
async def get(self, request):
|
||||
return await self.render(["messages_debug.html"], request)
|
||||
|
||||
async def post(self, request):
|
||||
post = await request.post_vars()
|
||||
message = post.get("message", "")
|
||||
message_type = post.get("message_type") or "INFO"
|
||||
assert message_type in ("INFO", "WARNING", "ERROR", "all")
|
||||
datasette = self.ds
|
||||
if message_type == "all":
|
||||
datasette.add_message(request, message, datasette.INFO)
|
||||
datasette.add_message(request, message, datasette.WARNING)
|
||||
datasette.add_message(request, message, datasette.ERROR)
|
||||
else:
|
||||
datasette.add_message(request, message, getattr(datasette, message_type))
|
||||
return Response.redirect("/")
|
||||
|
|
|
|||
|
|
@ -221,6 +221,22 @@ class RowTableShared(DataView):
|
|||
class TableView(RowTableShared):
|
||||
name = "table"
|
||||
|
||||
async def post(self, request, db_name, table_and_format):
|
||||
# Handle POST to a canned query
|
||||
canned_query = self.ds.get_canned_query(db_name, table_and_format)
|
||||
assert canned_query, "You may only POST to a canned query"
|
||||
return await QueryView(self.ds).data(
|
||||
request,
|
||||
db_name,
|
||||
None,
|
||||
canned_query["sql"],
|
||||
metadata=canned_query,
|
||||
editable=False,
|
||||
canned_query=table_and_format,
|
||||
named_parameters=canned_query.get("params"),
|
||||
write=bool(canned_query.get("write")),
|
||||
)
|
||||
|
||||
async def data(
|
||||
self,
|
||||
request,
|
||||
|
|
@ -241,6 +257,8 @@ class TableView(RowTableShared):
|
|||
metadata=canned_query,
|
||||
editable=False,
|
||||
canned_query=table,
|
||||
named_parameters=canned_query.get("params"),
|
||||
write=bool(canned_query.get("write")),
|
||||
)
|
||||
|
||||
db = self.ds.databases[database]
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ The request object is passed to various plugin hooks. It represents an incoming
|
|||
``.headers`` - dictionary (str -> str)
|
||||
A dictionary of incoming HTTP request headers.
|
||||
|
||||
``.cookies`` - dictionary (str -> str)
|
||||
A dictionary of incoming cookies
|
||||
|
||||
``.host`` - string
|
||||
The host header from the incoming request, e.g. ``latest.datasette.io`` or ``localhost``.
|
||||
|
||||
|
|
@ -211,6 +214,24 @@ This method returns a signed string, which can be decoded and verified using :re
|
|||
|
||||
Returns the original, decoded object that was passed to :ref:`datasette_sign`. If the signature is not valid this raises a ``itsdangerous.BadSignature`` exception.
|
||||
|
||||
.. _datasette_add_message:
|
||||
|
||||
.add_message(request, message, message_type=datasette.INFO)
|
||||
-----------------------------------------------------------
|
||||
|
||||
``request`` - Request
|
||||
The current Request object
|
||||
|
||||
``message`` - string
|
||||
The message string
|
||||
|
||||
``message_type`` - constant, optional
|
||||
The message type - ``datasette.INFO``, ``datasette.WARNING`` or ``datasette.ERROR``
|
||||
|
||||
Datasette's flash messaging mechanism allows you to add a message that will be displayed to the user on the next page that they visit. Messages are persisted in a ``ds_messages`` cookie. This method adds a message to that cookie.
|
||||
|
||||
You can try out these messages (including the different visual styling of the three message types) using the ``/-/messages`` debugging tool.
|
||||
|
||||
.. _internals_database:
|
||||
|
||||
Database class
|
||||
|
|
|
|||
|
|
@ -78,10 +78,13 @@ Shows a list of currently installed plugins and their versions. `Plugins example
|
|||
"name": "datasette_cluster_map",
|
||||
"static": true,
|
||||
"templates": false,
|
||||
"version": "0.4"
|
||||
"version": "0.10",
|
||||
"hooks": ["extra_css_urls", "extra_js_urls", "extra_body_script"]
|
||||
}
|
||||
]
|
||||
|
||||
Add ``?all=1`` to include details of the default plugins baked into Datasette.
|
||||
|
||||
.. _JsonDataView_config:
|
||||
|
||||
/-/config
|
||||
|
|
@ -166,3 +169,11 @@ Shows the currently authenticated actor. Useful for debugging Datasette authenti
|
|||
"username": "some-user"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.. _MessagesDebugView:
|
||||
|
||||
/-/messages
|
||||
-----------
|
||||
|
||||
The debug tool at ``/-/messages`` can be used to set flash messages to try out that feature. See :ref:`datasette_add_message` for details of this feature.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ class TestResponse:
|
|||
self.headers = headers
|
||||
self.body = body
|
||||
|
||||
@property
|
||||
def cookies(self):
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(self.headers.get("set-cookie") or "")
|
||||
return {key: value.value for key, value in cookie.items()}
|
||||
|
||||
@property
|
||||
def json(self):
|
||||
return json.loads(self.text)
|
||||
|
|
|
|||
21
tests/plugins/messages_output_renderer.py
Normal file
21
tests/plugins/messages_output_renderer.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from datasette import hookimpl
|
||||
|
||||
|
||||
def render_message_debug(datasette, request):
|
||||
if request.args.get("add_msg"):
|
||||
msg_type = request.args.get("type", "INFO")
|
||||
datasette.add_message(
|
||||
request, request.args["add_msg"], getattr(datasette, msg_type)
|
||||
)
|
||||
return {"body": "Hello from message debug"}
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_output_renderer(datasette):
|
||||
return [
|
||||
{
|
||||
"extension": "message",
|
||||
"render": render_message_debug,
|
||||
"can_render": lambda: False,
|
||||
}
|
||||
]
|
||||
|
|
@ -1260,13 +1260,59 @@ def test_threads_json(app_client):
|
|||
def test_plugins_json(app_client):
|
||||
response = app_client.get("/-/plugins.json")
|
||||
expected = [
|
||||
{"name": name, "static": False, "templates": False, "version": None}
|
||||
for name in (
|
||||
"my_plugin.py",
|
||||
"my_plugin_2.py",
|
||||
"register_output_renderer.py",
|
||||
"view_name.py",
|
||||
)
|
||||
{
|
||||
"name": "messages_output_renderer.py",
|
||||
"static": False,
|
||||
"templates": False,
|
||||
"version": None,
|
||||
"hooks": ["register_output_renderer"],
|
||||
},
|
||||
{
|
||||
"name": "my_plugin.py",
|
||||
"static": False,
|
||||
"templates": False,
|
||||
"version": None,
|
||||
"hooks": [
|
||||
"actor_from_request",
|
||||
"extra_body_script",
|
||||
"extra_css_urls",
|
||||
"extra_js_urls",
|
||||
"extra_template_vars",
|
||||
"permission_allowed",
|
||||
"prepare_connection",
|
||||
"prepare_jinja2_environment",
|
||||
"register_facet_classes",
|
||||
"render_cell",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "my_plugin_2.py",
|
||||
"static": False,
|
||||
"templates": False,
|
||||
"version": None,
|
||||
"hooks": [
|
||||
"actor_from_request",
|
||||
"asgi_wrapper",
|
||||
"extra_js_urls",
|
||||
"extra_template_vars",
|
||||
"permission_allowed",
|
||||
"render_cell",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "register_output_renderer.py",
|
||||
"static": False,
|
||||
"templates": False,
|
||||
"version": None,
|
||||
"hooks": ["register_output_renderer"],
|
||||
},
|
||||
{
|
||||
"name": "view_name.py",
|
||||
"static": False,
|
||||
"templates": False,
|
||||
"version": None,
|
||||
"hooks": ["extra_template_vars"],
|
||||
},
|
||||
]
|
||||
assert expected == sorted(response.json, key=lambda p: p["name"])
|
||||
|
||||
|
|
@ -1761,16 +1807,10 @@ def test_common_prefix_database_names(app_client_conflicting_database_names):
|
|||
# https://github.com/simonw/datasette/issues/597
|
||||
assert ["fixtures", "foo", "foo-bar"] == [
|
||||
d["name"]
|
||||
for d in json.loads(
|
||||
app_client_conflicting_database_names.get("/-/databases.json").body.decode(
|
||||
"utf8"
|
||||
)
|
||||
)
|
||||
for d in app_client_conflicting_database_names.get("/-/databases.json").json
|
||||
]
|
||||
for db_name, path in (("foo", "/foo.json"), ("foo-bar", "/foo-bar.json")):
|
||||
data = json.loads(
|
||||
app_client_conflicting_database_names.get(path).body.decode("utf8")
|
||||
)
|
||||
data = app_client_conflicting_database_names.get(path).json
|
||||
assert db_name == data["database"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,7 @@ def test_auth_token(app_client):
|
|||
response = app_client.get(path, allow_redirects=False,)
|
||||
assert 302 == response.status
|
||||
assert "/" == response.headers["Location"]
|
||||
set_cookie = response.headers["set-cookie"]
|
||||
assert set_cookie.endswith("; Path=/")
|
||||
assert set_cookie.startswith("ds_actor=")
|
||||
cookie_value = set_cookie.split("ds_actor=")[1].split("; Path=/")[0]
|
||||
assert {"id": "root"} == app_client.ds.unsign(cookie_value, "actor")
|
||||
assert {"id": "root"} == app_client.ds.unsign(response.cookies["ds_actor"], "actor")
|
||||
# Check that a second with same token fails
|
||||
assert app_client.ds._root_token is None
|
||||
assert 403 == app_client.get(path, allow_redirects=False,).status
|
||||
|
|
|
|||
|
|
@ -84,21 +84,20 @@ def config_dir_client(tmp_path_factory):
|
|||
def test_metadata(config_dir_client):
|
||||
response = config_dir_client.get("/-/metadata.json")
|
||||
assert 200 == response.status
|
||||
assert METADATA == json.loads(response.text)
|
||||
assert METADATA == response.json
|
||||
|
||||
|
||||
def test_config(config_dir_client):
|
||||
response = config_dir_client.get("/-/config.json")
|
||||
assert 200 == response.status
|
||||
config = json.loads(response.text)
|
||||
assert 60 == config["default_cache_ttl"]
|
||||
assert not config["allow_sql"]
|
||||
assert 60 == response.json["default_cache_ttl"]
|
||||
assert not response.json["allow_sql"]
|
||||
|
||||
|
||||
def test_plugins(config_dir_client):
|
||||
response = config_dir_client.get("/-/plugins.json")
|
||||
assert 200 == response.status
|
||||
assert "hooray.py" in {p["name"] for p in json.loads(response.text)}
|
||||
assert "hooray.py" in {p["name"] for p in response.json}
|
||||
|
||||
|
||||
def test_templates_and_plugin(config_dir_client):
|
||||
|
|
@ -123,7 +122,7 @@ def test_static_directory_browsing_not_allowed(config_dir_client):
|
|||
def test_databases(config_dir_client):
|
||||
response = config_dir_client.get("/-/databases.json")
|
||||
assert 200 == response.status
|
||||
databases = json.loads(response.text)
|
||||
databases = response.json
|
||||
assert 2 == len(databases)
|
||||
databases.sort(key=lambda d: d["name"])
|
||||
assert "demo" == databases[0]["name"]
|
||||
|
|
@ -141,4 +140,4 @@ def test_metadata_yaml(tmp_path_factory, filename):
|
|||
client.ds = ds
|
||||
response = client.get("/-/metadata.json")
|
||||
assert 200 == response.status
|
||||
assert {"title": "Title from metadata"} == json.loads(response.text)
|
||||
assert {"title": "Title from metadata"} == response.json
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ def test_csv_with_non_ascii_characters(app_client):
|
|||
)
|
||||
assert response.status == 200
|
||||
assert "text/plain; charset=utf-8" == response.headers["content-type"]
|
||||
assert "text,number\r\n𝐜𝐢𝐭𝐢𝐞𝐬,1\r\nbob,2\r\n" == response.body.decode("utf8")
|
||||
assert "text,number\r\n𝐜𝐢𝐭𝐢𝐞𝐬,1\r\nbob,2\r\n" == response.text
|
||||
|
||||
|
||||
def test_max_csv_mb(app_client_csv_max_mb_one):
|
||||
|
|
|
|||
|
|
@ -606,9 +606,7 @@ def test_row_html_simple_primary_key(app_client):
|
|||
|
||||
|
||||
def test_table_not_exists(app_client):
|
||||
assert "Table not found: blah" in app_client.get("/fixtures/blah").body.decode(
|
||||
"utf8"
|
||||
)
|
||||
assert "Table not found: blah" in app_client.get("/fixtures/blah").text
|
||||
|
||||
|
||||
def test_table_html_no_primary_key(app_client):
|
||||
|
|
|
|||
28
tests/test_messages.py
Normal file
28
tests/test_messages.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from .fixtures import app_client
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"qs,expected",
|
||||
[
|
||||
("add_msg=added-message", [["added-message", 1]]),
|
||||
("add_msg=added-warning&type=WARNING", [["added-warning", 2]]),
|
||||
("add_msg=added-error&type=ERROR", [["added-error", 3]]),
|
||||
],
|
||||
)
|
||||
def test_add_message_sets_cookie(app_client, qs, expected):
|
||||
response = app_client.get("/fixtures.message?{}".format(qs))
|
||||
signed = response.cookies["ds_messages"]
|
||||
decoded = app_client.ds.unsign(signed, "messages")
|
||||
assert expected == decoded
|
||||
|
||||
|
||||
def test_messages_are_displayed_and_cleared(app_client):
|
||||
# First set the message cookie
|
||||
set_msg_response = app_client.get("/fixtures.message?add_msg=xmessagex")
|
||||
# Now access a page that displays messages
|
||||
response = app_client.get("/", cookies=set_msg_response.cookies)
|
||||
# Messages should be in that HTML
|
||||
assert "xmessagex" in response.text
|
||||
# Cookie should have been set that clears messages
|
||||
assert "" == response.cookies["ds_messages"]
|
||||
|
|
@ -218,7 +218,7 @@ def test_plugin_config_file(app_client):
|
|||
)
|
||||
def test_plugins_extra_body_script(app_client, path, expected_extra_body_script):
|
||||
r = re.compile(r"<script>var extra_body_script = (.*?);</script>")
|
||||
json_data = r.search(app_client.get(path).body.decode("utf8")).group(1)
|
||||
json_data = r.search(app_client.get(path).text).group(1)
|
||||
actual_data = json.loads(json_data)
|
||||
assert expected_extra_body_script == actual_data
|
||||
|
||||
|
|
@ -331,7 +331,7 @@ def view_names_client(tmp_path_factory):
|
|||
def test_view_names(view_names_client, path, view_name):
|
||||
response = view_names_client.get(path)
|
||||
assert response.status == 200
|
||||
assert "view_name:{}".format(view_name) == response.body.decode("utf8")
|
||||
assert "view_name:{}".format(view_name) == response.text
|
||||
|
||||
|
||||
def test_register_output_renderer_no_parameters(app_client):
|
||||
|
|
@ -345,8 +345,7 @@ def test_register_output_renderer_all_parameters(app_client):
|
|||
assert 200 == response.status
|
||||
# Lots of 'at 0x103a4a690' in here - replace those so we can do
|
||||
# an easy comparison
|
||||
body = response.body.decode("utf-8")
|
||||
body = at_memory_re.sub(" at 0xXXX", body)
|
||||
body = at_memory_re.sub(" at 0xXXX", response.text)
|
||||
assert {
|
||||
"1+1": 2,
|
||||
"datasette": "<datasette.app.Datasette object at 0xXXX>",
|
||||
|
|
@ -468,7 +467,6 @@ def test_register_facet_classes(app_client):
|
|||
response = app_client.get(
|
||||
"/fixtures/compound_three_primary_keys.json?_dummy_facet=1"
|
||||
)
|
||||
data = json.loads(response.body)
|
||||
assert [
|
||||
{
|
||||
"name": "pk1",
|
||||
|
|
@ -502,7 +500,7 @@ def test_register_facet_classes(app_client):
|
|||
"name": "pk3",
|
||||
"toggle_url": "http://localhost/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_facet=pk3",
|
||||
},
|
||||
] == data["suggested_facets"]
|
||||
] == response.json["suggested_facets"]
|
||||
|
||||
|
||||
def test_actor_from_request(app_client):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue