tenzir-test 0.12.0__py3-none-any.whl
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.
- tenzir_test/__init__.py +56 -0
- tenzir_test/_python_runner.py +93 -0
- tenzir_test/checks.py +31 -0
- tenzir_test/cli.py +216 -0
- tenzir_test/config.py +57 -0
- tenzir_test/engine/__init__.py +5 -0
- tenzir_test/engine/operations.py +36 -0
- tenzir_test/engine/registry.py +30 -0
- tenzir_test/engine/state.py +43 -0
- tenzir_test/engine/worker.py +8 -0
- tenzir_test/fixtures/__init__.py +614 -0
- tenzir_test/fixtures/node.py +257 -0
- tenzir_test/packages.py +33 -0
- tenzir_test/py.typed +0 -0
- tenzir_test/run.py +3678 -0
- tenzir_test/runners/__init__.py +164 -0
- tenzir_test/runners/_utils.py +17 -0
- tenzir_test/runners/custom_python_fixture_runner.py +171 -0
- tenzir_test/runners/diff_runner.py +139 -0
- tenzir_test/runners/ext_runner.py +28 -0
- tenzir_test/runners/runner.py +38 -0
- tenzir_test/runners/shell_runner.py +158 -0
- tenzir_test/runners/tenzir_runner.py +37 -0
- tenzir_test/runners/tql_runner.py +13 -0
- tenzir_test-0.12.0.dist-info/METADATA +81 -0
- tenzir_test-0.12.0.dist-info/RECORD +29 -0
- tenzir_test-0.12.0.dist-info/WHEEL +4 -0
- tenzir_test-0.12.0.dist-info/entry_points.txt +3 -0
- tenzir_test-0.12.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import ExitStack, AbstractContextManager, contextmanager
|
|
12
|
+
from contextvars import ContextVar, Token
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from functools import wraps
|
|
16
|
+
from typing import (
|
|
17
|
+
Any,
|
|
18
|
+
Callable,
|
|
19
|
+
ContextManager,
|
|
20
|
+
Iterable,
|
|
21
|
+
Iterator,
|
|
22
|
+
Mapping,
|
|
23
|
+
Protocol,
|
|
24
|
+
Sequence,
|
|
25
|
+
Literal,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_FIXTURES_ENV = "TENZIR_TEST_FIXTURES"
|
|
30
|
+
_HOOKS_ATTR = "__tenzir_fixture_hooks__"
|
|
31
|
+
|
|
32
|
+
_TMP_DIR_CLEANUP: dict[Path, Callable[[], None]] = {}
|
|
33
|
+
_TMP_DIR_CLEANUP_LOCK = threading.RLock()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class FixtureContext:
|
|
38
|
+
"""Describe the invocation context available to fixture factories."""
|
|
39
|
+
|
|
40
|
+
test: Path
|
|
41
|
+
config: dict[str, Any]
|
|
42
|
+
coverage: bool
|
|
43
|
+
env: dict[str, str]
|
|
44
|
+
config_args: Sequence[str]
|
|
45
|
+
tenzir_binary: str | None
|
|
46
|
+
tenzir_node_binary: str | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_CONTEXT: ContextVar[FixtureContext | None] = ContextVar(
|
|
50
|
+
"tenzir_test_fixture_context", default=None
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Executor:
|
|
57
|
+
def __init__(self, env: Mapping[str, str] | None = None) -> None:
|
|
58
|
+
source = env or os.environ
|
|
59
|
+
try:
|
|
60
|
+
self.binary: str = source["TENZIR_NODE_CLIENT_BINARY"]
|
|
61
|
+
except KeyError as exc: # pragma: no cover - defensive guard
|
|
62
|
+
raise RuntimeError("TENZIR_NODE_CLIENT_BINARY is not configured") from exc
|
|
63
|
+
self.endpoint: str | None = source.get("TENZIR_NODE_CLIENT_ENDPOINT")
|
|
64
|
+
timeout_raw = source.get("TENZIR_NODE_CLIENT_TIMEOUT")
|
|
65
|
+
self.remaining_timeout: float = float(timeout_raw) if timeout_raw is not None else 0.0
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_env(cls, env: Mapping[str, str]) -> "Executor":
|
|
69
|
+
return cls(env=env)
|
|
70
|
+
|
|
71
|
+
def run(
|
|
72
|
+
self, source: str, desired_timeout: float | None = None, mirror: bool = False
|
|
73
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
74
|
+
cmd = [
|
|
75
|
+
self.binary,
|
|
76
|
+
"--bare-mode",
|
|
77
|
+
"--console-verbosity=warning",
|
|
78
|
+
"--multi",
|
|
79
|
+
]
|
|
80
|
+
if self.endpoint is not None:
|
|
81
|
+
cmd.append(f"--endpoint={self.endpoint}")
|
|
82
|
+
cmd.append(source)
|
|
83
|
+
start = time.process_time()
|
|
84
|
+
requested_timeout = (
|
|
85
|
+
desired_timeout if desired_timeout is not None else self.remaining_timeout
|
|
86
|
+
)
|
|
87
|
+
timeout = min(self.remaining_timeout, requested_timeout)
|
|
88
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
89
|
+
command_line = shlex.join(str(part) for part in cmd)
|
|
90
|
+
logger.debug("executing fixture command: %s", command_line)
|
|
91
|
+
res = subprocess.run(cmd, timeout=timeout, capture_output=True)
|
|
92
|
+
end = time.process_time()
|
|
93
|
+
used_time = end - start
|
|
94
|
+
self.remaining_timeout = max(0, self.remaining_timeout - used_time)
|
|
95
|
+
if mirror:
|
|
96
|
+
if res.stdout:
|
|
97
|
+
print(res.stdout.decode())
|
|
98
|
+
if res.stderr:
|
|
99
|
+
print(res.stderr.decode(), file=sys.stderr)
|
|
100
|
+
return res
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _parse_fixture_env(raw: str | None) -> frozenset[str]:
|
|
104
|
+
if not raw:
|
|
105
|
+
return frozenset()
|
|
106
|
+
parts = [part.strip() for part in raw.split(",")]
|
|
107
|
+
return frozenset(part for part in parts if part)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class FixtureSelection:
|
|
112
|
+
names: frozenset[str]
|
|
113
|
+
|
|
114
|
+
def __iter__(self) -> Iterator[str]:
|
|
115
|
+
return iter(self.names)
|
|
116
|
+
|
|
117
|
+
def __bool__(self) -> bool:
|
|
118
|
+
return bool(self.names)
|
|
119
|
+
|
|
120
|
+
def __contains__(self, item: str) -> bool:
|
|
121
|
+
return item in self.names
|
|
122
|
+
|
|
123
|
+
def has(self, name: str) -> bool:
|
|
124
|
+
return name in self.names
|
|
125
|
+
|
|
126
|
+
def __getattr__(self, item: str) -> bool:
|
|
127
|
+
if item in self.names:
|
|
128
|
+
return True
|
|
129
|
+
raise AttributeError(
|
|
130
|
+
f"fixture '{item}' was not requested; available fixtures: "
|
|
131
|
+
f"{', '.join(sorted(self.names)) or '<none>'}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def __dir__(self) -> list[str]:
|
|
135
|
+
base = set(super().__dir__())
|
|
136
|
+
base.update(self.names)
|
|
137
|
+
return sorted(base)
|
|
138
|
+
|
|
139
|
+
def require(self, *names: str) -> None:
|
|
140
|
+
missing = [name for name in names if name not in self.names]
|
|
141
|
+
if missing:
|
|
142
|
+
missing_list = ", ".join(sorted(missing))
|
|
143
|
+
available = ", ".join(sorted(self.names)) or "<none>"
|
|
144
|
+
raise RuntimeError(
|
|
145
|
+
f"Missing required fixture(s): {missing_list} (available: {available})"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def any_of(self, names: Iterable[str]) -> bool:
|
|
149
|
+
for name in names:
|
|
150
|
+
if name in self.names:
|
|
151
|
+
return True
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
def as_tuple(self) -> tuple[str, ...]:
|
|
155
|
+
return tuple(sorted(self.names))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def fixtures() -> FixtureSelection:
|
|
159
|
+
"""Return the current fixture selection."""
|
|
160
|
+
|
|
161
|
+
return FixtureSelection(_parse_fixture_env(os.environ.get(_FIXTURES_ENV)))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def has(name: str) -> bool:
|
|
165
|
+
"""Check whether the given fixture was requested."""
|
|
166
|
+
|
|
167
|
+
return name in fixtures()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def require(*names: str) -> None:
|
|
171
|
+
"""Assert that all requested fixtures are present, raising RuntimeError otherwise."""
|
|
172
|
+
|
|
173
|
+
fixtures().require(*names)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class FixturesAccessor:
|
|
177
|
+
def __call__(self) -> FixtureSelection:
|
|
178
|
+
return fixtures()
|
|
179
|
+
|
|
180
|
+
def __getattr__(self, item: str) -> Any:
|
|
181
|
+
module = sys.modules[__name__]
|
|
182
|
+
return getattr(module, item)
|
|
183
|
+
|
|
184
|
+
def __dir__(self) -> list[str]:
|
|
185
|
+
module = sys.modules[__name__]
|
|
186
|
+
base = set(dir(module))
|
|
187
|
+
try:
|
|
188
|
+
base.update(fixtures().names)
|
|
189
|
+
except Exception: # pragma: no cover - defensive
|
|
190
|
+
pass
|
|
191
|
+
return sorted(base)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
fixtures_api = FixturesAccessor()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class FixtureController:
|
|
198
|
+
"""Imperative controller for manually driving a fixture lifecycle."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, name: str, factory: FixtureFactory) -> None:
|
|
201
|
+
self._name = name
|
|
202
|
+
self._factory = factory
|
|
203
|
+
self._force_teardown_log = bool(getattr(factory, "tenzir_log_teardown", False))
|
|
204
|
+
self._state: tuple[ContextManager[dict[str, str] | None], bool] | None = None
|
|
205
|
+
self.env: dict[str, str] = {}
|
|
206
|
+
self._hooks: dict[str, Callable[..., Any]] = {}
|
|
207
|
+
|
|
208
|
+
def __enter__(self) -> "FixtureController":
|
|
209
|
+
self.start()
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
def __exit__(
|
|
213
|
+
self,
|
|
214
|
+
exc_type: type[BaseException] | None,
|
|
215
|
+
exc: BaseException | None,
|
|
216
|
+
tb: object | None,
|
|
217
|
+
) -> Literal[False]:
|
|
218
|
+
self.stop()
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def is_running(self) -> bool:
|
|
223
|
+
return self._state is not None
|
|
224
|
+
|
|
225
|
+
def start(self) -> dict[str, str]:
|
|
226
|
+
if self._state is not None:
|
|
227
|
+
raise RuntimeError(f"fixture '{self._name}' is already running")
|
|
228
|
+
|
|
229
|
+
context = self._factory()
|
|
230
|
+
logger.info("activating fixture '%s'", self._name)
|
|
231
|
+
should_log_teardown = self._force_teardown_log
|
|
232
|
+
|
|
233
|
+
env = context.__enter__()
|
|
234
|
+
env_dict = env or {}
|
|
235
|
+
if env_dict:
|
|
236
|
+
keys = tuple(sorted(env_dict.keys()))
|
|
237
|
+
logger.info("fixture '%s' provided context keys:", self._name)
|
|
238
|
+
for key in keys:
|
|
239
|
+
logger.info(" - %s", key)
|
|
240
|
+
should_log_teardown = True
|
|
241
|
+
|
|
242
|
+
hooks = getattr(context, _HOOKS_ATTR, {}) or {}
|
|
243
|
+
self._hooks = {
|
|
244
|
+
name: self._wrap_hook(name, hook) for name, hook in hooks.items() if callable(hook)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
self.env = env_dict
|
|
248
|
+
self._state = (context, should_log_teardown)
|
|
249
|
+
return self.env
|
|
250
|
+
|
|
251
|
+
def stop(self) -> None:
|
|
252
|
+
if self._state is None:
|
|
253
|
+
return
|
|
254
|
+
context, should_log_teardown = self._state
|
|
255
|
+
self._state = None
|
|
256
|
+
try:
|
|
257
|
+
context.__exit__(None, None, None)
|
|
258
|
+
finally:
|
|
259
|
+
if should_log_teardown:
|
|
260
|
+
logger.info("tearing down fixture '%s'", self._name)
|
|
261
|
+
self.env = {}
|
|
262
|
+
self._hooks.clear()
|
|
263
|
+
|
|
264
|
+
def restart(self) -> dict[str, str]:
|
|
265
|
+
self.stop()
|
|
266
|
+
return self.start()
|
|
267
|
+
|
|
268
|
+
def _wrap_hook(self, name: str, hook: Callable[..., Any]) -> Callable[..., Any]:
|
|
269
|
+
@wraps(hook)
|
|
270
|
+
def _inner(*args: Any, **kwargs: Any) -> Any:
|
|
271
|
+
if self._state is None:
|
|
272
|
+
raise RuntimeError(
|
|
273
|
+
f"cannot call '{name}' on fixture '{self._name}' because it is not running"
|
|
274
|
+
)
|
|
275
|
+
return hook(*args, **kwargs)
|
|
276
|
+
|
|
277
|
+
return _inner
|
|
278
|
+
|
|
279
|
+
def __getattr__(self, item: str) -> Any:
|
|
280
|
+
hooks = self._hooks
|
|
281
|
+
if item in hooks:
|
|
282
|
+
return hooks[item]
|
|
283
|
+
raise AttributeError(f"fixture controller has no attribute '{item}'")
|
|
284
|
+
|
|
285
|
+
def __dir__(self) -> list[str]:
|
|
286
|
+
base = set(super().__dir__())
|
|
287
|
+
base.update(self._hooks.keys())
|
|
288
|
+
return sorted(base)
|
|
289
|
+
|
|
290
|
+
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
291
|
+
status = "running" if self.is_running else "stopped"
|
|
292
|
+
return f"FixtureController(name={self._name!r}, status={status})"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def acquire_fixture(name: str) -> FixtureController:
|
|
296
|
+
"""Return a controller for manually driving the named fixture."""
|
|
297
|
+
|
|
298
|
+
factory = _FACTORIES.get(name)
|
|
299
|
+
if factory is None:
|
|
300
|
+
available = ", ".join(sorted(_FACTORIES.keys())) or "<none>"
|
|
301
|
+
raise ValueError(f"fixture '{name}' is not registered (available: {available})")
|
|
302
|
+
return FixtureController(name, factory)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def push_context(context: FixtureContext) -> Token:
|
|
306
|
+
"""Install the given context for the duration of fixture activation."""
|
|
307
|
+
|
|
308
|
+
return _CONTEXT.set(context)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def pop_context(token: Token) -> None:
|
|
312
|
+
"""Restore the previous fixture context."""
|
|
313
|
+
|
|
314
|
+
_CONTEXT.reset(token)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def register_tmp_dir_cleanup(path: str | os.PathLike[str], callback: Callable[[], None]) -> None:
|
|
318
|
+
"""Register a cleanup callback for a temporary directory."""
|
|
319
|
+
|
|
320
|
+
normalized = Path(path).resolve()
|
|
321
|
+
with _TMP_DIR_CLEANUP_LOCK:
|
|
322
|
+
_TMP_DIR_CLEANUP[normalized] = callback
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def unregister_tmp_dir_cleanup(path: str | os.PathLike[str]) -> None:
|
|
326
|
+
"""Remove a previously registered cleanup callback, if any."""
|
|
327
|
+
|
|
328
|
+
normalized = Path(path).resolve()
|
|
329
|
+
with _TMP_DIR_CLEANUP_LOCK:
|
|
330
|
+
_TMP_DIR_CLEANUP.pop(normalized, None)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def invoke_tmp_dir_cleanup(path: str | os.PathLike[str]) -> None:
|
|
334
|
+
"""Execute and discard the cleanup callback registered for `path`, if present."""
|
|
335
|
+
|
|
336
|
+
normalized = Path(path).resolve()
|
|
337
|
+
with _TMP_DIR_CLEANUP_LOCK:
|
|
338
|
+
targets = [
|
|
339
|
+
_TMP_DIR_CLEANUP.pop(candidate)
|
|
340
|
+
for candidate in tuple(_TMP_DIR_CLEANUP)
|
|
341
|
+
if candidate == normalized or candidate.is_relative_to(normalized)
|
|
342
|
+
]
|
|
343
|
+
for callback in targets:
|
|
344
|
+
callback()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def current_context() -> FixtureContext | None:
|
|
348
|
+
"""Return the active fixture context, if any."""
|
|
349
|
+
|
|
350
|
+
return _CONTEXT.get()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
FixtureFactory = Callable[[], ContextManager[dict[str, str] | None]]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass(slots=True)
|
|
357
|
+
class FixtureHandle:
|
|
358
|
+
"""Container describing a fixture environment and optional teardown hook."""
|
|
359
|
+
|
|
360
|
+
env: dict[str, str] | None = None
|
|
361
|
+
teardown: Callable[[], None] | None = None
|
|
362
|
+
hooks: Mapping[str, Callable[..., Any]] | None = None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class _FactoryCallable(Protocol):
|
|
366
|
+
def __call__(
|
|
367
|
+
self,
|
|
368
|
+
) -> (
|
|
369
|
+
ContextManager[dict[str, str] | None]
|
|
370
|
+
| FixtureHandle
|
|
371
|
+
| dict[str, str]
|
|
372
|
+
| tuple[dict[str, str] | None, Callable[[], None] | None]
|
|
373
|
+
| None
|
|
374
|
+
): ...
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
_FACTORIES: dict[str, FixtureFactory] = {}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass(slots=True)
|
|
381
|
+
class _SuiteScope:
|
|
382
|
+
fixtures: tuple[str, ...]
|
|
383
|
+
stack: ExitStack
|
|
384
|
+
env: dict[str, str]
|
|
385
|
+
depth: int = 0
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
_SUITE_SCOPE: ContextVar[_SuiteScope | None] = ContextVar(
|
|
389
|
+
"tenzir_test_fixture_suite_scope", default=None
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _attach_hooks(
|
|
394
|
+
manager: ContextManager[dict[str, str] | None],
|
|
395
|
+
hooks: Mapping[str, Callable[..., Any]] | None = None,
|
|
396
|
+
) -> ContextManager[dict[str, str] | None]:
|
|
397
|
+
if hooks:
|
|
398
|
+
setattr(manager, _HOOKS_ATTR, dict(hooks))
|
|
399
|
+
elif not hasattr(manager, _HOOKS_ATTR):
|
|
400
|
+
setattr(manager, _HOOKS_ATTR, {})
|
|
401
|
+
return manager
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _normalize_factory(factory: _FactoryCallable) -> FixtureFactory:
|
|
405
|
+
def _as_context_manager() -> ContextManager[dict[str, str] | None]:
|
|
406
|
+
result = factory()
|
|
407
|
+
if isinstance(result, AbstractContextManager):
|
|
408
|
+
return _attach_hooks(result)
|
|
409
|
+
if isinstance(result, FixtureHandle):
|
|
410
|
+
env_dict: dict[str, str] = result.env or {}
|
|
411
|
+
hooks = result.hooks
|
|
412
|
+
|
|
413
|
+
@contextmanager
|
|
414
|
+
def _ctx() -> Iterator[dict[str, str] | None]:
|
|
415
|
+
try:
|
|
416
|
+
yield env_dict
|
|
417
|
+
finally:
|
|
418
|
+
if result.teardown:
|
|
419
|
+
result.teardown()
|
|
420
|
+
|
|
421
|
+
return _attach_hooks(_ctx(), hooks)
|
|
422
|
+
if isinstance(result, tuple) and len(result) == 2:
|
|
423
|
+
raw_env, teardown = result
|
|
424
|
+
env_dict = raw_env or {}
|
|
425
|
+
|
|
426
|
+
@contextmanager
|
|
427
|
+
def _ctx() -> Iterator[dict[str, str] | None]:
|
|
428
|
+
try:
|
|
429
|
+
yield env_dict
|
|
430
|
+
finally:
|
|
431
|
+
if callable(teardown):
|
|
432
|
+
teardown()
|
|
433
|
+
|
|
434
|
+
return _attach_hooks(_ctx())
|
|
435
|
+
if result is None:
|
|
436
|
+
|
|
437
|
+
@contextmanager
|
|
438
|
+
def _ctx_none() -> Iterator[dict[str, str] | None]:
|
|
439
|
+
yield {}
|
|
440
|
+
|
|
441
|
+
return _attach_hooks(_ctx_none())
|
|
442
|
+
if isinstance(result, dict):
|
|
443
|
+
|
|
444
|
+
@contextmanager
|
|
445
|
+
def _ctx_dict() -> Iterator[dict[str, str] | None]:
|
|
446
|
+
yield result
|
|
447
|
+
|
|
448
|
+
return _attach_hooks(_ctx_dict())
|
|
449
|
+
raise TypeError(
|
|
450
|
+
"fixture factory must return a context manager, FixtureHandle, dict,"
|
|
451
|
+
" tuple[env, teardown], or None"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
return _as_context_manager
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _wrap_factory(
|
|
458
|
+
factory: FixtureFactory,
|
|
459
|
+
*,
|
|
460
|
+
name: str,
|
|
461
|
+
force_teardown_log: bool,
|
|
462
|
+
) -> ContextManager[dict[str, str] | None]:
|
|
463
|
+
@contextmanager
|
|
464
|
+
def _logged_context() -> Iterator[dict[str, str] | None]:
|
|
465
|
+
if force_teardown_log:
|
|
466
|
+
setattr(factory, "tenzir_log_teardown", True)
|
|
467
|
+
controller = FixtureController(name, factory)
|
|
468
|
+
try:
|
|
469
|
+
yield controller.start()
|
|
470
|
+
finally:
|
|
471
|
+
controller.stop()
|
|
472
|
+
|
|
473
|
+
return _logged_context()
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _activate_into_stack(
|
|
477
|
+
names: tuple[str, ...],
|
|
478
|
+
stack: ExitStack,
|
|
479
|
+
) -> dict[str, str]:
|
|
480
|
+
combined: dict[str, str] = {}
|
|
481
|
+
for name in names:
|
|
482
|
+
factory = _FACTORIES.get(name)
|
|
483
|
+
if not factory:
|
|
484
|
+
logger.debug("requested fixture '%s' has no registered factory", name)
|
|
485
|
+
continue
|
|
486
|
+
force_teardown_log = bool(getattr(factory, "tenzir_log_teardown", False))
|
|
487
|
+
env: dict[str, str] | None = stack.enter_context(
|
|
488
|
+
_wrap_factory(factory, name=name, force_teardown_log=force_teardown_log)
|
|
489
|
+
)
|
|
490
|
+
if env:
|
|
491
|
+
combined.update(env)
|
|
492
|
+
return combined
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _infer_name(func: Callable[..., object], explicit: str | None) -> str:
|
|
496
|
+
if explicit:
|
|
497
|
+
return explicit
|
|
498
|
+
code_obj = getattr(func, "__code__", None)
|
|
499
|
+
if code_obj is not None and hasattr(code_obj, "co_filename"):
|
|
500
|
+
file = Path(code_obj.co_filename)
|
|
501
|
+
return file.stem
|
|
502
|
+
name_attr = getattr(func, "__name__", None)
|
|
503
|
+
if isinstance(name_attr, str):
|
|
504
|
+
return name_attr
|
|
505
|
+
raise ValueError("Unable to infer fixture name; please provide one explicitly")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def register(name: str | None, factory: _FactoryCallable, *, replace: bool = False) -> None:
|
|
509
|
+
resolved_name = _infer_name(factory, name)
|
|
510
|
+
if resolved_name in _FACTORIES and not replace:
|
|
511
|
+
raise ValueError(f"fixture '{resolved_name}' already registered")
|
|
512
|
+
_FACTORIES[resolved_name] = _normalize_factory(factory)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def fixture(
|
|
516
|
+
func: _FactoryCallable | None = None,
|
|
517
|
+
*,
|
|
518
|
+
name: str | None = None,
|
|
519
|
+
replace: bool = False,
|
|
520
|
+
log_teardown: bool = False,
|
|
521
|
+
) -> Callable[[_FactoryCallable], _FactoryCallable] | _FactoryCallable:
|
|
522
|
+
"""Decorator registering a fixture factory.
|
|
523
|
+
|
|
524
|
+
``@fixture`` accepts generator functions, context managers, or callables that
|
|
525
|
+
return any of the supported fixture factory types. When used on a generator
|
|
526
|
+
function, the decorator implicitly wraps it with :func:`contextlib.contextmanager`
|
|
527
|
+
so authors can ``yield`` environments directly.
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
def _decorator(inner: _FactoryCallable) -> _FactoryCallable:
|
|
531
|
+
resolved_name = _infer_name(inner, name)
|
|
532
|
+
candidate: _FactoryCallable
|
|
533
|
+
if inspect.isgeneratorfunction(inner):
|
|
534
|
+
candidate = contextmanager(inner)
|
|
535
|
+
else:
|
|
536
|
+
candidate = inner
|
|
537
|
+
|
|
538
|
+
register(resolved_name, candidate, replace=replace)
|
|
539
|
+
|
|
540
|
+
if log_teardown:
|
|
541
|
+
registered = _FACTORIES.get(resolved_name)
|
|
542
|
+
if registered is not None:
|
|
543
|
+
setattr(registered, "tenzir_log_teardown", True)
|
|
544
|
+
|
|
545
|
+
return inner
|
|
546
|
+
|
|
547
|
+
if func is not None:
|
|
548
|
+
return _decorator(func)
|
|
549
|
+
|
|
550
|
+
return _decorator
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@contextmanager
|
|
554
|
+
def activate(names: Iterable[str]) -> Iterator[dict[str, str]]:
|
|
555
|
+
normalized = tuple(names)
|
|
556
|
+
scope = _SUITE_SCOPE.get()
|
|
557
|
+
if scope is not None and scope.fixtures == normalized:
|
|
558
|
+
scope.depth += 1
|
|
559
|
+
try:
|
|
560
|
+
yield scope.env
|
|
561
|
+
finally:
|
|
562
|
+
scope.depth -= 1
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
stack = ExitStack()
|
|
566
|
+
try:
|
|
567
|
+
combined = _activate_into_stack(normalized, stack)
|
|
568
|
+
yield combined
|
|
569
|
+
finally:
|
|
570
|
+
stack.close()
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@contextmanager
|
|
574
|
+
def suite_scope(names: Iterable[str]) -> Iterator[dict[str, str]]:
|
|
575
|
+
normalized = tuple(names)
|
|
576
|
+
existing = _SUITE_SCOPE.get()
|
|
577
|
+
if existing is not None:
|
|
578
|
+
raise RuntimeError("nested fixture suite scopes are not supported")
|
|
579
|
+
|
|
580
|
+
stack = ExitStack()
|
|
581
|
+
combined = _activate_into_stack(normalized, stack)
|
|
582
|
+
scope = _SuiteScope(fixtures=normalized, stack=stack, env=combined)
|
|
583
|
+
token = _SUITE_SCOPE.set(scope)
|
|
584
|
+
try:
|
|
585
|
+
yield combined
|
|
586
|
+
finally:
|
|
587
|
+
_SUITE_SCOPE.reset(token)
|
|
588
|
+
stack.close()
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# Import built-in fixtures so they self-register on package import.
|
|
592
|
+
from . import node # noqa: F401,E402
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
__all__ = [
|
|
596
|
+
"Executor",
|
|
597
|
+
"FixtureContext",
|
|
598
|
+
"FixtureHandle",
|
|
599
|
+
"FixtureSelection",
|
|
600
|
+
"FixturesAccessor",
|
|
601
|
+
"FixtureController",
|
|
602
|
+
"activate",
|
|
603
|
+
"acquire_fixture",
|
|
604
|
+
"fixture",
|
|
605
|
+
"fixtures",
|
|
606
|
+
"fixtures_api",
|
|
607
|
+
"suite_scope",
|
|
608
|
+
"has",
|
|
609
|
+
"register",
|
|
610
|
+
"require",
|
|
611
|
+
"register_tmp_dir_cleanup",
|
|
612
|
+
"unregister_tmp_dir_cleanup",
|
|
613
|
+
"invoke_tmp_dir_cleanup",
|
|
614
|
+
]
|