tenzir-test 0.12.0__py3-none-any.whl → 0.15.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/cli.py CHANGED
@@ -51,6 +51,15 @@ def _normalize_exit_code(value: object) -> int:
51
51
  type=click.Path(path_type=Path, dir_okay=False, writable=False, resolve_path=False),
52
52
  help="Path to the tenzir-node executable.",
53
53
  )
54
+ @click.option(
55
+ "package_dirs",
56
+ "--package-dirs",
57
+ multiple=True,
58
+ help=(
59
+ "Comma-separated list of package directories to load (repeatable). "
60
+ "These only control package visibility; test selection still follows the usual --root/args."
61
+ ),
62
+ )
54
63
  @click.argument(
55
64
  "tests",
56
65
  nargs=-1,
@@ -124,6 +133,12 @@ def _normalize_exit_code(value: object) -> int:
124
133
  is_flag=True,
125
134
  help="Stream raw test output directly to the terminal.",
126
135
  )
136
+ @click.option(
137
+ "-v",
138
+ "--verbose",
139
+ is_flag=True,
140
+ help="Print individual test results as they complete. By default, only failures are shown. Automatically enabled in passthrough mode.",
141
+ )
127
142
  @click.option(
128
143
  "-a",
129
144
  "--all-projects",
@@ -137,6 +152,7 @@ def cli(
137
152
  root: Path | None,
138
153
  tenzir_binary: Path | None,
139
154
  tenzir_node_binary: Path | None,
155
+ package_dirs: tuple[str, ...],
140
156
  tests: tuple[Path, ...],
141
157
  update: bool,
142
158
  debug: bool,
@@ -151,10 +167,19 @@ def cli(
151
167
  keep_tmp_dirs: bool,
152
168
  jobs: int,
153
169
  passthrough: bool,
170
+ verbose: bool,
154
171
  all_projects: bool,
155
172
  ) -> int:
156
173
  """Execute tenzir-test scenarios."""
157
174
 
175
+ package_paths: list[Path] = []
176
+ for entry in package_dirs:
177
+ for piece in entry.split(","):
178
+ piece = piece.strip()
179
+ if not piece:
180
+ continue
181
+ package_paths.append(Path(piece))
182
+
158
183
  jobs_source = ctx.get_parameter_source("jobs")
159
184
  jobs_overridden = jobs_source is not click.core.ParameterSource.DEFAULT
160
185
 
@@ -163,6 +188,7 @@ def cli(
163
188
  root=root,
164
189
  tenzir_binary=tenzir_binary,
165
190
  tenzir_node_binary=tenzir_node_binary,
191
+ package_dirs=package_paths,
166
192
  tests=list(tests),
167
193
  update=update,
168
194
  debug=debug,
@@ -177,6 +203,7 @@ def cli(
177
203
  keep_tmp_dirs=keep_tmp_dirs,
178
204
  jobs=jobs,
179
205
  passthrough=passthrough,
206
+ verbose=verbose,
180
207
  jobs_overridden=jobs_overridden,
181
208
  all_projects=all_projects,
182
209
  )
tenzir_test/config.py CHANGED
@@ -41,7 +41,7 @@ def discover_settings(
41
41
  ) -> Settings:
42
42
  """Produce harness settings by combining CLI overrides with environment defaults."""
43
43
 
44
- environment = dict(env or os.environ)
44
+ environment = dict(os.environ if env is None else env)
45
45
 
46
46
  chosen_root = root or environment.get("TENZIR_TEST_ROOT") or Path.cwd()
47
47
  root_path = Path(chosen_root).resolve()
@@ -6,6 +6,7 @@ import atexit
6
6
  import functools
7
7
  import logging
8
8
  import os
9
+ import select
9
10
  import shlex
10
11
  import subprocess
11
12
  import tempfile
@@ -26,6 +27,37 @@ from . import (
26
27
  if TYPE_CHECKING:
27
28
  from . import FixtureContext
28
29
 
30
+ _STDERR_READ_TIMEOUT = 0.5 # seconds to wait for stderr data
31
+
32
+
33
+ def _read_available_stderr(process: subprocess.Popen[str]) -> str:
34
+ """Read any available stderr output without blocking.
35
+
36
+ Uses select() to check if data is available before reading. This allows
37
+ capturing diagnostic output even when the process is still running but
38
+ has written error messages to stderr.
39
+ """
40
+ if not process.stderr:
41
+ return ""
42
+ try:
43
+ fd = process.stderr.fileno()
44
+ readable, _, _ = select.select([fd], [], [], _STDERR_READ_TIMEOUT)
45
+ if not readable:
46
+ return ""
47
+ # Read available data in chunks to avoid blocking on partial reads.
48
+ chunks: list[str] = []
49
+ while True:
50
+ ready, _, _ = select.select([fd], [], [], 0)
51
+ if not ready:
52
+ break
53
+ chunk = os.read(fd, 4096).decode("utf-8", errors="replace")
54
+ if not chunk:
55
+ break
56
+ chunks.append(chunk)
57
+ return "".join(chunks).strip()
58
+ except Exception:
59
+ return ""
60
+
29
61
 
30
62
  def _terminate_process(process: subprocess.Popen[str]) -> None:
31
63
  """Terminate the spawned node process and ensure its group is gone."""
@@ -138,16 +170,47 @@ def node() -> Iterator[dict[str, str]]:
138
170
  raise RuntimeError("TENZIR_NODE_BINARY must be configured for the node fixture")
139
171
 
140
172
  env = context.env.copy()
141
- config_args = [arg for arg in context.config_args if not arg.startswith("--config=")]
173
+ # Extract and filter config arguments: we handle --config and --package-dirs separately.
174
+ config_args: list[str] = []
175
+ config_package_dirs: list[str] = []
176
+ for arg in context.config_args:
177
+ if arg.startswith("--config="):
178
+ continue
179
+ if arg.startswith("--package-dirs="):
180
+ value = arg.split("=", 1)[1]
181
+ config_package_dirs.extend(entry.strip() for entry in value.split(",") if entry.strip())
182
+ continue
183
+ config_args.append(arg)
142
184
  node_config = env.get("TENZIR_NODE_CONFIG")
143
185
  if node_config:
144
186
  config_args.append(f"--config={node_config}")
145
- package_root = env.get("TENZIR_PACKAGE_ROOT")
146
187
  package_args: list[str] = []
188
+ package_root = env.get("TENZIR_PACKAGE_ROOT")
189
+ package_dirs: list[str] = []
147
190
  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)
191
+ package_dirs.append(package_root)
192
+
193
+ extra_package_dirs = env.get("TENZIR_PACKAGE_DIRS")
194
+ if extra_package_dirs:
195
+ package_dirs.extend(
196
+ [entry.strip() for entry in extra_package_dirs.split(",") if entry.strip()]
197
+ )
198
+
199
+ # Include package directories from config_args as well.
200
+ package_dirs.extend(config_package_dirs)
201
+
202
+ # Deduplicate while preserving order by using resolved paths as keys.
203
+ seen: set[str] = set()
204
+ unique_dirs: list[str] = []
205
+ for entry in package_dirs:
206
+ resolved = str(Path(entry).expanduser().resolve(strict=False))
207
+ if resolved not in seen:
208
+ seen.add(resolved)
209
+ unique_dirs.append(entry)
210
+ package_dirs = unique_dirs
211
+
212
+ if package_dirs:
213
+ package_args.append(f"--package-dirs={','.join(package_dirs)}")
151
214
  temp_dir = _ensure_temp_dir(context)
152
215
  key = id(context)
153
216
 
@@ -213,7 +276,21 @@ def node() -> Iterator[dict[str, str]]:
213
276
  endpoint = process.stdout.readline().strip()
214
277
 
215
278
  if not endpoint:
216
- raise RuntimeError("failed to obtain endpoint from tenzir-node")
279
+ # Collect diagnostic information to help debug startup failures.
280
+ diagnostics: list[str] = []
281
+ returncode = process.poll()
282
+ if returncode is not None:
283
+ diagnostics.append(f"exit code {returncode}")
284
+ # Try to read stderr output using non-blocking I/O. This captures
285
+ # diagnostics even when the process is still running (e.g., when it
286
+ # hangs after writing an error message but before exiting).
287
+ stderr_output = _read_available_stderr(process)
288
+ if stderr_output:
289
+ diagnostics.append(f"stderr:\n{stderr_output}")
290
+ detail = (
291
+ "; ".join(diagnostics) if diagnostics else "no additional diagnostics available"
292
+ )
293
+ raise RuntimeError(f"failed to obtain endpoint from tenzir-node ({detail})")
217
294
 
218
295
  fixture_env = {
219
296
  "TENZIR_NODE_CLIENT_ENDPOINT": endpoint,
tenzir_test/run.py CHANGED
@@ -128,6 +128,7 @@ class ExecutionMode(enum.Enum):
128
128
 
129
129
  PROJECT = "project"
130
130
  PACKAGE = "package"
131
+ LIBRARY = "library"
131
132
 
132
133
 
133
134
  class HarnessMode(enum.Enum):
@@ -146,6 +147,18 @@ class ColorMode(enum.Enum):
146
147
  NEVER = "never"
147
148
 
148
149
 
150
+ def _is_library_root(path: Path) -> bool:
151
+ """Return True when the directory looks like a library of packages."""
152
+
153
+ if packages.is_package_dir(path):
154
+ return False
155
+ try:
156
+ entries = list(packages.iter_package_dirs(path))
157
+ except OSError:
158
+ return False
159
+ return bool(entries)
160
+
161
+
149
162
  def detect_execution_mode(root: Path) -> tuple[ExecutionMode, Path | None]:
150
163
  """Return the execution mode and detected package root for `root`."""
151
164
 
@@ -156,6 +169,9 @@ def detect_execution_mode(root: Path) -> tuple[ExecutionMode, Path | None]:
156
169
  if parent is not None and packages.is_package_dir(parent):
157
170
  return ExecutionMode.PACKAGE, parent
158
171
 
172
+ if _is_library_root(root):
173
+ return ExecutionMode.LIBRARY, None
174
+
159
175
  return ExecutionMode.PROJECT, None
160
176
 
161
177
 
@@ -467,6 +483,7 @@ KEEP_TMP_DIRS = bool(os.environ.get(_TMP_KEEP_ENV_VAR))
467
483
 
468
484
  SHOW_DIFF_OUTPUT = True
469
485
  SHOW_DIFF_STAT = True
486
+ VERBOSE_OUTPUT = False
470
487
  _BLOCK_INDENT = ""
471
488
  _PLUS_SYMBOLS = {1: "□", 10: "▣", 100: "■"}
472
489
  _MINUS_SYMBOLS = {1: "□", 10: "▣", 100: "■"}
@@ -490,6 +507,21 @@ def should_show_diff_stat() -> bool:
490
507
  return SHOW_DIFF_STAT
491
508
 
492
509
 
510
+ def set_verbose_output(enabled: bool) -> None:
511
+ """Enable or disable verbose output for individual test results.
512
+
513
+ When enabled, both success and skip messages are printed as tests complete.
514
+ When disabled (default), only failures are printed.
515
+ """
516
+ global VERBOSE_OUTPUT
517
+ VERBOSE_OUTPUT = enabled
518
+
519
+
520
+ def is_verbose_output() -> bool:
521
+ """Return whether verbose output is enabled."""
522
+ return VERBOSE_OUTPUT
523
+
524
+
493
525
  def set_harness_mode(mode: HarnessMode) -> None:
494
526
  """Set the global harness execution mode."""
495
527
 
@@ -746,6 +778,23 @@ _CLI_LOGGER.setLevel(logging.DEBUG if _debug_logging else logging.WARNING)
746
778
  _DIRECTORY_CONFIG_CACHE: dict[Path, "_DirectoryConfig"] = {}
747
779
 
748
780
  _DISCOVERY_ENABLED = False
781
+ _CLI_PACKAGES: list[Path] = []
782
+
783
+
784
+ def _expand_package_dirs(path: Path) -> list[str]:
785
+ """Normalize a package dir hint; if it contains packages, return those."""
786
+
787
+ resolved = path.expanduser().resolve()
788
+ if packages.is_package_dir(resolved):
789
+ return [str(resolved)]
790
+ expanded: list[str] = []
791
+ try:
792
+ for pkg in packages.iter_package_dirs(resolved):
793
+ expanded.append(str(pkg.resolve()))
794
+ except OSError as exc:
795
+ _CLI_LOGGER.debug("failed to expand package dir %s: %s", path, exc)
796
+ return [str(resolved)]
797
+ return expanded or [str(resolved)]
749
798
 
750
799
 
751
800
  def _set_discovery_logging(enabled: bool) -> None:
@@ -753,6 +802,29 @@ def _set_discovery_logging(enabled: bool) -> None:
753
802
  _DISCOVERY_ENABLED = enabled
754
803
 
755
804
 
805
+ def _set_cli_packages(package_paths: list[Path]) -> None:
806
+ global _CLI_PACKAGES
807
+ _CLI_PACKAGES = [path.resolve() for path in package_paths]
808
+
809
+
810
+ def _get_cli_packages() -> list[Path]:
811
+ return list(_CLI_PACKAGES)
812
+
813
+
814
+ def _deduplicate_package_dirs(candidates: list[str]) -> list[str]:
815
+ """Remove duplicate package directories while preserving order."""
816
+
817
+ seen: set[str] = set()
818
+ result: list[str] = []
819
+ for candidate in candidates:
820
+ normalized = str(Path(candidate).expanduser().resolve(strict=False))
821
+ if normalized in seen:
822
+ continue
823
+ seen.add(normalized)
824
+ result.append(candidate)
825
+ return result
826
+
827
+
756
828
  def _print_discovery_message(message: str) -> None:
757
829
  if _CLI_LOGGER.isEnabledFor(logging.DEBUG):
758
830
  _CLI_LOGGER.debug(message)
@@ -768,6 +840,7 @@ class ProjectMarker(enum.Enum):
768
840
  TESTS_DIRECTORY = "tests_directory"
769
841
  TEST_CONFIG = "test_config"
770
842
  TEST_SUITE_DIRECTORY = "test_suite_directory"
843
+ LIBRARY_ROOT = "library_root"
771
844
 
772
845
 
773
846
  @dataclasses.dataclass(frozen=True, slots=True)
@@ -790,6 +863,7 @@ _PRIMARY_PROJECT_MARKERS = {
790
863
  ProjectMarker.TESTS_DIRECTORY,
791
864
  ProjectMarker.TEST_CONFIG,
792
865
  ProjectMarker.TEST_SUITE_DIRECTORY,
866
+ ProjectMarker.LIBRARY_ROOT,
793
867
  }
794
868
 
795
869
 
@@ -819,6 +893,12 @@ def _describe_project_root(path: Path) -> ProjectSignature | None:
819
893
  if resolved.name == "tests" and resolved.is_dir():
820
894
  markers.add(ProjectMarker.TEST_SUITE_DIRECTORY)
821
895
 
896
+ try:
897
+ if _is_library_root(resolved):
898
+ markers.add(ProjectMarker.LIBRARY_ROOT)
899
+ except OSError:
900
+ pass
901
+
822
902
  if not markers:
823
903
  return None
824
904
 
@@ -1117,12 +1197,15 @@ def _default_test_config() -> TestConfig:
1117
1197
  "inputs": None,
1118
1198
  "retry": 1,
1119
1199
  "suite": None,
1200
+ "package_dirs": tuple(),
1120
1201
  }
1121
1202
 
1122
1203
 
1123
1204
  def _canonical_config_key(key: str) -> str:
1124
1205
  if key == "fixture":
1125
1206
  return "fixtures"
1207
+ if key in {"package_dirs", "package-dirs"}:
1208
+ return "package_dirs"
1126
1209
  return key
1127
1210
 
1128
1211
 
@@ -1224,6 +1307,50 @@ def _normalize_inputs_value(
1224
1307
  return None
1225
1308
 
1226
1309
 
1310
+ def _normalize_package_dirs_value(
1311
+ value: typing.Any,
1312
+ *,
1313
+ location: Path | str,
1314
+ line_number: int | None = None,
1315
+ ) -> tuple[str, ...]:
1316
+ if value is None:
1317
+ return tuple()
1318
+ if not isinstance(value, (list, tuple)):
1319
+ _raise_config_error(
1320
+ location,
1321
+ f"Invalid value for 'package-dirs', expected list of strings, got '{value}'",
1322
+ line_number,
1323
+ )
1324
+ return tuple()
1325
+ base_dir = _extract_location_path(location).parent
1326
+ normalized: list[str] = []
1327
+ for entry in value:
1328
+ if not isinstance(entry, (str, os.PathLike)):
1329
+ _raise_config_error(
1330
+ location,
1331
+ f"Invalid package-dirs entry '{entry}', expected string",
1332
+ line_number,
1333
+ )
1334
+ continue
1335
+ raw = os.fspath(entry).strip()
1336
+ if not raw:
1337
+ _raise_config_error(
1338
+ location,
1339
+ "Invalid package-dirs entry: must be non-empty string",
1340
+ line_number,
1341
+ )
1342
+ continue
1343
+ path = Path(raw)
1344
+ if not path.is_absolute():
1345
+ path = base_dir / path
1346
+ try:
1347
+ path = path.resolve()
1348
+ except OSError:
1349
+ path = path
1350
+ normalized.append(str(path))
1351
+ return tuple(normalized)
1352
+
1353
+
1227
1354
  def _assign_config_option(
1228
1355
  config: TestConfig,
1229
1356
  key: str,
@@ -1234,7 +1361,16 @@ def _assign_config_option(
1234
1361
  origin: ConfigOrigin,
1235
1362
  ) -> None:
1236
1363
  canonical = _canonical_config_key(key)
1237
- valid_keys: set[str] = {"error", "timeout", "runner", "skip", "fixtures", "inputs", "retry"}
1364
+ valid_keys: set[str] = {
1365
+ "error",
1366
+ "timeout",
1367
+ "runner",
1368
+ "skip",
1369
+ "fixtures",
1370
+ "inputs",
1371
+ "retry",
1372
+ "package_dirs",
1373
+ }
1238
1374
  if origin == "directory":
1239
1375
  valid_keys.add("suite")
1240
1376
  if canonical not in valid_keys:
@@ -1321,6 +1457,17 @@ def _assign_config_option(
1321
1457
  value, location=location, line_number=line_number
1322
1458
  )
1323
1459
  return
1460
+ if canonical == "package_dirs":
1461
+ if origin != "directory":
1462
+ _raise_config_error(
1463
+ location,
1464
+ "'package-dirs' can only be specified in directory-level test.yaml files",
1465
+ line_number,
1466
+ )
1467
+ config[canonical] = _normalize_package_dirs_value(
1468
+ value, location=location, line_number=line_number
1469
+ )
1470
+ return
1324
1471
  if canonical == "retry":
1325
1472
  if origin == "frontmatter" and isinstance(config.get("suite"), str):
1326
1473
  _raise_config_error(
@@ -1510,20 +1657,12 @@ def _iter_project_test_directories(root: Path) -> Iterator[Path]:
1510
1657
 
1511
1658
 
1512
1659
  def _is_inputs_path(path: Path) -> bool:
1513
- """Return True when the path lives under an inputs directory."""
1660
+ """Return True when the path lives under any inputs directory."""
1514
1661
  try:
1515
1662
  parts = path.relative_to(ROOT).parts
1516
1663
  except ValueError:
1517
1664
  parts = path.parts
1518
-
1519
- for index, part in enumerate(parts):
1520
- if part != "inputs":
1521
- continue
1522
- if index == 0:
1523
- return True
1524
- if index > 0 and parts[index - 1] == "tests":
1525
- return True
1526
- return False
1665
+ return "inputs" in parts
1527
1666
 
1528
1667
 
1529
1668
  def _refresh_registry() -> None:
@@ -1556,6 +1695,25 @@ def _resolve_inputs_dir(root: Path) -> Path:
1556
1695
  return direct
1557
1696
 
1558
1697
 
1698
+ def _find_nearest_inputs_dir(test: Path, root: Path) -> Path | None:
1699
+ """Walk up from test directory to find nearest inputs/ directory.
1700
+
1701
+ Returns the nearest inputs/ directory between the test and root,
1702
+ or None if no inputs/ directory exists in that range.
1703
+ """
1704
+ current = test.parent
1705
+ root_resolved = root.resolve()
1706
+ while True:
1707
+ candidate = current / "inputs"
1708
+ if candidate.is_dir():
1709
+ return candidate
1710
+ # Stop when we reach or pass the root
1711
+ if current.resolve() == root_resolved or current == current.parent:
1712
+ break
1713
+ current = current.parent
1714
+ return None
1715
+
1716
+
1559
1717
  def _looks_like_project_root(path: Path) -> bool:
1560
1718
  """Return True when the path or one of its parents resembles a project root."""
1561
1719
 
@@ -1687,13 +1845,22 @@ def get_test_env_and_config_args(
1687
1845
  config_args = [f"--config={config_file}"] if config_file.exists() else []
1688
1846
  env = os.environ.copy()
1689
1847
  if inputs is None:
1690
- inputs_path = str(_resolve_inputs_dir(ROOT).resolve())
1848
+ # Try nearest inputs/ directory first, fall back to project-level
1849
+ nearest = _find_nearest_inputs_dir(test, ROOT)
1850
+ if nearest is not None:
1851
+ inputs_path = str(nearest.resolve())
1852
+ else:
1853
+ inputs_path = str(_resolve_inputs_dir(ROOT).resolve())
1691
1854
  else:
1692
1855
  candidate = Path(os.fspath(inputs))
1693
1856
  if not candidate.is_absolute():
1694
1857
  candidate = test.parent / candidate
1695
1858
  inputs_path = str(candidate.resolve())
1696
1859
  env["TENZIR_INPUTS"] = inputs_path
1860
+ # Check for inline input file (.input extension)
1861
+ inline_input = test.with_suffix(".input")
1862
+ if inline_input.is_file():
1863
+ env["TENZIR_INPUT"] = str(inline_input.resolve())
1697
1864
  if config_file.exists():
1698
1865
  env.setdefault("TENZIR_CONFIG", str(config_file))
1699
1866
  if node_config_file.exists():
@@ -2556,6 +2723,8 @@ def get_version() -> str:
2556
2723
 
2557
2724
 
2558
2725
  def success(test: Path) -> None:
2726
+ if not is_verbose_output():
2727
+ return
2559
2728
  with stdout_lock:
2560
2729
  rel_test = _relativize_path(test)
2561
2730
  suite_suffix = _format_suite_suffix()
@@ -2563,6 +2732,10 @@ def success(test: Path) -> None:
2563
2732
  print(f"{CHECKMARK} {rel_test}{suite_suffix}{attempt_suffix}")
2564
2733
 
2565
2734
 
2735
+ # Failures always print regardless of verbose mode because failures are critical
2736
+ # information that users need to see immediately. In contrast, success() and
2737
+ # handle_skip() respect the verbose setting since passed/skipped tests are
2738
+ # expected outcomes that can be summarized at the end.
2566
2739
  def fail(test: Path) -> None:
2567
2740
  with stdout_lock:
2568
2741
  rel_test = _relativize_path(test)
@@ -2710,14 +2883,32 @@ def run_simple_test(
2710
2883
  expect_error = bool(test_config.get("error", False))
2711
2884
  passthrough_mode = is_passthrough_enabled()
2712
2885
 
2886
+ config_package_dirs = cast(tuple[str, ...], test_config.get("package_dirs", tuple()))
2887
+ additional_package_dirs: list[str] = []
2888
+ for entry in config_package_dirs:
2889
+ additional_package_dirs.extend(_expand_package_dirs(Path(entry)))
2890
+
2713
2891
  package_root = packages.find_package_root(test)
2714
2892
  package_args: list[str] = []
2893
+ package_dir_candidates: list[str] = []
2715
2894
  if package_root is not None:
2716
2895
  env["TENZIR_PACKAGE_ROOT"] = str(package_root)
2717
2896
  package_tests_root = package_root / "tests"
2718
2897
  if inputs_override is None:
2719
- env["TENZIR_INPUTS"] = str(package_tests_root / "inputs")
2720
- package_args.append(f"--package-dirs={package_root}")
2898
+ # Try nearest inputs/ directory, fall back to package-level
2899
+ nearest = _find_nearest_inputs_dir(test, package_root)
2900
+ if nearest is not None:
2901
+ env["TENZIR_INPUTS"] = str(nearest.resolve())
2902
+ else:
2903
+ env["TENZIR_INPUTS"] = str(package_tests_root / "inputs")
2904
+ package_dir_candidates.append(str(package_root))
2905
+ package_dir_candidates.extend(additional_package_dirs)
2906
+ for cli_path in _get_cli_packages():
2907
+ package_dir_candidates.extend(_expand_package_dirs(cli_path))
2908
+ if package_dir_candidates:
2909
+ merged_dirs = _deduplicate_package_dirs(package_dir_candidates)
2910
+ env["TENZIR_PACKAGE_DIRS"] = ",".join(merged_dirs)
2911
+ package_args.append(f"--package-dirs={','.join(merged_dirs)}")
2721
2912
 
2722
2913
  context_token = fixtures_impl.push_context(
2723
2914
  fixtures_impl.FixtureContext(
@@ -2859,9 +3050,10 @@ def run_simple_test(
2859
3050
 
2860
3051
 
2861
3052
  def handle_skip(reason: str, test: Path, update: bool, output_ext: str) -> bool | str:
2862
- rel_path = _relativize_path(test)
2863
- suite_suffix = _format_suite_suffix()
2864
- print(f"{SKIP} skipped {rel_path}{suite_suffix}: {reason}")
3053
+ if is_verbose_output():
3054
+ rel_path = _relativize_path(test)
3055
+ suite_suffix = _format_suite_suffix()
3056
+ print(f"{SKIP} skipped {rel_path}{suite_suffix}: {reason}")
2865
3057
  ref_path = test.with_suffix(f".{output_ext}")
2866
3058
  if update:
2867
3059
  with ref_path.open("wb") as f:
@@ -2975,12 +3167,29 @@ class Worker:
2975
3167
  raise RuntimeError(f"failed to parse suite config for {primary_test}: {exc}") from exc
2976
3168
  inputs_override = typing.cast(str | None, primary_config.get("inputs"))
2977
3169
  env, config_args = get_test_env_and_config_args(primary_test, inputs=inputs_override)
3170
+ config_package_dirs = cast(tuple[str, ...], primary_config.get("package_dirs", tuple()))
3171
+ additional_package_dirs: list[str] = []
3172
+ for entry in config_package_dirs:
3173
+ additional_package_dirs.extend(_expand_package_dirs(Path(entry)))
2978
3174
  package_root = packages.find_package_root(primary_test)
3175
+ package_dir_candidates: list[str] = []
2979
3176
  if package_root is not None:
2980
3177
  env["TENZIR_PACKAGE_ROOT"] = str(package_root)
2981
3178
  if inputs_override is None:
2982
- env["TENZIR_INPUTS"] = str((package_root / "tests" / "inputs"))
2983
- _apply_fixture_env(env, suite_item.fixtures)
3179
+ # Try nearest inputs/ directory, fall back to package-level
3180
+ nearest = _find_nearest_inputs_dir(primary_test, package_root)
3181
+ if nearest is not None:
3182
+ env["TENZIR_INPUTS"] = str(nearest.resolve())
3183
+ else:
3184
+ env["TENZIR_INPUTS"] = str((package_root / "tests" / "inputs"))
3185
+ package_dir_candidates.append(str(package_root))
3186
+ package_dir_candidates.extend(additional_package_dirs)
3187
+ for cli_path in _get_cli_packages():
3188
+ package_dir_candidates.extend(_expand_package_dirs(cli_path))
3189
+ if package_dir_candidates:
3190
+ merged_dirs = _deduplicate_package_dirs(package_dir_candidates)
3191
+ env["TENZIR_PACKAGE_DIRS"] = ",".join(merged_dirs)
3192
+ config_args = list(config_args) + [f"--package-dirs={','.join(merged_dirs)}"]
2984
3193
  context_token = fixtures_impl.push_context(
2985
3194
  fixtures_impl.FixtureContext(
2986
3195
  test=primary_test,
@@ -3152,6 +3361,7 @@ def run_cli(
3152
3361
  root: Path | None,
3153
3362
  tenzir_binary: Path | None,
3154
3363
  tenzir_node_binary: Path | None,
3364
+ package_dirs: Sequence[Path] | None = None,
3155
3365
  tests: Sequence[Path],
3156
3366
  update: bool,
3157
3367
  debug: bool,
@@ -3166,10 +3376,16 @@ def run_cli(
3166
3376
  jobs: int,
3167
3377
  keep_tmp_dirs: bool,
3168
3378
  passthrough: bool,
3379
+ verbose: bool = False,
3169
3380
  jobs_overridden: bool = False,
3170
3381
  all_projects: bool = False,
3171
3382
  ) -> ExecutionResult:
3172
- """Execute the harness and return a structured result for library consumers."""
3383
+ """Execute the harness and return a structured result for library consumers.
3384
+
3385
+ Args:
3386
+ verbose: Print individual test results (pass/skip) as they complete.
3387
+ When False (default), only failures are printed during execution.
3388
+ """
3173
3389
  from tenzir_test.engine import state as engine_state
3174
3390
 
3175
3391
  try:
@@ -3222,6 +3438,8 @@ def run_cli(
3222
3438
  harness_mode = HarnessMode.COMPARE
3223
3439
  set_harness_mode(harness_mode)
3224
3440
  passthrough_mode = harness_mode is HarnessMode.PASSTHROUGH
3441
+ # Passthrough mode requires verbose output to show real-time test results
3442
+ set_verbose_output(verbose or passthrough_mode)
3225
3443
  if passthrough_mode and jobs > 1:
3226
3444
  if jobs_overridden:
3227
3445
  print(f"{INFO} forcing --jobs=1 in passthrough mode to preserve output ordering")
@@ -3236,6 +3454,7 @@ def run_cli(
3236
3454
  tenzir_node_binary=tenzir_node_binary,
3237
3455
  )
3238
3456
  apply_settings(settings)
3457
+ _set_cli_packages(list(package_dirs or []))
3239
3458
  selected_tests = list(tests)
3240
3459
 
3241
3460
  plan: ExecutionPlan | None = None
@@ -3494,7 +3713,8 @@ def run_cli(
3494
3713
  _print_compact_summary(project_summary)
3495
3714
  summary_enabled = show_summary or runner_summary or fixture_summary
3496
3715
  if summary_enabled:
3497
- _print_detailed_summary(project_summary)
3716
+ if is_verbose_output():
3717
+ _print_detailed_summary(project_summary)
3498
3718
  _print_ascii_summary(
3499
3719
  project_summary,
3500
3720
  include_runner=runner_summary,
@@ -3592,11 +3812,16 @@ def execute(
3592
3812
  jobs: int | None = None,
3593
3813
  keep_tmp_dirs: bool = False,
3594
3814
  passthrough: bool = False,
3815
+ verbose: bool = False,
3595
3816
  jobs_overridden: bool = False,
3596
3817
  all_projects: bool = False,
3597
3818
  ) -> ExecutionResult:
3598
- """Library-oriented wrapper around `run_cli` with defaulted parameters."""
3819
+ """Library-oriented wrapper around `run_cli` with defaulted parameters.
3599
3820
 
3821
+ Args:
3822
+ verbose: Print individual test results (pass/skip) as they complete.
3823
+ When False (default), only failures are printed during execution.
3824
+ """
3600
3825
  resolved_jobs = jobs if jobs is not None else get_default_jobs()
3601
3826
  return run_cli(
3602
3827
  root=root,
@@ -3616,6 +3841,7 @@ def execute(
3616
3841
  jobs=resolved_jobs,
3617
3842
  keep_tmp_dirs=keep_tmp_dirs,
3618
3843
  passthrough=passthrough,
3844
+ verbose=verbose,
3619
3845
  jobs_overridden=jobs_overridden,
3620
3846
  all_projects=all_projects,
3621
3847
  )
@@ -106,11 +106,16 @@ class DiffRunner(TqlRunner):
106
106
  fixture_api.pop_context(context_token)
107
107
  run_mod.cleanup_test_tmp_dir(env.get(run_mod.TEST_TMP_ENV_VAR))
108
108
 
109
+ # Strip the ROOT prefix from paths in output to make them relative,
110
+ # consistent with run_simple_test behavior.
111
+ root_bytes = str(run_mod.ROOT).encode() + b"/"
112
+ unoptimized_stdout = unoptimized.stdout.replace(root_bytes, b"")
113
+ optimized_stdout = optimized.stdout.replace(root_bytes, b"")
109
114
  diff_chunks = list(
110
115
  difflib.diff_bytes(
111
116
  difflib.unified_diff,
112
- unoptimized.stdout.splitlines(keepends=True),
113
- optimized.stdout.splitlines(keepends=True),
117
+ unoptimized_stdout.splitlines(keepends=True),
118
+ optimized_stdout.splitlines(keepends=True),
114
119
  n=2**31 - 1,
115
120
  )
116
121
  )[3:]
@@ -118,7 +123,7 @@ class DiffRunner(TqlRunner):
118
123
  diff_bytes = b"".join(diff_chunks)
119
124
  else:
120
125
  diff_bytes = b"".join(
121
- b" " + line for line in unoptimized.stdout.splitlines(keepends=True)
126
+ b" " + line for line in unoptimized_stdout.splitlines(keepends=True)
122
127
  )
123
128
  ref_path = test.with_suffix(".diff")
124
129
  if update:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tenzir-test
3
- Version: 0.12.0
3
+ Version: 0.15.0
4
4
  Summary: Reusable test execution framework extracted from the Tenzir repository.
5
5
  Project-URL: Homepage, https://github.com/tenzir/test
6
6
  Project-URL: Repository, https://github.com/tenzir/test
@@ -63,18 +63,18 @@ for an end-to-end walkthrough of writing tests.
63
63
  We also provide a dense [reference](https://docs.tenzir.com/reference/test) that
64
64
  explains concepts, configuration, multi-project execution, and CLI details.
65
65
 
66
- ## 🧑‍💻 Development
67
-
68
- Contributor workflows, quality gates, and release procedures live in
69
- [`DEVELOPMENT.md`](DEVELOPMENT.md). Follow that guide when you work on the
70
- project locally.
71
-
72
66
  ## 🗞️ Releases
73
67
 
74
68
  New versions are published to PyPI through trusted publishing when a GitHub
75
69
  release is created. Review the latest release notes on GitHub for details about
76
70
  what's new.
77
71
 
72
+ ## 🤝 Contributing
73
+
74
+ Want to contribute? We're all-in on agentic coding with [Claude
75
+ Code](https://claude.ai/code)! The repo comes pre-configured with our [custom
76
+ plugins](https://github.com/tenzir/claude-plugins)—just clone and start hacking.
77
+
78
78
  ## 📜 License
79
79
 
80
80
  `tenzir-test` is available under the Apache License, Version 2.0. See
@@ -1,29 +1,28 @@
1
1
  tenzir_test/__init__.py,sha256=k7V6Pbjaa8SAy6t4KnaauHTyfnyVEwc1VGtH823MANU,1181
2
2
  tenzir_test/_python_runner.py,sha256=LmghMIolsNEC2wUyJdv1h_cefOxTxET1IACrw-_hHuY,2900
3
- tenzir_test/checks.py,sha256=VhZjU1TExqWzA1KcaW1xOGICpqb_G43AezrJIzw09eM,653
4
- tenzir_test/cli.py,sha256=kDatxC4drfjAKnYIYFQmjhPJX45KYbm03pX44iqFgOk,5967
5
- tenzir_test/config.py,sha256=q1_VEXuxL-xsGlnooeGvXxx9cMw652UEB9a1mPzZIQs,1680
3
+ tenzir_test/cli.py,sha256=e93O5dyp6oeiuW3Im11wWyuOc6duKTwUJmcmFuoVnoE,6803
4
+ tenzir_test/config.py,sha256=bVuMJlEvevZxCmvzTJ4bs4SkYLNGZtxcCDYhtC-0sp8,1697
6
5
  tenzir_test/packages.py,sha256=cTCQdGjCS1XmuKyiwh0ew-z9tHn6J-xZ6nvBP-hU8bc,948
7
6
  tenzir_test/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- tenzir_test/run.py,sha256=LE23W07n6O8TnUnkXJArvfwvLI1HYcIu8WCV4lPW70o,125110
7
+ tenzir_test/run.py,sha256=OmW-qaWK6Y9vCAWZmnMczz-ycaleMa9jmiPcATpCoL8,133280
9
8
  tenzir_test/engine/__init__.py,sha256=5APwy90YDm7rmL_qCZfToAcfbQthcZ8yV2_ExXKqaqE,110
10
9
  tenzir_test/engine/operations.py,sha256=OCYjuMHyMAaay4s08u2Sl7oE-PmgeXumylp7R8GYIH4,950
11
10
  tenzir_test/engine/registry.py,sha256=LXCr6TGlv1sR1m1eboTk7SrbS2IVErc3PqUuHxGA2xk,594
12
11
  tenzir_test/engine/state.py,sha256=Ez-Q27dL5oNqiJE0A4P2OP0p6i7aYKzH4ZyVWNgEezs,889
13
12
  tenzir_test/engine/worker.py,sha256=WwIkx1m_ANNveQjNisy5-qpbUZl_DfDXxVIfdxABKjc,140
14
13
  tenzir_test/fixtures/__init__.py,sha256=PdkN334btlqagY-4wAwsCyfhHE8kd_RLNoVM_ULii_I,18384
15
- tenzir_test/fixtures/node.py,sha256=rLEzNff78r048KZmOanzGBCNg-OuJu9lQoKgGCArths,8197
14
+ tenzir_test/fixtures/node.py,sha256=Y3YqWeqYz-mg3B5V0wIthk55zVOd4JS37zlTdHzlA6c,11193
16
15
  tenzir_test/runners/__init__.py,sha256=M3p-TsDp231Dy58miDb467bA1kLYzgpa0pqVr_KP1ro,4616
17
16
  tenzir_test/runners/_utils.py,sha256=BWv7UEPGa01l4tGTCg5i_22NblIyRw8vjk_5NIf1x_c,467
18
17
  tenzir_test/runners/custom_python_fixture_runner.py,sha256=uXbSf18xTttf51k2EbjWf8AqN2R7TfpfSCkLyU5HJx4,7284
19
- tenzir_test/runners/diff_runner.py,sha256=ah1hr1vvD6BON2PZz61mxwioRFIzHFuaAbJ0DjDSqG4,5151
18
+ tenzir_test/runners/diff_runner.py,sha256=WIEry_-4R4VIlA9HON5kl2ipyy1TKzS4jRc2wPl6r5c,5476
20
19
  tenzir_test/runners/ext_runner.py,sha256=sKL9Mw_ksVVBWnrdIJR2WS5ueVnLKuNYYWZ22FTZIPo,730
21
20
  tenzir_test/runners/runner.py,sha256=LtlD8huQOSmD7RyYDnKeCuI4Y6vhxGXMKsHA2qgfWN0,989
22
21
  tenzir_test/runners/shell_runner.py,sha256=OuofgHeZN2FaO6xRI3uyqstLBymc6rmWC4HAnSn91AE,6068
23
22
  tenzir_test/runners/tenzir_runner.py,sha256=464FFYS_mh6l-ehccc-S8cIUO1MxdapwQL5X3PmMkMI,1006
24
23
  tenzir_test/runners/tql_runner.py,sha256=2ZLMf3TIKwcOvaOFrVvvhzK-EcWmGOUZxKkbSoByyQA,248
25
- tenzir_test-0.12.0.dist-info/METADATA,sha256=VjTW82AKGkRswsZB-0w5wsCWY7MPIrIzZEREINlgZrI,3008
26
- tenzir_test-0.12.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
27
- tenzir_test-0.12.0.dist-info/entry_points.txt,sha256=q0eD9RQ_9eMPYvFNpBElo55HQYeaPgLfe9YhLsNwl10,93
28
- tenzir_test-0.12.0.dist-info/licenses/LICENSE,sha256=ajMbpcBiSTXI8Rr4t17pvowV-On8DktghfZKxY_A22Q,10750
29
- tenzir_test-0.12.0.dist-info/RECORD,,
24
+ tenzir_test-0.15.0.dist-info/METADATA,sha256=MWDDBuFmf5BU8h1kBsj591GmYUPgv58-IZ357_iu0n8,3066
25
+ tenzir_test-0.15.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
26
+ tenzir_test-0.15.0.dist-info/entry_points.txt,sha256=l8DJgiEVrjScdTTo613cZ3PKodOmqrUVIbz-3awfV8w,53
27
+ tenzir_test-0.15.0.dist-info/licenses/LICENSE,sha256=ajMbpcBiSTXI8Rr4t17pvowV-On8DktghfZKxY_A22Q,10750
28
+ tenzir_test-0.15.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,3 +1,2 @@
1
1
  [console_scripts]
2
- check-release = tenzir_test.checks:main
3
2
  tenzir-test = tenzir_test.cli:main
tenzir_test/checks.py DELETED
@@ -1,31 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import shlex
4
- import subprocess
5
- from typing import Sequence
6
-
7
- _COMMANDS: Sequence[Sequence[str]] = (
8
- ("ruff", "check"),
9
- ("ruff", "format", "--check"),
10
- ("mypy",),
11
- ("pytest",),
12
- ("uv", "build"),
13
- )
14
-
15
-
16
- def _run(command: Sequence[str]) -> None:
17
- printable = " ".join(shlex.quote(part) for part in command)
18
- print(f"> {printable}")
19
- result = subprocess.run(command, check=False)
20
- if result.returncode != 0:
21
- raise SystemExit(result.returncode)
22
-
23
-
24
- def main() -> int:
25
- for command in _COMMANDS:
26
- _run(command)
27
- return 0
28
-
29
-
30
- if __name__ == "__main__":
31
- raise SystemExit(main())