Histograms recorded inside a sampled span carry trace IDs automatically, so a
latency spike links to a trace that caused it. Nothing said so.
Two things are documented because they were measured rather than assumed: an
exemplar is kept per histogram bucket, so the bucket boundaries fixed earlier
in this stack took the same workload from one reachable trace to four; and the
pinned opentelemetry-exporter-prometheus drops exemplars entirely, so the path
that works is an OTLP collector rather than Datasette's Prometheus exporter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from 9d066255; section numbering and cross-references
adjusted to this branch's demo README, and the exemplar reference placed
as a subsection of the new Metric reference in internals.rst.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2h9ANGZ7paWSpqs5DUAcG
Both histograms declared unit="s" but inherited OpenTelemetry's default
boundaries, which are tuned for milliseconds - so every SQLite query
landed in the single (0, 5] second bucket and every quantile query
returned noise.
The boundaries are the semantic conventions' recommended set for
db.client.operation.duration plus 0.0001 and 0.0005 at the bottom, since
SQLite is in-process and many real queries take tens of microseconds.
(Adapted from 024f2029: that commit assumed the metrics were already in
telemetry_registry.py, which on this lineage held spans only - so this
commit also brings the MetricName registry machinery, the registry
entries for all eight phase-3 metrics, the cog-generated Metric
reference in internals.rst, and the datasette.operation attribute. The
template and facet histograms it also touched belong to phase 5 and are
not included.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2h9ANGZ7paWSpqs5DUAcG
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>
The request span was created at the ASGI edge, before anything knew which
route would match, so it carried nothing but the method: every request in a
trace UI showed up as "GET", and the only URL on it was url.path, which is
unbounded on a public instance and useless as a grouping key. Routing
resolves in DatasetteRouter, so that is where the span gets http.route and
its semconv `{method} {route}` name.
http.route is the compiled route pattern, not a prettified
/{database}/{table} template. Datasette routes with compiled regexes and the
route table is fixed when the app is built, so the pattern is exact, bounded
and needs no parsing; the transform into something prettier accretes edge
cases, and Django's instrumentation ships regex-flavoured routes for the same
reason. A request that matches no route gets no http.route and keeps its bare
method name, which is what semantic conventions ask for.
Two things the obvious implementation gets wrong, both found by testing it:
- The router must not read `get_current_span()`. A plugin asgi_wrapper()
runs *inside* the request middleware, so an instrumented plugin makes its
own span current for the whole request - and the route then lands on that
plugin's INTERNAL span, renaming it, while the actual request span never
gets the one attribute a trace UI groups by. It reproduces with a five-line
plugin. The span is passed through the ASGI scope instead, falling back to
the current span so an externally-created SERVER span is still enriched.
- The method has to be clamped again here. The middleware clamps it for the
attribute, but the name is rebuilt from request.method, which is the raw
client string - so an unclamped rename put `FROB /(?P<database>...` back
into the span name that the middleware had just kept it out of.
Both guards are `is_recording()`, not `get_span_context().is_valid`: with no
provider but an inbound traceparent the API returns a NonRecordingSpan
carrying the remote context, which is valid and records nothing, so an
is_valid guard would do the work on every request from a traced caller.
Tests cover the route and name, the unrouted 404 fallback, the full attribute
set, db.query spans reaching the request span by parent walk, a 500, an
inbound traceparent becoming a remote parent, ?sql= never reaching a span
attribute, and - in a subprocess, because the suite's provider fixture is
session-scoped and unavoidable - the no-provider fast path handing the app
the original `send`. The streaming test uses a table larger than one page so
the export genuinely issues queries during the body send; without that it
passes however early the span ends.
Measured on this branch against fixtures.db: a faceted table page went from
112 spans in 56 traces to 113 spans in 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing in Datasette created a span for the HTTP request itself, so every
span the database layer emits was a root span. Measured on this branch: one
faceted table page produces 70 spans in 36 separate traces, none of which
carries a URL. A trace UI shows that as dozens of unrelated single-span
traces per page, interleaved across concurrent requests - worse than
?_trace=1 at the exact job people reach for tracing to do. With the request
span it is 71 spans in 1 trace.
`opentelemetry-instrument` does not fix this on its own: auto-instrumentation
only picks up frameworks that ship an instrumentor entry point, and
Datasette's raw ASGI app is not one.
TelemetryMiddleware is mounted outermost in Datasette.app(), after the
asgi_wrapper() plugin loop, so plugin middleware and the CSRF layer run
*inside* the span. Putting it in DatasetteRouter instead would leave a span
created by an instrumented plugin as an orphan root - reintroducing the
problem for exactly the code most likely to be instrumented.
It stays at ~90 lines, against roughly 700 for
opentelemetry-instrumentation-asgi, because Datasette's app does not return
before its body is sent: route_path awaits response.asgi_send(send), and a
streaming CSV export runs its generator inline inside AsgiStream.asgi_send.
So a plain `finally` covers the response body and no deferred-end machinery
is needed.
Two decisions worth flagging for review:
- Inbound W3C traceparent and baggage are extracted, using the *global*
propagator. That is the ecosystem norm (Flask, Django, FastAPI, the ASGI
instrumentation), and going through the global propagator leaves the
operator in control with no Datasette setting to invent:
OTEL_PROPAGATORS=none disables it entirely. A public instance that does
not want client-influenced traces should strip those headers at the proxy.
- url.query is not recorded, anywhere. Datasette query strings carry
user-supplied SQL in ?sql= and canned query parameters. client.address is
not recorded either.
The status code is sniffed from the ASGI http.response.start message rather
than read off a Response, because asgi_static, the favicon route, AsgiStream
and AsgiFileDownload all send that message themselves and never build one.
Only a >= 500 sets an error status - per semantic conventions a 4xx is the
client's mistake, and Datasette 404s are routine enough that treating them
as errors would bury a real 500.
The registry gains a `dynamic` flag, because this span's name is composed at
runtime and so can never equal a fixed registry string. Dynamic entries
resolve by span kind instead, and only after exact and prefix matching has
failed, so they cannot shadow a span that does have a registered name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The span reference itself is generated from the registry, so this adds the
prose the generated list cannot supply: how to actually see a span, what is
deliberately never recorded, and where the instrumentation stops short.
The "how to turn it on" part is the part people get wrong. Core installs no
provider, so OTEL_TRACES_EXPORTER=console against a plain `datasette` process
emits nothing at all - that variable is read by the SDK auto-configuration
which only runs under `opentelemetry-instrument`. Documented as a warning
because it reads like a bug when you hit it. Two more measured facts get the
same treatment: the SDK's BatchSpanProcessor default schedule delay is 5000ms
(checked, not assumed - `BatchSpanProcessor._default_schedule_delay_millis()`
on opentelemetry-sdk 1.44), so nothing appears for five seconds; and without
OTEL_SERVICE_NAME the default resource reports service.name=unknown_service.
Privacy properties are stated positively rather than left implicit: SQL
truncated at 2048 characters, parameter values never recorded, no actor
identifiers, table names only from an explicit `table=` argument. The last of
those is now documented on db.execute() itself, since it is public API.
The limitations section claims only what was measured. An earlier draft said
two traces per process are orphaned by the register_output_renderer and
asgi_wrapper hooks; measuring it showed a default install emits zero spans
from either, because Datasette queries no database there - it is a plugin
that would produce the orphan. Corrected to say that.
It also deliberately does NOT say an embedder must install its provider
before Datasette's first span or get nothing. That claim is false:
ProxyTracer._tracer returns the no-op tracer without caching it when no
provider is set, so early spans are dropped and nothing is poisoned.
The telemetry.py docstring said no-op spans "cost approximately nothing".
The benchmark for this diff does not support a claim that strong - a table
page emits ~58 spans - so it now states the measurement instead: median
9.80ms to 9.98ms across 15 runs, inside a 1.4ms run-to-run spread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Datasette has a family of callers that run a query under a tiny time limit
and treat "did not finish" as a usable answer. table_counts() is the loudest:
the homepage counts every table with a 10ms budget and stores None for the
ones that blow it. The QueryInterrupted handler on the db.query span was
unconditional, so on a two-table database that produced four ERROR spans -
two db.query and two db.query.execute - on every homepage hit. Measured on a
30MB two-table database: 4 red spans before, 0 after.
Honouring log_sql_errors here would have silenced none of it. Only the three
ArrayFacet json_type() probes pass log_sql_errors=False, and they are not the
queries that time out; table_counts() and ColumnFacet.suggest both leave it at
its True default. The signal that does separate the two cases is the budget
itself: a caller asking for less time than sql_time_limit_ms is saying the
query may not finish. Keying off that needs no new API and no changes outside
database.py. A query that runs out the instance-wide limit is still an error.
datasette.interrupted is still set in every case - it is the signal worth
having, and only the ERROR status becomes conditional. Its registry
description said the status is "also set to ERROR" full stop, which is now
wrong, and that string is published in docs/internals.rst.
The inner db.query.execute span carried the same bug through
set_status_on_exception=log_sql_errors, so its exception handling is now
explicit, matching the db.query span above it. The context manager's flags
apply to every exception type alike and this span has to tell two apart.
test_query_interrupted_sets_error_status forced its timeout with
?_timelimit=5, which is exactly the signal now reclassified as expected. It
now forces one via sql_time_limit_ms so it still tests what it was written to
test.
Also documents, at the copy_context() sites, that context propagation carries
Datasette's non-OTel ContextVars into worker threads too. Verified harmless:
nothing reads _skip_permission_checks, _permission_check_cache or
_in_datasette_client off the event loop, and Context.run() restores the
thread's previous context on return, so no value can reach the next task on
the shared pool.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The span and attribute names were string literals spread across four call
sites in database.py and one in app.py, with a hand-written reference page
that would have been true only on the day it was written. That drift is not
hypothetical: an earlier iteration of this work carried a README asserting
parameter values were never recorded for two branches after that had stopped
being true.
datasette/telemetry_registry.py now holds each name once, with its
documentation. Attribute and SpanName subclass str, so a registry entry *is*
the string OpenTelemetry wants - no wrapper API over the OTel calls, no
parallel structure to keep in step, and a typo becomes an ImportError rather
than a silently misnamed attribute. docs/internals.rst renders the span
reference from it via cog, and `cog --check docs/*.rst` already runs in CI,
so the reference cannot drift from the definitions.
Nothing changes on the wire: the emitted span names and attribute keys are
byte-identical before and after, verified by diffing a dump of both.
tests/test_telemetry_registry.py exercises a real workload and compares it
against the registry in both directions - emitted-but-unregistered catches
instrumentation added without documentation, registered-but-never-emitted
catches documentation that has outlived its code. Because the call sites now
take their names from the registry, neither direction can catch a rename:
move DB_NAMESPACE to "db.namespace2" and code and registry still agree while
every dashboard breaks. So the literal names are also written out in the test
and asserted against the registry and against the wire separately. That pair
is the only comparison in the file not derived from the registry itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Response.error(messages, status=400) builds a JSON error response in
Datasette's standard error format, alongside Response.json/html/text.
messages can be a single string or a list. All internal error response
construction now uses it - the private views.base._error() helper is
gone and the verbose Response.json(error_body(...), status=...) sites
are converted. error_body() remains for the cases that merge the error
keys into a larger payload (the JSON renderer, handle_exception and the
permission debug payload builders).
Since Response is public plugin API, plugins that build JSON endpoints
now have an obvious way to return errors in the canonical shape.
Documented in the internals documentation, including the guidance to
raise Forbidden/NotFound/BadRequest/DatasetteError instead when the
error should content-negotiate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
Adds a per-request cache for permission check results, plus wiring that
resolves action permissions in bulk before plugin hooks need them:
- New _permission_check_cache contextvar, set to a fresh dict for each
request by DatasetteRouter and reset when the request ends. Keys
include the full serialized actor, so actors differing in any field
(e.g. token restrictions) never share entries. SkipPermissions mode
bypasses the cache entirely.
- datasette.allowed_many() now consults the cache and stores its
results there, so repeated datasette.allowed() checks within one
request resolve without further SQL.
- Table pages resolve all registered table-level actions against the
current table and all database-level actions against its database
(database pages likewise) in batched queries before invoking the
table_actions/database_actions plugin hooks - allowed() calls made
inside those hooks are then served from the cache with no plugin
changes required. Actions with no permission rules from any plugin
are resolved to False without touching the database.
Benchmarks (benchmarks/) with a simulated 12-plugin ecosystem making
18 checks per table page show 34 -> 13 internal-DB queries per page;
with 2ms-per-query internal DB latency (modelling Datasette Cloud)
table page time drops from 77.9ms to 27.6ms - the caching layer
accounts for ~91% of that improvement over allowed_many() alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix for execute write returning, closes#2762
* Fix stored write returning rowcount message
* Add configurable execute_write returning limit
* Return rows/truncated from execute query if it used RETURNING
* INSERT ... RETURNING shows rows in /-/execute-write
* Skip RETURNING tests if SQLite version does not support it
Screenshot: https://github.com/simonw/datasette/issues/2762#issuecomment-4588111545
Datasette.close() iterates over every attached Database (including the
internal database), calls Database.close() on each, then shuts down the
ThreadPoolExecutor. Exceptions raised by one Database don't prevent the
others from being closed; the first exception is re-raised afterwards.
Idempotent.
Refs #2692
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After this commit, Database.close() sends a sentinel to the write queue so
the background write thread exits cleanly, closes cached read/write
connections, and marks the instance closed. Subsequent calls to execute*()
raise DatasetteClosedError. close() remains idempotent and one-way.
Refs #2692
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`datasette.client.get(path, actor={"id": "root"}` now makes the internal request with that actor as `request.actor` - same for the other HTTP verb methods on `datasette.client`.
Upgraded relevant tests to use the new `actor=` mechanism.
- New CSRF protection middleware inspired by Go 1.25 and research by Filippo Valsorda - https://words.filippo.io/csrf/ - this replaces the old CSRF token based protection.
- Removes all instances of `<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">` in the templates - they are no longer needed.
- Removes the `def skip_csrf(datasette, scope):` plugin hook defined in `datasette/hookspecs.py` and its documentation and tests.
- Updated CSRF protection documentation to describe the new approach.
- Upgrade guide now describes the CSRF change.
Closes#2683
* Add is_temp_disk option to Database for temp file-backed databases
Replace the default in-memory internal database with a temporary
file-backed database using WAL mode. This fixes concurrent read/write
locking errors that occur with named in-memory SQLite databases.
The new is_temp_disk parameter on Database creates a temp file via
tempfile.mkstemp, connects to it as a regular file-based database
with WAL mode enabled, and cleans it up on close() and via atexit.
https://claude.ai/code/session_01TteLrUjpDcARjnP1GMRqz2
I noticed that VACUUM can update the rootpage for tables in a way that
could confuse our rename table detection logic - but using the
execute_isolated_fn() method to run VACUUM avoids this problem.
Refs #2681
* Add track_event callback to execute_write_fn and write_wrapper
Allows write functions and write_wrapper generators to queue events
during a write operation that are dispatched after successful commit.
The fn or wrapper can optionally accept a `track_event` parameter
(detected via call_with_supported_arguments). Events are discarded
if the write raises an exception.
Does not yet handle the block=False (non-blocking) case - events
queued during non-blocking writes are currently silently discarded.
Refs https://github.com/simonw/datasette/issues/2681
* Dispatch track_event events for non-blocking (block=False) writes
Spawns a background asyncio task that awaits the write thread's reply
queue and dispatches pending events after a successful non-blocking
write. Events are still discarded if the write raises an exception.
Refs https://github.com/simonw/datasette/issues/2681
* Warn that events won't fire for other processes
Refs https://github.com/simonw/datasette/issues/2681#issuecomment-4157118662
* Document call_with_supported_arguments as a supported public API
Mark both call_with_supported_arguments and async_call_with_supported_arguments
with the @documented decorator and add documentation to docs/internals.rst
so plugin authors can use these dependency injection utilities in their own code.
https://claude.ai/code/session_01DKogZpHwzCTrbeG4XjXmNc
- register_column_types() now returns classes instead of instances
- ColumnType.__init__ takes optional config=, baking it into the instance
- get_column_type() returns a ColumnType instance (or None) instead of a
(name, config) tuple
- get_column_types() returns {col: ColumnType instance} instead of tuples
- Remove get_column_type_class() - no longer needed
- render_cell/validate/transform_value methods no longer take config arg;
use self.config instead
- render_cell hook takes column_type (ColumnType or None) instead of
column_type + column_type_config
https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
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
* Add request.form() for multipart form data and file uploads
New Request.form() method that handles both application/x-www-form-urlencoded
and multipart/form-data content types with streaming parsing.
Features:
- Streaming multipart parser that doesn't buffer entire body in memory
- Files spill to disk above 1MB threshold via SpooledTemporaryFile
- files=False (default) discards file content, files=True stores them
- Security limits: max_request_size, max_file_size, max_fields, max_files
- FormData container with dict-like access and getlist() for multiple values
- UploadedFile class with async read(), seek(), filename, content_type, size
- Support for RFC 5987 filename* encoding for international filenames
Uses multipart-form-data-conformance test suite for validation.
* Update views to use request.form() and document new API
- Migrate PermissionsDebugView, MessagesDebugView, and CreateTokenView
from post_vars() to form()
- Add documentation for request.form(), FormData, and UploadedFile classes
Centralize multipart defaults and expose stricter limits via Request.form().
Enforce header, part, file, and disk space limits even when files are discarded; detect truncated bodies and client disconnects; and move blocking work off the event loop.
Add FormData close/aclose context managers, update internals docs, and expand multipart tests (including len semantics and stricter conformance expectations).