mirror of
https://github.com/simonw/datasette.git
synced 2026-05-27 12:34:37 +02:00
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:
parent
d72dd35378
commit
34cc320eab
4 changed files with 190 additions and 0 deletions
78
datasette/_pytest_plugin.py
Normal file
78
datasette/_pytest_plugin.py
Normal 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}"
|
||||
)
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
93
tests/test_pytest_autoclose_plugin.py
Normal file
93
tests/test_pytest_autoclose_plugin.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue