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.
@@ -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
+ ]