Compare commits

...

12 commits

Author SHA1 Message Date
Simon Willison
b76a90e871 Added messages to pattern portfolio, refs #790 2020-06-02 16:56:46 -07:00
Simon Willison
69aa0277f5 /-/plugins now shows details of hooks, closes #794
Also added /-/plugins?all=1 parameter to see default plugins.
2020-06-02 16:56:39 -07:00
Simon Willison
9dd6d1ae6d More consistent use of response.text/response.json in tests, closes #792 2020-06-02 16:56:26 -07:00
Simon Willison
61e40e917e Flash messages mechanism, closes #790 2020-06-02 14:08:12 -07:00
Simon Willison
29b8e80f3c New request.cookies property 2020-06-02 14:06:53 -07:00
Simon Willison
2c3f086e98 permission_checks is now _permission_checks 2020-06-02 10:43:50 -07:00
Simon Willison
6690d6bf7f Internal scope correlation ID, refs #703 2020-06-01 08:47:16 -07:00
Simon Willison
61131f1fd7 Merge branch 'writable-canned' of github.com:simonw/datasette into writable-canned 2020-06-01 07:54:35 -07:00
Simon Willison
ee51e0b82c Applied black 2020-06-01 07:54:07 -07:00
Simon Willison
5b252a2348 First working version of writable canned queries, refs #698 2020-06-01 07:54:05 -07:00
Simon Willison
95bb5181b4 Applied black 2020-04-29 00:50:55 -07:00
Simon Willison
18c5bd1805 First working version of writable canned queries, refs #698 2020-04-29 00:47:12 -07:00
24 changed files with 393 additions and 62 deletions

View file

@ -5,14 +5,9 @@ from http.cookies import SimpleCookie
@hookimpl @hookimpl
def actor_from_request(datasette, request): def actor_from_request(datasette, request):
cookies = SimpleCookie() if "ds_actor" not in request.cookies:
cookies.load(
dict(request.scope.get("headers") or []).get(b"cookie", b"").decode("utf-8")
)
if "ds_actor" not in cookies:
return None return None
ds_actor = cookies["ds_actor"].value
try: try:
return datasette.unsign(ds_actor, "actor") return datasette.unsign(request.cookies["ds_actor"], "actor")
except BadSignature: except BadSignature:
return None return None

View file

@ -2,6 +2,7 @@ import asyncio
import collections import collections
import datetime import datetime
import hashlib import hashlib
from http.cookies import SimpleCookie
import itertools import itertools
import json import json
import os import os
@ -10,6 +11,7 @@ import sys
import threading import threading
import traceback import traceback
import urllib.parse import urllib.parse
import uuid
from concurrent import futures from concurrent import futures
from pathlib import Path from pathlib import Path
@ -30,6 +32,7 @@ from .views.special import (
PatternPortfolioView, PatternPortfolioView,
AuthTokenView, AuthTokenView,
PermissionsDebugView, PermissionsDebugView,
MessagesDebugView,
) )
from .views.table import RowView, TableView from .views.table import RowView, TableView
from .renderer import json_renderer from .renderer import json_renderer
@ -156,6 +159,11 @@ async def favicon(scope, receive, send):
class Datasette: class Datasette:
# Message constants:
INFO = 1
WARNING = 2
ERROR = 3
def __init__( def __init__(
self, self,
files, files,
@ -289,7 +297,7 @@ class Datasette:
pm.hook.prepare_jinja2_environment(env=self.jinja_env) pm.hook.prepare_jinja2_environment(env=self.jinja_env)
self._register_renderers() self._register_renderers()
self.permission_checks = collections.deque(maxlen=30) self._permission_checks = collections.deque(maxlen=30)
self._root_token = os.urandom(32).hex() self._root_token = os.urandom(32).hex()
def sign(self, value, namespace="default"): def sign(self, value, namespace="default"):
@ -423,6 +431,38 @@ class Datasette:
# pylint: disable=no-member # pylint: disable=no-member
pm.hook.prepare_connection(conn=conn, database=database, datasette=self) 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( async def permission_allowed(
self, actor, action, resource_type=None, resource_identifier=None, default=False self, actor, action, resource_type=None, resource_identifier=None, default=False
): ):
@ -445,7 +485,7 @@ class Datasette:
if result is None: if result is None:
result = default result = default
used_default = True used_default = True
self.permission_checks.append( self._permission_checks.append(
{ {
"when": datetime.datetime.utcnow().isoformat(), "when": datetime.datetime.utcnow().isoformat(),
"actor": actor, "actor": actor,
@ -586,9 +626,9 @@ class Datasette:
}, },
} }
def _plugins(self, show_all=False): def _plugins(self, request):
ps = list(get_plugins()) 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] ps = [p for p in ps if p["name"] not in DEFAULT_PLUGINS]
return [ return [
{ {
@ -596,6 +636,7 @@ class Datasette:
"static": p["static_path"] is not None, "static": p["static_path"] is not None,
"templates": p["templates_path"] is not None, "templates": p["templates_path"] is not None,
"version": p.get("version"), "version": p.get("version"),
"hooks": p["hooks"],
} }
for p in ps for p in ps
] ]
@ -783,7 +824,9 @@ class Datasette:
r"/-/versions(?P<as_format>(\.json)?)$", r"/-/versions(?P<as_format>(\.json)?)$",
) )
add_route( 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)?)$", r"/-/plugins(?P<as_format>(\.json)?)$",
) )
add_route( add_route(
@ -808,6 +851,9 @@ class Datasette:
add_route( add_route(
PermissionsDebugView.as_asgi(self), r"/-/permissions$", PermissionsDebugView.as_asgi(self), r"/-/permissions$",
) )
add_route(
MessagesDebugView.as_asgi(self), r"/-/messages$",
)
add_route( add_route(
PatternPortfolioView.as_asgi(self), r"/-/patterns$", PatternPortfolioView.as_asgi(self), r"/-/patterns$",
) )
@ -856,13 +902,15 @@ class DatasetteRouter(AsgiRouter):
base_url = self.ds.config("base_url") base_url = self.ds.config("base_url")
if base_url != "/" and path.startswith(base_url): if base_url != "/" and path.startswith(base_url):
path = "/" + path[len(base_url) :] path = "/" + path[len(base_url) :]
# Add a correlation ID
scope_modifications = {"correlation_id": str(uuid.uuid4())}
# Apply force_https_urls, if set # Apply force_https_urls, if set
if ( if (
self.ds.config("force_https_urls") self.ds.config("force_https_urls")
and scope["type"] == "http" and scope["type"] == "http"
and scope.get("scheme") != "https" and scope.get("scheme") != "https"
): ):
scope = dict(scope, scheme="https") scope_modifications["scheme"] = "https"
# Handle authentication # Handle authentication
actor = None actor = None
for actor in pm.hook.actor_from_request( for actor in pm.hook.actor_from_request(
@ -874,7 +922,10 @@ class DatasetteRouter(AsgiRouter):
actor = await actor actor = await actor
if actor: if actor:
break 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): async def handle_404(self, scope, receive, send, exception=None):
# If URL has a trailing slash, redirect to URL without it # If URL has a trailing slash, redirect to URL without it

View file

@ -49,6 +49,7 @@ def get_plugins():
"name": plugin.__name__, "name": plugin.__name__,
"static_path": static_path, "static_path": static_path,
"templates_path": templates_path, "templates_path": templates_path,
"hooks": [h.name for h in pm.get_hookcallers(plugin)],
} }
distinfo = plugin_to_distinfo.get(plugin) distinfo = plugin_to_distinfo.get(plugin)
if distinfo: if distinfo:

View file

@ -351,3 +351,19 @@ p.zero-results {
.type-float, .type-int { .type-float, .type-int {
color: #666; 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;
}

View file

@ -17,6 +17,14 @@
<nav class="hd">{% block nav %}{% endblock %}</nav> <nav class="hd">{% block nav %}{% endblock %}</nav>
<div class="bd"> <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 %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>

View 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 %}

View file

@ -20,6 +20,12 @@
<a href="/fixtures/attraction_characteristic">attraction_characteristic</a> <a href="/fixtures/attraction_characteristic">attraction_characteristic</a>
</p> </p>
</nav> </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> <h2 class="pattern-heading">.bd for /</h2>
<div class="bd"> <div class="bd">
<h1>Datasette Fixtures</h1> <h1>Datasette Fixtures</h1>

View file

@ -27,11 +27,12 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or database }}</h1> <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 %} {% 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> <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 not hide_sql %}
{% if editable and config.allow_sql %} {% if editable and config.allow_sql %}
@ -74,7 +75,12 @@
</tbody> </tbody>
</table> </table>
{% else %} {% 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 %} {% endif %}
{% include "_codemirror_foot.html" %} {% include "_codemirror_foot.html" %}

View file

@ -4,6 +4,7 @@ from mimetypes import guess_type
from urllib.parse import parse_qs, urlunparse, parse_qsl from urllib.parse import parse_qs, urlunparse, parse_qsl
from pathlib import Path from pathlib import Path
from html import escape from html import escape
from http.cookies import SimpleCookie
import re import re
import aiofiles import aiofiles
@ -44,6 +45,12 @@ class Request:
def host(self): def host(self):
return self.headers.get("host") or "localhost" 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 @property
def path(self): def path(self):
if self.scope.get("raw_path") is not None: if self.scope.get("raw_path") is not None:
@ -173,9 +180,9 @@ class AsgiLifespan:
class AsgiView: class AsgiView:
def dispatch_request(self, request, *args, **kwargs): async def dispatch_request(self, request, *args, **kwargs):
handler = getattr(self, request.method.lower(), None) handler = getattr(self, request.method.lower(), None)
return handler(request, *args, **kwargs) return await handler(request, *args, **kwargs)
@classmethod @classmethod
def as_asgi(cls, *class_args, **class_kwargs): def as_asgi(cls, *class_args, **class_kwargs):

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
import csv import csv
import itertools import itertools
from itsdangerous import BadSignature
import json import json
import re import re
import time import time
@ -73,6 +74,20 @@ class BaseView(AsgiView):
def database_color(self, database): def database_color(self, database):
return "ff0000" 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): async def render(self, templates, request, context=None):
context = context or {} context = context or {}
template = self.ds.jinja_env.select_template(templates) template = self.ds.jinja_env.select_template(templates)
@ -81,6 +96,7 @@ class BaseView(AsgiView):
**{ **{
"database_url": self.database_url, "database_url": self.database_url,
"database_color": self.database_color, "database_color": self.database_color,
"show_messages": lambda: self.ds._show_messages(request),
"select_templates": [ "select_templates": [
"{}{}".format( "{}{}".format(
"*" if template_name == template.name else "", template_name "*" if template_name == template.name else "", template_name

View file

@ -106,6 +106,8 @@ class QueryView(DataView):
canned_query=None, canned_query=None,
metadata=None, metadata=None,
_size=None, _size=None,
named_parameters=None,
write=False,
): ):
params = {key: request.args.get(key) for key in request.args} params = {key: request.args.get(key) for key in request.args}
if "sql" in params: if "sql" in params:
@ -113,7 +115,7 @@ class QueryView(DataView):
if "_shape" in params: if "_shape" in params:
params.pop("_shape") params.pop("_shape")
# Extract any :named parameters # 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_values = {
named_parameter: params.get(named_parameter) or "" named_parameter: params.get(named_parameter) or ""
for named_parameter in named_parameters for named_parameter in named_parameters
@ -129,12 +131,49 @@ class QueryView(DataView):
extra_args["custom_time_limit"] = int(params["_timelimit"]) extra_args["custom_time_limit"] = int(params["_timelimit"])
if _size: if _size:
extra_args["page_size"] = _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"] 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: if canned_query:
templates.insert( templates.insert(
0, 0,

View file

@ -92,5 +92,29 @@ class PermissionsDebugView(BaseView):
return await self.render( return await self.render(
["permissions_debug.html"], ["permissions_debug.html"],
request, 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("/")

View file

@ -221,6 +221,22 @@ class RowTableShared(DataView):
class TableView(RowTableShared): class TableView(RowTableShared):
name = "table" 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( async def data(
self, self,
request, request,
@ -241,6 +257,8 @@ class TableView(RowTableShared):
metadata=canned_query, metadata=canned_query,
editable=False, editable=False,
canned_query=table, canned_query=table,
named_parameters=canned_query.get("params"),
write=bool(canned_query.get("write")),
) )
db = self.ds.databases[database] db = self.ds.databases[database]

View file

@ -27,6 +27,9 @@ The request object is passed to various plugin hooks. It represents an incoming
``.headers`` - dictionary (str -> str) ``.headers`` - dictionary (str -> str)
A dictionary of incoming HTTP request headers. A dictionary of incoming HTTP request headers.
``.cookies`` - dictionary (str -> str)
A dictionary of incoming cookies
``.host`` - string ``.host`` - string
The host header from the incoming request, e.g. ``latest.datasette.io`` or ``localhost``. 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. 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: .. _internals_database:
Database class Database class

View file

@ -78,10 +78,13 @@ Shows a list of currently installed plugins and their versions. `Plugins example
"name": "datasette_cluster_map", "name": "datasette_cluster_map",
"static": true, "static": true,
"templates": false, "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: .. _JsonDataView_config:
/-/config /-/config
@ -166,3 +169,11 @@ Shows the currently authenticated actor. Useful for debugging Datasette authenti
"username": "some-user" "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.

View file

@ -29,6 +29,12 @@ class TestResponse:
self.headers = headers self.headers = headers
self.body = body 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 @property
def json(self): def json(self):
return json.loads(self.text) return json.loads(self.text)

View 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,
}
]

View file

@ -1260,13 +1260,59 @@ def test_threads_json(app_client):
def test_plugins_json(app_client): def test_plugins_json(app_client):
response = app_client.get("/-/plugins.json") response = app_client.get("/-/plugins.json")
expected = [ expected = [
{"name": name, "static": False, "templates": False, "version": None} {
for name in ( "name": "messages_output_renderer.py",
"my_plugin.py", "static": False,
"my_plugin_2.py", "templates": False,
"register_output_renderer.py", "version": None,
"view_name.py", "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"]) 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 # https://github.com/simonw/datasette/issues/597
assert ["fixtures", "foo", "foo-bar"] == [ assert ["fixtures", "foo", "foo-bar"] == [
d["name"] d["name"]
for d in json.loads( for d in app_client_conflicting_database_names.get("/-/databases.json").json
app_client_conflicting_database_names.get("/-/databases.json").body.decode(
"utf8"
)
)
] ]
for db_name, path in (("foo", "/foo.json"), ("foo-bar", "/foo-bar.json")): for db_name, path in (("foo", "/foo.json"), ("foo-bar", "/foo-bar.json")):
data = json.loads( data = app_client_conflicting_database_names.get(path).json
app_client_conflicting_database_names.get(path).body.decode("utf8")
)
assert db_name == data["database"] assert db_name == data["database"]

View file

@ -9,11 +9,7 @@ def test_auth_token(app_client):
response = app_client.get(path, allow_redirects=False,) response = app_client.get(path, allow_redirects=False,)
assert 302 == response.status assert 302 == response.status
assert "/" == response.headers["Location"] assert "/" == response.headers["Location"]
set_cookie = response.headers["set-cookie"] assert {"id": "root"} == app_client.ds.unsign(response.cookies["ds_actor"], "actor")
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")
# Check that a second with same token fails # Check that a second with same token fails
assert app_client.ds._root_token is None assert app_client.ds._root_token is None
assert 403 == app_client.get(path, allow_redirects=False,).status assert 403 == app_client.get(path, allow_redirects=False,).status

View file

@ -84,21 +84,20 @@ def config_dir_client(tmp_path_factory):
def test_metadata(config_dir_client): def test_metadata(config_dir_client):
response = config_dir_client.get("/-/metadata.json") response = config_dir_client.get("/-/metadata.json")
assert 200 == response.status assert 200 == response.status
assert METADATA == json.loads(response.text) assert METADATA == response.json
def test_config(config_dir_client): def test_config(config_dir_client):
response = config_dir_client.get("/-/config.json") response = config_dir_client.get("/-/config.json")
assert 200 == response.status assert 200 == response.status
config = json.loads(response.text) assert 60 == response.json["default_cache_ttl"]
assert 60 == config["default_cache_ttl"] assert not response.json["allow_sql"]
assert not config["allow_sql"]
def test_plugins(config_dir_client): def test_plugins(config_dir_client):
response = config_dir_client.get("/-/plugins.json") response = config_dir_client.get("/-/plugins.json")
assert 200 == response.status 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): 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): def test_databases(config_dir_client):
response = config_dir_client.get("/-/databases.json") response = config_dir_client.get("/-/databases.json")
assert 200 == response.status assert 200 == response.status
databases = json.loads(response.text) databases = response.json
assert 2 == len(databases) assert 2 == len(databases)
databases.sort(key=lambda d: d["name"]) databases.sort(key=lambda d: d["name"])
assert "demo" == databases[0]["name"] assert "demo" == databases[0]["name"]
@ -141,4 +140,4 @@ def test_metadata_yaml(tmp_path_factory, filename):
client.ds = ds client.ds = ds
response = client.get("/-/metadata.json") response = client.get("/-/metadata.json")
assert 200 == response.status assert 200 == response.status
assert {"title": "Title from metadata"} == json.loads(response.text) assert {"title": "Title from metadata"} == response.json

View file

@ -101,7 +101,7 @@ def test_csv_with_non_ascii_characters(app_client):
) )
assert response.status == 200 assert response.status == 200
assert "text/plain; charset=utf-8" == response.headers["content-type"] 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): def test_max_csv_mb(app_client_csv_max_mb_one):

View file

@ -606,9 +606,7 @@ def test_row_html_simple_primary_key(app_client):
def test_table_not_exists(app_client): def test_table_not_exists(app_client):
assert "Table not found: blah" in app_client.get("/fixtures/blah").body.decode( assert "Table not found: blah" in app_client.get("/fixtures/blah").text
"utf8"
)
def test_table_html_no_primary_key(app_client): def test_table_html_no_primary_key(app_client):

28
tests/test_messages.py Normal file
View 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"]

View file

@ -218,7 +218,7 @@ def test_plugin_config_file(app_client):
) )
def test_plugins_extra_body_script(app_client, path, expected_extra_body_script): def test_plugins_extra_body_script(app_client, path, expected_extra_body_script):
r = re.compile(r"<script>var extra_body_script = (.*?);</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) actual_data = json.loads(json_data)
assert expected_extra_body_script == actual_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): def test_view_names(view_names_client, path, view_name):
response = view_names_client.get(path) response = view_names_client.get(path)
assert response.status == 200 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): 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 assert 200 == response.status
# Lots of 'at 0x103a4a690' in here - replace those so we can do # Lots of 'at 0x103a4a690' in here - replace those so we can do
# an easy comparison # an easy comparison
body = response.body.decode("utf-8") body = at_memory_re.sub(" at 0xXXX", response.text)
body = at_memory_re.sub(" at 0xXXX", body)
assert { assert {
"1+1": 2, "1+1": 2,
"datasette": "<datasette.app.Datasette object at 0xXXX>", "datasette": "<datasette.app.Datasette object at 0xXXX>",
@ -468,7 +467,6 @@ def test_register_facet_classes(app_client):
response = app_client.get( response = app_client.get(
"/fixtures/compound_three_primary_keys.json?_dummy_facet=1" "/fixtures/compound_three_primary_keys.json?_dummy_facet=1"
) )
data = json.loads(response.body)
assert [ assert [
{ {
"name": "pk1", "name": "pk1",
@ -502,7 +500,7 @@ def test_register_facet_classes(app_client):
"name": "pk3", "name": "pk3",
"toggle_url": "http://localhost/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_facet=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): def test_actor_from_request(app_client):