Compare commits

...

325 commits

Author SHA1 Message Date
Simon Willison
cec5e6b2ef
Screenshots for reusable modal dialogs docs, plus just shots (#2958)
docs/shots.yml defines documentation screenshots for shot-scraper

"Just shots" runs them.
2026-09-24 15:06:57 -07:00
Simon Willison
8e17729ff3
Simplify code comments added since 1.0a40 (#2957)
Refs #2867
2026-09-24 13:52:29 -07:00
Simon Willison
90f2f1910f Improve OpenTelemetry security suggestions
Refs #2867
2026-09-24 12:12:01 -07:00
Simon Willison
34a7c28ba3 Case insensitive check for foreign key tables
Refs #1515"
2026-09-24 12:10:42 -07:00
Simon Willison
0361afb12e Typo fixes 2026-09-24 12:09:29 -07:00
Simon Willison
aa8c2e36f7 Release 1.0a41
Refs #1515, #1730, #1980, #2012, #2446, #2790, #2858, #2867, #2948, #2949, #2951, #2952
2026-09-24 11:58:30 -07:00
Sanjay Santhanam
5c9aa3e52a
Exclude unsortable primary key from sort menu
Closes #1980
2026-09-24 11:51:51 -07:00
Dipak Chaudhari
e4e6f91962
Fix missing space before the view count on the index page (#2951)
Fixes the "0 tables1 view" bug, closes #2012
2026-09-24 11:50:01 -07:00
Simon Willison
b140f4d734 Made execute_write time limit test less flaky
No uses time_limit_ms=0
2026-09-24 11:45:02 -07:00
Simon Willison
83640cf6e6 Simplified testing docs 2026-09-24 11:45:02 -07:00
Dipak Chaudhari
83cef452ea
Don't link a foreign key that points at a table that does not exist
Closes #1515
2026-09-24 11:42:23 -07:00
pengboyu-dev
d9f9ed5027
Don't 500 on ?_facets=x
Tighten up code that extracts `?_facet=` or `?_facet_type=` parameters.
2026-09-24 11:34:45 -07:00
Simon Willison
4d361cadcd More documentation simplification, refs #2867 2026-09-24 11:28:56 -07:00
Simon Willison
96d2baec32 More documentation work, refs #2867 2026-09-24 11:16:17 -07:00
Simon Willison
287e8ba3b2 Simplified telemetry descriptions in telemetry_registry.py
Told GPT-6 Astra to apply similar simplifications to the ones I used
when I hand-edited other parts of the docs.

Refs #2867
2026-09-24 11:04:41 -07:00
Simon Willison
1bba4d9a67 Simplified the docs for #2867 2026-09-24 10:47:31 -07:00
Alex Garcia
d9457d782e Make registry entries deepcopy-able, so console metric dumps work
Attribute, SpanName and MetricName are str subclasses whose __new__
requires the metadata arguments, so copy.deepcopy could not reconstruct
one - it falls back to cls.__new__(cls) and raises TypeError.

That broke a real path rather than a theoretical one. The SDK's
ConsoleMetricExporter renders data points through dataclasses.asdict(),
which deepcopies mappings, and both core and kit-based plugins pass
registry entries as metric attribute keys - so every console metrics
dump crashed, core's own points included. Found by datasette-paper's
dev harness running opentelemetry-instrument with console exporters.

__reduce__ collapses copies to a plain str, which is what an entry is
everywhere except the registry module itself: the description, values
and buckets describe the single registered instance, and nothing reads
them off a copy. Pickle is fixed by the same change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwU7BcTnAxUGSJYhBrQaY7
2026-09-24 09:58:58 -07:00
Alex Garcia
2588634358 Capstone review fixes: per-test reset, rename, provider guard, UpDownCounter, naming rules, privacy walk
Outcome of a whole-stack review with the kit visible as one system:

- otel_reset: an autouse fixture draining the span exporter and metric
  reader after every test. Without it a large suite accumulates hundreds
  of thousands of recorded spans in the session-scoped exporter - the
  likeliest amplifier of the slow-runner CI flakes - and plugins would
  inherit the same leak.
- assert_registry_covered renamed to assert_spans_covered: the old name
  read as covering the whole registry, which is exactly wrong next to
  assert_metrics_covered. Public API is forever; renamed before anything
  ships, no alias.
- The installers now verify their provider actually took: with a
  provider installed first (opentelemetry-instrument, an embedding app),
  set_*_provider() is silently ignored, and fixtures would assert
  against an exporter wired to nothing. They skip clearly instead.
- UPDOWN_COUNTER registry kind, mapped to Sum with monotonicity checked
  both ways - a Counter must collect monotonic, an UpDownCounter must
  not. Previously an UpDownCounter's kind check was silently skipped.
- The docs page now prescribes naming: scope = import package name
  (underscores), signal prefix = a name you own, never bare datasette.*;
  its own examples no longer teach the hyphenated outlier. Plus an
  observable-gauges pattern section and a prefix-overlap note.
- assert_no_forbidden_values(): the enforcement half of the privacy
  rules - plant sentinel secrets in a workload and assert they never
  appear in any span name, attribute, event, status description or
  metric attribute, across all scopes by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-24 09:58:58 -07:00
Alex Garcia
b80535c48a Front-load the kit's subprocess test to dodge the macOS fork crash
test_kit_module_itself_never_imports_the_sdk shells out, and like every
subprocess-spawning test in this suite it crashes the interpreter with
SIGBUS on macOS/CPython 3.13 when it runs late enough that the process
holds many threads - the exact failure conftest already front-loads
test_datasette_package_never_imports_the_sdk for. Move it to the front
too, and note the hazard in assert_package_never_imports_sdk's docstring
since plugin suites will call it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-24 09:58:58 -07:00
Alex Garcia
29a2e6605d Extend the kit with metric-side conformance: kind, unit and enum checks
The five surveyed plugin plans all kept a hand-rolled metrics-vs-registry
diff because the kit's conformance helpers covered spans only. This adds
the metric side:

- metric_for() in the registry (the span_for analogue - no prefix/dynamic
  machinery, metric names are static), and the attribute helpers are
  documented as accepting MetricName entries.
- MetricsCollector.collect() now retains the instrumentation scope per
  collected metric, so a plugin is judged against its own meter only.
- assert_metrics_conform(): every collected metric in scope is registered,
  was created as the instrument kind and unit its registry entry declares
  (drift between the registry entry and the meter.create_*() call was
  previously caught by nothing, in core or any plugin), sets only
  registered attributes, and respects values= enums - the check that makes
  a metric dimension provably bounded.
- assert_metrics_covered(): every registered metric collected at least
  once with every non-optional attribute seen. Both *_covered helpers now
  exempt optional=True attributes, so a workload is not forced to
  manufacture every error path; pin those with targeted tests instead.
- datasette.operation declares values={"read", "write"} - core dogfoods
  the enum enforcement on the dimension where it matters most.
- Core's generic metric conformance tests are now calls to the kit
  helpers with scope_name="datasette"; the stricter literal-pinning and
  optional-attribute-coverage tests stay hand-written on purpose.
- The metric reference docs render attributes through the same helper as
  spans, so *(optional)* markers and enum values now appear there too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-24 09:58:58 -07:00
Alex Garcia
05ca128c02 Add a plugin telemetry kit: public registry API, linked_root_span_kwargs, test helpers, docs
A survey of five plugin OTel plans (datasette-paper, -agent, -litestream,
-accounts, -cron) found every one hand-copying the same core machinery:
the registry classes, the conformance-test harness, the pytest fixtures,
the bucket boundaries and the detached-root-with-Link recipe. This makes
that machinery importable instead:

- The registry classes are documented public API. Attribute gains
  values= (a closed enum the conformance helpers enforce - what makes an
  attribute safe as a metric dimension); SpanName gains prefix=True for
  span families like "chat {model}" whose names share a fixed prefix,
  matched by span_for() after exact names. span_for()/attribute helpers
  accept a spans= tuple so plugin registries can use them.
- datasette.telemetry.linked_root_span_kwargs(): the root-span-with-Link
  shape for work a request caused without containing - background jobs,
  scheduled ticks, block=False writes. Core's own write thread now uses
  it instead of building the kwargs inline.
- datasette.telemetry_testing: the session provider fixtures, otel_spans
  / otel_metrics, a two-way registry conformance checker (including enum
  and prefix handling, filtered by instrumentation scope) and an
  assert_package_never_imports_sdk() guard. Core's conftest now imports
  these instead of defining them, so the suite consumes the kit exactly
  as a plugin's would.
- New "Telemetry for plugin authors" docs page: scope discipline,
  registry usage, privacy/cardinality rules, named-callable guidance,
  request_span(), the background root-with-link convention (one root per
  tick, always emitted), provider-ordering facts and known caveats.
  request_span() is now documented public API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-24 09:58:58 -07:00
Alex Garcia
8614bb7779
OpenTelemetry metrics: SQL thread saturation, query latency, write queue depth
Merge pull request #2897
2026-09-24 09:34:11 -07:00
Alex Garcia
2cefab7b4c
Count callback-style calls in db.client.operation.duration
The callback entry points gained db.query spans in the database-spans PR;
this adds their other half - the duration histogram measurement, so a
plugin's execute_fn/execute_write_fn work and the JSON write API's inserts
and deletes stop being invisible to the one series that survives trace
sampling. execute_isolated_fn records "write" when the database is mutable
(the call blocks the write queue) and "read" when immutable (it runs on
the read pool). error.type comes from the raised exception class, same as
the SQL-string paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-24 09:14:06 -07:00
Alex Garcia
8f49def8cb
Review polish: fix stale exemplar context, dedupe rationales, close test gaps
- The exemplars docs described "the pinned opentelemetry-exporter-prometheus"
  and "Datasette's own Prometheus exporter" - context from demo/plugin work
  that is no longer part of this stack. Reworded to stand alone.
- Saturate a num_sql_threads=1 pool and assert the queue-depth gauge reads
  above zero - the headline alerting metric previously only had an absence
  test, and this also pins the private ThreadPoolExecutor._work_queue
  attribute it depends on.
- Pin error.type on the write path of db.client.operation.duration - the
  write wrappers time a different code path than the read one already tested.
- Isolate the non-threaded-mode gauge test from other live instances instead
  of comparing global observation counts, which a GC pass could shift.
- Halve the metrics banner, point conftest's meter note at it, compact the
  interrupted-counter call-site comment to a registry pointer, note why
  instrument and registry descriptions are separate strings, and stop
  calling the metric dimension a "later phase" now that metrics shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
2026-09-24 09:14:06 -07:00
Alex Garcia
000a023aa0
Apply black to the metrics additions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2h9ANGZ7paWSpqs5DUAcG
2026-09-24 09:14:06 -07:00
Alex Garcia
22cdcd39a6
Document metric exemplars, which Datasette already emits
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
2026-09-24 09:14:06 -07:00
Alex Garcia
3024d950d4
Check metric attributes in the registry conformance test
Span attributes were checked in both directions; metric attributes were not
checked at all, so the generated reference could publish an incomplete list
with nothing to catch it.

The metric workload lives in an `emitted_metrics` fixture, mirroring the
span side, and error.type is checked like every other attribute rather than
exempted for being optional - the workload reaches it two separate ways.

(Adapted from b30c5341: the old workload's facet-timeout probe belongs to
phase 5 and is dropped, and the interrupted counter now needs a query that
exceeds the *configured* time limit - custom short budgets are excluded from
the count on this lineage - so the fixture runs one against a second
instance configured with sql_time_limit_ms=5.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2h9ANGZ7paWSpqs5DUAcG
2026-09-24 09:14:06 -07:00
Alex Garcia
383fa1ad0a
Register metrics and give histograms bucket boundaries suited to seconds
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
2026-09-24 09:14:06 -07:00
Alex Garcia
90b727db73
Add OpenTelemetry metrics for SQL thread pool saturation and query latency
Spans describe requests that have finished. They structurally cannot answer
"am I saturating my 3 SQL threads right now", because that is a level rather
than an event - and with num_sql_threads defaulting to 3, it is usually the
first thing worth knowing about a busy Datasette. This adds the metrics that
answer it.

Five observable gauges, computed only when something is collecting, so an
instance with no MeterProvider installed does no work for them at all:

  datasette.sql.threads.limit         num_sql_threads
  datasette.sql.threads.queue_depth   queries waiting for a free thread
  datasette.sql.queries.pending       in-flight reads, by db.namespace
  datasette.write.queue_depth         writes behind the single write thread
  datasette.connections.open          tracked file connections

Three instruments recorded inline, which matters because metrics survive
trace sampling and spans do not - an operator sampling 1% of traces still
gets 100% of the latency distribution:

  db.client.operation.duration        semconv histogram, with error.type
  datasette.write.queue_wait          the metric twin of the existing span
  datasette.sql.queries.interrupted   sql_time_limit_ms kills

The interrupted counter closes a gap the plan called out as unanswerable:
"how often are we killing queries at the limit" is a rate, and a rate cannot
be recovered from sampled spans.

Core still creates no provider of any kind, so the architecture is unchanged;
`grep -rn 'opentelemetry.sdk' datasette/` stays empty. One real difference
from tracing is worth recording: _ProxyMeter and its instruments forward to a
provider installed after they were created, whereas ProxyTracer permanently
caches the first concrete tracer it resolves. Module-level instruments are
therefore safe and the test fixture has no ordering constraint.

Live instances are tracked in a lock-guarded WeakSet so instrumenting an
instance never keeps it alive. The pool gauges carry no attribute saying
which Datasette produced them: production runs one instance per process, and
adding an id to disambiguate the test suite's hundreds of instances would buy
unbounded attribute cardinality to fix a case that does not occur. The
collision is documented instead, and the gauge callbacks are plain generator
functions so tests can assert exact values by calling them directly rather
than through the SDK's last-value aggregation.

demos/otel/metrics_demo.py fires 12 concurrent 40ms queries at a 3-thread
pool and samples the gauges mid-flight: queue_depth peaks at exactly 9, and
the duration histogram reads max=0.1695s for a query whose work is 40ms. That
gap is the queue, and it is the thing traces alone will not show you.

Also corrects the demo README's privacy section, which still claimed
parameter values are never recorded - that stopped being unconditionally true
when trace_sql_parameters landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

(cherry picked from 6ef0dd8c and adapted to the rebuilt phase-1 stack:
attribute names now come from telemetry_registry where entries exist, the
meter carries the instrumentation-scope version and schema URL, and the
interrupted-queries counter skips expected timeouts - callers that opted
into a deliberately short budget, like facet suggestion - matching how
those are excluded from span error status. The internals.rst reference
lands with the registry commit that follows.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2h9ANGZ7paWSpqs5DUAcG
2026-09-24 09:14:06 -07:00
Alex Garcia
9bce2eec7c
Emit an OpenTelemetry span for each HTTP request
PR #2863
2026-09-24 09:14:03 -07:00
Alex Garcia
daf1989ebe
OpenTelemetry tracing for the database layer (#2862)
PR #2862
2026-09-24 09:11:08 -07:00
dependabot[bot]
5f1b8f3453
Bump the python-packages group across 1 directory with 2 updates (#2950)
Bumps the python-packages group with 2 updates in the / directory: [black](https://github.com/psf/black) and [furo](https://github.com/pradyunsg/furo).


Updates `black` from 26.3.1 to 26.5.1
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/26.3.1...26.5.1)

Updates `furo` from 2025.9.25 to 2025.12.19
- [Release notes](https://github.com/pradyunsg/furo/releases)
- [Changelog](https://github.com/pradyunsg/furo/blob/main/docs/changelog.md)
- [Commits](https://github.com/pradyunsg/furo/compare/2025.09.25...2025.12.19)

---
updated-dependencies:
- dependency-name: black
  dependency-version: 26.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-packages
- dependency-name: furo
  dependency-version: 2025.12.19
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-packages
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 09:02:17 -07:00
Simon Willison
74f178cd12
Extract reusable modal dialogs as a documented JS utility
Merge pull request #2948 from simonw/codex/reusable-modal-dialogs

Closes #2790
2026-09-17 16:12:27 -07:00
Simon Willison
70be140197 Human edit round of dialog JS docs, refs #2790 2026-09-17 16:11:31 -07:00
Simon Willison
3e9356dde0 Rename modal trigger option to returnFocusTo, refs #2790 2026-09-17 15:46:34 -07:00
Simon Willison
ad1d0bf6f9 Rename modal dismissal reason to source, refs #2790 2026-09-17 15:32:47 -07:00
Simon Willison
269c043da3 Share scrolling dialog body styles with modal-body, refs #2790 2026-09-17 14:42:14 -07:00
Simon Willison
0ad118ba26 Clarify focus restoration when reopening a modal, refs #2790 2026-09-17 14:18:22 -07:00
Simon Willison
2474c45f10 Move shared modal styles into app.css, refs #2790 2026-09-17 14:17:35 -07:00
Simon Willison
8220413a8a Keep modal documentation in the JavaScript plugin docs, refs #2790 2026-09-17 14:13:17 -07:00
Simon Willison
71600f1c0a Simplify shared modals now that all dialogs use the document, refs #2790 2026-09-17 14:10:45 -07:00
Simon Willison
15d511e2da Render the column chooser without shadow DOM, refs #2790 2026-09-17 14:10:45 -07:00
Simon Willison
e60d1bfe1c Render navigation search without shadow DOM, refs #2790 2026-09-17 14:10:44 -07:00
Simon Willison
3b013b7ea3 Refactor row editing and insertion to use the shared modal, refs #2790 2026-09-17 13:28:26 -07:00
Simon Willison
90f543327e Refactor row deletion to use the shared modal, refs #2790 2026-09-17 13:28:26 -07:00
Simon Willison
814165c8b1 Refactor the alter table dialog to use the shared modal, refs #2790 2026-09-17 13:28:25 -07:00
Simon Willison
c82a98c88a Refactor the create table dialog to use the shared modal, refs #2790 2026-09-17 13:28:25 -07:00
Simon Willison
de37f1451f Refactor the column type dialog to use the shared modal, refs #2790 2026-09-17 13:28:24 -07:00
Simon Willison
17b19b4d27 Refactor mobile column actions to use the shared modal, refs #2790 2026-09-17 13:28:24 -07:00
Simon Willison
328b2e6c6f Refactor the column chooser to use the shared modal, refs #2790 2026-09-17 13:28:24 -07:00
Simon Willison
c410ed9555 Refactor navigation search to use the shared modal, refs #2790 2026-09-17 13:28:23 -07:00
Simon Willison
e1f89494f1 Add reusable modal component, documentation and lifecycle tests, refs #2790 2026-09-17 13:28:23 -07:00
Simon Willison
3239d10b9b Use cog to show default values in settings.rst 2026-09-17 12:30:47 -07:00
Simon Willison
9cdf95ac2c Show correct query timings, closes #2446 2026-09-16 21:23:54 -07:00
Simon Willison
ffd3b8cada Refactor internal docs to add Background tasks section
Previously information about background tasks was mixed in with
method descriptions for the Datasette class.
2026-09-16 17:47:23 -07:00
Simon Willison
b661889b62 Adjust headings so Request object has three sub-headings 2026-09-16 17:46:16 -07:00
Simon Willison
1dbbacedda Add intro sentence to 1.0a40 release notes 2026-09-16 16:55:01 -07:00
Simon Willison
2eb2571fee Release 1.0a40
Refs #1681, #1695, #1861, #2005, #2129, #2639, #2876, #2902, #2903, #2914, #2924, #2925, GHSA-h547-rmjf-5m2m
2026-09-16 16:46:51 -07:00
Simon Willison
caf238aac8 Fix for GHSA-h547-rmjf-5m2m 2026-09-16 16:43:34 -07:00
Simon Willison
c7e9c52e5b pytest skip tests in ignored/ folder
This is where Codex has a bunch of worktrees.
2026-09-16 16:22:48 -07:00
Simon Willison
d2098e9f84
Fix facet selection for explicit exact filters
`colname__exact=value` is now treated the same as `colname=value` when determining which facets are selected.

Closes #1695
2026-09-16 14:56:09 -07:00
Simon Willison
266eaddb73
Fix remaining pytest warnings (#2928)
- Close in-memory database connections, including reads opened on threads
- Close completed and partial file upload file handles
- Close SQLite connections and file handles owned by tests
2026-09-16 14:50:06 -07:00
Simon Willison
926c6ed2cb
Allow write wrappers to roll back alter-table operations
Merge pull request #2925
2026-09-16 14:43:18 -07:00
Simon Willison
0d0bb5dd8c
Merge branch 'main' into codex/test-write-wrapper-alter-rollback 2026-09-16 14:42:20 -07:00
Simon Willison
61fd3461c9 Docs showing how to use db.atomic(), refs #2871 2026-09-16 14:41:29 -07:00
Simon Willison
6c119323d0 Keep alter-table changes rollbackable by write wrappers 2026-09-16 14:40:50 -07:00
Simon Willison
6dd5297b34
Allow extra_template_vars to resolve to None
Closes #2005
2026-09-16 10:30:11 -07:00
Simon Willison
df4c0fde0f
Make request header lookups case-insensitive
Closes #1861
2026-09-16 10:27:35 -07:00
Simon Willison
faff4c8820 Return CSV errors as plain text, closes #2129 2026-09-15 15:53:10 -07:00
Alex Garcia
4bf30aaf8e
Use $DATASETTE_INTERNAL in absence of --internal (#2174) 2026-09-15 15:39:39 -07:00
Simon Willison
27b7240f30 Fix for render_cell() pks for SQL views, refs #2639 2026-09-15 15:30:51 -07:00
Simon Willison
7c8ed1015e Changelog for #1681, #2876 2026-09-15 13:35:51 -07:00
Rami Abdelrazzaq
82ed8d47f9
Fix float coercion for numeric filter parameters (#2876)
Closes #1681
2026-09-15 13:14:29 -07:00
Simon Willison
f40d838429 Changelog entry for #2902 and #2903 2026-09-15 12:58:41 -07:00
AnkitaAdvitot
920adc6880
Fix infinite loop when streaming CSV on SQL views, fixes #2902 (#2903)
When streaming a view as CSV with ?_stream=on, _next was being
re-read from request.args, overwriting the kwarg passed by the
streaming loop with None. This caused the pagination offset to stay
stuck on page 1 forever.
2026-09-15 12:57:09 -07:00
Simon Willison
ea3edcc1b9 How to use crumbs for plugin authors, closes #1902 2026-09-15 12:53:02 -07:00
nightcityblade
4fe985945d
Document customizing breadcrumbs (#2885)
Refs #1902
2026-09-15 12:45:01 -07:00
Melbin J Paulose
a1b73f830b
README: minimum Python is 3.10, not 3.8 (#2878) 2026-09-15 12:38:41 -07:00
Simon Willison
ee5b5728bc Fix some broken links in docs
Refs #2912
2026-09-15 12:31:55 -07:00
Simon Willison
b09976b924 Tests for table count endpoint, refs #2914
Forgot to include these tests in commit 1538832830
2026-09-15 12:21:36 -07:00
Simon Willison
5827feff74 Fix old broken link in Changelog
Refs #2912
2026-09-15 12:20:47 -07:00
Simon Willison
32845d88d5 Deploy canned queries to latest.datasette.io again with workaround for #2916
Should fix some broken links reported in #2912
2026-09-15 12:12:22 -07:00
Aurora
d60d88488f
docs: fix dead Starlette, Uvicorn and Docker Hub links (#2912)
www.starlette.io and www.uvicorn.org no longer resolve (NXDOMAIN); both
projects now publish their documentation at starlette.dev and uvicorn.dev
respectively. The Docker Hub link was missing the /r/ path segment, which
returns a 404.

- docs/plugin_hooks.rst: www.starlette.io/middleware/ -> starlette.dev/middleware/
- docs/changelog.rst (x3): www.uvicorn.org/ -> uvicorn.dev/
- docs/changelog.rst: hub.docker.com/datasetteproject/... -> hub.docker.com/r/datasetteproject/...

All four replacement URLs verified to return HTTP 200.
2026-09-15 11:57:39 -07:00
Alex Garcia
374b194ff5
Add /-/tasks introspection endpoint for supervised background tasks (#2892)
Co-authored-by: Simon Willison <swillison@gmail.com>
2026-09-15 11:56:53 -07:00
Alex Garcia
784695aea6 Ensure startup() runs before any plugin ASGI middleware (#2891) 2026-09-15 11:22:45 -07:00
Alex Garcia
cca08d2886 Add shutdown() plugin hook with ordered graceful teardown (#2890) 2026-09-15 11:09:57 -07:00
Alex Garcia
0c31778580 Add datasette.add_background_task() with supervised launch after startup (#2889) 2026-09-15 10:55:54 -07:00
Simon Willison
b0407867cc Tweak display of count all button, refs #2914 2026-09-15 10:16:16 -07:00
Simon Willison
1538832830 /db/table/-/count? endpoint, fixed 'count all' button
Closes #2914
2026-09-15 10:03:17 -07:00
Alex Garcia
063eeae83d
Slim down CI runs per commit (#2911)
- Only run push-triggered workflows on main; PRs are covered by
  pull_request, so branch commits no longer run everything twice
- Test SQLite versions: single job, Python 3.13 + SQLite 3.25
- Fold coverage into the Python 3.14 test job (including serial
  tests) and remove test-coverage.yml; bump codecov-action to v5
- Cancel superseded in-progress runs on pull requests
- Prettier now also runs on pull requests

Claude-Session: https://claude.ai/code/session_012G7fa8HqJy9AkSjkocetAz
2026-09-15 08:51:24 -07:00
Simon Willison
b338c6f5f6
Migrate from httpx to httpx2, closes #2879
https://claude.ai/code/session_01Xdqoneq8ddvruVZETo6rFf
2026-09-10 19:44:49 -07:00
Simon Willison
61400fba1a Fix Docker release builds on main with Bookworm base image
Apply the Dockerfile fix from 0.65.x to avoid expired Bullseye security repository metadata.

Original fix: 91fa786de9
2026-09-10 18:52:19 -07:00
Simon Willison
186be52863 Publish stable and latest documentation databases to S3 2026-09-10 18:29:54 -07:00
Simon Willison
36acd1ea92 Publish packages when releases are published 2026-09-10 16:53:13 -07:00
Simon Willison
5e7cdaabbd Release 1.0a39 2026-09-10 16:52:52 -07:00
Simon Willison
92c7d4b608 Limit derived-table permissions to one source hop
Simplify the solution to 5de0c1724e - avoid contextvar.
2026-09-10 16:52:52 -07:00
Simon Willison
f70edbfa60 Filter incoming foreign-key relationships by view permission 2026-09-10 16:52:52 -07:00
Simon Willison
b97bb5f016 Reconcile write-timeout regression with the per-call limit 2026-09-10 16:52:26 -07:00
Simon Willison
e036907fc3 Reject structured row writes to virtual and shadow tables 2026-09-10 16:52:25 -07:00
Simon Willison
3f8d8417f6 Inherit source permissions for FTS vocabulary tables 2026-09-10 16:52:25 -07:00
Simon Willison
d334539a1e Deny SQLite statistics table access through a default hook 2026-09-10 16:52:25 -07:00
Simon Willison
628cec8f0c Block framing of stored-query mutation forms 2026-09-09 08:39:03 -07:00
Simon Willison
506c4bb522 Match table permission identities using SQLite case semantics 2026-09-09 08:39:03 -07:00
Simon Willison
e429bd2efa Reuse trusted magic parameter bindings for CSV exports 2026-09-09 08:39:03 -07:00
Alex Garcia
c6ba7b3298 Refuse API token creation from restricted actors 2026-09-09 08:38:40 -07:00
Simon Willison
ac2a9a43a5 Add a default execution time limit to writes 2026-09-08 21:17:03 -07:00
Simon Willison
9d3d741620 Require view permission before using row labels in flash messages 2026-09-08 21:16:36 -07:00
Simon Willison
ceef351622 Protect personalized dynamic responses from shared caching 2026-09-08 21:16:36 -07:00
Simon Willison
7e6039b8df Normalize URL column schemes consistently 2026-09-08 21:16:35 -07:00
Simon Willison
d43a04eb54 Authorize row resources before resolving primary keys 2026-09-08 21:16:35 -07:00
Simon Willison
8b10f58e1b Reject untrusted table-valued PRAGMA reads 2026-09-08 21:16:35 -07:00
Simon Willison
4b8f3b484d Keep private row and table responses out of shared caches 2026-09-08 21:16:35 -07:00
Simon Willison
1be4df77ac Reject invalid token expiry input 2026-09-08 21:16:35 -07:00
Simon Willison
6aa58bf4e5 Authorize configured full-text search targets 2026-09-08 21:16:35 -07:00
Simon Willison
22c601b3d0 Redact configuration keys case-insensitively 2026-09-08 21:16:35 -07:00
Simon Willison
e949ae46de Use unshadowable table classification 2026-09-08 21:16:35 -07:00
Simon Willison
a365903d56 Require view permission before returning written rows 2026-09-08 21:16:35 -07:00
Simon Willison
4c56ce2103 Escape identifiers in upsert row readback 2026-09-08 21:16:35 -07:00
Simon Willison
158c88f259 Escape primary-key cell values in row pages 2026-09-08 21:16:34 -07:00
Simon Willison
35232b5c37 Escape primary-key identifiers in row queries 2026-09-08 21:16:34 -07:00
Simon Willison
c01e95f3bd Filter foreign-key helper targets by view permission 2026-09-08 21:14:10 -07:00
Simon Willison
bf348a22fc Escape LIKE metacharacters in FTS detection 2026-09-08 21:12:23 -07:00
Simon Willison
59618371e9 Validate URL before rendering column links 2026-09-08 21:11:44 -07:00
Simon Willison
5de0c1724e Viewing derived table requires permission for both table and its source
Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 18:09:23 -07:00
Simon Willison
d06737b6f4 Fix CREATE VIEW analysis on Python 3.10 2026-09-08 10:49:52 -07:00
Simon Willison
6473a7ecb0 Clearly document relationship between execute-sql and facets
Refs GHSA-5fff-xcm9-q6vh

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:49:52 -07:00
Simon Willison
f6d0f9bd38 detect_fts() now uses parameterized SQL
Refs GHSA-jcvx-2fh3-pjfp

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:48:20 -07:00
Simon Willison
c899beaebe escape_sqlite() against column names
Refs GHSA-jcvx-2fh3-pjfp

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:48:20 -07:00
Simon Willison
3ae092896d Only allow /db/name/-/schema against tables and views
Refs GHSA-926p-cw2f-643h

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:48:20 -07:00
Simon Willison
5d9a74f370 Check view-table as part of /-/schema and /db/-/schema
Refs GHSA-926p-cw2f-643h

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:48:19 -07:00
Simon Willison
01bf476d51 Require view-instance permission for /-/allowed
Refs GHSA-hp2x-vx2r-6vxg

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:48:19 -07:00
Simon Willison
4904249025 Use .textContent for column names in column chooser
Refs GHSA-hp2x-vx2r-6vxg

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:48:19 -07:00
Simon Willison
435e55ff0a Remove JSON syntax highlighting
Refs GHSA-hp2x-vx2r-6vxg

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:48:19 -07:00
Simon Willison
f8e8e65af7 actor cookie respects expire_after
Refs GHSA-53fc-rhfg-h7qp

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:48:19 -07:00
Simon Willison
577aeb73f0 Disallow ?_through= if user lacks view-table permission
Refs GHSA-53fc-rhfg-h7qp

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:48:19 -07:00
Simon Willison
c280c47424 POST /db/-/create checks table-level insert/update/alter permissions
Refs GHSA-53fc-rhfg-h7qp

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:48:19 -07:00
Simon Willison
4d0a2f2e84 execute-write: Check view-table for every table in a CREATE VIEW
Refs GHSA-53fc-rhfg-h7qp

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:48:19 -07:00
Simon Willison
c7944fc454 Skip deploy if environment variables are missing 2026-09-08 10:48:19 -07:00
Simon Willison
bdaa8cc76c Disable extension loading once --load-extension extensions are loaded
Refs GHSA-2mvv-ffvc-q5p6

Co-authored-by: Alex Garcia <15178711+asg017@users.noreply.github.com>
2026-09-08 10:48:19 -07:00
Zain Dana Harper
7403ae68bb Give each non-blocking write a distinct task id, refs #2860, #2859
execute_write_fn(fn, block=False) is documented to return "a UUID representing
the queued task". Two things stopped that being true.

_send_to_write_thread() derived the id from uuid.uuid5(NAMESPACE_DNS,
"datasette.io"), which is deterministic, so every non-blocking write in every
database in every process returned 3f143baa-4e3d-5842-a36f-4fa2f683b72f. A
constant cannot identify a particular task. Now uuid4(). Refs #2860.

With num_sql_threads=0 there is no write thread, so execute_write_fn took the
synchronous branch and `result` was the write function's return value, normally
None. The block=False path then unpacked it unconditionally and raised
TypeError: cannot unpack non-iterable NoneType object. The non-threaded branch
now returns the same (task_id, reply_future) shape, with the future already
resolved because the write has finished, so both modes share one code path.
Refs #2859.

test_execute_write_fn_block_false only asserted isinstance(task_id, uuid.UUID),
which a constant satisfies. The new test is parametrized over threaded and
non-threaded and asserts two calls return different ids, so either regression
fails it.
2026-09-08 10:03:52 -07:00
Simon Willison
bdc9731740
check-latest: true, add 3.15 to test matrix, to test RCs (#2895)
See https://simonwillison.net/2026/Sep/1/python-315-rc-2/
2026-09-01 13:37:15 -07:00
Alex Garcia
3e018bb1b5
Run startup via ASGI lifespan instead of waiting for the first request (#2887)
* Run startup via ASGI lifespan instead of waiting for the first request
* Ensure immutable table counts still precompute when startup ran first
2026-09-01 09:39:25 -07:00
Alex Garcia
e78b8a2e6a
Run datasette serve startup and uvicorn on a single event loop (#2886)
* Run datasette serve startup and uvicorn on a single event loop
* Move the serve-subprocess test plumbing into a conftest fixture
* Fix datasette-litestream URL and trim marker-task test comments
* Explain why serve_with_plugins needs a subprocess and plugin files
* Apply ruff 0.16 and black fixes
* Tweaked some comments
2026-09-01 09:32:37 -07:00
Simon Willison
0337fba234
disable_fts() before dropping table
Closes #2874
2026-08-10 15:03:29 -07:00
Simon Willison
12b25affb5 Release 1.0a38
Refs #2868
2026-08-06 11:20:25 -07:00
Simon Willison
eb6c2b96b9 Fix for SQL injection issue in table filters, refs #2868 2026-08-06 11:19:50 -07:00
Simon Willison
e889403d3b
Upgrade to ruff>=0.16.0 (#2857)
* ruff>=0.16.0

See https://astral.sh/blog/ruff-v0.16.0

* uv run ruff check . --fix --unsafe-fixes

* Ruff fixes by Claude Code Opus 5
2026-07-25 15:47:08 -07:00
Simon Willison
481df7ff6d Shorten link text in changelog 2026-07-14 09:31:28 -07:00
Simon Willison
2ffd8a860e Release 1.0a37
Refs #2831, #2832, #2841, #2842, #2843, #2846
2026-07-14 09:28:29 -07:00
Simon Willison
8b7c942d5e Major performance boost for SQL permissions, closes #2832 2026-07-14 09:18:51 -07:00
TowyTowy
591b909a4d
Escape table names with [square] brackets, refs #2431 (#2846)
Several internal helpers quoted table names using SQLite [bracket]
identifiers built with an f-string, e.g. PRAGMA foreign_key_list([{table}]).
Bracket quoting cannot escape a "]" character, so any table whose name
contains "]" (for example "[foo]" or "foo]") produced
"sqlite3.OperationalError: unrecognized token" - crashing schema
introspection at startup and 500-ing the table page.

Switch these call sites to the existing escape_sqlite() helper, which uses
"double quote" quoting with correct "" escaping (the same approach already
used elsewhere in the codebase and in the test suite):

- utils/internal_db.py: PRAGMA foreign_key_list / index_list
- utils/__init__.py: get_outbound_foreign_keys
- database.py: table_counts count query
- facets.py: default "select * from" SQL

Added a regression test covering table names with "]" characters.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-14 08:53:45 -07:00
Simon Willison
9cfc252394
Make internal catalog refresh atomic
Refs #2831
2026-07-14 08:41:27 -07:00
Simon Willison
7f0a8b38ae
Better permission debug tools and documentation
Closes #2841
2026-07-14 08:40:07 -07:00
Simon Willison
10088dfa1d
execute_write(transaction=False) parameter, plus fix for errors inside tasks
Ensure a write inside a failing Datasette task never becomes visible. Refs #2831
2026-07-13 22:42:44 -07:00
Simon Willison
ccace40e5a
/-/plugins.json is now an array of objects again (#2843)
Reverts the object envelope introduced in 1.0a36 for this endpoint -
it once again returns a top-level JSON array of plugin objects.

Closes #2842


Claude-Session: https://claude.ai/code/session_012TYc1NTBK4zEjabB3u2zqu

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-13 21:19:04 -07:00
Simon Willison
db82123108 Bump a whole lot of GitHub Actions versions 2026-07-07 14:40:33 -07:00
Simon Willison
52ae7d1b6d Release 1.0a36
Refs
#1983, #1996, #2783, #2806, #2809, #2811, #2812, #2813, #2815, #2818, #2819, #2822, #2823, #2827
2026-07-07 14:32:25 -07:00
Simon Willison
a31673c90b Changelog for #2811, #2815, #2783 2026-07-07 14:26:48 -07:00
Simon Willison
54597f22fa A few more SQLite string fixes, refs #2783 2026-07-07 14:26:34 -07:00
JSap0914
bf3e277c98
Fix named_parameters when string literals contain comment markers (#2783)
named_parameters stripped SQL comments before string literals in
separate passes. A string literal such as '-- TODO' would be treated
as the start of a line comment, swallowing the rest of the line and
hiding any named parameters that followed it. For example:

    select * from t where note = '-- TODO' and id = :id

returned [] instead of ['id'], so the query parameter input form
would be missing the :id field.

Match comments and string literals in a single left-to-right pass so
that whichever construct starts first wins, matching how SQL is
actually tokenized.

Co-authored-by: JSap0914 <JSap0914@users.noreply.github.com>
2026-07-07 14:23:31 -07:00
Zain Dana Harper
211e70d4e1
Return 400 not 500 for wrong-arity composite-PK row URLs (#2815)
Fixes #2811

Co-authored-by: Zain Dana Harper <zain@aurora-framework.dev>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 14:19:08 -07:00
Simon Willison
617acedd38 Remove readthedocs/actions/preview
Closes #2828
2026-07-07 14:18:10 -07:00
Simon Willison
7f37205e76 Remove Datasette Desktop from installation guide
Until I have time to fix it up and bring it back.
2026-07-07 14:04:46 -07:00
Simon Willison
a926ab392e Updated internals.rst schema using cog, refs #2827 2026-07-07 14:02:02 -07:00
Simon Willison
db7ba1d30c Switch to sqlite-utils migrations for internal.db, closes #2827 2026-07-07 13:58:48 -07:00
Simon Willison
96e8b85523 Upgrade to sqlite-utils 4.0 2026-07-07 13:57:06 -07:00
Simon Willison
6f27aa112a
Test against sqlite-utils>=4.0
https://github.com/simonw/sqlite-utils/issues/769
2026-07-07 12:03:28 -07:00
Simon Willison
d2695a0c2f
Test Datasette against sqlite-utils>=4.0rc4
Refs https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4900497417
2026-07-06 22:43:04 -07:00
Simon Willison
ebd013c6ef Bump GitHub Actions versions 2026-07-06 22:38:57 -07:00
Simon Willison
27a5be1326
Fable review of JSON API consistency and subsequent improvements
Merge PR #2824
2026-07-06 22:30:19 -07:00
Claude
b7bbde04be
Link the consistency review release note to PR #2824
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-07 05:18:54 +00:00
Claude
be25d6e3e4
Remove test_query_list_json_signals_pagination_via_next_only
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-07 05:18:54 +00:00
Claude
4a853cb10c
Use UNSTABLE_API_MESSAGE constant in tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-07 05:18:54 +00:00
Claude
57ce1a059f
Tighten unstable marker release note
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-07 01:13:33 +00:00
Claude
b83b12dd7a
Remove params input alias from the query create and update APIs
The alias existed so API payloads could mirror the params key used by
queries defined in datasette.yaml, but it was undocumented and untested,
and the create endpoint is not part of the stable API. The API now only
accepts parameters - sending params is a 400 Invalid keys error. The
documented params key for queries in configuration is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-07 01:13:33 +00:00
Claude
b23fc4ec48
Unreleased release notes for the JSON API consistency review
Documents the canonical error format, the ok/envelope changes, the
array-to-object endpoint conversions, 401s for invalid tokens, the
pagination and page-size unification, removed legacy keys and formats,
and the new Response.error(), TokenInvalid, count_truncated and
unstable-marker APIs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-07 00:32:29 +00:00
Claude
610c24d59a
/-/jump is a stable documented endpoint, not a debug exemption
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-07 00:25:09 +00:00
Claude
4874c29286
Remove the next_url extra - the key is always present
next_url became a default table JSON key alongside next, making the
extra a no-op. Requesting ?_extra=next_url now returns the standard
unknown-extra 400 error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-06 23:59:53 +00:00
Claude
8b159144a5
Add Response.error() for JSON errors in the standard format
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
2026-07-06 23:48:49 +00:00
Claude
53ccca5e15
Remove working analysis documents
existing-api.md and stable-api-recommendations.md were working
documents for the 1.0 API consistency review. Their content remains
available in this branch history; they are not intended to merge to
main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-06 23:34:17 +00:00
Claude
0d962deb05
Plain text SQL Interrupted errors in JSON responses
The SQL time limit error embedded an HTML fragment (paragraph, textarea
and script tags) as the error string in JSON responses. DatasetteError
now accepts a plain_message which the exception handler prefers for
JSON error bodies; the HTML error page keeps the rich message with the
SQL textarea.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-06 23:34:17 +00:00
Claude
60bac9439d
Mark shape=object item done in recommendations
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-06 23:21:42 +00:00
Claude
87cd695ca3
Write endpoints parse the body as JSON regardless of Content-Type
The insert, upsert, alter and set-column-type endpoints previously
required Content-Type: application/json while /-/create parsed the body
blind - and insert returned a 500 AttributeError when the header was
missing entirely. The lenient rule is now uniform: the body is always
parsed as JSON and invalid JSON is a 400. This makes curl -d and
requests data=json.dumps(...) invocations work without remembering the
header. Cross-site request forgery remains prevented by the Origin and
Sec-Fetch-Site checks in CrossOriginProtectionMiddleware, which is the
defense the strict content-type requirement was historically standing
in for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-06 23:20:47 +00:00
Claude
022eb6d3a0
Mark documented items in recommendations
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-06 23:09:27 +00:00
Claude
8154f7149f
Advise plugin authors on naming secret configuration keys
/-/config redacts values for keys whose names contain secret, key,
password, token, hash or dsn. Plugins that follow that naming get
automatic redaction; plugins that don't will leak their secrets on
that endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-06 23:09:10 +00:00
Claude
3322e1f528
Document the boolean query string argument grammar
Boolean arguments parsed by value_as_boolean() accept on/true/1 and
off/false/0 - state this once in the JSON API docs rather than leaving
each argument to imply its own grammar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-06 23:08:28 +00:00
Claude
6e17c51361
Document why upsert returns 200 where insert returns 201
An upsert may update existing rows without creating anything, so it
deliberately does not claim resource creation with a 201.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-06 23:08:05 +00:00
Claude
e892c686c2
Remove has_more from query list JSON - next: null signals the end
next: null (with next_url: null) is the single end-of-results signal
across the API, keeping default response keys to a minimum. The
StoredQueryPage.has_more attribute on the documented Python API is
unchanged.

Also fixes a bug this uncovered: the query list JSON next_url pointed
at the HTML page (it was built from the query list path, dropping the
.json extension) and was a relative path where the table view next_url
is absolute. It is now built from the request path and absolute, so it
preserves the requested format and can be followed directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-06 23:02:42 +00:00
Claude
5c418efd7f
Update tests from main for canonical error shape and rows key
Two tests merged from main were written against the pre-merge response
shapes: the max_post_body_bytes 413 error now uses the canonical error
envelope, and row update with return:true responds with a rows list
rather than a singular row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-06 22:42:12 +00:00
Claude
194ee95ae2
Merge remote-tracking branch 'origin/main' into claude/json-api-docs-1-0-review-a3e83u
# Conflicts:
#	datasette/__init__.py
#	tests/test_api_write.py
2026-07-06 22:25:27 +00:00
Claude
f4dfd6e0f7
Fix unused variable flagged by ruff
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-06 22:19:35 +00:00
Simon Willison
b6f5fd5cd0 test_internal_foreign_key_references() fix for sqlite-utils 4.0rc3
Refs https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4889529980
2026-07-05 23:04:51 -07:00
Simon Willison
9a0b78b76c Vendor setup-sqlite-version
Same solution as https://github.com/simonw/sqlite-utils/pull/775
2026-07-05 22:53:12 -07:00
Simon Willison
c833217401 Test with sqlite-utils>=4.0rc3
Refs https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4889420844
2026-07-05 22:47:05 -07:00
Simon Willison
557e08c6ef Make binary playwright test more robust
Failed here: https://github.com/simonw/datasette/actions/runs/28746832371/job/85239037361
2026-07-05 22:46:00 -07:00
Simon Willison
6d253d10c8 Compatibility with sqlite-utils>=4.0rc2
Runs the tests in CI. Had to add a little bit of code to handle
the difference between [table] and "table" and REAL v.s FLOAT.
2026-07-05 09:09:20 -07:00
Claude
3a0ea58557
Unify page-size parameters on _size with table semantics
The stored query lists silently clamped out-of-range ?_size= values
(a request for 5000 quietly returned 1000) and did not accept the max
keyword. They now share the table view semantics via a new
parse_size_limit() helper: blank means default, "max" means the
maximum (max_returned_rows for query lists), negative or non-integer
values are a 400, and values over the maximum are a 400 instead of
being silently clamped.

The /-/allowed and /-/rules debug endpoints renamed their bare
page/page_size parameters to _page/_size, matching the underscore
grammar used by every other system parameter, with the same validation
(400 instead of silently capping page_size at 200). Their HTML debug
pages and next_url/previous_url builders use the new names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 17:54:03 +00:00
Claude
404ee4c3a7
Document the JSON API stability promise
docs/json_api.rst now opens with an API stability section declaring
what the 1.x promise covers: documented endpoints, parameters and
response keys are stable with additive-only changes; pagination tokens
are opaque strings; the error format and token restriction semantics
are stable. It lists the exempt tiers: endpoints carrying the
"unstable" marker key, debug and support endpoints (/-/threads,
/-/actions, /-/jump, the permission debug endpoints, table
autocomplete), and keys explicitly labeled unstable such as the
execute-write analysis block. Cross-referenced from the introspection
and permission-debug documentation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 17:07:04 +00:00
Claude
afa7b1ba0d
Add unstable marker to undocumented JSON endpoints
JSON endpoints that are not part of the documented API now include
"unstable": "This API is not part of Datasette's stable interface and
may change at any time" in their responses, making the stability tier
machine-readable. Applied to the homepage (/.json and /-/.json),
/db/-/queries/analyze, POST /db/-/queries/store,
/db/<query>/-/definition, /db/-/query/parameters,
/db/-/execute-write/analyze and the POST /-/permissions playground
response. The message lives in datasette.utils.UNSTABLE_API_MESSAGE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 17:01:53 +00:00
Claude
5cb2bc6909
Homepage JSON returns databases as a list
/.json previously returned databases as an object keyed by database
name, unlike /-/databases.json and every other collection in the API.
It now returns a list of database objects. The HTML template already
consumed the list. This endpoint remains undocumented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 16:45:19 +00:00
Claude
13dc7a08b7
Fix foreign key label test expectations for default next_url key
Follow-up to the commit adding next_url to the default table JSON keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 16:30:04 +00:00
Claude
e5e9aca871
Require permissions-debug for /-/threads
/-/threads exposes runtime internals - thread idents and asyncio task
reprs including file paths - but only required view-instance. It now
requires permissions-debug, like /-/actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 16:22:41 +00:00
Claude
0bf3a54716
Include next_url in default table JSON keys
Table JSON responses previously only included the next pagination token
by default - the ready-to-follow next_url required ?_extra=next_url.
Both keys are now always present (null on the final page), which the
pagination documentation already claimed. The next_url extra remains
valid for backwards compatibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 16:19:03 +00:00
Claude
e0ba8b3c6a
Return 400 for unknown _extra names on data formats
Unknown ?_extra= names (including internal HTML-only extras such as
display_rows) were silently ignored, so a typo returned the default
payload with no signal. Table, row and query data formats now return
400 "Unknown _extra: <names>". HTML pages continue to ignore unknown
names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 16:16:02 +00:00
Claude
9ee95cab3d
Schema endpoints check permission before database existence
/db/-/schema previously returned 404 for missing databases before
checking view-database, letting unauthorized actors probe for database
existence. The permission check now runs first, so actors without
view-database get a uniform 403. The table schema endpoint also now
returns 404 for an unknown database instead of an unhandled KeyError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 16:11:30 +00:00
Claude
b958d03c0f
Expose count truncation in table JSON via count_truncated extra
The count extra is computed with a limit subquery, so a count equal to
count_limit + 1 (default 10001) actually means "at least this many" -
but only the HTML view knew that. A public count_truncated extra now
reports the flag and is implicitly included whenever count is
requested, using the same logic the HTML view already used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 16:09:40 +00:00
Claude
b09dceea88
Row update return:true responds with rows list, matching insert/upsert
Row update previously returned a singular "row" object where insert and
upsert return a "rows" list. All write endpoints now use "rows".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 16:07:22 +00:00
Claude
6488b7a30e
Remove duplicate params key from stored query JSON objects
Every stored-query object carried the same list of parameter names
twice, as both "params" and "parameters". Output objects now carry only
"parameters", consistent with /-/query/parameters and the two analyze
endpoints (and distinct from the "params" bound-values dictionary used
by the query extra and /-/execute-write). "params" remains an accepted
input alias for query creation, update and datasette.yaml config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 16:00:25 +00:00
Claude
f3f5e891c9
Block API deletion of trusted stored queries
QueryUpdateView already rejected is_trusted queries but QueryDeleteView
did not, so an actor with delete-query could delete a config-defined
trusted query - which would then silently reappear on restart when the
config re-syncs. Both the POST endpoint and the HTML confirmation page
now return 403, matching update. datasette.remove_query() is unchanged
for internal use.

The docs already claimed this behavior ("Trusted stored queries cannot
be edited or deleted through the web interface or the JSON API") - the
code now matches them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 15:49:49 +00:00
Claude
ea9c1b1524
Return 400 for query data formats when ?sql= is missing
GET /db/-/query.json with no (or blank) ?sql= previously returned 200
with empty rows, masking caller bugs, while the .csv format returned
400 "?sql= is required" for the same request. All data formats now
return the 400; the HTML SQL editor page is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 15:39:11 +00:00
Claude
aaaffe45b8
Return 401 for invalid or expired bearer tokens
Invalid dstok_ tokens - bad signature, malformed payload, expired, or
presented while allow_signed_tokens is off - previously degraded the
request to anonymous, so clients saw a 403 permission error or worse,
a 200 with anonymous-visible data. Token handlers can now raise
TokenInvalid for tokens they recognize but reject; Datasette responds
with 401, the canonical JSON error body and a WWW-Authenticate: Bearer
error="invalid_token" header, even when a valid cookie is also present.

Bearer tokens no registered handler recognizes are still ignored, so
authentication plugins with their own token formats keep working.
TokenInvalid is exported from the datasette package for use by plugin
token handlers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 15:18:12 +00:00
Claude
b2cdc81d34
Return 400 instead of 500 for row delete write failures
Row delete previously returned 500 when the write failed (for example
a constraint violation raised by a trigger or foreign key), while row
update and every other write endpoint report the same failure class as
400. Delete now matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 14:55:41 +00:00
Claude
e8048e023f
Return 400 for write canned-query SQL failures
POST to a write canned query previously returned HTTP 200 with
{"ok": false, "message": ...} when the SQL failed to execute, so JSON
clients (and anything that trusts HTTP status) recorded success for
failed writes. SQL failures now return 400 with the canonical error
shape plus the "redirect" context key from on_error_redirect; the
QueryWriteRejected 403 branch uses the canonical shape too. Successful
executions and the HTML flash-message flow are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 14:35:54 +00:00
Claude
ae10a99811
Return canonical JSON error for Forbidden on JSON requests
The default forbidden() hook previously rendered an HTML error page even
for .json requests. It now returns the canonical JSON error shape with
status 403 when the request path ends in .json or the request sends an
Accept: application/json or Content-Type: application/json header. HTML
requests still get the error page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 14:09:10 +00:00
Claude
f091b6dab1
Filter /-/databases by view-database permission
/-/databases previously listed every attached database (including
filesystem paths and sizes) to any actor with view-instance, while the
homepage and every other endpoint filtered by view-database. The
endpoint now only lists databases the current actor is allowed to view.

JsonDataView data callbacks may now be async.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 14:00:42 +00:00
Claude
23ccdaeffc
Convert /-/actions.json from top-level array to object
/-/actions.json now returns {"ok": true, "actions": [...]} instead of a
bare JSON array, so the response can grow additional keys without a
breaking change. The debug_actions.html template reads data.actions,
and the endpoint is now documented in docs/introspection.rst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 13:46:26 +00:00
Claude
19e54b10d4
Convert /-/databases.json from top-level array to object
/-/databases.json now returns {"ok": true, "databases": [...]} instead
of a bare JSON array, so the response can grow additional keys without
a breaking change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 13:43:44 +00:00
Claude
b74a8e5b12
Convert /-/plugins.json from top-level array to object
/-/plugins.json now returns {"ok": true, "plugins": [...]} instead of a
bare JSON array, so the response can grow additional keys without a
breaking change. The `datasette plugins` CLI command still outputs a
plain array.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 13:42:24 +00:00
Claude
089e96a437
Add "ok": true to every JSON object success response
JsonDataView now injects "ok": true into dict responses, covering
/-/versions, /-/settings, /-/config, /-/threads and /-/actor. The
homepage JSON, /-/jump, the three /-/schema endpoints, /-/allowed,
/-/rules, /-/check, POST /-/permissions and the table /-/autocomplete
endpoint set it explicitly.

The remaining top-level array endpoints (/-/plugins, /-/databases,
/-/actions) will be converted to objects in separate commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 13:40:05 +00:00
Claude
bc51c00724
Remove legacy .jsono format extension
The homepage routes now only accept .json, and the row view no longer
redirects .jsono to .json?_shape=objects. The .jsono extension was
superseded by ?_shape= and returned output identical to .json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 05:10:26 +00:00
Claude
0679e04bd3
Unify JSON error responses into one canonical shape
All JSON error responses now use a single format built by the new
datasette.utils.error_body() helper:

    {"ok": false, "error": "...", "errors": ["..."], "status": 400}

- error is all messages joined with '; ', errors is the full list,
  status always matches the HTTP status code
- The exception handler no longer emits the legacy title key in JSON
  (it is still available to the HTML error template)
- The permission debug endpoints (/-/allowed, /-/rules, /-/check,
  POST /-/permissions) no longer return bare {"error": ...} objects
- JSON renderer SQL errors keep their rows/truncated context keys but
  now include the canonical keys as well
- _shape=object misuse (queries or tables without primary keys) now
  returns HTTP 400 instead of 200 with an error body
- Method-not-allowed 405 responses use the canonical shape

Adds tests/test_error_shape.py covering all four previous shape
producers, updates affected tests, and documents the format in a new
'Error responses' section of docs/json_api.rst.

Implements section 1 of stable-api-recommendations.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 03:12:15 +00:00
Simon Willison
58c07cc264 max_post_body_bytes setting, enforced for reuest.post_body()
Closes #2823
2026-07-03 17:42:39 -07:00
Simon Willison
5bcf191e60
?_extra=column_details, binary-in-JSON mechanism, UI for setting or replacing BLOB values
Merge pull request #2822
2026-07-03 17:30:43 -07:00
Simon Willison
7ab8b644a7 Merge remote-tracking branch 'origin/main' into edit-blobs
# Conflicts:
#	docs/changelog.rst
#	tests/test_playwright.py
2026-07-03 17:25:41 -07:00
Simon Willison
ae26c3372a Use image load events for BLOB previews 2026-07-03 17:19:13 -07:00
Simon Willison
81d6ee69cd Allow declared column type case to vary 2026-07-03 17:12:26 -07:00
Simon Willison
5f05d33ef7 Run Prettier 2026-07-03 16:44:07 -07:00
Simon Willison
19dde1c860 Support BLOB values in row edit UI 2026-07-03 16:09:27 -07:00
Simon Willison
b476218edb Refine column_details metadata shape 2026-07-03 16:08:34 -07:00
Simon Willison
8856914be8 Add column_details JSON extra 2026-07-03 14:45:38 -07:00
Simon Willison
3b24c88e93 Support editing BLOBs through JSON APIs 2026-07-03 14:05:45 -07:00
Simon Willison
c9975c1fe1
Bulk row inserts / create table from data
Merge pull request #2813 from simonw/bulk-insert
2026-07-03 13:07:42 -07:00
Simon Willison
2bcabd8e1f Harden Playwright server startup wait
Allow the Datasette subprocess more time to become reachable under CI load.

If startup still times out, terminate the process and include stdout and stderr in the assertion so future failures have useful diagnostics.
2026-07-03 12:57:36 -07:00
Simon Willison
8f32a8f134 Fix bulk insert Playwright expectations
Keep copied spreadsheet templates aligned with the single-row insert columns while still previewing auto primary keys in bulk insert rows.

Treat invalid JSON during live bulk-insert validation as unfinished input so Firefox does not expose browser-specific parse errors while typing.
2026-07-03 12:47:22 -07:00
Simon Willison
b1d990ceba Wording tweak 2026-07-03 12:37:07 -07:00
Simon Willison
3d67168ee5 Docs and unreleased release notes for bulk insert/create table from data 2026-07-03 12:35:59 -07:00
Simon Willison
3aea678eab Show auto primary keys in bulk insert preview
Render omitted auto integer primary key values as muted auto text in the bulk insert preview so generated IDs are easier to understand.

Expose the auto primary key flag in insert UI metadata and cover the preview behavior in Playwright.
2026-07-03 12:27:02 -07:00
Simon Willison
c864bc866d Add bulk insert upsert controls
Expose conflict handling in the bulk insert UI when pasted rows include primary key columns, and route update-or-insert selections through the existing upsert API.

Add live validation for bulk textarea column errors and Playwright coverage for upsert permissions, conflict options, and validation behavior.

Refs shttps://github.com/simonw/datasette/pull/2813#issuecomment-4878320713
2026-07-03 10:44:36 -07:00
Simon Willison
ed6235e59a Ran cog 2026-07-03 10:30:38 -07:00
Simon Willison
141fe194c6 Hide create-from-data UI without insert permission
Expose whether the actor can insert rows in the create-table page data, then use that flag to hide and guard the create-table-from-data path. This avoids offering a flow that fails after preview for create-table-only actors.

Refs https://github.com/simonw/datasette/pull/2813#issuecomment-4878146376
2026-07-03 09:56:09 -07:00
Simon Willison
4a39e44eb6 Preserve numeric inference for blank CSV values
Convert blank delimited cells to null when create-table-from-data inferred an integer or float column. This keeps sqlite-utils from treating mixed numeric and empty-string values as text.

Add a Playwright regression covering CSV table creation with a blank numeric value.
2026-07-03 09:53:57 -07:00
Simon Willison
9c033b7ce9
Add Prettier check to lint recipe (#2821) 2026-07-03 09:50:35 -07:00
Simon Willison
4bd9d41c43 Preserve SQLite defaults in bulk insert
Omit columns that are absent from pasted bulk insert data so SQLite defaults can fire during insert. Keep preview cells blank for omitted values while preserving explicit null rendering.

Refs [https://github.com/simonw/datasette/pull/2813#issuecomment-4878063728](https://github.com/simonw/datasette/pull/2813#issuecomment-4878063728)
2026-07-03 09:45:34 -07:00
Simon Willison
2f84ab77f2
Support CREATE VIEW / DROP VIEW in execute-write-sql
New create-view and drop-view actions.

Also fix a related bug in analyze_sql_tables(): SQLite's authorizer fires a spurious SQLITE_DELETE callback against the view name when a view is dropped (the same thing it does for dropped tables), which was incorrectly surfaced as a delete-row requirement on the view. Broaden the existing drop-table-delete suppression to cover dropped views too.

Closes #2819
2026-07-02 08:56:04 -07:00
Claude
8985ecf438
Add JSON API reference and 1.0 stability review documents
- existing-api.md: complete reference for the JSON API as implemented,
  derived from source code (routes, views, renderer, permissions)
- stable-api-recommendations.md: consistency and completeness review
  with prioritized recommendations for the 1.0 stable release

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-01 22:32:28 +00:00
Simon Willison
b759ea5486 Mock navigator.clipboard so Playwright passes in all three browsers 2026-06-29 17:38:51 -07:00
Simon Willison
9ec42b2dad Improvements to create table from data 2026-06-29 16:02:56 -07:00
Simon Willison
e0a138ffbd Tweaks to bulp insert screen
Moved text around, added clickable 'open a file' button for mobile.

Screenshot: https://github.com/simonw/datasette/pull/2813#issuecomment-4837736664
2026-06-29 15:17:19 -07:00
Simon Willison
34ab85e664
Handle recursive CTEs in query analysis
Closes #2809
2026-06-29 11:36:54 -07:00
Simon Willison
cb622a3dd6 Switching to create table from data tab no longer marks as dirty
I was getting a confirm() dialog when I clicked outside the dialog on the
create table from data page despite not having filled out any fields.
2026-06-25 22:35:17 -07:00
Simon Willison
7ae601588e Create table from data feature
Similar to insert rows from JSON/CSV/TSV
but it creates a brand new table instead.
2026-06-25 22:32:47 -07:00
Simon Willison
b7a896a803 Insert multiple rows feature in the insert dialog
Works entirely using existing JSON API.

User can paste in CSV, TSV or JSON (or drop a text file on).

They get a preview of the rows to be imported, then they
are imported in max_insert_rows batches.
2026-06-25 22:02:39 -07:00
Simon Willison
d621bdfbfe Ignore ignored/ directory 2026-06-25 21:20:29 -07:00
Chris Amico
fc2922a300
Document custom json encoder (#1996)
Closes #1983
2026-06-23 16:24:03 -07:00
Simon Willison
488c9cf3d3 Small tweaks to 1.0a35 release notes prior to release 2026-06-23 14:33:20 -07:00
Simon Willison
753fa3b316 Release 1.0a35
Refs #1477, #1510, #1975, #2002, #2127, #2787, #2788, #2794, #2796, #2798, #2803
2026-06-23 14:30:08 -07:00
Simon Willison
85be50ac71 Changelog entry for #1975
Plus fixed broken rst link
2026-06-23 14:22:14 -07:00
Ritesh Kewlani
463eea2bd0
Fix _col=<pk> producing duplicate column in output (#2774)
Closes #1975
2026-06-23 14:18:49 -07:00
Simon Willison
a913ba372a Changelog note for #2002
Refs #2792
2026-06-23 14:09:31 -07:00
Sebastian Cao
a5931594de
Document how actors are displayed (#2792)
Closes #2002
2026-06-23 14:07:56 -07:00
Simon Willison
22ccd8a087 escape_sqlite() favors double quotes, closes #2795 2026-06-23 14:04:20 -07:00
Simon Willison
39f1df5997 Improve docs structure for static(), refs #2804, #2800 2026-06-23 13:59:01 -07:00
Simon Willison
5eca46a4bc
Add cache-busted static asset helper (#2804)
* Add cache-busted static asset helper

Add a static() helper for Datasette, plugin, and mounted static assets that appends content-based hashes, caches hashes in production, and serves matching hashed asset URLs with immutable far-future cache headers.

Closes #2800
2026-06-23 13:44:58 -07:00
Simon Willison
a4f74d1d2b More unreleased changes in changelog 2026-06-23 12:50:23 -07:00
Simon Willison
e3ff63b0f9 Warn plugin authors to avoid name conflicts with extra_template_vars
Closes #1988
2026-06-23 12:46:15 -07:00
Simon Willison
e0cdd38786
Documentation for template context variables
Merge pull request #2803

Closes #1510, closes #2127, closes #1477, refs #2333
2026-06-23 12:37:06 -07:00
Simon Willison
0c523dda20 Remove count truncated context test 2026-06-23 12:29:21 -07:00
Simon Willison
8276879997 Construct table context explicitly 2026-06-23 12:24:42 -07:00
Simon Willison
0d1c097396 Document database views and queries as dataclasses 2026-06-23 12:16:42 -07:00
Simon Willison
34d9a3bf33 Use dataclasses for database table context 2026-06-23 12:11:26 -07:00
Simon Willison
59ab0c0ca0 Clarify template context metadata names 2026-06-23 11:30:30 -07:00
Simon Willison
a43e76c31a Construct row template context explicitly 2026-06-23 09:08:40 -07:00
Simon Willison
cda8f7bbef Remove DataView base class 2026-06-23 09:02:11 -07:00
Simon Willison
17ec88503e Document table mutation UI context 2026-06-23 07:36:07 -07:00
Simon Willison
2680e3c4bd Refresh JSON API extra descriptions 2026-06-23 07:32:37 -07:00
Simon Willison
4ac795e20c Expand template context field documentation 2026-06-23 07:30:04 -07:00
Simon Willison
29971d9729 Clarify base template context docs 2026-06-23 07:29:14 -07:00
Simon Willison
4d031c8562 Add count_truncated template context 2026-06-22 19:47:21 -07:00
Simon Willison
49b1adba7b Merge remote-tracking branch 'origin/main' into template-context-docs
# Conflicts:
#	datasette/views/row.py
2026-06-22 18:44:37 -07:00
Simon Willison
86ea1d4722 Visual improvements to table filter UI
- Looks nicer
- Add / remove buttons work properly

Closes #2798
2026-06-22 18:20:06 -07:00
Simon Willison
f831352551 Avoid SQLite RETURNING for compat with older SQLite
Refs #2789
2026-06-22 14:24:34 -07:00
Simon Willison
f0645c6ddf
Create table, alter table - APIs and modal dialog UI
* Add create table UI

Adds a permission-gated database action that opens a create table modal on database pages, backed by the existing create-table JSON API.

The modal starts with an id integer primary key column plus a blank text column, supports SQLite type selection, and shows custom column type controls only when the actor can set column types.

Selected custom column types are applied after table creation with follow-up set-column-type API calls. Includes styling plus HTML and Playwright coverage for the action payload and create-table flow.

* Add alter table JSON API

- Add POST /<database>/<table>/-/alter with Pydantic validation and dry-run support.
- Support add, rename, alter, drop, primary-key and reorder operations, including allow-listed default expressions.
- Document the endpoint and cover schema changes, validation, permissions, events and dry runs.

Refs #2788

* Add alter table modal

- Register a built-in table action and expose alter-table metadata to table pages.
- Build the client-side modal for editing columns, defaults, ordering, primary keys, and custom column types.
- Add a review/apply confirmation flow with HTML and Playwright coverage.

Refs #2788

* Ran Prettier

* Isolate Unix domain socket test server paths

- Use a per-process socket path for the UDS test fixture.
- Clean up stale socket files before and after the fixture runs.
- Close the HTTP client and wait for the Datasette subprocess to exit.

* Precompute action permissions for table pages

- Extract reusable helpers for database and table action permission preloading.
- Precompute those permissions before building table-page HTML data.
- Document the default table actions plugin.

* Test against pyodide/v314.0.0

Now that we depend on pydantic we need a more recent
pyodide in order to load the emscripten build
of pydantic-core.

Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4733412763

* Split table create and alter views

- Move create-table and alter-table API views into table_create_alter.py.
- Keep create and alter schema-editing constants and helpers together.
- Rename the create table modal context helper.

* Add foreign keys to create table API

- Add fk_table and optional fk_column support to create-table columns.
- Validate create-table requests with Pydantic while preserving existing errors.
- Document the API and cover inferred primary-key and validation cases.
Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4733544452

* Add foreign keys to alter table API

- Add add_foreign_key, drop_foreign_key, and set_foreign_keys operations.
- Validate flat fk_table and fk_column arguments with Pydantic.
- Document the API and cover inferred primary-key and validation cases.

* /db/table/-/foreign-key-suggestions API

Improved version of the implementation datasette-edit-schema

* /<database>/-/foreign-key-targets API endpoint

Returns a list of tables with a single primary key, and for each one
the name of that primary key column and its SQLite type affinity.

This will be used by the create table UI to suggest foreign keys.

* Expose foreign key targets to create table UI

- Add foreignKeyTargetsPath to create table page data
- Filter hidden tables from database-level foreign key target results
- Update JSON API docs and tests for filtered targets

* Add foreign key controls to create table dialog

- Add create table advanced controls for foreign keys and first-column primary keys
- Share schema dialog row helpers between create and alter dialogs
- Move custom type into advanced options and add Add column icons

* More robust test_datasette_https_server.sh test

* Make custom type and foreign key mutually exclusive

In the create table dialog a column can now have either a custom display
type or a foreign key target, but not both - a foreign key column's type
is determined by the referenced primary key, so a custom type doesn't
apply. Setting one clears and disables the other, and the foreign key
select stays disabled on the primary key column and when no targets exist.

Also add "Controls how Datasette displays and edits this column" help
text (with aria-describedby) under the custom type selector in both the
create and alter dialogs, and style the alter dialog help text.

* Drop table button in alter dialog

* Object not chain of ifs

Refs https://github.com/simonw/datasette/pull/2789/changes#r3453964430

* Fix broken Playwright tests

* Keyword arguments for readability

* Ran prettier

* Removed the alter table dry run feature

It works by doing conn.backup(memory_conn) which could use
a lot of memory for a large database.

* sqlite-utils>=3.30,<4.0

So we don't get test failures from reformatted SQL.

* not_null, default and default_exr support for create table API columns

* Alter table API can now rename tables, refs #2788

Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4771774289

* Fix for Safari select box heights

Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4772241681

* Expose foreign key data to alter table UI

Include current foreign key metadata in the alter table page data and allow the foreign-key-targets endpoint to be read by actors with alter-table permission for a specific table.

Add API and HTML data tests for the new alter-table foreign key support.

* Unify create and alter table modal controls

Share default value controls between the create and alter table dialogs and expose create-table default expressions to the frontend.

Add create-table not-null/default handling and align the shared foreign key picker behavior across both dialogs.

* Add rename table controls to alter table dialog

Add a collapsed rename-table section to the alter table modal and include rename_table operations in the review/apply flow.

Redirect to the renamed table URL after applying changes and cover the review text in Playwright.

* current_unixtime and current_unixtime_ms default_expr options

Plus tweaked how alter table changing those works a bit.

* Draft changelog for create/alter table UI, refs #2787, #2788
2026-06-22 13:54:36 -07:00
Simon Willison
b3b5c25df8 Draft changelog for create/alter table UI, refs #2787, #2788 2026-06-22 13:51:32 -07:00
Simon Willison
b932d0dc78 current_unixtime and current_unixtime_ms default_expr options
Plus tweaked how alter table changing those works a bit.
2026-06-22 13:42:35 -07:00
Copilot
1717ab02f7 Replace row edit icon SVG in table.py
Closes #2796
2026-06-22 13:23:18 -07:00
Simon Willison
2ebae5ed71 Add rename table controls to alter table dialog
Add a collapsed rename-table section to the alter table modal and include rename_table operations in the review/apply flow.

Redirect to the renamed table URL after applying changes and cover the review text in Playwright.
2026-06-22 12:51:23 -07:00
Simon Willison
fa43aba309 Unify create and alter table modal controls
Share default value controls between the create and alter table dialogs and expose create-table default expressions to the frontend.

Add create-table not-null/default handling and align the shared foreign key picker behavior across both dialogs.
2026-06-22 12:50:57 -07:00
Simon Willison
063b04ad83 Expose foreign key data to alter table UI
Include current foreign key metadata in the alter table page data and allow the foreign-key-targets endpoint to be read by actors with alter-table permission for a specific table.

Add API and HTML data tests for the new alter-table foreign key support.
2026-06-22 12:47:02 -07:00
Simon Willison
dada4de172 Fix for Safari select box heights
Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4772241681
2026-06-22 12:35:34 -07:00
Simon Willison
4b219be8bd Alter table API can now rename tables, refs #2788
Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4771774289
2026-06-22 12:09:07 -07:00
Simon Willison
87354cf94e not_null, default and default_exr support for create table API columns 2026-06-22 11:04:19 -07:00
Simon Willison
a2e75967ce sqlite-utils>=3.30,<4.0
So we don't get test failures from reformatted SQL.
2026-06-22 11:03:49 -07:00
Simon Willison
084df1fba2 Removed the alter table dry run feature
It works by doing conn.backup(memory_conn) which could use
a lot of memory for a large database.
2026-06-22 10:18:01 -07:00
Simon Willison
c4aead65ee Ran prettier 2026-06-22 10:11:56 -07:00
Simon Willison
17876ccf45 Keyword arguments for readability 2026-06-22 10:11:56 -07:00
Simon Willison
b02999bca6 Fix broken Playwright tests 2026-06-22 10:11:56 -07:00
Simon Willison
a87c4ac555 Object not chain of ifs
Refs https://github.com/simonw/datasette/pull/2789/changes#r3453964430
2026-06-22 10:11:56 -07:00
Simon Willison
354780a136 Drop table button in alter dialog 2026-06-22 10:11:56 -07:00
Simon Willison
e834008075 Make custom type and foreign key mutually exclusive
In the create table dialog a column can now have either a custom display
type or a foreign key target, but not both - a foreign key column's type
is determined by the referenced primary key, so a custom type doesn't
apply. Setting one clears and disables the other, and the foreign key
select stays disabled on the primary key column and when no targets exist.

Also add "Controls how Datasette displays and edits this column" help
text (with aria-describedby) under the custom type selector in both the
create and alter dialogs, and style the alter dialog help text.
2026-06-22 10:11:56 -07:00
Simon Willison
c77dad910b More robust test_datasette_https_server.sh test 2026-06-22 10:11:56 -07:00
Simon Willison
1f863def5e Add foreign key controls to create table dialog
- Add create table advanced controls for foreign keys and first-column primary keys
- Share schema dialog row helpers between create and alter dialogs
- Move custom type into advanced options and add Add column icons
2026-06-22 10:11:56 -07:00
Simon Willison
21c156dfb1 Expose foreign key targets to create table UI
- Add foreignKeyTargetsPath to create table page data
- Filter hidden tables from database-level foreign key target results
- Update JSON API docs and tests for filtered targets
2026-06-22 10:11:56 -07:00
Simon Willison
a6ef65f90d /<database>/-/foreign-key-targets API endpoint
Returns a list of tables with a single primary key, and for each one
the name of that primary key column and its SQLite type affinity.

This will be used by the create table UI to suggest foreign keys.
2026-06-22 10:11:56 -07:00
Simon Willison
2900efb32d /db/table/-/foreign-key-suggestions API
Improved version of the implementation datasette-edit-schema
2026-06-22 10:11:56 -07:00
Simon Willison
9d9a2d3ff3 Add foreign keys to alter table API
- Add add_foreign_key, drop_foreign_key, and set_foreign_keys operations.
- Validate flat fk_table and fk_column arguments with Pydantic.
- Document the API and cover inferred primary-key and validation cases.
2026-06-22 10:11:56 -07:00
Simon Willison
9766a9c087 Add foreign keys to create table API
- Add fk_table and optional fk_column support to create-table columns.
- Validate create-table requests with Pydantic while preserving existing errors.
- Document the API and cover inferred primary-key and validation cases.
Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4733544452
2026-06-22 10:11:56 -07:00
Simon Willison
1972ba8952 Split table create and alter views
- Move create-table and alter-table API views into table_create_alter.py.
- Keep create and alter schema-editing constants and helpers together.
- Rename the create table modal context helper.
2026-06-22 10:11:56 -07:00
Simon Willison
8cec528eeb Test against pyodide/v314.0.0
Now that we depend on pydantic we need a more recent
pyodide in order to load the emscripten build
of pydantic-core.

Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4733412763
2026-06-22 10:11:56 -07:00
Simon Willison
4115213e17 Precompute action permissions for table pages
- Extract reusable helpers for database and table action permission preloading.
- Precompute those permissions before building table-page HTML data.
- Document the default table actions plugin.
2026-06-22 10:11:56 -07:00
Simon Willison
c9c79fdfc8 Isolate Unix domain socket test server paths
- Use a per-process socket path for the UDS test fixture.
- Clean up stale socket files before and after the fixture runs.
- Close the HTTP client and wait for the Datasette subprocess to exit.
2026-06-22 10:11:56 -07:00
Simon Willison
15a3ac58cc Ran Prettier 2026-06-22 10:11:56 -07:00
Simon Willison
fdd1b61a3e Add alter table modal
- Register a built-in table action and expose alter-table metadata to table pages.
- Build the client-side modal for editing columns, defaults, ordering, primary keys, and custom column types.
- Add a review/apply confirmation flow with HTML and Playwright coverage.

Refs #2788
2026-06-22 10:11:56 -07:00
Simon Willison
b40665dd14 Add alter table JSON API
- Add POST /<database>/<table>/-/alter with Pydantic validation and dry-run support.
- Support add, rename, alter, drop, primary-key and reorder operations, including allow-listed default expressions.
- Document the endpoint and cover schema changes, validation, permissions, events and dry runs.

Refs #2788
2026-06-22 10:11:56 -07:00
Simon Willison
2d3c85dfc0 Add create table UI
Adds a permission-gated database action that opens a create table modal on database pages, backed by the existing create-table JSON API.

The modal starts with an id integer primary key column plus a blank text column, supports SQLite type selection, and shows custom column type controls only when the actor can set column types.

Selected custom column types are applied after table creation with follow-up set-column-type API calls. Includes styling plus HTML and Playwright coverage for the action payload and create-table flow.
2026-06-22 10:11:56 -07:00
Simon Willison
57e7bba38f Stop matrix testing against sqlite-utils 4.0rc1
Refs https://github.com/simonw/sqlite-utils/issues/758#issuecomment-4770349630

I'm going to test this in a branch instead.
2026-06-22 09:06:00 -07:00
Simon Willison
bccb7f17e5 Don't fail test run on sqlite-utils 4.0rc1 failures
Refs https://github.com/simonw/sqlite-utils/issues/758#issuecomment-4763675905
2026-06-21 16:52:14 -07:00
Simon Willison
387f4dd4bc Try sqlite-utils 4.0rc1 in CI
Also output current sqlite-utils version in pytest headers

Refs https://github.com/simonw/sqlite-utils/issues/758
2026-06-21 16:46:20 -07:00
Simon Willison
8e01542fe9 One consistent pattern: every page context is a Context dataclass
datasette/template_contexts.py is now a thin index with no
documentation strings of its own - the docs live next to the code:

- Each page's Context class (DatabaseContext, QueryContext,
  TableContext, RowContext) carries a docstring, its template name and
  help metadata on view-added fields, in the view module itself
- extra_field() fields document themselves from the Extra classes
- The keys render_template() adds to every page are documented in
  TEMPLATE_BASE_CONTEXT in app.py, next to the code that adds them,
  with the contract tests keeping the two in sync

docs/template_context.rst is regenerated from the dataclasses, so the
table and row pages now include field types like the others.

Refs #2127

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:47:15 -07:00
Simon Willison
3cc0fc07b4 RowContext - row page now renders a documented Context dataclass
RowView declares context_class = RowContext; BaseView.render()
constructs the dataclass from the assembled context, dropping any keys
not declared on the class, after select_templates and
alternate_url_json have been added. Extras-named fields use
extra_field() so their documentation comes from the Extra classes;
view-added fields carry help metadata next to the view code.

Refs #2127

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:47:15 -07:00
Simon Willison
8b89a3aca8 TableContext - table page now renders a documented Context dataclass
The table HTML view constructs a TableContext instead of an ad-hoc
dict, matching how the database and query pages already work. Fields
resolved by registered extras are declared with extra_field() so their
documentation lives on the Extra classes in table_extras.py; fields
added by the view code carry help metadata next to the view.

render_template() now converts Context dataclasses shallowly instead
of via dataclasses.asdict(), which deep-copied every value and would
fail on values like sqlite3.Row.

Keys not declared on TableContext - extras requested with ?_extra= on
the HTML page, or extra filter context from filters_from_request
plugins - are now dropped from the HTML template context rather than
passed through undocumented.

Refs #2127

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:47:15 -07:00
Simon Willison
63995ce823 extra_field() - Context fields documented by their Extra class
A Context dataclass field declared with extra_field() takes its
documentation from the description on the registered Extra of the same
name, validated against the class's extras_scope. This keeps doc
strings next to the resolve() code instead of duplicating them on the
dataclass, ahead of introducing TableContext and RowContext.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:47:15 -07:00
Simon Willison
3ea7ed8606 Isolate test plugins from template context contract tests
Datasette instances created with plugins_dir register their plugins on
the global plugin manager for the rest of the process, so the contract
tests could see extra_template_vars keys leaked from earlier test
modules (e.g. the session-scoped ds_client fixture). A fixture now
unregisters non-default plugins implementing extra_template_vars for
the duration of each contract test and restores them afterwards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:47:15 -07:00
Simon Willison
a55ae2adfc Generated template context documentation, closes #1510
docs/template_context.rst is generated by cog from the manifest in
datasette/template_contexts.py, following the json_api_doc.py pattern.
It documents the base context available on every page plus the
database, query, table and row pages, including the stability policy
for custom template authors.

Refs #2127

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:47:15 -07:00
Simon Willison
6a1b237b39 Documented template context manifest with contract tests
datasette/template_contexts.py is the source of truth for the template
context contract: the variables custom templates can rely on for the
database, query, table and row pages, plus the base context that
render_template() adds to every page.

Documentation for each key comes from the Context dataclass field help
(database, query), the Extra class description (table and row extras)
or inline docs in the manifest (keys added by view code).

Contract tests render each page with template_debug ?_context=1 and
assert the real context keys exactly match the documented set, in both
directions - an undocumented addition or a removed documented key both
fail.

Refs #1510, #2127

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:47:15 -07:00
Simon Willison
435ff7fa88 Context.documented_fields() and extras doc-metadata enforcement
- Context dataclasses now expose documented_fields(), returning
  ContextField(name, type_name, help) for each field
- ExtraRegistry.internal_classes_for_scope() returns the Extra classes
  that are available to HTML templates but excluded from JSON
- Tests enforce that every registered Extra has a description and every
  DatabaseContext/QueryContext field has help metadata

Refs #1510, #2127

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:47:15 -07:00
242 changed files with 35895 additions and 6765 deletions

View file

@ -0,0 +1,39 @@
name: "Setup SQLite version"
description: "Build and activate a specific SQLite version from its amalgamation archive"
inputs:
version:
description: "The SQLite version to install"
required: true
cflags:
description: "CFLAGS to use when compiling SQLite"
required: false
default: ""
skip-activate:
description: "Set to true to skip modifying the library path"
required: false
default: "false"
fallback-urls:
description: "Whitespace-separated fallback download URLs to try after sqlite.org"
required: false
default: ""
outputs:
sqlite-location:
description: "Directory containing the compiled SQLite library"
value: ${{ steps.build.outputs.sqlite-location }}
runs:
using: "composite"
steps:
- shell: bash
run: mkdir -p "$RUNNER_TEMP/sqlite-versions/downloads"
- uses: actions/cache@v6
with:
path: ${{ runner.temp }}/sqlite-versions/downloads
key: setup-sqlite-version-${{ inputs.version }}-amalgamation-v1
- id: build
shell: bash
run: bash "$GITHUB_ACTION_PATH/setup-sqlite-version.sh"
env:
SQLITE_VERSION: ${{ inputs.version }}
SQLITE_CFLAGS: ${{ inputs.cflags }}
SQLITE_SKIP_ACTIVATE: ${{ inputs.skip-activate }}
SQLITE_EXTRA_FALLBACK_URLS: ${{ inputs.fallback-urls }}

View file

@ -0,0 +1,144 @@
#!/usr/bin/env bash
set -euo pipefail
version_spec="${SQLITE_VERSION:?SQLITE_VERSION is required}"
cflags="${SQLITE_CFLAGS:-}"
skip_activate="${SQLITE_SKIP_ACTIVATE:-false}"
extra_fallback_urls="${SQLITE_EXTRA_FALLBACK_URLS:-}"
case "$version_spec" in
3.46 | 3.46.0)
sqlite_version="3.46.0"
sqlite_year="2024"
amalgamation_id="3460000"
builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3460000.zip"
;;
3.25 | 3.25.0)
sqlite_version="3.25.0"
sqlite_year="2018"
amalgamation_id="3250000"
builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3250000.zip?v=1"
;;
*)
echo "::error::Unsupported SQLite version '$version_spec'. Add its release year and amalgamation id to $GITHUB_ACTION_PATH/setup-sqlite-version.sh."
exit 1
;;
esac
case "$(uname -s)" in
Linux)
library_name="libsqlite3.so.0"
library_path_var="LD_LIBRARY_PATH"
;;
Darwin)
library_name="libsqlite3.dylib"
library_path_var="DYLD_LIBRARY_PATH"
;;
*)
echo "::error::Unsupported platform $(uname -s)"
exit 1
;;
esac
runner_temp="${RUNNER_TEMP:-}"
if [ -z "$runner_temp" ]; then
runner_temp="$(mktemp -d)"
fi
filename="sqlite-amalgamation-${amalgamation_id}"
official_url="https://www.sqlite.org/${sqlite_year}/${filename}.zip"
download_dir="${runner_temp}/sqlite-versions/downloads"
source_root="${runner_temp}/sqlite-versions/source"
source_dir="${source_root}/${filename}"
build_dir="${runner_temp}/sqlite-versions/build/${sqlite_version}"
archive_path="${download_dir}/${filename}.zip"
mkdir -p "$download_dir" "$source_root" "$build_dir"
download_archive() {
local url
local candidate_path="${archive_path}.tmp"
local urls=("$official_url")
for url in $builtin_fallback_urls $extra_fallback_urls; do
urls+=("$url")
done
rm -f "$candidate_path"
for url in "${urls[@]}"; do
echo "Downloading SQLite ${sqlite_version} amalgamation from ${url}"
if curl \
--fail \
--location \
--show-error \
--retry 5 \
--retry-delay 2 \
--retry-max-time 180 \
--retry-all-errors \
--connect-timeout 20 \
--max-time 240 \
--output "$candidate_path" \
"$url"; then
mv "$candidate_path" "$archive_path"
return 0
fi
echo "::warning::Download failed from ${url}"
rm -f "$candidate_path"
done
echo "::error::Could not download SQLite ${sqlite_version} amalgamation"
return 1
}
if [ ! -f "${source_dir}/sqlite3.c" ]; then
if [ ! -f "$archive_path" ]; then
download_archive
fi
rm -rf "$source_dir"
unzip -q "$archive_path" -d "$source_root"
fi
if [ ! -f "${source_dir}/sqlite3.c" ]; then
echo "::error::Expected ${source_dir}/sqlite3.c after extracting ${archive_path}"
exit 1
fi
read -r -a cflag_args <<< "$cflags"
echo "Compiling SQLite ${sqlite_version} to ${build_dir}/${library_name}"
gcc \
-fPIC \
-shared \
"${cflag_args[@]}" \
"${source_dir}/sqlite3.c" \
"-I${source_dir}" \
-o "${build_dir}/${library_name}"
if [ "$library_name" = "libsqlite3.so.0" ]; then
ln -sf "$library_name" "${build_dir}/libsqlite3.so"
fi
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "sqlite-location=${build_dir}" >> "$GITHUB_OUTPUT"
else
echo "sqlite-location=${build_dir}"
fi
case "$(printf '%s' "$skip_activate" | tr '[:upper:]' '[:lower:]')" in
true | 1 | yes)
echo "Skipping ${library_path_var} activation"
;;
*)
existing_value="${!library_path_var:-}"
if [ -n "${GITHUB_ENV:-}" ]; then
if [ -n "$existing_value" ]; then
echo "${library_path_var}=${build_dir}:${existing_value}" >> "$GITHUB_ENV"
else
echo "${library_path_var}=${build_dir}" >> "$GITHUB_ENV"
fi
fi
echo "Added ${build_dir} to ${library_path_var}"
;;
esac

View file

@ -14,24 +14,46 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check deployment prerequisites
id: deployment-prerequisites
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}
run: |
missing=()
for variable in GCP_SA_KEY LATEST_DATASETTE_SECRET; do
if [[ -z "${!variable:-}" ]]; then
missing+=("$variable")
fi
done
if (( ${#missing[@]} )); then
echo "::notice::Skipping deployment because required environment variables are missing: ${missing[*]}"
echo "available=false" >> "$GITHUB_OUTPUT"
else
echo "available=true" >> "$GITHUB_OUTPUT"
fi
- name: Check out datasette
uses: actions/checkout@v6
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
uses: actions/checkout@v7
- name: Set up Python
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: pip
- name: Install Python dependencies
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
run: |
python -m pip install --upgrade pip
python -m pip install . --group dev
python -m pip install sphinx-to-sqlite==0.1a1
python -m pip install sphinx-to-sqlite==0.1a1 "s3-credentials>=0.17"
- name: Run tests
if: ${{ github.ref == 'refs/heads/main' }}
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' && github.ref == 'refs/heads/main' }}
run: |
pytest -n auto -m "not serial"
pytest -m "serial"
- name: Build fixtures.db and other files needed to deploy the demo
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
run: |-
python tests/fixtures.py \
fixtures.db \
@ -39,14 +61,18 @@ jobs:
fixtures-metadata.json \
plugins \
--extra-db-filename extra_database.db
# Package the config with the plugins, excluding test-only plugin secrets
# that reference temporary files outside the deployed container.
jq 'del(.plugins)' fixtures-config.json > plugins/fixtures-config.json
- name: Build docs.db
if: ${{ github.ref == 'refs/heads/main' }}
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' && github.ref == 'refs/heads/main' }}
run: |-
cd docs
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
sphinx-to-sqlite ../docs.db _build
cd ..
- name: Set up the alternate-route demo
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
run: |
echo '
from datasette import hookimpl
@ -58,6 +84,7 @@ jobs:
' > plugins/alternative_route.py
cp fixtures.db fixtures2.db
- name: And the counters writable stored query demo
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
run: |
cat > plugins/counters.py <<EOF
from datasette import hookimpl
@ -97,12 +124,15 @@ jobs:
# cat metadata.json
- id: auth
name: Authenticate to Google Cloud
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
uses: google-github-actions/auth@v3
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- name: Set up Cloud SDK
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
uses: google-github-actions/setup-gcloud@v3
- name: Deploy to Cloud Run
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' }}
env:
LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}
run: |-
@ -117,16 +147,16 @@ jobs:
--plugins-dir=plugins \
--branch=$GITHUB_SHA \
--version-note=$GITHUB_SHA \
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \
--extra-options="--config plugins/fixtures-config.json --setting template_debug 1 --setting trace_debug 1 --crossdb --root" \
--install 'datasette-ephemeral-tables>=0.2.2' \
--service "datasette-latest$SUFFIX" \
--secret $LATEST_DATASETTE_SECRET
- name: Deploy to docs as well (only for main)
if: ${{ github.ref == 'refs/heads/main' }}
- name: Upload latest documentation database to S3 (only for main)
if: ${{ steps.deployment-prerequisites.outputs.available == 'true' && github.ref == 'refs/heads/main' }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_DATASETTE_DOCS_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_DATASETTE_DOCS_SECRET_KEY }}
run: |-
# Deploy docs.db to a different service
datasette publish cloudrun docs.db \
--branch=$GITHUB_SHA \
--version-note=$GITHUB_SHA \
--extra-options="--setting template_debug 1" \
--service=datasette-docs-latest
# Keep development documentation separate from the stable release database.
s3-credentials put-object datasette-docs latest/docs.db docs.db \
--content-type application/octet-stream

View file

@ -1,16 +0,0 @@
name: Read the Docs Pull Request Preview
on:
pull_request:
types:
- opened
permissions:
pull-requests: write
jobs:
documentation-links:
runs-on: ubuntu-latest
steps:
- uses: readthedocs/actions/preview@v1
with:
project-slug: "datasette"

View file

@ -2,9 +2,15 @@ name: Playwright
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@ -16,7 +22,7 @@ jobs:
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Python 3.14
uses: actions/setup-python@v6
with:
@ -25,14 +31,14 @@ jobs:
cache: pip
cache-dependency-path: pyproject.toml
- name: Cache uv
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.cache/uv
key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-py3.14-uv-
- name: Cache Playwright browsers
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright/
key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }}

View file

@ -1,6 +1,15 @@
name: Check JavaScript for conformance with Prettier
on: [push]
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@ -10,8 +19,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Configure npm caching
with:
path: ~/.npm

View file

@ -2,7 +2,7 @@ name: Publish Python Package
on:
release:
types: [created]
types: [published]
permissions:
contents: read
@ -14,7 +14,7 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
@ -35,7 +35,7 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
@ -51,12 +51,14 @@ jobs:
- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1
# After the first non-prerelease 1.0 release, disable this job on 0.65.x,
# even for later 0.65 releases, so they cannot overwrite the 1.0 stable docs.
deploy_static_docs:
runs-on: ubuntu-latest
needs: [deploy]
if: "!github.event.release.prerelease"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
@ -66,33 +68,27 @@ jobs:
- name: Install dependencies
run: |
python -m pip install . --group dev
python -m pip install sphinx-to-sqlite==0.1a1
python -m pip install sphinx-to-sqlite==0.1a1 "s3-credentials>=0.17"
- name: Build docs.db
run: |-
cd docs
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
sphinx-to-sqlite ../docs.db _build
cd ..
- id: auth
name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v3
- name: Deploy stable-docs.datasette.io to Cloud Run
- name: Upload stable documentation database to S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_DATASETTE_DOCS_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_DATASETTE_DOCS_SECRET_KEY }}
run: |-
gcloud config set run/region us-central1
gcloud config set project datasette-222320
datasette publish cloudrun docs.db \
--service=datasette-docs-stable
s3-credentials put-object datasette-docs docs.db docs.db \
--content-type application/octet-stream
deploy_docker:
runs-on: ubuntu-latest
needs: [deploy]
if: "!github.event.release.prerelease"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Build and push to Docker Hub
env:
DOCKER_USER: ${{ secrets.DOCKER_USER }}

View file

@ -13,7 +13,7 @@ jobs:
deploy_docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Build and push to Docker Hub
env:
DOCKER_USER: ${{ secrets.DOCKER_USER }}

View file

@ -1,6 +1,15 @@
name: Check spelling in documentation
on: [push, pull_request]
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@ -9,7 +18,7 @@ jobs:
spellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
with:

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # We need all commits to find docs/ changes
- name: Set up Git user

View file

@ -1,40 +0,0 @@
name: Calculate test coverage
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out datasette
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: '**/pyproject.toml'
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
python -m pip install . --group dev
python -m pip install pytest-cov
- name: Run tests
run: |-
ls -lah
cat .coveragerc
pytest -m "not serial" --cov=datasette --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term -x
ls -lah
- name: Upload coverage report
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: coverage.xml

View file

@ -2,9 +2,15 @@ name: Test in Pyodide with shot-scraper
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@ -12,7 +18,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
with:
@ -20,7 +26,7 @@ jobs:
cache: 'pip'
cache-dependency-path: '**/pyproject.toml'
- name: Cache Playwright browsers
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright/
key: ${{ runner.os }}-browsers

View file

@ -1,6 +1,15 @@
name: Test SQLite versions
on: [push, pull_request]
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@ -12,10 +21,10 @@ jobs:
strategy:
matrix:
platform: [ubuntu-latest]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.13"]
sqlite-version: [
#"3", # latest version
"3.46",
#"3.46",
#"3.45",
#"3.27",
#"3.26",
@ -25,7 +34,7 @@ jobs:
#"3.23.1" # 2018-04-10, before UPSERT
]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
@ -34,7 +43,7 @@ jobs:
cache: pip
cache-dependency-path: pyproject.toml
- name: Set up SQLite ${{ matrix.sqlite-version }}
uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
uses: ./.github/actions/setup-sqlite-version
with:
version: ${{ matrix.sqlite-version }}
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"

View file

@ -1,6 +1,15 @@
name: Test
on: [push, pull_request]
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@ -9,17 +18,22 @@ jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
include:
- python-version: "3.14"
coverage: true
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
cache: pip
cache-dependency-path: pyproject.toml
check-latest: true
- name: Build extension for --load-extension test
run: |-
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
@ -27,12 +41,27 @@ jobs:
run: |
pip install . --group dev
pip freeze
- name: Install pytest-cov
if: ${{ matrix.coverage }}
run: pip install pytest-cov
- name: Run tests
run: |
pytest -n auto -m "not serial"
pytest -m "serial"
if [ "${{ matrix.coverage }}" = "true" ]; then
COV="--cov=datasette --cov-config=.coveragerc"
pytest -n auto -m "not serial" $COV --cov-report=
pytest -m "serial" $COV --cov-append --cov-report xml:coverage.xml --cov-report term
else
pytest -n auto -m "not serial"
pytest -m "serial"
fi
# And the test that exceeds a localhost HTTPS server
tests/test_datasette_https_server.sh
- name: Upload coverage report
if: ${{ matrix.coverage }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.xml
- name: Black
run: |
black --version

View file

@ -10,6 +10,6 @@ jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup tmate session
uses: mxschmitt/action-tmate@v3

View file

@ -11,7 +11,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup tmate session
uses: mxschmitt/action-tmate@v3
env:

2
.gitignore vendored
View file

@ -5,6 +5,8 @@ datasets.json
scratchpad
ignored/
.vscode
uv.lock

View file

@ -1,4 +1,4 @@
FROM python:3.11.0-slim-bullseye as build
FROM python:3.11-slim-bookworm AS build
# Version of Datasette to install, e.g. 0.55
# docker build . -t datasette --build-arg VERSION=0.55

View file

@ -33,10 +33,11 @@ export DATASETTE_SECRET := "not_a_secret"
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
# Run linters: black, ruff, cog
# Run linters: black, ruff, prettier, cog
@lint: codespell
uv run black datasette tests --check
uv run ruff check datasette tests
npm run prettier -- --check
uv run cog --check README.md docs/*.rst
# Apply ruff fixes
@ -48,13 +49,18 @@ export DATASETTE_SECRET := "not_a_secret"
uv run cog -r README.md docs/*.rst
# Serve live docs on localhost:8000
@docs: cog blacken-docs
@docs: shots cog blacken-docs
uv run make -C docs livehtml
# Build docs as static HTML
@docs-build: cog blacken-docs
rm -rf docs/_build && cd docs && uv run make html
# Take any missing documentation screenshots defined in docs/shots.yml
@shots:
uv run --group shots shot-scraper install
cd docs && uv run --group shots shot-scraper multi shots.yml --no-clobber --reduced-motion --retina
# Apply Black
@black:
uv run black datasette tests

View file

@ -36,7 +36,7 @@ You can also install it using `pip` or `pipx`:
pip install datasette
Datasette requires Python 3.8 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker.
Datasette requires Python 3.10 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker.
## Basic usage

View file

@ -1,8 +1,15 @@
from datasette.permissions import Permission # noqa
from datasette.version import __version_info__, __version__ # noqa
from datasette.events import Event # noqa
from datasette.tokens import TokenHandler, TokenRestrictions # noqa
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
from datasette.background_tasks import BackgroundTask, BackgroundTaskSupervisor # noqa
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
from datasette.utils.asgi import ( # noqa
Forbidden,
NotFound,
PayloadTooLarge,
Request,
Response,
)
from datasette.utils import actor_matches_allow # noqa
from datasette.views import Context # noqa
from .hookspecs import hookimpl # noqa

View file

@ -89,7 +89,8 @@ def pytest_runtest_protocol(item, nextitem):
continue
try:
ds.close()
except Exception as e:
except Exception as e: # noqa: BLE001
# Surfaced as a pytest warning; teardown must not fail the run
item.warn(
pytest.PytestUnraisableExceptionWarning(
f"Error closing Datasette instance: {e!r}"

View file

@ -1,8 +1,10 @@
from datasette import hookimpl
from itsdangerous import BadSignature
from datasette.utils import baseconv
import time
from itsdangerous import BadSignature
from datasette import hookimpl
from datasette.utils import baseconv
@hookimpl
def actor_from_request(datasette, request):

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,227 @@
"""
Supervised background-task registration for Datasette core.
Plugins that need long-lived background work (a polling loop, a queue
consumer, a scheduled job runner) register it with
``datasette.add_background_task(func, name=None)`` - typically from a
``startup`` plugin hook - instead of fire-and-forgetting their own
``asyncio.create_task()``. Core owns:
- **references**: every launched ``asyncio.Task`` is kept alive on a
:class:`BackgroundTaskSupervisor`, so it can never be silently garbage
collected the way an unreferenced ``create_task()`` call can be;
- **launch timing**: registered work is buffered until
:meth:`BackgroundTaskSupervisor.launch_all` runs, which core arranges to
happen only after *every* plugin's ``startup`` hook has finished - so
a task that depends on another plugin having registered something first
doesn't need ``tryfirst=True`` ordering tricks;
- **crash surfacing**: an unhandled exception in a background task is
logged with its full traceback to the ``datasette.background_tasks``
logger and recorded on the handle, instead of becoming an "Task
exception was never retrieved" warning nobody sees;
- **cancellation**: :meth:`BackgroundTaskSupervisor.cancel_all` cancels
every task still running and waits (with a grace period) for them to
actually stop.
"""
from __future__ import annotations
import asyncio
import datetime
import functools
import logging
from collections.abc import Awaitable, Callable
logger = logging.getLogger("datasette.background_tasks")
def _utcnow_iso() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def _function_path(func: Callable) -> str:
"""Describe the callable without guessing which plugin registered it."""
while isinstance(func, functools.partial):
func = func.func
if not hasattr(func, "__qualname__"):
func = type(func).__call__
return f"{func.__module__}.{func.__qualname__}"
class BackgroundTask:
"""A handle to a single piece of supervised background work.
States: ``registered`` (added but not yet launched) -> ``running`` ->
one of ``completed`` (returned cleanly), ``crashed`` (raised an
exception other than ``CancelledError`` - see ``.exception``), or
``cancelled`` (``.cancel()`` was called, or it was still running at
shutdown).
"""
def __init__(
self,
name: str,
func: Callable[[object], Awaitable[None]],
):
self.name = name
self.state = "registered"
self.task: asyncio.Task | None = None
self.exception: BaseException | None = None
self.started_at: str | None = None
self.function = _function_path(func)
self._func = func
self._supervisor: BackgroundTaskSupervisor | None = None
def cancel(self) -> None:
"""Cancel this task.
If it has already been launched, cancels the underlying
``asyncio.Task`` - its state becomes ``cancelled`` once the
cancellation is observed (asynchronously, via the task's done
callback). If it has not been launched yet, this is a no-op as
far as asyncio is concerned (there's no task to cancel) but it
deregisters the handle from its supervisor so it never runs.
"""
if self.task is not None:
self.task.cancel()
elif self._supervisor is not None:
self._supervisor._deregister(self)
def __repr__(self) -> str:
return f"<BackgroundTask name={self.name!r} state={self.state!r}>"
class BackgroundTaskSupervisor:
"""Owns registration and launch of every :class:`BackgroundTask` for a
single ``Datasette`` instance.
Registration (:meth:`add`) is separate from launch
(:meth:`launch_all`): plugins register work whenever convenient
(typically from a ``startup`` hook, but request handlers can register
dynamic per-job work too), and it either sits buffered until
:meth:`launch_all` runs, or - if :meth:`launch_all` has already run -
starts immediately.
Strong references to every :class:`BackgroundTask` (and its
``asyncio.Task``) are kept for the life of the instance, by design -
that's what makes the enrichments-style "fire-and-forget task gets
garbage collected mid-flight" bug impossible here. There is currently
no pruning of completed/crashed/cancelled tasks, so a plugin that
dynamically registers many short-lived tasks over a long process
lifetime (a per-job registration pattern, e.g. one task per queued
job) will grow this list without bound. That's an accepted v1
trade-off in favour of full introspection (``/-/tasks``); revisit
with a pruning or capping policy if unbounded growth is reported in
practice.
"""
def __init__(self, datasette):
self._datasette = datasette
self._tasks: list[BackgroundTask] = []
self._names = set()
self._launched = False
self._lock = asyncio.Lock()
def add(self, func, name=None) -> BackgroundTask:
base_name = name or getattr(func, "__qualname__", None) or repr(func)
actual_name = self._unique_name(base_name)
handle = BackgroundTask(actual_name, func)
handle._supervisor = self
self._tasks.append(handle)
self._names.add(actual_name)
if self._launched:
self._launch_one(handle)
return handle
def _unique_name(self, base_name: str) -> str:
if base_name not in self._names:
return base_name
n = 2
while f"{base_name}-{n}" in self._names:
n += 1
return f"{base_name}-{n}"
def _deregister(self, handle: BackgroundTask) -> None:
try:
self._tasks.remove(handle)
except ValueError:
pass
self._names.discard(handle.name)
def _launch_one(self, handle: BackgroundTask) -> None:
handle.state = "running"
handle.started_at = _utcnow_iso()
handle.task = asyncio.create_task(
handle._func(self._datasette), name=handle.name
)
handle.task.add_done_callback(functools.partial(_on_task_done, handle))
async def launch_all(self) -> None:
"""Launch every currently-registered task that hasn't launched
yet. Idempotent and safe to call concurrently: subsequent (or
racing) calls are no-ops once the first has set ``self._launched``.
"""
if self._launched:
return
async with self._lock:
if self._launched:
return
self._launched = True
for handle in list(self._tasks):
if handle.task is None:
self._launch_one(handle)
async def cancel_all(self, grace: float = 5.0) -> None:
"""Cancel every task that isn't already done, then wait up to
``grace`` seconds for them to actually finish. Stragglers still
running after that are logged by name (but left to finish or not
on their own - this does not forcibly kill them, asyncio has no
mechanism for that).
"""
handles_by_task = {
handle.task: handle for handle in self._tasks if handle.task is not None
}
pending = [task for task in handles_by_task if not task.done()]
for task in pending:
task.cancel()
if not pending:
return
_done, not_done = await asyncio.wait(pending, timeout=grace)
if not_done:
names = sorted(handles_by_task[task].name for task in not_done)
logger.warning(
"%d background task(s) did not finish within the %.1fs grace "
"period after cancellation: %s",
len(names),
grace,
", ".join(names),
)
def tasks(self) -> list[BackgroundTask]:
"""Return every registered :class:`BackgroundTask`, launched or
not, in registration order. Used by the ``/-/tasks`` debug
endpoint.
"""
return list(self._tasks)
@property
def launched(self) -> bool:
"""Whether :meth:`launch_all` has run yet - lets ``/-/tasks``
distinguish "no tasks registered" from "tasks registered but
nothing has armed the launch yet" without reaching for the
private ``_launched`` attribute.
"""
return self._launched
def _on_task_done(handle: BackgroundTask, task: asyncio.Task) -> None:
if task.cancelled():
handle.state = "cancelled"
return
exc = task.exception()
if exc is not None:
handle.state = "crashed"
handle.exception = exc
logger.error("Background task %r crashed", handle.name, exc_info=exc)
return
handle.state = "completed"

View file

@ -1,8 +1,9 @@
from datasette import hookimpl
from datasette.utils.asgi import Response, BadRequest
from datasette.utils import to_css_class
import hashlib
from datasette import hookimpl
from datasette.utils import to_css_class
from datasette.utils.asgi import BadRequest, Response
_BLOB_COLUMN = "_blob_column"
_BLOB_HASH = "_blob_hash"

View file

@ -1,43 +1,45 @@
import asyncio
import uvicorn
import click
from click import formatting
from click.types import CompositeParamType
from click_default_group import DefaultGroup
import functools
import json
import os
import pathlib
from runpy import run_module
import shutil
from subprocess import call
import sys
import textwrap
import webbrowser
from runpy import run_module
from subprocess import call
import click
import uvicorn
from click import formatting
from click.types import CompositeParamType
from click_default_group import DefaultGroup
from .app import (
Datasette,
DEFAULT_SETTINGS,
SETTINGS,
SQLITE_LIMIT_ATTACHED,
Datasette,
pm,
)
from .inspect import inspect_tables
from .utils import (
ConnectionProblem,
LoadExtension,
SpatialiteConnectionProblem,
SpatialiteNotFound,
StartupError,
StaticMount,
ValueAsBooleanError,
check_connection,
deep_dict_update,
find_spatialite,
parse_metadata,
ConnectionProblem,
SpatialiteConnectionProblem,
initial_path_for_datasette,
pairs_to_nested_config,
parse_metadata,
temporary_docker_directory,
value_as_boolean,
SpatialiteNotFound,
StaticMount,
ValueAsBooleanError,
)
from .utils.sqlite import sqlite3
from .utils.testing import TestClient
@ -75,7 +77,7 @@ class Setting(CompositeParamType):
# Datasette 1.0, we turn bare setting names into setting.name
# Type checking for those older settings
default = DEFAULT_SETTINGS[name]
name = "settings.{}".format(name)
name = f"settings.{name}"
if isinstance(default, bool):
try:
return name, "true" if value_as_boolean(value) else "false"
@ -155,7 +157,11 @@ async def inspect_(files, sqlite_extensions):
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
data = {}
for name, database in app.databases.items():
tables = await database.execute_fn(lambda conn: inspect_tables(conn, {}))
def _inspect_tables(conn):
return inspect_tables(conn, {})
tables = await database.execute_fn(_inspect_tables)
data[name] = {
"hash": database.hash,
"size": database.size,
@ -171,7 +177,6 @@ async def inspect_(files, sqlite_extensions):
@cli.group()
def publish():
"""Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"""
pass
# Register publish plugins
@ -496,6 +501,7 @@ def uninstall(packages, yes):
"--internal",
type=click.Path(),
help="Path to a persistent Datasette internal SQLite database",
envvar="DATASETTE_INTERNAL",
)
def serve(
files,
@ -578,27 +584,27 @@ def serve(
# https://github.com/simonw/datasette/issues/2389
deep_dict_update(config_data, settings_updates)
kwargs = dict(
immutables=immutable,
cache_headers=not reload,
cors=cors,
inspect_data=inspect_data,
config=config_data,
metadata=metadata_data,
sqlite_extensions=sqlite_extensions,
template_dir=template_dir,
plugins_dir=plugins_dir,
static_mounts=static,
settings=None, # These are passed in config= now
memory=memory,
secret=secret,
version_note=version_note,
pdb=pdb,
crossdb=crossdb,
nolock=nolock,
internal=internal,
default_deny=default_deny,
)
kwargs = {
"immutables": immutable,
"cache_headers": not reload,
"cors": cors,
"inspect_data": inspect_data,
"config": config_data,
"metadata": metadata_data,
"sqlite_extensions": sqlite_extensions,
"template_dir": template_dir,
"plugins_dir": plugins_dir,
"static_mounts": static,
"settings": None, # These are passed in config= now
"memory": memory,
"secret": secret,
"version_note": version_note,
"pdb": pdb,
"crossdb": crossdb,
"nolock": nolock,
"internal": internal,
"default_deny": default_deny,
}
# Separate directories from files
directories = [f for f in files if os.path.isdir(f)]
@ -621,9 +627,7 @@ def serve(
conn.close()
else:
raise click.ClickException(
"Invalid value for '[FILES]...': Path '{}' does not exist.".format(
file
)
f"Invalid value for '[FILES]...': Path '{file}' does not exist."
)
# Check for duplicate files by resolving all paths to their absolute forms
@ -664,16 +668,6 @@ def serve(
# Private utility mechanism for writing unit tests
return ds
# Run async soundness checks before startup hooks, since invoke_startup
# now populates internal tables which requires querying each database
run_sync(lambda: check_databases(ds))
# Run the "startup" plugin hooks
try:
run_sync(ds.invoke_startup)
except StartupError as e:
raise click.ClickException(e.args[0])
if headers and not get:
raise click.ClickException("--headers can only be used with --get")
@ -681,10 +675,23 @@ def serve(
raise click.ClickException("--token can only be used with --get")
if get:
# --get means we don't run Uvicorn at all
run_sync(lambda: check_databases(ds))
try:
run_sync(ds.invoke_startup)
except StartupError as e:
raise click.ClickException(e.args[0])
# --get never launches background tasks: TestClient's request below
# flows through the full ASGI stack, including the
# AsgiRunOnFirstRequest fallback, which would otherwise launch them.
ds._suppress_background_tasks = True
client = TestClient(ds)
request_headers = {}
if token:
request_headers["Authorization"] = "Bearer {}".format(token)
request_headers["Authorization"] = f"Bearer {token}"
cookies = {}
if actor:
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
@ -705,30 +712,54 @@ def serve(
sys.exit(exit_code)
return
# Start the server
url = None
if root:
ds.root_enabled = True
url = "http://{}:{}{}?token={}".format(
host, port, ds.urls.path("-/auth-token"), ds._root_token
)
click.echo(url)
if open_browser:
if url is None:
# Figure out most convenient URL - to table, database or homepage
path = run_sync(lambda: initial_path_for_datasette(ds))
url = f"http://{host}:{port}{path}"
webbrowser.open(url)
uvicorn_kwargs = dict(
host=host, port=port, log_level="info", lifespan="on", workers=1
)
if uds:
uvicorn_kwargs["uds"] = uds
if ssl_keyfile:
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
if ssl_certfile:
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
uvicorn.run(ds.app(), **uvicorn_kwargs)
# check_databases, invoke_startup() and the uvicorn server all run on a
# single event loop, so that anything a plugin's "startup" hook schedules
# on the loop (asyncio.create_task, Lock/Queue/Event objects, ...) is
# still alive when the server starts handling requests.
async def _serve_async():
# Populate internal catalog tables before invoke_startup
await check_databases(ds)
# Run the full startup sequence (immutable-database table-count
# precompute + the "startup" plugin hooks) via the same entry point
# AsgiLifespan/AsgiRunOnFirstRequest use, so it's not skipped when
# uvicorn's lifespan.startup fires moments later.
try:
await ds._startup_sequence()
except StartupError as e:
raise click.ClickException(e.args[0])
# Start the server
url = None
if root:
ds.root_enabled = True
url = "http://{}:{}{}?token={}".format(
host, port, ds.urls.path("-/auth-token"), ds._root_token
)
click.echo(url)
if open_browser:
if url is None:
# Figure out most convenient URL - to table, database or homepage
path = await initial_path_for_datasette(ds)
url = f"http://{host}:{port}{path}"
webbrowser.open(url)
uvicorn_kwargs = {
"host": host,
"port": port,
"log_level": "info",
"lifespan": "on",
"workers": 1,
}
if uds:
uvicorn_kwargs["uds"] = uds
if ssl_keyfile:
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
if ssl_certfile:
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
server = uvicorn.Server(uvicorn.Config(ds.app(), **uvicorn_kwargs))
await server.serve()
asyncio.run(_serve_async())
@cli.command()
@ -885,7 +916,7 @@ async def check_databases(ds):
)
except ConnectionProblem as e:
raise click.UsageError(
f"Connection to {database.path} failed check: {str(e.args[0])}"
f"Connection to {database.path} failed check: {e.args[0]!s}"
)
# If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning
if (
@ -893,9 +924,5 @@ async def check_databases(ds):
and len([db for db in ds.databases.values() if not db.is_memory])
> SQLITE_LIMIT_ATTACHED
):
msg = (
"Warning: --crossdb only works with the first {} attached databases".format(
SQLITE_LIMIT_ATTACHED
)
)
msg = f"Warning: --crossdb only works with the first {SQLITE_LIMIT_ATTACHED} attached databases"
click.echo(click.style(msg, bold=True, fg="yellow"), err=True)

View file

@ -64,14 +64,14 @@ class ColumnType:
Return an HTML string to render this cell value, or None to
fall through to the default render_cell plugin hook chain.
"""
return None
return
async def validate(self, value, datasette):
"""
Validate a value before it is written. Return None if valid,
or a string error message if invalid.
"""
return None
return
async def transform_value(self, value, datasette):
"""

View file

@ -40,12 +40,12 @@ def _origin_tuple(value):
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
if not scheme or not host:
raise ValueError("missing scheme or host in {!r}".format(value))
raise ValueError(f"missing scheme or host in {value!r}")
port = parsed.port # may raise ValueError on bad ports
if port is None:
port = DEFAULT_PORTS.get(scheme)
if port is None:
raise ValueError("unknown default port for scheme {!r}".format(scheme))
raise ValueError(f"unknown default port for scheme {scheme!r}")
return scheme, host, port
@ -125,9 +125,7 @@ class CrossOriginProtectionMiddleware:
return
await self._forbid(
send,
"Sec-Fetch-Site was {!r}, expected 'same-origin' or 'none'".format(
sec_fetch_site
),
f"Sec-Fetch-Site was {sec_fetch_site!r}, expected 'same-origin' or 'none'",
)
return
@ -141,11 +139,11 @@ class CrossOriginProtectionMiddleware:
request_scheme = self._request_scheme(scope)
try:
origin_tuple = _origin_tuple(origin)
expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
expected_tuple = _origin_tuple(f"{request_scheme}://{host}")
except ValueError:
await self._forbid(
send,
"Malformed Origin {!r} or Host {!r}".format(origin, host),
f"Malformed Origin {origin!r} or Host {host!r}",
)
return
@ -155,7 +153,7 @@ class CrossOriginProtectionMiddleware:
await self._forbid(
send,
"Origin {!r} does not match Host {!r}".format(origin, host),
f"Origin {origin!r} does not match Host {host!r}",
)
def _request_scheme(self, scope):
@ -163,7 +161,8 @@ class CrossOriginProtectionMiddleware:
try:
if self.datasette.setting("force_https_urls"):
return "https"
except Exception:
except Exception: # noqa: BLE001, S110
# Settings may not be readable this early; fall back to the ASGI scheme
pass
return scope.get("scheme") or "http"

View file

@ -1,33 +1,71 @@
import asyncio
import atexit
from collections import namedtuple
import contextvars
import inspect
import os
from pathlib import Path
import queue
import sqlite_utils
import sys
import tempfile
import threading
import time
import uuid
from collections import namedtuple
from pathlib import Path
import sqlite_utils
from opentelemetry import context as otel_context_api
from opentelemetry.trace import Status, StatusCode
from .inspect import inspect_hash
from .telemetry import (
callback_name,
linked_root_span_kwargs,
record_operation_duration,
record_query_interrupted,
record_write_queue_wait,
sql_attribute,
sql_operation_name,
tracer,
)
from .telemetry_registry import (
CALLBACK,
DB_NAMESPACE,
DB_OPERATION_NAME,
DB_QUERY,
DB_QUERY_EXECUTE,
DB_QUERY_TEXT,
DB_SYSTEM,
DB_WRITE_EXECUTE,
DB_WRITE_QUEUE_WAIT,
EXECUTEMANY,
EXECUTESCRIPT,
INTERRUPTED,
ISOLATED_CONNECTION,
PARAM_COUNT,
PARAM_SETS,
ROWS_RETURNED,
SQL_ERROR_SUPPRESSED,
TIME_LIMIT_MS,
TRANSACTION,
TRUNCATED,
)
from .tracer import trace
from .utils import (
call_with_supported_arguments,
detect_fts,
detect_primary_keys,
detect_spatialite,
escape_sqlite,
get_all_foreign_keys,
get_outbound_foreign_keys,
md5_not_usedforsecurity,
sqlite_timelimit,
sqlite3,
table_columns,
sqlite_timelimit,
table_column_details,
table_columns,
)
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
from .utils.sqlite import sqlite_hidden_table_names
from .inspect import inspect_hash
from .utils.sqlite import sqlite_derived_table_dependencies, sqlite_hidden_table_names
connections = threading.local()
@ -83,6 +121,7 @@ class Database:
self.cached_hash = None
self.cached_size = None
self._cached_table_counts = None
self._cached_derived_table_dependencies = None
self._write_thread = None
self._write_queue = None
self._closed = False
@ -91,16 +130,15 @@ class Database:
# These are used when in non-threaded mode:
self._read_connection = None
self._write_connection = None
# This is used to track all file connections so they can be closed
self._all_file_connections = []
# Track file and memory connections, including reads on worker threads,
# so close() can release all of them from the calling thread.
self._all_connections = []
if not is_temp_disk:
self.mode = mode
def _check_not_closed(self):
if self._closed:
raise DatasetteClosedError(
"Database {!r} has been closed".format(self.name)
)
raise DatasetteClosedError(f"Database {self.name!r} has been closed")
def _remove_pending_execute_future(self, future):
with self._pending_execute_futures_lock:
@ -139,15 +177,18 @@ class Database:
if write:
extra_kwargs["isolation_level"] = "IMMEDIATE"
if self.memory_name:
uri = "file:{}?mode=memory&cache=shared".format(self.memory_name)
uri = f"file:{self.memory_name}?mode=memory&cache=shared"
conn = sqlite3.connect(
uri, uri=True, check_same_thread=False, **extra_kwargs
)
if not write:
conn.execute("PRAGMA query_only=1")
self._all_connections.append(conn)
return conn
if self.is_memory:
return sqlite3.connect(":memory:", uri=True)
conn = sqlite3.connect(":memory:", uri=True, check_same_thread=False)
self._all_connections.append(conn)
return conn
# mode=ro or immutable=1?
if self.is_mutable:
@ -164,7 +205,7 @@ class Database:
conn = sqlite3.connect(
f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs
)
self._all_file_connections.append(conn)
self._all_connections.append(conn)
if self.is_temp_disk and not self._wal_enabled:
conn.execute("PRAGMA journal_mode=WAL")
self._wal_enabled = True
@ -192,23 +233,22 @@ class Database:
write_thread.join(timeout=10)
if write_thread.is_alive():
sys.stderr.write(
"Datasette: write thread for {!r} did not exit within 10s\n".format(
self.name
)
f"Datasette: write thread for {self.name!r} did not exit within 10s\n"
)
sys.stderr.flush()
for future in pending_execute_futures:
try:
future.result()
except Exception:
except Exception: # noqa: BLE001, S110
# Shutdown teardown - a failed pending write must not block close()
pass
# Close anything still tracked in _all_file_connections
for connection in self._all_file_connections:
# Close anything still tracked in _all_connections
for connection in self._all_connections:
try:
connection.close()
except Exception:
except Exception: # noqa: BLE001, S110
pass
self._all_file_connections = []
self._all_connections = []
# Drop per-thread cached read connections we can reach
try:
delattr(connections, self._thread_local_id)
@ -218,13 +258,13 @@ class Database:
if self._read_connection is not None:
try:
self._read_connection.close()
except Exception:
except Exception: # noqa: BLE001, S110
pass
self._read_connection = None
if self._write_connection is not None:
try:
self._write_connection.close()
except Exception:
except Exception: # noqa: BLE001, S110
pass
self._write_connection = None
if self.is_temp_disk:
@ -246,19 +286,46 @@ class Database:
request=None,
return_all=False,
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
transaction=True,
time_limit_ms=2000,
):
self._check_not_closed()
if returning_limit < 0:
raise ValueError("returning_limit must be >= 0")
def _inner(conn):
def execute_sql(conn):
cursor = conn.execute(sql, params or [])
return ExecuteWriteResult.from_cursor(
cursor, return_all=return_all, returning_limit=returning_limit
)
with trace("sql", database=self.name, sql=sql.strip(), params=params):
results = await self.execute_write_fn(_inner, block=block, request=request)
def _inner(conn):
try:
if time_limit_ms is None:
return execute_sql(conn)
with sqlite_timelimit(conn, time_limit_ms):
return execute_sql(conn)
except (sqlite3.OperationalError, sqlite3.DatabaseError) as e:
if e.args == ("interrupted",):
raise QueryInterrupted(e, sql, params)
raise
with trace( # noqa: SIM117
"sql", database=self.name, sql=sql.strip(), params=params
):
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
span.set_attribute(DB_SYSTEM, "sqlite")
span.set_attribute(DB_NAMESPACE, self.name)
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
operation_name = sql_operation_name(sql)
if operation_name:
span.set_attribute(DB_OPERATION_NAME, operation_name)
if params:
span.set_attribute(PARAM_COUNT, len(params))
with record_operation_duration(self.name, "write"):
results = await self._execute_write_fn(
_inner, block=block, request=request, transaction=transaction
)
return results
async def execute_write_script(self, sql, block=True, request=None):
@ -267,10 +334,19 @@ class Database:
def _inner(conn):
return conn.executescript(sql)
with trace("sql", database=self.name, sql=sql.strip(), executescript=True):
results = await self.execute_write_fn(
_inner, block=block, transaction=False, request=request
)
with trace( # noqa: SIM117
"sql", database=self.name, sql=sql.strip(), executescript=True
):
# No db.operation.name, since the script can contain multiple statements
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
span.set_attribute(DB_SYSTEM, "sqlite")
span.set_attribute(DB_NAMESPACE, self.name)
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
span.set_attribute(EXECUTESCRIPT, True)
with record_operation_duration(self.name, "write"):
results = await self._execute_write_fn(
_inner, block=block, transaction=False, request=request
)
return results
async def execute_write_many(self, sql, params_seq, block=True, request=None):
@ -290,9 +366,19 @@ class Database:
with trace(
"sql", database=self.name, sql=sql.strip(), executemany=True
) as kwargs:
results, count = await self.execute_write_fn(
_inner, block=block, request=request
)
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
span.set_attribute(DB_SYSTEM, "sqlite")
span.set_attribute(DB_NAMESPACE, self.name)
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
span.set_attribute(EXECUTEMANY, True)
operation_name = sql_operation_name(sql)
if operation_name:
span.set_attribute(DB_OPERATION_NAME, operation_name)
with record_operation_duration(self.name, "write"):
results, count = await self._execute_write_fn(
_inner, block=block, request=request
)
span.set_attribute(PARAM_SETS, count)
kwargs["count"] = count
return results
@ -309,31 +395,58 @@ class Database:
finally:
isolated_connection.close()
try:
self._all_file_connections.remove(isolated_connection)
self._all_connections.remove(isolated_connection)
except ValueError:
# Was probably a memory connection
# May already have been cleared by close().
pass
if self.ds.executor is None:
# non-threaded mode
return _run()
if not write:
# Immutable database - no writes can ever occur, so there is no
# write queue to block; run against a fresh read-only connection
return await asyncio.get_running_loop().run_in_executor(
self.ds.executor, _run
)
# Threaded mode - send to write thread
return await self._send_to_write_thread(fn, isolated_connection=True)
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
span.set_attribute(DB_SYSTEM, "sqlite")
span.set_attribute(DB_NAMESPACE, self.name)
span.set_attribute(CALLBACK, callback_name(fn))
# Immutable databases run this on the read pool, not the write queue
with record_operation_duration(self.name, "write" if write else "read"):
if self.ds.executor is None:
# non-threaded mode
return _run()
if not write:
# Immutable database - no writes can ever occur, so there
# is no write queue to block; run against a fresh
# read-only connection
ctx = contextvars.copy_context()
return await asyncio.get_running_loop().run_in_executor(
self.ds.executor, ctx.run, _run
)
# Threaded mode - send to write thread
return await self._send_to_write_thread(fn, isolated_connection=True)
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
self._check_not_closed()
return await self.execute_isolated_fn(
lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name)
)
def _analyze_sql(conn):
return analyze_sql_tables(conn, sql, params, database_name=self.name)
return await self.execute_isolated_fn(_analyze_sql)
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
"""Run `fn(conn)` on the write connection, traced as a `db.query` span.
The SQL-string write methods call `_execute_write_fn()` directly to
avoid creating a second span.
"""
self._check_not_closed()
# Record the name before _wrap_fn_with_hooks() wraps fn
name = callback_name(fn)
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
span.set_attribute(DB_SYSTEM, "sqlite")
span.set_attribute(DB_NAMESPACE, self.name)
span.set_attribute(CALLBACK, name)
with record_operation_duration(self.name, "write"):
return await self._execute_write_fn(
fn, block=block, transaction=transaction, request=request
)
async def _execute_write_fn(self, fn, block=True, transaction=True, request=None):
self._check_not_closed()
pending_events = []
@ -348,9 +461,19 @@ class Database:
self.ds._prepare_connection(self._write_connection, self.name)
if transaction:
with self._write_connection:
self._write_connection.execute("BEGIN IMMEDIATE")
result = fn(self._write_connection)
else:
result = fn(self._write_connection)
if not block:
# There is no write thread here, so the write has already
# finished. Hand back the same (task_id, reply_future) shape
# _send_to_write_thread() returns, with the future already
# resolved, so the block=False path below is identical in
# both modes.
reply_future = asyncio.get_running_loop().create_future()
reply_future.set_result(result)
result = (uuid.uuid4(), reply_future)
else:
result = await self._send_to_write_thread(
fn, block=block, transaction=transaction
@ -366,7 +489,8 @@ class Database:
async def _dispatch_events_after_write():
try:
await reply_future
except Exception:
except Exception: # noqa: BLE001
# The write failed; skip success events regardless of why
# if the write failed, don't emit success events
return
for event in pending_events:
@ -419,15 +543,24 @@ class Database:
self._write_thread = threading.Thread(
target=self._execute_writes, daemon=True
)
self._write_thread.name = "_execute_writes for database {}".format(
self.name
)
self._write_thread.name = f"_execute_writes for database {self.name}"
self._write_thread.start()
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
task_id = uuid.uuid4()
loop = asyncio.get_running_loop()
reply_future = loop.create_future()
# Capture the OpenTelemetry context and enqueue time for the write thread
self._write_queue.put(
WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction)
WriteTask(
fn,
task_id,
loop,
reply_future,
isolated_connection,
transaction,
otel_context_api.get_current(),
time.time_ns(),
block,
)
)
if block:
return await reply_future
@ -441,8 +574,11 @@ class Database:
conn = None
try:
conn = self.connect(write=True)
# Threads do not inherit the caller's context, so any spans
# created by prepare_connection hooks here are root spans
self.ds._prepare_connection(conn, self.name)
except Exception as e:
except Exception as e: # noqa: BLE001
# Stored and re-raised to whoever queues the next write
conn_exception = e
while True:
task = self._write_queue.get()
@ -450,43 +586,105 @@ class Database:
if conn is not None:
try:
conn.close()
except Exception:
except Exception: # noqa: BLE001, S110
# Best-effort close as the write thread exits
pass
return
exception = None
result = None
if conn_exception is not None:
exception = conn_exception
elif task.isolated_connection:
try:
isolated_connection = self.connect(write=True)
try:
result = task.fn(isolated_connection)
finally:
isolated_connection.close()
try:
self._all_file_connections.remove(isolated_connection)
except ValueError:
# Was probably a memory connection
pass
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
exception = e
# block=True: the caller awaits the result, so the write spans
# are children of the caller's span. The token must be detached
# in the finally block or the context leaks into later writes.
# block=False: the caller may finish first, so the write spans
# are root spans with a link back to the caller's span.
token = None
write_span_kwargs = {}
if task.block:
token = otel_context_api.attach(task.otel_context)
else:
try:
if task.transaction:
with conn:
result = task.fn(conn)
else:
result = task.fn(conn)
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
exception = e
_deliver_write_result(task, result, exception)
write_span_kwargs = linked_root_span_kwargs(task.otel_context)
try:
exception = None
result = None
# Span covers the time from enqueue to dequeue
dequeued_at_ns = time.time_ns()
tracer.start_span(
DB_WRITE_QUEUE_WAIT,
start_time=task.enqueued_at_ns,
**write_span_kwargs,
).end(end_time=dequeued_at_ns)
record_write_queue_wait(self.name, dequeued_at_ns - task.enqueued_at_ns)
if conn_exception is not None:
exception = conn_exception
elif task.isolated_connection:
try:
with tracer.start_as_current_span(
DB_WRITE_EXECUTE, **write_span_kwargs
) as span:
span.set_attribute(
ISOLATED_CONNECTION,
task.isolated_connection,
)
span.set_attribute(TRANSACTION, task.transaction)
isolated_connection = self.connect(write=True)
try:
result = task.fn(isolated_connection)
finally:
isolated_connection.close()
try:
self._all_connections.remove(isolated_connection)
except ValueError:
# May already have been cleared by close().
pass
except Exception as e: # noqa: BLE001
# Write thread must survive any task failure or the database wedges
sys.stderr.write(f"{e}\n")
sys.stderr.flush()
exception = e
else:
try:
with tracer.start_as_current_span(
DB_WRITE_EXECUTE, **write_span_kwargs
) as span:
span.set_attribute(
ISOLATED_CONNECTION,
task.isolated_connection,
)
span.set_attribute(TRANSACTION, task.transaction)
if task.transaction:
with conn:
conn.execute("BEGIN IMMEDIATE")
result = task.fn(conn)
else:
result = task.fn(conn)
except Exception as e: # noqa: BLE001
sys.stderr.write(f"{e}\n")
sys.stderr.flush()
exception = e
_deliver_write_result(task, result, exception)
finally:
if token is not None:
otel_context_api.detach(token)
async def execute_fn(self, fn):
"""Run `fn(conn)` on a read connection, traced as a `db.query` span.
`execute()` calls `_execute_fn()` directly to avoid creating a second
span.
"""
self._check_not_closed()
def fn_in_execute_span(conn):
# Runs on the worker thread
with tracer.start_as_current_span(DB_QUERY_EXECUTE):
return fn(conn)
with tracer.start_as_current_span(DB_QUERY, kind=DB_QUERY.kind) as span:
span.set_attribute(DB_SYSTEM, "sqlite")
span.set_attribute(DB_NAMESPACE, self.name)
span.set_attribute(CALLBACK, callback_name(fn))
with record_operation_duration(self.name, "read"):
return await self._execute_fn(fn_in_execute_span)
async def _execute_fn(self, fn):
self._check_not_closed()
if self.ds.executor is None:
# non-threaded mode
@ -506,7 +704,11 @@ class Database:
with self._pending_execute_futures_lock:
self._check_not_closed()
future = self.ds.executor.submit(in_thread)
# Run in a copy of the caller's context so spans created in the
# thread have the correct parent. This needs a fresh copy for
# each submit, since a Context cannot be entered concurrently.
ctx = contextvars.copy_context()
future = self.ds.executor.submit(ctx.run, in_thread)
self._pending_execute_futures.add(future)
future.add_done_callback(self._remove_pending_execute_future)
return await asyncio.wrap_future(future)
@ -523,46 +725,101 @@ class Database:
"""Executes sql against db_name in a thread"""
self._check_not_closed()
page_size = page_size or self.ds.page_size
time_limit_ms = self.ds.sql_time_limit_ms
# Callers that pass a shorter custom_time_limit, such as table counts
# and facet suggestions, expect timeouts, so they are not span errors
timeout_expected = bool(custom_time_limit) and custom_time_limit < time_limit_ms
if timeout_expected:
time_limit_ms = custom_time_limit
def sql_operation_in_thread(conn):
time_limit_ms = self.ds.sql_time_limit_ms
if custom_time_limit and custom_time_limit < time_limit_ms:
time_limit_ms = custom_time_limit
with sqlite_timelimit(conn, time_limit_ms):
# Expected timeouts and errors with log_sql_errors=False are not
# recorded as span errors, so exceptions are handled explicitly
with tracer.start_as_current_span(
DB_QUERY_EXECUTE,
record_exception=False,
set_status_on_exception=False,
) as execute_span:
try:
cursor = conn.cursor()
cursor.execute(sql, params if params is not None else {})
max_returned_rows = self.ds.max_returned_rows
if max_returned_rows == page_size:
max_returned_rows += 1
if max_returned_rows and truncate:
rows = cursor.fetchmany(max_returned_rows + 1)
truncated = len(rows) > max_returned_rows
rows = rows[:max_returned_rows]
else:
rows = cursor.fetchall()
truncated = False
except (sqlite3.OperationalError, sqlite3.DatabaseError) as e:
if e.args == ("interrupted",):
raise QueryInterrupted(e, sql, params)
with sqlite_timelimit(conn, time_limit_ms):
try:
cursor = conn.cursor()
cursor.execute(sql, params if params is not None else {})
max_returned_rows = self.ds.max_returned_rows
if max_returned_rows == page_size:
max_returned_rows += 1
if max_returned_rows and truncate:
rows = cursor.fetchmany(max_returned_rows + 1)
truncated = len(rows) > max_returned_rows
rows = rows[:max_returned_rows]
else:
rows = cursor.fetchall()
truncated = False
except (sqlite3.OperationalError, sqlite3.DatabaseError) as e:
if e.args == ("interrupted",):
raise QueryInterrupted(e, sql, params)
if log_sql_errors:
sys.stderr.write(
f"ERROR: conn={conn}, sql = {sql!r}, params = {params}: {e}\n"
)
sys.stderr.flush()
raise
except QueryInterrupted as e:
if not timeout_expected:
execute_span.record_exception(e)
execute_span.set_status(Status(StatusCode.ERROR, str(e)))
raise
except Exception as e:
if log_sql_errors:
sys.stderr.write(
"ERROR: conn={}, sql = {}, params = {}: {}\n".format(
conn, repr(sql), params, e
)
)
sys.stderr.flush()
execute_span.record_exception(e)
execute_span.set_status(Status(StatusCode.ERROR, str(e)))
raise
if truncate:
return Results(rows, truncated, cursor.description)
if truncate:
return Results(rows, truncated, cursor.description)
else:
return Results(rows, False, cursor.description)
else:
return Results(rows, False, cursor.description)
with trace("sql", database=self.name, sql=sql.strip(), params=params):
results = await self.execute_fn(sql_operation_in_thread)
with trace( # noqa: SIM117
"sql", database=self.name, sql=sql.strip(), params=params
):
with tracer.start_as_current_span(
DB_QUERY,
kind=DB_QUERY.kind,
record_exception=False,
set_status_on_exception=False,
) as span:
span.set_attribute(DB_SYSTEM, "sqlite")
span.set_attribute(DB_NAMESPACE, self.name)
span.set_attribute(DB_QUERY_TEXT, sql_attribute(sql))
span.set_attribute(TIME_LIMIT_MS, time_limit_ms)
operation_name = sql_operation_name(sql)
if operation_name:
span.set_attribute(DB_OPERATION_NAME, operation_name)
if params:
span.set_attribute(PARAM_COUNT, len(params))
try:
with record_operation_duration(self.name, "read"):
results = await self._execute_fn(sql_operation_in_thread)
except QueryInterrupted as e:
span.set_attribute(INTERRUPTED, True)
if not timeout_expected:
span.set_status(Status(StatusCode.ERROR, str(e)))
span.record_exception(e)
record_query_interrupted(self.name)
raise
except Exception as e:
# log_sql_errors=False callers, such as facet suggestion,
# expect some queries to fail
if log_sql_errors:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
else:
span.set_attribute(SQL_ERROR_SUPPRESSED, True)
raise
span.set_attribute(TRUNCATED, results.truncated)
span.set_attribute(ROWS_RETURNED, len(results.rows))
return results
@property
@ -603,7 +860,7 @@ class Database:
try:
table_count = (
await self.execute(
f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})",
f"select count(*) from (select * from {escape_sqlite(table)} limit {self.count_limit + 1})",
custom_time_limit=limit,
)
).rows[0][0]
@ -653,17 +910,32 @@ class Database:
)
return [r[0] for r in results.rows]
# Named functions rather than lambdas give more useful datasette.callback
# span attributes
async def table_columns(self, table):
return await self.execute_fn(lambda conn: table_columns(conn, table))
def _table_columns(conn):
return table_columns(conn, table)
return await self.execute_fn(_table_columns)
async def table_column_details(self, table):
return await self.execute_fn(lambda conn: table_column_details(conn, table))
def _table_column_details(conn):
return table_column_details(conn, table)
return await self.execute_fn(_table_column_details)
async def primary_keys(self, table):
return await self.execute_fn(lambda conn: detect_primary_keys(conn, table))
def _primary_keys(conn):
return detect_primary_keys(conn, table)
return await self.execute_fn(_primary_keys)
async def fts_table(self, table):
return await self.execute_fn(lambda conn: detect_fts(conn, table))
def _fts_table(conn):
return detect_fts(conn, table)
return await self.execute_fn(_fts_table)
async def label_column_for_table(self, table):
explicit_label_column = (await self.ds.table_config(self.name, table)).get(
@ -707,9 +979,9 @@ class Database:
column_names
and len(column_names) == 2
and ("id" in column_names or "pk" in column_names)
and not set(column_names) == {"id", "pk"}
and set(column_names) != {"id", "pk"}
):
return [c for c in column_names if c not in ("id", "pk")][0]
return next(c for c in column_names if c not in ("id", "pk"))
# Couldn't find a label:
return None
@ -755,6 +1027,17 @@ class Database:
return hidden_tables
async def derived_table_dependencies(self):
"""Return implementation tables and the tables they derive from."""
schema_version = (await self.execute("PRAGMA schema_version")).first()[0]
if (
self._cached_derived_table_dependencies is None
or self._cached_derived_table_dependencies[0] != schema_version
):
dependencies = await self.execute_fn(sqlite_derived_table_dependencies)
self._cached_derived_table_dependencies = (schema_version, dependencies)
return self._cached_derived_table_dependencies[1]
async def view_names(self):
results = await self.execute("select name from sqlite_master where type='view'")
return [r[0] for r in results.rows]
@ -850,16 +1133,28 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
class WriteTask:
__slots__ = (
"block",
"enqueued_at_ns",
"fn",
"task_id",
"loop",
"reply_future",
"isolated_connection",
"loop",
"otel_context",
"reply_future",
"task_id",
"transaction",
)
def __init__(
self, fn, task_id, loop, reply_future, isolated_connection, transaction
self,
fn,
task_id,
loop,
reply_future,
isolated_connection,
transaction,
otel_context,
enqueued_at_ns,
block,
):
self.fn = fn
self.task_id = task_id
@ -867,6 +1162,9 @@ class WriteTask:
self.reply_future = reply_future
self.isolated_connection = isolated_connection
self.transaction = transaction
self.otel_context = otel_context
self.enqueued_at_ns = enqueued_at_ns
self.block = block
def _deliver_write_result(task, result, exception):
@ -895,7 +1193,7 @@ class QueryInterrupted(Exception):
self.params = params
def __str__(self):
return "QueryInterrupted: {}".format(self.e)
return f"QueryInterrupted: {self.e}"
class MultipleValues(Exception):

View file

@ -2,8 +2,8 @@ from datasette import hookimpl
from datasette.permissions import Action
from datasette.resources import (
DatabaseResource,
TableResource,
QueryResource,
TableResource,
)
@ -61,6 +61,12 @@ def register_actions():
description="Create tables",
resource_class=DatabaseResource,
),
Action(
name="create-view",
abbr="cv",
description="Create views",
resource_class=DatabaseResource,
),
Action(
name="store-query",
abbr="sq",
@ -111,6 +117,12 @@ def register_actions():
description="Drop tables",
resource_class=TableResource,
),
Action(
name="drop-view",
abbr="dv",
description="Drop views",
resource_class=TableResource,
),
# Query-level actions (child-level)
Action(
name="view-query",

View file

@ -6,6 +6,17 @@ import markupsafe
from datasette import hookimpl
from datasette.column_types import ColumnType, SQLiteType
_HTTP_URL_RE = re.compile(r"https?://\S+", re.IGNORECASE)
def _normalize_http_url(value):
if not isinstance(value, str):
return None
normalized = value.strip()
if not _HTTP_URL_RE.fullmatch(normalized):
return None
return normalized
class UrlColumnType(ColumnType):
name = "url"
@ -15,7 +26,10 @@ class UrlColumnType(ColumnType):
async def render_cell(self, value, column, table, database, datasette, request):
if not value or not isinstance(value, str):
return None
escaped = markupsafe.escape(value.strip())
normalized = _normalize_http_url(value)
if normalized is None:
return markupsafe.escape(value.strip())
escaped = markupsafe.escape(normalized)
return markupsafe.Markup(f'<a href="{escaped}">{escaped}</a>')
async def validate(self, value, datasette):
@ -23,7 +37,7 @@ class UrlColumnType(ColumnType):
return None
if not isinstance(value, str):
return "URL must be a string"
if not re.match(r"^https?://\S+$", value.strip()):
if _normalize_http_url(value) is None:
return "Invalid URL"
return None

View file

@ -1,8 +1,9 @@
from datasette import hookimpl
import datetime
import os
import time
from datasette import hookimpl
def header(key, request):
key = key.replace("_", "-").encode("utf-8")

View file

@ -17,18 +17,29 @@ UNION/INTERSECT operations. The order of evaluation is:
from __future__ import annotations
# Re-export all hooks and public utilities
from .restrictions import (
actor_restrictions_sql as actor_restrictions_sql,
restrictions_allow_action as restrictions_allow_action,
ActorRestrictions as ActorRestrictions,
)
from .root import root_user_permissions_sql as root_user_permissions_sql
from .config import config_permissions_sql as config_permissions_sql
from .defaults import (
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
)
from .defaults import (
default_action_permissions_sql as default_action_permissions_sql,
)
from .defaults import (
# Avoid "datasette.default_permissions" does not explicitly export attribute
default_allow_sql_check as default_allow_sql_check,
default_action_permissions_sql as default_action_permissions_sql,
default_query_permissions_sql as default_query_permissions_sql,
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
)
from .defaults import (
default_query_permissions_sql as default_query_permissions_sql,
)
from .restrictions import (
ActorRestrictions as ActorRestrictions,
)
# Re-export all hooks and public utilities
from .restrictions import (
actor_restrictions_sql as actor_restrictions_sql,
)
from .restrictions import (
restrictions_allow_action as restrictions_allow_action,
)
from .root import root_user_permissions_sql as root_user_permissions_sql

View file

@ -6,7 +6,7 @@ Applies permission rules from datasette.yaml configuration.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from datasette.app import Datasette
@ -55,8 +55,8 @@ class ConfigPermissionProcessor:
def __init__(
self,
datasette: "Datasette",
actor: Optional[dict],
datasette: Datasette,
actor: dict | None,
action: str,
):
self.datasette = datasette
@ -74,8 +74,8 @@ class ConfigPermissionProcessor:
self.restrictions = actor.get("_r", {}) if actor else {}
# Pre-compute restriction info for efficiency
self.restricted_databases: Set[str] = set()
self.restricted_tables: Set[Tuple[str, str]] = set()
self.restricted_databases: set[str] = set()
self.restricted_tables: set[tuple[str, str]] = set()
if self.has_restrictions:
self.restricted_databases = {
@ -92,16 +92,27 @@ class ConfigPermissionProcessor:
# Tables implicitly reference their parent databases
self.restricted_databases.update(db for db, _ in self.restricted_tables)
def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
# Resolve identity keys once per action, rather than scanning the
# restriction allowlist for every configured table's allow block.
self.restricted_table_keys = {
(db, self.action_obj.normalize_child(table) if self.action_obj else table)
for db, table in self.restricted_tables
}
def evaluate_allow_block(self, allow_block: Any) -> bool | None:
"""Evaluate an allow block against the current actor."""
if allow_block is None:
return None
# Values passed using ``-s permissions.* 1`` or ``0`` are parsed as
# integers, but should retain the CLI's boolean 1/0 behavior.
if isinstance(allow_block, int) and allow_block in (0, 1):
return bool(allow_block)
return actor_matches_allow(self.actor, allow_block)
def is_in_restriction_allowlist(
self,
parent: Optional[str],
child: Optional[str],
parent: str | None,
child: str | None,
) -> bool:
"""Check if resource is allowed by actor restrictions."""
if not self.has_restrictions:
@ -121,8 +132,10 @@ class ConfigPermissionProcessor:
if parent:
table_restrictions = (self.restrictions.get("r", {}) or {}).get(parent, {})
if child:
table_actions = table_restrictions.get(child, [])
if self.action_checks.intersection(table_actions):
child_key = (
self.action_obj.normalize_child(child) if self.action_obj else child
)
if (parent, child_key) in self.restricted_table_keys:
return True
else:
# Parent query should proceed if any child in this database is allowlisted
@ -143,9 +156,9 @@ class ConfigPermissionProcessor:
def add_permissions_rule(
self,
parent: Optional[str],
child: Optional[str],
permissions_block: Optional[dict],
parent: str | None,
child: str | None,
permissions_block: dict | None,
scope_desc: str,
) -> None:
"""Add a rule from a permissions:{action} block."""
@ -165,8 +178,8 @@ class ConfigPermissionProcessor:
def add_allow_block_rule(
self,
parent: Optional[str],
child: Optional[str],
parent: str | None,
child: str | None,
allow_block: Any,
scope_desc: str,
) -> None:
@ -198,8 +211,8 @@ class ConfigPermissionProcessor:
def _add_restriction_gate_denies(
self,
parent: Optional[str],
child: Optional[str],
parent: str | None,
child: str | None,
is_allowed: bool,
scope_desc: str,
) -> None:
@ -231,7 +244,7 @@ class ConfigPermissionProcessor:
if db_name == parent:
self.collector.add(db_name, table_name, False, reason)
def process(self) -> Optional[PermissionSQL]:
def process(self) -> PermissionSQL | None:
"""Process all config rules and return combined PermissionSQL."""
self._process_root_permissions()
self._process_databases()
@ -421,10 +434,10 @@ class ConfigPermissionProcessor:
@hookimpl(specname="permission_resources_sql")
async def config_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
datasette: Datasette,
actor: dict | None,
action: str,
) -> Optional[List[PermissionSQL]]:
) -> list[PermissionSQL] | None:
"""
Apply permission rules from datasette.yaml configuration.

View file

@ -6,7 +6,7 @@ Provides default allow rules for standard view/execute actions.
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datasette.app import Datasette
@ -29,29 +29,28 @@ DEFAULT_ALLOW_ACTIONS = frozenset(
@hookimpl(specname="permission_resources_sql")
async def default_allow_sql_check(
datasette: "Datasette",
actor: Optional[dict],
datasette: Datasette,
actor: dict | None,
action: str,
) -> Optional[PermissionSQL]:
) -> PermissionSQL | None:
"""
Enforce the default_allow_sql setting.
When default_allow_sql is false (the default), execute-sql is denied
unless explicitly allowed by config or other rules.
"""
if action == "execute-sql":
if not datasette.setting("default_allow_sql"):
return PermissionSQL.deny(reason="default_allow_sql is false")
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
return PermissionSQL.deny(reason="default_allow_sql is false")
return None
@hookimpl(specname="permission_resources_sql")
async def default_action_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
datasette: Datasette,
actor: dict | None,
action: str,
) -> Optional[PermissionSQL]:
) -> PermissionSQL | None:
"""
Provide default allow rules for standard view/execute actions.
@ -71,10 +70,10 @@ async def default_action_permissions_sql(
@hookimpl(specname="permission_resources_sql")
async def default_query_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
datasette: Datasette,
actor: dict | None,
action: str,
) -> Optional[PermissionSQL]:
) -> PermissionSQL | None:
actor_id = actor.get("id") if isinstance(actor, dict) else None
if action not in {"view-query", "update-query", "delete-query"}:

View file

@ -5,7 +5,7 @@ Shared helper utilities for default permission implementations.
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Set
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datasette.app import Datasette
@ -13,7 +13,7 @@ if TYPE_CHECKING:
from datasette.permissions import PermissionSQL
def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
def get_action_name_variants(datasette: Datasette, action: str) -> set[str]:
"""
Get all name variants for an action (full name and abbreviation).
@ -27,7 +27,7 @@ def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
return variants
def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool:
def action_in_list(datasette: Datasette, action: str, action_list: list) -> bool:
"""Check if an action (or its abbreviation) is in a list."""
return bool(get_action_name_variants(datasette, action).intersection(action_list))
@ -36,8 +36,8 @@ def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bo
class PermissionRow:
"""A single permission rule row."""
parent: Optional[str]
child: Optional[str]
parent: str | None
child: str | None
allow: bool
reason: str
@ -46,14 +46,14 @@ class PermissionRowCollector:
"""Collects permission rows and converts them to PermissionSQL."""
def __init__(self, prefix: str = "row"):
self.rows: List[PermissionRow] = []
self.rows: list[PermissionRow] = []
self.prefix = prefix
def add(
self,
parent: Optional[str],
child: Optional[str],
allow: Optional[bool],
parent: str | None,
child: str | None,
allow: bool | None,
reason: str,
if_not_none: bool = False,
) -> None:
@ -62,7 +62,7 @@ class PermissionRowCollector:
return
self.rows.append(PermissionRow(parent, child, allow, reason))
def to_permission_sql(self) -> Optional[PermissionSQL]:
def to_permission_sql(self) -> PermissionSQL | None:
"""Convert collected rows to a PermissionSQL object."""
if not self.rows:
return None

View file

@ -8,7 +8,7 @@ contains allowlists of resources the actor can access.
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datasette.app import Datasette
@ -23,12 +23,12 @@ from .helpers import action_in_list, get_action_name_variants
class ActorRestrictions:
"""Parsed actor restrictions from the _r key."""
global_actions: List[str] # _r.a - globally allowed actions
global_actions: list[str] # _r.a - globally allowed actions
database_actions: dict # _r.d - {db_name: [actions]}
table_actions: dict # _r.r - {db_name: {table: [actions]}}
@classmethod
def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]:
def from_actor(cls, actor: dict | None) -> ActorRestrictions | None:
"""Parse restrictions from actor dict. Returns None if no restrictions."""
if not actor:
return None
@ -44,11 +44,11 @@ class ActorRestrictions:
table_actions=restrictions.get("r", {}),
)
def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool:
def is_action_globally_allowed(self, datasette: Datasette, action: str) -> bool:
"""Check if action is in the global allowlist."""
return action_in_list(datasette, action, self.global_actions)
def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]:
def get_allowed_databases(self, datasette: Datasette, action: str) -> set[str]:
"""Get database names where this action is allowed."""
allowed = set()
for db_name, db_actions in self.database_actions.items():
@ -57,8 +57,8 @@ class ActorRestrictions:
return allowed
def get_allowed_tables(
self, datasette: "Datasette", action: str
) -> Set[Tuple[str, str]]:
self, datasette: Datasette, action: str
) -> set[tuple[str, str]]:
"""Get (database, table) pairs where this action is allowed."""
allowed = set()
for db_name, tables in self.table_actions.items():
@ -70,10 +70,10 @@ class ActorRestrictions:
@hookimpl(specname="permission_resources_sql")
async def actor_restrictions_sql(
datasette: "Datasette",
actor: Optional[dict],
datasette: Datasette,
actor: dict | None,
action: str,
) -> Optional[List[PermissionSQL]]:
) -> list[PermissionSQL] | None:
"""
Handle actor restriction-based permission rules.
@ -140,10 +140,10 @@ async def actor_restrictions_sql(
def restrictions_allow_action(
datasette: "Datasette",
datasette: Datasette,
restrictions: dict,
action: str,
resource: Optional[str | Tuple[str, str]],
resource: str | tuple[str, str] | None,
) -> bool:
"""
Check if restrictions allow the requested action on the requested resource.
@ -185,11 +185,15 @@ def restrictions_allow_action(
# Check table/resource level
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
database, table = resource
table_allowed = restrictions.get("r", {}).get(database, {}).get(table)
if table_allowed is not None:
assert isinstance(table_allowed, list)
if to_check.intersection(table_allowed):
return True
action_obj = datasette.actions.get(action)
normalize = action_obj.normalize_child if action_obj else lambda name: name
for table_name, table_allowed in (
restrictions.get("r", {}).get(database, {}).items()
):
if normalize(table_name) == normalize(table):
assert isinstance(table_allowed, list)
if to_check.intersection(table_allowed):
return True
# This action is not explicitly allowed, so reject it
return False

View file

@ -6,7 +6,7 @@ Grants full permissions to the root user when --root flag is used.
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datasette.app import Datasette
@ -17,9 +17,9 @@ from datasette.permissions import PermissionSQL
@hookimpl(specname="permission_resources_sql")
async def root_user_permissions_sql(
datasette: "Datasette",
actor: Optional[dict],
) -> Optional[PermissionSQL]:
datasette: Datasette,
actor: dict | None,
) -> PermissionSQL | None:
"""
Grant root user full permissions when --root flag is used.
"""

View file

@ -0,0 +1,25 @@
"""Default table-access policy for SQLite optimizer statistics."""
import json
from datasette import hookimpl
from datasette.permissions import PermissionSQL
@hookimpl
def permission_resources_sql(action):
if action != "view-table":
return None
return PermissionSQL(
sql="""
SELECT database_name AS parent, value AS child, 0 AS allow,
'SQLite statistics tables are denied by default' AS reason
FROM catalog_databases
CROSS JOIN json_each(:sqlite_statistics_names)
""",
params={
"sqlite_statistics_names": json.dumps(
["sqlite_stat1", "sqlite_stat2", "sqlite_stat3", "sqlite_stat4"]
)
},
)

View file

@ -7,7 +7,7 @@ to datasette.verify_token() so all registered handlers are tried.
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datasette.app import Datasette
@ -17,15 +17,13 @@ from datasette.tokens import SignedTokenHandler
@hookimpl
def register_token_handler(datasette: "Datasette"):
def register_token_handler(datasette: Datasette):
"""Register the default signed token handler."""
return SignedTokenHandler()
@hookimpl(specname="actor_from_request")
async def actor_from_signed_api_token(
datasette: "Datasette", request
) -> Optional[dict]:
async def actor_from_signed_api_token(datasette: Datasette, request) -> dict | None:
"""
Authenticate requests using API tokens by delegating to all registered
token handlers via datasette.verify_token().

View file

@ -0,0 +1,29 @@
from datasette import hookimpl
from datasette.resources import TableResource
@hookimpl
def table_actions(datasette, actor, database, table, request):
async def inner():
db = datasette.get_database(database)
if not db.is_mutable:
return []
if not await datasette.allowed(
action="alter-table",
resource=TableResource(database=database, table=table),
actor=actor,
):
return []
return [
{
"type": "button",
"label": "Alter table",
"description": "Change columns and primary key for this table.",
"attrs": {
"aria-label": f"Alter table {table}",
"data-table-action": "alter-table",
},
}
]
return inner

View file

@ -1,8 +1,9 @@
from abc import ABC, abstractproperty
from dataclasses import asdict, dataclass, field
from datasette.hookspecs import hookimpl
from datetime import datetime, timezone
from datasette.hookspecs import hookimpl
@dataclass
class Event(ABC):

View file

@ -5,6 +5,8 @@ from typing import ClassVar
from asyncinject import Registry
from datasette.utils.asgi import BadRequest
def extra_names_from_request(request):
extra_bits = request.args.getlist("_extra")
@ -81,6 +83,16 @@ class ExtraRegistry:
def public_classes_for_scope(self, scope):
return self.classes_for_scope(scope, include_internal=False)
def internal_classes_for_scope(self, scope):
# Extras that are available to HTML templates but excluded from
# JSON responses - plain Providers are dependency plumbing and
# never surface as keys, so they are not included
return [
cls
for cls in self.classes_for_scope(scope)
if issubclass(cls, Extra) and not cls.public
]
def _registry_for_scope(self, scope):
registry = self._scope_registries.get(scope)
if registry is None:
@ -103,6 +115,17 @@ class ExtraRegistry:
self._allowed_names[key] = names
return names
def validate_requested(self, requested, scope):
"""
Raise BadRequest if any requested extra name is not a public extra
for this scope. Used by data formats such as .json - HTML pages
silently ignore unknown names instead.
"""
allowed = self._allowed_names_for_scope(scope, include_internal=False)
unknown = sorted(name for name in requested if name not in allowed)
if unknown:
raise BadRequest("Unknown _extra: {}".format(", ".join(unknown)))
async def resolve(self, requested, context, scope, include_internal=False):
allowed_names = self._allowed_names_for_scope(scope, include_internal)
requested_names = [name for name in requested if name in allowed_names]

View file

@ -1,12 +1,13 @@
import json
import urllib
from datasette import hookimpl
from datasette.database import QueryInterrupted
from datasette.utils import (
detect_json1,
escape_sqlite,
path_with_added_args,
path_with_removed_args,
detect_json1,
sqlite3,
)
@ -30,7 +31,7 @@ def load_facet_configs(request, table_config):
assert (
len(facet_config.values()) == 1
), "Metadata config dicts should be {type: config}"
type, facet_config = list(facet_config.items())[0]
type, facet_config = next(iter(facet_config.items()))
if isinstance(facet_config, str):
facet_config = {"simple": facet_config}
facet_configs.setdefault(type, []).append(
@ -38,7 +39,7 @@ def load_facet_configs(request, table_config):
)
qs_pairs = urllib.parse.parse_qs(request.query_string, keep_blank_values=True)
for key, values in qs_pairs.items():
if key.startswith("_facet"):
if key == "_facet" or key.startswith("_facet_"):
# Figure out the facet type
if key == "_facet":
type = "column"
@ -85,7 +86,7 @@ class Facet:
self.database = database
# For foreign key expansion. Can be None for e.g. stored SQL queries:
self.table = table
self.sql = sql or f"select * from [{table}]"
self.sql = sql or f"select * from {escape_sqlite(table)}"
self.params = params or []
self.table_config = table_config
# row_count can be None, in which case we calculate it ourselves:
@ -160,18 +161,13 @@ class ColumnFacet(Facet):
for column in columns:
if column in already_enabled:
continue
suggested_facet_sql = """
with limited as (select * from ({sql}) limit {suggest_consider})
select {column} as value, count(*) as n from limited
suggested_facet_sql = f"""
with limited as (select * from ({self.sql}) limit {self.suggest_consider})
select {escape_sqlite(column)} as value, count(*) as n from limited
where value is not null
group by value
limit {limit}
""".format(
column=escape_sqlite(column),
sql=self.sql,
limit=facet_size + 1,
suggest_consider=self.suggest_consider,
)
limit {facet_size + 1}
"""
distinct_values = None
try:
distinct_values = await self.ds.execute(
@ -267,11 +263,16 @@ class ColumnFacet(Facet):
for row in facet_rows:
column_qs = column
if column.startswith("_"):
column_qs = "{}__exact".format(column)
selected = (column_qs, str(row["value"])) in qs_pairs
column_qs = f"{column}__exact"
selected_args = {
key: str(row["value"])
for key in (column_qs, f"{column}__exact")
if (key, str(row["value"])) in qs_pairs
}
selected = bool(selected_args)
if selected:
toggle_path = path_with_removed_args(
self.request, {column_qs: str(row["value"])}
self.request, selected_args
)
else:
toggle_path = path_with_added_args(
@ -342,12 +343,12 @@ class ArrayFacet(Facet):
for v in await self.ds.execute(
self.database,
(
"select {column} from ({sql}) "
"where {column} is not null "
"and {column} != '' "
"and json_array_length({column}) > 0 "
f"select {escape_sqlite(column)} from ({self.sql}) "
f"where {escape_sqlite(column)} is not null "
f"and {escape_sqlite(column)} != '' "
f"and json_array_length({escape_sqlite(column)}) > 0 "
"limit 100"
).format(column=escape_sqlite(column), sql=self.sql),
),
self.params,
truncate=False,
custom_time_limit=self.ds.setting(
@ -388,14 +389,14 @@ class ArrayFacet(Facet):
source = source_and_config["source"]
column = config.get("column") or config["simple"]
# https://github.com/simonw/datasette/issues/448
facet_sql = """
with inner as ({sql}),
facet_sql = f"""
with inner as ({self.sql}),
deduped_array_items as (
select
distinct j.value,
inner.*
from
json_each([inner].{col}) j
json_each([inner].{escape_sqlite(column)}) j
join inner
)
select
@ -406,12 +407,8 @@ class ArrayFacet(Facet):
group by
value
order by
count(*) desc, value limit {limit}
""".format(
col=escape_sqlite(column),
sql=self.sql,
limit=facet_size + 1,
)
count(*) desc, value limit {facet_size + 1}
"""
try:
facet_rows_results = await self.ds.execute(
self.database,

View file

@ -1,8 +1,12 @@
from datasette import hookimpl
from datasette.resources import DatabaseResource
from datasette.views.base import DatasetteError
from datasette.utils.asgi import BadRequest
import json
import math
from typing import ClassVar
from datasette import hookimpl
from datasette.resources import DatabaseResource, TableResource
from datasette.utils.asgi import BadRequest
from datasette.views.base import DatasetteError
from .utils import detect_json1, escape_sqlite, path_with_removed_args
@ -48,13 +52,20 @@ def search_filters(request, database, table, datasette):
human_descriptions = []
extra_context = {}
# Figure out which fts_table to use
# Figure out which trusted fts_table to use. Query string parameters can
# repeat this mapping (for backwards compatibility), but must not select
# a different table or primary key.
table_metadata = await datasette.table_config(database, table)
db = datasette.get_database(database)
fts_table = request.args.get("_fts_table")
fts_table = fts_table or table_metadata.get("fts_table")
fts_table = table_metadata.get("fts_table")
fts_table = fts_table or await db.fts_table(table)
fts_pk = request.args.get("_fts_pk", table_metadata.get("fts_pk", "rowid"))
fts_pk = table_metadata.get("fts_pk", "rowid")
requested_fts_table = request.args.get("_fts_table")
requested_fts_pk = request.args.get("_fts_pk")
if (requested_fts_table and requested_fts_table != fts_table) or (
requested_fts_pk and requested_fts_pk != fts_pk
):
raise BadRequest("Invalid _fts_table or _fts_pk")
search_args = {
key: request.args[key]
for key in request.args
@ -72,6 +83,11 @@ def search_filters(request, database, table, datasette):
extra_context["supports_search"] = bool(fts_table)
if fts_table and search_args:
await datasette.ensure_permission(
action="view-table",
resource=TableResource(database=database, table=fts_table),
actor=request.actor,
)
if "_search" in search_args:
# Simple ?_search=xxx
search = search_args["_search"]
@ -99,9 +115,9 @@ def search_filters(request, database, table, datasette):
fts_table=escape_sqlite(fts_table),
search_col=escape_sqlite(search_col),
match_clause=(
":search_{}".format(i)
f":search_{i}"
if search_mode_raw
else "escape_fts(:search_{})".format(i)
else f"escape_fts(:search_{i})"
),
)
)
@ -132,13 +148,18 @@ def through_filters(request, database, table, datasette):
through_table = through_data["table"]
other_column = through_data["column"]
value = through_data["value"]
await datasette.ensure_permission(
action="view-table",
resource=TableResource(database=database, table=through_table),
actor=request.actor,
)
db = datasette.get_database(database)
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
try:
fk_to_us = [
fk for fk in outgoing_foreign_keys if fk["other_table"] == table
][0]
except IndexError:
fk_to_us = next(
(fk for fk in outgoing_foreign_keys if fk["other_table"] == table),
None,
)
if fk_to_us is None:
raise DatasetteError(
"Invalid _through - could not find corresponding foreign key"
)
@ -182,6 +203,17 @@ class Filter:
raise NotImplementedError
def _coerce_numeric_filter_value(value):
try:
return int(value)
except ValueError:
try:
converted = float(value)
except ValueError:
return value
return converted if math.isfinite(converted) else value
class TemplatedFilter(Filter):
def __init__(
self,
@ -203,13 +235,17 @@ class TemplatedFilter(Filter):
def where_clause(self, table, column, value, param_counter):
converted = self.format.format(value)
if self.numeric and converted.isdigit():
converted = int(converted)
if self.numeric:
converted = _coerce_numeric_filter_value(converted)
if self.no_argument:
kwargs = {"c": column}
kwargs = {"c": _quote_sqlite_identifier(column)}
converted = None
else:
kwargs = {"c": column, "p": f"p{param_counter}", "t": table}
kwargs = {
"c": _quote_sqlite_identifier(column),
"p": f"p{param_counter}",
"t": _quote_sqlite_identifier(table),
}
return self.sql_template.format(**kwargs), converted
def human_clause(self, column, value):
@ -223,6 +259,14 @@ class TemplatedFilter(Filter):
return template.format(c=column, v=value)
def _quote_sqlite_identifier(identifier):
# Preserve the historic always-quoted SQL generated by TemplatedFilter.
escaped = escape_sqlite(identifier)
if escaped == identifier:
return f'"{identifier}"'
return escaped
class InFilter(Filter):
key = "in"
display = "in"
@ -264,56 +308,56 @@ class Filters:
TemplatedFilter(
"exact",
"=",
'"{c}" = :{p}',
"{c} = :{p}",
lambda c, v: "{c} = {v}" if v.isdigit() else '{c} = "{v}"',
),
TemplatedFilter(
"not",
"!=",
'"{c}" != :{p}',
"{c} != :{p}",
lambda c, v: "{c} != {v}" if v.isdigit() else '{c} != "{v}"',
),
TemplatedFilter(
"contains",
"contains",
'"{c}" like :{p}',
"{c} like :{p}",
'{c} contains "{v}"',
format="%{}%",
),
TemplatedFilter(
"notcontains",
"does not contain",
'"{c}" not like :{p}',
"{c} not like :{p}",
'{c} does not contain "{v}"',
format="%{}%",
),
TemplatedFilter(
"endswith",
"ends with",
'"{c}" like :{p}',
"{c} like :{p}",
'{c} ends with "{v}"',
format="%{}",
),
TemplatedFilter(
"startswith",
"starts with",
'"{c}" like :{p}',
"{c} like :{p}",
'{c} starts with "{v}"',
format="{}%",
),
TemplatedFilter("gt", ">", '"{c}" > :{p}', "{c} > {v}", numeric=True),
TemplatedFilter("gt", ">", "{c} > :{p}", "{c} > {v}", numeric=True),
TemplatedFilter(
"gte", "\u2265", '"{c}" >= :{p}', "{c} \u2265 {v}", numeric=True
"gte", "\u2265", "{c} >= :{p}", "{c} \u2265 {v}", numeric=True
),
TemplatedFilter("lt", "<", '"{c}" < :{p}', "{c} < {v}", numeric=True),
TemplatedFilter("lt", "<", "{c} < :{p}", "{c} < {v}", numeric=True),
TemplatedFilter(
"lte", "\u2264", '"{c}" <= :{p}', "{c} \u2264 {v}", numeric=True
"lte", "\u2264", "{c} <= :{p}", "{c} \u2264 {v}", numeric=True
),
TemplatedFilter("like", "like", '"{c}" like :{p}', '{c} like "{v}"'),
TemplatedFilter("like", "like", "{c} like :{p}", '{c} like "{v}"'),
TemplatedFilter(
"notlike", "not like", '"{c}" not like :{p}', '{c} not like "{v}"'
"notlike", "not like", "{c} not like :{p}", '{c} not like "{v}"'
),
TemplatedFilter("glob", "glob", '"{c}" glob :{p}', '{c} glob "{v}"'),
TemplatedFilter("glob", "glob", "{c} glob :{p}", '{c} glob "{v}"'),
InFilter(),
NotInFilter(),
]
@ -322,13 +366,13 @@ class Filters:
TemplatedFilter(
"arraycontains",
"array contains",
""":{p} in (select value from json_each([{t}].[{c}]))""",
""":{p} in (select value from json_each({t}.{c}))""",
'{c} contains "{v}"',
),
TemplatedFilter(
"arraynotcontains",
"array does not contain",
""":{p} not in (select value from json_each([{t}].[{c}]))""",
""":{p} not in (select value from json_each({t}.{c}))""",
'{c} does not contain "{v}"',
),
]
@ -336,36 +380,34 @@ class Filters:
else []
)
+ [
TemplatedFilter("date", "date", "date({c}) = :{p}", '"{c}" is on date {v}'),
TemplatedFilter(
"date", "date", 'date("{c}") = :{p}', '"{c}" is on date {v}'
),
TemplatedFilter(
"isnull", "is null", '"{c}" is null', "{c} is null", no_argument=True
"isnull", "is null", "{c} is null", "{c} is null", no_argument=True
),
TemplatedFilter(
"notnull",
"is not null",
'"{c}" is not null',
"{c} is not null",
"{c} is not null",
no_argument=True,
),
TemplatedFilter(
"isblank",
"is blank",
'("{c}" is null or "{c}" = "")',
"({c} is null or {c} = '')",
"{c} is blank",
no_argument=True,
),
TemplatedFilter(
"notblank",
"is not blank",
'("{c}" is not null and "{c}" != "")',
"({c} is not null and {c} != '')",
"{c} is not blank",
no_argument=True,
),
]
)
_filters_by_key = {f.key: f for f in _filters}
_filters_by_key: ClassVar[dict[str, Filter]] = {f.key: f for f in _filters}
def __init__(self, pairs):
self.pairs = pairs

View file

@ -1,9 +1,10 @@
from datasette.utils.sqlite import sqlite3
from datasette.utils import documented
import itertools
import random
import string
from datasette.utils import documented
from datasette.utils.sqlite import sqlite3
__all__ = [
"EXTRA_DATABASE_SQL",
"TABLES",
@ -346,9 +347,7 @@ CREATE VIEW searchable_view_configured_by_metadata AS
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
+ "\n".join(
[
'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'.format(
a=a, b=b, c=c, content=content
)
f'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'
for a, b, c, content in generate_compound_rows(1001)
]
)

View file

@ -1,9 +1,20 @@
from datasette import hookimpl, Response
from datasette import Response, hookimpl
from .utils import add_cors_headers
@hookimpl(trylast=True)
def forbidden(datasette, request, message):
async def inner():
if (
request.path.split("?")[0].endswith(".json")
or "application/json" in (request.headers.get("accept") or "")
or request.headers.get("content-type") == "application/json"
):
headers = {}
if datasette.cors:
add_cors_headers(headers)
return Response.error(message, 403, headers=headers)
return Response.html(
await datasette.render_template(
"error.html",

View file

@ -1,16 +1,21 @@
from datasette import hookimpl, Response
from .utils import add_cors_headers
import traceback
from markupsafe import Markup
from datasette import Response, hookimpl
from .utils import add_cors_headers, error_body
from .utils.asgi import (
Base400,
)
from .views.base import DatasetteError
from markupsafe import Markup
import traceback
# Debugger imports are deliberate - they back the "pdb" setting, which drops
# into a debugger on unhandled exceptions
try:
import ipdb as pdb
import ipdb as pdb # noqa: T100
except ImportError:
import pdb
import pdb # noqa: T100
try:
import rich
@ -28,6 +33,7 @@ def handle_exception(datasette, request, exception):
rich.get_console().print_exception(show_locals=True)
title = None
plain_message = None
if isinstance(exception, Base400):
status = exception.status
info = {}
@ -36,6 +42,7 @@ def handle_exception(datasette, request, exception):
status = exception.status
info = exception.error_dict
message = exception.message
plain_message = exception.plain_message
if exception.message_is_html:
message = Markup(message)
title = exception.title
@ -45,6 +52,17 @@ def handle_exception(datasette, request, exception):
message = str(exception)
traceback.print_exc()
templates = [f"{status}.html", "error.html"]
headers = {}
if datasette.cors:
add_cors_headers(headers)
if request.path.split("?")[0].endswith(".json"):
body = dict(info)
body.update(error_body(plain_message or message, status))
return Response.json(body, status=status, headers=headers)
if request.path.split("?")[0].endswith(".csv"):
return Response.text(
plain_message or message, status=status, headers=headers
)
info.update(
{
"ok": False,
@ -53,25 +71,18 @@ def handle_exception(datasette, request, exception):
"title": title,
}
)
headers = {}
if datasette.cors:
add_cors_headers(headers)
if request.path.split("?")[0].endswith(".json"):
return Response.json(info, status=status, headers=headers)
else:
environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates)
return Response.html(
await template.render_async(
dict(
info,
urls=datasette.urls,
app_css_hash=datasette.app_css_hash(),
menu_links=lambda: [],
)
),
status=status,
headers=headers,
)
environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates)
return Response.html(
await template.render_async(
dict(
info,
urls=datasette.urls,
menu_links=list,
)
),
status=status,
headers=headers,
)
return inner

View file

@ -1,5 +1,4 @@
from pluggy import HookimplMarker
from pluggy import HookspecMarker
from pluggy import HookimplMarker, HookspecMarker
hookspec = HookspecMarker("datasette")
hookimpl = HookimplMarker("datasette")
@ -10,6 +9,11 @@ def startup(datasette):
"""Fires directly after Datasette first starts running"""
@hookspec
def shutdown(datasette):
"""Called once when the Datasette server is shutting down"""
@hookspec
def asgi_wrapper(datasette):
"""Returns an ASGI middleware callable to wrap our ASGI application with"""
@ -46,7 +50,7 @@ def extra_body_script(
def extra_template_vars(
template, database, table, columns, view_name, request, datasette
):
"""Extra template variables to be made available to the template - can return dict or callable or awaitable"""
"""Extra template variables to be made available to the template - can return dict, None, callable or awaitable"""
@hookspec

View file

@ -1,13 +1,13 @@
import hashlib
from .utils import (
detect_spatialite,
detect_fts,
detect_primary_keys,
detect_spatialite,
escape_sqlite,
get_all_foreign_keys,
table_columns,
sqlite3,
table_columns,
)
HASH_BLOCK_SIZE = 1024 * 1024
@ -95,10 +95,10 @@ def inspect_tables(conn, database_metadata):
""")
]
for t in tables.keys():
for t, table_info in tables.items():
for hidden_table in hidden_tables:
if t == hidden_table or t.startswith(hidden_table):
tables[t]["hidden"] = True
table_info["hidden"] = True
continue
return tables

View file

@ -21,7 +21,7 @@ class JumpSQL:
search_text: str | None = None,
display_name: str | None = None,
item_type: str = "menu",
) -> "JumpSQL":
) -> JumpSQL:
if search_text is None:
search_text = " ".join(
text for text in (label, display_name, description) if text is not None

View file

@ -1,7 +1,11 @@
import contextvars
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, NamedTuple
import contextvars
_SQLITE_IDENTIFIER_CASE = str.maketrans(
"ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"
)
# Context variable to track when permission checks should be skipped
_skip_permission_checks = contextvars.ContextVar(
@ -49,6 +53,15 @@ class Resource(ABC):
# Class-level metadata (subclasses must define these)
name: str = None # e.g., "table", "database", "model"
parent_class: type["Resource"] | None = None # e.g., DatabaseResource for tables
case_insensitive_child: bool = False
@classmethod
def normalize_child(cls, child: str | None) -> str | None:
"""Return a comparison key without changing the resource's display name."""
if cls.case_insensitive_child and child is not None:
# Match SQLite NOCASE: fold ASCII only, not Unicode lower/casefold.
return child.translate(_SQLITE_IDENTIFIER_CASE)
return child
# Instance-level optional extra attributes
reasons: list[str] | None = None
@ -72,8 +85,8 @@ class Resource(ABC):
)
def __repr__(self) -> str:
return "{}(parent={!r}, child={!r})".format(
self.__class__.__name__, self.parent, self.child
return (
f"{self.__class__.__name__}(parent={self.parent!r}, child={self.child!r})"
)
@property
@ -129,7 +142,6 @@ class Resource(ABC):
Must return two columns: parent, child
"""
pass
class AllowedResource(NamedTuple):
@ -147,6 +159,11 @@ class Action:
resource_class: type[Resource] | None = None
also_requires: str | None = None # Optional action name that must also be allowed
def normalize_child(self, child: str | None) -> str | None:
if self.resource_class is None:
return child
return self.resource_class.normalize_child(child)
@property
def takes_parent(self) -> bool:
"""

View file

@ -1,20 +1,14 @@
import importlib
import importlib.metadata as importlib_metadata
import importlib.resources as importlib_resources
import os
import pluggy
from pprint import pprint
import sys
from pprint import pprint
import pluggy
from . import hookspecs
if sys.version_info >= (3, 9):
import importlib.resources as importlib_resources
else:
import importlib_resources
if sys.version_info >= (3, 10):
import importlib.metadata as importlib_metadata
else:
import importlib_metadata
DEFAULT_PLUGINS = (
"datasette.publish.heroku",
"datasette.publish.cloudrun",
@ -24,6 +18,7 @@ DEFAULT_PLUGINS = (
"datasette.actor_auth_cookie",
"datasette.default_permissions",
"datasette.default_permissions.tokens",
"datasette.default_permissions.sqlite_statistics",
"datasette.default_actions",
"datasette.default_column_types",
"datasette.default_magic_parameters",
@ -31,6 +26,7 @@ DEFAULT_PLUGINS = (
"datasette.default_debug_menu",
"datasette.default_jump_items",
"datasette.default_database_actions",
"datasette.default_table_actions",
"datasette.default_query_actions",
"datasette.handle_exception",
"datasette.forbidden",
@ -84,7 +80,7 @@ if DATASETTE_LOAD_PLUGINS is not None:
# Ensure name can be found in plugin_to_distinfo later:
pm._plugin_distinfo.append((mod, distribution))
except importlib_metadata.PackageNotFoundError:
sys.stderr.write("Plugin {} could not be found\n".format(package_name))
sys.stderr.write(f"Plugin {package_name} could not be found\n")
# Load default plugins

View file

@ -1,15 +1,17 @@
from datasette import hookimpl
import click
import json
import os
import re
from subprocess import CalledProcessError, check_call, check_output
import click
from datasette import hookimpl
from ..utils import temporary_docker_directory
from .common import (
add_common_publish_arguments_and_options,
fail_if_publish_binary_not_installed,
)
from ..utils import temporary_docker_directory
@hookimpl
@ -219,7 +221,7 @@ def publish_subcommand(publish):
check_call(
"gcloud builds submit --tag {}{}".format(
image_id, " --timeout {}".format(timeout) if timeout else ""
image_id, f" --timeout {timeout}" if timeout else ""
),
shell=True,
)
@ -231,7 +233,7 @@ def publish_subcommand(publish):
("--min-instances", min_instances),
):
if value is not None:
extra_deploy_options.append("{} {}".format(option, value))
extra_deploy_options.append(f"{option} {value}")
check_call(
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
image_id,
@ -258,24 +260,16 @@ def _ensure_artifact_registry(artifact_project, artifact_region, artifact_reposi
) from exc
describe_cmd = (
"gcloud artifacts repositories describe {repo} --project {project} "
"--location {location} --quiet"
).format(
repo=artifact_repository,
project=artifact_project,
location=artifact_region,
f"gcloud artifacts repositories describe {artifact_repository} --project {artifact_project} "
f"--location {artifact_region} --quiet"
)
try:
check_call(describe_cmd, shell=True)
return
except CalledProcessError:
create_cmd = (
"gcloud artifacts repositories create {repo} --repository-format=docker "
'--location {location} --project {project} --description "Datasette Cloud Run images" --quiet'
).format(
repo=artifact_repository,
location=artifact_region,
project=artifact_project,
f"gcloud artifacts repositories create {artifact_repository} --repository-format=docker "
f'--location {artifact_region} --project {artifact_project} --description "Datasette Cloud Run images" --quiet'
)
try:
check_call(create_cmd, shell=True)

View file

@ -1,9 +1,11 @@
from ..utils import StaticMount
import click
import os
import shutil
import sys
import click
from ..utils import StaticMount
def add_common_publish_arguments_and_options(subcommand):
for decorator in reversed(
@ -76,9 +78,7 @@ def fail_if_publish_binary_not_installed(binary, publish_target, install_link):
"""Exit (with error message) if ``binary` isn't installed"""
if not shutil.which(binary):
click.secho(
"Publishing to {publish_target} requires {binary} to be installed and configured".format(
publish_target=publish_target, binary=binary
),
f"Publishing to {publish_target} requires {binary} to be installed and configured",
bg="red",
fg="white",
bold=True,

View file

@ -1,19 +1,21 @@
from contextlib import contextmanager
from datasette import hookimpl
import click
import json
import os
import pathlib
import shlex
import shutil
from subprocess import call, check_output
import tempfile
from contextlib import contextmanager
from subprocess import call, check_output
import click
from datasette import hookimpl
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
from .common import (
add_common_publish_arguments_and_options,
fail_if_publish_binary_not_installed,
)
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
@hookimpl
@ -234,7 +236,7 @@ def temporary_heroku_directory(
extras.extend(["--static", f"{mount_point}:{mount_point}"])
quoted_files = " ".join(
["-i {}".format(shlex.quote(file_name)) for file_name in file_names]
[f"-i {shlex.quote(file_name)}" for file_name in file_names]
)
procfile_cmd = "web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json {extras}".format(
quoted_files=quoted_files, extras=" ".join(extras)

View file

@ -1,11 +1,13 @@
import json
from datasette.extras import extra_names_from_request
from datasette.utils import (
value_as_boolean,
remove_infinites,
CustomJSONEncoder,
error_body,
path_from_row_pks,
remove_infinites,
sqlite3,
value_as_boolean,
)
from datasette.utils.asgi import Response
@ -52,8 +54,7 @@ def json_renderer(request, args, data, error, truncated=None):
if error:
shape = "objects"
status_code = 400
data["error"] = error
data["ok"] = False
data.update(error_body(error, status_code))
if truncated is not None:
data["truncated"] = truncated
@ -87,7 +88,8 @@ def json_renderer(request, args, data, error, truncated=None):
object_rows[pk_string] = row
data = object_rows
if shape_error:
data = {"ok": False, "error": shape_error}
status_code = 400
data = error_body(shape_error, status_code)
elif shape == "array":
data = data["rows"]
@ -100,12 +102,7 @@ def json_renderer(request, args, data, error, truncated=None):
data["rows"] = [list(row.values()) for row in data["rows"]]
else:
status_code = 400
data = {
"ok": False,
"error": f"Invalid _shape: {shape}",
"status": 400,
"title": None,
}
data = error_body(f"Invalid _shape: {shape}", status_code)
# Don't include "columns" in output
# https://github.com/simonw/datasette/issues/2136

View file

@ -25,6 +25,7 @@ class TableResource(Resource):
name = "table"
parent_class = DatabaseResource
case_insensitive_child = True
def __init__(self, database: str, table: str):
super().__init__(parent=database, child=table)

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,9 @@
let columnChooserInstanceCounter = 0;
class ColumnChooser extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.titleId = `column-chooser-title-${++columnChooserInstanceCounter}`;
// State
this._items = [];
@ -26,375 +28,60 @@ class ColumnChooser extends HTMLElement {
// Bound handlers
this._onMove = this._onMove.bind(this);
this._onUp = this._onUp.bind(this);
}
this.shadowRoot.innerHTML = `
<style>
:host {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--accent-light: #e8effd;
--card: #ffffff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
dialog {
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: 100%;
max-width: 420px;
max-height: min(640px, calc(100vh - 32px));
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
dialog[open] {
display: flex;
flex-direction: column;
height: min(640px, calc(100vh - 32px));
}
dialog::backdrop {
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.modal-title {
font-size: 1rem;
font-weight: 600;
}
.modal-meta {
font-family: ui-monospace, monospace;
font-size: 0.7rem;
color: var(--muted);
background: var(--paper);
padding: 3px 9px;
border-radius: 20px;
}
.list-toolbar {
padding: 6px 24px;
border-bottom: 1px solid var(--rule);
display: flex;
gap: 12px;
flex-shrink: 0;
}
.list-toolbar button {
background: var(--accent-light);
border: 1px solid var(--rule);
border-radius: 4px;
font-family: inherit;
font-size: 0.75rem;
color: var(--accent);
cursor: pointer;
padding: 3px 10px;
transition: background 0.12s, color 0.12s;
}
.list-toolbar button:hover { background: var(--accent); color: white; }
.list-wrap {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.list-wrap::before,
.list-wrap::after {
content: '';
position: sticky;
display: block;
left: 0; right: 0;
height: 20px;
pointer-events: none;
z-index: 5;
transition: opacity 0.2s;
}
.list-wrap::before {
top: 0;
background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);
}
.list-wrap::after {
bottom: 0;
background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);
margin-top: -20px;
}
.scroll-zone {
position: absolute;
left: 0; right: 0;
height: 72px;
pointer-events: none;
z-index: 10;
}
.scroll-zone-top { top: 0; }
.scroll-zone-bot { bottom: 0; }
.drag-list {
list-style: none;
padding: 4px 0;
}
.drag-item {
display: flex;
align-items: center;
background: white;
border-bottom: 1px solid var(--rule);
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
position: relative;
transition: background 0.08s;
}
.drag-item:last-child { border-bottom: none; }
.drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
flex-shrink: 0;
cursor: grab;
color: #c8c4bc;
touch-action: none;
transition: color 0.15s;
}
.drag-handle:hover { color: var(--accent); }
.drag-handle svg { pointer-events: none; display: block; }
.drag-item-content {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
cursor: pointer;
}
.drag-item-check {
display: flex;
align-items: center;
width: 32px;
height: 48px;
flex-shrink: 0;
}
.drag-item-check input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.drag-item-label {
flex: 1;
font-size: 0.9rem;
line-height: 48px;
padding-right: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
}
.drag-item.is-dragging {
opacity: 0;
}
.drop-indicator {
position: absolute;
left: 48px;
right: 0;
height: 2px;
background: var(--accent);
border-radius: 99px;
pointer-events: none;
z-index: 20;
display: none;
}
.drop-indicator.top { top: -1px; display: block; }
.drop-indicator.bottom { bottom: -1px; display: block; }
.drag-ghost {
position: fixed;
pointer-events: none;
z-index: 9999;
background: white;
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1);
display: flex;
align-items: center;
border: 1.5px solid var(--accent-light);
opacity: 0.97;
will-change: transform;
font-family: system-ui, -apple-system, sans-serif;
}
.scroll-pulse {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
opacity: 0;
pointer-events: none;
z-index: 10;
transition: opacity 0.15s;
}
.scroll-pulse.top { top: 8px; }
.scroll-pulse.bot { bottom: 8px; }
.scroll-pulse.active {
opacity: 0.18;
animation: pulse 0.8s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: translateX(-50%) scale(1); opacity: 0.18; }
50% { transform: translateX(-50%) scale(1.5); opacity: 0.07; }
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
}
.footer-info {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 0.68rem;
color: var(--muted);
}
.btn {
border: none;
border-radius: 5px;
padding: 9px 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
touch-action: manipulation;
font-family: inherit;
transition: background 0.12s;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover { background: #1448c0; }
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
}
.btn-ghost:hover { background: var(--rule); color: var(--ink); }
.list-wrap::-webkit-scrollbar { width: 5px; }
.list-wrap::-webkit-scrollbar-track { background: transparent; }
.list-wrap::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 99px; }
input, textarea { -webkit-user-select: auto; user-select: auto; }
</style>
<dialog aria-labelledby="modalTitle">
connectedCallback() {
if (this._modal) return;
this.innerHTML = `
<datasette-modal><dialog aria-labelledby="${this.titleId}">
<div class="modal-header">
<span class="modal-title" id="modalTitle">Choose columns</span>
<span class="modal-meta" id="selectedCount"></span>
<span class="modal-title" id="${this.titleId}">Choose columns</span>
<span class="modal-meta"></span>
</div>
<div class="list-toolbar">
<button id="selectAllBtn">Select all</button>
<button id="deselectAllBtn">Deselect all</button>
<button class="select-all">Select all</button>
<button class="deselect-all">Deselect all</button>
</div>
<div class="list-wrap" id="listWrap">
<div class="scroll-pulse top" id="pulseTop"></div>
<div class="scroll-pulse bot" id="pulseBot"></div>
<ul class="drag-list" id="dragList"></ul>
<div class="modal-body list-wrap">
<div class="scroll-pulse top"></div>
<div class="scroll-pulse bot"></div>
<ul class="drag-list"></ul>
</div>
<div class="modal-footer">
<span class="footer-info" id="footerInfo"></span>
<button class="btn btn-ghost" id="cancelBtn">Cancel</button>
<button class="btn btn-primary" id="applyBtn">Apply</button>
<span class="footer-info"></span>
<button class="modal-btn modal-btn-ghost">Cancel</button>
<button class="modal-btn modal-btn-primary">Apply</button>
</div>
</dialog>
</dialog></datasette-modal>
`;
// DOM refs
this._dialog = this.shadowRoot.querySelector("dialog");
this._listWrap = this.shadowRoot.getElementById("listWrap");
this._dragList = this.shadowRoot.getElementById("dragList");
this._pulseTop = this.shadowRoot.getElementById("pulseTop");
this._pulseBot = this.shadowRoot.getElementById("pulseBot");
this._selectAllBtn = this.shadowRoot.getElementById("selectAllBtn");
this._deselectAllBtn = this.shadowRoot.getElementById("deselectAllBtn");
this._cancelBtn = this.shadowRoot.getElementById("cancelBtn");
this._applyBtn = this.shadowRoot.getElementById("applyBtn");
this._countEl = this.shadowRoot.getElementById("selectedCount");
this._footerEl = this.shadowRoot.getElementById("footerInfo");
this._modal = this.querySelector("datasette-modal");
this._listWrap = this.querySelector(".list-wrap");
this._dragList = this.querySelector(".drag-list");
this._pulseTop = this.querySelector(".scroll-pulse.top");
this._pulseBot = this.querySelector(".scroll-pulse.bot");
this._selectAllBtn = this.querySelector(".select-all");
this._deselectAllBtn = this.querySelector(".deselect-all");
this._cancelBtn = this.querySelector(".modal-btn-ghost");
this._applyBtn = this.querySelector(".modal-btn-primary");
this._countEl = this.querySelector(".modal-meta");
this._footerEl = this.querySelector(".footer-info");
// Event listeners
this._selectAllBtn.addEventListener("click", () => this._selectAll());
this._deselectAllBtn.addEventListener("click", () => this._deselectAll());
this._cancelBtn.addEventListener("click", () => this._close());
this._cancelBtn.addEventListener("click", () =>
this._modal.requestClose("cancel"),
);
this._applyBtn.addEventListener("click", () => this._apply());
this._dialog.addEventListener("click", (e) => {
if (e.target === this._dialog) this._close();
});
this._dialog.addEventListener("cancel", (e) => {
e.preventDefault();
this._close();
});
this._modal.beforeClose = () => {
this._items = this._savedItems ? [...this._savedItems] : this._items;
this._checked = this._savedChecked
? new Set(this._savedChecked)
: this._checked;
return true;
};
}
/**
@ -414,19 +101,11 @@ class ColumnChooser extends HTMLElement {
this._savedChecked = new Set(this._checked);
this._render();
this._dialog.showModal();
this._modal.show();
}
// ── Internal methods ──
_close() {
this._items = this._savedItems ? [...this._savedItems] : this._items;
this._checked = this._savedChecked
? new Set(this._savedChecked)
: this._checked;
this._dialog.close();
}
_selectAll() {
this._items.forEach((col) => this._checked.add(col));
this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
@ -445,7 +124,7 @@ class ColumnChooser extends HTMLElement {
_apply() {
const selected = this._items.filter((col) => this._checked.has(col));
this._dialog.close();
this._modal.close();
if (this._onApply) {
this._onApply(selected);
}
@ -472,11 +151,13 @@ class ColumnChooser extends HTMLElement {
<span class="drag-item-check">
<input type="checkbox" ${this._checked.has(col) ? "checked" : ""}>
</span>
<span class="drag-item-label">${col}</span>
<span class="drag-item-label"></span>
</label>
<div class="drop-indicator"></div>
`;
li.querySelector(".drag-item-label").textContent = col;
li.querySelector("input").addEventListener("change", (e) => {
e.target.checked ? this._checked.add(col) : this._checked.delete(col);
this._updateCounts();
@ -509,7 +190,7 @@ class ColumnChooser extends HTMLElement {
this._ghostOffX = e.clientX - rect.left;
this._ghostOffY = e.clientY - rect.top;
// Build ghost inside shadow DOM
// Keep the drag preview inside the dialog so it stays above the backdrop.
this._ghost = document.createElement("div");
this._ghost.className = "drag-ghost";
this._ghost.style.width = rect.width + "px";
@ -518,7 +199,7 @@ class ColumnChooser extends HTMLElement {
this._ghost.querySelector(".drop-indicator")?.remove();
const h = this._ghost.querySelector(".drag-handle");
if (h) h.style.color = "var(--accent)";
this.shadowRoot.appendChild(this._ghost);
this._modal.dialog.appendChild(this._ghost);
srcEl.classList.add("is-dragging");
this._positionGhost(e.clientX, e.clientY);

File diff suppressed because it is too large Load diff

View file

@ -1,56 +0,0 @@
/*
https://github.com/luyilin/json-format-highlight
From https://unpkg.com/json-format-highlight@1.0.1/dist/json-format-highlight.js
MIT Licensed
*/
(function (global, factory) {
typeof exports === "object" && typeof module !== "undefined"
? (module.exports = factory())
: typeof define === "function" && define.amd
? define(factory)
: (global.jsonFormatHighlight = factory());
})(this, function () {
"use strict";
var defaultColors = {
keyColor: "dimgray",
numberColor: "lightskyblue",
stringColor: "lightcoral",
trueColor: "lightseagreen",
falseColor: "#f66578",
nullColor: "cornflowerblue",
};
function index(json, colorOptions) {
if (colorOptions === void 0) colorOptions = {};
if (!json) {
return;
}
if (typeof json !== "string") {
json = JSON.stringify(json, null, 2);
}
var colors = Object.assign({}, defaultColors, colorOptions);
json = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
return json.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+]?\d+)?)/g,
function (match) {
var color = colors.numberColor;
if (/^"/.test(match)) {
color = /:$/.test(match) ? colors.keyColor : colors.stringColor;
} else {
color = /true/.test(match)
? colors.trueColor
: /false/.test(match)
? colors.falseColor
: /null/.test(match)
? colors.nullColor
: color;
}
return '<span style="color: ' + color + '">' + match + "</span>";
},
);
}
return index;
});

View file

@ -66,7 +66,8 @@ function initMobileColumnActions(manager) {
return;
}
var dialog = document.createElement("dialog");
var modal = DatasetteModal.create();
var dialog = modal.dialog;
dialog.className = "mobile-column-actions-dialog";
dialog.id = MOBILE_COLUMN_DIALOG_ID;
dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID);
@ -75,13 +76,13 @@ function initMobileColumnActions(manager) {
<span class="modal-title" id="${MOBILE_COLUMN_DIALOG_TITLE_ID}">Column actions</span>
<span class="modal-meta"></span>
</div>
<div class="list-wrap mobile-column-list"></div>
<div class="modal-body list-wrap mobile-column-list"></div>
<div class="modal-footer">
<span class="footer-info">Tap a column to reveal actions.</span>
<button type="button" class="btn btn-ghost mobile-column-actions-done">Done</button>
<button type="button" class="modal-btn modal-btn-ghost mobile-column-actions-done">Done</button>
</div>
`;
document.body.appendChild(dialog);
document.body.appendChild(modal);
triggerButton.setAttribute("aria-haspopup", "dialog");
triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID);
@ -91,7 +92,6 @@ function initMobileColumnActions(manager) {
var listWrap = dialog.querySelector(".mobile-column-list");
var doneButton = dialog.querySelector(".mobile-column-actions-done");
var expandedSectionId = null;
var shouldRestoreFocus = true;
function updateExpandedSection() {
Array.from(dialog.querySelectorAll(".col-header")).forEach((button) => {
@ -128,16 +128,7 @@ function initMobileColumnActions(manager) {
}
function closeDialog(options) {
options = options || {};
shouldRestoreFocus = options.restoreFocus !== false;
if (dialog.open) {
dialog.close();
} else {
triggerButton.setAttribute("aria-expanded", "false");
if (shouldRestoreFocus) {
triggerButton.focus();
}
}
modal.close(options);
}
function renderDialog() {
@ -166,7 +157,8 @@ function initMobileColumnActions(manager) {
topActions.className = "mobile-column-top-actions";
var showAllColumns = document.createElement("a");
showAllColumns.className = "btn btn-ghost mobile-column-top-action";
showAllColumns.className =
"modal-btn modal-btn-ghost mobile-column-top-action";
showAllColumns.href = manager.columnActions.showAllColumnsUrl();
showAllColumns.textContent = "Show all columns";
@ -265,9 +257,7 @@ function initMobileColumnActions(manager) {
if (!renderDialog()) {
return;
}
if (!dialog.open) {
dialog.showModal();
}
modal.show({ returnFocusTo: triggerButton });
triggerButton.setAttribute("aria-expanded", "true");
var focusTarget =
dialog.querySelector(".mobile-column-top-action") ||
@ -288,22 +278,8 @@ function initMobileColumnActions(manager) {
closeDialog();
});
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog) {
closeDialog();
}
});
dialog.addEventListener("cancel", function (ev) {
ev.preventDefault();
closeDialog();
});
dialog.addEventListener("close", function () {
triggerButton.setAttribute("aria-expanded", "false");
if (shouldRestoreFocus) {
triggerButton.focus();
}
});
window.addEventListener("resize", function () {

167
datasette/static/modal.js Normal file
View file

@ -0,0 +1,167 @@
// Shared lifecycle for native modal dialogs.
(() => {
class DatasetteModal extends HTMLElement {
constructor() {
super();
this.beforeClose = null;
this._busy = false;
this._restoreFocus = true;
this._returnFocusTo = null;
this._escapeCleanup = null;
this._escapeTimer = null;
}
static create() {
const modal = document.createElement("datasette-modal");
modal.appendChild(document.createElement("dialog"));
return modal;
}
get dialog() {
return this.querySelector(":scope > dialog");
}
get busy() {
return this._busy;
}
set busy(value) {
this._busy = !!value;
if (this.dialog) {
this.dialog.setAttribute("aria-busy", String(this._busy));
}
}
connectedCallback() {
const dialog = this.dialog;
if (!dialog) return;
dialog.classList.add("datasette-modal");
this._listeners?.abort();
this._listeners = new AbortController();
const options = { signal: this._listeners.signal };
let backdropPointerDown = false;
const outside = (event) => {
const rect = dialog.getBoundingClientRect();
return (
event.target === dialog &&
(event.clientX < rect.left ||
event.clientX > rect.right ||
event.clientY < rect.top ||
event.clientY > rect.bottom)
);
};
dialog.addEventListener(
"pointerdown",
(event) => {
backdropPointerDown = outside(event);
},
options,
);
dialog.addEventListener(
"click",
(event) => {
if (backdropPointerDown && outside(event))
this.requestClose("backdrop");
backdropPointerDown = false;
},
options,
);
dialog.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
// A nested native dialog or plugin picker gets first refusal.
if (event.target.closest("dialog") !== dialog) return;
event.preventDefault();
if (this.busy || this._escapeCleanup || this._escapeTimer !== null)
return;
// Safari can otherwise use this Escape press to cancel confirm() too.
// Only keyboard dismissals wait for keyup; native cancel events needn't.
const onKeyup = (up) => {
if (up.key !== "Escape") return;
this._escapeCleanup();
this._escapeCleanup = null;
this._escapeTimer = setTimeout(() => {
this._escapeTimer = null;
this.requestClose("escape");
}, 0);
};
this.ownerDocument.addEventListener("keyup", onKeyup, true);
this._escapeCleanup = () =>
this.ownerDocument.removeEventListener("keyup", onKeyup, true);
},
options,
);
dialog.addEventListener(
"cancel",
(event) => {
if (event.target !== dialog) return;
event.preventDefault();
if (!this._escapeCleanup && this._escapeTimer === null)
this.requestClose("escape");
},
options,
);
dialog.addEventListener(
"close",
(event) => {
if (event.target !== dialog || dialog.open) return;
this._clearPendingClose();
this.busy = false;
if (this._restoreFocus && this._returnFocusTo?.isConnected) {
// Menu actions may have become hidden while the dialog was open.
const details = this._returnFocusTo.closest("details:not([open])");
const target =
details?.querySelector("summary") || this._returnFocusTo;
target.focus({ preventScroll: true });
}
this._returnFocusTo = null;
},
options,
);
}
disconnectedCallback() {
this._listeners?.abort();
this._clearPendingClose();
this._returnFocusTo = null;
if (this.dialog?.open) this.dialog.close();
this.busy = false;
}
_clearPendingClose() {
this._escapeCleanup?.();
this._escapeCleanup = null;
clearTimeout(this._escapeTimer);
this._escapeTimer = null;
}
show({ returnFocusTo, initialFocus } = {}) {
const dialog = this.dialog;
if (!dialog.open) {
this._clearPendingClose();
this._returnFocusTo = returnFocusTo || this.ownerDocument.activeElement;
this._restoreFocus = true;
dialog.showModal();
}
if (typeof initialFocus === "function") initialFocus();
else initialFocus?.focus();
}
requestClose(source = "cancel") {
if (!this.dialog.open || this.busy) return false;
if (this.beforeClose && this.beforeClose(source) === false) return false;
this.close();
return true;
}
close({ restoreFocus = true } = {}) {
this._clearPendingClose();
this._restoreFocus = restoreFocus;
this.dialog.close();
}
}
customElements.define("datasette-modal", DatasetteModal);
window.DatasetteModal = DatasetteModal;
})();

View file

@ -10,277 +10,22 @@ class NavigationSearch extends HTMLElement {
this.recentHeadingId = `navigation-search-recent-${this.instanceId}`;
this.statusId = `navigation-search-status-${this.instanceId}`;
this.titleId = `navigation-search-title-${this.instanceId}`;
this.attachShadow({ mode: "open" });
this.selectedIndex = -1;
this.matches = [];
this.renderedMatches = [];
this.debounceTimer = null;
this.restoreFocusTarget = null;
this.shouldRestoreFocus = true;
}
connectedCallback() {
if (this._initialized) return;
this._initialized = true;
this.render();
this.setupEventListeners();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: contents;
}
dialog {
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
max-width: 90vw;
width: 600px;
max-height: 80vh;
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;
}
dialog::backdrop {
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.search-container {
display: flex;
flex-direction: column;
}
.search-input-wrapper {
padding: 1.25rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
gap: 0.5rem;
align-items: center;
}
.search-input {
width: 100%;
flex: 1;
min-width: 0;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.search-input:focus {
border-color: #2563eb;
}
.close-search {
background: transparent;
border: 1px solid transparent;
border-radius: 0.375rem;
color: #4b5563;
cursor: pointer;
flex: 0 0 auto;
font: inherit;
font-size: 1.5rem;
height: 2.75rem;
line-height: 1;
width: 2.75rem;
}
.close-search:hover,
.close-search:focus {
background-color: #f3f4f6;
border-color: #d1d5db;
}
.results-container {
overflow-y: auto;
height: calc(80vh - 180px);
padding: 0.5rem;
}
.results-list:empty {
display: none;
}
.result-item {
padding: 0.875rem 1rem;
cursor: pointer;
border-radius: 0.5rem;
transition: background-color 0.15s;
display: flex;
align-items: center;
gap: 0.75rem;
}
.result-item:hover {
background-color: #f3f4f6;
}
.result-item.selected {
background-color: #dbeafe;
}
.result-item > div {
flex: 1;
min-width: 0;
}
.jump-start-content {
border-bottom: 1px solid #e5e7eb;
margin-bottom: 0.5rem;
padding: 0.5rem 0.5rem 1rem;
}
.jump-start-content:empty {
display: none;
}
.result-name {
font-weight: 500;
color: #111827;
}
.result-label {
font-size: 0.875rem;
color: #4b5563;
}
.result-type {
color: #4b5563;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.result-url {
font-size: 0.875rem;
color: #6b7280;
}
.result-description {
color: #374151;
display: -webkit-box;
font-size: 0.8125rem;
line-height: 1.35;
margin-top: 0.35rem;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.results-heading {
color: #4b5563;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0;
padding: 0.5rem 1rem 0.25rem;
text-transform: uppercase;
}
.recent-actions {
padding: 0.25rem 1rem 0.75rem;
}
.clear-recent {
background: transparent;
border: 0;
color: #2563eb;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
padding: 0;
}
.clear-recent:hover {
text-decoration: underline;
}
.no-results {
padding: 2rem;
text-align: center;
color: #6b7280;
}
.hint-text {
padding: 0.75rem 1.25rem;
font-size: 0.875rem;
color: #6b7280;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.hint-text kbd {
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.75rem;
border: 1px solid #d1d5db;
font-family: monospace;
}
.visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
/* Mobile optimizations */
@media (max-width: 640px) {
dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.search-input-wrapper {
padding: 1rem;
}
.search-input {
font-size: 16px; /* Prevents zoom on iOS */
}
.result-item {
padding: 1rem 0.75rem;
}
.hint-text {
font-size: 0.8rem;
padding: 0.5rem 1rem;
}
}
</style>
<dialog aria-modal="true" aria-labelledby="${this.titleId}">
this.innerHTML = `
<datasette-modal><dialog aria-modal="true" aria-labelledby="${this.titleId}">
<div class="search-container">
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
@ -302,23 +47,22 @@ class NavigationSearch extends HTMLElement {
>
<button type="button" class="close-search" aria-label="Close jump menu">&times;</button>
</div>
<div class="results-container"></div>
<div class="modal-body results-container"></div>
<div class="hint-text">
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
<span><kbd>Enter</kbd> Select</span>
<span><kbd>Esc</kbd> Close</span>
</div>
</div>
</dialog>
</dialog></datasette-modal>
`;
}
setupEventListeners() {
const dialog = this.shadowRoot.querySelector("dialog");
const input = this.shadowRoot.querySelector(".search-input");
const closeButton = this.shadowRoot.querySelector(".close-search");
const resultsContainer =
this.shadowRoot.querySelector(".results-container");
const dialog = this.querySelector("dialog");
const input = this.querySelector(".search-input");
const closeButton = this.querySelector(".close-search");
const resultsContainer = this.querySelector(".results-container");
// Global keyboard listener for "/"
document.addEventListener("keydown", (e) => {
@ -355,8 +99,6 @@ class NavigationSearch extends HTMLElement {
} else if (e.key === "Enter") {
e.preventDefault();
this.selectCurrentItem();
} else if (e.key === "Escape") {
this.closeMenu();
}
});
@ -380,18 +122,6 @@ class NavigationSearch extends HTMLElement {
}
});
// Close on backdrop click
dialog.addEventListener("click", (e) => {
if (e.target === dialog) {
this.closeMenu();
}
});
dialog.addEventListener("cancel", (e) => {
e.preventDefault();
this.closeMenu();
});
dialog.addEventListener("close", () => {
this.onMenuClosed();
});
@ -432,19 +162,6 @@ class NavigationSearch extends HTMLElement {
}
}
focusRestoreTarget(trigger) {
if (trigger && typeof trigger.focus === "function") {
return trigger;
}
if (
document.activeElement &&
typeof document.activeElement.focus === "function"
) {
return document.activeElement;
}
return null;
}
setNavigationTriggersExpanded(expanded) {
if (typeof document.querySelectorAll !== "function") {
return;
@ -465,8 +182,8 @@ class NavigationSearch extends HTMLElement {
}
updateComboboxState() {
const dialog = this.shadowRoot.querySelector("dialog");
const input = this.shadowRoot.querySelector(".search-input");
const dialog = this.querySelector("dialog");
const input = this.querySelector(".search-input");
const matches = this.renderedMatches || [];
this.setElementAttribute(
input,
@ -491,7 +208,7 @@ class NavigationSearch extends HTMLElement {
}
setStatus(message) {
const status = this.shadowRoot.querySelector(`#${this.statusId}`);
const status = this.querySelector(`#${this.statusId}`);
if (status) {
status.textContent = message || "";
}
@ -701,7 +418,7 @@ class NavigationSearch extends HTMLElement {
section.render(node, {
navigationSearch: this,
container,
input: this.shadowRoot.querySelector(".search-input"),
input: this.querySelector(".search-input"),
});
});
}
@ -740,8 +457,8 @@ class NavigationSearch extends HTMLElement {
}
renderResults() {
const container = this.shadowRoot.querySelector(".results-container");
const input = this.shadowRoot.querySelector(".search-input");
const container = this.querySelector(".results-container");
const input = this.querySelector(".search-input");
const showStartContent = !input.value.trim();
const jumpSections = showStartContent ? this.jumpSections() : [];
const startBlock = showStartContent
@ -853,18 +570,15 @@ class NavigationSearch extends HTMLElement {
}
}
openMenu(trigger) {
const dialog = this.shadowRoot.querySelector("dialog");
const input = this.shadowRoot.querySelector(".search-input");
openMenu(returnFocusTo) {
const input = this.querySelector(".search-input");
this.restoreFocusTarget = this.focusRestoreTarget(trigger);
this.shouldRestoreFocus = true;
if (!dialog.open) {
dialog.showModal();
}
this.querySelector("datasette-modal").show({
returnFocusTo,
initialFocus: input,
});
this.setNavigationTriggersExpanded(true);
input.value = "";
input.focus();
// Reset state, then populate the default jump list.
this.matches = [];
@ -874,29 +588,15 @@ class NavigationSearch extends HTMLElement {
}
closeMenu(options = {}) {
const dialog = this.shadowRoot.querySelector("dialog");
this.shouldRestoreFocus = options.restoreFocus !== false;
if (dialog.open) {
dialog.close();
} else {
this.onMenuClosed();
}
this.querySelector("datasette-modal").close(options);
}
onMenuClosed() {
const input = this.shadowRoot.querySelector(".search-input");
const input = this.querySelector(".search-input");
this.setElementAttribute(input, "aria-expanded", "false");
this.removeElementAttribute(input, "aria-activedescendant");
this.setNavigationTriggersExpanded(false);
this.setStatus("");
if (
this.shouldRestoreFocus &&
this.restoreFocusTarget &&
typeof this.restoreFocusTarget.focus === "function"
) {
this.restoreFocusTarget.focus();
}
this.restoreFocusTarget = null;
}
escapeHtml(text) {

View file

@ -157,6 +157,7 @@ function createSetColumnTypeOption(value, name, description, checked) {
function setSetColumnTypeDialogBusy(state, isBusy) {
state.isBusy = isBusy;
state.modal.busy = isBusy;
state.saveButton.disabled = isBusy;
state.cancelButton.disabled = isBusy;
Array.from(
@ -185,7 +186,8 @@ function ensureSetColumnTypeDialog() {
return null;
}
var dialog = document.createElement("dialog");
var modal = DatasetteModal.create();
var dialog = modal.dialog;
dialog.id = SET_COLUMN_TYPE_DIALOG_ID;
dialog.className = "set-column-type-dialog";
dialog.setAttribute("aria-labelledby", "set-column-type-title");
@ -196,16 +198,17 @@ function ensureSetColumnTypeDialog() {
</div>
<p class="set-column-type-status"></p>
<p class="set-column-type-error" hidden></p>
<div class="set-column-type-options"></div>
<div class="modal-body set-column-type-options"></div>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost set-column-type-cancel">Cancel</button>
<button type="button" class="btn btn-primary set-column-type-save">Save</button>
<button type="button" class="modal-btn modal-btn-ghost set-column-type-cancel">Cancel</button>
<button type="button" class="modal-btn modal-btn-primary set-column-type-save">Save</button>
</div>
`;
document.body.appendChild(dialog);
document.body.appendChild(modal);
setColumnTypeDialogState = {
modal: modal,
dialog: dialog,
meta: dialog.querySelector(".modal-meta"),
status: dialog.querySelector(".set-column-type-status"),
@ -220,21 +223,7 @@ function ensureSetColumnTypeDialog() {
};
setColumnTypeDialogState.cancelButton.addEventListener("click", function () {
if (!setColumnTypeDialogState.isBusy) {
dialog.close();
}
});
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog && !setColumnTypeDialogState.isBusy) {
dialog.close();
}
});
dialog.addEventListener("cancel", function (ev) {
if (setColumnTypeDialogState.isBusy) {
ev.preventDefault();
}
modal.requestClose("cancel");
});
dialog.addEventListener("close", function () {
@ -242,49 +231,52 @@ function ensureSetColumnTypeDialog() {
setSetColumnTypeDialogBusy(setColumnTypeDialogState, false);
});
setColumnTypeDialogState.saveButton.addEventListener("click", async function () {
var state = setColumnTypeDialogState;
var selected = state.dialog.querySelector(
'input[name="set-column-type-choice"]:checked',
);
var selectedType = selected ? selected.value : "";
var currentType = state.currentConfig.current
? state.currentConfig.current.type
: "";
setColumnTypeDialogState.saveButton.addEventListener(
"click",
async function () {
var state = setColumnTypeDialogState;
var selected = state.dialog.querySelector(
'input[name="set-column-type-choice"]:checked',
);
var selectedType = selected ? selected.value : "";
var currentType = state.currentConfig.current
? state.currentConfig.current.type
: "";
if (selectedType === currentType) {
state.dialog.close();
return;
}
clearSetColumnTypeDialogError(state);
setSetColumnTypeDialogBusy(state, true);
var payload = {
column: state.currentColumn,
column_type: selectedType ? { type: selectedType } : null,
};
try {
var response = await fetch(getSetColumnTypeData().path, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
var data = await response.json();
if (!response.ok || data.ok === false) {
var message = (data.errors || ["Request failed"]).join(" ");
throw new Error(message);
if (selectedType === currentType) {
state.modal.close();
return;
}
location.reload();
} catch (error) {
setSetColumnTypeDialogBusy(state, false);
showSetColumnTypeDialogError(state, error.message || "Request failed");
}
});
clearSetColumnTypeDialogError(state);
setSetColumnTypeDialogBusy(state, true);
var payload = {
column: state.currentColumn,
column_type: selectedType ? { type: selectedType } : null,
};
try {
var response = await fetch(getSetColumnTypeData().path, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
var data = await response.json();
if (!response.ok || data.ok === false) {
var message = (data.errors || ["Request failed"]).join(" ");
throw new Error(message);
}
location.reload();
} catch (error) {
setSetColumnTypeDialogBusy(state, false);
showSetColumnTypeDialogError(state, error.message || "Request failed");
}
},
);
return setColumnTypeDialogState;
}
@ -341,9 +333,7 @@ function openSetColumnTypeDialog(th) {
state.optionsWrap.appendChild(emptyState);
}
if (!state.dialog.open) {
state.dialog.showModal();
}
state.modal.show();
var selectedOption = state.dialog.querySelector(
'input[name="set-column-type-choice"]:checked',
);
@ -367,9 +357,10 @@ function shouldShowShowAllColumns() {
function hasMultipleVisibleColumns(manager) {
return (
Array.from(document.querySelectorAll(manager.selectors.tableHeaders)).filter(
(th) => th.dataset.column && th.dataset.isLinkColumn !== "1",
).length > 1
Array.from(
document.querySelectorAll(manager.selectors.tableHeaders),
).filter((th) => th.dataset.column && th.dataset.isLinkColumn !== "1")
.length > 1
);
}
@ -633,32 +624,157 @@ const initDatasetteTable = function (manager) {
});
};
/* Add x buttons to the filter rows */
function addButtonsToFilterRows(manager) {
var x = "✖";
var rows = Array.from(
document.querySelectorAll(manager.selectors.filterRow),
function filterRowSelector(manager) {
return manager.selectors.filterRows || manager.selectors.filterRow;
}
function filterRowsWithControls(manager) {
return Array.from(
document.querySelectorAll(filterRowSelector(manager)),
).filter((el) => el.querySelector(".filter-op"));
rows.forEach((row) => {
var a = document.createElement("a");
a.setAttribute("href", "#");
a.setAttribute("aria-label", "Remove this filter");
a.style.textDecoration = "none";
a.innerText = x;
a.addEventListener("click", (ev) => {
ev.preventDefault();
let row = ev.target.closest("div");
row.querySelector("select").value = "";
row.querySelector(".filter-op select").value = "exact";
row.querySelector("input.filter-value").value = "";
ev.target.closest("a").style.display = "none";
});
row.appendChild(a);
}
function filterRowNumberFromName(name) {
var match = name && name.match(/^_filter_column_(\d+)$/);
return match ? parseInt(match[1], 10) : 0;
}
function nextFilterRowNumber(manager) {
return (
filterRowsWithControls(manager).reduce((max, row) => {
var column = row.querySelector("select");
return Math.max(max, filterRowNumberFromName(column && column.name));
}, 0) + 1
);
}
function setFilterRowNumber(row, number) {
row.querySelector("select").name = `_filter_column_${number}`;
row.querySelector(".filter-op select").name = `_filter_op_${number}`;
row.querySelector("input.filter-value").name = `_filter_value_${number}`;
}
function resetFilterRow(row) {
row.querySelector("select").value = "";
row.querySelector(".filter-op select").value = "exact";
row.querySelector("input.filter-value").value = "";
}
function updateFilterRowButtons(manager) {
var rows = filterRowsWithControls(manager);
rows.forEach((row, index) => {
var removeButton = row.querySelector(".filter-row-remove");
var addButton = row.querySelector(".filter-row-add");
var column = row.querySelector("select");
if (!column.value) {
a.style.display = "none";
if (removeButton) {
removeButton.hidden = index === 0;
}
if (addButton) {
addButton.hidden = index !== rows.length - 1 || !column.value;
}
var visibleButtonCount = [removeButton, addButton].filter(
function (button) {
return button && !button.hidden;
},
).length;
row.classList.toggle(
"filter-controls-row-has-buttons",
visibleButtonCount > 0,
);
row.classList.toggle(
"filter-controls-row-one-button",
visibleButtonCount === 1,
);
row.classList.toggle(
"filter-controls-row-two-buttons",
visibleButtonCount === 2,
);
});
}
function cloneFilterRow(row) {
var clone = row.cloneNode(true);
clone.querySelector("select").name = "_filter_column";
clone.querySelector(".filter-op select").name = "_filter_op";
clone.querySelector("input.filter-value").name = "_filter_value";
resetFilterRow(clone);
clone
.querySelectorAll(".filter-row-icon")
.forEach((button) => button.remove());
return clone;
}
var FILTER_REMOVE_ICON_SVG = `<svg class="filter-row-remove-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
</svg>`;
var FILTER_ADD_ICON_SVG = `<svg class="filter-row-add-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>`;
function addFilterRowButtons(row, manager) {
var removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "filter-row-icon filter-row-remove";
removeButton.setAttribute("aria-label", "Remove this filter");
removeButton.title = "Remove this filter";
removeButton.tabIndex = 0;
removeButton.innerHTML = FILTER_REMOVE_ICON_SVG;
removeButton.addEventListener("click", (ev) => {
var row = ev.currentTarget.closest(filterRowSelector(manager));
var rows = filterRowsWithControls(manager);
var rowIndex = rows.indexOf(row);
var focusRow = rows[rowIndex + 1] || rows[rowIndex - 1] || null;
row.remove();
updateFilterRowButtons(manager);
if (focusRow) {
var focusTarget =
focusRow.querySelector(".filter-row-add:not([hidden])") ||
focusRow.querySelector("select");
if (focusTarget) {
focusTarget.focus();
}
}
});
row.appendChild(removeButton);
var addButton = document.createElement("button");
addButton.type = "button";
addButton.className = "filter-row-icon filter-row-add";
addButton.setAttribute("aria-label", "Add another filter");
addButton.title = "Add another filter";
addButton.tabIndex = 0;
addButton.innerHTML = FILTER_ADD_ICON_SVG;
addButton.addEventListener("click", (ev) => {
var row = ev.currentTarget.closest(filterRowSelector(manager));
if (row.querySelector("select").name === "_filter_column") {
setFilterRowNumber(row, nextFilterRowNumber(manager));
}
var clone = cloneFilterRow(row);
addFilterRowButtons(clone, manager);
row.parentNode.insertBefore(clone, row.nextSibling);
updateFilterRowButtons(manager);
clone.querySelector("select").focus();
});
row.appendChild(addButton);
row.querySelector("select").addEventListener("change", () => {
updateFilterRowButtons(manager);
});
}
/* Add buttons to the filter rows */
function addButtonsToFilterRows(manager) {
var rows = filterRowsWithControls(manager);
rows.forEach((row) => {
addFilterRowButtons(row, manager);
});
updateFilterRowButtons(manager);
}
/* Set up datalist autocomplete for filter values */
@ -687,11 +803,11 @@ function initAutocompleteForFilterValues(manager) {
});
}
createDataLists();
// When any select with name=_filter_column changes, update the datalist
// When any filter column select changes, update the datalist
document.body.addEventListener("change", function (event) {
if (event.target.name === "_filter_column") {
if (event.target.name && event.target.name.startsWith("_filter_column")) {
event.target
.closest(manager.selectors.filterRow)
.closest(filterRowSelector(manager))
.querySelector(".filter-value")
.setAttribute("list", "datalist-" + event.target.value);
}
@ -741,10 +857,45 @@ function openColumnChooser() {
});
}
function initCountAll() {
var button = document.querySelector(".count-all");
if (!button) {
return;
}
button.addEventListener("click", async function () {
var count = document.querySelector(".table-count");
var error = document.querySelector(".count-error");
button.disabled = true;
button.textContent = "Counting…";
error.textContent = "";
try {
var response = await fetch(button.dataset.countUrl + location.search, {
method: "POST",
headers: {
Accept: "application/json",
},
});
var data = await response.json();
if (!response.ok || !data.ok) {
throw new Error((data.errors || ["Count failed"]).join(" "));
}
count.textContent =
data.count.toLocaleString("en-US") +
(data.count === 1 ? " row" : " rows");
button.remove();
} catch (ex) {
error.textContent = ex.message || "Count failed";
button.disabled = false;
button.textContent = "count all";
}
});
}
// Ensures Table UI is initialized only after the Manager is ready.
document.addEventListener("datasette_init", function (evt) {
const { detail: manager } = evt;
initCountAll();
initializeColumnActions(manager);
// Main table

View file

@ -1,8 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any, Iterable
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
from .utils import tilde_encode, urlsafe_components
@ -62,7 +63,6 @@ def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
"description_html": query.description_html,
"hide_sql": query.hide_sql,
"fragment": query.fragment,
"params": list(query.parameters),
"parameters": list(query.parameters),
"is_write": query.is_write,
"is_private": query.is_private,
@ -84,7 +84,6 @@ def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
return {
"queries": [stored_query_to_dict(query) for query in page.queries],
"next": page.next,
"has_more": page.has_more,
"limit": page.limit,
}
@ -388,7 +387,7 @@ async def count_queries(
OR q.sql LIKE :query_search
)
""")
params["query_search"] = "%{}%".format(q)
params["query_search"] = f"%{q}%"
if is_write is not None:
where_clauses.append("q.is_write = :query_is_write")
params["query_is_write"] = int(bool(is_write))
@ -464,7 +463,7 @@ async def list_queries(
except ValueError:
components = []
if database is None and len(components) == 3:
where_clauses.append("""
where_clauses.append(f"""
(
q.database_name > :cursor_database
OR (
@ -478,12 +477,12 @@ async def list_queries(
)
)
)
""".format(sort_key_sql=sort_key_sql))
""")
params["cursor_database"] = components[0]
params["cursor_sort_key"] = components[1]
params["cursor_name"] = components[2]
elif database is not None and len(components) == 2:
where_clauses.append("""
where_clauses.append(f"""
(
{sort_key_sql} > :cursor_sort_key
OR (
@ -491,7 +490,7 @@ async def list_queries(
AND q.name > :cursor_name
)
)
""".format(sort_key_sql=sort_key_sql))
""")
params["cursor_sort_key"] = components[0]
params["cursor_name"] = components[1]
@ -504,7 +503,7 @@ async def list_queries(
OR q.sql LIKE :query_search
)
""")
params["query_search"] = "%{}%".format(q)
params["query_search"] = f"%{q}%"
if is_write is not None:
where_clauses.append("q.is_write = :query_is_write")
params["query_is_write"] = int(bool(is_write))

481
datasette/telemetry.py Normal file
View file

@ -0,0 +1,481 @@
"""
OpenTelemetry integration for Datasette.
This uses `opentelemetry-api` only. Providers, exporters and sampling are
configured by whoever runs Datasette, for example `opentelemetry-instrument`.
"""
import contextvars
import re
import threading
import time
import weakref
from contextlib import contextmanager
from opentelemetry import context as otel_context_api
from opentelemetry import metrics as otel_metrics
from opentelemetry import trace as otel_trace
from opentelemetry.propagate import extract
from opentelemetry.propagators.textmap import Getter
from opentelemetry.trace import Link, SpanKind, Status, StatusCode, get_current_span
from .telemetry_registry import (
DB_NAMESPACE,
DB_SYSTEM,
ERROR_TYPE,
HTTP_REQUEST_METHOD,
HTTP_RESPONSE_STATUS_CODE,
INTERNAL_CLIENT,
M_CONNECTIONS_OPEN,
M_OPERATION_DURATION,
M_QUERIES_INTERRUPTED,
M_QUERIES_PENDING,
M_THREADS_LIMIT,
M_THREADS_QUEUE_DEPTH,
M_WRITE_QUEUE_DEPTH,
M_WRITE_QUEUE_WAIT,
OPERATION,
SERVER_ADDRESS,
URL_PATH,
URL_SCHEME,
USER_AGENT_ORIGINAL,
)
from .version import __version__
# True while code is executing within a datasette.client request. Defined
# here rather than in app.py to avoid a circular import.
_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False)
# The semantic conventions version matching the attribute names used here.
# 1.30.0 renamed `db.system` to `db.system.name`, so update this when
# renaming attributes to match a newer version.
SCHEMA_URL = "https://opentelemetry.io/schemas/1.29.0"
tracer = otel_trace.get_tracer("datasette", __version__, schema_url=SCHEMA_URL)
meter = otel_metrics.get_meter("datasette", __version__, schema_url=SCHEMA_URL)
MAX_SQL_LENGTH = 2048
def sql_attribute(sql: str) -> str:
"Truncate SQL text so it is safe to attach to a span as an attribute."
sql = sql.strip()
if len(sql) <= MAX_SQL_LENGTH:
return sql
return sql[:MAX_SQL_LENGTH] + "…[truncated]"
def callback_name(fn) -> str:
"""
The name recorded as `datasette.callback` for a callback-style call.
Falls back to the type name for callables such as `functools.partial`
that have no `__qualname__`.
"""
return getattr(fn, "__qualname__", type(fn).__name__)
def linked_root_span_kwargs(context=None):
"""
Keyword arguments that start a new root span with a ``Link`` back to
the current span.
Use this for work that can outlive the span that caused it, such as a
background task or a ``block=False`` write.
Pass ``context`` to link to the span in a previously captured context
instead of the current one. If there is no valid span, no link is added.
Works with any tracer::
with my_tracer.start_as_current_span(
"myplugin.job", **linked_root_span_kwargs()
):
...
"""
cause = get_current_span(context).get_span_context()
links = [Link(cause)] if cause.is_valid else []
return {"context": otel_context_api.Context(), "links": links}
# Keywords that can be recorded as db.operation.name. SQL can be supplied by
# users, so an allowlist keeps the number of distinct values small.
DB_OPERATION_ALLOWLIST = frozenset(
{
"SELECT",
"INSERT",
"UPDATE",
"DELETE",
"CREATE",
"DROP",
"ALTER",
"PRAGMA",
"EXPLAIN",
"REPLACE",
"VACUUM",
"ANALYZE",
"WITH",
}
)
_LEADING_KEYWORD = re.compile(r"^\s*([A-Za-z]+)")
def sql_operation_name(sql: str) -> str | None:
"""
The statement's leading keyword if it is in the allowlist, else None.
Statements that start with a comment or "(" return None. Statements
starting with a CTE return `WITH`. Only call this for a single statement.
"""
match = _LEADING_KEYWORD.match(sql)
if not match:
return None
keyword = match.group(1).upper()
if keyword in DB_OPERATION_ALLOWLIST:
return keyword
return None
# --- The HTTP request span ------------------------------------------------
class _ScopeHeadersGetter(Getter):
"Read W3C trace context from an ASGI scope's headers."
def get(self, carrier, key):
wanted = key.lower().encode("latin-1")
values = [v.decode("latin-1") for k, v in carrier if k.lower() == wanted]
return values or None
def keys(self, carrier):
return [k.decode("latin-1") for k, _ in carrier]
_HEADERS_GETTER = _ScopeHeadersGetter()
# Methods defined by RFC 9110 plus PATCH (RFC 5789). Anything else is
# recorded as `_OTHER`, as recommended by semantic conventions.
_KNOWN_METHODS = frozenset(
{"GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"}
)
def clamp_http_method(method):
"The request method if it is one we recognise, else ``_OTHER``."
method = (method or "").upper()
return method if method in _KNOWN_METHODS else "_OTHER"
def _first_header(headers, name):
"The first value of a header, decoded, or None."
for key, value in headers:
if key.lower() == name:
return value.decode("latin-1")
return None
def _url_path(scope):
"""
The request path, with any query string removed.
Prefers `raw_path`, which preserves encoded slashes in database and
table names. Some clients include the query string in `raw_path`, so
that is stripped as well.
"""
raw_path = scope.get("raw_path")
if raw_path:
if isinstance(raw_path, bytes):
raw_path = raw_path.decode("latin-1")
return raw_path.split("?", 1)[0]
return scope.get("path", "")
# The request span is passed to the router in the ASGI scope, because a
# plugin's asgi_wrapper() middleware may have made its own span current.
# Absent if the span is not recording.
REQUEST_SPAN_SCOPE_KEY = "datasette.telemetry.request_span"
def request_span(scope):
"""
The recording request span for an ASGI scope, or None.
Falls back to the current span, for when Datasette is running under
other instrumentation.
"""
span = scope.get(REQUEST_SPAN_SCOPE_KEY)
if span is None:
span = otel_trace.get_current_span()
return span if span.is_recording() else None
class TelemetryMiddleware:
"""
One `SpanKind.SERVER` span per HTTP request.
The span ends after the full response, including any streamed body,
has been sent.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
# Pass lifespan and websocket scopes straight through
if scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = scope.get("headers") or []
# Uses the global propagator, configured with OTEL_PROPAGATORS
context = extract(headers, getter=_HEADERS_GETTER)
method = clamp_http_method(scope.get("method", ""))
# Renamed to include the route once routing has happened
with tracer.start_as_current_span(
method, context=context, kind=SpanKind.SERVER
) as span:
if not span.is_recording():
# No provider installed, or the trace was not sampled
await self.app(scope, receive, send)
return
span.set_attribute(HTTP_REQUEST_METHOD, method)
span.set_attribute(URL_PATH, _url_path(scope))
scheme = scope.get("scheme")
if scheme:
span.set_attribute(URL_SCHEME, scheme)
host = _first_header(headers, b"host")
if host:
span.set_attribute(SERVER_ADDRESS, host)
user_agent = _first_header(headers, b"user-agent")
if user_agent:
span.set_attribute(USER_AGENT_ORIGINAL, user_agent)
if _in_datasette_client.get():
span.set_attribute(INTERNAL_CLIENT, True)
scope = dict(scope, **{REQUEST_SPAN_SCOPE_KEY: span})
# Some responses are sent without a Response object, so the
# status is captured by wrapping send()
status_holder = {}
async def wrapped_send(message):
if (
message["type"] == "http.response.start"
and "status" not in status_holder
):
status_holder["status"] = message["status"]
await send(message)
escaped = False
try:
await self.app(scope, receive, wrapped_send)
except BaseException as exception:
# Includes asyncio.CancelledError when a client disconnects
escaped = True
span.set_attribute(ERROR_TYPE, type(exception).__name__)
span.set_status(Status(StatusCode.ERROR, str(exception)))
raise
finally:
status = status_holder.get("status")
if status is not None:
span.set_attribute(HTTP_RESPONSE_STATUS_CODE, status)
# 4xx responses are not errors for a server span. If an
# exception escaped, keep its class name as error.type.
if status >= 500 and not escaped:
span.set_status(Status(StatusCode.ERROR))
span.set_attribute(ERROR_TYPE, str(status))
# --- Metrics --------------------------------------------------------------
def _duration_attributes(database_name, operation):
return {
DB_SYSTEM: "sqlite",
DB_NAMESPACE: database_name,
OPERATION: operation,
}
# Instruments use plain text descriptions. The registry entries have longer
# reStructuredText descriptions for the documentation.
sql_operation_duration = meter.create_histogram(
M_OPERATION_DURATION,
unit=M_OPERATION_DURATION.unit,
description="Duration of a SQL operation issued by Datasette",
explicit_bucket_boundaries_advisory=M_OPERATION_DURATION.buckets,
)
write_queue_wait = meter.create_histogram(
M_WRITE_QUEUE_WAIT,
unit=M_WRITE_QUEUE_WAIT.unit,
description=(
"Time a write spent queued behind the single write thread for its database"
),
explicit_bucket_boundaries_advisory=M_WRITE_QUEUE_WAIT.buckets,
)
queries_interrupted = meter.create_counter(
M_QUERIES_INTERRUPTED,
unit=M_QUERIES_INTERRUPTED.unit,
description="Queries cancelled for exceeding sql_time_limit_ms",
)
@contextmanager
def record_operation_duration(database_name, operation):
"""
Record `db.client.operation.duration` for one SQL operation.
Sets `error.type` to the exception class on failure. For a `block=False`
write this measures the time taken to enqueue the write.
"""
attributes = _duration_attributes(database_name, operation)
started = time.perf_counter()
try:
yield
except BaseException as exception:
attributes[ERROR_TYPE] = type(exception).__qualname__
raise
finally:
sql_operation_duration.record(time.perf_counter() - started, attributes)
def record_write_queue_wait(database_name, waited_ns):
write_queue_wait.record(waited_ns / 1e9, {DB_NAMESPACE: database_name})
def record_query_interrupted(database_name):
queries_interrupted.add(1, {DB_NAMESPACE: database_name})
# Live Datasette instances reported by the gauges below. The lock is needed
# because gauge callbacks run on the SDK's collection thread.
#
# The pool gauges do not identify which instance they came from, so they
# are only meaningful for a process running a single Datasette instance.
_live_datasettes = weakref.WeakSet()
_live_datasettes_lock = threading.Lock()
def register_datasette(ds):
"Start reporting pool/queue gauges for this Datasette instance."
with _live_datasettes_lock:
_live_datasettes.add(ds)
def unregister_datasette(ds):
"Stop reporting gauges for an instance that has been closed."
with _live_datasettes_lock:
_live_datasettes.discard(ds)
def _live_instances():
with _live_datasettes_lock:
return list(_live_datasettes)
def _databases_of(ds):
"Every Database attached to an instance, including the internal database."
databases = list(ds.databases.values())
internal = getattr(ds, "_internal_database", None)
if internal is not None:
databases.append(internal)
return databases
def observe_sql_thread_limit(options=None):
"Size of the shared read-query thread pool (the num_sql_threads setting)."
for ds in _live_instances():
if ds.executor is None:
# num_sql_threads=0 - queries run on the event loop, no pool.
continue
yield otel_metrics.Observation(ds.setting("num_sql_threads"), {})
def observe_sql_thread_queue_depth(options=None):
"""
Read queries waiting for a free thread in the shared pool.
`_work_queue` is a private attribute of ThreadPoolExecutor, so this
reports nothing if it is missing.
"""
for ds in _live_instances():
if ds.executor is None:
continue
work_queue = getattr(ds.executor, "_work_queue", None)
if work_queue is None:
continue
yield otel_metrics.Observation(work_queue.qsize(), {})
def observe_pending_queries(options=None):
"""
Read queries submitted to the pool and not yet finished, per database.
Reads `len()` without `_pending_execute_futures_lock` to avoid blocking
queries.
"""
for ds in _live_instances():
for db in _databases_of(ds):
yield otel_metrics.Observation(
len(db._pending_execute_futures), {DB_NAMESPACE: db.name}
)
def observe_write_queue_depth(options=None):
"Writes queued behind the single write thread, per database."
for ds in _live_instances():
for db in _databases_of(ds):
write_queue = db._write_queue
if write_queue is None:
# No write has ever been queued for this database.
continue
yield otel_metrics.Observation(write_queue.qsize(), {DB_NAMESPACE: db.name})
def observe_open_connections(options=None):
"Open SQLite connections tracked for closing, per database."
for ds in _live_instances():
for db in _databases_of(ds):
yield otel_metrics.Observation(
len(db._all_connections), {DB_NAMESPACE: db.name}
)
sql_thread_limit_gauge = meter.create_observable_gauge(
M_THREADS_LIMIT,
callbacks=[observe_sql_thread_limit],
unit=M_THREADS_LIMIT.unit,
description="Maximum concurrent read queries (the num_sql_threads setting)",
)
sql_thread_queue_depth_gauge = meter.create_observable_gauge(
M_THREADS_QUEUE_DEPTH,
callbacks=[observe_sql_thread_queue_depth],
unit=M_THREADS_QUEUE_DEPTH.unit,
description="Read queries waiting for a free thread in the shared SQL pool",
)
pending_queries_gauge = meter.create_observable_gauge(
M_QUERIES_PENDING,
callbacks=[observe_pending_queries],
unit=M_QUERIES_PENDING.unit,
description="Read queries submitted to the pool and not yet complete",
)
write_queue_depth_gauge = meter.create_observable_gauge(
M_WRITE_QUEUE_DEPTH,
callbacks=[observe_write_queue_depth],
unit=M_WRITE_QUEUE_DEPTH.unit,
description="Writes queued behind a database's single write thread",
)
open_connections_gauge = meter.create_observable_gauge(
M_CONNECTIONS_OPEN,
callbacks=[observe_open_connections],
unit=M_CONNECTIONS_OPEN.unit,
description="Open SQLite connections tracked for closing",
)

View file

@ -0,0 +1,502 @@
"""
Every span, metric and attribute that Datasette emits.
These entries are used by the instrumentation code, by `docs/telemetry_doc.py`
to generate the documentation, and by `tests/test_telemetry_registry.py` to
check that the emitted telemetry matches the registry.
"""
from opentelemetry.trace import SpanKind
class Attribute(str):
"""
A span attribute key, carrying its own documentation.
Subclasses `str` so it can be handed straight to `set_attribute()`.
Part of Datasette's public plugin API - plugins declare their own
telemetry registries with these classes. See the "Telemetry for plugin
authors" documentation.
"""
__slots__ = ("description", "optional", "values")
def __new__(cls, name, description, optional=False, values=None):
self = super().__new__(cls, name)
self.description = description
self.optional = optional
# The allowed values for this attribute, or None to allow any value
self.values = frozenset(values) if values is not None else None
return self
def __reduce__(self):
# Copies and pickles become a plain str, since __new__ requires the
# extra arguments. ConsoleMetricExporter deepcopies attribute keys.
return (str, (str(self),))
def __repr__(self):
return f"Attribute({str(self)!r})"
class SpanName(str):
"""A span name, carrying its documentation and the attributes it may set.
Part of Datasette's public plugin API, like `Attribute`.
"""
__slots__ = ("attributes", "description", "dynamic", "kind", "prefix")
def __new__(
cls,
name,
description,
attributes=(),
prefix=False,
dynamic=False,
kind=SpanKind.INTERNAL,
):
self = super().__new__(cls, name)
self.description = description
self.attributes = tuple(attributes)
# Match emitted names that start with this prefix, for names with a
# variable suffix such as SpanName("chat ", ..., prefix=True)
self.prefix = prefix
# The emitted name is built at runtime, so `span_for()` matches it by
# span kind. The entry's string is a template for the documentation.
self.dynamic = dynamic
self.kind = kind
return self
def __reduce__(self):
# See Attribute.__reduce__.
return (str, (str(self),))
def __repr__(self):
return f"SpanName({str(self)!r})"
class MetricName(str):
"A metric name, carrying its instrument kind, unit and attributes."
__slots__ = ("attributes", "buckets", "description", "kind", "unit")
def __new__(cls, name, kind, unit, description, attributes=(), buckets=None):
self = super().__new__(cls, name)
self.kind = kind
self.unit = unit
self.description = description
self.attributes = tuple(attributes)
# Explicit bucket boundaries, for histograms only
self.buckets = tuple(buckets) if buckets is not None else None
return self
def __reduce__(self):
# See Attribute.__reduce__.
return (str, (str(self),))
def __repr__(self):
return f"MetricName({str(self)!r})"
COUNTER = "Counter"
UPDOWN_COUNTER = "UpDownCounter"
HISTOGRAM = "Histogram"
GAUGE = "Observable gauge"
# --- Attributes -----------------------------------------------------------
HTTP_REQUEST_METHOD = Attribute(
"http.request.method",
"The HTTP request method. Methods outside the nine defined by RFC 9110 "
"and RFC 5789 are recorded as ``_OTHER``.",
)
HTTP_RESPONSE_STATUS_CODE = Attribute(
"http.response.status_code",
"The HTTP response status code. Omitted if no response was started.",
optional=True,
)
HTTP_ROUTE = Attribute(
"http.route",
"The regular expression for the matched route, for example "
"``/(?P<database>[^\\/\\.]+)/(?P<table>[^\\/\\.]+)(\\.(?P<format>\\w+))?$`` "
"for a table page. Use this attribute to group requests by route. "
"Omitted when no route matches.",
optional=True,
)
URL_PATH = Attribute(
"url.path",
"The URL path, excluding the query string.",
)
URL_SCHEME = Attribute("url.scheme", "``http`` or ``https``.")
SERVER_ADDRESS = Attribute(
"server.address",
"The ``Host`` header, including any ``:port`` suffix. This value is "
"supplied by the client.",
optional=True,
)
USER_AGENT_ORIGINAL = Attribute(
"user_agent.original",
"The ``User-Agent`` header, verbatim. Omitted if the client sent none.",
optional=True,
)
INTERNAL_CLIENT = Attribute(
"datasette.internal_client",
"``True`` for requests made through ``datasette.client``. Calls made "
"inside another request produce a nested ``SERVER`` span. Filter on "
"this attribute to exclude internal requests from request counts. "
"Omitted for requests received over the network.",
optional=True,
)
ERROR_TYPE = Attribute(
"error.type",
"The exception class name for a failed operation. On HTTP spans, also "
"set to the status code as a string for 5xx responses. A 4xx response "
"alone does not set this attribute or an error status.",
optional=True,
)
DB_SYSTEM = Attribute("db.system", "Always ``sqlite``.")
DB_NAMESPACE = Attribute("db.namespace", "Name of the database being queried.")
OPERATION = Attribute(
"datasette.operation",
"Whether the operation was a read or a write.",
values={"read", "write"},
)
DB_QUERY_TEXT = Attribute(
"db.query.text",
"The SQL, truncated to 2048 characters. Bound parameter values are not "
"recorded. For callback methods, ``datasette.callback`` is recorded instead.",
optional=True,
)
CALLBACK = Attribute(
"datasette.callback",
"The qualified name of the Python callable passed to ``execute_fn()``, "
"``execute_write_fn()`` or ``execute_isolated_fn()``, for example "
"``TableInsertView.post.<locals>.insert_or_upsert_rows``. Set instead of "
"``db.query.text``. Lambdas appear as ``<lambda>``; use a named function "
"for a more descriptive span.",
optional=True,
)
DB_OPERATION_NAME = Attribute(
"db.operation.name",
"The statement's leading keyword, such as ``SELECT``, ``INSERT`` or "
"``CREATE``, if it matches the supported allowlist. Statements beginning "
"with a common table expression report ``WITH``. Omitted for unrecognized "
"keywords and ``execute_write_script()``.",
optional=True,
)
PARAM_COUNT = Attribute(
"datasette.param_count",
"Number of bound parameters. Recorded instead of the values themselves.",
optional=True,
)
PARAM_SETS = Attribute(
"datasette.param_sets",
"Number of parameter sets consumed by ``execute_write_many()``. "
"The parameter values are not recorded.",
optional=True,
)
TIME_LIMIT_MS = Attribute(
"datasette.time_limit_ms",
"Time limit applied to the read query, in milliseconds: "
":ref:`setting_sql_time_limit_ms` or a shorter ``custom_time_limit``.",
optional=True,
)
ROWS_RETURNED = Attribute(
"datasette.rows_returned",
"Number of rows returned by a successful read query.",
optional=True,
)
TRUNCATED = Attribute(
"datasette.truncated",
"True if the result was cut short by :ref:`setting_max_returned_rows`.",
optional=True,
)
INTERRUPTED = Attribute(
"datasette.interrupted",
"True if the query exceeded its time limit. The span status is set to "
"``ERROR`` unless the caller used a ``custom_time_limit`` shorter than "
":ref:`setting_sql_time_limit_ms`, in which case the status is left unset.",
optional=True,
)
SQL_ERROR_SUPPRESSED = Attribute(
"datasette.sql_error_suppressed",
"True for a non-timeout SQL error with ``log_sql_errors=False``. The "
"exception is still raised, but the span status is left unset.",
optional=True,
)
EXECUTESCRIPT = Attribute(
"datasette.executescript",
"True for ``execute_write_script()``, which runs multiple statements.",
optional=True,
)
EXECUTEMANY = Attribute(
"datasette.executemany",
"True for ``execute_write_many()``, which runs one statement against many "
"parameter sets.",
optional=True,
)
ISOLATED_CONNECTION = Attribute(
"datasette.isolated_connection",
"True if the write ran on its own connection rather than the shared write "
"connection.",
)
TRANSACTION = Attribute(
"datasette.transaction",
"False for statements such as ``VACUUM`` that cannot run inside a transaction.",
)
# --- Spans ----------------------------------------------------------------
HTTP_REQUEST = SpanName(
"{http.request.method} {http.route}",
"One span per HTTP request, containing spans from plugin middleware and "
"database operations. Named for the HTTP method and matched route, or "
"just the method if no route matches. Incoming ``traceparent`` headers "
"are extracted using the global propagator to continue the caller's "
"trace. Incoming ``baggage`` is not propagated into plugin or downstream "
"context in this release. Set ``OTEL_PROPAGATORS=none`` to disable "
"extraction. For public instances, strip trace context headers at your "
"proxy if callers should not supply trace context.",
(
HTTP_REQUEST_METHOD,
HTTP_ROUTE,
URL_PATH,
URL_SCHEME,
SERVER_ADDRESS,
USER_AGENT_ORIGINAL,
HTTP_RESPONSE_STATUS_CODE,
ERROR_TYPE,
INTERNAL_CLIENT,
),
dynamic=True,
kind=SpanKind.SERVER,
)
DB_QUERY = SpanName(
"db.query",
"A SQL operation, including time spent queued for a worker thread. For "
"``block=False`` writes, the span ends after the write is queued. "
"Callback methods record ``datasette.callback`` in place of ``db.query.text``.",
(
DB_SYSTEM,
DB_NAMESPACE,
DB_QUERY_TEXT,
CALLBACK,
DB_OPERATION_NAME,
PARAM_COUNT,
PARAM_SETS,
TIME_LIMIT_MS,
ROWS_RETURNED,
TRUNCATED,
INTERRUPTED,
SQL_ERROR_SUPPRESSED,
EXECUTESCRIPT,
EXECUTEMANY,
),
kind=SpanKind.CLIENT,
)
DB_QUERY_EXECUTE = SpanName(
"db.query.execute",
"The read executing inside a SQL worker thread. Child of ``db.query``; the "
"gap between the two is time spent waiting for a thread.",
)
DB_WRITE_QUEUE_WAIT = SpanName(
"db.write.queue_wait",
"Time a write spent waiting in its database's write queue. For "
"``block=True``, this is a child of ``db.query``. For ``block=False``, "
"it is a root span linked to the span that queued the write, since the "
"write can outlive that request.",
)
DB_WRITE_EXECUTE = SpanName(
"db.write.execute",
"The write executing on the write thread. For ``block=True``, this is "
"a child of ``db.query``. For ``block=False``, it is a root span linked "
"to the span that queued the write.",
(ISOLATED_CONNECTION, TRANSACTION),
)
STARTUP = SpanName(
"datasette.startup",
"Startup work performed by ``invoke_startup()``, including registration "
"hooks, schema catalog updates, saved queries, column type configuration "
"and the ``startup`` hook. Runs during instance startup, either before "
"serving requests or as part of the first request.",
)
SPANS = (
HTTP_REQUEST,
DB_QUERY,
DB_QUERY_EXECUTE,
DB_WRITE_QUEUE_WAIT,
DB_WRITE_EXECUTE,
STARTUP,
)
def span_for(emitted_name, kind=None, spans=None):
"""
Resolve an emitted span name to its registry entry, or None.
Exact matches take precedence over `prefix=True` entries, which take
precedence over `dynamic=True` entries matched by `kind`.
`spans` defaults to Datasette's own registry.
"""
if spans is None:
spans = SPANS
for span in spans:
if span.dynamic:
continue
if emitted_name == span:
return span
for span in spans:
if span.prefix and emitted_name.startswith(span):
return span
if kind is not None:
for span in spans:
if span.dynamic and span.kind == kind:
return span
return None
def metric_for(emitted_name, metrics=None):
"""
Resolve an emitted metric name to its registry entry, or None.
`metrics` defaults to Datasette's own registry.
"""
if metrics is None:
metrics = METRICS
for metric in metrics:
if emitted_name == metric:
return metric
return None
def attribute_allowed(entry, emitted_key):
"""
Whether `emitted_key` is a registered attribute of `entry`.
`entry` is a `SpanName` or a `MetricName` - both carry `.attributes`.
"""
if entry is None:
return False
return emitted_key in entry.attributes
def attribute_value_allowed(entry, emitted_key, value):
"""
Whether `value` is permitted for `emitted_key` on `entry` (a `SpanName`
or a `MetricName`).
Any value is allowed if the attribute does not declare `values=`.
"""
if entry is None:
return False
for attribute in entry.attributes:
if attribute == emitted_key:
return attribute.values is None or value in attribute.values
return False
# --- Metrics --------------------------------------------------------------
# Bucket boundaries in seconds for every duration histogram. OpenTelemetry's
# defaults are designed for milliseconds and would put almost every SQLite
# query in the first bucket. These are the semantic conventions' recommended
# boundaries for db.client.operation.duration, plus 0.0001 and 0.0005 for
# fast in-process SQLite queries.
DURATION_BUCKETS = (0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10)
M_OPERATION_DURATION = MetricName(
"db.client.operation.duration",
HISTOGRAM,
"s",
"Duration of a SQL operation, including callback-based calls such as "
"``execute_fn()``. For ``block=False`` writes, measures enqueue time.",
(DB_SYSTEM, DB_NAMESPACE, OPERATION, ERROR_TYPE),
buckets=DURATION_BUCKETS,
)
M_WRITE_QUEUE_WAIT = MetricName(
"datasette.write.queue_wait",
HISTOGRAM,
"s",
"Time each write waited in its database's write queue.",
(DB_NAMESPACE,),
buckets=DURATION_BUCKETS,
)
M_QUERIES_INTERRUPTED = MetricName(
"datasette.sql.queries.interrupted",
COUNTER,
"{query}",
"Queries cancelled for exceeding :ref:`setting_sql_time_limit_ms`. A "
"rising rate can indicate that queries need optimization or a higher "
"time limit. Caller-selected timeouts shorter than this limit, such as "
"those used for facet suggestion, are excluded.",
(DB_NAMESPACE,),
)
M_THREADS_LIMIT = MetricName(
"datasette.sql.threads.limit",
GAUGE,
"{thread}",
"Maximum concurrent read queries, configured by "
":ref:`setting_num_sql_threads`. Not reported when ``num_sql_threads`` "
"is ``0``.",
)
M_THREADS_QUEUE_DEPTH = MetricName(
"datasette.sql.threads.queue_depth",
GAUGE,
"{query}",
"Read queries waiting for a free SQL thread. Sustained values above "
"zero indicate a saturated read pool.",
)
M_QUERIES_PENDING = MetricName(
"datasette.sql.queries.pending",
GAUGE,
"{query}",
"Read queries submitted to the pool and not yet complete. Sum across "
"databases and compare with ``datasette.sql.threads.limit`` to assess "
"pool usage.",
(DB_NAMESPACE,),
)
M_WRITE_QUEUE_DEPTH = MetricName(
"datasette.write.queue_depth",
GAUGE,
"{write}",
"Writes waiting for a database's single write thread. Increasing "
"``num_sql_threads`` does not increase write concurrency. Not reported for "
"databases that have never been written to.",
(DB_NAMESPACE,),
)
M_CONNECTIONS_OPEN = MetricName(
"datasette.connections.open",
GAUGE,
"{connection}",
"Open SQLite connections managed by Datasette.",
(DB_NAMESPACE,),
)
METRICS = (
M_OPERATION_DURATION,
M_WRITE_QUEUE_WAIT,
M_QUERIES_INTERRUPTED,
M_THREADS_LIMIT,
M_THREADS_QUEUE_DEPTH,
M_QUERIES_PENDING,
M_WRITE_QUEUE_DEPTH,
M_CONNECTIONS_OPEN,
)

View file

@ -0,0 +1,427 @@
"""
Pytest helpers for testing OpenTelemetry instrumentation - Datasette's own
and any plugin's. Part of Datasette's public plugin API; see the "Telemetry
for plugin authors" documentation.
Usage from a plugin's ``conftest.py``::
from datasette.telemetry_testing import ( # noqa: F401
MetricsCollector,
otel_metrics,
otel_meter_provider,
otel_provider,
otel_spans,
)
Tests can then use the ``otel_spans`` and ``otel_metrics`` fixtures. The
OpenTelemetry SDK is imported lazily, and the fixtures skip if it is not
installed.
"""
import subprocess
import sys
import pytest
from .telemetry_registry import (
attribute_allowed,
attribute_value_allowed,
metric_for,
span_for,
)
_span_exporter = None
_metric_reader = None
def install_span_exporter():
"""
Install a TracerProvider + InMemorySpanExporter once per process and
return the exporter, or None when the SDK is not installed.
Uses `SimpleSpanProcessor` so spans are exported as soon as they end.
"""
global _span_exporter
if _span_exporter is not None:
return _span_exporter
try:
from opentelemetry import trace as otel_trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
except ImportError:
return None
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
otel_trace.set_tracer_provider(provider)
# set_tracer_provider() is ignored if a provider was already installed,
# in which case the fixtures skip
if otel_trace.get_tracer_provider() is not provider:
return None
_span_exporter = exporter
return exporter
def install_metric_reader():
"""
Install a MeterProvider + InMemoryMetricReader once per process and
return the reader, or None when the SDK is not installed.
Uses delta temporality for counters and histograms, so each collection
only reports measurements since the previous one.
"""
global _metric_reader
if _metric_reader is not None:
return _metric_reader
try:
from opentelemetry import metrics as otel_metrics_api
from opentelemetry.sdk.metrics import Counter, Histogram, MeterProvider
from opentelemetry.sdk.metrics.export import (
AggregationTemporality,
InMemoryMetricReader,
)
except ImportError:
return None
reader = InMemoryMetricReader(
preferred_temporality={
Counter: AggregationTemporality.DELTA,
Histogram: AggregationTemporality.DELTA,
}
)
provider = MeterProvider(metric_readers=[reader])
otel_metrics_api.set_meter_provider(provider)
if otel_metrics_api.get_meter_provider() is not provider:
return None
_metric_reader = reader
return reader
@pytest.fixture(scope="session", autouse=True)
def otel_provider():
"Install the span exporter once per test session, before any spans are created."
install_span_exporter()
@pytest.fixture(scope="session", autouse=True)
def otel_meter_provider():
"Install the metric reader once per test session."
install_metric_reader()
@pytest.fixture(autouse=True)
def otel_reset():
"Clear recorded spans and drain collected metrics after every test."
yield
if _span_exporter is not None:
_span_exporter.clear()
if _metric_reader is not None:
_metric_reader.get_metrics_data()
@pytest.fixture
def otel_spans():
"""
The in-memory span exporter, cleared before the test. Call
`.get_finished_spans()` to retrieve spans.
"""
pytest.importorskip("opentelemetry.sdk")
exporter = install_span_exporter()
if exporter is None:
pytest.skip("OpenTelemetry SDK provider was not installed")
exporter.clear()
yield exporter
class MetricsCollector:
"""
Wraps an `InMemoryMetricReader`.
`collect()` runs a collection cycle and stores a snapshot, which
`points()` and `point()` then query.
"""
def __init__(self, reader):
self.reader = reader
self.snapshot = {}
# (instrumentation scope name, sdk Metric) pairs from the last collect()
self.collected = []
def collect(self):
self.snapshot = {}
self.collected = []
data = self.reader.get_metrics_data()
if data is None:
return self.snapshot
for resource_metrics in data.resource_metrics:
for scope_metrics in resource_metrics.scope_metrics:
scope_name = scope_metrics.scope.name if scope_metrics.scope else None
for metric in scope_metrics.metrics:
self.snapshot.setdefault(metric.name, []).extend(
metric.data.data_points
)
self.collected.append((scope_name, metric))
return self.snapshot
def points(self, name, attributes=None):
"Data points for `name` whose attributes are a superset of `attributes`."
found = []
for point in self.snapshot.get(name, []):
point_attributes = dict(point.attributes or {})
if all(point_attributes.get(k) == v for k, v in (attributes or {}).items()):
found.append(point)
return found
def point(self, name, attributes=None):
"The single matching data point, asserting there is exactly one."
found = self.points(name, attributes)
assert len(found) == 1, (
f"expected exactly one {name} point matching {attributes}, "
f"got {len(found)}: {found}"
)
return found[0]
@pytest.fixture
def otel_metrics():
"A `MetricsCollector`, drained before the test so counts start from zero."
pytest.importorskip("opentelemetry.sdk")
reader = install_metric_reader()
if reader is None:
pytest.skip("OpenTelemetry SDK meter provider was not installed")
reader.get_metrics_data()
yield MetricsCollector(reader)
def _scoped(finished_spans, scope_name):
if scope_name is None:
return list(finished_spans)
return [
span
for span in finished_spans
if span.instrumentation_scope and span.instrumentation_scope.name == scope_name
]
def assert_spans_conform(registry_spans, finished_spans, scope_name=None):
"""
Assert every finished span is registered in `registry_spans`, sets only
registered attributes and uses allowed attribute values.
Pass `scope_name` to only check spans from that instrumentation scope.
"""
problems = []
for span in _scoped(finished_spans, scope_name):
entry = span_for(str(span.name), kind=span.kind, spans=registry_spans)
if entry is None:
problems.append(f"unregistered span: {span.name!r}")
continue
for key, value in (span.attributes or {}).items():
if not attribute_allowed(entry, str(key)):
problems.append(f"{span.name}: unregistered attribute {key!r}")
elif not attribute_value_allowed(entry, str(key), value):
problems.append(
f"{span.name}: {key}={value!r} not in the declared enum"
)
assert not problems, "\n".join(problems)
def assert_spans_covered(registry_spans, finished_spans, scope_name=None):
"""
Assert every entry in `registry_spans` was emitted at least once, with
each of its attributes that is not `optional=True`.
"""
spans = _scoped(finished_spans, scope_name)
seen_attributes = {}
for span in spans:
entry = span_for(str(span.name), kind=span.kind, spans=registry_spans)
if entry is not None:
seen = seen_attributes.setdefault(str(entry), set())
seen.update(str(key) for key in (span.attributes or {}))
problems = []
for entry in registry_spans:
if str(entry) not in seen_attributes:
problems.append(f"registered span never emitted: {entry!r}")
continue
required = {
str(attribute) for attribute in entry.attributes if not attribute.optional
}
missing = required - seen_attributes[str(entry)]
if missing:
problems.append(
f"{entry}: registered attributes never emitted: {sorted(missing)}"
)
assert not problems, "\n".join(problems)
# Registry instrument kinds mapped to the SDK data type collected for them.
# Both counter kinds collect as Sum, distinguished by is_monotonic.
_KIND_TO_DATA_TYPE = {
"Counter": "Sum",
"UpDownCounter": "Sum",
"Histogram": "Histogram",
"Observable gauge": "Gauge",
}
_KIND_IS_MONOTONIC = {"Counter": True, "UpDownCounter": False}
def _scoped_metrics(collector, scope_name):
for scope, metric in collector.collected:
if scope_name is None or scope == scope_name:
yield metric
def assert_metrics_conform(registry_metrics, collector, scope_name=None):
"""
Assert every metric in the collector's last `collect()` is registered in
`registry_metrics` with a matching instrument kind and unit, sets only
registered attributes and uses allowed attribute values.
Pass `scope_name` to only check metrics from that instrumentation scope.
"""
problems = set()
for metric in _scoped_metrics(collector, scope_name):
entry = metric_for(metric.name, metrics=registry_metrics)
if entry is None:
problems.add(f"unregistered metric: {metric.name!r}")
continue
expected_data_type = _KIND_TO_DATA_TYPE.get(entry.kind)
actual_data_type = type(metric.data).__name__
if expected_data_type is not None and actual_data_type != expected_data_type:
problems.add(
f"{metric.name}: registry declares {entry.kind}, "
f"SDK collected {actual_data_type}"
)
expected_monotonic = _KIND_IS_MONOTONIC.get(entry.kind)
actual_monotonic = getattr(metric.data, "is_monotonic", None)
if (
expected_monotonic is not None
and actual_monotonic is not None
and actual_monotonic != expected_monotonic
):
problems.add(
f"{metric.name}: registry declares {entry.kind}, but the "
f"collected Sum is_monotonic={actual_monotonic}"
)
if (metric.unit or "") != (entry.unit or ""):
problems.add(
f"{metric.name}: instrument unit {metric.unit!r} != "
f"registry unit {entry.unit!r}"
)
for point in metric.data.data_points:
for key, value in dict(point.attributes or {}).items():
if not attribute_allowed(entry, str(key)):
problems.add(f"{metric.name}: unregistered attribute {key!r}")
elif not attribute_value_allowed(entry, str(key), value):
problems.add(
f"{metric.name}: {key}={value!r} not in the declared enum"
)
assert not problems, "\n".join(sorted(problems))
def assert_metrics_covered(registry_metrics, collector, scope_name=None):
"""
Assert every entry in `registry_metrics` was collected at least once,
with each of its attributes that is not `optional=True`.
Call `collect()` once after the workload and before this check.
"""
seen_attributes = {}
for metric in _scoped_metrics(collector, scope_name):
entry = metric_for(metric.name, metrics=registry_metrics)
if entry is None:
continue
seen = seen_attributes.setdefault(str(entry), set())
for point in metric.data.data_points:
seen.update(str(key) for key in dict(point.attributes or {}))
problems = []
for entry in registry_metrics:
if str(entry) not in seen_attributes:
problems.append(f"registered metric never collected: {entry!r}")
continue
required = {
str(attribute) for attribute in entry.attributes if not attribute.optional
}
missing = required - seen_attributes[str(entry)]
if missing:
problems.append(
f"{entry}: registered attributes never collected: {sorted(missing)}"
)
assert not problems, "\n".join(problems)
def assert_no_forbidden_values(
forbidden, finished_spans=None, collector=None, scope_name=None
):
"""
Assert that none of the `forbidden` strings appear anywhere in the
emitted telemetry: span names, span attribute values, span event names
and attributes, span status descriptions, or metric point attributes.
Use fake private values such as tokens or email addresses in your test
workload, then check that they were not recorded:
FORBIDDEN = {"secret-token-123", "alice@example.com"}
run_workload_using_those_values()
assert_no_forbidden_values(
FORBIDDEN,
finished_spans=otel_spans.get_finished_spans(),
collector=otel_metrics,
)
Matches substrings of each value's string form. Empty strings in
`forbidden` are ignored. Leave `scope_name` unset to also check
Datasette's own telemetry.
"""
needles = [needle for needle in forbidden if needle]
leaks = set()
def check(value, where):
text = str(value)
for needle in needles:
if needle in text:
leaks.add(f"{where} contains {needle!r}")
if finished_spans is not None:
for span in _scoped(finished_spans, scope_name):
check(span.name, f"span name {str(span.name)!r}")
for key, value in (span.attributes or {}).items():
check(value, f"{span.name} attribute {key}")
for event in span.events or ():
check(event.name, f"{span.name} event name")
for key, value in (event.attributes or {}).items():
check(value, f"{span.name} event {event.name} attribute {key}")
if span.status is not None and span.status.description:
check(span.status.description, f"{span.name} status description")
if collector is not None:
for metric in _scoped_metrics(collector, scope_name):
for point in metric.data.data_points:
for key, value in dict(point.attributes or {}).items():
check(value, f"metric {metric.name} attribute {key}")
assert not leaks, "forbidden values leaked into telemetry:\n" + "\n".join(
sorted(leaks)
)
def assert_package_never_imports_sdk(*module_names):
"""
Import the named modules in a fresh interpreter and assert none of them
imported `opentelemetry.sdk`.
Run the test that calls this early in your suite: on macOS with CPython
3.13, starting a subprocess from a process with many threads can crash.
"""
imports = "; ".join(f"import {name}" for name in module_names)
code = (
f"import sys; {imports}; "
"print([m for m in sys.modules if m.startswith('opentelemetry.sdk')])"
)
result = subprocess.run(
[sys.executable, "-c", code], capture_output=True, text=True, check=True
)
assert result.stdout.strip() == "[]", (
f"importing {module_names} pulled in the OpenTelemetry SDK: "
f"{result.stdout.strip()}"
)

View file

@ -0,0 +1,40 @@
"""
Index of the documented template contexts for Datasette's core HTML pages.
This module deliberately contains no documentation strings of its own -
the documentation lives next to the code it describes:
- Every page renders a Context dataclass defined in its view module
(DatabaseContext, QueryContext in views/database.py, TableContext in
views/table.py, RowContext in views/row.py). Fields added by view code
carry ``help`` metadata; fields declared with from_extra() take their
documentation from the description on the matching Extra class in
views/table_extras.py.
- The keys render_template() adds to every page are documented in
TEMPLATE_BASE_CONTEXT in datasette/app.py, next to the code that adds
them.
The contract tests in tests/test_template_context.py assert that the real
rendered context for each page exactly matches what is documented, and
docs/template_context_doc.py generates docs/template_context.rst from the
same classes.
"""
from datasette.app import TEMPLATE_BASE_CONTEXT
from datasette.views.database import DatabaseContext, QueryContext
from datasette.views.row import RowContext
from datasette.views.table import TableContext
PAGES = {
"database": DatabaseContext,
"query": QueryContext,
"table": TableContext,
"row": RowContext,
}
def documented_context_keys(page_name):
"Set of every documented key for the named page, including base context keys"
return set(TEMPLATE_BASE_CONTEXT) | {
f.name for f in PAGES[page_name].documented_fields()
}

View file

@ -1,5 +1,5 @@
<script src="{{ base_url }}-/static/sql-formatter-2.3.3.min.js" defer></script>
<script src="{{ base_url }}-/static/cm-editor-6.0.1.bundle.js"></script>
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script>
<script src="{{ static('cm-editor-6.0.1.bundle.js') }}"></script>
<style>
.cm-editor {
resize: both;

View file

@ -6,8 +6,20 @@
padding: 1.5em;
margin-bottom: 2em;
}
.permission-form form {
max-width: 60rem;
}
.permission-form-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.permission-form-result {
margin-top: 1rem;
max-width: 60rem;
}
.form-section {
margin-bottom: 1em;
margin-bottom: 1.25em;
}
.form-section label {
display: block;
@ -15,22 +27,51 @@
font-weight: bold;
}
.form-section input[type="text"],
.form-section select {
width: 100%;
max-width: 500px;
padding: 0.5em;
.form-section input[type="number"],
.form-section select,
.permission-textarea {
background-color: #fff;
border: 1px solid #aaa;
border-radius: 4px;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 3px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08);
color: #222;
font-family: inherit;
font-size: 1rem;
line-height: 1.4;
max-width: none;
width: 100%;
}
.form-section input[type="text"] {
height: 3rem;
padding: 0.6rem 0.75rem;
}
.form-section input[type="number"] {
height: 3rem;
max-width: 7rem;
padding: 0.6rem 0.75rem;
}
.form-section select {
height: 3rem;
padding: 0.6rem 0.75rem;
}
.permission-textarea {
font-family: monospace;
min-height: 12rem;
padding: 0.75rem;
resize: vertical;
}
.form-section input[type="text"]:focus,
.form-section select:focus {
outline: 2px solid #0066cc;
.form-section input[type="number"]:focus,
.form-section select:focus,
.permission-textarea:focus {
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18);
outline: none;
}
.form-section small {
display: block;
margin-top: 0.3em;
margin-top: 0.45em;
color: #666;
}
.form-actions {
@ -142,4 +183,9 @@
text-align: center;
color: #666;
}
@media only screen and (max-width: 576px) {
.permission-form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View file

@ -44,10 +44,10 @@
</style>
<nav class="permissions-debug-tabs">
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</a>
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rules</a>
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Explain</a>
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Access map</a>
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rule explorer</a>
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Activity</a>
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
</nav>

View file

@ -3,29 +3,11 @@
{% block title %}Debug allow rules{% endblock %}
{% block extra_head %}
{% include "_permission_ui_styles.html" %}
<style>
textarea {
height: 10em;
width: 95%;
box-sizing: border-box;
padding: 0.5em;
border: 2px dotted black;
}
.two-col {
display: inline-block;
width: 48%;
}
.two-col label {
width: 48%;
}
p.message-warning {
white-space: pre-wrap;
}
@media only screen and (max-width: 576px) {
.two-col {
width: 100%;
}
}
</style>
{% endblock %}
@ -38,24 +20,28 @@ p.message-warning {
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
<div class="two-col">
<p><label>Allow block</label></p>
<textarea name="allow">{{ allow_input }}</textarea>
</div>
<div class="two-col">
<p><label>Actor</label></p>
<textarea name="actor">{{ actor_input }}</textarea>
</div>
<div style="margin-top: 1em;">
<input type="submit" value="Apply allow block to actor">
</div>
</form>
<div class="permission-form">
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get">
<div class="permission-form-grid">
<div class="form-section">
<label for="allow-block">Allow block</label>
<textarea class="permission-textarea" id="allow-block" name="allow">{{ allow_input }}</textarea>
</div>
<div class="form-section">
<label for="allow-actor">Actor</label>
<textarea class="permission-textarea" id="allow-actor" name="actor">{{ actor_input }}</textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="submit-btn">Apply allow block to actor</button>
</div>
</form>
{% if error %}<p class="message-warning">{{ error }}</p>{% endif %}
{% if error %}<p class="message-warning permission-form-result">{{ error }}</p>{% endif %}
{% if result == "True" %}<p class="message-info">Result: allow</p>{% endif %}
{% if result == "True" %}<p class="message-info permission-form-result">Result: allow</p>{% endif %}
{% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
{% if result == "False" %}<p class="message-error permission-form-result">Result: deny</p>{% endif %}
</div>
{% endblock %}

View file

@ -3,7 +3,6 @@
{% block title %}API Explorer{% endblock %}
{% block extra_head %}
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
{% endblock %}
{% block content %}
@ -126,7 +125,7 @@ getForm.addEventListener("submit", (ev) => {
document.getElementById('response-status').textContent = response.status;
return response.json();
}).then((data) => {
output.querySelector('pre').innerHTML = jsonFormatHighlight(data);
output.querySelector('pre').textContent = JSON.stringify(data, null, 2);
errorList.style.display = 'none';
}).catch((error) => {
alert(error);
@ -174,7 +173,7 @@ postForm.addEventListener("submit", (ev) => {
} else {
errorList.style.display = 'none';
}
output.querySelector('pre').innerHTML = jsonFormatHighlight(data);
output.querySelector('pre').textContent = JSON.stringify(data, null, 2);
output.style.display = 'block';
}).catch(err => {
alert("Error: " + err);

View file

@ -2,13 +2,14 @@
<html lang="en">
<head>
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="{{ urls.static('app.css') }}?{{ app_css_hash }}">
<link rel="stylesheet" href="{{ static('app.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{% for url in extra_css_urls %}
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
{% endfor %}
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
<script src="{{ urls.static('datasette-manager.js') }}" defer></script>
<script src="{{ static('modal.js') }}" defer></script>
<script src="{{ static('datasette-manager.js') }}" defer></script>
{% for url in extra_js_urls %}
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
{% endfor %}
@ -70,7 +71,7 @@
{% endfor %}
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
<script src="{{ static('navigation-search.js') }}" defer></script>
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search>
</body>
</html>

View file

@ -6,6 +6,10 @@
{{- super() -}}
{% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %}
{% if database_page_data.createTable %}
<script>window._datasetteDatabaseData = {{ database_page_data|tojson }};</script>
<script src="{{ static('edit-tools.js') }}" defer></script>
{% endif %}
{% endblock %}
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
@ -72,7 +76,7 @@
<div class="db-table">
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
<p>{% if table.count is none %}Many rows{% elif table.count == count_limit + 1 %}&gt;{{ "{:,}".format(count_limit) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
<p>{% if table.count is none %}Many rows{% elif table.count_truncated %}&gt;{{ "{:,}".format(table.count - 1) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
</div>
{% endif %}
{% endfor %}

View file

@ -9,7 +9,7 @@
{% include "_permissions_debug_tabs.html" %}
<p style="margin-bottom: 2em;">
This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}.
This Datasette instance has registered {{ data.actions|length }} action{{ data.actions|length != 1 and "s" or "" }}.
Actions are used by the permission system to control access to different features.
</p>
@ -26,7 +26,7 @@
</tr>
</thead>
<tbody>
{% for action in data %}
{% for action in data.actions %}
<tr>
<td><strong>{{ action.name }}</strong></td>
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>

View file

@ -3,7 +3,6 @@
{% block title %}Allowed Resources{% endblock %}
{% block extra_head %}
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
{% include "_permission_ui_styles.html" %}
{% include "_debug_common_functions.html" %}
{% endblock %}
@ -49,7 +48,7 @@
<div class="form-section">
<label for="page_size">Page size:</label>
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
<small>Number of results per page (max 200)</small>
</div>
@ -88,7 +87,7 @@ const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
(function() {
const params = populateFormFromURL();
const action = params.get('action');
const page = params.get('page');
const page = params.get('_page');
if (action) {
fetchResults(page ? parseInt(page) : 1);
}
@ -102,14 +101,14 @@ async function fetchResults(page = 1) {
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value && key !== 'page_size') {
if (value && key !== '_size' && key !== '_page') {
params.append(key, value);
}
}
const pageSize = document.getElementById('page_size').value || '50';
params.append('page', page.toString());
params.append('page_size', pageSize);
params.append('_page', page.toString());
params.append('_size', pageSize);
try {
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
@ -198,7 +197,7 @@ function displayResults(data) {
}
// Update raw JSON
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
}
function displayError(data) {
@ -208,7 +207,7 @@ function displayError(data) {
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
}
// Disable child input if parent is empty

View file

@ -4,7 +4,7 @@
{% block extra_head %}
{{ super() }}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
<script src="{{ static('autocomplete.js') }}" defer></script>
{% endblock %}
{% block content %}

View file

@ -1,9 +1,8 @@
{% extends "base.html" %}
{% block title %}Permission Check{% endblock %}
{% block title %}Explain a permission decision{% endblock %}
{% block extra_head %}
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
{% include "_permission_ui_styles.html" %}
{% include "_debug_common_functions.html" %}
<style>
@ -13,29 +12,35 @@
border-radius: 5px;
}
#output.allowed {
background-color: #e8f5e9;
background-color: #f3fbf4;
border: 2px solid #4caf50;
}
#output.denied {
background-color: #ffebee;
background-color: #fff7f7;
border: 2px solid #f44336;
}
#output h2 {
margin-top: 0;
}
#output .result-badge {
#output h3 {
margin-bottom: 0.5em;
}
#output .result-badge,
.effect-badge,
.rule-status {
display: inline-block;
padding: 0.3em 0.8em;
padding: 0.2em 0.5em;
border-radius: 3px;
font-weight: bold;
font-size: 1.1em;
}
#output .allowed-badge {
background-color: #4caf50;
#output .allowed-badge,
.effect-allow {
background-color: #2e7d32;
color: white;
}
#output .denied-badge {
background-color: #f44336;
#output .denied-badge,
.effect-deny {
background-color: #c62828;
color: white;
}
.details-section {
@ -48,70 +53,130 @@
.details-section dd {
margin-left: 1em;
}
.explanation-section {
background: rgba(255, 255, 255, 0.75);
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 1em;
padding: 0 1em 1em;
}
.rules-table {
border-collapse: collapse;
width: 100%;
}
.rules-table th,
.rules-table td {
border-bottom: 1px solid #ddd;
padding: 0.5em;
text-align: left;
vertical-align: top;
}
.rule-status {
background: #e8f5e9;
color: #1b5e20;
}
.rule-ignored {
background: #eee;
color: #555;
font-weight: normal;
}
.requirement-allowed {
color: #1b5e20;
}
.requirement-denied {
color: #b71c1c;
}
@media only screen and (max-width: 576px) {
.rules-table,
.rules-table tbody,
.rules-table tr,
.rules-table td {
display: block;
}
.rules-table thead {
display: none;
}
.rules-table td::before {
content: attr(data-label) ": ";
font-weight: bold;
}
}
</style>
{% endblock %}
{% block content %}
<h1>Permission check</h1>
<h1>Explain a permission decision</h1>
{% set current_tab = "check" %}
{% include "_permissions_debug_tabs.html" %}
<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
{% if request.actor %}
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
{% else %}
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
{% endif %}
<p>Test an actor, action and resource. The result explains which rules matched, which specificity level won, and whether actor restrictions or required actions changed the verdict.</p>
<div class="permission-form">
<form id="check-form" method="get" action="{{ urls.path("-/check") }}">
<form id="check-form" method="get" action="{{ urls.path('-/check') }}">
<div class="form-section">
<label for="action">Action (permission name):</label>
<label for="actor">Actor JSON:</label>
<textarea class="permission-textarea" id="actor" name="actor">{{ actor_json }}</textarea>
<small>Use <code>null</code> for an anonymous actor. This actor is simulated; it does not change who you are signed in as.</small>
</div>
<div class="form-section">
<label for="action">Action:</label>
<select id="action" name="action" required>
<option value="">Select an action...</option>
{% for action_name in sorted_actions %}
<option value="{{ action_name }}">{{ action_name }}</option>
{% for action in actions %}
<option value="{{ action.name }}">{{ action.name }}{% if action.description %} — {{ action.description }}{% endif %}</option>
{% endfor %}
</select>
<small>The permission action to check</small>
<small id="action-help">The operation to evaluate</small>
</div>
<div class="form-section">
<label for="parent">Parent resource (optional):</label>
<div class="form-section" id="parent-section">
<label for="parent">Parent resource:</label>
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
<small>For database-level permissions, specify the database name</small>
<small>The database or other parent resource</small>
</div>
<div class="form-section">
<label for="child">Child resource (optional):</label>
<input type="text" id="child" name="child" placeholder="e.g., table name">
<small>For table-level permissions, specify the table name (requires parent)</small>
<div class="form-section" id="child-section">
<label for="child">Child resource:</label>
<input type="text" id="child" name="child" placeholder="e.g., table or query name">
<small>The table, query or other child resource</small>
</div>
<div class="form-actions">
<button type="submit" class="submit-btn" id="submit-btn">Check Permission</button>
<button type="submit" class="submit-btn" id="submit-btn">Explain decision</button>
</div>
</form>
</div>
<div id="output" style="display: none;">
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
<p id="result-summary"></p>
<dl class="details-section">
<dt>Actor:</dt>
<dd><code id="result-actor"></code></dd>
<dt>Action:</dt>
<dd id="result-action"></dd>
<dt>Resource Path:</dt>
<dd id="result-resource"></dd>
<dt>Actor ID:</dt>
<dd id="result-actor"></dd>
<div id="additional-details"></div>
<dd><code id="result-action"></code></dd>
<dt>Resource:</dt>
<dd><code id="result-resource"></code></dd>
</dl>
<section class="explanation-section">
<h3>Matching rules</h3>
<div id="matching-rules"></div>
</section>
<section class="explanation-section" id="restrictions-section">
<h3>Actor restrictions</h3>
<div id="restriction-results"></div>
</section>
<section class="explanation-section" id="requirements-section">
<h3>Required actions</h3>
<div id="requirement-results"></div>
</section>
<details style="margin-top: 1em;">
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
@ -119,152 +184,134 @@
</div>
<script>
const actions = Object.fromEntries({{ actions|tojson }}.map(action => [action.name, action]));
const form = document.getElementById('check-form');
const output = document.getElementById('output');
const submitBtn = document.getElementById('submit-btn');
const actionSelect = document.getElementById('action');
function updateResourceFields() {
const action = actions[actionSelect.value];
document.getElementById('parent-section').style.display = action && action.takes_parent ? 'block' : 'none';
document.getElementById('child-section').style.display = action && action.takes_child ? 'block' : 'none';
let help = action && action.description ? action.description : 'The operation to evaluate';
if (action && action.also_requires) {
help += `; also requires ${action.also_requires}`;
}
document.getElementById('action-help').textContent = help;
}
async function performCheck() {
submitBtn.disabled = true;
submitBtn.textContent = 'Checking...';
const formData = new FormData(form);
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value) {
params.append(key, value);
}
}
submitBtn.textContent = 'Explaining...';
const params = new URLSearchParams(new FormData(form));
try {
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
method: 'GET',
headers: {
'Accept': 'application/json',
}
headers: {'Accept': 'application/json'}
});
const data = await response.json();
if (response.ok) {
displayResult(data);
} else {
displayError(data);
}
} catch (error) {
alert('Error: ' + error.message);
displayError({error: error.message});
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Check Permission';
submitBtn.textContent = 'Explain decision';
}
}
// Populate form on initial load
(function() {
const params = populateFormFromURL();
const action = params.get('action');
if (action) {
performCheck();
}
})();
function displayResult(data) {
output.style.display = 'block';
// Set badge and styling
const resultBadge = document.getElementById('result-badge');
if (data.allowed) {
output.className = 'allowed';
resultBadge.className = 'result-badge allowed-badge';
resultBadge.textContent = 'ALLOWED ✓';
} else {
output.className = 'denied';
resultBadge.className = 'result-badge denied-badge';
resultBadge.textContent = 'DENIED ✗';
output.className = data.allowed ? 'allowed' : 'denied';
resultBadge.className = `result-badge ${data.allowed ? 'allowed-badge' : 'denied-badge'}`;
resultBadge.textContent = data.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
document.getElementById('result-summary').textContent = data.explanation.summary;
document.getElementById('result-actor').textContent = data.actor === null ? 'anonymous' : JSON.stringify(data.actor);
document.getElementById('result-action').textContent = data.action;
document.getElementById('result-resource').textContent = data.resource.path;
displayRules(data.explanation);
displayRestrictions(data.explanation.restrictions);
displayRequirements(data.explanation.required_actions);
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
}
function displayRules(explanation) {
const container = document.getElementById('matching-rules');
if (!explanation.matched_rules.length) {
container.innerHTML = '<p>No rules matched. Datasette denies access when there is no matching rule.</p>';
return;
}
// Basic details
document.getElementById('result-action').textContent = data.action || 'N/A';
document.getElementById('result-resource').textContent = data.resource?.path || '/';
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
// Additional details
const additionalDetails = document.getElementById('additional-details');
additionalDetails.innerHTML = '';
if (data.reason !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Reason:';
const dd = document.createElement('dd');
dd.textContent = data.reason || 'N/A';
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
let html = '<table class="rules-table"><thead><tr><th>Effect</th><th>Scope</th><th>Source</th><th>Reason</th><th>Role in decision</th></tr></thead><tbody>';
for (const rule of explanation.matched_rules) {
const status = rule.decisive
? '<span class="rule-status">Decisive</span>'
: `<span class="rule-status rule-ignored">${escapeHtml(rule.ignored_because)}</span>`;
html += '<tr>';
html += `<td data-label="Effect"><span class="effect-badge effect-${rule.effect}">${rule.effect.toUpperCase()}</span></td>`;
html += `<td data-label="Scope">${escapeHtml(rule.scope)}</td>`;
html += `<td data-label="Source"><code>${escapeHtml(rule.source || 'unknown')}</code></td>`;
html += `<td data-label="Reason">${escapeHtml(rule.reason || 'No reason supplied')}</td>`;
html += `<td data-label="Role in decision">${status}</td>`;
html += '</tr>';
}
container.innerHTML = html + '</tbody></table>';
}
if (data.source_plugin !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Source Plugin:';
const dd = document.createElement('dd');
dd.textContent = data.source_plugin || 'N/A';
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
function displayRestrictions(restrictions) {
const section = document.getElementById('restrictions-section');
const container = document.getElementById('restriction-results');
section.style.display = restrictions.length ? 'block' : 'none';
container.innerHTML = restrictions.map(restriction => {
const className = restriction.allowed ? 'requirement-allowed' : 'requirement-denied';
const verdict = restriction.allowed ? 'INCLUDED ✓' : 'EXCLUDED ✗';
return `<p class="${className}"><strong>${verdict}</strong> by <code>${escapeHtml(restriction.source || 'unknown')}</code>: ${escapeHtml(restriction.reason)}</p>`;
}).join('');
}
if (data.used_default !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Used Default:';
const dd = document.createElement('dd');
dd.textContent = data.used_default ? 'Yes' : 'No';
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
if (data.depth !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Depth:';
const dd = document.createElement('dd');
dd.textContent = data.depth;
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
// Raw JSON
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
// Scroll to output
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
function displayRequirements(requirements) {
const section = document.getElementById('requirements-section');
const container = document.getElementById('requirement-results');
section.style.display = requirements.length ? 'block' : 'none';
container.innerHTML = requirements.map(requirement => {
const className = requirement.allowed ? 'requirement-allowed' : 'requirement-denied';
const verdict = requirement.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
return `<p class="${className}"><strong>${escapeHtml(requirement.action)}: ${verdict}</strong> — ${escapeHtml(requirement.summary)}</p>`;
}).join('');
}
function displayError(data) {
output.style.display = 'block';
output.className = 'denied';
const resultBadge = document.getElementById('result-badge');
resultBadge.className = 'result-badge denied-badge';
resultBadge.textContent = 'ERROR';
document.getElementById('result-action').textContent = 'N/A';
document.getElementById('result-resource').textContent = 'N/A';
document.getElementById('result-actor').textContent = 'N/A';
const additionalDetails = document.getElementById('additional-details');
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
document.getElementById('result-summary').textContent = data.error || 'Unknown error';
document.getElementById('result-actor').textContent = '—';
document.getElementById('result-action').textContent = '—';
document.getElementById('result-resource').textContent = '—';
document.getElementById('matching-rules').innerHTML = '';
document.getElementById('restrictions-section').style.display = 'none';
document.getElementById('requirements-section').style.display = 'none';
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
}
// Disable child input if parent is empty
const parentInput = document.getElementById('parent');
const childInput = document.getElementById('child');
childInput.addEventListener('focus', () => {
if (!parentInput.value) {
alert('Please specify a parent resource first before adding a child resource.');
parentInput.focus();
}
form.addEventListener('submit', event => {
event.preventDefault();
performCheck();
});
</script>
actionSelect.addEventListener('change', updateResourceFields);
(function initializeFromUrl() {
const params = populateFormFromURL();
updateResourceFields();
if (params.get('action')) {
performCheck();
}
})();
</script>
{% endblock %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Debug permissions{% endblock %}
{% block title %}Permission activity{% endblock %}
{% block extra_head %}
{% include "_permission_ui_styles.html" %}
@ -20,60 +20,45 @@
.check-action, .check-when, .check-result {
font-size: 1.3em;
}
textarea {
height: 10em;
width: 95%;
box-sizing: border-box;
padding: 0.5em;
border: 2px dotted black;
}
.two-col {
display: inline-block;
width: 48%;
}
.two-col label {
width: 48%;
}
@media only screen and (max-width: 576px) {
.two-col {
width: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<h1>Permission playground</h1>
<h1>Permission activity</h1>
{% set current_tab = "permissions" %}
{% include "_permissions_debug_tabs.html" %}
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
<h2>Raw simulator</h2>
<p>This form runs a hypothetical permission check and returns its raw explanation JSON. Use the <a href="{{ urls.path('-/check') }}">Explain tool</a> for a visual explanation of the same decision.</p>
<div class="permission-form">
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
<div class="two-col">
<div class="form-section">
<label>Actor</label>
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
<div class="permission-form-grid">
<div>
<div class="form-section">
<label for="activity-actor">Actor</label>
<textarea class="permission-textarea" id="activity-actor" name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
</div>
</div>
</div>
<div class="two-col" style="vertical-align: top">
<div class="form-section">
<label for="permission">Action</label>
<select name="permission" id="permission">
{% for permission in permissions %}
<option value="{{ permission.name }}">{{ permission.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-section">
<label for="resource_1">Parent</label>
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
</div>
<div class="form-section">
<label for="resource_2">Child</label>
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
<div>
<div class="form-section">
<label for="permission">Action</label>
<select name="permission" id="permission">
{% for permission in permissions %}
<option value="{{ permission.name }}">{{ permission.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-section">
<label for="resource_1">Parent</label>
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
</div>
<div class="form-section">
<label for="resource_2">Child</label>
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
</div>
</div>
</div>
<div class="form-actions">
@ -125,7 +110,7 @@ debugPost.addEventListener('submit', function(ev) {
});
</script>
<h1>Recent permissions checks</h1>
<h2>Recent permission checks</h2>
<p>
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},

View file

@ -3,7 +3,6 @@
{% block title %}Permission Rules{% endblock %}
{% block extra_head %}
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
{% include "_permission_ui_styles.html" %}
{% include "_debug_common_functions.html" %}
{% endblock %}
@ -37,7 +36,7 @@
<div class="form-section">
<label for="page_size">Page size:</label>
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
<small>Number of results per page (max 200)</small>
</div>
@ -75,7 +74,7 @@ const submitBtn = document.getElementById('submit-btn');
(function() {
const params = populateFormFromURL();
const action = params.get('action');
const page = params.get('page');
const page = params.get('_page');
if (action) {
fetchResults(page ? parseInt(page) : 1);
}
@ -89,14 +88,14 @@ async function fetchResults(page = 1) {
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value && key !== 'page_size') {
if (value && key !== '_size' && key !== '_page') {
params.append(key, value);
}
}
const pageSize = document.getElementById('page_size').value || '50';
params.append('page', page.toString());
params.append('page_size', pageSize);
params.append('_page', page.toString());
params.append('_size', pageSize);
try {
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
@ -185,7 +184,7 @@ function displayResults(data) {
}
// Update raw JSON
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
}
function displayError(data) {
@ -195,7 +194,7 @@ function displayError(data) {
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
document.getElementById('raw-json').textContent = JSON.stringify(data, null, 2);
}
</script>

View file

@ -26,8 +26,7 @@
{% if database.show_table_row_counts %}{{ "{:,}".format(database.hidden_table_rows_sum) }} rows in {% endif %}{{ database.hidden_tables_count }} hidden table{% if database.hidden_tables_count != 1 %}s{% endif -%}
{% endif -%}
{% if database.views_count -%}
{% if database.tables_count or database.hidden_tables_count %}, {% endif -%}
{{ "{:,}".format(database.views_count) }} view{% if database.views_count != 1 %}s{% endif %}
, {{ "{:,}".format(database.views_count) }} view{% if database.views_count != 1 %}s{% endif %}
{% endif %}
</p>
<p>{% for table in database.tables_and_views_truncated %}<a href="{{ urls.table(database.name, table.name) }}"{% if table.count %} title="{{ table.count }} rows"{% endif %}>{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_and_views_more %}, <a href="{{ urls.database(database.name) }}">...</a>{% endif %}</p>

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<title>Datasette: Pattern Portfolio</title>
<link rel="stylesheet" href="{{ base_url }}-/static/app.css?{{ app_css_hash }}">
<link rel="stylesheet" href="{{ static('app.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="noindex">
<style></style>
@ -202,9 +202,9 @@
<h3>3 rows
where characteristic_id = 2
</h3>
<form class="filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
<form class="core filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
<div class="filter-row">
<div class="filter-row filter-controls-row">
<div class="select-wrapper">
<select name="_filter_column_1">
<option value="">- remove filter -</option>
@ -238,7 +238,7 @@
</select>
</div><input type="text" name="_filter_value_1" class="filter-value" value="2">
</div>
<div class="filter-row">
<div class="filter-row filter-controls-row">
<div class="select-wrapper">
<select name="_filter_column">
<option value="">- column -</option>
@ -272,8 +272,8 @@
</select>
</div><input type="text" name="_filter_value" class="filter-value">
</div>
<div class="filter-row">
<div class="select-wrapper small-screen-only">
<div class="filter-row filter-actions-row">
<div class="select-wrapper">
<select name="_sort" id="sort_by">
<option value="">Sort...</option>
<option value="rowid" selected>Sort by rowid</option>
@ -281,8 +281,8 @@
<option value="characteristic_id">Sort by characteristic_id</option>
</select>
</div>
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"> descending</label>
<input type="submit" value="Apply">
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc"> descending</label>
<input type="submit" value="Apply filters">
</div>
</form>

View file

@ -7,9 +7,9 @@
{% if row_mutation_ui %}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
{% if table_page_data.foreignKeys %}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
<script src="{{ static('autocomplete.js') }}" defer></script>
{% endif %}
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
<script src="{{ static('edit-tools.js') }}" defer></script>
{% endif %}
<style>
@media only screen and (max-width: 576px) {

View file

@ -1,17 +1,17 @@
{% extends "base.html" %}
{% block title %}{{ database }}: {{ table }}: {% if count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
{% block title %}{{ database }}: {{ table }}: {% if count_truncated %}{{ "{:,}".format(count - 1) }}+ rows{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
{% block extra_head %}
{{- super() -}}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
<script src="{{ static('column-chooser.js') }}" defer></script>
{% if table_page_data.foreignKeys %}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
<script src="{{ static('autocomplete.js') }}" defer></script>
{% endif %}
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
<script src="{{ static('edit-tools.js') }}" defer></script>
<script src="{{ static('table.js') }}" defer></script>
<script src="{{ static('mobile-column-actions.js') }}" defer></script>
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
<style>
@media only screen and (max-width: 576px) {
@ -47,20 +47,21 @@
{% endif %}
{% if count or human_description_en %}
<h3>
{% if count == count_limit + 1 %}&gt;{{ "{:,}".format(count_limit) }} rows
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
<h3 class="table-summary">
{% if count_truncated %}<span class="table-count" aria-live="polite">{{ "{:,}".format(count - 1) }}+ rows</span>
<button type="button" class="count-all" data-count-url="{{ urls.table(database, table) }}/-/count">count all</button>
<span class="count-error" role="alert"></span>
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
{% if human_description_en %}{{ human_description_en }}{% endif %}
{% if human_description_en %}<span class="table-summary-description">{{ human_description_en }}</span>{% endif %}
</h3>
{% endif %}
<form class="core" class="filters" action="{{ urls.table(database, table) }}" method="get">
<form class="core filters" action="{{ urls.table(database, table) }}" method="get">
{% if supports_search %}
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
{% endif %}
{% for column, lookup, value in filters.selections() %}
<div class="filter-row">
<div class="filter-row filter-controls-row">
<div class="select-wrapper">
<select name="_filter_column_{{ loop.index }}">
<option value="">- remove filter -</option>
@ -77,7 +78,7 @@
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
</div>
{% endfor %}
<div class="filter-row">
<div class="filter-row filter-controls-row">
<div class="select-wrapper">
<select name="_filter_column">
<option value="">- column -</option>
@ -93,9 +94,9 @@
</select>
</div><input type="text" name="_filter_value" class="filter-value">
</div>
<div class="filter-row">
<div class="filter-row filter-actions-row">
{% if is_sortable %}
<div class="select-wrapper small-screen-only">
<div class="select-wrapper">
<select name="_sort" id="sort_by">
<option value="">Sort...</option>
{% for column in display_columns %}
@ -105,12 +106,12 @@
{% endfor %}
</select>
</div>
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"{% if sort_desc %} checked{% endif %}> descending</label>
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc" tabindex="0"{% if sort_desc %} checked{% endif %}> descending</label>
{% endif %}
{% for key, value in form_hidden_args %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<input type="submit" value="Apply">
<input type="submit" value="Apply filters" tabindex="0">
</div>
</form>

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import dataclasses
import time
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
import itsdangerous
@ -18,6 +18,21 @@ if TYPE_CHECKING:
from datasette.app import Datasette
class TokenInvalid(Exception):
"""
Raised by a TokenHandler when a token it recognizes is invalid -
for example a bad signature, malformed payload or expired token.
Datasette responds to this with an HTTP 401 error. Handlers should
return None instead for tokens they do not recognize at all, so that
other registered handlers get a chance to verify them.
"""
def __init__(self, message="Invalid token"):
self.message = message
super().__init__(message)
@dataclasses.dataclass
class TokenRestrictions:
"""
@ -35,24 +50,24 @@ class TokenRestrictions:
database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)
def allow_all(self, action: str) -> "TokenRestrictions":
def allow_all(self, action: str) -> TokenRestrictions:
"""Allow an action across all databases and resources."""
self.all.append(action)
return self
def allow_database(self, database: str, action: str) -> "TokenRestrictions":
def allow_database(self, database: str, action: str) -> TokenRestrictions:
"""Allow an action on a specific database."""
self.database.setdefault(database, []).append(action)
return self
def allow_resource(
self, database: str, resource: str, action: str
) -> "TokenRestrictions":
) -> TokenRestrictions:
"""Allow an action on a specific resource within a database."""
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
return self
def abbreviated(self, datasette: "Datasette") -> Optional[dict]:
def abbreviated(self, datasette: Datasette) -> dict | None:
"""
Return the abbreviated ``_r`` dictionary shape for this set of
restrictions, using action abbreviations registered with ``datasette``.
@ -97,19 +112,23 @@ class TokenHandler:
async def create_token(
self,
datasette: "Datasette",
datasette: Datasette,
actor_id: str,
*,
expires_after: Optional[int] = None,
restrictions: Optional[TokenRestrictions] = None,
expires_after: int | None = None,
restrictions: TokenRestrictions | None = None,
) -> str:
"""Create and return a token string for the given actor."""
raise NotImplementedError
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
async def verify_token(self, datasette: Datasette, token: str) -> dict | None:
"""
Verify a token and return an actor dict, or None if this handler
does not recognize the token.
Verify a token and return an actor dict.
Return None if this handler does not recognize the token at all,
so other handlers can try it. Raise TokenInvalid if the token is
recognized but invalid (bad signature, malformed, expired) - the
request will fail with a 401 error.
"""
raise NotImplementedError
@ -123,11 +142,11 @@ class SignedTokenHandler(TokenHandler):
async def create_token(
self,
datasette: "Datasette",
datasette: Datasette,
actor_id: str,
*,
expires_after: Optional[int] = None,
restrictions: Optional[TokenRestrictions] = None,
expires_after: int | None = None,
restrictions: TokenRestrictions | None = None,
) -> str:
if not datasette.setting("allow_signed_tokens"):
raise ValueError(
@ -144,32 +163,35 @@ class SignedTokenHandler(TokenHandler):
token["_r"] = abbreviated
return "dstok_{}".format(datasette.sign(token, namespace="token"))
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
async def verify_token(self, datasette: Datasette, token: str) -> dict | None:
prefix = "dstok_"
if not datasette.setting("allow_signed_tokens"):
if not token.startswith(prefix):
# Not one of our tokens - leave it for other handlers
return None
if not datasette.setting("allow_signed_tokens"):
raise TokenInvalid(
"Signed tokens are not enabled for this Datasette instance"
)
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
if not token.startswith(prefix):
return None
raw = token[len(prefix) :]
try:
decoded = datasette.unsign(raw, namespace="token")
except itsdangerous.BadSignature:
return None
raise TokenInvalid("Invalid token signature")
if "t" not in decoded:
return None
raise TokenInvalid("Invalid token: no timestamp")
created = decoded["t"]
if not isinstance(created, int):
return None
raise TokenInvalid("Invalid token: invalid timestamp")
duration = decoded.get("d")
if duration is not None and not isinstance(duration, int):
return None
raise TokenInvalid("Invalid token: invalid duration")
if (duration is None and max_signed_tokens_ttl) or (
duration is not None
@ -178,9 +200,8 @@ class SignedTokenHandler(TokenHandler):
):
duration = max_signed_tokens_ttl
if duration:
if time.time() - created > duration:
return None
if duration and time.time() - created > duration:
raise TokenInvalid("Token has expired")
actor = {"id": decoded["a"], "token": "dstok"}

View file

@ -1,10 +1,11 @@
import asyncio
import json
import time
import traceback
from contextlib import contextmanager
from contextvars import ContextVar
from markupsafe import escape
import time
import json
import traceback
tracers = {}
@ -132,17 +133,17 @@ class AsgiTracer:
"num_traces": len(traces),
"traces": traces,
}
try:
content_type = [
content_type = next(
(
v.decode("utf8")
for k, v in response_headers
if k.lower() == b"content-type"
][0]
except IndexError:
content_type = ""
),
"",
)
if "text/html" in content_type and b"</body>" in accumulated_body:
extra = escape(json.dumps(trace_info, indent=2))
extra_html = f"<pre>{extra}</pre></body>".encode("utf8")
extra_html = f"<pre>{extra}</pre></body>".encode()
accumulated_body = accumulated_body.replace(b"</body>", extra_html)
elif "json" in content_type and accumulated_body.startswith(b"{"):
data = json.loads(accumulated_body.decode("utf8"))

View file

@ -1,6 +1,7 @@
from .utils import tilde_encode, path_with_format, PrefixedUrlString
import urllib
from .utils import PrefixedUrlString, path_with_format, tilde_encode
class Urls:
def __init__(self, ds):
@ -8,8 +9,7 @@ class Urls:
def path(self, path, format=None):
if not isinstance(path, PrefixedUrlString):
if path.startswith("/"):
path = path[1:]
path = path.removeprefix("/")
path = self.ds.setting("base_url") + path
if format is not None:
path = path_with_format(path=path, format=format)
@ -56,6 +56,7 @@ class Urls:
return PrefixedUrlString(path)
def row_blob(self, database, table, row_path, column):
return self.table(database, table) + "/{}.blob?_blob_column={}".format(
row_path, urllib.parse.quote_plus(column)
return (
self.table(database, table)
+ f"/{row_path}.blob?_blob_column={urllib.parse.quote_plus(column)}"
)

View file

@ -1,28 +1,31 @@
import asyncio
from contextlib import contextmanager
import aiofiles
import click
from collections import OrderedDict, namedtuple, Counter
import base64
import binascii
import copy
import dataclasses
import base64
import hashlib
import inspect
import json
import markupsafe
import mergedeep
import os
import re
import secrets
import shlex
import shutil
import tempfile
import typing
import time
import types
import secrets
import shutil
from typing import Iterable, List, Tuple
import typing
import urllib
from collections import Counter, OrderedDict, namedtuple
from collections.abc import Iterable
from contextlib import contextmanager
import aiofiles
import click
import markupsafe
import mergedeep
import yaml
from .shutil_backport import copytree
from .sqlite import sqlite3, supports_table_xinfo
@ -35,7 +38,7 @@ if typing.TYPE_CHECKING:
class PaginatedResources:
"""Paginated results from allowed_resources query."""
resources: List["Resource"]
resources: list["Resource"]
next: str | None # Keyset token for next page (None if no more results)
_datasette: typing.Any = dataclasses.field(default=None, repr=False)
_action: str = dataclasses.field(default=None, repr=False)
@ -82,22 +85,132 @@ class PaginatedResources:
# From https://www.sqlite.org/lang_keywords.html
reserved_words = set(
(
"abort action add after all alter analyze and as asc attach autoincrement "
"before begin between by cascade case cast check collate column commit "
"conflict constraint create cross current_date current_time "
"current_timestamp database default deferrable deferred delete desc detach "
"distinct drop each else end escape except exclusive exists explain fail "
"for foreign from full glob group having if ignore immediate in index "
"indexed initially inner insert instead intersect into is isnull join key "
"left like limit match natural no not notnull null of offset on or order "
"outer plan pragma primary query raise recursive references regexp reindex "
"release rename replace restrict right rollback row savepoint select set "
"table temp temporary then to transaction trigger union unique update using "
"vacuum values view virtual when where with without"
).split()
)
reserved_words = {
"abort",
"action",
"add",
"after",
"all",
"alter",
"analyze",
"and",
"as",
"asc",
"attach",
"autoincrement",
"before",
"begin",
"between",
"by",
"cascade",
"case",
"cast",
"check",
"collate",
"column",
"commit",
"conflict",
"constraint",
"create",
"cross",
"current_date",
"current_time",
"current_timestamp",
"database",
"default",
"deferrable",
"deferred",
"delete",
"desc",
"detach",
"distinct",
"drop",
"each",
"else",
"end",
"escape",
"except",
"exclusive",
"exists",
"explain",
"fail",
"for",
"foreign",
"from",
"full",
"glob",
"group",
"having",
"if",
"ignore",
"immediate",
"in",
"index",
"indexed",
"initially",
"inner",
"insert",
"instead",
"intersect",
"into",
"is",
"isnull",
"join",
"key",
"left",
"like",
"limit",
"match",
"natural",
"no",
"not",
"notnull",
"null",
"of",
"offset",
"on",
"or",
"order",
"outer",
"plan",
"pragma",
"primary",
"query",
"raise",
"recursive",
"references",
"regexp",
"reindex",
"release",
"rename",
"replace",
"restrict",
"right",
"rollback",
"row",
"savepoint",
"select",
"set",
"table",
"temp",
"temporary",
"then",
"to",
"transaction",
"trigger",
"union",
"unique",
"update",
"using",
"vacuum",
"values",
"view",
"virtual",
"when",
"where",
"with",
"without",
}
APT_GET_DOCKERFILE_EXTRAS = r"""
RUN apt-get update && \
@ -157,7 +270,7 @@ functions_marked_as_documented = []
def documented(fn=None, *, label=None):
def decorate(fn):
fn._datasette_docs_label = label or "internals_utils_{}".format(fn.__name__)
fn._datasette_docs_label = label or f"internals_utils_{fn.__name__}"
functions_marked_as_documented.append(fn)
return fn
@ -224,24 +337,71 @@ def compound_keys_after_sql(pks, start_index=0):
return "({})".format("\n or\n".join(or_clauses))
@documented
class CustomJSONEncoder(json.JSONEncoder):
"""
The CustomJSONEncoder class handles serialization for objects commonly used by Datasette,
including SQLite cursors and binary blobs. Datasette uses it internally to serve .json endpoints,
and plugins that return JSON can use it to match Datasette's own handling.
Built-in types (text, numbers, lists, etc) are encoded the same as Python's built-in ``json`` module.
- ``sqlite3.Row`` becomes a tuple
- ``sqlite3.Cursor`` becomes a list
Binary blobs are encoded as an object, with the actual data base64-encoded,
like so: ::
{
"$base64": True,
"encoded": ...,
}
Example: https://latest.datasette.io/fixtures/binary_data.json
"""
def default(self, obj):
if isinstance(obj, sqlite3.Row):
return tuple(obj)
if isinstance(obj, sqlite3.Cursor):
return list(obj)
if isinstance(obj, bytes):
# Does it encode to utf8?
try:
return obj.decode("utf8")
except UnicodeDecodeError:
return {
"$base64": True,
"encoded": base64.b64encode(obj).decode("latin1"),
}
return {
"$base64": True,
"encoded": base64.b64encode(obj).decode("latin1"),
}
return json.JSONEncoder.default(self, obj)
class WriteJsonValueError(ValueError):
pass
def decode_write_json_cell(value):
if not isinstance(value, dict):
return value
keys = set(value.keys())
if keys == {"$raw"}:
return value["$raw"]
if keys == {"$base64", "encoded"} and value.get("$base64") is True:
encoded = value["encoded"]
if not isinstance(encoded, str):
raise WriteJsonValueError("$base64 encoded value must be a string")
try:
return base64.b64decode(encoded, validate=True)
except binascii.Error as ex:
raise WriteJsonValueError("Invalid $base64 encoded value") from ex
return value
def decode_write_json_row(row):
return {key: decode_write_json_cell(value) for key, value in row.items()}
def decode_write_json_rows(rows):
return [decode_write_json_row(row) for row in rows]
@contextmanager
def sqlite_timelimit(conn, ms):
deadline = time.perf_counter() + (ms / 1000)
@ -312,7 +472,7 @@ disallawed_sql_res = [
(
re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"),
"Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format(
", ".join("pragma_{}()".format(pragma) for pragma in allowed_pragmas)
", ".join(f"pragma_{pragma}()" for pragma in allowed_pragmas)
),
)
]
@ -408,14 +568,9 @@ def escape_css_string(s):
def escape_sqlite(s):
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
if _boring_keyword_re.fullmatch(s) and (s.lower() not in reserved_words):
return s
elif "]" in s:
# SQLite does not support escaping ] inside [bracket] quoting, so fall
# back to double-quote quoting (doubling any embedded ") - #2677
return '"{}"'.format(s.replace('"', '""'))
else:
return f"[{s}]"
return '"{}"'.format(s.replace('"', '""'))
def make_dockerfile(
@ -491,10 +646,7 @@ CMD {cmd}""".format(
else ""
),
environment_variables="\n".join(
[
"ENV {} '{}'".format(key, value)
for key, value in environment_variables.items()
]
[f"ENV {key} '{value}'" for key, value in environment_variables.items()]
),
install_from=" ".join(install),
files=" ".join(files),
@ -593,11 +745,11 @@ def detect_primary_keys(conn, table):
def get_outbound_foreign_keys(conn, table):
infos = conn.execute(f"PRAGMA foreign_key_list([{table}])").fetchall()
infos = conn.execute(f"PRAGMA foreign_key_list({escape_sqlite(table)})").fetchall()
fks = []
for info in infos:
if info is not None:
id, seq, table_name, from_, to_, on_update, on_delete, match = info
id, seq, table_name, from_, to_, _on_update, _on_delete, _match = info
fks.append(
{
"column": from_,
@ -668,7 +820,8 @@ def detect_spatialite(conn):
def detect_fts(conn, table):
"""Detect if table has a corresponding FTS virtual table and return it"""
rows = conn.execute(detect_fts_sql(table)).fetchall()
sql, params = detect_fts_sql(table)
rows = conn.execute(sql, params).fetchall()
if len(rows) == 0:
return None
else:
@ -676,18 +829,26 @@ def detect_fts(conn, table):
def detect_fts_sql(table):
return r"""
select name from sqlite_master
where rootpage = 0
and (
sql like '%VIRTUAL TABLE%USING FTS%content="{table}"%'
or sql like '%VIRTUAL TABLE%USING FTS%content=[{table}]%'
or (
tbl_name = "{table}"
and sql like '%VIRTUAL TABLE%USING FTS%'
escaped_table = table.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return (
r"""
select name from sqlite_master
where rootpage = 0
and (
sql like :fts_double_quoted escape char(92)
or sql like :fts_bracket_quoted escape char(92)
or (
tbl_name = :table
and sql like '%VIRTUAL TABLE%USING FTS%'
)
)
)
""".format(table=table.replace("'", "''"))
""",
{
"fts_double_quoted": f'%VIRTUAL TABLE%USING FTS%content="{escaped_table}"%',
"fts_bracket_quoted": f"%VIRTUAL TABLE%USING FTS%content=[{escaped_table}]%",
"table": table,
},
)
def detect_json1(conn=None):
@ -698,7 +859,7 @@ def detect_json1(conn=None):
try:
conn.execute("SELECT json('{}')")
return True
except Exception:
except sqlite3.Error:
return False
finally:
if close_conn:
@ -778,9 +939,7 @@ def is_url(value):
if not value.startswith("http://") and not value.startswith("https://"):
return False
# Any whitespace at all is invalid
if whitespace_re.search(value):
return False
return True
return not whitespace_re.search(value)
css_class_re = re.compile(r"^[a-zA-Z]+[_a-zA-Z0-9-]*$")
@ -833,7 +992,9 @@ def module_from_path(path, name):
mod.__file__ = path
with open(path, "r") as file:
code = compile(file.read(), path, "exec", dont_inherit=True)
exec(code, mod.__dict__)
# Executing the file is the whole point - this is how --plugins-dir loads
# plugins and how metadata/config .py files are evaluated
exec(code, mod.__dict__) # noqa: S102
return mod
@ -990,9 +1151,7 @@ def escape_fts(query):
query += '"'
bits = _escape_fts_re.split(query)
bits = [b for b in bits if b and b != '""']
return " ".join(
'"{}"'.format(bit) if not bit.startswith('"') else bit for bit in bits
)
return " ".join(f'"{bit}"' if not bit.startswith('"') else bit for bit in bits)
class MultiParams:
@ -1004,7 +1163,7 @@ class MultiParams:
data[key], (list, tuple)
), "dictionary data should be a dictionary of key => [list]"
self._data = data
elif isinstance(data, list) or isinstance(data, tuple):
elif isinstance(data, (list, tuple)):
new_data = {}
for item in data:
assert (
@ -1094,9 +1253,7 @@ def _gather_arguments(fn, kwargs):
for parameter in parameters:
if parameter not in kwargs:
raise TypeError(
"{} requires parameters {}, missing: {}".format(
fn, tuple(parameters), set(parameters) - set(kwargs.keys())
)
f"{fn} requires parameters {tuple(parameters)}, missing: {set(parameters) - set(kwargs.keys())}"
)
call_with.append(kwargs[parameter])
return call_with
@ -1165,9 +1322,9 @@ def resolve_env_secrets(config, environ):
"""Create copy that recursively replaces {"$env": "NAME"} with values from environ"""
if isinstance(config, dict):
if list(config.keys()) == ["$env"]:
return environ.get(list(config.values())[0])
return environ.get(next(iter(config.values())))
elif list(config.keys()) == ["$file"]:
with open(list(config.values())[0]) as fp:
with open(next(iter(config.values()))) as fp:
return fp.read()
else:
return {
@ -1245,29 +1402,38 @@ class StartupError(Exception):
pass
_single_line_comment_re = re.compile(r"--.*")
_multi_line_comment_re = re.compile(r"/\*.*?\*/", re.DOTALL)
_single_quote_re = re.compile(r"'(?:''|[^'])*'")
_double_quote_re = re.compile(r'"(?:\"\"|[^"])*"')
# Comments and string literals, matched in a single pass so that whichever
# construct starts first "wins" - this ensures a comment marker inside a string
# literal (or a quote inside a comment) does not confuse the parameter scan.
_comments_and_strings_re = re.compile(
r"""
--[^\n]* # single line comment
| /\*.*?(?:\*/|\Z) # multi line comment, possibly to end-of-input
| '(?:''|[^'])*' # single quoted string ('' escapes a quote)
| "(?:""|[^"])*" # double quoted identifier ("" escapes a quote)
| \[(?:[^\]])*\] # square-bracket quoted identifier
| `(?:``|[^`])*` # backtick quoted identifier
""",
re.DOTALL | re.VERBOSE,
)
_named_param_re = re.compile(r":(\w+)")
@documented
def named_parameters(sql: str) -> List[str]:
def named_parameters(sql: str) -> list[str]:
"""
Given a SQL statement, return a list of named parameters that are used in the statement
e.g. for ``select * from foo where id=:id`` this would return ``["id"]``
"""
sql = _single_line_comment_re.sub("", sql)
sql = _multi_line_comment_re.sub("", sql)
sql = _single_quote_re.sub("", sql)
sql = _double_quote_re.sub("", sql)
# Strip comments and string literals first so that any ":name" sequences
# inside them are not mistaken for named parameters
sql = _comments_and_strings_re.sub("", sql)
# Extract parameters from what is left
return _named_param_re.findall(sql)
async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
async def derive_named_parameters(db: "Database", sql: str) -> list[str]:
"""
This undocumented but stable method exists for backwards compatibility
with plugins that were using it before it switched to named_parameters()
@ -1275,6 +1441,54 @@ async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
return named_parameters(sql)
def parse_size_limit(value, default, maximum, name="_size"):
"""
Parse a page-size parameter using the same semantics as the table
view's ?_size=: blank means default, "max" means maximum, integers
must be 0 or greater and no larger than maximum. Raises ValueError
with a message suitable for a 400 response.
"""
if value in (None, ""):
return default
if value == "max":
return maximum
try:
size = int(value)
if size < 0:
raise ValueError
except ValueError:
raise ValueError(f"{name} must be a positive integer")
if size > maximum:
raise ValueError(f"{name} must be <= {maximum}")
return size
UNSTABLE_API_MESSAGE = (
"This API is not part of Datasette's stable interface and may change at any time"
)
def error_body(messages, status):
"""
The canonical JSON error body used by every Datasette JSON error response:
{"ok": False, "error": "...", "errors": ["...", ...], "status": 400}
"error" is all of the messages joined with "; ", "errors" is the full
list, "status" matches the HTTP status code. Callers may add extra
context keys to the returned dictionary but must not remove these four.
"""
if isinstance(messages, str):
messages = [messages]
messages = [str(message) for message in messages]
return {
"ok": False,
"error": "; ".join(messages),
"errors": messages,
"status": status,
}
def add_cors_headers(headers):
headers["Access-Control-Allow-Origin"] = "*"
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
@ -1303,7 +1517,7 @@ class TildeEncoder(dict):
elif b == _space:
res = "+"
else:
res = "~{:02X}".format(b)
res = f"~{b:02X}"
self[b] = res
return res
@ -1352,7 +1566,13 @@ async def row_sql_params_pks(db, table, pk_values):
if use_rowid:
select = "rowid, *"
pks = ["rowid"]
wheres = [f'"{pk}"=:p{i}' for i, pk in enumerate(pks)]
wheres = []
for i, pk in enumerate(pks):
escaped_pk = escape_sqlite(pk)
# Preserve the historic always-quoted SQL exposed by _extra=query
if escaped_pk == pk:
escaped_pk = f'"{pk}"'
wheres.append(f"{escaped_pk}=:p{i}")
sql = f"select {select} from {escape_sqlite(table)} where {' AND '.join(wheres)}"
params = {}
for i, pk_value in enumerate(pk_values):
@ -1398,7 +1618,7 @@ def _combine(base: dict, update: dict) -> dict:
return base
def pairs_to_nested_config(pairs: typing.List[typing.Tuple[str, typing.Any]]) -> dict:
def pairs_to_nested_config(pairs: list[tuple[str, typing.Any]]) -> dict:
"""
Parse a list of key-value pairs into a nested dictionary.
"""
@ -1413,7 +1633,7 @@ def make_slot_function(name, datasette, request, **kwargs):
from datasette.plugins import pm
method = getattr(pm.hook, name, None)
assert method is not None, "No hook found for {}".format(name)
assert method is not None, f"No hook found for {name}"
async def inner():
html_bits = []
@ -1437,7 +1657,7 @@ def prune_empty_dicts(d: dict):
d.pop(key, None)
def move_plugins_and_allow(source: dict, destination: dict) -> Tuple[dict, dict]:
def move_plugins_and_allow(source: dict, destination: dict) -> tuple[dict, dict]:
"""
Move 'plugins' and 'allow' keys from source to destination dictionary. Creates
hierarchy in destination if needed. After moving, recursively remove any keys
@ -1524,7 +1744,7 @@ def redact_keys(original: dict, key_patterns: Iterable) -> dict:
return {
k: (
redact(v)
if not any(pattern in k for pattern in key_patterns)
if not any(pattern in k.casefold() for pattern in key_patterns)
else "***"
)
for k, v in data.items()
@ -1548,6 +1768,17 @@ def md5_not_usedforsecurity(s):
_etag_cache = {}
def sha256_file(filepath, chunk_size=4096):
hasher = hashlib.sha256()
with open(filepath, "rb") as fp:
while True:
chunk = fp.read(chunk_size)
if not chunk:
break
hasher.update(chunk)
return hasher.hexdigest()
async def calculate_etag(filepath, chunk_size=4096):
if filepath in _etag_cache:
return _etag_cache[filepath]

View file

@ -29,6 +29,15 @@ from datasette.utils.permissions import gather_permission_sql_from_hooks
if TYPE_CHECKING:
from datasette.app import Datasette
from datasette.permissions import Action
def _child_collation(action: "Action") -> str:
"""Match resource identity without changing the spelling returned by SQL."""
resource_class = action.resource_class
if resource_class is not None and resource_class.case_insensitive_child:
return "NOCASE"
return "BINARY"
async def build_allowed_resources_sql(
@ -149,6 +158,7 @@ async def _build_single_action_sql(
raise ValueError(f"Unknown action: {action}")
# Get base resources SQL from the resource class
child_collation = _child_collation(action_obj)
base_resources_sql = await action_obj.resource_class.resources_sql(
datasette, actor=actor
)
@ -185,7 +195,7 @@ async def _build_single_action_sql(
if permission_sql.sql is None:
continue
rule_sqls.append(f"""
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
SELECT parent, child COLLATE {child_collation} AS child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
{permission_sql.sql}
)
""".strip())
@ -252,88 +262,62 @@ async def _build_single_action_sql(
]
)
# Continue with the cascading logic
query_parts.extend(
[
"child_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
" GROUP BY b.parent, b.child",
"),",
"parent_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"global_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
# Continue with the cascading logic.
# Aggregate the RULES by cascade level (small), rather than grouping
# base x rules (which scales with the number of resources).
def _agg(select_key, where, group_by):
parts = [
f" SELECT {select_key}",
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN allow = 0 THEN source_plugin || ': ' || reason END) AS deny_reasons,",
" json_group_array(CASE WHEN allow = 1 THEN source_plugin || ': ' || reason END) AS allow_reasons",
f" FROM all_rules WHERE {where}",
]
if group_by:
parts.append(f" GROUP BY {group_by}")
return parts
query_parts.extend(
["child_agg AS ("]
+ _agg(
"parent, child,",
"parent IS NOT NULL AND child IS NOT NULL",
"parent, child",
)
+ ["),", "parent_agg AS ("]
+ _agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
+ ["),", "global_agg AS ("]
+ _agg("", "parent IS NULL AND child IS NULL", None)
+ ["),"]
)
# Add anonymous decision logic if needed
if include_is_private:
query_parts.extend(
[
"anon_child_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child",
" GROUP BY b.parent, b.child",
"),",
"anon_parent_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"anon_global_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"anon_decisions AS (",
" SELECT",
" b.parent, b.child,",
" CASE",
" WHEN acl.any_deny = 1 THEN 0",
" WHEN acl.any_allow = 1 THEN 1",
" WHEN apl.any_deny = 1 THEN 0",
" WHEN apl.any_allow = 1 THEN 1",
" WHEN agl.any_deny = 1 THEN 0",
" WHEN agl.any_allow = 1 THEN 1",
" ELSE 0",
" END AS anon_is_allowed",
" FROM base b",
" JOIN anon_child_lvl acl ON b.parent = acl.parent AND (b.child = acl.child OR (b.child IS NULL AND acl.child IS NULL))",
" JOIN anon_parent_lvl apl ON b.parent = apl.parent AND (b.child = apl.child OR (b.child IS NULL AND apl.child IS NULL))",
" JOIN anon_global_lvl agl ON b.parent = agl.parent AND (b.child = agl.child OR (b.child IS NULL AND agl.child IS NULL))",
"),",
def _anon_agg(select_key, where, group_by):
parts = [
f" SELECT {select_key}",
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow",
f" FROM anon_rules WHERE {where}",
]
if group_by:
parts.append(f" GROUP BY {group_by}")
return parts
query_parts.extend(
["anon_child_agg AS ("]
+ _anon_agg(
f"parent, child COLLATE {child_collation} AS child,",
"parent IS NOT NULL AND child IS NOT NULL",
f"parent, child COLLATE {child_collation}",
)
+ ["),", "anon_parent_agg AS ("]
+ _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
+ ["),", "anon_global_agg AS ("]
+ _anon_agg("", "parent IS NULL AND child IS NULL", None)
+ ["),"]
)
# Final decisions
@ -342,31 +326,28 @@ async def _build_single_action_sql(
"decisions AS (",
" SELECT",
" b.parent, b.child,",
" -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level",
" -- Cascading permission logic: child -> parent -> global, DENY beats ALLOW at each level",
" -- Priority order:",
" -- 1. Child-level deny (most specific, blocks access)",
" -- 2. Child-level allow (most specific, grants access)",
" -- 3. Parent-level deny (intermediate, blocks access)",
" -- 4. Parent-level allow (intermediate, grants access)",
" -- 5. Global-level deny (least specific, blocks access)",
" -- 6. Global-level allow (least specific, grants access)",
" -- 1. Child-level deny 2. Child-level allow",
" -- 3. Parent-level deny 4. Parent-level allow",
" -- 5. Global-level deny 6. Global-level allow",
" -- 7. Default deny (no rules match)",
" CASE",
" WHEN cl.any_deny = 1 THEN 0",
" WHEN cl.any_allow = 1 THEN 1",
" WHEN pl.any_deny = 1 THEN 0",
" WHEN pl.any_allow = 1 THEN 1",
" WHEN gl.any_deny = 1 THEN 0",
" WHEN gl.any_allow = 1 THEN 1",
" WHEN ca.any_deny = 1 THEN 0",
" WHEN ca.any_allow = 1 THEN 1",
" WHEN pa.any_deny = 1 THEN 0",
" WHEN pa.any_allow = 1 THEN 1",
" WHEN ga.any_deny = 1 THEN 0",
" WHEN ga.any_allow = 1 THEN 1",
" ELSE 0",
" END AS is_allowed,",
" CASE",
" WHEN cl.any_deny = 1 THEN cl.deny_reasons",
" WHEN cl.any_allow = 1 THEN cl.allow_reasons",
" WHEN pl.any_deny = 1 THEN pl.deny_reasons",
" WHEN pl.any_allow = 1 THEN pl.allow_reasons",
" WHEN gl.any_deny = 1 THEN gl.deny_reasons",
" WHEN gl.any_allow = 1 THEN gl.allow_reasons",
" WHEN ca.any_deny = 1 THEN ca.deny_reasons",
" WHEN ca.any_allow = 1 THEN ca.allow_reasons",
" WHEN pa.any_deny = 1 THEN pa.deny_reasons",
" WHEN pa.any_allow = 1 THEN pa.allow_reasons",
" WHEN ga.any_deny = 1 THEN ga.deny_reasons",
" WHEN ga.any_allow = 1 THEN ga.allow_reasons",
" ELSE '[]'",
" END AS reason",
]
@ -374,21 +355,34 @@ async def _build_single_action_sql(
if include_is_private:
query_parts.append(
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
" , CASE WHEN ("
"CASE"
" WHEN aca.any_deny = 1 THEN 0"
" WHEN aca.any_allow = 1 THEN 1"
" WHEN apa.any_deny = 1 THEN 0"
" WHEN apa.any_allow = 1 THEN 1"
" WHEN aga.any_deny = 1 THEN 0"
" WHEN aga.any_allow = 1 THEN 1"
" ELSE 0 END"
") = 0 THEN 1 ELSE 0 END AS is_private"
)
query_parts.extend(
[
" FROM base b",
" JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
" JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
" JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))",
" LEFT JOIN child_agg ca ON ca.parent = b.parent AND ca.child = b.child",
" LEFT JOIN parent_agg pa ON pa.parent = b.parent",
" CROSS JOIN global_agg ga",
]
)
if include_is_private:
query_parts.append(
" JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))"
query_parts.extend(
[
" LEFT JOIN anon_child_agg aca ON aca.parent = b.parent AND aca.child = b.child",
" LEFT JOIN anon_parent_agg apa ON apa.parent = b.parent",
" CROSS JOIN anon_global_agg aga",
]
)
query_parts.append(")")
@ -398,10 +392,31 @@ async def _build_single_action_sql(
# Wrap each restriction_sql in a subquery to avoid operator precedence issues
# with UNION ALL inside the restriction SQL statements
restriction_intersect = "\nINTERSECT\n".join(
f"SELECT * FROM ({sql})" for sql in restriction_sqls
f"SELECT parent, child COLLATE {child_collation} AS child FROM ({sql})"
for sql in restriction_sqls
)
# Decompose by NULL-pattern so the final filter can use pure-equality
# EXISTS lookups (satisfiable via automatic indexes) instead of a
# correlated OR-scan over the whole list.
query_parts.extend(
[",", "restriction_list AS (", f" {restriction_intersect}", ")"]
[
",",
"restriction_list AS (",
f" {restriction_intersect}",
"),",
"restriction_exact AS (",
" SELECT parent, child FROM restriction_list WHERE parent IS NOT NULL AND child IS NOT NULL",
"),",
"restriction_parent_any AS (",
" SELECT DISTINCT parent FROM restriction_list WHERE parent IS NOT NULL AND child IS NULL",
"),",
"restriction_child_any AS (",
" SELECT DISTINCT child FROM restriction_list WHERE parent IS NULL AND child IS NOT NULL",
"),",
"restriction_all AS (",
" SELECT 1 AS matched FROM restriction_list WHERE parent IS NULL AND child IS NULL LIMIT 1",
")",
]
)
# Final SELECT
@ -416,10 +431,11 @@ async def _build_single_action_sql(
# Add restriction filter if there are restrictions
if restriction_sqls:
query_parts.append("""
AND EXISTS (
SELECT 1 FROM restriction_list r
WHERE (r.parent = decisions.parent OR r.parent IS NULL)
AND (r.child = decisions.child OR r.child IS NULL)
AND (
EXISTS (SELECT 1 FROM restriction_all)
OR EXISTS (SELECT 1 FROM restriction_parent_any r WHERE r.parent = decisions.parent)
OR EXISTS (SELECT 1 FROM restriction_child_any r WHERE r.child = decisions.child)
OR EXISTS (SELECT 1 FROM restriction_exact r WHERE r.parent = decisions.parent AND r.child = decisions.child)
)""")
# Add parent filter if specified
@ -475,6 +491,7 @@ async def build_permission_rules_sql(
union_parts = []
all_params = {}
restriction_sqls = []
child_collation = _child_collation(action_obj)
for permission_sql in permission_sqls:
all_params.update(permission_sql.params or {})
@ -488,7 +505,7 @@ async def build_permission_rules_sql(
continue
union_parts.append(f"""
SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
SELECT parent, child COLLATE {child_collation} AS child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (
{permission_sql.sql}
)
""".strip())
@ -559,6 +576,7 @@ async def check_permissions_for_actions(
verdicts = {}
for i, (action, permission_sqls) in enumerate(zip(unique_actions, gathered)):
child_collation = _child_collation(datasette.actions[action])
prefix = f"a{i}_"
rule_parts = []
restriction_parts = []
@ -584,7 +602,7 @@ async def check_permissions_for_actions(
if sql is None:
continue
rule_parts.append(
f"SELECT parent, child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
f"SELECT parent, child COLLATE {child_collation} AS child, allow, reason, '{permission_sql.source}' AS source_plugin FROM (\n{sql}\n)"
)
if not rule_parts:
@ -618,7 +636,8 @@ async def check_permissions_for_actions(
if restriction_parts:
# Database-level restrictions (parent, NULL) match all children
restriction_intersect = "\nINTERSECT\n".join(
f"SELECT * FROM ({sql})" for sql in restriction_parts
f"SELECT parent, child COLLATE {child_collation} AS child FROM ({sql})"
for sql in restriction_parts
)
ctes.append(f"a{i}_restriction AS (\n{restriction_intersect}\n)")
verdict_sql = f"""({verdict_sql}) AND EXISTS (
@ -673,3 +692,240 @@ async def check_permission_for_resource(
child=child,
)
return results[action]
async def explain_permission_for_resource(
*,
datasette: "Datasette",
actor: dict | None,
action: str,
parent: str | None,
child: str | None,
) -> dict:
"""Explain a permission decision for one action and resource.
This is intended for Datasette's permission debugging tools. It uses the
same ``permission_resources_sql`` hook results and the same resolution
rules as :func:`check_permissions_for_actions`, but also returns the
matching rules, actor restriction results and ``also_requires`` chain.
The returned dictionary is part of Datasette's unstable debugging API.
"""
action_obj = datasette.actions.get(action)
if action_obj is None:
raise ValueError(f"Unknown action: {action}")
explanation = await _explain_single_action(
datasette=datasette,
actor=actor,
action=action,
parent=parent,
child=child,
)
required_actions = []
if action_obj.also_requires:
required = await explain_permission_for_resource(
datasette=datasette,
actor=actor,
action=action_obj.also_requires,
parent=parent,
child=child,
)
required_actions.append(required)
explanation["required_actions"] = required_actions
explanation["allowed"] = bool(
explanation["rule_allowed"]
and explanation["restriction_allowed"]
and all(required["allowed"] for required in required_actions)
)
explanation["summary"] = _permission_explanation_summary(explanation)
return explanation
async def _explain_single_action(
*,
datasette: "Datasette",
actor: dict | None,
action: str,
parent: str | None,
child: str | None,
) -> dict:
"""Return matching rules and restrictions for a single action."""
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
permission_sqls = await gather_permission_sql_from_hooks(
datasette=datasette,
actor=actor,
action=action,
)
if permission_sqls is SKIP_PERMISSION_CHECKS:
return {
"action": action,
"rule_allowed": True,
"restriction_allowed": True,
"winning_scope": "global",
"matched_rules": [
{
"scope": "global",
"effect": "allow",
"source": "skip_permission_checks",
"reason": "Permission checks were explicitly skipped",
"decisive": True,
"ignored_because": None,
}
],
"restrictions": [],
}
db = datasette.get_internal_database()
matched_rules = []
restrictions = []
child_collation = _child_collation(datasette.actions[action])
for permission_sql in permission_sqls:
params = dict(permission_sql.params or {})
parent_param = _unused_parameter_name(params, "_explain_parent")
params[parent_param] = parent
child_param = _unused_parameter_name(params, "_explain_child")
params[child_param] = child
if permission_sql.sql:
rows = await db.execute(
f"""
SELECT parent, child, allow, reason
FROM ({permission_sql.sql}) AS permission_rules
WHERE (parent IS NULL OR parent = :{parent_param})
AND (child IS NULL OR child COLLATE {child_collation} = :{child_param})
""",
params,
)
for row in rows:
specificity = (
2
if row["child"] is not None
else 1 if row["parent"] is not None else 0
)
matched_rules.append(
{
"scope": ("resource", "parent", "global")[2 - specificity],
"effect": "allow" if row["allow"] else "deny",
"source": permission_sql.source,
"reason": row["reason"],
"_specificity": specificity,
}
)
if permission_sql.restriction_sql:
restriction_row = (
await db.execute(
f"""
SELECT EXISTS(
SELECT 1 FROM ({permission_sql.restriction_sql}) AS restriction_rules
WHERE (parent IS NULL OR parent = :{parent_param})
AND (child IS NULL OR child COLLATE {child_collation} = :{child_param})
) AS resource_is_in_allowlist
""",
params,
)
).first()
restriction_allowed = bool(restriction_row[0])
restrictions.append(
{
"source": permission_sql.source,
"allowed": restriction_allowed,
"reason": params.get("deny")
or (
"Resource is included in this restriction allowlist"
if restriction_allowed
else "Resource is not included in this restriction allowlist"
),
}
)
matched_rules.sort(
key=lambda rule: (
-rule["_specificity"],
0 if rule["effect"] == "deny" else 1,
rule["source"] or "",
rule["reason"] or "",
)
)
if matched_rules:
winning_specificity = matched_rules[0]["_specificity"]
winning_rules = [
rule
for rule in matched_rules
if rule["_specificity"] == winning_specificity
]
rule_allowed = not any(rule["effect"] == "deny" for rule in winning_rules)
winning_scope = winning_rules[0]["scope"]
else:
winning_specificity = None
rule_allowed = False
winning_scope = None
for rule in matched_rules:
specificity = rule.pop("_specificity")
if specificity != winning_specificity:
rule["decisive"] = False
rule["ignored_because"] = "A more specific rule matched"
elif not rule_allowed and rule["effect"] == "allow":
rule["decisive"] = False
rule["ignored_because"] = "A deny rule matched at the same scope"
else:
rule["decisive"] = True
rule["ignored_because"] = None
return {
"action": action,
"rule_allowed": rule_allowed,
"restriction_allowed": all(
restriction["allowed"] for restriction in restrictions
),
"winning_scope": winning_scope,
"matched_rules": matched_rules,
"restrictions": restrictions,
}
def _unused_parameter_name(params: dict, preferred: str) -> str:
"""Return a SQL parameter name that is not already in ``params``."""
candidate = preferred
suffix = 2
while candidate in params:
candidate = f"{preferred}_{suffix}"
suffix += 1
return candidate
def _permission_explanation_summary(explanation: dict) -> str:
denied_requirement = next(
(
required
for required in explanation["required_actions"]
if not required["allowed"]
),
None,
)
if denied_requirement:
return (
f"Denied because {explanation['action']} also requires "
f"{denied_requirement['action']}, which was denied."
)
if not explanation["matched_rules"]:
return "Denied because no permission rule matched this actor and resource."
if not explanation["rule_allowed"]:
return (
f"Denied by a {explanation['winning_scope']}-level rule. "
"Deny rules take precedence over allow rules at the same scope."
)
if not explanation["restriction_allowed"]:
return (
"Denied because the resource is not included in the actor's restrictions."
)
return f"Allowed by the matching {explanation['winning_scope']}-level rule."

View file

@ -1,28 +1,30 @@
import asyncio
import json
from typing import Optional
from datasette.utils import MultiParams, calculate_etag
import re
from http.cookies import Morsel, SimpleCookie
from mimetypes import guess_type
from pathlib import Path
from urllib.parse import parse_qs, parse_qsl, urlunparse
import aiofiles
import aiofiles.os
from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file
from datasette.utils.multipart import (
parse_form_data,
MultipartParseError,
FormData,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_REQUEST_SIZE,
DEFAULT_MAX_FIELDS,
DEFAULT_MAX_FILES,
DEFAULT_MAX_PARTS,
DEFAULT_MAX_FIELD_SIZE,
DEFAULT_MAX_FIELDS,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
DEFAULT_MAX_MEMORY_FILE_SIZE,
DEFAULT_MAX_PART_HEADER_BYTES,
DEFAULT_MAX_PART_HEADER_LINES,
DEFAULT_MAX_PARTS,
DEFAULT_MAX_REQUEST_SIZE,
DEFAULT_MIN_FREE_DISK_BYTES,
FormData,
MultipartParseError,
parse_form_data,
)
from mimetypes import guess_type
from urllib.parse import parse_qs, urlunparse, parse_qsl
from pathlib import Path
from http.cookies import SimpleCookie, Morsel
import aiofiles
import aiofiles.os
import re
# Workaround for adding samesite support to pre 3.8 python
Morsel._reserved["samesite"] = "SameSite"
@ -67,16 +69,41 @@ class BadRequest(Base400):
status = 400
class PayloadTooLarge(Base400):
status = 413
SAMESITE_VALUES = ("strict", "lax", "none")
# Bodies read fully into memory (post_body/post_vars/json) are capped at this
# size unless the max_post_body_bytes setting says otherwise. Kept deliberately
# far below multipart's DEFAULT_MAX_REQUEST_SIZE: that parser streams to disk,
# while these bodies are held in RAM and json.loads() can multiply their
# footprint several times over.
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
class _RequestHeaders(dict):
"""Incoming headers with lowercase keys and case-insensitive lookups."""
def __getitem__(self, key):
return super().__getitem__(key.lower())
def get(self, key, default=None):
return super().get(key.lower(), default)
def __contains__(self, key):
return super().__contains__(key.lower())
class Request:
def __init__(self, scope, receive):
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
self.scope = scope
self.receive = receive
self.max_post_body_bytes = max_post_body_bytes
def __repr__(self):
return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
return f'<asgi.Request method="{self.method}" url="{self.url}">'
@property
def method(self):
@ -98,10 +125,10 @@ class Request:
@property
def headers(self):
return {
k.decode("latin-1").lower(): v.decode("latin-1")
return _RequestHeaders(
(k.decode("latin-1").lower(), v.decode("latin-1"))
for k, v in self.scope.get("headers") or []
}
)
@property
def host(self):
@ -141,15 +168,43 @@ class Request:
def actor(self):
return self.scope.get("actor", None)
async def post_body(self):
body = b""
async def post_body(self, max_bytes=None):
"""
Read the request body fully into memory.
The body is capped at max_bytes - or self.max_post_body_bytes
(default 2MB, set from the max_post_body_bytes setting for requests
created by Datasette) if max_bytes is not provided. Pass max_bytes=0
to disable the limit. Raises PayloadTooLarge (HTTP 413) if exceeded -
oversized bodies are rejected as soon as the limit is passed, without
buffering the rest.
"""
if max_bytes is None:
max_bytes = self.max_post_body_bytes
too_large = PayloadTooLarge(
f"Request body exceeded maximum size of {max_bytes} bytes"
)
if max_bytes:
# Reject early if the client declares an oversized body
try:
if int(self.headers.get("content-length", "")) > max_bytes:
raise too_large
except ValueError:
# Missing or malformed - the streaming check below still applies
pass
chunks = []
received = 0
more_body = True
while more_body:
message = await self.receive()
assert message["type"] == "http.request", message
body += message.get("body", b"")
chunk = message.get("body", b"")
received += len(chunk)
if max_bytes and received > max_bytes:
raise too_large
chunks.append(chunk)
more_body = message.get("more_body", False)
return body
return b"".join(chunks)
async def post_vars(self):
body = await self.post_body()
@ -166,7 +221,7 @@ class Request:
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES,
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_parts: int | None = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
@ -259,12 +314,24 @@ class AsgiLifespan:
while True:
message = await receive()
if message["type"] == "lifespan.startup":
for fn in self.on_startup:
await fn()
try:
for fn in self.on_startup:
await fn()
except Exception as e: # noqa: BLE001
await send(
{"type": "lifespan.startup.failed", "message": str(e)}
)
return
await send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown":
for fn in self.on_shutdown:
await fn()
try:
for fn in self.on_shutdown:
await fn()
except Exception as e: # noqa: BLE001
await send(
{"type": "lifespan.shutdown.failed", "message": str(e)}
)
return
await send({"type": "lifespan.shutdown.complete"})
return
else:
@ -397,6 +464,9 @@ async def asgi_send_file(
)
HASHED_STATIC_CACHE_CONTROL = "max-age=31536000, immutable, public"
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
root_path = Path(root_path)
static_headers = {}
@ -423,11 +493,17 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
return
try:
# Calculate ETag for filepath
hash_value = request.args.get("_hash")
if (
hash_value
and hash_value == sha256_file(full_path, chunk_size=chunk_size)[:12]
):
headers["Cache-Control"] = HASHED_STATIC_CACHE_CONTROL
etag = await calculate_etag(full_path, chunk_size=chunk_size)
headers["ETag"] = etag
if_none_match = request.headers.get("if-none-match")
if if_none_match and if_none_match == etag:
return await asgi_send(send, "", 304)
return await asgi_send(send, "", 304, headers=headers)
await asgi_send_file(
send, full_path, chunk_size=chunk_size, headers=headers
)
@ -435,6 +511,8 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
await asgi_send_html(send, "404: File not found", 404)
return
# Only the actual static-file handler can bypass dynamic response privacy.
inner_static._datasette_static = True
return inner_static
@ -480,9 +558,9 @@ class Response:
httponly=False,
samesite="lax",
):
assert samesite in SAMESITE_VALUES, "samesite should be one of {}".format(
SAMESITE_VALUES
)
assert (
samesite in SAMESITE_VALUES
), f"samesite should be one of {SAMESITE_VALUES}"
cookie = SimpleCookie()
cookie[key] = value
for prop_name, prop_value in (
@ -526,6 +604,18 @@ class Response:
content_type="application/json; charset=utf-8",
)
@classmethod
def error(cls, messages, status=400, headers=None):
"""
A JSON error response using Datasette's standard error format.
messages can be a single string or a list of strings. For errors
that should content-negotiate between JSON and HTML, raise
Forbidden, NotFound, BadRequest or DatasetteError instead and let
Datasette's error handling hooks build the response.
"""
return cls.json(error_body(messages, status), status=status, headers=headers)
@classmethod
def redirect(cls, path, status=302, headers=None):
headers = headers or {}
@ -562,10 +652,23 @@ class AsgiRunOnFirstRequest:
self.asgi = asgi
self.on_startup = on_startup
self._started = False
# Guards against concurrent early requests interleaving with startup:
# without this, several requests could all observe `_started is
# False` and proceed before any of them finish running the hooks.
self._lock = asyncio.Lock()
async def __call__(self, scope, receive, send):
if not self._started:
self._started = True
for hook in self.on_startup:
await hook()
# Leave "lifespan" scope events alone - this shim only exists as a
# fallback for hosts that never send them. It wraps AsgiLifespan, so
# if it ran on_startup here too, a startup exception would escape
# before AsgiLifespan's own try/except got a chance to turn it into
# a lifespan.startup.failed message.
if scope["type"] != "lifespan" and not self._started:
async with self._lock:
# Re-check: another request may have finished startup while
# we were waiting for the lock.
if not self._started:
for hook in self.on_startup:
await hook()
self._started = True
return await self.asgi(scope, receive, send)

View file

@ -13,7 +13,7 @@ Originally shared here: https://www.djangosnippets.org/snippets/1431/
"""
class BaseConverter(object):
class BaseConverter:
decimal_digits = "0123456789"
def __init__(self, digits):

View file

@ -1,6 +1,6 @@
import inspect
import types
from typing import NamedTuple, Any
from typing import Any, NamedTuple
class CallableStatus(NamedTuple):
@ -19,7 +19,7 @@ def check_callable(obj: Any) -> CallableStatus:
if isinstance(obj, types.FunctionType):
return CallableStatus(True, inspect.iscoroutinefunction(obj))
if hasattr(obj, "__call__"):
if callable(obj):
return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__))
assert False, "obj {} is somehow callable with no __call__ method".format(repr(obj))
assert False, f"obj {obj!r} is somehow callable with no __call__ method"

View file

@ -1,9 +1,30 @@
import textwrap
from datasette.utils import table_column_details
from sqlite_utils import Database as SQLiteUtilsDatabase
from sqlite_utils import Migrations
async def init_internal_db(db):
create_tables_sql = textwrap.dedent("""
from datasette.utils import escape_sqlite, table_column_details
INTERNAL_DB_SCHEMA_TABLES = {
"catalog_databases",
"catalog_tables",
"catalog_views",
"catalog_columns",
"catalog_indexes",
"catalog_foreign_keys",
"metadata_instance",
"metadata_databases",
"metadata_resources",
"metadata_columns",
"column_types",
"queries",
}
INTERNAL_DB_SCHEMA_INDEXES = {
"queries_owner_idx",
}
INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
CREATE TABLE IF NOT EXISTS catalog_databases (
database_name TEXT PRIMARY KEY,
path TEXT,
@ -67,99 +88,101 @@ async def init_internal_db(db):
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
);
CREATE TABLE IF NOT EXISTS metadata_instance (
key text,
value text,
unique(key)
);
CREATE TABLE IF NOT EXISTS metadata_databases (
database_name text,
key text,
value text,
unique(database_name, key)
);
CREATE TABLE IF NOT EXISTS metadata_resources (
database_name text,
resource_name text,
key text,
value text,
unique(database_name, resource_name, key)
);
CREATE TABLE IF NOT EXISTS metadata_columns (
database_name text,
resource_name text,
column_name text,
key text,
value text,
unique(database_name, resource_name, column_name, key)
);
CREATE TABLE IF NOT EXISTS column_types (
database_name TEXT NOT NULL,
resource_name TEXT NOT NULL,
column_name TEXT NOT NULL,
column_type TEXT NOT NULL,
config TEXT,
PRIMARY KEY (database_name, resource_name, column_name)
);
CREATE TABLE IF NOT EXISTS queries (
database_name TEXT NOT NULL,
name TEXT NOT NULL,
sql TEXT NOT NULL,
title TEXT,
description TEXT,
description_html TEXT,
options TEXT NOT NULL DEFAULT '{}',
parameters TEXT NOT NULL DEFAULT '[]',
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
source TEXT NOT NULL DEFAULT 'user',
owner_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (database_name, name)
);
CREATE INDEX IF NOT EXISTS queries_owner_idx
ON queries(owner_id);
""").strip()
await db.execute_write_script(create_tables_sql)
await initialize_metadata_tables(db)
async def initialize_metadata_tables(db):
await db.execute_write_script(textwrap.dedent("""
CREATE TABLE IF NOT EXISTS metadata_instance (
key text,
value text,
unique(key)
);
CREATE TABLE IF NOT EXISTS metadata_databases (
database_name text,
key text,
value text,
unique(database_name, key)
);
CREATE TABLE IF NOT EXISTS metadata_resources (
database_name text,
resource_name text,
key text,
value text,
unique(database_name, resource_name, key)
);
CREATE TABLE IF NOT EXISTS metadata_columns (
database_name text,
resource_name text,
column_name text,
key text,
value text,
unique(database_name, resource_name, column_name, key)
);
CREATE TABLE IF NOT EXISTS column_types (
database_name TEXT NOT NULL,
resource_name TEXT NOT NULL,
column_name TEXT NOT NULL,
column_type TEXT NOT NULL,
config TEXT,
PRIMARY KEY (database_name, resource_name, column_name)
);
CREATE TABLE IF NOT EXISTS queries (
database_name TEXT NOT NULL,
name TEXT NOT NULL,
sql TEXT NOT NULL,
title TEXT,
description TEXT,
description_html TEXT,
options TEXT NOT NULL DEFAULT '{}',
parameters TEXT NOT NULL DEFAULT '[]',
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
source TEXT NOT NULL DEFAULT 'user',
owner_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (database_name, name)
);
CREATE INDEX IF NOT EXISTS queries_owner_idx
ON queries(owner_id);
"""))
internal_migrations = Migrations("datasette_internal")
async def populate_schema_tables(internal_db, db):
def _internal_schema_exists(db):
table_names = set(db.table_names())
if not INTERNAL_DB_SCHEMA_TABLES.issubset(table_names):
return False
index_names = {
row[0]
for row in db.execute("select name from sqlite_master where type = 'index'")
}
return INTERNAL_DB_SCHEMA_INDEXES.issubset(index_names)
@internal_migrations(name="0001_initial")
def initial_internal_schema(db):
if _internal_schema_exists(db):
return
db.executescript(INTERNAL_DB_SCHEMA_SQL)
async def init_internal_db(db):
def apply_migrations(conn):
internal_migrations.apply(SQLiteUtilsDatabase(conn, execute_plugins=False))
await db.execute_write_fn(apply_migrations, transaction=False)
async def populate_schema_tables(internal_db, db, schema_version):
database_name = db.name
def delete_everything(conn):
conn.execute(
"DELETE FROM catalog_tables WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_views WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_columns WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_foreign_keys WHERE database_name = ?",
[database_name],
)
conn.execute(
"DELETE FROM catalog_indexes WHERE database_name = ?", [database_name]
)
await internal_db.execute_write_fn(delete_everything)
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
@ -184,25 +207,30 @@ async def populate_schema_tables(internal_db, db):
columns = table_column_details(conn, table_name)
columns_to_insert.extend(
{
**{"database_name": database_name, "table_name": table_name},
"database_name": database_name,
"table_name": table_name,
**column._asdict(),
}
for column in columns
)
foreign_keys = conn.execute(
f"PRAGMA foreign_key_list([{table_name}])"
f"PRAGMA foreign_key_list({escape_sqlite(table_name)})"
).fetchall()
foreign_keys_to_insert.extend(
{
**{"database_name": database_name, "table_name": table_name},
"database_name": database_name,
"table_name": table_name,
**dict(foreign_key),
}
for foreign_key in foreign_keys
)
indexes = conn.execute(f"PRAGMA index_list([{table_name}])").fetchall()
indexes = conn.execute(
f"PRAGMA index_list({escape_sqlite(table_name)})"
).fetchall()
indexes_to_insert.extend(
{
**{"database_name": database_name, "table_name": table_name},
"database_name": database_name,
"table_name": table_name,
**dict(index),
}
for index in indexes
@ -223,47 +251,76 @@ async def populate_schema_tables(internal_db, db):
indexes_to_insert,
) = await db.execute_fn(collect_info)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
values (?, ?, ?, ?)
""",
tables_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
values (?, ?, ?, ?)
""",
views_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_columns (
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
) VALUES (
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
def replace_catalog(conn):
# Delete child rows before their catalog_tables parents so this also
# works if a prepare_connection plugin enables foreign key enforcement.
for table in (
"catalog_columns",
"catalog_foreign_keys",
"catalog_indexes",
"catalog_views",
"catalog_tables",
):
conn.execute(
f"DELETE FROM {table} WHERE database_name = ?",
[database_name],
)
conn.execute(
"""
INSERT OR REPLACE INTO catalog_databases (
database_name, path, is_memory, schema_version
) VALUES (?, ?, ?, ?)
""",
[
database_name,
str(db.path) if db.path is not None else None,
db.is_memory,
schema_version,
],
)
""",
columns_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_foreign_keys (
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
) VALUES (
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
conn.executemany(
"""
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
values (?, ?, ?, ?)
""",
tables_to_insert,
)
""",
foreign_keys_to_insert,
)
await internal_db.execute_write_many(
"""
INSERT INTO catalog_indexes (
database_name, table_name, seq, name, "unique", origin, partial
) VALUES (
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
conn.executemany(
"""
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
values (?, ?, ?, ?)
""",
views_to_insert,
)
""",
indexes_to_insert,
)
conn.executemany(
"""
INSERT INTO catalog_columns (
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
) VALUES (
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
)
""",
columns_to_insert,
)
conn.executemany(
"""
INSERT INTO catalog_foreign_keys (
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
) VALUES (
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
)
""",
foreign_keys_to_insert,
)
conn.executemany(
"""
INSERT INTO catalog_indexes (
database_name, table_name, seq, name, "unique", origin, partial
) VALUES (
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
)
""",
indexes_to_insert,
)
await internal_db.execute_write_fn(replace_catalog)

View file

@ -11,15 +11,10 @@ Supports:
import asyncio
import shutil
import tempfile
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
)
from urllib.parse import parse_qsl
@ -29,7 +24,7 @@ DEFAULT_MAX_REQUEST_SIZE = 100 * 1024 * 1024 # 100MB
DEFAULT_MAX_FIELDS = 1000
DEFAULT_MAX_FILES = 100
# If max_parts is not specified, it defaults to max_fields + max_files
DEFAULT_MAX_PARTS: Optional[int] = None
DEFAULT_MAX_PARTS: int | None = None
DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB
DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB
DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB
@ -40,8 +35,6 @@ DEFAULT_MIN_FREE_DISK_BYTES = 50 * 1024 * 1024 # 50MB
class MultipartParseError(Exception):
"""Raised when multipart parsing fails."""
pass
@dataclass
class UploadedFile:
@ -57,7 +50,7 @@ class UploadedFile:
name: str
filename: str
content_type: Optional[str]
content_type: str | None
size: int
_file: tempfile.SpooledTemporaryFile = field(repr=False)
@ -86,7 +79,8 @@ class UploadedFile:
def __del__(self):
try:
self._file.close()
except Exception:
except Exception: # noqa: BLE001, S110
# __del__ must never raise
pass
@ -98,27 +92,27 @@ class FormData:
"""
def __init__(self):
self._data: List[Tuple[str, Union[str, UploadedFile]]] = []
self._data: list[tuple[str, str | UploadedFile]] = []
def append(self, key: str, value: Union[str, UploadedFile]) -> None:
def append(self, key: str, value: str | UploadedFile) -> None:
"""Add a key-value pair."""
self._data.append((key, value))
def __getitem__(self, key: str) -> Union[str, UploadedFile]:
def __getitem__(self, key: str) -> str | UploadedFile:
"""Get the first value for a key."""
for k, v in self._data:
if k == key:
return v
raise KeyError(key)
def get(self, key: str, default: Any = None) -> Optional[Union[str, UploadedFile]]:
def get(self, key: str, default: Any = None) -> str | UploadedFile | None:
"""Get the first value for a key, or default if not found."""
try:
return self[key]
except KeyError:
return default
def getlist(self, key: str) -> List[Union[str, UploadedFile]]:
def getlist(self, key: str) -> list[str | UploadedFile]:
"""Get all values for a key."""
return [v for k, v in self._data if k == key]
@ -142,15 +136,15 @@ class FormData:
"""Return unique keys."""
return list(self)
def items(self) -> List[Tuple[str, Union[str, UploadedFile]]]:
def items(self) -> list[tuple[str, str | UploadedFile]]:
"""Return all key-value pairs."""
return list(self._data)
def values(self) -> List[Union[str, UploadedFile]]:
def values(self) -> list[str | UploadedFile]:
"""Return all values."""
return [v for _, v in self._data]
def _uploaded_files(self) -> List[UploadedFile]:
def _uploaded_files(self) -> list[UploadedFile]:
"""Return UploadedFile instances contained in this form."""
return [v for _, v in self._data if isinstance(v, UploadedFile)]
@ -163,7 +157,7 @@ class FormData:
for uploaded in self._uploaded_files():
try:
uploaded.close_sync()
except Exception:
except Exception: # noqa: BLE001, S110
# Best-effort cleanup; ignore close errors
pass
@ -172,7 +166,7 @@ class FormData:
for uploaded in self._uploaded_files():
try:
await uploaded.close()
except Exception:
except Exception: # noqa: BLE001, S110
# Best-effort cleanup; ignore close errors
pass
@ -189,13 +183,13 @@ class FormData:
await self.aclose()
def parse_content_disposition(header: str) -> Dict[str, Optional[str]]:
def parse_content_disposition(header: str) -> dict[str, str | None]:
"""
Parse Content-Disposition header value.
Returns dict with 'name', 'filename' keys (filename may be None).
"""
result: Dict[str, Optional[str]] = {"name": None, "filename": None}
result: dict[str, str | None] = {"name": None, "filename": None}
# Split on semicolons, handling quoted strings
parts = []
@ -238,7 +232,8 @@ def parse_content_disposition(header: str) -> Dict[str, Optional[str]]:
from urllib.parse import unquote
result["filename"] = unquote(encoded, encoding="utf-8")
except Exception:
except Exception: # noqa: BLE001, S110
# Malformed RFC 5987 filename* - fall back to the plain filename
pass
continue
@ -250,20 +245,19 @@ def parse_content_disposition(header: str) -> Dict[str, Optional[str]]:
if key == "name":
result["name"] = value
elif key == "filename":
# Only set if filename* hasn't already set it
if result["filename"] is None:
# Strip path components (security)
# Handle both Unix and Windows paths
value = value.replace("\\", "/")
if "/" in value:
value = value.rsplit("/", 1)[-1]
result["filename"] = value
# Only set filename if filename* hasn't already set it
elif key == "filename" and result["filename"] is None:
# Strip path components (security)
# Handle both Unix and Windows paths
value = value.replace("\\", "/")
if "/" in value:
value = value.rsplit("/", 1)[-1]
result["filename"] = value
return result
def parse_content_type(header: str) -> Tuple[str, Dict[str, str]]:
def parse_content_type(header: str) -> tuple[str, dict[str, str]]:
"""
Parse Content-Type header value.
@ -307,7 +301,7 @@ class MultipartParser:
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES,
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_parts: int | None = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
@ -348,12 +342,12 @@ class MultipartParser:
self._tempdir = tempfile.gettempdir()
# Current part state
self.current_headers: Dict[str, str] = {}
self.current_file: Optional[tempfile.SpooledTemporaryFile] = None
self.current_headers: dict[str, str] = {}
self.current_file: tempfile.SpooledTemporaryFile | None = None
self.current_body = bytearray()
self.current_name: Optional[str] = None
self.current_filename: Optional[str] = None
self.current_content_type: Optional[str] = None
self.current_name: str | None = None
self.current_filename: str | None = None
self.current_content_type: str | None = None
def feed(self, chunk: bytes) -> None:
"""Feed a chunk of data to the parser."""
@ -364,6 +358,13 @@ class MultipartParser:
self.buffer.extend(chunk)
self._process()
def close(self) -> None:
"""Discard completed uploads and any file still being received."""
if self.current_file is not None:
self.current_file.close()
self.current_file = None
self.form_data.close()
def _process(self) -> None:
"""Process buffered data."""
while True:
@ -454,7 +455,7 @@ class MultipartParser:
# Parse header
try:
line_str = line.decode("utf-8", errors="replace")
except Exception:
except UnicodeDecodeError:
line_str = line.decode("latin-1")
if ":" in line_str:
@ -481,7 +482,9 @@ class MultipartParser:
if self.file_count > self.max_files:
raise MultipartParseError("Too many files")
if self.handle_files:
self.current_file = tempfile.SpooledTemporaryFile(
# Outlives this method - it is filled in across parser callbacks
# and then handed to the UploadedFile the caller consumes
self.current_file = tempfile.SpooledTemporaryFile( # noqa: SIM115
max_size=self.max_memory_file_size
)
else:
@ -581,6 +584,9 @@ class MultipartParser:
def _finish_part(self) -> None:
"""Finalize current part and add to form data."""
if self.current_name is None:
if self.current_file is not None:
self.current_file.close()
self.current_file = None
return
if self.current_filename is not None:
@ -644,7 +650,7 @@ async def parse_form_data(
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES,
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_parts: int | None = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
@ -726,29 +732,50 @@ async def parse_form_data(
batch_target = 64 * 1024
batch = bytearray()
async def run_parser(fn, *args):
# Cancellation must not close files while a worker is using them.
task = asyncio.create_task(asyncio.to_thread(fn, *args))
try:
return await asyncio.shield(task)
except asyncio.CancelledError as cancelled:
try:
while not task.done():
try:
await asyncio.shield(task)
except asyncio.CancelledError:
continue
task.result()
finally:
raise cancelled
async def flush_batch() -> None:
if batch:
data = bytes(batch)
batch.clear()
await asyncio.to_thread(parser.feed, data)
await run_parser(parser.feed, data)
while True:
message = await receive()
message_type = message.get("type")
if message_type == "http.disconnect":
raise MultipartParseError("Client disconnected during request body")
if message_type is not None and message_type != "http.request":
continue
chunk = message.get("body", b"")
if chunk:
batch.extend(chunk)
if len(batch) >= batch_target:
await flush_batch()
if not message.get("more_body", False):
break
try:
while True:
message = await receive()
message_type = message.get("type")
if message_type == "http.disconnect":
raise MultipartParseError("Client disconnected during request body")
if message_type is not None and message_type != "http.request":
continue
chunk = message.get("body", b"")
if chunk:
batch.extend(chunk)
if len(batch) >= batch_target:
await flush_batch()
if not message.get("more_body", False):
break
await flush_batch()
return await asyncio.to_thread(parser.finalize)
await flush_batch()
return await run_parser(parser.finalize)
except BaseException:
# No FormData is returned to the caller to take ownership on failure.
await asyncio.to_thread(parser.close)
raise
else:
raise MultipartParseError(

View file

@ -2,8 +2,9 @@
from __future__ import annotations
import json
from typing import Any, Dict, Iterable, List, Sequence, Tuple
import sqlite3
from collections.abc import Iterable, Sequence
from typing import Any
from datasette.permissions import PermissionSQL
from datasette.plugins import pm
@ -15,7 +16,7 @@ SKIP_PERMISSION_CHECKS = object()
async def gather_permission_sql_from_hooks(
*, datasette, actor: dict | None, action: str
) -> List[PermissionSQL] | object:
) -> list[PermissionSQL] | object:
"""Collect PermissionSQL objects from the permission_resources_sql hook.
Ensures that each returned PermissionSQL has a populated ``source``.
@ -34,7 +35,7 @@ async def gather_permission_sql_from_hooks(
hookimpls = hook_caller.get_hookimpls()
hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))
collected: List[PermissionSQL] = []
collected: list[PermissionSQL] = []
actor_json = json.dumps(actor) if actor is not None else None
actor_id = actor.get("id") if isinstance(actor, dict) else None
@ -71,7 +72,7 @@ def _iter_permission_sql_from_result(
if isinstance(result, PermissionSQL):
return [result]
if isinstance(result, (list, tuple)):
collected: List[PermissionSQL] = []
collected: list[PermissionSQL] = []
for item in result:
collected.extend(_iter_permission_sql_from_result(item, action=action))
return collected
@ -90,7 +91,7 @@ def _iter_permission_sql_from_result(
def build_rules_union(
actor: dict | None, plugins: Sequence[PermissionSQL]
) -> Tuple[str, Dict[str, Any]]:
) -> tuple[str, dict[str, Any]]:
"""
Compose plugin SQL into a UNION ALL.
@ -102,10 +103,10 @@ def build_rules_union(
The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent
Plugin parameters should be prefixed with a unique identifier (e.g., source name).
"""
parts: List[str] = []
parts: list[str] = []
actor_json = json.dumps(actor) if actor else None
actor_id = actor.get("id") if actor else None
params: Dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
params: dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
for p in plugins:
# No namespacing - just use plugin params as-is
@ -141,10 +142,10 @@ async def resolve_permissions_from_catalog(
plugins: Sequence[Any],
action: str,
candidate_sql: str,
candidate_params: Dict[str, Any] | None = None,
candidate_params: dict[str, Any] | None = None,
*,
implicit_deny: bool = True,
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""
Resolve permissions by embedding the provided *candidate_sql* in a CTE.
@ -168,8 +169,8 @@ async def resolve_permissions_from_catalog(
- parent, child, allow, reason, source_plugin, depth
- resource (rendered "/parent/child" or "/parent" or "/")
"""
resolved_plugins: List[PermissionSQL] = []
restriction_sqls: List[str] = []
resolved_plugins: list[PermissionSQL] = []
restriction_sqls: list[str] = []
for plugin in plugins:
if callable(plugin) and not isinstance(plugin, PermissionSQL):
@ -398,11 +399,11 @@ async def resolve_permissions_with_candidates(
db,
actor: dict | None,
plugins: Sequence[Any],
candidates: List[Tuple[str, str | None]],
candidates: list[tuple[str, str | None]],
action: str,
*,
implicit_deny: bool = True,
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""
Resolve permissions without any external candidate table by embedding
the candidates as a UNION of parameterized SELECTs in a CTE.
@ -411,8 +412,8 @@ async def resolve_permissions_with_candidates(
actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action
"""
# Build a small CTE for candidates.
cand_rows_sql: List[str] = []
cand_params: Dict[str, Any] = {}
cand_rows_sql: list[str] = []
cand_params: dict[str, Any] = {}
for i, (parent, child) in enumerate(candidates):
pkey = f"cand_p_{i}"
ckey = f"cand_c_{i}"

Some files were not shown because too many files have changed in this diff Show more