2026-06-21 16:46:20 -07:00
|
|
|
import importlib.metadata
|
2019-07-05 17:05:56 -07:00
|
|
|
import os
|
2020-06-06 22:30:36 -07:00
|
|
|
import pathlib
|
|
|
|
|
import re
|
2026-09-01 09:32:37 -07:00
|
|
|
import socket
|
2021-02-11 16:52:16 -08:00
|
|
|
import subprocess
|
2025-10-01 12:49:09 -07:00
|
|
|
import sys
|
2021-02-11 16:52:16 -08:00
|
|
|
import tempfile
|
|
|
|
|
import time
|
2024-02-06 17:27:20 -08:00
|
|
|
from dataclasses import dataclass
|
2026-07-25 15:47:08 -07:00
|
|
|
|
2026-09-10 19:44:49 -07:00
|
|
|
import httpx2
|
2026-07-25 15:47:08 -07:00
|
|
|
import pytest
|
|
|
|
|
import pytest_asyncio
|
|
|
|
|
|
2024-01-31 15:21:40 -08:00
|
|
|
from datasette import Event, hookimpl
|
2021-02-11 16:52:16 -08:00
|
|
|
|
2020-11-30 13:29:57 -08:00
|
|
|
try:
|
|
|
|
|
import pysqlite3 as sqlite3
|
|
|
|
|
except ImportError:
|
|
|
|
|
import sqlite3
|
|
|
|
|
|
2020-06-06 22:30:36 -07:00
|
|
|
UNDOCUMENTED_PERMISSIONS = {
|
|
|
|
|
"this_is_allowed",
|
|
|
|
|
"this_is_denied",
|
|
|
|
|
"this_is_allowed_async",
|
|
|
|
|
"this_is_denied_async",
|
|
|
|
|
"no_match",
|
2025-11-01 11:35:08 -07:00
|
|
|
# Test actions from test_hook_register_actions_with_custom_resources
|
|
|
|
|
"manage_documents",
|
|
|
|
|
"view_document_collection",
|
|
|
|
|
"view_document",
|
2020-06-06 22:30:36 -07:00
|
|
|
}
|
2019-07-05 17:05:56 -07:00
|
|
|
|
2026-04-16 20:44:21 -07:00
|
|
|
|
2026-09-10 19:44:49 -07:00
|
|
|
def wait_until_responds(url, timeout=5.0, client=httpx2, process=None, **kwargs):
|
2022-12-17 17:22:00 -08:00
|
|
|
start = time.time()
|
|
|
|
|
while time.time() - start < timeout:
|
2026-09-01 09:32:37 -07:00
|
|
|
# If the server died there is no point waiting out the timeout - fail
|
|
|
|
|
# now, with its output, instead of after `timeout` seconds of silence
|
|
|
|
|
if process is not None and process.poll() is not None:
|
|
|
|
|
raise AssertionError(
|
|
|
|
|
"Server exited early with returncode {}\n{}".format(
|
|
|
|
|
process.returncode, process.stdout.read().decode("utf-8")
|
|
|
|
|
)
|
|
|
|
|
)
|
2022-12-17 17:22:00 -08:00
|
|
|
try:
|
|
|
|
|
client.get(url, **kwargs)
|
|
|
|
|
return
|
2026-09-10 19:44:49 -07:00
|
|
|
except httpx2.TransportError:
|
2022-12-17 17:22:00 -08:00
|
|
|
time.sleep(0.1)
|
2026-07-25 15:47:08 -07:00
|
|
|
raise AssertionError(f"Timed out waiting for {url} to respond")
|
2022-12-17 17:22:00 -08:00
|
|
|
|
|
|
|
|
|
2026-09-01 09:32:37 -07:00
|
|
|
def find_free_port():
|
|
|
|
|
with socket.socket() as sock:
|
|
|
|
|
sock.bind(("127.0.0.1", 0))
|
|
|
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
|
|
|
|
|
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-02 12:24:42 -07:00
|
|
|
# The otel fixtures moved to datasette.telemetry_testing, which is public
|
|
|
|
|
# plugin API - core's suite consumes it exactly the way a plugin's would.
|
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-02 14:26:45 -07:00
|
|
|
from datasette.telemetry_testing import ( # noqa: F401
|
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-02 12:24:42 -07:00
|
|
|
MetricsCollector,
|
|
|
|
|
otel_meter_provider,
|
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-02 14:26:45 -07:00
|
|
|
otel_metrics,
|
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-02 12:24:42 -07:00
|
|
|
otel_provider,
|
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-02 14:26:45 -07:00
|
|
|
otel_reset,
|
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-02 12:24:42 -07:00
|
|
|
otel_spans,
|
|
|
|
|
)
|
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-07-30 09:43:30 -07:00
|
|
|
|
|
|
|
|
|
2026-04-14 17:11:36 -07:00
|
|
|
@pytest.fixture
|
|
|
|
|
def bare_ds():
|
|
|
|
|
"""
|
|
|
|
|
Minimal Datasette with no plugins, data, metadata, or config - for tests
|
|
|
|
|
that want to exercise core behavior (e.g. middleware) in isolation.
|
|
|
|
|
"""
|
|
|
|
|
from datasette.app import Datasette
|
|
|
|
|
|
|
|
|
|
return Datasette(memory=True)
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 20:40:51 -07:00
|
|
|
@pytest_asyncio.fixture(scope="session")
|
2022-12-15 13:44:48 -08:00
|
|
|
async def ds_client():
|
2026-07-25 15:47:08 -07:00
|
|
|
import secrets
|
|
|
|
|
|
2022-12-15 13:44:48 -08:00
|
|
|
from datasette.app import Datasette
|
2025-10-30 10:41:41 -07:00
|
|
|
from datasette.database import Database
|
2026-07-25 15:47:08 -07:00
|
|
|
|
2023-09-13 14:06:25 -07:00
|
|
|
from .fixtures import CONFIG, METADATA, PLUGINS_DIR
|
2022-12-15 13:44:48 -08:00
|
|
|
|
|
|
|
|
ds = Datasette(
|
|
|
|
|
metadata=METADATA,
|
2023-09-13 14:06:25 -07:00
|
|
|
config=CONFIG,
|
2022-12-15 13:44:48 -08:00
|
|
|
plugins_dir=PLUGINS_DIR,
|
|
|
|
|
settings={
|
|
|
|
|
"default_page_size": 50,
|
|
|
|
|
"max_returned_rows": 100,
|
|
|
|
|
"sql_time_limit_ms": 200,
|
2025-11-03 11:51:53 -08:00
|
|
|
"facet_suggest_time_limit_ms": 200, # Up from 50 default
|
2022-12-15 13:44:48 -08:00
|
|
|
# Default is 3 but this results in "too many open files"
|
|
|
|
|
# errors when running the full test suite:
|
|
|
|
|
"num_sql_threads": 1,
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-05-21 23:05:37 -07:00
|
|
|
from datasette.fixtures import populate_fixture_database
|
2022-12-15 13:44:48 -08:00
|
|
|
|
2025-10-30 10:41:41 -07:00
|
|
|
# Use a unique memory_name to avoid collisions between different
|
|
|
|
|
# Datasette instances in the same process, but use "fixtures" for routing
|
|
|
|
|
unique_memory_name = f"fixtures_{secrets.token_hex(8)}"
|
|
|
|
|
db = ds.add_database(Database(ds, memory_name=unique_memory_name), name="fixtures")
|
2022-12-15 17:38:22 -08:00
|
|
|
ds.remove_database("_memory")
|
2022-12-15 13:44:48 -08:00
|
|
|
|
|
|
|
|
def prepare(conn):
|
2022-12-15 17:38:22 -08:00
|
|
|
if not conn.execute("select count(*) from sqlite_master").fetchone()[0]:
|
2026-05-21 23:05:37 -07:00
|
|
|
populate_fixture_database(conn)
|
2022-12-15 13:44:48 -08:00
|
|
|
|
|
|
|
|
await db.execute_write_fn(prepare)
|
2022-12-16 09:51:29 -08:00
|
|
|
await ds.invoke_startup()
|
2026-09-16 14:50:06 -07:00
|
|
|
try:
|
|
|
|
|
yield ds.client
|
|
|
|
|
finally:
|
|
|
|
|
ds.close()
|
2022-12-15 13:44:48 -08:00
|
|
|
|
|
|
|
|
|
2020-11-30 13:29:57 -08:00
|
|
|
def pytest_report_header(config):
|
2025-12-12 22:38:04 -08:00
|
|
|
conn = sqlite3.connect(":memory:")
|
|
|
|
|
version = conn.execute("select sqlite_version()").fetchone()[0]
|
|
|
|
|
conn.close()
|
2026-06-21 16:46:20 -07:00
|
|
|
sqlite_utils_version = importlib.metadata.version("sqlite-utils")
|
|
|
|
|
headers = [
|
2026-07-25 15:47:08 -07:00
|
|
|
f"SQLite: {version}",
|
|
|
|
|
f"sqlite-utils: {sqlite_utils_version}",
|
2026-06-21 16:46:20 -07:00
|
|
|
]
|
2026-06-16 13:35:15 -07:00
|
|
|
if config.getoption("--playwright"):
|
|
|
|
|
try:
|
|
|
|
|
browsers = config.getoption("--browser")
|
|
|
|
|
except ValueError:
|
|
|
|
|
browsers = None
|
|
|
|
|
if isinstance(browsers, str):
|
|
|
|
|
browsers = [browsers]
|
|
|
|
|
if browsers:
|
|
|
|
|
headers.append("Playwright browsers: {}".format(", ".join(browsers)))
|
|
|
|
|
return headers
|
2020-11-30 13:29:57 -08:00
|
|
|
|
|
|
|
|
|
2026-06-14 16:39:55 -07:00
|
|
|
def pytest_addoption(parser):
|
|
|
|
|
parser.addoption(
|
|
|
|
|
"--playwright",
|
|
|
|
|
action="store_true",
|
|
|
|
|
default=False,
|
|
|
|
|
help="run Playwright browser automation tests",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2019-05-01 22:10:23 -07:00
|
|
|
def pytest_configure(config):
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
sys._called_from_test = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pytest_unconfigure(config):
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
del sys._called_from_test
|
2019-05-03 22:15:14 -04:00
|
|
|
|
|
|
|
|
|
2026-06-14 16:39:55 -07:00
|
|
|
def pytest_collection_modifyitems(config, items):
|
|
|
|
|
if not config.getoption("--playwright"):
|
|
|
|
|
skip_playwright = pytest.mark.skip(reason="need --playwright option to run")
|
|
|
|
|
for item in items:
|
|
|
|
|
if "playwright" in item.keywords:
|
|
|
|
|
item.add_marker(skip_playwright)
|
|
|
|
|
|
2020-08-15 13:38:15 -07:00
|
|
|
# Ensure test_cli.py and test_black.py and test_inspect.py run first before any asyncio code kicks in
|
|
|
|
|
move_to_front(items, "test_cli")
|
2019-05-11 14:45:59 -07:00
|
|
|
move_to_front(items, "test_black")
|
|
|
|
|
move_to_front(items, "test_inspect_cli")
|
2020-08-15 13:38:15 -07:00
|
|
|
move_to_front(items, "test_serve_with_get")
|
2020-09-11 15:04:23 -07:00
|
|
|
move_to_front(items, "test_serve_with_get_exit_code_for_error")
|
2019-05-11 15:03:52 -07:00
|
|
|
move_to_front(items, "test_inspect_cli_writes_to_file")
|
2019-05-11 16:22:55 -07:00
|
|
|
move_to_front(items, "test_spatialite_error_if_attempt_to_open_spatialite")
|
2020-01-29 14:46:43 -08:00
|
|
|
move_to_front(items, "test_package")
|
|
|
|
|
move_to_front(items, "test_package_with_port")
|
2026-09-24 09:11:08 -07:00
|
|
|
# Same reason: this one shells out to a fresh interpreter. Late in a serial
|
|
|
|
|
# run the pytest process holds enough threads that the fork half of
|
|
|
|
|
# subprocess' fork+exec crashes the interpreter on macOS/CPython 3.13
|
|
|
|
|
# (SIGSEGV/SIGBUS inside _execute_child). Reproduces with any subprocess
|
|
|
|
|
# call placed there, on an unmodified tree - running it first avoids it.
|
|
|
|
|
move_to_front(items, "test_datasette_package_never_imports_the_sdk")
|
2026-09-02 13:02:25 -07:00
|
|
|
move_to_front(items, "test_kit_module_itself_never_imports_the_sdk")
|
2026-09-24 09:14:03 -07:00
|
|
|
move_to_front(items, "test_no_provider_takes_the_fast_path")
|
2019-05-11 14:45:59 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def move_to_front(items, test_name):
|
|
|
|
|
test = [fn for fn in items if fn.name == test_name]
|
|
|
|
|
if test:
|
|
|
|
|
items.insert(0, items.pop(items.index(test[0])))
|
2019-07-05 17:05:56 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def restore_working_directory(tmpdir, request):
|
2021-06-05 16:01:34 -07:00
|
|
|
try:
|
|
|
|
|
previous_cwd = os.getcwd()
|
|
|
|
|
except OSError:
|
|
|
|
|
# https://github.com/simonw/datasette/issues/1361
|
|
|
|
|
previous_cwd = None
|
2019-07-05 17:05:56 -07:00
|
|
|
tmpdir.chdir()
|
|
|
|
|
|
|
|
|
|
def return_to_previous():
|
|
|
|
|
os.chdir(previous_cwd)
|
|
|
|
|
|
2021-06-05 16:01:34 -07:00
|
|
|
if previous_cwd is not None:
|
|
|
|
|
request.addfinalizer(return_to_previous)
|
2020-06-06 22:30:36 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
2025-10-30 17:59:54 -07:00
|
|
|
def check_actions_are_documented():
|
2026-06-12 12:51:40 -07:00
|
|
|
from datasette.default_actions import register_actions as default_register_actions
|
2026-07-25 15:47:08 -07:00
|
|
|
from datasette.plugins import pm
|
2020-06-06 22:30:36 -07:00
|
|
|
|
|
|
|
|
content = (
|
2021-03-11 17:15:49 +01:00
|
|
|
pathlib.Path(__file__).parent.parent / "docs" / "authentication.rst"
|
|
|
|
|
).read_text()
|
2025-10-30 17:59:54 -07:00
|
|
|
permissions_re = re.compile(r"\.\. _actions_([^\s:]+):")
|
|
|
|
|
documented_actions = set(permissions_re.findall(content)).union(
|
2020-06-06 22:30:36 -07:00
|
|
|
UNDOCUMENTED_PERMISSIONS
|
|
|
|
|
)
|
2026-06-12 12:51:40 -07:00
|
|
|
# Only Datasette core actions need to be documented - actions registered
|
|
|
|
|
# by (test) plugins are checked for registration but not documentation
|
|
|
|
|
core_actions = {action.name for action in default_register_actions()}
|
2020-06-06 22:30:36 -07:00
|
|
|
|
|
|
|
|
def before(hook_name, hook_impls, kwargs):
|
2025-10-25 08:52:48 -07:00
|
|
|
if hook_name == "permission_resources_sql":
|
2022-12-12 18:05:54 -08:00
|
|
|
datasette = kwargs["datasette"]
|
2025-10-24 14:31:33 -07:00
|
|
|
assert kwargs["action"] in datasette.actions, (
|
|
|
|
|
"'{}' has not been registered with register_actions()".format(
|
2022-12-12 18:05:54 -08:00
|
|
|
kwargs["action"]
|
|
|
|
|
)
|
|
|
|
|
+ " (or maybe a test forgot to do await ds.invoke_startup())"
|
|
|
|
|
)
|
2020-06-06 22:30:36 -07:00
|
|
|
action = kwargs.get("action").replace("-", "_")
|
2026-06-12 12:51:40 -07:00
|
|
|
if kwargs["action"] in core_actions:
|
|
|
|
|
assert (
|
|
|
|
|
action in documented_actions
|
2026-07-25 15:47:08 -07:00
|
|
|
), f"Undocumented permission action: {action}"
|
2020-06-06 22:30:36 -07:00
|
|
|
|
|
|
|
|
pm.add_hookcall_monitoring(
|
|
|
|
|
before=before, after=lambda outcome, hook_name, hook_impls, kwargs: None
|
|
|
|
|
)
|
2021-02-11 16:52:16 -08:00
|
|
|
|
|
|
|
|
|
2024-01-31 15:21:40 -08:00
|
|
|
class TrackEventPlugin:
|
|
|
|
|
__name__ = "TrackEventPlugin"
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class OneEvent(Event):
|
|
|
|
|
name = "one"
|
|
|
|
|
|
|
|
|
|
extra: str
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
|
|
|
|
def register_events(self, datasette):
|
|
|
|
|
async def inner():
|
|
|
|
|
return [self.OneEvent]
|
|
|
|
|
|
|
|
|
|
return inner
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
|
|
|
|
def track_event(self, datasette, event):
|
|
|
|
|
datasette._tracked_events = getattr(datasette, "_tracked_events", [])
|
|
|
|
|
datasette._tracked_events.append(event)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
|
|
|
def install_event_tracking_plugin():
|
|
|
|
|
from datasette.plugins import pm
|
|
|
|
|
|
|
|
|
|
pm.register(TrackEventPlugin(), name="TrackEventPlugin")
|
|
|
|
|
|
|
|
|
|
|
2021-02-11 16:52:16 -08:00
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def ds_localhost_http_server():
|
|
|
|
|
ds_proc = subprocess.Popen(
|
2025-10-01 12:49:09 -07:00
|
|
|
[sys.executable, "-m", "datasette", "--memory", "-p", "8041"],
|
2021-02-11 16:52:16 -08:00
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
# Avoid FileNotFoundError: [Errno 2] No such file or directory:
|
|
|
|
|
cwd=tempfile.gettempdir(),
|
|
|
|
|
)
|
2026-09-16 14:50:06 -07:00
|
|
|
try:
|
|
|
|
|
wait_until_responds("http://localhost:8041/", process=ds_proc)
|
|
|
|
|
yield ds_proc
|
|
|
|
|
finally:
|
|
|
|
|
stop_process(ds_proc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stop_process(proc):
|
|
|
|
|
try:
|
|
|
|
|
if proc.poll() is None:
|
|
|
|
|
proc.terminate()
|
|
|
|
|
try:
|
|
|
|
|
proc.wait(timeout=5)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
proc.kill()
|
|
|
|
|
proc.wait()
|
|
|
|
|
finally:
|
|
|
|
|
proc.stdout.close()
|
2021-07-10 16:37:30 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def ds_unix_domain_socket_server(tmp_path_factory):
|
2021-07-31 11:48:33 -07:00
|
|
|
# This used to use tmp_path_factory.mktemp("uds") but that turned out to
|
|
|
|
|
# produce paths that were too long to use as UDS on macOS, see
|
|
|
|
|
# https://github.com/simonw/datasette/issues/1407 - so I switched to
|
2026-06-17 09:58:39 -07:00
|
|
|
# using tempfile.gettempdir() with a per-process filename.
|
|
|
|
|
uds = str(pathlib.Path(tempfile.gettempdir()) / f"datasette-{os.getpid()}.sock")
|
|
|
|
|
try:
|
|
|
|
|
os.unlink(uds)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
pass
|
2021-07-10 16:37:30 -07:00
|
|
|
ds_proc = subprocess.Popen(
|
2025-10-01 12:49:09 -07:00
|
|
|
[sys.executable, "-m", "datasette", "--memory", "--uds", uds],
|
2021-07-10 16:37:30 -07:00
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
cwd=tempfile.gettempdir(),
|
|
|
|
|
)
|
2022-10-25 12:42:21 -07:00
|
|
|
# Poll until available
|
2026-09-10 19:44:49 -07:00
|
|
|
transport = httpx2.HTTPTransport(uds=uds)
|
|
|
|
|
client = httpx2.Client(transport=transport)
|
2026-06-17 09:58:39 -07:00
|
|
|
try:
|
2026-09-16 14:50:06 -07:00
|
|
|
# Probe with a socket we own: the HTTP transport can leak a socket
|
|
|
|
|
# when connect() fails before the UDS server has started listening.
|
|
|
|
|
start = time.monotonic()
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
|
|
|
|
|
probe.settimeout(0.1)
|
|
|
|
|
probe.connect(uds)
|
|
|
|
|
break
|
|
|
|
|
except OSError:
|
|
|
|
|
if ds_proc.poll() is not None or time.monotonic() - start > 30:
|
|
|
|
|
raise
|
|
|
|
|
time.sleep(0.1)
|
2026-06-17 09:58:39 -07:00
|
|
|
wait_until_responds(
|
2026-09-16 14:50:06 -07:00
|
|
|
"http://localhost/_memory.json",
|
|
|
|
|
timeout=30.0,
|
|
|
|
|
client=client,
|
|
|
|
|
process=ds_proc,
|
2026-06-17 09:58:39 -07:00
|
|
|
)
|
|
|
|
|
# Check it started successfully
|
|
|
|
|
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
|
|
|
|
|
yield ds_proc, uds
|
|
|
|
|
finally:
|
|
|
|
|
client.close()
|
|
|
|
|
# Shut it down at the end of the pytest session
|
2026-09-16 14:50:06 -07:00
|
|
|
stop_process(ds_proc)
|
2026-06-17 09:58:39 -07:00
|
|
|
try:
|
|
|
|
|
os.unlink(uds)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
pass
|
2025-10-26 15:52:36 -07:00
|
|
|
|
|
|
|
|
|
2026-09-01 09:32:37 -07:00
|
|
|
@pytest.fixture
|
|
|
|
|
def serve_with_plugins(tmp_path):
|
|
|
|
|
"""Factory fixture for starting ``datasette serve`` in a subprocess with
|
|
|
|
|
plugins written to a temporary ``--plugins-dir``.
|
|
|
|
|
|
|
|
|
|
For tests that need the real serve path: event-loop wiring, exit codes,
|
|
|
|
|
signals. The usual in-process ``pm.register`` plugin pattern can't reach
|
|
|
|
|
a subprocess, so plugin source is written out as importable files instead.
|
|
|
|
|
|
|
|
|
|
Unlike ``ds_localhost_http_server`` this is function-scoped and takes a
|
|
|
|
|
fresh port each time, because each test needs its own plugins. Call it as::
|
|
|
|
|
|
|
|
|
|
proc, port = serve_with_plugins({"my_plugin": PLUGIN_SOURCE})
|
|
|
|
|
|
|
|
|
|
``plugins`` maps module name to Python source. Pass
|
|
|
|
|
``wait_for_startup=False`` when the server is expected to fail during
|
|
|
|
|
startup rather than begin serving. Extra CLI arguments are passed through.
|
|
|
|
|
Every process started is terminated when the test ends.
|
|
|
|
|
"""
|
|
|
|
|
processes = []
|
|
|
|
|
|
|
|
|
|
def start(plugins, *extra_args, wait_for_startup=True):
|
|
|
|
|
plugins_dir = tmp_path / "plugins"
|
|
|
|
|
plugins_dir.mkdir(exist_ok=True)
|
|
|
|
|
for module_name, source in plugins.items():
|
|
|
|
|
(plugins_dir / f"{module_name}.py").write_text(source, "utf-8")
|
|
|
|
|
port = find_free_port()
|
|
|
|
|
proc = subprocess.Popen(
|
|
|
|
|
[
|
|
|
|
|
sys.executable,
|
|
|
|
|
"-m",
|
|
|
|
|
"datasette",
|
|
|
|
|
"--memory",
|
|
|
|
|
"--plugins-dir",
|
|
|
|
|
str(plugins_dir),
|
|
|
|
|
"-h",
|
|
|
|
|
"127.0.0.1",
|
|
|
|
|
"-p",
|
|
|
|
|
str(port),
|
|
|
|
|
*extra_args,
|
|
|
|
|
],
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
# Avoid FileNotFoundError: [Errno 2] No such file or directory:
|
|
|
|
|
cwd=tempfile.gettempdir(),
|
|
|
|
|
)
|
|
|
|
|
processes.append(proc)
|
|
|
|
|
if wait_for_startup:
|
|
|
|
|
wait_until_responds(
|
|
|
|
|
f"http://127.0.0.1:{port}/-/versions.json", process=proc
|
|
|
|
|
)
|
|
|
|
|
return proc, port
|
|
|
|
|
|
|
|
|
|
yield start
|
|
|
|
|
|
|
|
|
|
for proc in processes:
|
2026-09-16 14:50:06 -07:00
|
|
|
stop_process(proc)
|
2026-09-01 09:32:37 -07:00
|
|
|
|
|
|
|
|
|
2025-10-26 15:52:36 -07:00
|
|
|
# Import fixtures from fixtures.py to make them available
|
2026-07-25 15:47:08 -07:00
|
|
|
from .fixtures import ( # noqa: F401
|
|
|
|
|
TEMP_PLUGIN_SECRET_FILE,
|
2025-10-26 15:52:36 -07:00
|
|
|
app_client,
|
|
|
|
|
app_client_base_url_prefix,
|
|
|
|
|
app_client_conflicting_database_names,
|
|
|
|
|
app_client_csv_max_mb_one,
|
|
|
|
|
app_client_immutable_and_inspect_file,
|
|
|
|
|
app_client_larger_cache_size,
|
|
|
|
|
app_client_no_files,
|
|
|
|
|
app_client_returned_rows_matches_page_size,
|
|
|
|
|
app_client_shorter_time_limit,
|
|
|
|
|
app_client_two_attached_databases,
|
|
|
|
|
app_client_two_attached_databases_crossdb_enabled,
|
|
|
|
|
app_client_two_attached_databases_one_immutable,
|
|
|
|
|
app_client_with_cors,
|
|
|
|
|
app_client_with_dot,
|
|
|
|
|
app_client_with_trace,
|
|
|
|
|
make_app_client,
|
|
|
|
|
)
|