2022-02-04 21:19:49 -08:00
|
|
|
import asyncio
|
2026-07-25 15:47:08 -07:00
|
|
|
import base64
|
|
|
|
|
import json
|
|
|
|
|
import urllib.parse
|
|
|
|
|
|
Remove the hand-rolled tracer now that OpenTelemetry covers the same ground
Datasette had two tracing systems since the OpenTelemetry spans landed. The
hand-rolled one measures the wrong thing - issue 1730, open since 2022, is
about exactly that - and it cannot be rebuilt on top of the new spans without
core owning a TracerProvider, which is the one thing the OTel design refuses
to do. Rather than carry duplicate instrumentation on the db.execute() hot
path indefinitely, the old system goes.
Deleted: datasette/tracer.py, the trace_debug setting, the AsgiTracer
response-rewriting middleware and the ?_trace=1 query-string argument.
- datasette/database.py: the four `with trace(...)` wrappers PR 1 deliberately
nested the OTel spans inside are removed and the bodies dedented. That also
retires the `# noqa: SIM117` comments those wrappers required - a leftover
unnecessary noqa trips ruff's RUF100 - and `kwargs["count"] = count` in
execute_write_many, which fed the old tracer only. `git diff -w` on this file
shows nothing but the deleted lines.
- datasette/views/base.py: stream_csv() still read ?_trace=1 to wrap CSV output
in an HTML <textarea> debug page. That whole branch, including the
EscapeHtmlWriter selection and the conditional content-type, is gone. The
EscapeHtmlWriter class itself stays in datasette.utils - it is an importable
public name and removing it would widen the API break.
- .github/workflows/deploy-latest.yml no longer passes --setting trace_debug 1.
Worth stating precisely, because the ticket claimed otherwise: this would not
have broken the deploy. Setting.convert() in cli.py only rewrites a bare name
to settings.<name> for *known* settings, so `--setting trace_debug 1` would
have been silently accepted as a meaningless top-level config key. The flag is
removed because it is dead, not because it errors.
Tests. tests/test_tracer.py is deleted outright (6 items). Four other tests used
?_trace=1 as an assertion instrument rather than testing tracing:
- test_csv_trace tested the trace mechanism itself - deleted.
- test_table_csv_stream_does_not_calculate_facets,
test_table_csv_stream_does_not_calculate_counts and
test_nocount_nofacet_if_shape_is_object test real behaviour, and are rebuilt
against captured spans. All three had silently stopped being able to fail: the
facets test looked for "select content, count(*) as n", which facet suggestion
has not emitted since it moved to a `with limited as (...)` CTE, and none of
the three requested the count or facet work whose suppression they claim to
check. The rebuilt versions ask for it explicitly, match strings the current
SQL contains, and carry a guard assertion so an empty span list cannot
masquerade as a pass. Each was confirmed to fail with the covered code broken.
- test_trace_correctly_escaped is kept, renamed test_query_page_escapes_sql,
with ?_trace=1 dropped. It ran against ds_client, which has no trace_debug, so
it never exercised the tracer - what it actually covered is the query page
echoing user SQL into HTML, the surface of the two reflected-XSS advisories in
issue 1360, and nothing else in the suite covers it. Deleting it would have
quietly dropped that.
tests/test_utils.py's pairs_to_nested_config case used settings.trace_debug to
check that a later key overrides an earlier one; it now uses template_debug
rather than losing the case.
Docs: the datasette.tracer section of internals.rst, the trace_debug section of
settings.rst, the ?_trace=1 entries in json_api.rst and introspection.rst, and
the regenerated cli-reference.rst. changelog.rst gets a breaking-change entry
and keeps all its historical ?_trace=1 entries - two of them had to lose a
:ref: role pointing at a label this commit deletes, or Sphinx warns on every
build.
2368 passed, 39 skipped, 6 xfailed, 15 xpassed, 140 subtests, against 2375 /
141 before. Net -7 tests, fully accounted for: -6 test_tracer.py, -1
test_csv_trace, -1 test_trace_correctly_escaped, +1 test_query_page_escapes_sql.
The lost subtest is the per-setting case trace_debug generated in
test_settings_are_documented.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:14:08 -07:00
|
|
|
from datasette import hookimpl
|
2020-05-27 21:09:16 -07:00
|
|
|
from datasette.facets import Facet
|
2025-10-23 15:21:55 -07:00
|
|
|
from datasette.permissions import Action
|
2025-11-01 11:35:08 -07:00
|
|
|
from datasette.resources import DatabaseResource
|
2026-07-25 15:47:08 -07:00
|
|
|
from datasette.tokens import TokenHandler
|
2020-05-27 21:09:16 -07:00
|
|
|
from datasette.utils import path_with_added_args
|
2026-07-25 15:47:08 -07:00
|
|
|
from datasette.utils.asgi import Response, asgi_send_json
|
2020-05-27 17:57:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
|
|
|
|
def prepare_connection(conn, database, datasette):
|
|
|
|
|
def convert_units(amount, from_, to_):
|
2020-12-23 18:04:32 +01:00
|
|
|
"""select convert_units(100, 'm', 'ft');"""
|
2024-08-20 19:03:33 -07:00
|
|
|
# Convert meters to feet
|
|
|
|
|
if from_ == "m" and to_ == "ft":
|
|
|
|
|
return amount * 3.28084
|
|
|
|
|
# Convert feet to meters
|
|
|
|
|
if from_ == "ft" and to_ == "m":
|
|
|
|
|
return amount / 3.28084
|
|
|
|
|
assert False, "Unsupported conversion"
|
2020-05-27 17:57:25 -07:00
|
|
|
|
|
|
|
|
conn.create_function("convert_units", 3, convert_units)
|
|
|
|
|
|
|
|
|
|
def prepare_connection_args():
|
|
|
|
|
return 'database={}, datasette.plugin_config("name-of-plugin")={}'.format(
|
|
|
|
|
database, datasette.plugin_config("name-of-plugin")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
conn.create_function("prepare_connection_args", 0, prepare_connection_args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
2020-08-16 11:09:53 -07:00
|
|
|
def extra_css_urls(template, database, table, view_name, columns, request, datasette):
|
2020-08-16 09:50:23 -07:00
|
|
|
async def inner():
|
|
|
|
|
return [
|
2020-10-31 12:47:42 -07:00
|
|
|
"https://plugin-example.datasette.io/{}/extra-css-urls-demo.css".format(
|
2020-08-16 09:50:23 -07:00
|
|
|
base64.b64encode(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"template": template,
|
|
|
|
|
"database": database,
|
|
|
|
|
"table": table,
|
|
|
|
|
"view_name": view_name,
|
2024-01-30 19:55:26 -08:00
|
|
|
"request_path": (
|
|
|
|
|
request.path if request is not None else None
|
|
|
|
|
),
|
2020-08-16 09:50:23 -07:00
|
|
|
"added": (
|
|
|
|
|
await datasette.get_database().execute("select 3 * 5")
|
|
|
|
|
).first()[0],
|
2020-08-16 11:09:53 -07:00
|
|
|
"columns": columns,
|
2020-08-16 09:50:23 -07:00
|
|
|
}
|
|
|
|
|
).encode("utf8")
|
|
|
|
|
).decode("utf8")
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return inner
|
2020-05-27 17:57:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
|
|
|
|
def extra_js_urls():
|
|
|
|
|
return [
|
2020-09-02 15:24:55 -07:00
|
|
|
{
|
2020-10-31 12:47:42 -07:00
|
|
|
"url": "https://plugin-example.datasette.io/jquery.js",
|
2020-09-02 15:24:55 -07:00
|
|
|
"sri": "SRIHASH",
|
|
|
|
|
},
|
2020-10-31 12:47:42 -07:00
|
|
|
"https://plugin-example.datasette.io/plugin1.js",
|
2021-01-13 17:50:52 -08:00
|
|
|
{"url": "https://plugin-example.datasette.io/plugin.module.js", "module": True},
|
2020-05-27 17:57:25 -07:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
2020-08-16 11:09:53 -07:00
|
|
|
def extra_body_script(
|
|
|
|
|
template, database, table, view_name, columns, request, datasette
|
|
|
|
|
):
|
2020-08-16 09:50:23 -07:00
|
|
|
async def inner():
|
2021-01-13 18:14:33 -08:00
|
|
|
script = "var extra_body_script = {};".format(
|
2020-08-16 09:50:23 -07:00
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"template": template,
|
|
|
|
|
"database": database,
|
|
|
|
|
"table": table,
|
|
|
|
|
"config": datasette.plugin_config(
|
2020-09-02 15:24:55 -07:00
|
|
|
"name-of-plugin",
|
|
|
|
|
database=database,
|
|
|
|
|
table=table,
|
2020-08-16 09:50:23 -07:00
|
|
|
),
|
|
|
|
|
"view_name": view_name,
|
|
|
|
|
"request_path": request.path if request is not None else None,
|
|
|
|
|
"added": (
|
|
|
|
|
await datasette.get_database().execute("select 3 * 5")
|
|
|
|
|
).first()[0],
|
2020-08-16 11:09:53 -07:00
|
|
|
"columns": columns,
|
2020-08-16 09:50:23 -07:00
|
|
|
}
|
|
|
|
|
)
|
2020-05-27 17:57:25 -07:00
|
|
|
)
|
2021-01-13 18:14:33 -08:00
|
|
|
return {"script": script, "module": True}
|
2020-08-16 09:50:23 -07:00
|
|
|
|
|
|
|
|
return inner
|
2020-05-27 17:57:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
2026-02-17 18:21:25 +00:00
|
|
|
def render_cell(row, value, column, table, pks, database, datasette, request):
|
2021-08-08 16:04:42 -07:00
|
|
|
async def inner():
|
|
|
|
|
# Render some debug output in cell with value RENDER_CELL_DEMO
|
|
|
|
|
if value == "RENDER_CELL_DEMO":
|
2023-01-27 19:34:14 -08:00
|
|
|
data = {
|
|
|
|
|
"row": dict(row),
|
|
|
|
|
"column": column,
|
|
|
|
|
"table": table,
|
|
|
|
|
"database": database,
|
2026-02-17 18:21:25 +00:00
|
|
|
"pks": pks,
|
2023-01-27 19:34:14 -08:00
|
|
|
"config": datasette.plugin_config(
|
|
|
|
|
"name-of-plugin",
|
|
|
|
|
database=database,
|
|
|
|
|
table=table,
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
if request.args.get("_render_cell_extra"):
|
|
|
|
|
data["render_cell_extra"] = 1
|
|
|
|
|
return json.dumps(data)
|
2021-08-08 16:04:42 -07:00
|
|
|
elif value == "RENDER_CELL_ASYNC":
|
|
|
|
|
return (
|
|
|
|
|
await datasette.get_database(database).execute(
|
|
|
|
|
"select 'RENDER_CELL_ASYNC_RESULT'"
|
|
|
|
|
)
|
|
|
|
|
).single_value()
|
|
|
|
|
|
|
|
|
|
return inner
|
2020-05-27 17:57:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
2020-08-16 11:09:53 -07:00
|
|
|
def extra_template_vars(
|
|
|
|
|
template, database, table, view_name, columns, request, datasette
|
|
|
|
|
):
|
2020-05-27 17:57:25 -07:00
|
|
|
return {
|
|
|
|
|
"extra_template_vars": json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"template": template,
|
|
|
|
|
"scope_path": request.scope["path"] if request else None,
|
2020-08-16 11:09:53 -07:00
|
|
|
"columns": columns,
|
2020-05-27 17:57:25 -07:00
|
|
|
},
|
|
|
|
|
default=lambda b: b.decode("utf8"),
|
|
|
|
|
)
|
|
|
|
|
}
|
2020-05-27 20:13:32 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
2022-09-14 14:31:54 -07:00
|
|
|
def prepare_jinja2_environment(env, datasette):
|
2022-09-16 20:38:15 -07:00
|
|
|
async def select_times_three(s):
|
|
|
|
|
db = datasette.get_database()
|
|
|
|
|
return (await db.execute("select 3 * ?", [int(s)])).first()[0]
|
|
|
|
|
|
|
|
|
|
async def inner():
|
|
|
|
|
env.filters["select_times_three"] = select_times_three
|
|
|
|
|
|
|
|
|
|
return inner
|
2020-05-27 21:09:16 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
|
|
|
|
def register_facet_classes():
|
|
|
|
|
return [DummyFacet]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DummyFacet(Facet):
|
|
|
|
|
type = "dummy"
|
|
|
|
|
|
|
|
|
|
async def suggest(self):
|
|
|
|
|
columns = await self.get_columns(self.sql, self.params)
|
|
|
|
|
return (
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
"name": column,
|
|
|
|
|
"toggle_url": self.ds.absolute_url(
|
|
|
|
|
self.request,
|
|
|
|
|
path_with_added_args(self.request, {"_facet_dummy": column}),
|
|
|
|
|
),
|
|
|
|
|
"type": "dummy",
|
|
|
|
|
}
|
|
|
|
|
for column in columns
|
|
|
|
|
]
|
|
|
|
|
if self.request.args.get("_dummy_facet")
|
|
|
|
|
else []
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def facet_results(self):
|
|
|
|
|
facet_results = {}
|
|
|
|
|
facets_timed_out = []
|
|
|
|
|
return facet_results, facets_timed_out
|
2020-05-30 15:06:33 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
|
|
|
|
def actor_from_request(datasette, request):
|
|
|
|
|
if request.args.get("_bot"):
|
|
|
|
|
return {"id": "bot"}
|
|
|
|
|
else:
|
|
|
|
|
return None
|
2020-05-30 15:24:43 -07:00
|
|
|
|
|
|
|
|
|
2020-06-18 11:37:28 -07:00
|
|
|
@hookimpl
|
|
|
|
|
def asgi_wrapper():
|
|
|
|
|
def wrap(app):
|
2021-08-03 09:11:18 -07:00
|
|
|
async def maybe_set_actor_in_scope(scope, receive, send):
|
2020-10-31 10:25:32 -07:00
|
|
|
if b"_actor_in_scope" in scope.get("query_string", b""):
|
2020-06-18 11:37:28 -07:00
|
|
|
scope = dict(scope, actor={"id": "from-scope"})
|
|
|
|
|
print(scope)
|
2021-08-03 09:11:18 -07:00
|
|
|
await app(scope, receive, send)
|
2020-06-18 11:37:28 -07:00
|
|
|
|
|
|
|
|
return maybe_set_actor_in_scope
|
|
|
|
|
|
|
|
|
|
return wrap
|
|
|
|
|
|
|
|
|
|
|
2020-06-08 20:12:06 -07:00
|
|
|
@hookimpl
|
|
|
|
|
def register_routes():
|
|
|
|
|
async def one(datasette):
|
|
|
|
|
return Response.text(
|
|
|
|
|
(await datasette.get_database().execute("select 1 + 1")).first()[0]
|
|
|
|
|
)
|
|
|
|
|
|
2020-06-08 20:40:00 -07:00
|
|
|
async def two(request):
|
|
|
|
|
name = request.url_vars["name"]
|
2020-06-08 20:12:06 -07:00
|
|
|
greeting = request.args.get("greeting")
|
2020-11-15 15:24:22 -08:00
|
|
|
return Response.text(f"{greeting} {name}")
|
2020-06-08 20:12:06 -07:00
|
|
|
|
|
|
|
|
async def three(scope, send):
|
|
|
|
|
await asgi_send_json(
|
|
|
|
|
send, {"hello": "world"}, status=200, headers={"x-three": "1"}
|
|
|
|
|
)
|
|
|
|
|
|
2020-06-18 09:21:15 -07:00
|
|
|
async def post(request):
|
|
|
|
|
if request.method == "GET":
|
|
|
|
|
return Response.html(request.scope["csrftoken"]())
|
|
|
|
|
else:
|
|
|
|
|
return Response.json(await request.post_vars())
|
|
|
|
|
|
2020-06-23 20:23:30 -07:00
|
|
|
async def csrftoken_form(request, datasette):
|
|
|
|
|
return Response.html(
|
|
|
|
|
await datasette.render_template("csrftoken_form.html", request=request)
|
|
|
|
|
)
|
|
|
|
|
|
2020-06-27 11:30:34 -07:00
|
|
|
def not_async():
|
|
|
|
|
return Response.html("This was not async")
|
|
|
|
|
|
2020-06-28 17:25:35 -07:00
|
|
|
def add_message(datasette, request):
|
|
|
|
|
datasette.add_message(request, "Hello from messages")
|
|
|
|
|
return Response.html("Added message")
|
|
|
|
|
|
2020-06-28 17:50:47 -07:00
|
|
|
async def render_message(datasette, request):
|
|
|
|
|
return Response.html(
|
|
|
|
|
await datasette.render_template("render_message.html", request=request)
|
|
|
|
|
)
|
|
|
|
|
|
2020-10-31 10:25:32 -07:00
|
|
|
def login_as_root(datasette, request):
|
|
|
|
|
# Mainly for the latest.datasette.io demo
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
response = Response.redirect("/")
|
2025-01-15 17:37:25 -08:00
|
|
|
datasette.set_actor_cookie(response, {"id": "root"})
|
2020-10-31 10:25:32 -07:00
|
|
|
return response
|
2026-02-17 13:30:24 -08:00
|
|
|
return Response.html("""
|
2020-10-31 10:25:32 -07:00
|
|
|
<form action="{}" method="POST">
|
|
|
|
|
<p>
|
|
|
|
|
<input type="hidden" name="csrftoken" value="{}">
|
2022-12-01 13:29:31 -08:00
|
|
|
<input type="submit"
|
|
|
|
|
value="Sign in as root user"
|
|
|
|
|
style="font-size: 2em; padding: 0.1em 0.5em;">
|
|
|
|
|
</p>
|
2020-10-31 10:25:32 -07:00
|
|
|
</form>
|
2026-02-17 13:30:24 -08:00
|
|
|
""".format(request.path, request.scope["csrftoken"]()))
|
2020-10-31 10:25:32 -07:00
|
|
|
|
2020-10-31 12:29:42 -07:00
|
|
|
def asgi_scope(scope):
|
|
|
|
|
return Response.json(scope, default=repr)
|
|
|
|
|
|
2022-02-04 21:19:49 -08:00
|
|
|
async def parallel_queries(datasette):
|
|
|
|
|
db = datasette.get_database()
|
Remove the hand-rolled tracer now that OpenTelemetry covers the same ground
Datasette had two tracing systems since the OpenTelemetry spans landed. The
hand-rolled one measures the wrong thing - issue 1730, open since 2022, is
about exactly that - and it cannot be rebuilt on top of the new spans without
core owning a TracerProvider, which is the one thing the OTel design refuses
to do. Rather than carry duplicate instrumentation on the db.execute() hot
path indefinitely, the old system goes.
Deleted: datasette/tracer.py, the trace_debug setting, the AsgiTracer
response-rewriting middleware and the ?_trace=1 query-string argument.
- datasette/database.py: the four `with trace(...)` wrappers PR 1 deliberately
nested the OTel spans inside are removed and the bodies dedented. That also
retires the `# noqa: SIM117` comments those wrappers required - a leftover
unnecessary noqa trips ruff's RUF100 - and `kwargs["count"] = count` in
execute_write_many, which fed the old tracer only. `git diff -w` on this file
shows nothing but the deleted lines.
- datasette/views/base.py: stream_csv() still read ?_trace=1 to wrap CSV output
in an HTML <textarea> debug page. That whole branch, including the
EscapeHtmlWriter selection and the conditional content-type, is gone. The
EscapeHtmlWriter class itself stays in datasette.utils - it is an importable
public name and removing it would widen the API break.
- .github/workflows/deploy-latest.yml no longer passes --setting trace_debug 1.
Worth stating precisely, because the ticket claimed otherwise: this would not
have broken the deploy. Setting.convert() in cli.py only rewrites a bare name
to settings.<name> for *known* settings, so `--setting trace_debug 1` would
have been silently accepted as a meaningless top-level config key. The flag is
removed because it is dead, not because it errors.
Tests. tests/test_tracer.py is deleted outright (6 items). Four other tests used
?_trace=1 as an assertion instrument rather than testing tracing:
- test_csv_trace tested the trace mechanism itself - deleted.
- test_table_csv_stream_does_not_calculate_facets,
test_table_csv_stream_does_not_calculate_counts and
test_nocount_nofacet_if_shape_is_object test real behaviour, and are rebuilt
against captured spans. All three had silently stopped being able to fail: the
facets test looked for "select content, count(*) as n", which facet suggestion
has not emitted since it moved to a `with limited as (...)` CTE, and none of
the three requested the count or facet work whose suppression they claim to
check. The rebuilt versions ask for it explicitly, match strings the current
SQL contains, and carry a guard assertion so an empty span list cannot
masquerade as a pass. Each was confirmed to fail with the covered code broken.
- test_trace_correctly_escaped is kept, renamed test_query_page_escapes_sql,
with ?_trace=1 dropped. It ran against ds_client, which has no trace_debug, so
it never exercised the tracer - what it actually covered is the query page
echoing user SQL into HTML, the surface of the two reflected-XSS advisories in
issue 1360, and nothing else in the suite covers it. Deleting it would have
quietly dropped that.
tests/test_utils.py's pairs_to_nested_config case used settings.trace_debug to
check that a later key overrides an earlier one; it now uses template_debug
rather than losing the case.
Docs: the datasette.tracer section of internals.rst, the trace_debug section of
settings.rst, the ?_trace=1 entries in json_api.rst and introspection.rst, and
the regenerated cli-reference.rst. changelog.rst gets a breaking-change entry
and keeps all its historical ?_trace=1 entries - two of them had to lose a
:ref: role pointing at a label this commit deletes, or Sphinx warns on every
build.
2368 passed, 39 skipped, 6 xfailed, 15 xpassed, 140 subtests, against 2375 /
141 before. Net -7 tests, fully accounted for: -6 test_tracer.py, -1
test_csv_trace, -1 test_trace_correctly_escaped, +1 test_query_page_escapes_sql.
The lost subtest is the per-setting case trace_debug generated in
test_settings_are_documented.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:14:08 -07:00
|
|
|
one, two = await asyncio.gather(
|
|
|
|
|
db.execute("select coalesce(sleep(0.1), 1)"),
|
|
|
|
|
db.execute("select coalesce(sleep(0.1), 2)"),
|
|
|
|
|
)
|
2022-02-04 21:19:49 -08:00
|
|
|
return Response.json({"one": one.single_value(), "two": two.single_value()})
|
|
|
|
|
|
2020-06-08 20:12:06 -07:00
|
|
|
return [
|
|
|
|
|
(r"/one/$", one),
|
|
|
|
|
(r"/two/(?P<name>.*)$", two),
|
|
|
|
|
(r"/three/$", three),
|
2020-06-18 09:21:15 -07:00
|
|
|
(r"/post/$", post),
|
2020-06-23 20:23:30 -07:00
|
|
|
(r"/csrftoken-form/$", csrftoken_form),
|
2020-10-31 10:25:32 -07:00
|
|
|
(r"/login-as-root$", login_as_root),
|
2020-06-27 11:30:34 -07:00
|
|
|
(r"/not-async/$", not_async),
|
2020-06-28 17:25:35 -07:00
|
|
|
(r"/add-message/$", add_message),
|
2020-06-28 17:50:47 -07:00
|
|
|
(r"/render-message/$", render_message),
|
2020-10-31 12:29:42 -07:00
|
|
|
(r"/asgi-scope$", asgi_scope),
|
2022-02-04 21:19:49 -08:00
|
|
|
(r"/parallel-queries$", parallel_queries),
|
2020-06-08 20:12:06 -07:00
|
|
|
]
|
2020-06-13 10:55:41 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
|
|
|
|
def startup(datasette):
|
|
|
|
|
datasette._startup_hook_fired = True
|
2020-06-18 16:22:33 -07:00
|
|
|
|
2022-02-05 22:34:33 -08:00
|
|
|
# And test some import shortcuts too
|
2026-07-25 15:47:08 -07:00
|
|
|
from datasette import Forbidden, NotFound, Response, actor_matches_allow, hookimpl
|
2022-02-05 22:34:33 -08:00
|
|
|
|
|
|
|
|
_ = (Response, Forbidden, NotFound, hookimpl, actor_matches_allow)
|
|
|
|
|
|
2020-06-18 16:22:33 -07:00
|
|
|
|
2020-06-27 19:58:16 -07:00
|
|
|
@hookimpl
|
|
|
|
|
def register_magic_parameters():
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
def uuid(key, request):
|
|
|
|
|
if key == "new":
|
|
|
|
|
return str(uuid4())
|
|
|
|
|
else:
|
|
|
|
|
raise KeyError
|
|
|
|
|
|
|
|
|
|
def request(key, request):
|
|
|
|
|
if key == "http_version":
|
|
|
|
|
return request.scope["http_version"]
|
|
|
|
|
else:
|
|
|
|
|
raise KeyError
|
|
|
|
|
|
2024-11-15 13:17:45 -08:00
|
|
|
async def asyncrequest(key, request):
|
|
|
|
|
return key
|
|
|
|
|
|
2020-06-27 19:58:16 -07:00
|
|
|
return [
|
|
|
|
|
("request", request),
|
|
|
|
|
("uuid", uuid),
|
2024-11-15 13:17:45 -08:00
|
|
|
("asyncrequest", asyncrequest),
|
2020-06-27 19:58:16 -07:00
|
|
|
]
|
2020-06-30 21:17:38 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
|
|
|
|
def forbidden(datasette, request, message):
|
|
|
|
|
datasette._last_forbidden_message = message
|
|
|
|
|
if request.path == "/data2":
|
|
|
|
|
return Response.redirect("/login?message=" + message)
|
2020-10-29 20:45:15 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
2021-06-09 21:45:24 -07:00
|
|
|
def menu_links(datasette, actor, request):
|
2020-10-29 20:45:15 -07:00
|
|
|
if actor:
|
2021-06-09 21:45:24 -07:00
|
|
|
label = "Hello"
|
|
|
|
|
if request.args.get("_hello"):
|
|
|
|
|
label += ", " + request.args["_hello"]
|
|
|
|
|
return [{"href": datasette.urls.instance(), "label": label}]
|
2020-10-29 22:16:41 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
2026-06-14 15:58:37 -07:00
|
|
|
def table_actions(datasette, database, table, actor, request):
|
2020-10-29 22:16:41 -07:00
|
|
|
if actor:
|
2026-06-14 15:58:37 -07:00
|
|
|
actions = [
|
2020-10-29 22:16:41 -07:00
|
|
|
{
|
|
|
|
|
"href": datasette.urls.instance(),
|
2020-11-15 15:24:22 -08:00
|
|
|
"label": f"Database: {database}",
|
2020-10-29 22:16:41 -07:00
|
|
|
},
|
2020-11-15 15:24:22 -08:00
|
|
|
{"href": datasette.urls.instance(), "label": f"Table: {table}"},
|
2020-10-29 22:16:41 -07:00
|
|
|
]
|
2026-06-14 15:58:37 -07:00
|
|
|
if request.args.get("_button"):
|
|
|
|
|
actions.append(
|
|
|
|
|
{
|
|
|
|
|
"type": "button",
|
|
|
|
|
"label": "Plugin button",
|
|
|
|
|
"description": "Runs JavaScript from a plugin",
|
|
|
|
|
"attrs": {
|
2026-07-25 15:47:08 -07:00
|
|
|
"aria-label": f"Plugin button for {table}",
|
2026-06-14 15:58:37 -07:00
|
|
|
"data-plugin-action": "plugin-button",
|
|
|
|
|
"data-database": database,
|
|
|
|
|
"data-table": table,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return actions
|
2020-11-02 10:27:25 -08:00
|
|
|
|
|
|
|
|
|
2024-03-12 14:25:07 -07:00
|
|
|
@hookimpl
|
|
|
|
|
def view_actions(datasette, database, view, actor):
|
|
|
|
|
if actor:
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"href": datasette.urls.instance(),
|
|
|
|
|
"label": f"Database: {database}",
|
|
|
|
|
},
|
|
|
|
|
{"href": datasette.urls.instance(), "label": f"View: {view}"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2024-02-27 21:55:16 -08:00
|
|
|
@hookimpl
|
|
|
|
|
def query_actions(datasette, database, query_name, sql):
|
2026-05-25 12:33:57 -07:00
|
|
|
# Don't explain an explain (or a missing query)
|
|
|
|
|
if not sql or sql.lower().startswith("explain"):
|
2024-03-05 18:14:55 -08:00
|
|
|
return
|
2024-02-27 21:55:16 -08:00
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"href": datasette.urls.database(database)
|
2024-07-15 10:33:51 -07:00
|
|
|
+ "/-/query"
|
2024-03-05 18:06:38 -08:00
|
|
|
+ "?"
|
|
|
|
|
+ urllib.parse.urlencode(
|
|
|
|
|
{
|
|
|
|
|
"sql": "explain " + sql,
|
|
|
|
|
}
|
|
|
|
|
),
|
2024-02-27 21:55:16 -08:00
|
|
|
"label": "Explain this query",
|
2024-03-06 22:54:06 -05:00
|
|
|
"description": "Runs a SQLite explain",
|
2024-02-27 21:55:16 -08:00
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2024-03-12 16:13:31 -07:00
|
|
|
@hookimpl
|
|
|
|
|
def row_actions(datasette, database, table, actor, row):
|
|
|
|
|
if actor:
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"href": datasette.urls.instance(),
|
|
|
|
|
"label": f"Row details for {actor['id']}",
|
|
|
|
|
"description": json.dumps(dict(row), default=repr),
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2020-11-02 10:27:25 -08:00
|
|
|
@hookimpl
|
2021-06-09 21:45:24 -07:00
|
|
|
def database_actions(datasette, database, actor, request):
|
2020-11-02 10:27:25 -08:00
|
|
|
if actor:
|
2021-06-09 21:45:24 -07:00
|
|
|
label = f"Database: {database}"
|
|
|
|
|
if request.args.get("_hello"):
|
|
|
|
|
label += " - " + request.args["_hello"]
|
2020-11-02 10:27:25 -08:00
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"href": datasette.urls.instance(),
|
2021-06-09 21:45:24 -07:00
|
|
|
"label": label,
|
2020-11-02 10:27:25 -08:00
|
|
|
}
|
|
|
|
|
]
|
2021-06-23 15:39:52 -07:00
|
|
|
|
|
|
|
|
|
2024-03-12 13:44:07 -07:00
|
|
|
@hookimpl
|
|
|
|
|
def homepage_actions(datasette, actor, request):
|
|
|
|
|
if actor:
|
|
|
|
|
label = f"Custom homepage for: {actor['id']}"
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"href": datasette.urls.path("/-/custom-homepage"),
|
|
|
|
|
"label": label,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2025-10-23 15:21:55 -07:00
|
|
|
@hookimpl
|
|
|
|
|
def register_actions(datasette):
|
2025-10-24 14:31:33 -07:00
|
|
|
extras_old = datasette.plugin_config("datasette-register-permissions") or {}
|
|
|
|
|
extras_new = datasette.plugin_config("datasette-register-actions") or {}
|
|
|
|
|
|
|
|
|
|
actions = [
|
|
|
|
|
Action(
|
|
|
|
|
name="action-from-plugin",
|
|
|
|
|
abbr="ap",
|
|
|
|
|
description="New action added by a plugin",
|
|
|
|
|
resource_class=DatabaseResource,
|
|
|
|
|
),
|
2025-10-23 15:21:55 -07:00
|
|
|
Action(
|
|
|
|
|
name="view-collection",
|
|
|
|
|
abbr="vc",
|
|
|
|
|
description="View a collection",
|
|
|
|
|
resource_class=DatabaseResource,
|
2025-10-25 08:45:10 -07:00
|
|
|
),
|
2025-11-07 16:50:00 -08:00
|
|
|
# Test actions for test_hook_custom_allowed (global actions - no resource_class)
|
2025-10-30 15:48:46 -07:00
|
|
|
Action(
|
|
|
|
|
name="this_is_allowed",
|
|
|
|
|
abbr=None,
|
|
|
|
|
description=None,
|
|
|
|
|
),
|
|
|
|
|
Action(
|
|
|
|
|
name="this_is_denied",
|
|
|
|
|
abbr=None,
|
|
|
|
|
description=None,
|
|
|
|
|
),
|
|
|
|
|
Action(
|
|
|
|
|
name="this_is_allowed_async",
|
|
|
|
|
abbr=None,
|
|
|
|
|
description=None,
|
|
|
|
|
),
|
|
|
|
|
Action(
|
|
|
|
|
name="this_is_denied_async",
|
|
|
|
|
abbr=None,
|
|
|
|
|
description=None,
|
|
|
|
|
),
|
2025-10-23 15:21:55 -07:00
|
|
|
]
|
2025-10-24 14:31:33 -07:00
|
|
|
|
|
|
|
|
# Support old-style config for backwards compatibility
|
|
|
|
|
if extras_old:
|
|
|
|
|
for p in extras_old["permissions"]:
|
2025-11-01 11:35:08 -07:00
|
|
|
# Map old takes_database/takes_resource to new global/resource_class
|
|
|
|
|
if p.get("takes_database"):
|
|
|
|
|
# Has database -> DatabaseResource
|
|
|
|
|
actions.append(
|
|
|
|
|
Action(
|
|
|
|
|
name=p["name"],
|
|
|
|
|
abbr=p["abbr"],
|
|
|
|
|
description=p["description"],
|
|
|
|
|
resource_class=DatabaseResource,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# No database -> global action (no resource_class)
|
|
|
|
|
actions.append(
|
|
|
|
|
Action(
|
|
|
|
|
name=p["name"],
|
|
|
|
|
abbr=p["abbr"],
|
|
|
|
|
description=p["description"],
|
|
|
|
|
)
|
2025-10-24 14:31:33 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Support new-style config
|
|
|
|
|
if extras_new:
|
|
|
|
|
for a in extras_new["actions"]:
|
2025-11-01 11:35:08 -07:00
|
|
|
# Check if this is a global action (no resource_class specified)
|
|
|
|
|
if not a.get("resource_class"):
|
|
|
|
|
actions.append(
|
|
|
|
|
Action(
|
|
|
|
|
name=a["name"],
|
|
|
|
|
abbr=a["abbr"],
|
|
|
|
|
description=a["description"],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# Map string resource_class to actual class
|
|
|
|
|
resource_class_map = {
|
|
|
|
|
"DatabaseResource": DatabaseResource,
|
|
|
|
|
}
|
|
|
|
|
resource_class = resource_class_map.get(
|
|
|
|
|
a.get("resource_class", "DatabaseResource"), DatabaseResource
|
|
|
|
|
)
|
2025-10-24 14:31:33 -07:00
|
|
|
|
2025-11-01 11:35:08 -07:00
|
|
|
actions.append(
|
|
|
|
|
Action(
|
|
|
|
|
name=a["name"],
|
|
|
|
|
abbr=a["abbr"],
|
|
|
|
|
description=a["description"],
|
|
|
|
|
resource_class=resource_class,
|
|
|
|
|
)
|
2025-10-24 14:31:33 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return actions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
|
|
|
|
def permission_resources_sql(datasette, actor, action):
|
|
|
|
|
from datasette.permissions import PermissionSQL
|
|
|
|
|
|
2025-11-07 16:50:00 -08:00
|
|
|
# Handle test actions used in test_hook_custom_allowed
|
2025-10-24 14:31:33 -07:00
|
|
|
if action == "this_is_allowed":
|
2025-10-30 15:48:46 -07:00
|
|
|
return PermissionSQL.allow(reason="test plugin allows this_is_allowed")
|
2025-10-24 14:31:33 -07:00
|
|
|
elif action == "this_is_denied":
|
2025-10-30 15:48:46 -07:00
|
|
|
return PermissionSQL.deny(reason="test plugin denies this_is_denied")
|
2025-10-24 14:31:33 -07:00
|
|
|
elif action == "this_is_allowed_async":
|
2025-10-30 15:48:46 -07:00
|
|
|
return PermissionSQL.allow(reason="test plugin allows this_is_allowed_async")
|
2025-10-24 14:31:33 -07:00
|
|
|
elif action == "this_is_denied_async":
|
2025-10-30 15:48:46 -07:00
|
|
|
return PermissionSQL.deny(reason="test plugin denies this_is_denied_async")
|
2025-10-24 14:31:33 -07:00
|
|
|
elif action == "view-database-download":
|
|
|
|
|
# Return rule based on actor's can_download permission
|
|
|
|
|
if actor and actor.get("can_download"):
|
2025-10-30 15:48:46 -07:00
|
|
|
return PermissionSQL.allow(reason="actor has can_download")
|
2025-10-24 14:31:33 -07:00
|
|
|
else:
|
|
|
|
|
return None # No opinion
|
2025-10-25 08:52:48 -07:00
|
|
|
elif action == "view-database":
|
|
|
|
|
# Also grant view-database if actor has can_download (needed for download to work)
|
|
|
|
|
if actor and actor.get("can_download"):
|
2025-10-30 15:48:46 -07:00
|
|
|
return PermissionSQL.allow(
|
|
|
|
|
reason="actor has can_download, grants view-database"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
return None
|
2025-10-25 08:45:10 -07:00
|
|
|
elif action in (
|
|
|
|
|
"insert-row",
|
|
|
|
|
"create-table",
|
|
|
|
|
"drop-table",
|
|
|
|
|
"delete-row",
|
|
|
|
|
"update-row",
|
|
|
|
|
):
|
2025-10-24 14:31:33 -07:00
|
|
|
# Special permissions for latest.datasette.io demos
|
|
|
|
|
actor_id = actor.get("id") if actor else None
|
|
|
|
|
if actor_id == "todomvc":
|
2025-10-30 15:48:46 -07:00
|
|
|
return PermissionSQL.allow(reason=f"todomvc actor allowed for {action}")
|
2025-10-24 14:31:33 -07:00
|
|
|
|
|
|
|
|
return None
|
register_token_handler() plugin hook for custom API token backends (#2650)
Closes #2649
* Add register_token_handler plugin hook for pluggable token backends
Adds a new register_token_handler hook that allows plugins to provide
custom token creation and verification backends. This enables plugins
like datasette-oauth to issue tokens without depending on specific
backend plugins like datasette-auth-tokens.
Key changes:
- New datasette/tokens.py with TokenHandler base class and SignedTokenHandler
(the default signed-token implementation moved here)
- New register_token_handler hookspec in hookspecs.py
- Datasette.create_token() is now async and delegates to token handlers
- New Datasette.verify_token() method tries all handlers in sequence
- handler= parameter on create_token() to select a specific backend
- TokenHandler exported from datasette package for plugin use
- Fixed actor_from_request loop to await all coroutines (avoids warnings)
* Add documentation and hook test for register_token_handler
Fixes CI failures: the new hook needs a section in docs/plugin_hooks.rst
(checked by test_plugin_hooks_are_documented) and a test_hook_* function
in test_plugins.py (checked by test_plugin_hooks_have_tests).
* Register tokens module as separate default plugin
Instead of re-exporting hookimpls from default_permissions/__init__.py,
register datasette.default_permissions.tokens as its own DEFAULT_PLUGINS
entry. Cleaner and avoids confusing import-for-side-effect patterns.
* Replace restrict_x params with TokenRestrictions dataclass
Consolidates the three separate restrict_all, restrict_database, and
restrict_resource parameters into a single TokenRestrictions dataclass.
Cleaner API surface for both Datasette.create_token() and
TokenHandler.create_token().
Also clarifies docs re: default handler selection via pluggy ordering.
* Add builder methods to TokenRestrictions
Adds allow_all(), allow_database(), and allow_resource() methods that
return self for chaining. Callers no longer need to manipulate nested
dicts directly:
restrictions = (TokenRestrictions()
.allow_all("view-instance")
.allow_database("mydb", "create-table")
.allow_resource("mydb", "mytable", "insert-row"))
* docs: add 1.0a25 upgrade guide section for create_token() signature change
Ref: https://github.com/simonw/datasette/issues/2649#issuecomment-3962639393
* docs: note that create_token() is now async in upgrade guide
* docs: update internals, plugin_hooks, authentication for new token API
- internals.rst: new async create_token() signature with restrictions
and handler params, add TokenRestrictions reference docs
- plugin_hooks.rst: show full create_token signature in TokenHandler
example, note list returns and error cases
- authentication.rst: cross-reference TokenRestrictions from the
restrictions section
* style: apply black formatting to token handler files
* docs: fix RST heading underline length in internals.rst
* tests: add restrictions round-trip and expiration tests for token handler
Covers allow_database/allow_resource builders, _r payload encoding,
and token_expires in verified actors. Coverage 76% -> 90%.
* tests: add test for signed tokens disabled
* fix: add TokenRestrictions TYPE_CHECKING import to fix ruff F821
* docs: regenerate plugins.rst with cog
* docs: reformat code blocks in plugin_hooks.rst with blacken-docs
* docs: add await .verify_token() to internals.rst
* tests: rewrite register_token_handler test to use real plugin handler
Adds a HardcodedTokenHandler to the test plugins dir that creates
tokens like dstok_hardcoded_token_1. The test now exercises creating
tokens via the default handler (which is the plugin's hardcoded one),
by explicitly naming the hardcoded handler, and by explicitly naming
the signed handler -- then verifies each token round-trips correctly.
* tests: clarify test_token_handler_via_http tests the default signed handler
* fix: use handler="signed" explicitly where signed tokens are expected
The HardcodedTokenHandler in my_plugin.py gets globally registered,
so create_token() without a handler name picks it up as the default.
Fix the create-token view, CLI, and tests to explicitly request the
signed handler where they depend on signed token behavior.
* fix: use handler="signed" in test_create_table_permissions
https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
2026-02-25 16:32:45 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class HardcodedTokenHandler(TokenHandler):
|
|
|
|
|
name = "hardcoded"
|
|
|
|
|
_counter = 0
|
|
|
|
|
|
|
|
|
|
async def create_token(
|
|
|
|
|
self,
|
|
|
|
|
datasette,
|
|
|
|
|
actor_id,
|
|
|
|
|
*,
|
|
|
|
|
expires_after=None,
|
|
|
|
|
restrictions=None,
|
|
|
|
|
):
|
|
|
|
|
HardcodedTokenHandler._counter += 1
|
|
|
|
|
return f"dstok_hardcoded_token_{HardcodedTokenHandler._counter}"
|
|
|
|
|
|
|
|
|
|
async def verify_token(self, datasette, token):
|
|
|
|
|
if token.startswith("dstok_hardcoded_token_"):
|
|
|
|
|
return {"id": "hardcoded-actor", "token": "hardcoded"}
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
|
|
|
|
def register_token_handler(datasette):
|
|
|
|
|
return HardcodedTokenHandler()
|