tenzir-test 0.10.0__tar.gz → 0.11.0__tar.gz

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.
Files changed (83) hide show
  1. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/PKG-INFO +1 -1
  2. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/pyproject.toml +1 -1
  3. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/__init__.py +6 -0
  4. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/cli.py +31 -24
  5. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/run.py +181 -35
  6. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/tests/test_cli.py +61 -13
  7. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/tests/test_run.py +27 -2
  8. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/.gitignore +0 -0
  9. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/LICENSE +0 -0
  10. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/README.md +0 -0
  11. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/README.md +0 -0
  12. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/fixtures/README.md +0 -0
  13. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/fixtures/__init__.py +0 -0
  14. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/fixtures/http.py +0 -0
  15. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/fixtures/server.py +0 -0
  16. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/inputs/events.ndjson +0 -0
  17. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/runners/__init__.py +0 -0
  18. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/runners/xxd.py +0 -0
  19. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/context/01-context-create.tql +0 -0
  20. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/context/01-context-create.txt +0 -0
  21. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/context/02-context-update.tql +0 -0
  22. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/context/02-context-update.txt +0 -0
  23. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/context/03-context-inspect.tql +0 -0
  24. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/context/03-context-inspect.txt +0 -0
  25. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/context/test.yaml +0 -0
  26. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/fail.tql +0 -0
  27. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/fail.txt +0 -0
  28. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/hex/hello.txt +0 -0
  29. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/hex/hello.xxd +0 -0
  30. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/http-fixture.tql +0 -0
  31. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/http-fixture.txt +0 -0
  32. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/lazy.tql +0 -0
  33. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/lazy.txt +0 -0
  34. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/node-fixture.tql +0 -0
  35. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/node-fixture.txt +0 -0
  36. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/executor-only/sum.py +0 -0
  37. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/executor-only/sum.txt +0 -0
  38. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/executor-with-http-fixture/request.py +0 -0
  39. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/executor-with-http-fixture/request.txt +0 -0
  40. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/executor-with-http-fixture/test.yaml +0 -0
  41. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/executor-with-node-fixture/context-manager.py +0 -0
  42. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/executor-with-node-fixture/context-manager.txt +0 -0
  43. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/fixture-driving/manual_control.py +0 -0
  44. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/fixture-driving/manual_control.txt +0 -0
  45. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/pure-python/flaky_coin.py +0 -0
  46. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/pure-python/flaky_coin.txt +0 -0
  47. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/pure-python/hello_world.py +0 -0
  48. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/python/pure-python/hello_world.txt +0 -0
  49. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/read-inputs.tql +0 -0
  50. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/read-inputs.txt +0 -0
  51. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/shell/exit-code-test.sh +0 -0
  52. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/shell/exit-code-test.txt +0 -0
  53. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/shell/http-fixture-check.sh +0 -0
  54. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/shell/http-fixture-check.txt +0 -0
  55. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/shell/tmp-dir.sh +0 -0
  56. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/example-project/tests/shell/tmp-dir.txt +0 -0
  57. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/_python_runner.py +0 -0
  58. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/checks.py +0 -0
  59. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/config.py +0 -0
  60. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/engine/__init__.py +0 -0
  61. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/engine/operations.py +0 -0
  62. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/engine/registry.py +0 -0
  63. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/engine/state.py +0 -0
  64. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/engine/worker.py +0 -0
  65. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/fixtures/__init__.py +0 -0
  66. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/fixtures/node.py +0 -0
  67. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/packages.py +0 -0
  68. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/py.typed +0 -0
  69. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/runners/__init__.py +0 -0
  70. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/runners/_utils.py +0 -0
  71. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/runners/custom_python_fixture_runner.py +0 -0
  72. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/runners/diff_runner.py +0 -0
  73. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/runners/ext_runner.py +0 -0
  74. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/runners/runner.py +0 -0
  75. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/runners/shell_runner.py +0 -0
  76. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/runners/tenzir_runner.py +0 -0
  77. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/src/tenzir_test/runners/tql_runner.py +0 -0
  78. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/tests/test_config.py +0 -0
  79. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/tests/test_engine_operations.py +0 -0
  80. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/tests/test_python_runner.py +0 -0
  81. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/tests/test_run_config.py +0 -0
  82. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/tests/test_runner_registry.py +0 -0
  83. {tenzir_test-0.10.0 → tenzir_test-0.11.0}/tests/test_shell_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tenzir-test
3
- Version: 0.10.0
3
+ Version: 0.11.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tenzir-test"
3
- version = "0.10.0"
3
+ version = "0.11.0"
4
4
  description = "Reusable test execution framework extracted from the Tenzir repository."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -3,6 +3,7 @@
3
3
  from importlib.metadata import PackageNotFoundError, version
4
4
 
5
5
  from . import run
6
+ from .run import ExecutionResult, HarnessError, ProjectResult, ensure_settings, execute
6
7
  from .config import Settings, discover_settings
7
8
  from .fixtures import (
8
9
  Executor,
@@ -32,9 +33,14 @@ __all__ = [
32
33
  "fixtures",
33
34
  "Settings",
34
35
  "discover_settings",
36
+ "ensure_settings",
37
+ "execute",
35
38
  "has",
36
39
  "register",
37
40
  "require",
41
+ "ExecutionResult",
42
+ "ProjectResult",
43
+ "HarnessError",
38
44
  "run",
39
45
  ]
40
46
 
@@ -152,33 +152,39 @@ def cli(
152
152
  jobs: int,
153
153
  passthrough: bool,
154
154
  all_projects: bool,
155
- ) -> None:
155
+ ) -> int:
156
156
  """Execute tenzir-test scenarios."""
157
157
 
158
158
  jobs_source = ctx.get_parameter_source("jobs")
159
159
  jobs_overridden = jobs_source is not click.core.ParameterSource.DEFAULT
160
160
 
161
- runtime.run_cli(
162
- root=root,
163
- tenzir_binary=tenzir_binary,
164
- tenzir_node_binary=tenzir_node_binary,
165
- tests=list(tests),
166
- update=update,
167
- debug=debug,
168
- purge=purge,
169
- coverage=coverage,
170
- coverage_source_dir=coverage_source_dir,
171
- runner_summary=runner_summary,
172
- fixture_summary=fixture_summary,
173
- show_summary=show_summary,
174
- show_diff_output=show_diff_output,
175
- show_diff_stat=show_diff_stat,
176
- keep_tmp_dirs=keep_tmp_dirs,
177
- jobs=jobs,
178
- passthrough=passthrough,
179
- jobs_overridden=jobs_overridden,
180
- all_projects=all_projects,
181
- )
161
+ try:
162
+ result = runtime.run_cli(
163
+ root=root,
164
+ tenzir_binary=tenzir_binary,
165
+ tenzir_node_binary=tenzir_node_binary,
166
+ tests=list(tests),
167
+ update=update,
168
+ debug=debug,
169
+ purge=purge,
170
+ coverage=coverage,
171
+ coverage_source_dir=coverage_source_dir,
172
+ runner_summary=runner_summary,
173
+ fixture_summary=fixture_summary,
174
+ show_summary=show_summary,
175
+ show_diff_output=show_diff_output,
176
+ show_diff_stat=show_diff_stat,
177
+ keep_tmp_dirs=keep_tmp_dirs,
178
+ jobs=jobs,
179
+ passthrough=passthrough,
180
+ jobs_overridden=jobs_overridden,
181
+ all_projects=all_projects,
182
+ )
183
+ except runtime.HarnessError as exc:
184
+ if exc.show_message and exc.args:
185
+ raise click.ClickException(str(exc)) from exc
186
+ return exc.exit_code
187
+ return result.exit_code
182
188
 
183
189
 
184
190
  def main(argv: Sequence[str] | None = None) -> int:
@@ -186,7 +192,7 @@ def main(argv: Sequence[str] | None = None) -> int:
186
192
 
187
193
  command_main = getattr(cli, "main")
188
194
  try:
189
- command_main(
195
+ result = command_main(
190
196
  args=list(argv) if argv is not None else None,
191
197
  standalone_mode=False,
192
198
  )
@@ -198,7 +204,8 @@ def main(argv: Sequence[str] | None = None) -> int:
198
204
  return _normalize_exit_code(exit_code)
199
205
  except SystemExit as exc: # pragma: no cover - propagate runner exits
200
206
  return _normalize_exit_code(exc.code)
201
- return 0
207
+ else:
208
+ return _normalize_exit_code(result)
202
209
 
203
210
 
204
211
  if __name__ == "__main__":
@@ -151,12 +151,13 @@ def detect_execution_mode(root: Path) -> tuple[ExecutionMode, Path | None]:
151
151
  return ExecutionMode.PROJECT, None
152
152
 
153
153
 
154
- _settings = discover_settings()
155
- TENZIR_BINARY = _settings.tenzir_binary
156
- TENZIR_NODE_BINARY = _settings.tenzir_node_binary
157
- ROOT = _settings.root
158
- INPUTS_DIR = _settings.inputs_dir
159
- EXECUTION_MODE, _DETECTED_PACKAGE_ROOT = detect_execution_mode(ROOT)
154
+ _settings: Settings | None = None
155
+ TENZIR_BINARY: str | None = None
156
+ TENZIR_NODE_BINARY: str | None = None
157
+ ROOT: Path = Path.cwd()
158
+ INPUTS_DIR: Path = ROOT / "inputs"
159
+ EXECUTION_MODE: ExecutionMode = ExecutionMode.PROJECT
160
+ _DETECTED_PACKAGE_ROOT: Path | None = None
160
161
  HARNESS_MODE = HarnessMode.COMPARE
161
162
  CHECKMARK = "\033[92;1m✔\033[0m"
162
163
  CROSS = "\033[31m✘\033[0m"
@@ -1459,6 +1460,14 @@ def _looks_like_project_root(path: Path) -> bool:
1459
1460
  return False
1460
1461
 
1461
1462
 
1463
+ def ensure_settings() -> Settings:
1464
+ """Return the active harness settings, discovering defaults on first use."""
1465
+
1466
+ if _settings is None:
1467
+ apply_settings(discover_settings())
1468
+ return cast(Settings, _settings)
1469
+
1470
+
1462
1471
  def apply_settings(settings: Settings) -> None:
1463
1472
  global TENZIR_BINARY, TENZIR_NODE_BINARY
1464
1473
  global _settings
@@ -1826,6 +1835,31 @@ class Summary:
1826
1835
  stats.failed += 1
1827
1836
 
1828
1837
 
1838
+ @dataclasses.dataclass(slots=True)
1839
+ class ProjectResult:
1840
+ selection: ProjectSelection
1841
+ summary: Summary
1842
+ queue_size: int
1843
+
1844
+
1845
+ @dataclasses.dataclass(slots=True)
1846
+ class ExecutionResult:
1847
+ summary: Summary
1848
+ project_results: tuple[ProjectResult, ...]
1849
+ queue_size: int
1850
+ exit_code: int
1851
+ interrupted: bool
1852
+
1853
+
1854
+ class HarnessError(RuntimeError):
1855
+ """Fatal harness error signalling invalid invocation or configuration."""
1856
+
1857
+ def __init__(self, message: str, *, exit_code: int = 1, show_message: bool = True) -> None:
1858
+ super().__init__(message)
1859
+ self.exit_code = exit_code
1860
+ self.show_message = show_message
1861
+
1862
+
1829
1863
  def _format_percentage(count: int, total: int) -> str:
1830
1864
  return f"{_percentage_value(count, total)}%"
1831
1865
 
@@ -1919,7 +1953,7 @@ def _build_queue_from_paths(
1919
1953
  try:
1920
1954
  runner = get_runner_for_test(test_path)
1921
1955
  except ValueError as error:
1922
- sys.exit(f"error: {error}")
1956
+ raise HarnessError(f"error: {error}") from error
1923
1957
 
1924
1958
  suite_info = _resolve_suite_for_test(test_path)
1925
1959
  test_item = TestQueueItem(runner=runner, path=test_path)
@@ -1951,7 +1985,7 @@ def _build_queue_from_paths(
1951
1985
  location_detail = (
1952
1986
  f" ({_relativize_path(mismatch_path)})" if mismatch_path is not None else ""
1953
1987
  )
1954
- sys.exit(
1988
+ raise HarnessError(
1955
1989
  f"error: suite '{suite_info.name}' defined in {config_path} must use identical fixtures "
1956
1990
  f"across tests (expected: {expected_list}, found: {example_list}{location_detail})"
1957
1991
  )
@@ -3018,7 +3052,8 @@ def run_cli(
3018
3052
  passthrough: bool,
3019
3053
  jobs_overridden: bool = False,
3020
3054
  all_projects: bool = False,
3021
- ) -> None:
3055
+ ) -> ExecutionResult:
3056
+ """Execute the harness and return a structured result for library consumers."""
3022
3057
  from tenzir_test.engine import state as engine_state
3023
3058
 
3024
3059
  try:
@@ -3098,25 +3133,31 @@ def run_cli(
3098
3133
 
3099
3134
  if not _is_project_root(ROOT):
3100
3135
  if all_projects:
3101
- sys.exit("error: --all-projects requires a project root; specify one with --root")
3136
+ raise HarnessError(
3137
+ "error: --all-projects requires a project root; specify one with --root"
3138
+ )
3102
3139
  if not selected_tests:
3103
- print(
3140
+ message = (
3104
3141
  f"{INFO} no tenzir-test project detected at {ROOT}.\n"
3105
3142
  f"{INFO} run from your project root or provide --root."
3106
3143
  )
3107
- sys.exit(1)
3144
+ print(message)
3145
+ raise HarnessError(message, show_message=False)
3108
3146
  assert plan is not None
3109
3147
  runnable_satellites = [item for item in plan.satellites if item.should_run()]
3110
3148
  if not runnable_satellites:
3111
- print(
3149
+ message = (
3112
3150
  f"{INFO} no tenzir-test project detected at {ROOT}.\n"
3113
3151
  f"{INFO} run from your project root or provide --root."
3114
3152
  )
3153
+ print(message)
3115
3154
  sample = ", ".join(str(path) for path in selected_tests[:3])
3116
3155
  if len(selected_tests) > 3:
3117
3156
  sample += ", ..."
3118
3157
  print(f"{INFO} ignoring provided selection(s): {sample}")
3119
- sys.exit(1)
3158
+ raise HarnessError(
3159
+ "no runnable tests selected outside of a project root", show_message=False
3160
+ )
3120
3161
  if plan is None:
3121
3162
  plan = _build_execution_plan(
3122
3163
  ROOT,
@@ -3135,7 +3176,9 @@ def run_cli(
3135
3176
  overall_summary = Summary()
3136
3177
  overall_queue_count = 0
3137
3178
  executed_projects: list[ProjectSelection] = []
3179
+ project_results: list[ProjectResult] = []
3138
3180
  printed_projects = 0
3181
+ interrupted = False
3139
3182
 
3140
3183
  for selection in plan.projects():
3141
3184
  if interrupt_requested():
@@ -3148,7 +3191,7 @@ def run_cli(
3148
3191
  _load_project_runners(selection.root, expose_namespace=True)
3149
3192
  _load_project_fixtures(selection.root, expose_namespace=True)
3150
3193
  except RuntimeError as exc:
3151
- sys.exit(f"error: {exc}")
3194
+ raise HarnessError(f"error: {exc}") from exc
3152
3195
  refresh_runner_metadata()
3153
3196
  _set_project_root(settings.root)
3154
3197
  engine_state.refresh()
@@ -3164,7 +3207,7 @@ def run_cli(
3164
3207
  _load_project_runners(selection.root, expose_namespace=expose_namespace)
3165
3208
  _load_project_fixtures(selection.root, expose_namespace=expose_namespace)
3166
3209
  except RuntimeError as exc:
3167
- sys.exit(f"error: {exc}")
3210
+ raise HarnessError(f"error: {exc}") from exc
3168
3211
  refresh_runner_metadata()
3169
3212
 
3170
3213
  tests_to_run = selection.selectors if not selection.run_all else [selection.root]
@@ -3184,14 +3227,16 @@ def run_cli(
3184
3227
 
3185
3228
  resolved = test.resolve()
3186
3229
  if not resolved.exists():
3187
- sys.exit(f"error: test path `{test}` does not exist")
3230
+ raise HarnessError(f"error: test path `{test}` does not exist")
3188
3231
 
3189
3232
  if resolved.is_dir():
3190
3233
  if _is_inputs_path(resolved):
3191
3234
  continue
3192
3235
  tql_files = list(collect_all_tests(resolved))
3193
3236
  if not tql_files:
3194
- sys.exit(f"error: no {_allowed_extensions} files found in {resolved}")
3237
+ raise HarnessError(
3238
+ f"error: no {_allowed_extensions} files found in {resolved}"
3239
+ )
3195
3240
  for file_path in tql_files:
3196
3241
  suite_info = _resolve_suite_for_test(file_path)
3197
3242
  if suite_info is None:
@@ -3211,7 +3256,10 @@ def run_cli(
3211
3256
  f"{INFO} select the suite directory instead",
3212
3257
  file=sys.stderr,
3213
3258
  )
3214
- sys.exit(1)
3259
+ raise HarnessError(
3260
+ f"invalid partial suite selection at {rel_target}",
3261
+ show_message=False,
3262
+ )
3215
3263
  for file_path in tql_files:
3216
3264
  collected_paths.add(file_path.resolve())
3217
3265
  elif resolved.is_file():
@@ -3229,18 +3277,18 @@ def run_cli(
3229
3277
  f"'{suite_info.name}' defined in {rel_suite / _CONFIG_FILE_NAME}."
3230
3278
  )
3231
3279
  print(f"{CROSS} {detail}", file=sys.stderr)
3232
- print(
3233
- f"{INFO} select the suite directory instead",
3234
- file=sys.stderr,
3280
+ print(f"{INFO} select the suite directory instead", file=sys.stderr)
3281
+ raise HarnessError(
3282
+ f"invalid suite selection for {rel_file}",
3283
+ show_message=False,
3235
3284
  )
3236
- sys.exit(1)
3237
3285
  collected_paths.add(resolved.resolve())
3238
3286
  else:
3239
- sys.exit(
3287
+ raise HarnessError(
3240
3288
  f"error: unsupported file type {resolved.suffix} for {resolved} - only {_allowed_extensions} files are supported"
3241
3289
  )
3242
3290
  else:
3243
- sys.exit(f"error: `{test}` is neither a file nor a directory")
3291
+ raise HarnessError(f"error: `{test}` is neither a file nor a directory")
3244
3292
 
3245
3293
  if interrupt_requested():
3246
3294
  break
@@ -3248,6 +3296,7 @@ def run_cli(
3248
3296
  queue = _build_queue_from_paths(collected_paths, coverage=coverage)
3249
3297
  queue.sort(key=_queue_sort_key, reverse=True)
3250
3298
  project_queue_size = _count_queue_tests(queue)
3299
+ project_summary = Summary()
3251
3300
  job_count, enabled_flags, verb = _summarize_harness_configuration(
3252
3301
  jobs=jobs,
3253
3302
  update=update,
@@ -3262,15 +3311,26 @@ def run_cli(
3262
3311
  if not project_queue_size:
3263
3312
  overall_queue_count += project_queue_size
3264
3313
  executed_projects.append(selection)
3314
+ project_results.append(
3315
+ ProjectResult(
3316
+ selection=selection,
3317
+ summary=project_summary,
3318
+ queue_size=project_queue_size,
3319
+ )
3320
+ )
3265
3321
  continue
3266
3322
 
3267
3323
  os.environ["TENZIR_EXEC__DUMP_DIAGNOSTICS"] = "true"
3268
3324
  if not TENZIR_BINARY:
3269
- sys.exit(f"error: could not find TENZIR_BINARY executable `{TENZIR_BINARY}`")
3325
+ raise HarnessError(
3326
+ f"error: could not find TENZIR_BINARY executable `{TENZIR_BINARY}`"
3327
+ )
3270
3328
  try:
3271
3329
  tenzir_version = get_version()
3272
3330
  except FileNotFoundError:
3273
- sys.exit(f"error: could not find TENZIR_BINARY executable `{TENZIR_BINARY}`")
3331
+ raise HarnessError(
3332
+ f"error: could not find TENZIR_BINARY executable `{TENZIR_BINARY}`"
3333
+ )
3274
3334
 
3275
3335
  runner_versions = _collect_runner_versions(queue, tenzir_version=tenzir_version)
3276
3336
  runner_breakdown = _runner_breakdown(
@@ -3303,7 +3363,6 @@ def run_cli(
3303
3363
  )
3304
3364
  for _ in range(jobs)
3305
3365
  ]
3306
- project_summary = Summary()
3307
3366
  for worker in workers:
3308
3367
  worker.start()
3309
3368
  try:
@@ -3313,6 +3372,7 @@ def run_cli(
3313
3372
  _request_interrupt()
3314
3373
  for worker in workers:
3315
3374
  worker.join()
3375
+ interrupted = True
3316
3376
  break
3317
3377
 
3318
3378
  _print_compact_summary(project_summary)
@@ -3336,6 +3396,13 @@ def run_cli(
3336
3396
  overall_summary += project_summary
3337
3397
  overall_queue_count += project_queue_size
3338
3398
  executed_projects.append(selection)
3399
+ project_results.append(
3400
+ ProjectResult(
3401
+ selection=selection,
3402
+ summary=project_summary,
3403
+ queue_size=project_queue_size,
3404
+ )
3405
+ )
3339
3406
 
3340
3407
  if interrupt_requested():
3341
3408
  break
@@ -3347,39 +3414,118 @@ def run_cli(
3347
3414
  if purge:
3348
3415
  for runner in runners_iter_runners():
3349
3416
  runner.purge()
3350
- return
3417
+ return ExecutionResult(
3418
+ summary=overall_summary,
3419
+ project_results=tuple(project_results),
3420
+ queue_size=overall_queue_count,
3421
+ exit_code=0,
3422
+ interrupted=interrupted,
3423
+ )
3351
3424
 
3352
3425
  if overall_queue_count == 0:
3353
3426
  print(f"{INFO} no tests selected")
3354
- return
3427
+ return ExecutionResult(
3428
+ summary=overall_summary,
3429
+ project_results=tuple(project_results),
3430
+ queue_size=overall_queue_count,
3431
+ exit_code=0,
3432
+ interrupted=interrupted,
3433
+ )
3355
3434
 
3356
3435
  if len(executed_projects) > 1:
3357
3436
  _print_aggregate_totals(len(executed_projects), overall_summary)
3358
3437
 
3359
- if interrupt_requested():
3360
- sys.exit(130)
3438
+ if interrupted:
3439
+ return ExecutionResult(
3440
+ summary=overall_summary,
3441
+ project_results=tuple(project_results),
3442
+ queue_size=overall_queue_count,
3443
+ exit_code=130,
3444
+ interrupted=True,
3445
+ )
3361
3446
 
3362
- if overall_summary.failed > 0:
3363
- sys.exit(1)
3447
+ exit_code = 1 if overall_summary.failed > 0 else 0
3448
+ return ExecutionResult(
3449
+ summary=overall_summary,
3450
+ project_results=tuple(project_results),
3451
+ queue_size=overall_queue_count,
3452
+ exit_code=exit_code,
3453
+ interrupted=False,
3454
+ )
3364
3455
 
3365
3456
  finally:
3366
3457
  _cleanup_all_tmp_dirs()
3367
3458
 
3368
3459
 
3460
+ def execute(
3461
+ *,
3462
+ root: Path | None = None,
3463
+ tenzir_binary: Path | None = None,
3464
+ tenzir_node_binary: Path | None = None,
3465
+ tests: Sequence[Path] = (),
3466
+ update: bool = False,
3467
+ debug: bool = False,
3468
+ purge: bool = False,
3469
+ coverage: bool = False,
3470
+ coverage_source_dir: Path | None = None,
3471
+ runner_summary: bool = False,
3472
+ fixture_summary: bool = False,
3473
+ show_summary: bool = False,
3474
+ show_diff_output: bool = True,
3475
+ show_diff_stat: bool = True,
3476
+ jobs: int | None = None,
3477
+ keep_tmp_dirs: bool = False,
3478
+ passthrough: bool = False,
3479
+ jobs_overridden: bool = False,
3480
+ all_projects: bool = False,
3481
+ ) -> ExecutionResult:
3482
+ """Library-oriented wrapper around `run_cli` with defaulted parameters."""
3483
+
3484
+ resolved_jobs = jobs if jobs is not None else get_default_jobs()
3485
+ return run_cli(
3486
+ root=root,
3487
+ tenzir_binary=tenzir_binary,
3488
+ tenzir_node_binary=tenzir_node_binary,
3489
+ tests=list(tests),
3490
+ update=update,
3491
+ debug=debug,
3492
+ purge=purge,
3493
+ coverage=coverage,
3494
+ coverage_source_dir=coverage_source_dir,
3495
+ runner_summary=runner_summary,
3496
+ fixture_summary=fixture_summary,
3497
+ show_summary=show_summary,
3498
+ show_diff_output=show_diff_output,
3499
+ show_diff_stat=show_diff_stat,
3500
+ jobs=resolved_jobs,
3501
+ keep_tmp_dirs=keep_tmp_dirs,
3502
+ passthrough=passthrough,
3503
+ jobs_overridden=jobs_overridden,
3504
+ all_projects=all_projects,
3505
+ )
3506
+
3507
+
3369
3508
  def main(argv: Sequence[str] | None = None) -> None:
3370
3509
  import click
3371
3510
 
3372
3511
  from . import cli as cli_module
3373
3512
 
3374
3513
  try:
3375
- cli_module.cli.main(
3514
+ result = cli_module.cli.main(
3376
3515
  args=list(argv) if argv is not None else None,
3377
3516
  standalone_mode=False,
3378
3517
  )
3518
+ except click.exceptions.ClickException as exc:
3519
+ exc.show(file=sys.stderr)
3520
+ exit_code = getattr(exc, "exit_code", 1)
3521
+ raise SystemExit(exit_code) from exc
3379
3522
  except click.exceptions.Exit as exc:
3380
3523
  raise SystemExit(exc.exit_code) from exc
3381
3524
  except click.exceptions.Abort as exc:
3382
3525
  raise SystemExit(1) from exc
3526
+ exit_code = cli_module._normalize_exit_code(result)
3527
+ if exit_code:
3528
+ raise SystemExit(exit_code)
3383
3529
 
3384
3530
 
3385
3531
  if __name__ == "__main__":
@@ -3,12 +3,22 @@ from __future__ import annotations
3
3
  import pytest
4
4
  import tenzir_test
5
5
 
6
- from tenzir_test import cli
6
+ from tenzir_test import cli, run
7
+
8
+
9
+ def _make_result(exit_code: int = 0, *, interrupted: bool = False) -> run.ExecutionResult:
10
+ return run.ExecutionResult(
11
+ summary=run.Summary(),
12
+ project_results=tuple(),
13
+ queue_size=0,
14
+ exit_code=exit_code,
15
+ interrupted=interrupted,
16
+ )
7
17
 
8
18
 
9
19
  def test_cli_returns_exit_code(monkeypatch: pytest.MonkeyPatch) -> None:
10
- def fake_run_cli(**_: object) -> None:
11
- raise SystemExit(5)
20
+ def fake_run_cli(**_: object) -> run.ExecutionResult:
21
+ return _make_result(5)
12
22
 
13
23
  monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
14
24
 
@@ -16,19 +26,50 @@ def test_cli_returns_exit_code(monkeypatch: pytest.MonkeyPatch) -> None:
16
26
 
17
27
 
18
28
  def test_cli_handles_success(monkeypatch: pytest.MonkeyPatch) -> None:
19
- def fake_run_cli(**_: object) -> None:
20
- raise SystemExit(None)
29
+ def fake_run_cli(**_: object) -> run.ExecutionResult:
30
+ return _make_result()
21
31
 
22
32
  monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
23
33
 
24
34
  assert cli.main([]) == 0
25
35
 
26
36
 
37
+ def test_cli_harness_error_with_message(
38
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
39
+ ) -> None:
40
+ def fake_run_cli(**_: object) -> run.ExecutionResult:
41
+ raise run.HarnessError("boom")
42
+
43
+ monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
44
+
45
+ exit_code = cli.main([])
46
+ captured = capsys.readouterr()
47
+ assert exit_code == 1
48
+ assert "boom" in captured.err
49
+
50
+
51
+ def test_cli_harness_error_without_message(
52
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
53
+ ) -> None:
54
+ def fake_run_cli(**_: object) -> run.ExecutionResult:
55
+ print("already reported") # emulate harness printing details
56
+ raise run.HarnessError("already reported", show_message=False)
57
+
58
+ monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
59
+
60
+ exit_code = cli.main([])
61
+ captured = capsys.readouterr()
62
+ assert exit_code == 1
63
+ assert captured.err == ""
64
+ assert "already reported" in captured.out
65
+
66
+
27
67
  def test_cli_keep_flag(monkeypatch: pytest.MonkeyPatch) -> None:
28
68
  captured: dict[str, object] = {}
29
69
 
30
- def fake_run_cli(**kwargs: object) -> None:
70
+ def fake_run_cli(**kwargs: object) -> run.ExecutionResult:
31
71
  captured.update(kwargs)
72
+ return _make_result()
32
73
 
33
74
  monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
34
75
 
@@ -44,8 +85,9 @@ def test_cli_keep_flag(monkeypatch: pytest.MonkeyPatch) -> None:
44
85
  def test_cli_passthrough_flag(monkeypatch: pytest.MonkeyPatch) -> None:
45
86
  captured: dict[str, object] = {}
46
87
 
47
- def fake_run_cli(**kwargs: object) -> None:
88
+ def fake_run_cli(**kwargs: object) -> run.ExecutionResult:
48
89
  captured.update(kwargs)
90
+ return _make_result()
49
91
 
50
92
  monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
51
93
 
@@ -59,8 +101,9 @@ def test_cli_passthrough_flag(monkeypatch: pytest.MonkeyPatch) -> None:
59
101
  def test_cli_passthrough_jobs(monkeypatch: pytest.MonkeyPatch) -> None:
60
102
  captured: dict[str, object] = {}
61
103
 
62
- def fake_run_cli(**kwargs: object) -> None:
104
+ def fake_run_cli(**kwargs: object) -> run.ExecutionResult:
63
105
  captured.update(kwargs)
106
+ return _make_result()
64
107
 
65
108
  monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
66
109
 
@@ -75,8 +118,9 @@ def test_cli_passthrough_jobs(monkeypatch: pytest.MonkeyPatch) -> None:
75
118
  def test_cli_all_projects_flag(monkeypatch: pytest.MonkeyPatch) -> None:
76
119
  captured: dict[str, object] = {}
77
120
 
78
- def fake_run_cli(**kwargs: object) -> None:
121
+ def fake_run_cli(**kwargs: object) -> run.ExecutionResult:
79
122
  captured.update(kwargs)
123
+ return _make_result()
80
124
 
81
125
  monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
82
126
 
@@ -88,8 +132,9 @@ def test_cli_all_projects_flag(monkeypatch: pytest.MonkeyPatch) -> None:
88
132
  def test_cli_debug_flag(monkeypatch: pytest.MonkeyPatch) -> None:
89
133
  captured: dict[str, object] = {}
90
134
 
91
- def fake_run_cli(**kwargs: object) -> None:
135
+ def fake_run_cli(**kwargs: object) -> run.ExecutionResult:
92
136
  captured.update(kwargs)
137
+ return _make_result()
93
138
 
94
139
  monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
95
140
 
@@ -101,8 +146,9 @@ def test_cli_debug_flag(monkeypatch: pytest.MonkeyPatch) -> None:
101
146
  def test_cli_summary_flag(monkeypatch: pytest.MonkeyPatch) -> None:
102
147
  captured: dict[str, object] = {}
103
148
 
104
- def fake_run_cli(**kwargs: object) -> None:
149
+ def fake_run_cli(**kwargs: object) -> run.ExecutionResult:
105
150
  captured.update(kwargs)
151
+ return _make_result()
106
152
 
107
153
  monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
108
154
 
@@ -113,8 +159,9 @@ def test_cli_summary_flag(monkeypatch: pytest.MonkeyPatch) -> None:
113
159
  def test_cli_no_diff_flag(monkeypatch: pytest.MonkeyPatch) -> None:
114
160
  captured: dict[str, object] = {}
115
161
 
116
- def fake_run_cli(**kwargs: object) -> None:
162
+ def fake_run_cli(**kwargs: object) -> run.ExecutionResult:
117
163
  captured.update(kwargs)
164
+ return _make_result()
118
165
 
119
166
  monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
120
167
 
@@ -126,8 +173,9 @@ def test_cli_no_diff_flag(monkeypatch: pytest.MonkeyPatch) -> None:
126
173
  def test_cli_no_diff_stat_flag(monkeypatch: pytest.MonkeyPatch) -> None:
127
174
  captured: dict[str, object] = {}
128
175
 
129
- def fake_run_cli(**kwargs: object) -> None:
176
+ def fake_run_cli(**kwargs: object) -> run.ExecutionResult:
130
177
  captured.update(kwargs)
178
+ return _make_result()
131
179
 
132
180
  monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
133
181
 
@@ -100,6 +100,29 @@ def test_main_accepts_current_directory_selection_without_project_root(
100
100
  assert lines == [f"{run.INFO} no tests selected"]
101
101
 
102
102
 
103
+ def test_execute_delegates_to_run_cli(monkeypatch: pytest.MonkeyPatch) -> None:
104
+ captured: dict[str, object] = {}
105
+
106
+ def fake_run_cli(**kwargs: object) -> run.ExecutionResult:
107
+ captured.update(kwargs)
108
+ return run.ExecutionResult(
109
+ summary=run.Summary(),
110
+ project_results=tuple(),
111
+ queue_size=0,
112
+ exit_code=0,
113
+ interrupted=False,
114
+ )
115
+
116
+ monkeypatch.setattr(run, "run_cli", fake_run_cli)
117
+
118
+ result = run.execute(tests=[Path("sample.tql")], jobs=2, update=True)
119
+
120
+ assert isinstance(result, run.ExecutionResult)
121
+ assert captured["tests"] == [Path("sample.tql")]
122
+ assert captured["jobs"] == 2
123
+ assert captured["update"] is True
124
+
125
+
103
126
  def test_format_summary_reports_counts_and_percentages() -> None:
104
127
  summary = run.Summary(failed=1, total=357, skipped=3)
105
128
  message = run._format_summary(summary)
@@ -755,7 +778,7 @@ def test_cli_rejects_partial_suite_selection(
755
778
  )
756
779
  run.refresh_runner_metadata()
757
780
  try:
758
- with pytest.raises(SystemExit):
781
+ with pytest.raises(run.HarnessError) as exc_info:
759
782
  run.run_cli(
760
783
  root=project_root,
761
784
  tenzir_binary=None,
@@ -777,11 +800,12 @@ def test_cli_rejects_partial_suite_selection(
777
800
  jobs_overridden=False,
778
801
  all_projects=False,
779
802
  )
803
+ assert exc_info.value.exit_code == 1
780
804
 
781
805
  first_err = capsys.readouterr().err
782
806
  assert "belongs to the suite" in first_err
783
807
 
784
- with pytest.raises(SystemExit):
808
+ with pytest.raises(run.HarnessError) as exc_info:
785
809
  run.run_cli(
786
810
  root=project_root,
787
811
  tenzir_binary=None,
@@ -803,6 +827,7 @@ def test_cli_rejects_partial_suite_selection(
803
827
  jobs_overridden=False,
804
828
  all_projects=False,
805
829
  )
830
+ assert exc_info.value.exit_code == 1
806
831
  second_err = capsys.readouterr().err
807
832
  assert "inside the suite" in second_err
808
833
  finally:
File without changes
File without changes
File without changes