From df96e12737454077c707f469506be0ee96091965 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 16 Apr 2026 20:32:19 -0700 Subject: [PATCH] Auto-close Datasette instances from function-scoped fixtures too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin now tracks instances across the full test protocol (setup, call, teardown) and closes all of them at the end — including ones created inside function-scoped pytest fixtures. Session-, module-, class- and package-scoped fixtures are still exempted by subtracting any instances their setup adds from the tracking list. This makes downstream projects like datasette-alerts work at low FD limits without every fixture needing an explicit ds.close() call. Refs #2692 See https://github.com/simonw/datasette/issues/2692#issuecomment-4265072230 Co-Authored-By: Claude Opus 4.7 (1M context) --- datasette/_pytest_plugin.py | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/datasette/_pytest_plugin.py b/datasette/_pytest_plugin.py index 3f6c0d96..5fb6b473 100644 --- a/datasette/_pytest_plugin.py +++ b/datasette/_pytest_plugin.py @@ -1,6 +1,9 @@ """ Pytest plugin that automatically closes any Datasette instances constructed -inside a test body. Fixture-scoped instances survive. +during a pytest test — both in the test body and in function-scoped +fixtures. Instances constructed by session-, module-, class- or package- +scoped fixtures are left alone, because other tests in the session will +still want to use them. 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. @@ -40,7 +43,7 @@ def pytest_addoption(parser): "datasette_autoclose", help=( "Automatically close Datasette instances created inside test " - "bodies (default: true)." + "bodies and function-scoped fixtures (default: true)." ), default="true", ) @@ -54,7 +57,8 @@ def _enabled(config) -> bool: @pytest.hookimpl(hookwrapper=True) -def pytest_runtest_call(item): +def pytest_runtest_protocol(item, nextitem): + """Track Datasette instances across setup, call and teardown; close at end.""" if not _enabled(item.config): yield return @@ -76,3 +80,29 @@ def pytest_runtest_call(item): f"Error closing Datasette instance: {e!r}" ) ) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_fixture_setup(fixturedef, request): + """Exempt instances created by non-function-scoped fixtures. + + Session-, module-, class- and package-scoped fixtures produce Datasette + instances that must survive beyond the current test — other tests in + the session will still use them. When such a fixture creates one or + more Datasette instances during its setup, we snapshot the tracking + list before the fixture runs and subtract off any instances that were + added during its setup, so they don't get closed at test teardown. + """ + refs = _active_instances.get() + if refs is None: + yield + return + before_ids = {id(ref) for ref in refs} + yield + if fixturedef.scope != "function": + new_refs = [ref for ref in refs if id(ref) not in before_ids] + for new_ref in new_refs: + try: + refs.remove(new_ref) + except ValueError: + pass