dirigent-testing 0.17.3__tar.gz → 0.18.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-testing
3
- Version: 0.17.3
3
+ Version: 0.18.1
4
4
  Summary: Test doubles and pytest fixtures for writing and testing dirigent blocks.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: Programming Language :: Python :: 3.13
9
- Requires-Dist: dirigent-common==0.17.3
10
- Requires-Dist: dirigent-plugin==0.17.3
9
+ Requires-Dist: dirigent-common==0.18.1
10
+ Requires-Dist: dirigent-plugin==0.18.1
11
11
  Requires-Dist: httpx2>=2.12.0
12
12
  Requires-Dist: pydantic>=2.13.5
13
13
  Requires-Dist: pytest>=9.1.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-testing"
3
- version = "0.17.3"
3
+ version = "0.18.1"
4
4
  description = "Test doubles and pytest fixtures for writing and testing dirigent blocks."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,8 +11,8 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-common==0.17.3",
15
- "dirigent-plugin==0.17.3",
14
+ "dirigent-common==0.18.1",
15
+ "dirigent-plugin==0.18.1",
16
16
  "httpx2>=2.12.0",
17
17
  "pydantic>=2.13.5",
18
18
  "pytest>=9.1.1",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-testing"
3
- version = "0.17.3"
3
+ version = "0.18.1"
4
4
  description = "Test doubles and pytest fixtures for writing and testing dirigent blocks."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,8 +11,8 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-common==0.17.3",
15
- "dirigent-plugin==0.17.3",
14
+ "dirigent-common==0.18.1",
15
+ "dirigent-plugin==0.18.1",
16
16
  "httpx2>=2.12.0",
17
17
  "pydantic>=2.13.5",
18
18
  "pytest>=9.1.1",
@@ -3,6 +3,7 @@
3
3
  from dirigent_testing.conformance import assert_contribution_conforms, check_pack_examples
4
4
  from dirigent_testing.doubles import FakeCapture, FakeContext, FakeRuns, FakeSink, FakeStorage, RecordingLogger
5
5
  from dirigent_testing.environment import CONFIGURING_PREFIXES, TEST_WIDTH, pin_terminal, scrub_configuration
6
+ from dirigent_testing.fixtures import no_connection_outlives_its_loop
6
7
  from dirigent_testing.running import call_block
7
8
 
8
9
  __all__ = [
@@ -17,6 +18,7 @@ __all__ = [
17
18
  "assert_contribution_conforms",
18
19
  "call_block",
19
20
  "check_pack_examples",
21
+ "no_connection_outlives_its_loop",
20
22
  "pin_terminal",
21
23
  "scrub_configuration",
22
24
  ]
@@ -0,0 +1,119 @@
1
+ """The pytest plugin: the fixtures a block author gets from installing this package.
2
+
3
+ Registered by entry point, so the names live in every consumer's suite. They are prefixed
4
+ to say what they are handed by, because a consumer's own fixtures got there first.
5
+ """
6
+
7
+ from collections.abc import Iterator
8
+ from pathlib import Path
9
+ from typing import Any, Final
10
+
11
+ import pytest
12
+
13
+ from dirigent_testing.doubles import FakeContext, FakeStorage
14
+ from dirigent_testing.environment import scrub_configuration
15
+
16
+ #: The server the lanes that need a real PostgreSQL start for themselves.
17
+ POSTGRES_IMAGE: Final = "postgres:17-alpine"
18
+
19
+ #: Every database connection a pool has opened and not closed again, by identity.
20
+ _open_connections: set[int] = set()
21
+
22
+ #: Whether the listeners that fill the set above have been registered yet.
23
+ _watching_pools = False
24
+
25
+
26
+ def _watch_pools() -> None:
27
+ """Register the pool listeners the connection guard counts with, once per process.
28
+
29
+ SQLAlchemy is not a dependency of this package, so it is imported on first use: a pack
30
+ without it installs the plugin, and has no pool to watch.
31
+ """
32
+ global _watching_pools
33
+ if _watching_pools:
34
+ return
35
+ _watching_pools = True
36
+ try:
37
+ from sqlalchemy import event
38
+ from sqlalchemy.pool import Pool
39
+ except ModuleNotFoundError: # pragma: no cover - a suite without SQLAlchemy opens no pooled connection
40
+ return
41
+
42
+ @event.listens_for(Pool, "connect")
43
+ def _connection_opened(dbapi_connection: Any, _record: Any) -> None:
44
+ """Record a connection a pool has just opened."""
45
+ _open_connections.add(id(dbapi_connection))
46
+
47
+ @event.listens_for(Pool, "close")
48
+ def _connection_closed(dbapi_connection: Any, _record: Any) -> None:
49
+ """Forget a connection a pool has closed."""
50
+ _open_connections.discard(id(dbapi_connection))
51
+
52
+ @event.listens_for(Pool, "close_detached")
53
+ def _detached_connection_closed(dbapi_connection: Any) -> None:
54
+ """Forget a connection closed after it was detached from its pool."""
55
+ _open_connections.discard(id(dbapi_connection))
56
+
57
+
58
+ @pytest.fixture(scope="session")
59
+ def postgres_url() -> Iterator[str]:
60
+ """Start a real PostgreSQL for the session, and hand back an asyncpg URL.
61
+
62
+ Session-scoped, so every lane that asks for it shares one container.
63
+ """
64
+ postgres = pytest.importorskip("testcontainers.community.postgres")
65
+ with postgres.PostgresContainer(POSTGRES_IMAGE, driver="asyncpg") as container:
66
+ yield container.get_connection_url()
67
+
68
+
69
+ @pytest.fixture
70
+ def block_storage(tmp_path: Path) -> FakeStorage:
71
+ """A storage facade over a throwaway directory."""
72
+ return FakeStorage(tmp_path)
73
+
74
+
75
+ @pytest.fixture
76
+ def block_ctx(block_storage: FakeStorage, tmp_path: Path) -> FakeContext:
77
+ """A context wired to that storage, with a scratch prefix inside it."""
78
+ return FakeContext(block_storage, "file://scratch", work=tmp_path / "work" / "runs" / "one")
79
+
80
+
81
+ @pytest.fixture
82
+ def local_block_ctx(tmp_path: Path) -> FakeContext:
83
+ """A context whose scratch is a real file:// prefix, for a block that reads it as files."""
84
+ root = tmp_path / "artifacts"
85
+ root.mkdir()
86
+ return FakeContext(FakeStorage(root), f"file://{root}/runs/one", work=tmp_path / "work" / "runs" / "one")
87
+
88
+
89
+ @pytest.fixture
90
+ def defaults_only_environment(monkeypatch: pytest.MonkeyPatch) -> None:
91
+ """Run a test against defaults, whatever the shell exports.
92
+
93
+ Requested rather than autouse: a distributed plugin must not strip the environment of a
94
+ suite that never asked it to, so a consumer opts in -- once, from its own conftest.
95
+ """
96
+ scrub_configuration(monkeypatch)
97
+
98
+
99
+ @pytest.fixture
100
+ def no_connection_outlives_its_loop() -> Iterator[None]:
101
+ """Fail a test that ends with a database connection it opened still open.
102
+
103
+ aiosqlite drives every connection from a worker thread that answers the event loop the
104
+ connection was opened in. One still open when that loop closes is torn down later, by the
105
+ garbage collector, and the thread then calls into a loop that is gone: a RuntimeError
106
+ raised in a thread, which pytest reports against whichever test happens to be running at
107
+ that moment rather than against the one that left the connection behind.
108
+
109
+ Requested rather than autouse: a distributed plugin must not fail a run that never asked
110
+ it to, so a consumer opts in -- once, from its own conftest.
111
+ """
112
+ _watch_pools()
113
+ held = set(_open_connections)
114
+ yield
115
+ leaked = _open_connections - held
116
+ assert not leaked, (
117
+ f"{len(leaked)} database connection(s) this test opened were never closed: "
118
+ "dispose every engine and close every session before the test ends"
119
+ )
@@ -1,58 +0,0 @@
1
- """The pytest plugin: the fixtures a block author gets from installing this package.
2
-
3
- Registered by entry point, so the names live in every consumer's suite. They are prefixed
4
- to say what they are handed by, because a consumer's own fixtures got there first.
5
- """
6
-
7
- from collections.abc import Iterator
8
- from pathlib import Path
9
- from typing import Final
10
-
11
- import pytest
12
-
13
- from dirigent_testing.doubles import FakeContext, FakeStorage
14
- from dirigent_testing.environment import scrub_configuration
15
-
16
- #: The server the lanes that need a real PostgreSQL start for themselves.
17
- POSTGRES_IMAGE: Final = "postgres:17-alpine"
18
-
19
-
20
- @pytest.fixture(scope="session")
21
- def postgres_url() -> Iterator[str]:
22
- """Start a real PostgreSQL for the session, and hand back an asyncpg URL.
23
-
24
- Session-scoped, so every lane that asks for it shares one container.
25
- """
26
- postgres = pytest.importorskip("testcontainers.community.postgres")
27
- with postgres.PostgresContainer(POSTGRES_IMAGE, driver="asyncpg") as container:
28
- yield container.get_connection_url()
29
-
30
-
31
- @pytest.fixture
32
- def block_storage(tmp_path: Path) -> FakeStorage:
33
- """A storage facade over a throwaway directory."""
34
- return FakeStorage(tmp_path)
35
-
36
-
37
- @pytest.fixture
38
- def block_ctx(block_storage: FakeStorage, tmp_path: Path) -> FakeContext:
39
- """A context wired to that storage, with a scratch prefix inside it."""
40
- return FakeContext(block_storage, "file://scratch", work=tmp_path / "work" / "runs" / "one")
41
-
42
-
43
- @pytest.fixture
44
- def local_block_ctx(tmp_path: Path) -> FakeContext:
45
- """A context whose scratch is a real file:// prefix, for a block that reads it as files."""
46
- root = tmp_path / "artifacts"
47
- root.mkdir()
48
- return FakeContext(FakeStorage(root), f"file://{root}/runs/one", work=tmp_path / "work" / "runs" / "one")
49
-
50
-
51
- @pytest.fixture
52
- def defaults_only_environment(monkeypatch: pytest.MonkeyPatch) -> None:
53
- """Run a test against defaults, whatever the shell exports.
54
-
55
- Requested rather than autouse: a distributed plugin must not strip the environment of a
56
- suite that never asked it to, so a consumer opts in -- once, from its own conftest.
57
- """
58
- scrub_configuration(monkeypatch)