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
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
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-01 09:32:37 -07:00
|
|
|
def wait_until_responds(url, timeout=5.0, client=httpx, 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-01 09:32:37 -07:00
|
|
|
except httpx.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 opentelemetry-api dependency and datasette/telemetry.py scaffolding
Datasette core is gaining OpenTelemetry spans alongside the existing
hand-rolled tracer. This commit only lays the groundwork - no span is
emitted yet.
Core takes a runtime dependency on opentelemetry-api and nothing more.
It deliberately never creates a TracerProvider, configures an exporter,
or touches sampling: that belongs to whoever runs Datasette, normally
via an opentelemetry-instrument agent. Owning a provider in core was
tried in an earlier design and produced a cross-request span leak, a
process-global provider that tests could not tear down, and a sampling
env var that silently blanked output. With no provider installed every
span is a NonRecordingSpan and costs approximately nothing.
datasette/telemetry.py exposes the module-level tracer plus
sql_attribute(), which truncates SQL to 2048 characters. On a public
instance the SQL is attacker-controlled and unbounded - someone can
paste a 10MB query into ?sql= - so it must never reach a telemetry
pipeline verbatim.
opentelemetry-sdk goes in the dev dependency group only, because the
test suite needs it to assert on spans while the package itself must
not import it. tests/test_telemetry.py enforces that by importing
datasette in a fresh interpreter and inspecting sys.modules, which
catches a lazy import inside a function body that a grep would miss.
conftest.py gains a session-scoped autouse fixture installing an SDK
provider with an InMemorySpanExporter. It has to be session-scoped
because set_tracer_provider() is effectively once-per-process - a
second call logs a warning and is ignored. SimpleSpanProcessor rather
than BatchSpanProcessor, so assertions made right after a request never
race a background export thread. The otel_spans fixture that later
tickets assert against is added here too.
test_datasette_package_never_imports_the_sdk is moved to the front of
the run. Late in a serial run the pytest process holds enough threads
that the fork half of subprocess' fork+exec segfaults the interpreter
on macOS/CPython 3.13. That reproduces with any subprocess call in that
position on an unmodified tree, so it is a pre-existing hazard rather
than something this commit introduces; the repo already moves its other
subprocess-spawning tests to the front for related reasons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:52 -07:00
|
|
|
_otel_span_exporter = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
|
|
|
def _otel_provider():
|
|
|
|
|
"""
|
|
|
|
|
Install a real OTel SDK TracerProvider + InMemorySpanExporter exactly
|
|
|
|
|
once, before any span is ever created in this process.
|
|
|
|
|
|
|
|
|
|
This has to be session-scoped and autouse because
|
|
|
|
|
`opentelemetry.trace.set_tracer_provider()` is effectively
|
|
|
|
|
once-per-process: a second call logs a warning and is ignored. So the
|
|
|
|
|
install must happen exactly once, before anything asserts on spans.
|
|
|
|
|
|
|
|
|
|
`datasette.telemetry.tracer` is a module-level `ProxyTracer`. Once a
|
|
|
|
|
provider exists, the first span it starts resolves a concrete tracer
|
|
|
|
|
and caches it permanently. It does *not* cache the no-op tracer, so
|
|
|
|
|
any span started before this fixture runs is merely lost rather than
|
|
|
|
|
poisoning the tracer for the rest of the process. If the SDK isn't
|
|
|
|
|
installed, do nothing: core spans stay no-op `NonRecordingSpan`s and
|
|
|
|
|
the rest of the suite is unaffected.
|
|
|
|
|
"""
|
|
|
|
|
global _otel_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
|
|
|
|
|
exporter = InMemorySpanExporter()
|
|
|
|
|
provider = TracerProvider()
|
|
|
|
|
# SimpleSpanProcessor exports synchronously on span end - no background
|
|
|
|
|
# batching thread, so assertions immediately after a request never race.
|
|
|
|
|
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
|
|
|
otel_trace.set_tracer_provider(provider)
|
|
|
|
|
_otel_span_exporter = exporter
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def otel_spans():
|
|
|
|
|
"""
|
|
|
|
|
Function-scoped access to the finished-spans exporter: clears any spans
|
|
|
|
|
left over from previous tests, then yields the exporter so a test can
|
|
|
|
|
call `.get_finished_spans()` after making requests. Skips (rather than
|
|
|
|
|
fails) if the OTel SDK is not installed.
|
|
|
|
|
"""
|
|
|
|
|
pytest.importorskip("opentelemetry.sdk")
|
|
|
|
|
if _otel_span_exporter is None:
|
|
|
|
|
pytest.skip("OpenTelemetry SDK provider was not installed")
|
|
|
|
|
_otel_span_exporter.clear()
|
|
|
|
|
yield _otel_span_exporter
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
_otel_metric_reader = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
|
|
|
def _otel_meter_provider():
|
|
|
|
|
"""
|
|
|
|
|
Install a real OTel SDK MeterProvider + InMemoryMetricReader once per
|
|
|
|
|
process.
|
|
|
|
|
|
|
|
|
|
Unlike the tracer, ordering is not load-bearing here: `_ProxyMeter` and
|
|
|
|
|
the `_ProxyInstrument`s it hands out forward to a provider installed
|
|
|
|
|
*after* they were created, whereas `ProxyTracer` permanently caches the
|
|
|
|
|
first concrete tracer it resolves. This fixture is still session-scoped
|
|
|
|
|
and autouse for symmetry, and so that a single reader collects for the
|
|
|
|
|
whole run.
|
|
|
|
|
|
|
|
|
|
DELTA temporality is chosen for counters and histograms so that each
|
|
|
|
|
collection reports only what happened since the previous one. With the
|
|
|
|
|
SDK default of CUMULATIVE, every metrics test would see every query run
|
|
|
|
|
by every earlier test in the session.
|
|
|
|
|
"""
|
|
|
|
|
global _otel_metric_reader
|
|
|
|
|
try:
|
|
|
|
|
from opentelemetry import metrics as otel_metrics
|
|
|
|
|
from opentelemetry.sdk.metrics import Counter, Histogram, MeterProvider
|
|
|
|
|
from opentelemetry.sdk.metrics.export import (
|
|
|
|
|
AggregationTemporality,
|
|
|
|
|
InMemoryMetricReader,
|
|
|
|
|
)
|
|
|
|
|
except ImportError:
|
|
|
|
|
return
|
|
|
|
|
reader = InMemoryMetricReader(
|
|
|
|
|
preferred_temporality={
|
|
|
|
|
Counter: AggregationTemporality.DELTA,
|
|
|
|
|
Histogram: AggregationTemporality.DELTA,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
otel_metrics.set_meter_provider(MeterProvider(metric_readers=[reader]))
|
|
|
|
|
_otel_metric_reader = reader
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MetricsCollector:
|
|
|
|
|
"""
|
|
|
|
|
Thin reader over an `InMemoryMetricReader`.
|
|
|
|
|
|
|
|
|
|
`collect()` runs a collection cycle - which is what invokes the observable
|
|
|
|
|
gauge callbacks - and snapshots the result. Queries then run against that
|
|
|
|
|
snapshot rather than re-collecting, so a test that inspects several
|
|
|
|
|
metrics sees one consistent moment and does not drain delta state twice.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, reader):
|
|
|
|
|
self.reader = reader
|
|
|
|
|
self.snapshot = {}
|
|
|
|
|
|
|
|
|
|
def collect(self):
|
|
|
|
|
self.snapshot = {}
|
|
|
|
|
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:
|
|
|
|
|
for metric in scope_metrics.metrics:
|
|
|
|
|
self.snapshot.setdefault(metric.name, []).extend(
|
|
|
|
|
metric.data.data_points
|
|
|
|
|
)
|
|
|
|
|
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():
|
|
|
|
|
"""
|
|
|
|
|
Function-scoped metrics collector. Drains any delta state accumulated by
|
|
|
|
|
earlier tests before yielding, so counts start from zero.
|
|
|
|
|
"""
|
|
|
|
|
pytest.importorskip("opentelemetry.sdk")
|
|
|
|
|
if _otel_metric_reader is None:
|
|
|
|
|
pytest.skip("OpenTelemetry SDK meter provider was not installed")
|
|
|
|
|
_otel_metric_reader.get_metrics_data()
|
|
|
|
|
yield MetricsCollector(_otel_metric_reader)
|
|
|
|
|
|
|
|
|
|
|
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-04-16 20:41:58 -07:00
|
|
|
return ds.client
|
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")
|
Add opentelemetry-api dependency and datasette/telemetry.py scaffolding
Datasette core is gaining OpenTelemetry spans alongside the existing
hand-rolled tracer. This commit only lays the groundwork - no span is
emitted yet.
Core takes a runtime dependency on opentelemetry-api and nothing more.
It deliberately never creates a TracerProvider, configures an exporter,
or touches sampling: that belongs to whoever runs Datasette, normally
via an opentelemetry-instrument agent. Owning a provider in core was
tried in an earlier design and produced a cross-request span leak, a
process-global provider that tests could not tear down, and a sampling
env var that silently blanked output. With no provider installed every
span is a NonRecordingSpan and costs approximately nothing.
datasette/telemetry.py exposes the module-level tracer plus
sql_attribute(), which truncates SQL to 2048 characters. On a public
instance the SQL is attacker-controlled and unbounded - someone can
paste a 10MB query into ?sql= - so it must never reach a telemetry
pipeline verbatim.
opentelemetry-sdk goes in the dev dependency group only, because the
test suite needs it to assert on spans while the package itself must
not import it. tests/test_telemetry.py enforces that by importing
datasette in a fresh interpreter and inspecting sys.modules, which
catches a lazy import inside a function body that a grep would miss.
conftest.py gains a session-scoped autouse fixture installing an SDK
provider with an InMemorySpanExporter. It has to be session-scoped
because set_tracer_provider() is effectively once-per-process - a
second call logs a warning and is ignored. SimpleSpanProcessor rather
than BatchSpanProcessor, so assertions made right after a request never
race a background export thread. The otel_spans fixture that later
tickets assert against is added here too.
test_datasette_package_never_imports_the_sdk is moved to the front of
the run. Late in a serial run the pytest process holds enough threads
that the fork half of subprocess' fork+exec segfaults the interpreter
on macOS/CPython 3.13. That reproduces with any subprocess call in that
position on an unmodified tree, so it is a pre-existing hazard rather
than something this commit introduces; the repo already moves its other
subprocess-spawning tests to the front for related reasons.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:52 -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")
|
Name the request span after the route it matched
The request span was created at the ASGI edge, before anything knew which
route would match, so it carried nothing but the method: every request in a
trace UI showed up as "GET", and the only URL on it was url.path, which is
unbounded on a public instance and useless as a grouping key. Routing
resolves in DatasetteRouter, so that is where the span gets http.route and
its semconv `{method} {route}` name.
http.route is the compiled route pattern, not a prettified
/{database}/{table} template. Datasette routes with compiled regexes and the
route table is fixed when the app is built, so the pattern is exact, bounded
and needs no parsing; the transform into something prettier accretes edge
cases, and Django's instrumentation ships regex-flavoured routes for the same
reason. A request that matches no route gets no http.route and keeps its bare
method name, which is what semantic conventions ask for.
Two things the obvious implementation gets wrong, both found by testing it:
- The router must not read `get_current_span()`. A plugin asgi_wrapper()
runs *inside* the request middleware, so an instrumented plugin makes its
own span current for the whole request - and the route then lands on that
plugin's INTERNAL span, renaming it, while the actual request span never
gets the one attribute a trace UI groups by. It reproduces with a five-line
plugin. The span is passed through the ASGI scope instead, falling back to
the current span so an externally-created SERVER span is still enriched.
- The method has to be clamped again here. The middleware clamps it for the
attribute, but the name is rebuilt from request.method, which is the raw
client string - so an unclamped rename put `FROB /(?P<database>...` back
into the span name that the middleware had just kept it out of.
Both guards are `is_recording()`, not `get_span_context().is_valid`: with no
provider but an inbound traceparent the API returns a NonRecordingSpan
carrying the remote context, which is valid and records nothing, so an
is_valid guard would do the work on every request from a traced caller.
Tests cover the route and name, the unrouted 404 fallback, the full attribute
set, db.query spans reaching the request span by parent walk, a 500, an
inbound traceparent becoming a remote parent, ?sql= never reaching a span
attribute, and - in a subprocess, because the suite's provider fixture is
session-scoped and unavoidable - the no-provider fast path handing the app
the original `send`. The streaming test uses a table larger than one page so
the export genuinely issues queries during the body send; without that it
passes however early the span ends.
Measured on this branch against fixtures.db: a faceted table page went from
112 spans in 56 traces to 113 spans in 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:52:12 -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(),
|
|
|
|
|
)
|
2022-12-17 17:22:00 -08:00
|
|
|
wait_until_responds("http://localhost:8041/")
|
2021-02-11 16:52:16 -08:00
|
|
|
# Check it started successfully
|
|
|
|
|
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
|
|
|
|
|
yield ds_proc
|
|
|
|
|
# Shut it down at the end of the pytest session
|
|
|
|
|
ds_proc.terminate()
|
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
|
|
|
|
|
transport = httpx.HTTPTransport(uds=uds)
|
|
|
|
|
client = httpx.Client(transport=transport)
|
2026-06-17 09:58:39 -07:00
|
|
|
try:
|
|
|
|
|
wait_until_responds(
|
|
|
|
|
"http://localhost/_memory.json", timeout=30.0, client=client
|
|
|
|
|
)
|
|
|
|
|
# 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
|
|
|
|
|
ds_proc.terminate()
|
|
|
|
|
try:
|
|
|
|
|
ds_proc.wait(timeout=5)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
ds_proc.kill()
|
|
|
|
|
ds_proc.wait()
|
|
|
|
|
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:
|
|
|
|
|
if proc.poll() is None:
|
|
|
|
|
proc.terminate()
|
|
|
|
|
try:
|
|
|
|
|
proc.wait(timeout=5)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
proc.kill()
|
|
|
|
|
proc.wait()
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
make_app_client,
|
|
|
|
|
)
|