tenzir-test 0.9.3__py3-none-any.whl → 0.9.5__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
@@ -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,
tenzir_test/run.py CHANGED
@@ -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
  )
@@ -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)
@@ -1,11 +1,11 @@
1
1
  tenzir_test/__init__.py,sha256=JvAAO9W-9cJXKZaDEI-fiTGkdRDILfx7nC7H6sYDCpk,991
2
2
  tenzir_test/_python_runner.py,sha256=LmghMIolsNEC2wUyJdv1h_cefOxTxET1IACrw-_hHuY,2900
3
3
  tenzir_test/checks.py,sha256=VhZjU1TExqWzA1KcaW1xOGICpqb_G43AezrJIzw09eM,653
4
- tenzir_test/cli.py,sha256=APdImK6OgKAdvfdENuUa-Nk6q0IS2SJ_IZyAf7AXve8,4994
4
+ tenzir_test/cli.py,sha256=O1FaxYFKWal--aqs-ZFT2URqL26_HjUtFNDACIBspXA,5452
5
5
  tenzir_test/config.py,sha256=q1_VEXuxL-xsGlnooeGvXxx9cMw652UEB9a1mPzZIQs,1680
6
6
  tenzir_test/packages.py,sha256=cTCQdGjCS1XmuKyiwh0ew-z9tHn6J-xZ6nvBP-hU8bc,948
7
7
  tenzir_test/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- tenzir_test/run.py,sha256=aIVFmM7U3MX5meONg8vT5-4FoQoZRu8tc7CqRLU5YBk,112596
8
+ tenzir_test/run.py,sha256=K6ZTQktVBLRMHQfx8322cKQzZIqQACa1PPXHO25TQNQ,116108
9
9
  tenzir_test/engine/__init__.py,sha256=5APwy90YDm7rmL_qCZfToAcfbQthcZ8yV2_ExXKqaqE,110
10
10
  tenzir_test/engine/operations.py,sha256=OCYjuMHyMAaay4s08u2Sl7oE-PmgeXumylp7R8GYIH4,950
11
11
  tenzir_test/engine/registry.py,sha256=LXCr6TGlv1sR1m1eboTk7SrbS2IVErc3PqUuHxGA2xk,594
@@ -22,8 +22,8 @@ tenzir_test/runners/runner.py,sha256=LtlD8huQOSmD7RyYDnKeCuI4Y6vhxGXMKsHA2qgfWN0
22
22
  tenzir_test/runners/shell_runner.py,sha256=wLiHYGrZDHPof6hohDT-t6zEi8w-DlkR5QG0i5DpwWE,5892
23
23
  tenzir_test/runners/tenzir_runner.py,sha256=464FFYS_mh6l-ehccc-S8cIUO1MxdapwQL5X3PmMkMI,1006
24
24
  tenzir_test/runners/tql_runner.py,sha256=2ZLMf3TIKwcOvaOFrVvvhzK-EcWmGOUZxKkbSoByyQA,248
25
- tenzir_test-0.9.3.dist-info/METADATA,sha256=gicldYcQVMjNqGk3vp9k0-bqBeOmEh6zAT0WAhqAKrw,3982
26
- tenzir_test-0.9.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
27
- tenzir_test-0.9.3.dist-info/entry_points.txt,sha256=q0eD9RQ_9eMPYvFNpBElo55HQYeaPgLfe9YhLsNwl10,93
28
- tenzir_test-0.9.3.dist-info/licenses/LICENSE,sha256=ajMbpcBiSTXI8Rr4t17pvowV-On8DktghfZKxY_A22Q,10750
29
- tenzir_test-0.9.3.dist-info/RECORD,,
25
+ tenzir_test-0.9.5.dist-info/METADATA,sha256=AFPYUhmvTIQ5XWmNBhmzTOb_2A_0gNu_kjWDc18LylQ,3007
26
+ tenzir_test-0.9.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
27
+ tenzir_test-0.9.5.dist-info/entry_points.txt,sha256=q0eD9RQ_9eMPYvFNpBElo55HQYeaPgLfe9YhLsNwl10,93
28
+ tenzir_test-0.9.5.dist-info/licenses/LICENSE,sha256=ajMbpcBiSTXI8Rr4t17pvowV-On8DktghfZKxY_A22Q,10750
29
+ tenzir_test-0.9.5.dist-info/RECORD,,