Skip to content

Commit f3e660b

Browse files
committed
Add singleton support with dependency injection
Singletons are expensive resources (e.g. VM pools, resource monitors) shared across test classes. They implement the async context manager protocol and are declared on test classes and other singletons via the singleton descriptor. Dependencies between singletons are resolved automatically. SingletonManager handles lifecycle via TaskGroup, tearing down in reverse creation order. Co-developed-by: Claude <claude@anthropic.com> Signed-off-by: Daan De Meyer <daan@amutable.com>
1 parent 588fe69 commit f3e660b

File tree

6 files changed

+1787
-5
lines changed

6 files changed

+1787
-5
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Barrage is a concurrent async test framework for Python 3.12+ with zero external
2020

2121
- **`runner.py`** — Execution engine. Runs test classes concurrently as asyncio tasks. Uses `contextvars.ContextVar` for per-task stdout/stderr capture so concurrent tests get isolated output.
2222
- **`case.py`**`AsyncTestCase` (base class, all hooks/tests are async) and `MonitoredTestCase` (adds background task crash monitoring with auto-cancel/skip).
23-
- **`singleton.py`** — Singleton lifecycle with dependency injection. `Singleton` base class (async context manager), `singleton[T]` descriptor for declaring dependencies on test classes, `SingletonManager` resolves dependencies from `__init__` type annotations and tears down in reverse creation order.
23+
- **`singleton.py`** — Singleton lifecycle with dependency injection. `Singleton` base class (async context manager), `singleton[T]` descriptor for declaring dependencies on both test classes and singleton classes, `SingletonManager` resolves dependencies recursively and tears down in reverse creation order.
2424
- **`discovery.py`** — Test discovery. Supports directories, files, and `File::Class::method` selectors.
2525
- **`result.py`**`TestOutcome` dataclass and `AsyncTestResult` collector (asyncio.Lock-protected).
2626
- **`subprocess.py`** — Async `spawn()` (context manager for long-lived processes) and `run()` helpers with PTY-aware output relaying through the capture system.

README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,70 @@ Because `spawn()` is an async context manager, `monitor_async_context()`
212212
enters it in a background task and monitors that task. The subprocess is
213213
killed and cleaned up when the test class finishes or when the background
214214
task is cancelled due to a crash.
215+
216+
## Singletons
217+
218+
A *singleton* is a resource that is expensive to create and tear down
219+
(e.g. a pool of virtual machines, a database connection pool) and
220+
should be shared across many test classes.
221+
222+
Singletons are declared as classes that inherit from `Singleton` and
223+
implement the async context manager protocol. They are attached to
224+
test classes using the `singleton` descriptor. The framework creates
225+
each singleton once, injects it before `setUpClass`, and tears
226+
everything down in reverse order when the test session ends.
227+
228+
```python
229+
from typing import Self
230+
231+
from barrage import AsyncTestCase, Singleton, singleton
232+
233+
class VMManager(Singleton):
234+
async def __aenter__(self) -> Self:
235+
self.pool = await create_vm_pool()
236+
return self
237+
238+
async def __aexit__(self, *exc: object) -> None:
239+
await self.pool.shutdown()
240+
241+
class MyTests(AsyncTestCase):
242+
manager = singleton(VMManager)
243+
244+
async def test_something(self) -> None:
245+
vm = await self.manager.acquire()
246+
result = await vm.run("uname -r")
247+
self.assertIn("6.", result)
248+
```
249+
250+
### Default `__aexit__` behaviour
251+
252+
The base `Singleton` class provides a default `__aexit__` that blocks
253+
forever (via an unresolved `asyncio.Future`), keeping the singleton's
254+
background task alive until the framework cancels it during teardown.
255+
Override `__aexit__` when you need custom cleanup logic.
256+
257+
### Dependency injection
258+
259+
Dependencies between singletons are declared using the same
260+
`singleton` descriptor. If singleton A has a `singleton(B)` descriptor,
261+
B is created and injected first. Circular dependencies are detected
262+
and raise `RuntimeError`.
263+
264+
```python
265+
class ResourceMonitor(Singleton):
266+
async def __aenter__(self) -> Self:
267+
...
268+
269+
class VMManager(Singleton):
270+
monitor = singleton(ResourceMonitor)
271+
272+
async def __aenter__(self) -> Self:
273+
...
274+
275+
class MyTests(AsyncTestCase):
276+
monitor = singleton(ResourceMonitor)
277+
manager = singleton(VMManager) # ResourceMonitor is created first
278+
```
279+
280+
Multiple test classes that reference the same singleton class share a
281+
single instance.

barrage/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,22 @@
88
basis via ``__init_subclass__``, and the overall level of concurrency
99
is tuneable via the runner.
1010
11+
Singletons (expensive resources shared across test classes) are
12+
supported via the :class:`singleton` descriptor and
13+
:class:`SingletonManager`::
14+
15+
from barrage import AsyncTestCase, singleton
16+
17+
class MyTests(AsyncTestCase):
18+
manager = singleton(VMManager)
19+
20+
async def test_example(self) -> None:
21+
vm = await self.manager.acquire()
22+
self.assertIsNotNone(vm)
23+
1124
Quick start::
1225
13-
from barrage import AsyncTestCase, AsyncTestRunner
26+
from barrage import AsyncTestRunner, AsyncTestCase
1427
1528
class MyTests(AsyncTestCase):
1629
async def setUp(self) -> None:
@@ -29,6 +42,7 @@ async def test_example(self) -> None:
2942
from barrage.discovery import discover, discover_module, resolve_tests
3043
from barrage.result import AsyncTestResult, Outcome, TestOutcome
3144
from barrage.runner import AsyncTestRunner, AsyncTestSuite
45+
from barrage.singleton import Singleton, SingletonManager, singleton
3246
from barrage.subprocess import (
3347
DEVNULL,
3448
PIPE,
@@ -44,6 +58,10 @@ async def test_example(self) -> None:
4458
# Core
4559
"AsyncTestCase",
4660
"MonitoredTestCase",
61+
# Singletons
62+
"Singleton",
63+
"singleton",
64+
"SingletonManager",
4765
# Runner
4866
"AsyncTestRunner",
4967
"AsyncTestSuite",

barrage/runner.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
style,
2323
)
2424
from barrage.result import AsyncTestResult, Outcome, TestOutcome
25+
from barrage.singleton import SingletonManager, discover_singletons
2526

2627

2728
def _collect_test_methods(cls: type[AsyncTestCase]) -> list[str]:
@@ -1035,6 +1036,9 @@ async def run_suite_async(self, suite: AsyncTestSuite) -> AsyncTestResult:
10351036
result = AsyncTestResult()
10361037
entries = suite.entries
10371038

1039+
# ── singleton injection ───────────────────────────────────────
1040+
has_singletons = any(discover_singletons(cls) for cls, _methods in entries)
1041+
10381042
# Create a stop event when failfast is enabled. It is shared
10391043
# across all test tasks so that a failure anywhere signals the
10401044
# rest to stop.
@@ -1117,10 +1121,16 @@ async def run_suite_async(self, suite: AsyncTestSuite) -> AsyncTestResult:
11171121

11181122
result.start_time = time.monotonic()
11191123

1120-
try:
1121-
if progress is not None:
1122-
await progress.start()
1124+
async def run_tests() -> None:
1125+
"""Run all test classes, with optional singleton injection."""
1126+
if has_singletons:
1127+
async with SingletonManager() as sm:
1128+
await sm.inject(entries)
1129+
await _run_all_classes()
1130+
else:
1131+
await _run_all_classes()
11231132

1133+
async def _run_all_classes() -> None:
11241134
if self.interactive:
11251135
# In interactive mode run classes sequentially so that
11261136
# their output is not interleaved.
@@ -1159,6 +1169,11 @@ async def run_suite_async(self, suite: AsyncTestSuite) -> AsyncTestResult:
11591169
for cls, methods in entries
11601170
]
11611171
await asyncio.gather(*class_tasks)
1172+
1173+
try:
1174+
if progress is not None:
1175+
await progress.start()
1176+
await run_tests()
11621177
finally:
11631178
if progress is not None:
11641179
await progress.stop()
@@ -1170,6 +1185,7 @@ async def run_suite_async(self, suite: AsyncTestSuite) -> AsyncTestResult:
11701185
devnull.close()
11711186

11721187
result.end_time = time.monotonic()
1188+
11731189
return result
11741190

11751191
def run_classes(self, *classes: type[AsyncTestCase]) -> AsyncTestResult:

0 commit comments

Comments
 (0)