Pytest auto-close plugin for Datasette instances

Installs a pytest11 entry point so that every Datasette() constructed
inside a pytest_runtest_call phase is auto-closed at the end of the test.
Fixture-scoped instances are untouched. Opt out via the
datasette_autoclose = false ini option.

This gives large test suites a safety net against FD exhaustion and leaked
write threads from the now-default temp-disk internal database without
requiring every existing test to be rewritten.

Refs #2692

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2026-04-16 20:15:50 -07:00
commit 34cc320eab
4 changed files with 190 additions and 0 deletions

View file

@ -0,0 +1,78 @@
"""
Pytest plugin that automatically closes any Datasette instances constructed
inside a test body. Fixture-scoped instances survive.
Registered as a pytest11 entry point in pyproject.toml so that downstream
projects using Datasette get the same FD-safety net for their own tests.
Opt out by setting ``datasette_autoclose = false`` in pytest.ini (or the
equivalent ini file).
"""
from __future__ import annotations
import contextvars
import weakref
import pytest
from datasette.app import Datasette
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
"datasette_active_instances", default=None
)
_original_init = Datasette.__init__
def _tracking_init(self, *args, **kwargs):
_original_init(self, *args, **kwargs)
instances = _active_instances.get()
if instances is not None:
instances.append(weakref.ref(self))
Datasette.__init__ = _tracking_init
def pytest_addoption(parser):
parser.addini(
"datasette_autoclose",
help=(
"Automatically close Datasette instances created inside test "
"bodies (default: true)."
),
default="true",
)
def _enabled(config) -> bool:
value = config.getini("datasette_autoclose")
if isinstance(value, bool):
return value
return str(value).strip().lower() not in ("false", "0", "no", "off")
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
if not _enabled(item.config):
yield
return
refs: list[weakref.ref] = []
token = _active_instances.set(refs)
try:
yield
finally:
_active_instances.reset(token)
for ref in reversed(refs):
ds = ref()
if ds is None:
continue
try:
ds.close()
except Exception as e:
item.warn(
pytest.PytestUnraisableExceptionWarning(
f"Error closing Datasette instance: {e!r}"
)
)

View file

@ -82,6 +82,22 @@ This method registers any :ref:`plugin_hook_startup` or :ref:`plugin_hook_prepar
If you are using ``await datasette.client.get()`` and similar methods then you don't need to worry about this - Datasette automatically calls ``invoke_startup()`` the first time it handles a request.
.. _testing_plugins_autoclose:
Automatic cleanup of Datasette instances
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Installing Datasette also installs a small pytest plugin that automatically calls :ref:`datasette_close` on any ``Datasette()`` instance constructed inside the body of a test function. This helps prevent large test suites from running out of file descriptors or leaking background threads from the hundreds of instances they may build up across a session.
Instances created inside a pytest fixture are **not** closed by this plugin — pytest fixtures often create a single ``Datasette`` that is shared across many tests, and closing it automatically would break those tests. If you need a per-test instance and want to share it between multiple tests, create it inside a fixture rather than at the top level of a test function.
If you need to opt out of this behavior, add the following to your ``pytest.ini`` (or equivalent):
.. code-block:: ini
[pytest]
datasette_autoclose = false
.. _testing_datasette_client:
Using datasette.client in tests

View file

@ -54,6 +54,9 @@ CI = "https://github.com/simonw/datasette/actions?query=workflow%3ATest"
[project.scripts]
datasette = "datasette.cli:cli"
[project.entry-points.pytest11]
datasette = "datasette._pytest_plugin"
[dependency-groups]
dev = [
"pytest>=9",

View file

@ -0,0 +1,93 @@
"""
Tests for datasette._pytest_plugin the pytest plugin that auto-closes
Datasette instances constructed inside test bodies.
These tests drive a real pytest session in a subprocess so the plugin
operates exactly as it would for a downstream consumer.
"""
import subprocess
import sys
import textwrap
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent
def _run_pytest(tmp_path: Path) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-m", "pytest", "-v", str(tmp_path)],
cwd=str(tmp_path),
capture_output=True,
text=True,
)
def test_auto_close_of_instances_made_in_test_body(tmp_path):
# Two ordered tests:
# test_a makes a Datasette() and stashes a hard reference
# test_b asserts that the hard-reffed instance was closed by the plugin
(tmp_path / "test_sample.py").write_text(textwrap.dedent("""
from datasette.app import Datasette
_stash = {}
def test_a():
ds = Datasette(memory=True)
_stash["ds"] = ds
assert ds._closed is False
def test_b():
assert _stash["ds"]._closed is True
"""))
result = _run_pytest(tmp_path)
assert result.returncode == 0, result.stdout + result.stderr
def test_fixture_scoped_instance_is_not_closed(tmp_path):
# A module-scoped fixture instance must survive across tests in the module.
(tmp_path / "test_fixture.py").write_text(textwrap.dedent("""
import pytest
from datasette.app import Datasette
@pytest.fixture(scope="module")
def ds():
return Datasette(memory=True)
def test_first(ds):
assert ds._closed is False
def test_second(ds):
# Still alive because the plugin only tracks instances
# constructed during pytest_runtest_call, not during fixture
# setup.
assert ds._closed is False
"""))
result = _run_pytest(tmp_path)
assert result.returncode == 0, result.stdout + result.stderr
def test_opt_out_via_ini(tmp_path):
# datasette_autoclose = false should leave instances untouched.
(tmp_path / "pytest.ini").write_text(textwrap.dedent("""
[pytest]
datasette_autoclose = false
""").strip())
(tmp_path / "test_optout.py").write_text(textwrap.dedent("""
from datasette.app import Datasette
_stash = {}
def test_a():
ds = Datasette(memory=True)
_stash["ds"] = ds
def test_b():
# Opt-out: plugin must not have closed it.
assert _stash["ds"]._closed is False
_stash["ds"].close()
"""))
result = _run_pytest(tmp_path)
assert result.returncode == 0, result.stdout + result.stderr