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,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"]
|
tenzir_test/packages.py
ADDED
|
@@ -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
|