From 34cc320eabb09d7d62f8a6045b868c746adfe9d2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 16 Apr 2026 20:15:50 -0700 Subject: [PATCH] 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) --- datasette/_pytest_plugin.py | 78 ++++++++++++++++++++++ docs/testing_plugins.rst | 16 +++++ pyproject.toml | 3 + tests/test_pytest_autoclose_plugin.py | 93 +++++++++++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 datasette/_pytest_plugin.py create mode 100644 tests/test_pytest_autoclose_plugin.py diff --git a/datasette/_pytest_plugin.py b/datasette/_pytest_plugin.py new file mode 100644 index 00000000..3f6c0d96 --- /dev/null +++ b/datasette/_pytest_plugin.py @@ -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}" + ) + ) diff --git a/docs/testing_plugins.rst b/docs/testing_plugins.rst index 1b10c132..070ab6cf 100644 --- a/docs/testing_plugins.rst +++ b/docs/testing_plugins.rst @@ -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 diff --git a/pyproject.toml b/pyproject.toml index a0ee050c..4a4ed75e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_pytest_autoclose_plugin.py b/tests/test_pytest_autoclose_plugin.py new file mode 100644 index 00000000..78154ef5 --- /dev/null +++ b/tests/test_pytest_autoclose_plugin.py @@ -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