tenzir-test 0.9.3__tar.gz → 0.9.5__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 (81) hide show
  1. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/PKG-INFO +1 -31
  2. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/README.md +0 -30
  3. tenzir_test-0.9.5/example-project/tests/fail.tql +3 -0
  4. tenzir_test-0.9.5/example-project/tests/fail.txt +28 -0
  5. tenzir_test-0.9.5/example-project/tests/shell/http-fixture-check.txt +0 -0
  6. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/pyproject.toml +1 -1
  7. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/cli.py +16 -0
  8. tenzir_test-0.9.5/src/tenzir_test/py.typed +0 -0
  9. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/run.py +144 -31
  10. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/tests/test_cli.py +26 -0
  11. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/tests/test_run.py +141 -0
  12. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/.gitignore +0 -0
  13. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/LICENSE +0 -0
  14. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/README.md +0 -0
  15. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/fixtures/README.md +0 -0
  16. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/fixtures/__init__.py +0 -0
  17. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/fixtures/http.py +0 -0
  18. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/fixtures/server.py +0 -0
  19. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/inputs/events.ndjson +0 -0
  20. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/runners/__init__.py +0 -0
  21. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/runners/xxd.py +0 -0
  22. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/context/01-context-create.tql +0 -0
  23. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/context/01-context-create.txt +0 -0
  24. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/context/02-context-update.tql +0 -0
  25. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/context/02-context-update.txt +0 -0
  26. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/context/03-context-inspect.tql +0 -0
  27. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/context/03-context-inspect.txt +0 -0
  28. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/context/test.yaml +0 -0
  29. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/hex/hello.txt +0 -0
  30. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/hex/hello.xxd +0 -0
  31. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/http-fixture.tql +0 -0
  32. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/http-fixture.txt +0 -0
  33. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/lazy.tql +0 -0
  34. /tenzir_test-0.9.3/src/tenzir_test/py.typed → /tenzir_test-0.9.5/example-project/tests/lazy.txt +0 -0
  35. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/node-fixture.tql +0 -0
  36. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/node-fixture.txt +0 -0
  37. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/executor-only/sum.py +0 -0
  38. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/executor-only/sum.txt +0 -0
  39. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/executor-with-http-fixture/request.py +0 -0
  40. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/executor-with-http-fixture/request.txt +0 -0
  41. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/executor-with-http-fixture/test.yaml +0 -0
  42. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/executor-with-node-fixture/context-manager.py +0 -0
  43. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/executor-with-node-fixture/context-manager.txt +0 -0
  44. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/fixture-driving/manual_control.py +0 -0
  45. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/fixture-driving/manual_control.txt +0 -0
  46. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/pure-python/flaky_coin.py +0 -0
  47. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/pure-python/flaky_coin.txt +0 -0
  48. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/pure-python/hello_world.py +0 -0
  49. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/python/pure-python/hello_world.txt +0 -0
  50. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/read-inputs.tql +0 -0
  51. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/read-inputs.txt +0 -0
  52. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/shell/http-fixture-check.sh +0 -0
  53. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/shell/tmp-dir.sh +0 -0
  54. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/example-project/tests/shell/tmp-dir.txt +0 -0
  55. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/__init__.py +0 -0
  56. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/_python_runner.py +0 -0
  57. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/checks.py +0 -0
  58. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/config.py +0 -0
  59. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/engine/__init__.py +0 -0
  60. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/engine/operations.py +0 -0
  61. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/engine/registry.py +0 -0
  62. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/engine/state.py +0 -0
  63. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/engine/worker.py +0 -0
  64. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/fixtures/__init__.py +0 -0
  65. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/fixtures/node.py +0 -0
  66. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/packages.py +0 -0
  67. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/runners/__init__.py +0 -0
  68. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/runners/_utils.py +0 -0
  69. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/runners/custom_python_fixture_runner.py +0 -0
  70. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/runners/diff_runner.py +0 -0
  71. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/runners/ext_runner.py +0 -0
  72. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/runners/runner.py +0 -0
  73. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/runners/shell_runner.py +0 -0
  74. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/runners/tenzir_runner.py +0 -0
  75. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/src/tenzir_test/runners/tql_runner.py +0 -0
  76. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/tests/test_config.py +0 -0
  77. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/tests/test_engine_operations.py +0 -0
  78. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/tests/test_python_runner.py +0 -0
  79. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/tests/test_run_config.py +0 -0
  80. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/tests/test_runner_registry.py +0 -0
  81. {tenzir_test-0.9.3 → tenzir_test-0.9.5}/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.9.3
3
+ Version: 0.9.5
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
@@ -55,36 +55,6 @@ uvx tenzir-test --help
55
55
  `uvx` downloads the newest compatible release, runs it in an isolated
56
56
  environment, and caches subsequent invocations for fast reuse.
57
57
 
58
- ## 🚀 Quick Start
59
-
60
- Create a project skeleton that mirrors the layout the harness expects:
61
-
62
- ```text
63
- project-root/
64
- ├── fixtures/
65
- │ └── http.py
66
- ├── inputs/
67
- │ └── sample.ndjson
68
- ├── runners/
69
- │ └── __init__.py
70
- └── tests/
71
- ├── alerts/
72
- │ ├── sample.py
73
- │ └── sample.txt
74
- └── regression/
75
- ├── dummy.tql
76
- └── dummy.txt
77
- ```
78
-
79
- 1. Author fixtures in `fixtures/` and register them at import time.
80
- 2. Store reusable datasets in `inputs/`—the harness exposes the path via
81
- `TENZIR_INPUTS` and provides a per-test scratch directory through
82
- `TENZIR_TMP_DIR` when tests execute.
83
- Use `--keep` (or `-k`) to preserve those temporary directories for debugging.
84
- 3. Create tests in `tests/` and pair them with reference artifacts (for example
85
- `.txt`) that the harness compares against.
86
- 4. Run `uvx tenzir-test` from the project root to execute the full suite.
87
-
88
58
  ## 📚 Documentation
89
59
 
90
60
  Consult our [user guide](https://docs.tenzir.com/guides/testing/write-tests)
@@ -26,36 +26,6 @@ uvx tenzir-test --help
26
26
  `uvx` downloads the newest compatible release, runs it in an isolated
27
27
  environment, and caches subsequent invocations for fast reuse.
28
28
 
29
- ## 🚀 Quick Start
30
-
31
- Create a project skeleton that mirrors the layout the harness expects:
32
-
33
- ```text
34
- project-root/
35
- ├── fixtures/
36
- │ └── http.py
37
- ├── inputs/
38
- │ └── sample.ndjson
39
- ├── runners/
40
- │ └── __init__.py
41
- └── tests/
42
- ├── alerts/
43
- │ ├── sample.py
44
- │ └── sample.txt
45
- └── regression/
46
- ├── dummy.tql
47
- └── dummy.txt
48
- ```
49
-
50
- 1. Author fixtures in `fixtures/` and register them at import time.
51
- 2. Store reusable datasets in `inputs/`—the harness exposes the path via
52
- `TENZIR_INPUTS` and provides a per-test scratch directory through
53
- `TENZIR_TMP_DIR` when tests execute.
54
- Use `--keep` (or `-k`) to preserve those temporary directories for debugging.
55
- 3. Create tests in `tests/` and pair them with reference artifacts (for example
56
- `.txt`) that the harness compares against.
57
- 4. Run `uvx tenzir-test` from the project root to execute the full suite.
58
-
59
29
  ## 📚 Documentation
60
30
 
61
31
  Consult our [user guide](https://docs.tenzir.com/guides/testing/write-tests)
@@ -0,0 +1,3 @@
1
+ from {x: "foo"}
2
+ repeat 10
3
+ enumerate
@@ -0,0 +1,28 @@
1
+ {
2
+ "#": 0,
3
+ x: "foo",
4
+ }
5
+ {
6
+ "#": 1,
7
+ x: "foo",
8
+ }
9
+ {
10
+ "#": -1,
11
+ x: "qux",
12
+ }
13
+ {
14
+ "#": 3,
15
+ x: "foo",
16
+ }
17
+ {
18
+ "#": 4,
19
+ x: "foo",
20
+ }
21
+ {
22
+ "#": -1,
23
+ x: "foo",
24
+ }
25
+ {
26
+ "#": -1,
27
+ x: "baz",
28
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tenzir-test"
3
- version = "0.9.3"
3
+ version = "0.9.5"
4
4
  description = "Reusable test execution framework extracted from the Tenzir repository."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -90,6 +90,18 @@ def _normalize_exit_code(value: object) -> int:
90
90
  is_flag=True,
91
91
  help="Show an aggregate table and detailed failure summary after execution.",
92
92
  )
93
+ @click.option(
94
+ "--diff/--no-diff",
95
+ "show_diff_output",
96
+ default=True,
97
+ help="Show unified diffs when expectations differ.",
98
+ )
99
+ @click.option(
100
+ "--diff-stat/--no-diff-stat",
101
+ "show_diff_stat",
102
+ default=True,
103
+ help="Include per-file diff statistics and change counters when expectations differ.",
104
+ )
93
105
  @click.option(
94
106
  "-k",
95
107
  "--keep",
@@ -134,6 +146,8 @@ def cli(
134
146
  runner_summary: bool,
135
147
  fixture_summary: bool,
136
148
  show_summary: bool,
149
+ show_diff_output: bool,
150
+ show_diff_stat: bool,
137
151
  keep_tmp_dirs: bool,
138
152
  jobs: int,
139
153
  passthrough: bool,
@@ -157,6 +171,8 @@ def cli(
157
171
  runner_summary=runner_summary,
158
172
  fixture_summary=fixture_summary,
159
173
  show_summary=show_summary,
174
+ show_diff_output=show_diff_output,
175
+ show_diff_stat=show_diff_stat,
160
176
  keep_tmp_dirs=keep_tmp_dirs,
161
177
  jobs=jobs,
162
178
  passthrough=passthrough,
File without changes
@@ -349,8 +349,33 @@ _TMP_ROOT_NAME = ".tenzir-test"
349
349
  _TMP_SUBDIR_NAME = "tmp"
350
350
  _TMP_BASE_DIRS: set[Path] = set()
351
351
  _ACTIVE_TMP_DIRS: set[Path] = set()
352
+ _TMP_DIR_LOCK = threading.Lock()
352
353
  KEEP_TMP_DIRS = bool(os.environ.get(_TMP_KEEP_ENV_VAR))
353
354
 
355
+ SHOW_DIFF_OUTPUT = True
356
+ SHOW_DIFF_STAT = True
357
+ _BLOCK_INDENT = ""
358
+ _PLUS_SYMBOLS = {1: "□", 10: "▣", 100: "■"}
359
+ _MINUS_SYMBOLS = {1: "□", 10: "▣", 100: "■"}
360
+
361
+
362
+ def set_show_diff_output(enabled: bool) -> None:
363
+ global SHOW_DIFF_OUTPUT
364
+ SHOW_DIFF_OUTPUT = enabled
365
+
366
+
367
+ def should_show_diff_output() -> bool:
368
+ return SHOW_DIFF_OUTPUT
369
+
370
+
371
+ def set_show_diff_stat(enabled: bool) -> None:
372
+ global SHOW_DIFF_STAT
373
+ SHOW_DIFF_STAT = enabled
374
+
375
+
376
+ def should_show_diff_stat() -> bool:
377
+ return SHOW_DIFF_STAT
378
+
354
379
 
355
380
  def set_harness_mode(mode: HarnessMode) -> None:
356
381
  """Set the global harness execution mode."""
@@ -482,10 +507,13 @@ def _tmp_prefix_for(test: Path) -> str:
482
507
 
483
508
 
484
509
  def _create_test_tmp_dir(test: Path) -> Path:
485
- base = _resolve_tmp_base()
486
510
  prefix = f"{_tmp_prefix_for(test)}-"
487
- path = Path(tempfile.mkdtemp(prefix=prefix, dir=str(base)))
488
- _ACTIVE_TMP_DIRS.add(path)
511
+ with _TMP_DIR_LOCK:
512
+ base = _resolve_tmp_base()
513
+ if not base.exists():
514
+ base.mkdir(parents=True, exist_ok=True)
515
+ path = Path(tempfile.mkdtemp(prefix=prefix, dir=str(base)))
516
+ _ACTIVE_TMP_DIRS.add(path)
489
517
  return path
490
518
 
491
519
 
@@ -498,22 +526,27 @@ def cleanup_test_tmp_dir(path: str | os.PathLike[str] | None) -> None:
498
526
  if not path:
499
527
  return
500
528
  tmp_path = Path(path)
501
- _ACTIVE_TMP_DIRS.discard(tmp_path)
529
+ with _TMP_DIR_LOCK:
530
+ _ACTIVE_TMP_DIRS.discard(tmp_path)
502
531
  try:
503
532
  fixtures_impl.invoke_tmp_dir_cleanup(tmp_path)
504
533
  except Exception: # pragma: no cover - defensive logging
505
534
  pass
506
535
  if KEEP_TMP_DIRS:
507
536
  return
508
- if tmp_path.exists():
509
- shutil.rmtree(tmp_path, ignore_errors=True)
510
- _cleanup_tmp_base_dirs()
537
+ with _TMP_DIR_LOCK:
538
+ if tmp_path.exists():
539
+ shutil.rmtree(tmp_path, ignore_errors=True)
540
+ _cleanup_tmp_base_dirs()
511
541
 
512
542
 
513
543
  def _cleanup_remaining_tmp_dirs() -> None:
514
- for tmp_path in list(_ACTIVE_TMP_DIRS):
544
+ with _TMP_DIR_LOCK:
545
+ remaining = list(_ACTIVE_TMP_DIRS)
546
+ for tmp_path in remaining:
515
547
  cleanup_test_tmp_dir(tmp_path)
516
- _cleanup_tmp_base_dirs()
548
+ with _TMP_DIR_LOCK:
549
+ _cleanup_tmp_base_dirs()
517
550
 
518
551
 
519
552
  def _cleanup_all_tmp_dirs() -> None:
@@ -521,9 +554,12 @@ def _cleanup_all_tmp_dirs() -> None:
521
554
 
522
555
  if KEEP_TMP_DIRS:
523
556
  return
524
- for tmp_path in list(_ACTIVE_TMP_DIRS):
557
+ with _TMP_DIR_LOCK:
558
+ remaining = list(_ACTIVE_TMP_DIRS)
559
+ for tmp_path in remaining:
525
560
  cleanup_test_tmp_dir(tmp_path)
526
- _cleanup_tmp_base_dirs()
561
+ with _TMP_DIR_LOCK:
562
+ _cleanup_tmp_base_dirs()
527
563
 
528
564
 
529
565
  def _cleanup_tmp_base_dirs() -> None:
@@ -2015,10 +2051,9 @@ def _summarize_harness_configuration(
2015
2051
  runner_summary: bool,
2016
2052
  fixture_summary: bool,
2017
2053
  passthrough: bool,
2018
- ) -> tuple[int, str]:
2054
+ ) -> tuple[int, str, str]:
2019
2055
  enabled_flags: list[str] = []
2020
2056
  toggles = (
2021
- ("update", update),
2022
2057
  ("coverage", coverage),
2023
2058
  ("debug", debug),
2024
2059
  ("summary", show_summary),
@@ -2029,9 +2064,13 @@ def _summarize_harness_configuration(
2029
2064
  for name, flag in toggles:
2030
2065
  if flag:
2031
2066
  enabled_flags.append(name)
2032
- if passthrough:
2033
- enabled_flags.append("passthrough")
2034
- return jobs, ", ".join(enabled_flags)
2067
+ if update:
2068
+ verb = "updating"
2069
+ elif passthrough:
2070
+ verb = "showing"
2071
+ else:
2072
+ verb = "running"
2073
+ return jobs, ", ".join(enabled_flags), verb
2035
2074
 
2036
2075
 
2037
2076
  def _relativize_path(path: Path) -> Path:
@@ -2395,7 +2434,46 @@ def last_and(items: Iterable[T]) -> Iterator[tuple[bool, T]]:
2395
2434
  for item in iterator:
2396
2435
  yield (False, previous)
2397
2436
  previous = item
2398
- yield (True, previous)
2437
+
2438
+
2439
+ def _format_unary_symbols(count: int, symbols: dict[int, str]) -> str:
2440
+ if count <= 0:
2441
+ return ""
2442
+ hundreds, remainder = divmod(count, 100)
2443
+ tens, ones = divmod(remainder, 10)
2444
+ parts: list[str] = []
2445
+ if hundreds:
2446
+ parts.append(symbols[100] * hundreds)
2447
+ if tens:
2448
+ parts.append(symbols[10] * tens)
2449
+ if ones:
2450
+ parts.append(symbols[1] * ones)
2451
+ return "".join(parts)
2452
+
2453
+
2454
+ def _format_diff_counter(added: int, removed: int) -> str:
2455
+ plus_segment = _format_unary_symbols(added, _PLUS_SYMBOLS)
2456
+ minus_segment = _format_unary_symbols(removed, _MINUS_SYMBOLS)
2457
+ colored_plus = f"\033[32m{plus_segment}\033[0m" if plus_segment else ""
2458
+ colored_minus = f"\033[31m{minus_segment}\033[0m" if minus_segment else ""
2459
+ return f"{colored_plus}{colored_minus}"
2460
+
2461
+
2462
+ def _format_stat_header(path: os.PathLike[str] | str, added: int, removed: int) -> str:
2463
+ path_str = os.fspath(path)
2464
+ counter = _format_diff_counter(added, removed)
2465
+ plus_count = f"\033[32m{added}(+)\033[0m"
2466
+ minus_count = f"\033[31m{removed}(-)\033[0m"
2467
+ if counter:
2468
+ counter_segment = f" {counter}"
2469
+ else:
2470
+ counter_segment = ""
2471
+ return f"{_BLOCK_INDENT}┌ {path_str} {plus_count}/{minus_count}{counter_segment}"
2472
+
2473
+
2474
+ def _format_lines_changed(total: int) -> str:
2475
+ line = "line" if total == 1 else "lines"
2476
+ return f"{_BLOCK_INDENT}└ {total} {line} changed"
2399
2477
 
2400
2478
 
2401
2479
  def print_diff(expected: bytes, actual: bytes, path: Path) -> None:
@@ -2409,22 +2487,51 @@ def print_diff(expected: bytes, actual: bytes, path: Path) -> None:
2409
2487
  n=2,
2410
2488
  )
2411
2489
  )
2412
- with stdout_lock:
2413
- rel_path = _relativize_path(path)
2490
+ added = sum(
2491
+ 1
2492
+ for index, line in enumerate(diff)
2493
+ if index >= 2 and line.startswith(b"+") and not line.startswith(b"+++")
2494
+ )
2495
+ removed = sum(
2496
+ 1
2497
+ for index, line in enumerate(diff)
2498
+ if index >= 2 and line.startswith(b"-") and not line.startswith(b"---")
2499
+ )
2500
+ show_stat = should_show_diff_stat()
2501
+ show_diff = should_show_diff_output()
2502
+ diff_lines: list[str] = []
2503
+ if should_show_diff_output():
2414
2504
  skip = 2
2415
- for i, line in enumerate(diff):
2505
+ for raw_line in diff:
2416
2506
  if skip > 0:
2417
2507
  skip -= 1
2418
2508
  continue
2419
- if line.startswith(b"@@"):
2420
- print(f"┌─▶ \033[31m{rel_path}\033[0m")
2421
- continue
2422
- if line.startswith(b"+"):
2423
- line = b"\033[92m" + line + b"\033[0m"
2424
- elif line.startswith(b"-"):
2425
- line = b"\033[31m" + line + b"\033[0m"
2426
- prefix = ("│ " if i != len(diff) - 1 else "└─").encode()
2427
- sys.stdout.buffer.write(prefix + line)
2509
+ text = raw_line.decode("utf-8", "replace").rstrip("\r\n")
2510
+ if raw_line.startswith(b"+") and not raw_line.startswith(b"+++"):
2511
+ text = f"\033[32m{text}\033[0m"
2512
+ elif raw_line.startswith(b"-") and not raw_line.startswith(b"---"):
2513
+ text = f"\033[31m{text}\033[0m"
2514
+ diff_lines.append(text)
2515
+ rel_path = _relativize_path(path)
2516
+ rel_path_str = os.fspath(rel_path)
2517
+ lines: list[str] = []
2518
+ total_changed = added + removed
2519
+ if not show_stat and not show_diff:
2520
+ return
2521
+ header = (
2522
+ _format_stat_header(rel_path, added, removed)
2523
+ if show_stat
2524
+ else f"{_BLOCK_INDENT}┌ {rel_path_str}"
2525
+ )
2526
+ lines.append(header)
2527
+ if show_diff and diff_lines:
2528
+ for diff_line in diff_lines:
2529
+ lines.append(f"{_BLOCK_INDENT}│ {diff_line}")
2530
+ if show_stat or (show_diff and total_changed > 0):
2531
+ lines.append(_format_lines_changed(total_changed))
2532
+ with stdout_lock:
2533
+ for output_line in lines:
2534
+ print(output_line)
2428
2535
 
2429
2536
 
2430
2537
  def check_group_is_empty(pgid: int) -> None:
@@ -2889,6 +2996,8 @@ def run_cli(
2889
2996
  runner_summary: bool,
2890
2997
  fixture_summary: bool,
2891
2998
  show_summary: bool,
2999
+ show_diff_output: bool,
3000
+ show_diff_stat: bool,
2892
3001
  jobs: int,
2893
3002
  keep_tmp_dirs: bool,
2894
3003
  passthrough: bool,
@@ -2937,6 +3046,8 @@ def run_cli(
2937
3046
  fixture_logger.propagate = True
2938
3047
 
2939
3048
  set_keep_tmp_dirs(bool(os.environ.get(_TMP_KEEP_ENV_VAR)) or keep_tmp_dirs)
3049
+ set_show_diff_output(show_diff_output)
3050
+ set_show_diff_stat(show_diff_stat)
2940
3051
  if passthrough:
2941
3052
  harness_mode = HarnessMode.PASSTHROUGH
2942
3053
  elif update:
@@ -3122,7 +3233,7 @@ def run_cli(
3122
3233
  queue = _build_queue_from_paths(collected_paths, coverage=coverage)
3123
3234
  queue.sort(key=_queue_sort_key, reverse=True)
3124
3235
  project_queue_size = _count_queue_tests(queue)
3125
- job_count, enabled_flags = _summarize_harness_configuration(
3236
+ job_count, enabled_flags, verb = _summarize_harness_configuration(
3126
3237
  jobs=jobs,
3127
3238
  update=update,
3128
3239
  coverage=coverage,
@@ -3159,6 +3270,7 @@ def run_cli(
3159
3270
  queue_size=project_queue_size,
3160
3271
  job_count=job_count,
3161
3272
  enabled_flags=enabled_flags,
3273
+ verb=verb,
3162
3274
  )
3163
3275
  count_width = max((len(str(count)) for _, count, _ in runner_breakdown), default=1)
3164
3276
  for name, count, version in runner_breakdown:
@@ -3266,6 +3378,7 @@ def _print_project_start(
3266
3378
  queue_size: int,
3267
3379
  job_count: int,
3268
3380
  enabled_flags: str,
3381
+ verb: str,
3269
3382
  ) -> None:
3270
3383
  project_name = selection.root.name or selection.root.as_posix()
3271
3384
  if selection.kind == "root":
@@ -3284,5 +3397,5 @@ def _print_project_start(
3284
3397
  toggles = f"; {enabled_flags}" if enabled_flags else ""
3285
3398
  jobs_segment = f" ({job_count} jobs)" if job_count else ""
3286
3399
  print(
3287
- f"{INFO} {project_display}: running {queue_size} tests{jobs_segment} from {project_kind} at {location_display}{toggles}"
3400
+ f"{INFO} {project_display}: {verb} {queue_size} tests{jobs_segment} from {project_kind} at {location_display}{toggles}"
3288
3401
  )
@@ -38,6 +38,7 @@ def test_cli_keep_flag(monkeypatch: pytest.MonkeyPatch) -> None:
38
38
  assert captured["jobs_overridden"] is False
39
39
  assert captured["all_projects"] is False
40
40
  assert captured["show_summary"] is False
41
+ assert captured["show_diff_stat"] is True
41
42
 
42
43
 
43
44
  def test_cli_passthrough_flag(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -109,6 +110,31 @@ def test_cli_summary_flag(monkeypatch: pytest.MonkeyPatch) -> None:
109
110
  assert captured["show_summary"] is True
110
111
 
111
112
 
113
+ def test_cli_no_diff_flag(monkeypatch: pytest.MonkeyPatch) -> None:
114
+ captured: dict[str, object] = {}
115
+
116
+ def fake_run_cli(**kwargs: object) -> None:
117
+ captured.update(kwargs)
118
+
119
+ monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
120
+
121
+ assert cli.main(["--no-diff"]) == 0
122
+ assert captured["show_diff_output"] is False
123
+ assert captured["show_diff_stat"] is True
124
+
125
+
126
+ def test_cli_no_diff_stat_flag(monkeypatch: pytest.MonkeyPatch) -> None:
127
+ captured: dict[str, object] = {}
128
+
129
+ def fake_run_cli(**kwargs: object) -> None:
130
+ captured.update(kwargs)
131
+
132
+ monkeypatch.setattr(cli.runtime, "run_cli", fake_run_cli)
133
+
134
+ assert cli.main(["--no-diff-stat"]) == 0
135
+ assert captured["show_diff_stat"] is False
136
+
137
+
112
138
  def test_cli_version_flag(capsys: pytest.CaptureFixture[str]) -> None:
113
139
  exit_code = cli.main(["--version"])
114
140
  assert exit_code == 0
@@ -769,6 +769,8 @@ def test_cli_rejects_partial_suite_selection(
769
769
  runner_summary=False,
770
770
  fixture_summary=False,
771
771
  show_summary=False,
772
+ show_diff_output=True,
773
+ show_diff_stat=True,
772
774
  jobs=1,
773
775
  keep_tmp_dirs=False,
774
776
  passthrough=False,
@@ -793,6 +795,8 @@ def test_cli_rejects_partial_suite_selection(
793
795
  runner_summary=False,
794
796
  fixture_summary=False,
795
797
  show_summary=False,
798
+ show_diff_output=True,
799
+ show_diff_stat=True,
796
800
  jobs=1,
797
801
  keep_tmp_dirs=False,
798
802
  passthrough=False,
@@ -828,6 +832,82 @@ def test_detailed_summary_order(capsys):
828
832
  assert skipped_index < failed_index < table_start
829
833
 
830
834
 
835
+ def test_print_diff_default_layout(capsys):
836
+ original_show_diff = run.should_show_diff_output()
837
+ original_show_stat = run.should_show_diff_stat()
838
+ try:
839
+ run.set_show_diff_output(True)
840
+ run.set_show_diff_stat(True)
841
+ run.print_diff(b"line\nbeta\n", b"line\ngamma\n", Path("tests/example.txt"))
842
+ finally:
843
+ run.set_show_diff_output(original_show_diff)
844
+ run.set_show_diff_stat(original_show_stat)
845
+
846
+ output = capsys.readouterr().out.splitlines()
847
+ assert output[0].startswith(f"{run._BLOCK_INDENT}┌ tests/example.txt")
848
+ expected_counter = run._format_diff_counter(1, 1)
849
+ assert "\033[32m1(+)\033[0m/\033[31m1(-)\033[0m" in output[0]
850
+ assert output[0].endswith(expected_counter)
851
+ assert output[1] == f"{run._BLOCK_INDENT}│ @@ -1,2 +1,2 @@"
852
+ assert output[2] == f"{run._BLOCK_INDENT}│ line"
853
+ assert output[3].startswith(f"{run._BLOCK_INDENT}│ \033[31m-beta")
854
+ assert output[4].startswith(f"{run._BLOCK_INDENT}│ \033[32m+gamma")
855
+ assert output[5] == f"{run._BLOCK_INDENT}└ 2 lines changed"
856
+
857
+
858
+ def test_print_diff_no_diff_outputs_stat_only(capsys):
859
+ original_show_diff = run.should_show_diff_output()
860
+ original_show_stat = run.should_show_diff_stat()
861
+ try:
862
+ run.set_show_diff_output(False)
863
+ run.set_show_diff_stat(True)
864
+ run.print_diff(b"line\nbeta\n", b"line\ngamma\n", Path("tests/example.txt"))
865
+ finally:
866
+ run.set_show_diff_output(original_show_diff)
867
+ run.set_show_diff_stat(original_show_stat)
868
+
869
+ output = capsys.readouterr().out.splitlines()
870
+ expected_header = (
871
+ f"{run._BLOCK_INDENT}┌ tests/example.txt "
872
+ f"\033[32m1(+)\033[0m/\033[31m1(-)\033[0m {run._format_diff_counter(1, 1)}"
873
+ )
874
+ assert output == [
875
+ expected_header,
876
+ f"{run._BLOCK_INDENT}└ 2 lines changed",
877
+ ]
878
+
879
+
880
+ def test_print_diff_stat_disabled_shows_only_diff(capsys):
881
+ original_show_diff = run.should_show_diff_output()
882
+ original_show_stat = run.should_show_diff_stat()
883
+ try:
884
+ run.set_show_diff_output(True)
885
+ run.set_show_diff_stat(False)
886
+ run.print_diff(b"line\nbeta\n", b"line\ngamma\n", Path("tests/example.txt"))
887
+ finally:
888
+ run.set_show_diff_output(original_show_diff)
889
+ run.set_show_diff_stat(original_show_stat)
890
+
891
+ output = capsys.readouterr().out.splitlines()
892
+ assert output[0] == f"{run._BLOCK_INDENT}┌ tests/example.txt"
893
+ assert output[1] == f"{run._BLOCK_INDENT}│ @@ -1,2 +1,2 @@"
894
+ assert output[-1] == f"{run._BLOCK_INDENT}└ 2 lines changed"
895
+
896
+
897
+ def test_print_diff_disabled_outputs_nothing(capsys):
898
+ original_show_diff = run.should_show_diff_output()
899
+ original_show_stat = run.should_show_diff_stat()
900
+ try:
901
+ run.set_show_diff_output(False)
902
+ run.set_show_diff_stat(False)
903
+ run.print_diff(b"line\nbeta\n", b"line\ngamma\n", Path("tests/example.txt"))
904
+ finally:
905
+ run.set_show_diff_output(original_show_diff)
906
+ run.set_show_diff_stat(original_show_stat)
907
+
908
+ assert capsys.readouterr().out.strip() == ""
909
+
910
+
831
911
  def test_describe_project_root_detects_standard_project(tmp_path: Path) -> None:
832
912
  project_root = tmp_path / "proj"
833
913
  tests_dir = project_root / "tests"
@@ -1143,6 +1223,7 @@ def test_print_project_start_reports_empty_projects(tmp_path, capsys):
1143
1223
  queue_size=0,
1144
1224
  job_count=0,
1145
1225
  enabled_flags="",
1226
+ verb="running",
1146
1227
  )
1147
1228
 
1148
1229
  output = capsys.readouterr().out
@@ -1152,6 +1233,66 @@ def test_print_project_start_reports_empty_projects(tmp_path, capsys):
1152
1233
  )
1153
1234
 
1154
1235
 
1236
+ def test_summarize_harness_configuration_sets_update_verb() -> None:
1237
+ job_count, enabled_flags, verb = run._summarize_harness_configuration(
1238
+ jobs=1,
1239
+ update=True,
1240
+ coverage=False,
1241
+ debug=False,
1242
+ show_summary=False,
1243
+ runner_summary=False,
1244
+ fixture_summary=False,
1245
+ passthrough=False,
1246
+ )
1247
+
1248
+ assert job_count == 1
1249
+ assert "update" not in enabled_flags
1250
+ assert verb == "updating"
1251
+
1252
+
1253
+ def test_summarize_harness_configuration_sets_passthrough_verb() -> None:
1254
+ job_count, enabled_flags, verb = run._summarize_harness_configuration(
1255
+ jobs=2,
1256
+ update=False,
1257
+ coverage=False,
1258
+ debug=False,
1259
+ show_summary=False,
1260
+ runner_summary=False,
1261
+ fixture_summary=False,
1262
+ passthrough=True,
1263
+ )
1264
+
1265
+ assert job_count == 2
1266
+ assert "passthrough" not in enabled_flags
1267
+ assert verb == "showing"
1268
+
1269
+
1270
+ def test_print_project_start_uses_custom_verb(tmp_path, capsys):
1271
+ project_root = tmp_path / "example"
1272
+ project_root.mkdir()
1273
+ selection = run.ProjectSelection(
1274
+ root=project_root,
1275
+ selectors=[],
1276
+ run_all=True,
1277
+ kind="root",
1278
+ )
1279
+
1280
+ run._print_project_start(
1281
+ selection=selection,
1282
+ display_base=tmp_path,
1283
+ queue_size=3,
1284
+ job_count=0,
1285
+ enabled_flags="",
1286
+ verb="showing",
1287
+ )
1288
+
1289
+ output = capsys.readouterr().out
1290
+ assert (
1291
+ output
1292
+ == f"{run.INFO} {run.BOLD}example{run.RESET_COLOR}: showing 3 tests from root project at ./example\n"
1293
+ )
1294
+
1295
+
1155
1296
  def test_execution_plan_single_project_summary(tmp_path, monkeypatch, capsys):
1156
1297
  root = tmp_path
1157
1298
  plan = run.ExecutionPlan(
File without changes
File without changes