shrinkray 25.12.27.3__tar.gz → 25.12.29.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 (69) hide show
  1. {shrinkray-25.12.27.3/src/shrinkray.egg-info → shrinkray-25.12.29.0}/PKG-INFO +10 -2
  2. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/README.md +6 -1
  3. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/pyproject.toml +5 -2
  4. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/passes/bytes.py +0 -125
  5. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/passes/clangdelta.py +0 -24
  6. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/passes/genericlanguages.py +0 -20
  7. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/passes/json.py +0 -8
  8. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/passes/patching.py +0 -63
  9. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/passes/sequences.py +0 -25
  10. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/reducer.py +0 -50
  11. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/state.py +43 -51
  12. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/subprocess/__init__.py +0 -4
  13. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/subprocess/protocol.py +10 -12
  14. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/subprocess/worker.py +59 -21
  15. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/tui.py +605 -32
  16. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0/src/shrinkray.egg-info}/PKG-INFO +10 -2
  17. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/SOURCES.txt +0 -2
  18. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/requires.txt +3 -0
  19. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_byte_reduction_passes.py +1 -97
  20. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_clang_delta.py +2 -47
  21. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_generic_language.py +0 -31
  22. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_generic_shrinking_properties.py +0 -15
  23. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_json_passes.py +0 -43
  24. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_patching.py +0 -140
  25. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_python_reducers.py +0 -11
  26. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_reducer.py +1 -13
  27. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_reduction_passes.py +8 -169
  28. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_state.py +129 -39
  29. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_subprocess_protocol.py +0 -32
  30. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_subprocess_worker.py +76 -24
  31. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_tui.py +3836 -1743
  32. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_validation.py +1 -0
  33. shrinkray-25.12.27.3/src/shrinkray/display.py +0 -75
  34. shrinkray-25.12.27.3/tests/test_display.py +0 -135
  35. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/LICENSE +0 -0
  36. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/setup.cfg +0 -0
  37. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/__init__.py +0 -0
  38. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/__main__.py +0 -0
  39. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/cli.py +0 -0
  40. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/formatting.py +0 -0
  41. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/passes/__init__.py +0 -0
  42. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/passes/definitions.py +0 -0
  43. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/passes/python.py +0 -0
  44. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/passes/sat.py +0 -0
  45. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/problem.py +0 -0
  46. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/process.py +0 -0
  47. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/py.typed +0 -0
  48. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/subprocess/client.py +0 -0
  49. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/ui.py +0 -0
  50. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/validation.py +0 -0
  51. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray/work.py +0 -0
  52. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/dependency_links.txt +0 -0
  53. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/entry_points.txt +0 -0
  54. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/top_level.txt +0 -0
  55. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_cli.py +0 -0
  56. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_definitions.py +0 -0
  57. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_dimacs_cnf.py +0 -0
  58. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_formatting.py +0 -0
  59. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_main.py +0 -0
  60. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_misc_reduction_performance.py +0 -0
  61. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_natural_sort_orders.py +0 -0
  62. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_problem.py +0 -0
  63. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_process.py +0 -0
  64. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_sat.py +0 -0
  65. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_subprocess_client.py +0 -0
  66. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_subprocess_integration.py +0 -0
  67. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_tui_snapshots.py +0 -0
  68. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_ui.py +0 -0
  69. {shrinkray-25.12.27.3 → shrinkray-25.12.29.0}/tests/test_work.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrinkray
3
- Version: 25.12.27.3
3
+ Version: 25.12.29.0
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -16,6 +16,7 @@ Requires-Dist: click>=8.0.1
16
16
  Requires-Dist: chardet>=5.2.0
17
17
  Requires-Dist: trio>=0.28.0
18
18
  Requires-Dist: textual>=0.47.0
19
+ Requires-Dist: textual-plotext>=0.2.0
19
20
  Requires-Dist: humanize>=4.9.0
20
21
  Requires-Dist: libcst>=1.1.0
21
22
  Requires-Dist: exceptiongroup>=1.2.0
@@ -28,6 +29,8 @@ Requires-Dist: hypothesmith>=0.3.1; extra == "dev"
28
29
  Requires-Dist: pytest>=8.0.0; extra == "dev"
29
30
  Requires-Dist: pytest-trio>=0.8.0; extra == "dev"
30
31
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
32
+ Requires-Dist: pytest-xdist>=3.5.0; extra == "dev"
33
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
31
34
  Requires-Dist: syrupy>=5.0.0; extra == "dev"
32
35
  Requires-Dist: jinja2>=3.0.0; extra == "dev"
33
36
  Requires-Dist: coverage[toml]>=7.4.0; extra == "dev"
@@ -78,7 +81,12 @@ shrinkray (or any other test-case reducer) then systematically tries smaller and
78
81
 
79
82
  While it runs, you will see the following user interface:
80
83
 
81
- ![Demo of shrink ray running](demo.png)
84
+ <video controls poster="gallery/enterprise-hello/hello.png">
85
+ <source src="https://drmaciver.github.io/shrinkray/assets/hello.mp4" type="video/mp4">
86
+ Your browser doesn't support video. <a href="gallery/enterprise-hello/hello.gif">View the GIF instead</a>.
87
+ </video>
88
+
89
+ (This is a toy example based on reducing a ridiculously bad version of hello world)
82
90
 
83
91
  When it finishes you will be left with the reduced test case in `mytestcase.py`.
84
92
 
@@ -38,7 +38,12 @@ shrinkray (or any other test-case reducer) then systematically tries smaller and
38
38
 
39
39
  While it runs, you will see the following user interface:
40
40
 
41
- ![Demo of shrink ray running](demo.png)
41
+ <video controls poster="gallery/enterprise-hello/hello.png">
42
+ <source src="https://drmaciver.github.io/shrinkray/assets/hello.mp4" type="video/mp4">
43
+ Your browser doesn't support video. <a href="gallery/enterprise-hello/hello.gif">View the GIF instead</a>.
44
+ </video>
45
+
46
+ (This is a toy example based on reducing a ridiculously bad version of hello world)
42
47
 
43
48
  When it finishes you will be left with the reduced test case in `mytestcase.py`.
44
49
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shrinkray"
3
- version = "25.12.27.3"
3
+ version = "25.12.29.0"
4
4
  description = "Shrink Ray"
5
5
  authors = [
6
6
  {name = "David R. MacIver", email = "david@drmaciver.com"}
@@ -16,6 +16,7 @@ dependencies = [
16
16
  "chardet>=5.2.0",
17
17
  "trio>=0.28.0",
18
18
  "textual>=0.47.0",
19
+ "textual-plotext>=0.2.0",
19
20
  "humanize>=4.9.0",
20
21
  "libcst>=1.1.0",
21
22
  "exceptiongroup>=1.2.0",
@@ -41,6 +42,8 @@ dev = [
41
42
  "pytest>=8.0.0",
42
43
  "pytest-trio>=0.8.0",
43
44
  "pytest-asyncio>=0.21.0",
45
+ "pytest-xdist>=3.5.0",
46
+ "pytest-cov>=4.1.0",
44
47
  # pytest-textual-snapshot is vendored in tests/pytest_textual_snapshot.py
45
48
  # to fix syrupy 5.0 compatibility. These are its dependencies:
46
49
  "syrupy>=5.0.0",
@@ -67,7 +70,7 @@ core = "ctrace"
67
70
 
68
71
  [tool.coverage.report]
69
72
  show_missing = true
70
- fail_under = 100
73
+ # fail_under is enforced in justfile to allow split parallel/serial runs
71
74
 
72
75
  [tool.ruff]
73
76
  line-length = 88
@@ -28,26 +28,6 @@ from shrinkray.passes.patching import Cuts, Patches, apply_patches
28
28
  from shrinkray.problem import Format, ReductionProblem
29
29
 
30
30
 
31
- @define(frozen=True)
32
- class Encoding(Format[bytes, str]):
33
- """Format that decodes/encodes bytes using a character encoding."""
34
-
35
- encoding: str
36
-
37
- def __repr__(self) -> str:
38
- return f"Encoding({repr(self.encoding)})"
39
-
40
- @property
41
- def name(self) -> str:
42
- return self.encoding
43
-
44
- def parse(self, input: bytes) -> str:
45
- return input.decode(self.encoding)
46
-
47
- def dumps(self, input: str) -> bytes:
48
- return input.encode(self.encoding)
49
-
50
-
51
31
  @define(frozen=True)
52
32
  class Split(Format[bytes, list[bytes]]):
53
33
  """Format that splits bytes by a delimiter.
@@ -165,9 +145,6 @@ def tokenize(text: bytes) -> list[bytes]:
165
145
  return result
166
146
 
167
147
 
168
- MAX_DELETE_INTERVAL = 8
169
-
170
-
171
148
  async def lexeme_based_deletions(
172
149
  problem: ReductionProblem[bytes], min_size: int = 8
173
150
  ) -> None:
@@ -601,108 +578,6 @@ async def lower_individual_bytes(problem: ReductionProblem[bytes]) -> None:
601
578
  await apply_patches(problem, IndividualByteReplacement(), patches)
602
579
 
603
580
 
604
- RegionReplacementPatch = list[tuple[int, int, int]]
605
-
606
-
607
- class RegionReplacement(Patches[RegionReplacementPatch, bytes]):
608
- @property
609
- def empty(self) -> RegionReplacementPatch:
610
- return []
611
-
612
- def combine(self, *patches: RegionReplacementPatch) -> RegionReplacementPatch:
613
- result: RegionReplacementPatch = []
614
- for p in patches:
615
- result.extend(p)
616
- return result
617
-
618
- def apply(self, patch: RegionReplacementPatch, target: bytes) -> bytes:
619
- result = bytearray(target)
620
- for i, j, d in patch:
621
- if d < result[i]:
622
- for k in range(i, j):
623
- result[k] = d
624
- return bytes(result)
625
-
626
- def size(self, patch: RegionReplacementPatch) -> int:
627
- return 0
628
-
629
-
630
- async def short_replacements(problem: ReductionProblem[bytes]) -> None:
631
- """Replace short regions with uniform byte values.
632
-
633
- Tries replacing 1-4 byte regions with uniform values like 0, 1,
634
- space, newline, or period. Useful for simplifying small sequences.
635
- """
636
- target = problem.current_test_case
637
- patches = [
638
- [(i, j, c)]
639
- for c in [0, 1] + list(b"01 \t\n\r.")
640
- for i in range(len(target))
641
- if target[i] > c
642
- for j in range(i + 1, min(i + 5, len(target) + 1))
643
- ]
644
-
645
- await apply_patches(problem, RegionReplacement(), patches)
646
-
647
-
648
- WHITESPACE = b" \t\r\n"
649
-
650
-
651
- async def sort_whitespace(problem: ReductionProblem[bytes]) -> None:
652
- """NB: This is a stupid pass that we only really need for artificial
653
- test cases, but it's helpful for allowing those artificial test cases
654
- to expose other issues."""
655
-
656
- whitespace_up_to = 0
657
- while (
658
- whitespace_up_to < len(problem.current_test_case)
659
- and problem.current_test_case[whitespace_up_to] not in WHITESPACE
660
- ):
661
- whitespace_up_to += 1
662
- while (
663
- whitespace_up_to < len(problem.current_test_case)
664
- and problem.current_test_case[whitespace_up_to] in WHITESPACE
665
- ):
666
- whitespace_up_to += 1
667
-
668
- # If the initial whitespace ends with a newline we want to keep it doing
669
- # that. This is mostly for Python purposes.
670
- if (
671
- whitespace_up_to > 0
672
- and problem.current_test_case[whitespace_up_to - 1] == b"\n"[0]
673
- ):
674
- whitespace_up_to -= 1
675
-
676
- i = whitespace_up_to + 1
677
-
678
- while i < len(problem.current_test_case):
679
- if problem.current_test_case[i] not in WHITESPACE:
680
- i += 1
681
- continue
682
-
683
- async def can_move_to_whitespace(k: int) -> bool:
684
- if i + k > len(problem.current_test_case):
685
- return False
686
-
687
- base = problem.current_test_case
688
- target = base[i : i + k]
689
-
690
- if any(c not in WHITESPACE for c in target):
691
- return False
692
-
693
- prefix = base[:whitespace_up_to]
694
- attempt = prefix + target + base[whitespace_up_to:i] + base[i + k :]
695
- return await problem.is_interesting(attempt)
696
-
697
- k = await problem.work.find_large_integer(can_move_to_whitespace)
698
- whitespace_up_to += k
699
- i += k + 1
700
- test_case = problem.current_test_case
701
- await problem.is_interesting(
702
- bytes(sorted(test_case[:whitespace_up_to])) + test_case[whitespace_up_to:]
703
- )
704
-
705
-
706
581
  # These are some cheat substitutions that are sometimes helpful, but mostly
707
582
  # for passing stupid tests.
708
583
  STANDARD_SUBSTITUTIONS = [(b"\0\0", b"\1"), (b"\0\0", b"\xff")]
@@ -1,6 +1,5 @@
1
1
  import os
2
2
  import subprocess
3
- from functools import lru_cache
4
3
  from glob import glob
5
4
  from shutil import which
6
5
  from tempfile import NamedTemporaryFile
@@ -26,29 +25,6 @@ def find_clang_delta():
26
25
  return clang_delta
27
26
 
28
27
 
29
- @lru_cache(maxsize=1)
30
- def clang_delta_works() -> bool:
31
- """Check if clang_delta can actually execute.
32
-
33
- This verifies not just that the binary exists, but that it can run.
34
- On some systems (e.g., Ubuntu 24.04), creduce is installed but
35
- clang_delta fails at runtime due to shared library issues.
36
- """
37
- clang_delta = find_clang_delta()
38
- if not clang_delta:
39
- return False
40
- try:
41
- # Run a simple test to verify clang_delta works
42
- result = subprocess.run(
43
- [clang_delta, "--help"],
44
- capture_output=True,
45
- timeout=5,
46
- )
47
- return result.returncode == 0
48
- except (OSError, subprocess.TimeoutExpired):
49
- return False
50
-
51
-
52
28
  TRANSFORMATIONS: list[str] = [
53
29
  "aggregate-to-scalar",
54
30
  "binop-simplification",
@@ -9,7 +9,6 @@ from string import ascii_lowercase, ascii_uppercase
9
9
  from typing import AnyStr
10
10
 
11
11
  import trio
12
- from attr import define
13
12
 
14
13
  from shrinkray.passes.bytes import ByteReplacement, delete_intervals
15
14
  from shrinkray.passes.definitions import ReductionPass
@@ -24,25 +23,6 @@ from shrinkray.problem import (
24
23
  from shrinkray.work import NotFound
25
24
 
26
25
 
27
- @define(frozen=True)
28
- class Substring(Format[AnyStr, AnyStr]):
29
- prefix: AnyStr
30
- suffix: AnyStr
31
-
32
- @property
33
- def name(self) -> str:
34
- return f"Substring({len(self.prefix)}, {len(self.suffix)})"
35
-
36
- def parse(self, input: AnyStr) -> AnyStr:
37
- if input.startswith(self.prefix) and input.endswith(self.suffix):
38
- return input[len(self.prefix) : len(input) - len(self.suffix)]
39
- else:
40
- raise ParseError()
41
-
42
- def dumps(self, input: AnyStr) -> AnyStr:
43
- return self.prefix + input + self.suffix
44
-
45
-
46
26
  class RegionReplacingPatches(Patches[dict[int, AnyStr], AnyStr]):
47
27
  def __init__(self, regions: list[tuple[int, int]]):
48
28
  assert regions
@@ -9,14 +9,6 @@ from shrinkray.passes.patching import Patches, apply_patches
9
9
  from shrinkray.problem import Format, ParseError, ReductionProblem
10
10
 
11
11
 
12
- def is_json(s: bytes) -> bool:
13
- try:
14
- json.loads(s)
15
- return True
16
- except ValueError:
17
- return False
18
-
19
-
20
12
  @define(frozen=True)
21
13
  class _JSON(Format[bytes, Any]):
22
14
  def __repr__(self) -> str:
@@ -1,7 +1,5 @@
1
1
  from abc import ABC, abstractmethod
2
2
  from collections.abc import Callable, Iterable, Sequence
3
- from enum import Enum
4
- from random import Random
5
3
  from typing import Any, TypeVar, cast
6
4
 
7
5
  import trio
@@ -52,27 +50,6 @@ class SetPatches[T, TargetType](Patches[frozenset[T], TargetType]):
52
50
  return len(patch)
53
51
 
54
52
 
55
- class ListPatches[T, TargetType](Patches[list[T], TargetType]):
56
- def __init__(self, apply: Callable[[list[T], TargetType], TargetType]):
57
- self.__apply = apply
58
-
59
- @property
60
- def empty(self):
61
- return []
62
-
63
- def combine(self, *patches: list[T]) -> list[T]:
64
- result = []
65
- for p in patches:
66
- result.extend(p)
67
- return result
68
-
69
- def apply(self, patch: list[T], target: TargetType) -> TargetType:
70
- return self.__apply(patch, target)
71
-
72
- def size(self, patch: list[T]) -> int:
73
- return len(patch)
74
-
75
-
76
53
  class PatchApplier[PatchType, TargetType]:
77
54
  def __init__(
78
55
  self,
@@ -174,15 +151,6 @@ class PatchApplier[PatchType, TargetType]:
174
151
  return await receive_merge_result.receive()
175
152
 
176
153
 
177
- class Direction(Enum):
178
- LEFT = 0
179
- RIGHT = 1
180
-
181
-
182
- class Completed(Exception):
183
- pass
184
-
185
-
186
154
  async def apply_patches[PatchType, TargetType](
187
155
  problem: ReductionProblem[TargetType],
188
156
  patch_info: Patches[PatchType, TargetType],
@@ -220,37 +188,6 @@ async def apply_patches[PatchType, TargetType](
220
188
  await applier.try_apply_patch(patch)
221
189
 
222
190
 
223
- class LazyMutableRange:
224
- def __init__(self, n: int):
225
- self.__size = n
226
- self.__mask: dict[int, int] = {}
227
-
228
- def __getitem__(self, i: int) -> int:
229
- return self.__mask.get(i, i)
230
-
231
- def __setitem__(self, i: int, v: int) -> None:
232
- self.__mask[i] = v
233
-
234
- def __len__(self) -> int:
235
- return self.__size
236
-
237
- def pop(self) -> int:
238
- i = len(self) - 1
239
- result = self[i]
240
- self.__size = i
241
- self.__mask.pop(i, None)
242
- return result
243
-
244
-
245
- def lazy_shuffle[T](seq: Sequence[T], rnd: Random) -> Iterable[T]:
246
- indices = LazyMutableRange(len(seq))
247
- while indices:
248
- j = len(indices) - 1
249
- i = rnd.randrange(0, len(indices))
250
- indices[i], indices[j] = indices[j], indices[i]
251
- yield seq[indices.pop()]
252
-
253
-
254
191
  CutPatch = list[tuple[int, int]]
255
192
 
256
193
 
@@ -18,31 +18,6 @@ async def delete_elements[Seq: Sequence[Any]](problem: ReductionProblem[Seq]) ->
18
18
  )
19
19
 
20
20
 
21
- def merged_intervals(intervals: list[tuple[int, int]]) -> list[tuple[int, int]]:
22
- normalized: list[list[int]] = []
23
- for start, end in sorted(map(tuple, intervals)):
24
- if normalized and normalized[-1][-1] >= start:
25
- normalized[-1][-1] = max(normalized[-1][-1], end)
26
- else:
27
- normalized.append([start, end])
28
- return list(map(tuple, normalized)) # type: ignore
29
-
30
-
31
- def with_deletions[Seq: Sequence[Any]](
32
- target: Seq, deletions: list[tuple[int, int]]
33
- ) -> Seq:
34
- result: list[Any] = []
35
- prev = 0
36
- total_deleted = 0
37
- for start, end in deletions:
38
- total_deleted += end - start
39
- result.extend(target[prev:start])
40
- prev = end
41
- result.extend(target[prev:])
42
- assert len(result) + total_deleted == len(target)
43
- return type(target)(result) # type: ignore
44
-
45
-
46
21
  def block_deletion[Seq: Sequence[Any]](
47
22
  min_block: int, max_block: int
48
23
  ) -> ReductionPass[Seq]:
@@ -99,50 +99,6 @@ class Reducer[T](ABC):
99
99
  """Skip the currently running pass. Override in subclasses for pass control."""
100
100
 
101
101
 
102
- @define
103
- class BasicReducer[T](Reducer[T]):
104
- reduction_passes: Iterable[ReductionPass[T]]
105
- pumps: Iterable[ReductionPump[T]] = ()
106
- _status: str = "Starting up"
107
-
108
- def __attrs_post_init__(self) -> None:
109
- self.reduction_passes = list(self.reduction_passes)
110
-
111
- @property
112
- def status(self) -> str:
113
- return self._status
114
-
115
- @status.setter
116
- def status(self, value: str) -> None:
117
- self._status = value
118
-
119
- async def run_pass(self, rp: ReductionPass[T]) -> None:
120
- await rp(self.target)
121
-
122
- async def run(self) -> None:
123
- await self.target.setup()
124
-
125
- while True:
126
- prev = self.target.current_test_case
127
- for rp in self.reduction_passes:
128
- self.status = f"Running reduction pass {rp.__name__}"
129
- await self.run_pass(rp)
130
- for pump in self.pumps:
131
- self.status = f"Pumping with {pump.__name__}"
132
- pumped = await pump(self.target)
133
- if pumped != self.target.current_test_case:
134
- with self.backtrack(pumped):
135
- for rp in self.reduction_passes:
136
- self.status = f"Running reduction pass {rp.__name__} under pump {pump.__name__}"
137
- await self.run_pass(rp)
138
- if prev == self.target.current_test_case:
139
- return
140
-
141
-
142
- class RestartPass(Exception):
143
- pass
144
-
145
-
146
102
  @define
147
103
  class PassStats:
148
104
  """Statistics for a single reduction pass."""
@@ -181,12 +137,6 @@ class PassStatsTracker:
181
137
  return list(self._stats.values())
182
138
 
183
139
 
184
- class SkipPass(Exception):
185
- """Raised to skip the current pass."""
186
-
187
- pass
188
-
189
-
190
140
  @define
191
141
  class ShrinkRay(Reducer[bytes]):
192
142
  clang_delta: ClangDelta | None = None
@@ -11,7 +11,7 @@ from abc import ABC, abstractmethod
11
11
  from collections import deque
12
12
  from datetime import timedelta
13
13
  from tempfile import TemporaryDirectory
14
- from typing import Any, TypeVar
14
+ from typing import Any
15
15
 
16
16
  import humanize
17
17
  import trio
@@ -28,9 +28,6 @@ from shrinkray.reducer import DirectoryShrinkRay, Reducer, ShrinkRay
28
28
  from shrinkray.work import Volume, WorkContext
29
29
 
30
30
 
31
- T = TypeVar("T")
32
-
33
-
34
31
  class TimeoutExceededOnInitial(InvalidInitialExample):
35
32
  def __init__(self, runtime: float, timeout: float) -> None:
36
33
  self.runtime = runtime
@@ -60,7 +57,7 @@ def compute_dynamic_timeout(runtime: float) -> float:
60
57
 
61
58
 
62
59
  @define
63
- class TestOutputManager:
60
+ class OutputCaptureManager:
64
61
  """Manages temporary files for test output capture.
65
62
 
66
63
  Allocates unique files for each test's stdout/stderr output,
@@ -77,7 +74,8 @@ class TestOutputManager:
77
74
 
78
75
  _sequence: int = 0
79
76
  _active_outputs: dict[int, str] = {}
80
- _completed_outputs: deque[tuple[int, str, float]] = deque()
77
+ # Completed outputs: (test_id, file_path, completion_time, return_code)
78
+ _completed_outputs: deque[tuple[int, str, float, int]] = deque()
81
79
 
82
80
  def __attrs_post_init__(self) -> None:
83
81
  # Initialize mutable defaults
@@ -92,11 +90,13 @@ class TestOutputManager:
92
90
  self._active_outputs[test_id] = file_path
93
91
  return test_id, file_path
94
92
 
95
- def mark_completed(self, test_id: int) -> None:
93
+ def mark_completed(self, test_id: int, return_code: int = 0) -> None:
96
94
  """Mark a test as completed and move to completed queue."""
97
95
  if test_id in self._active_outputs:
98
96
  file_path = self._active_outputs.pop(test_id)
99
- self._completed_outputs.append((test_id, file_path, time.time()))
97
+ self._completed_outputs.append(
98
+ (test_id, file_path, time.time(), return_code)
99
+ )
100
100
  self._cleanup_old_files()
101
101
 
102
102
  def _cleanup_old_files(self) -> None:
@@ -107,65 +107,55 @@ class TestOutputManager:
107
107
  self._completed_outputs
108
108
  and now - self._completed_outputs[0][2] > self.max_age_seconds
109
109
  ):
110
- _, file_path, _ = self._completed_outputs.popleft()
110
+ _, file_path, _, _ = self._completed_outputs.popleft()
111
111
  self._safe_delete(file_path)
112
112
  # Remove excess files beyond max_files
113
113
  while len(self._completed_outputs) > self.max_files:
114
- _, file_path, _ = self._completed_outputs.popleft()
114
+ _, file_path, _, _ = self._completed_outputs.popleft()
115
115
  self._safe_delete(file_path)
116
116
 
117
- def _should_show_completed(self) -> tuple[int, str] | None:
118
- """Check if we should show a completed test's output.
119
-
120
- Note: This method is only called when there are no active tests.
121
- It returns the completed test info if within the display window
122
- (min_display_seconds + grace_period).
123
- """
124
- if not self._completed_outputs:
125
- return None
126
- test_id, file_path, completion_time = self._completed_outputs[-1]
127
- elapsed = time.time() - completion_time
128
-
129
- # Show completed test during the full display window
130
- if elapsed < self.min_display_seconds + self.grace_period_seconds:
131
- return test_id, file_path
117
+ @staticmethod
118
+ def _file_has_content(path: str) -> bool:
119
+ """Check if a file exists and has non-zero size."""
120
+ try:
121
+ return os.path.getsize(path) > 0
122
+ except OSError:
123
+ return False
132
124
 
133
- return None
125
+ def get_current_output(self) -> tuple[str | None, int | None, int | None]:
126
+ """Get the current output to display.
134
127
 
135
- def get_current_output_path(self) -> str | None:
136
- """Get the most relevant output file path.
128
+ Returns (file_path, test_id, return_code) where:
129
+ - file_path: path to the output file to display
130
+ - test_id: the test ID (for display in header)
131
+ - return_code: the return code (None if test is still running)
137
132
 
138
- Active tests always take priority. If no active test, shows
139
- recently completed test output for min_display_seconds, plus
140
- an additional grace_period if no new test has started.
133
+ Active tests take priority only if they have produced output.
134
+ Otherwise, shows recently completed test output for min_display_seconds,
135
+ plus an additional grace_period if no new test has started.
141
136
  """
142
- # Active tests always take priority
137
+ # Active tests take priority only if they have content
143
138
  if self._active_outputs:
144
139
  max_id = max(self._active_outputs.keys())
145
- return self._active_outputs[max_id]
146
- # Then check for recently completed test that should stay visible
147
- recent = self._should_show_completed()
148
- if recent is not None:
149
- return recent[1]
150
- # Fall back to most recent completed (even if past display window)
140
+ active_path = self._active_outputs[max_id]
141
+ if self._file_has_content(active_path):
142
+ # Active test with output - no return code yet
143
+ return active_path, max_id, None
144
+ # Active test has no output yet - fall through to show previous output
145
+
146
+ # Check for recently completed test that should stay visible,
147
+ # or fall back to most recent completed (even if past display window)
151
148
  if self._completed_outputs:
152
- return self._completed_outputs[-1][1]
153
- return None
154
-
155
- def get_active_test_id(self) -> int | None:
156
- """Get the currently running test ID, if any.
149
+ test_id, file_path, _, return_code = self._completed_outputs[-1]
150
+ return file_path, test_id, return_code
157
151
 
158
- Returns the active test ID if one is running, None otherwise.
159
- """
160
- if self._active_outputs:
161
- return max(self._active_outputs.keys())
162
- return None
152
+ return None, None, None
163
153
 
164
154
  def cleanup_all(self) -> None:
165
155
  """Clean up all output files (called on shutdown)."""
166
156
  for file_path in self._active_outputs.values():
167
157
  self._safe_delete(file_path)
168
- for _, file_path, _ in self._completed_outputs:
158
+ for _, file_path, _, _ in self._completed_outputs:
169
159
  self._safe_delete(file_path)
170
160
  self._active_outputs.clear()
171
161
  self._completed_outputs.clear()
@@ -212,7 +202,7 @@ class ShrinkRayState[TestCase](ABC):
212
202
  _last_debug_output: str = ""
213
203
 
214
204
  # Optional output manager for capturing test output (TUI mode)
215
- output_manager: TestOutputManager | None = None
205
+ output_manager: OutputCaptureManager | None = None
216
206
 
217
207
  def __attrs_post_init__(self):
218
208
  self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
@@ -301,6 +291,7 @@ class ShrinkRayState[TestCase](ABC):
301
291
  # Determine output handling
302
292
  test_id: int | None = None
303
293
  output_file_handle = None
294
+ exit_code: int | None = None # Track for output manager
304
295
 
305
296
  if self.output_manager is not None:
306
297
  # Capture output to a file for TUI display
@@ -369,6 +360,7 @@ class ShrinkRayState[TestCase](ABC):
369
360
 
370
361
  result: int | None = sp.returncode
371
362
  assert result is not None
363
+ exit_code = result
372
364
 
373
365
  return result
374
366
  finally:
@@ -376,7 +368,7 @@ class ShrinkRayState[TestCase](ABC):
376
368
  if output_file_handle is not None:
377
369
  output_file_handle.close()
378
370
  if test_id is not None and self.output_manager is not None:
379
- self.output_manager.mark_completed(test_id)
371
+ self.output_manager.mark_completed(test_id, exit_code or 0)
380
372
 
381
373
  async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
382
374
  # Lazy import