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,257 @@
1
+ """Built-in fixture that manages a transient ``tenzir-node`` instance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import functools
7
+ import logging
8
+ import os
9
+ import shlex
10
+ import subprocess
11
+ import tempfile
12
+ import threading
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+ from dataclasses import dataclass
16
+ from typing import Iterator, TYPE_CHECKING
17
+ import weakref
18
+
19
+ from . import (
20
+ current_context,
21
+ register,
22
+ register_tmp_dir_cleanup,
23
+ unregister_tmp_dir_cleanup,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from . import FixtureContext
28
+
29
+
30
+ def _terminate_process(process: subprocess.Popen[str]) -> None:
31
+ """Terminate the spawned node process and ensure its group is gone."""
32
+
33
+ try:
34
+ pgid = os.getpgid(process.pid)
35
+ except OSError:
36
+ pgid = None
37
+
38
+ try:
39
+ process.terminate()
40
+ process.wait(timeout=20)
41
+ except subprocess.TimeoutExpired:
42
+ process.kill()
43
+ process.wait()
44
+ finally:
45
+ if pgid is not None:
46
+ try:
47
+ os.killpg(pgid, 0)
48
+ except ProcessLookupError:
49
+ return
50
+ # Leave a helpful error so call sites can report the leak.
51
+ raise RuntimeError("tenzir-node left descendant processes running")
52
+
53
+
54
+ @dataclass(slots=True)
55
+ class _TempDirRecord:
56
+ temp_dir: tempfile.TemporaryDirectory[str]
57
+ ref: weakref.ref["FixtureContext"]
58
+ path: Path
59
+ process: subprocess.Popen[str] | None = None
60
+
61
+
62
+ _TEMP_DIRS: dict[int, _TempDirRecord] = {}
63
+ _TEMP_DIR_LOCK = threading.Lock()
64
+ _LOGGER = logging.getLogger(__name__)
65
+
66
+
67
+ def _stop_process(record: _TempDirRecord) -> None:
68
+ process = record.process
69
+ if process is None:
70
+ return
71
+ record.process = None
72
+ try:
73
+ _terminate_process(process)
74
+ except Exception as exc: # pragma: no cover - defensive logging
75
+ _LOGGER.warning("failed to terminate tenzir-node process: %s", exc)
76
+
77
+
78
+ def _cleanup_record(record: _TempDirRecord) -> None:
79
+ _stop_process(record)
80
+ try:
81
+ record.temp_dir.cleanup()
82
+ except Exception as exc: # pragma: no cover - defensive logging
83
+ _LOGGER.debug("failed to remove temporary directory %s: %s", record.path, exc)
84
+
85
+
86
+ def _cleanup_record_by_key(key: int) -> None:
87
+ with _TEMP_DIR_LOCK:
88
+ record = _TEMP_DIRS.pop(key, None)
89
+ if record is None:
90
+ return
91
+ unregister_tmp_dir_cleanup(record.path)
92
+ _cleanup_record(record)
93
+
94
+
95
+ def _ensure_temp_dir(context: "FixtureContext") -> Path:
96
+ key = id(context)
97
+ stale_record: _TempDirRecord | None = None
98
+ with _TEMP_DIR_LOCK:
99
+ record = _TEMP_DIRS.get(key)
100
+ if record is not None:
101
+ current = record.ref()
102
+ if current is context:
103
+ return record.path
104
+ _TEMP_DIRS.pop(key, None)
105
+ stale_record = record
106
+ if stale_record is not None:
107
+ unregister_tmp_dir_cleanup(stale_record.path)
108
+ _cleanup_record(stale_record)
109
+
110
+ tmp_root = context.env.get("TENZIR_TMP_DIR") or None
111
+ temp_dir = tempfile.TemporaryDirectory(prefix="tenzir-node-", dir=tmp_root)
112
+ path = Path(temp_dir.name)
113
+
114
+ def _cleanup(dead_ref: weakref.ref["FixtureContext"]) -> None:
115
+ with _TEMP_DIR_LOCK:
116
+ existing = _TEMP_DIRS.get(key)
117
+ if existing is not None and existing.ref is dead_ref:
118
+ _cleanup_record_by_key(key)
119
+
120
+ ctx_ref = weakref.ref(context, _cleanup)
121
+ record = _TempDirRecord(temp_dir=temp_dir, ref=ctx_ref, path=path)
122
+ with _TEMP_DIR_LOCK:
123
+ _TEMP_DIRS[key] = record
124
+ register_tmp_dir_cleanup(path, functools.partial(_cleanup_record_by_key, key))
125
+ return path
126
+
127
+
128
+ @contextmanager
129
+ def node() -> Iterator[dict[str, str]]:
130
+ """Start ``tenzir-node`` and yield environment data for dependent tests."""
131
+
132
+ context = current_context()
133
+ if context is None:
134
+ raise RuntimeError("node fixture requires an active test context")
135
+
136
+ node_binary = context.tenzir_node_binary or context.env.get("TENZIR_NODE_BINARY")
137
+ if not node_binary:
138
+ raise RuntimeError("TENZIR_NODE_BINARY must be configured for the node fixture")
139
+
140
+ env = context.env.copy()
141
+ config_args = [arg for arg in context.config_args if not arg.startswith("--config=")]
142
+ node_config = env.get("TENZIR_NODE_CONFIG")
143
+ if node_config:
144
+ config_args.append(f"--config={node_config}")
145
+ package_root = env.get("TENZIR_PACKAGE_ROOT")
146
+ package_args: list[str] = []
147
+ if package_root:
148
+ package_arg = f"--package-dirs={package_root}"
149
+ if package_arg not in config_args:
150
+ package_args.append(package_arg)
151
+ temp_dir = _ensure_temp_dir(context)
152
+ key = id(context)
153
+
154
+ configured_state = env.get("TENZIR_NODE_STATE_DIRECTORY")
155
+ state_dir = Path(configured_state) if configured_state else temp_dir / "state"
156
+ configured_cache = env.get("TENZIR_NODE_CACHE_DIRECTORY")
157
+ cache_dir = Path(configured_cache) if configured_cache else temp_dir / "cache"
158
+ state_dir.mkdir(parents=True, exist_ok=True)
159
+ cache_dir.mkdir(parents=True, exist_ok=True)
160
+ env.setdefault("TENZIR_NODE_STATE_DIRECTORY", str(state_dir))
161
+ env.setdefault("TENZIR_NODE_CACHE_DIRECTORY", str(cache_dir))
162
+
163
+ if context.coverage:
164
+ coverage_dir = env.get(
165
+ "CMAKE_COVERAGE_OUTPUT_DIRECTORY",
166
+ os.path.join(os.getcwd(), "coverage"),
167
+ )
168
+ source_dir = env.get("COVERAGE_SOURCE_DIR", os.getcwd())
169
+ os.makedirs(coverage_dir, exist_ok=True)
170
+ profile_path = os.path.join(
171
+ coverage_dir,
172
+ f"{context.test.stem}-node-%p.profraw",
173
+ )
174
+ env["LLVM_PROFILE_FILE"] = profile_path
175
+ env["COVERAGE_SOURCE_DIR"] = source_dir
176
+
177
+ test_root = context.test.parent
178
+
179
+ node_cmd = [
180
+ node_binary,
181
+ "--bare-mode",
182
+ "--console-verbosity=warning",
183
+ f"--state-directory={state_dir}",
184
+ f"--cache-directory={cache_dir}",
185
+ "--endpoint=localhost:0",
186
+ "--print-endpoint",
187
+ *config_args,
188
+ *package_args,
189
+ ]
190
+
191
+ if _LOGGER.isEnabledFor(logging.DEBUG):
192
+ command_line = shlex.join(node_cmd)
193
+ _LOGGER.debug("spawning tenzir-node: %s (cwd=%s)", command_line, test_root)
194
+
195
+ process = subprocess.Popen(
196
+ node_cmd,
197
+ stdout=subprocess.PIPE,
198
+ stderr=subprocess.PIPE,
199
+ text=True,
200
+ bufsize=1,
201
+ env=env,
202
+ start_new_session=True,
203
+ cwd=test_root,
204
+ )
205
+ with _TEMP_DIR_LOCK:
206
+ record = _TEMP_DIRS.get(key)
207
+ if record is not None:
208
+ record.process = process
209
+
210
+ endpoint: str | None = None
211
+ try:
212
+ if process.stdout:
213
+ endpoint = process.stdout.readline().strip()
214
+
215
+ if not endpoint:
216
+ raise RuntimeError("failed to obtain endpoint from tenzir-node")
217
+
218
+ fixture_env = {
219
+ "TENZIR_NODE_CLIENT_ENDPOINT": endpoint,
220
+ "TENZIR_NODE_CLIENT_BINARY": context.tenzir_binary or env.get("TENZIR_BINARY"),
221
+ "TENZIR_NODE_CLIENT_TIMEOUT": str(context.config["timeout"]),
222
+ "TENZIR_NODE_STATE_DIRECTORY": str(state_dir),
223
+ "TENZIR_NODE_CACHE_DIRECTORY": str(cache_dir),
224
+ }
225
+ # Filter out empty values to avoid polluting the environment.
226
+ filtered_env = {k: v for k, v in fixture_env.items() if v}
227
+ yield filtered_env
228
+ finally:
229
+ if process.stdout:
230
+ process.stdout.close()
231
+ if process.stderr:
232
+ process.stderr.close()
233
+ with _TEMP_DIR_LOCK:
234
+ record = _TEMP_DIRS.get(key)
235
+ if record is not None:
236
+ _stop_process(record)
237
+ else: # pragma: no cover - defensive logging
238
+ _LOGGER.debug("node fixture context missing record for key %s", key)
239
+ try:
240
+ _terminate_process(process)
241
+ except Exception as exc:
242
+ _LOGGER.warning("failed to terminate leaked tenzir-node (key=%s): %s", key, exc)
243
+
244
+
245
+ register("node", node, replace=True)
246
+
247
+
248
+ @atexit.register
249
+ def _cleanup_all_node_temp_dirs() -> None: # pragma: no cover - interpreter shutdown
250
+ keys: list[int]
251
+ with _TEMP_DIR_LOCK:
252
+ keys = list(_TEMP_DIRS.keys())
253
+ for key in keys:
254
+ _cleanup_record_by_key(key)
255
+
256
+
257
+ __all__ = ["node"]
@@ -0,0 +1,33 @@
1
+ """Helpers for detecting and working with Tenzir library packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Iterator
7
+
8
+ PACKAGE_MANIFEST = "package.yaml"
9
+
10
+
11
+ def is_package_dir(path: Path) -> bool:
12
+ """Return True when the directory contains a package manifest."""
13
+ return (path / PACKAGE_MANIFEST).is_file()
14
+
15
+
16
+ def find_package_root(path: Path) -> Path | None:
17
+ """Search upwards from `path` for the enclosing package directory."""
18
+ current = path.resolve()
19
+ for parent in [current, *current.parents]:
20
+ if is_package_dir(parent):
21
+ return parent
22
+ return None
23
+
24
+
25
+ def iter_package_dirs(root: Path) -> Iterator[Path]:
26
+ """Yield package directories directly under `root`.
27
+
28
+ The helper does not recurse; callers decide how deep to search.
29
+ """
30
+
31
+ for candidate in root.iterdir():
32
+ if candidate.is_dir() and is_package_dir(candidate):
33
+ yield candidate
tenzir_test/py.typed ADDED
File without changes