shrinkray 25.12.29.0__tar.gz → 26.1.1.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.
- {shrinkray-25.12.29.0/src/shrinkray.egg-info → shrinkray-26.1.1.0}/PKG-INFO +2 -5
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/README.md +1 -4
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/pyproject.toml +1 -1
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/__main__.py +48 -1
- shrinkray-26.1.1.0/src/shrinkray/history.py +446 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/state.py +247 -41
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/subprocess/client.py +53 -4
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/subprocess/protocol.py +8 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/subprocess/worker.py +196 -31
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/tui.py +527 -19
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0/src/shrinkray.egg-info}/PKG-INFO +2 -5
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray.egg-info/SOURCES.txt +2 -0
- shrinkray-26.1.1.0/tests/test_history.py +1061 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_main.py +277 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_misc_reduction_performance.py +1 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_state.py +1343 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_subprocess_client.py +53 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_subprocess_worker.py +1131 -6
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_tui.py +1480 -5
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_tui_snapshots.py +94 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/LICENSE +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/setup.cfg +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/__init__.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/cli.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/formatting.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/__init__.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/bytes.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/clangdelta.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/definitions.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/genericlanguages.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/json.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/patching.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/python.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/sat.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/sequences.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/problem.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/process.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/py.typed +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/reducer.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/subprocess/__init__.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/ui.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/validation.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/work.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray.egg-info/dependency_links.txt +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray.egg-info/entry_points.txt +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray.egg-info/requires.txt +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray.egg-info/top_level.txt +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_byte_reduction_passes.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_clang_delta.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_cli.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_definitions.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_dimacs_cnf.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_formatting.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_generic_language.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_generic_shrinking_properties.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_json_passes.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_natural_sort_orders.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_patching.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_problem.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_process.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_python_reducers.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_reducer.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_reduction_passes.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_sat.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_subprocess_integration.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_subprocess_protocol.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_ui.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_validation.py +0 -0
- {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_work.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shrinkray
|
|
3
|
-
Version:
|
|
3
|
+
Version: 26.1.1.0
|
|
4
4
|
Summary: Shrink Ray
|
|
5
5
|
Author-email: "David R. MacIver" <david@drmaciver.com>
|
|
6
6
|
License: MIT
|
|
@@ -81,10 +81,7 @@ shrinkray (or any other test-case reducer) then systematically tries smaller and
|
|
|
81
81
|
|
|
82
82
|
While it runs, you will see the following user interface:
|
|
83
83
|
|
|
84
|
-
|
|
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>
|
|
84
|
+

|
|
88
85
|
|
|
89
86
|
(This is a toy example based on reducing a ridiculously bad version of hello world)
|
|
90
87
|
|
|
@@ -38,10 +38,7 @@ 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
|
-
|
|
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>
|
|
41
|
+

|
|
45
42
|
|
|
46
43
|
(This is a toy example based on reducing a ridiculously bad version of hello world)
|
|
47
44
|
|
|
@@ -197,6 +197,29 @@ This behaviour can be disabled by passing --trivial-is-not-error.
|
|
|
197
197
|
default=False,
|
|
198
198
|
help="Pass this if you do not want to use clang delta for C/C++ transformations.",
|
|
199
199
|
)
|
|
200
|
+
@click.option(
|
|
201
|
+
"--history/--no-history",
|
|
202
|
+
default=True,
|
|
203
|
+
help="""
|
|
204
|
+
Record reduction history to a .shrinkray directory. Each run creates a unique
|
|
205
|
+
subdirectory containing the initial test case and all successful reductions.
|
|
206
|
+
This is useful for debugging and analyzing the reduction process.
|
|
207
|
+
Enabled by default; use --no-history to disable.
|
|
208
|
+
""".strip(),
|
|
209
|
+
)
|
|
210
|
+
@click.option(
|
|
211
|
+
"--also-interesting",
|
|
212
|
+
type=int,
|
|
213
|
+
default=101,
|
|
214
|
+
help="""
|
|
215
|
+
Exit code indicating a test case is interesting enough to record but should not
|
|
216
|
+
be used for reduction. When the test script returns this code, the test case is
|
|
217
|
+
saved to the also-interesting/ directory within the history folder.
|
|
218
|
+
If --no-history is passed, also-interesting recording is disabled unless
|
|
219
|
+
--also-interesting is explicitly specified (in which case only also-interesting
|
|
220
|
+
cases are recorded, not reductions). Set to 0 to disable. Default: 101.
|
|
221
|
+
""".strip(),
|
|
222
|
+
)
|
|
200
223
|
@click.option(
|
|
201
224
|
"--clang-delta",
|
|
202
225
|
default="",
|
|
@@ -224,6 +247,8 @@ def main(
|
|
|
224
247
|
exit_on_completion: bool,
|
|
225
248
|
ui_type: UIType,
|
|
226
249
|
theme: str,
|
|
250
|
+
history: bool,
|
|
251
|
+
also_interesting: int,
|
|
227
252
|
) -> None:
|
|
228
253
|
if timeout is not None and timeout <= 0:
|
|
229
254
|
timeout = float("inf")
|
|
@@ -291,6 +316,21 @@ def main(
|
|
|
291
316
|
|
|
292
317
|
print("\nStarting reduction...", file=sys.stderr, flush=True)
|
|
293
318
|
|
|
319
|
+
# Determine if --also-interesting was explicitly passed
|
|
320
|
+
# If --no-history and --also-interesting not explicit, disable also-interesting
|
|
321
|
+
ctx = click.get_current_context()
|
|
322
|
+
also_interesting_explicit = (
|
|
323
|
+
ctx.get_parameter_source("also_interesting")
|
|
324
|
+
== click.core.ParameterSource.COMMANDLINE
|
|
325
|
+
)
|
|
326
|
+
if also_interesting == 0:
|
|
327
|
+
also_interesting_code: int | None = None
|
|
328
|
+
elif not history and not also_interesting_explicit:
|
|
329
|
+
# --no-history without explicit --also-interesting: disable both
|
|
330
|
+
also_interesting_code = None
|
|
331
|
+
else:
|
|
332
|
+
also_interesting_code = also_interesting
|
|
333
|
+
|
|
294
334
|
state_kwargs: dict[str, Any] = {
|
|
295
335
|
"input_type": input_type,
|
|
296
336
|
"in_place": in_place,
|
|
@@ -304,6 +344,8 @@ def main(
|
|
|
304
344
|
"seed": seed,
|
|
305
345
|
"volume": volume,
|
|
306
346
|
"clang_delta_executable": clang_delta_executable,
|
|
347
|
+
"history_enabled": history,
|
|
348
|
+
"also_interesting_code": also_interesting_code,
|
|
307
349
|
}
|
|
308
350
|
|
|
309
351
|
state: ShrinkRayState[Any]
|
|
@@ -357,6 +399,8 @@ def main(
|
|
|
357
399
|
trivial_is_error=trivial_is_error,
|
|
358
400
|
exit_on_completion=exit_on_completion,
|
|
359
401
|
theme=theme, # type: ignore[arg-type]
|
|
402
|
+
history_enabled=history,
|
|
403
|
+
also_interesting_code=also_interesting_code,
|
|
360
404
|
)
|
|
361
405
|
return
|
|
362
406
|
|
|
@@ -382,7 +426,10 @@ def main(
|
|
|
382
426
|
|
|
383
427
|
def worker_main() -> None:
|
|
384
428
|
"""Entry point for the worker subprocess."""
|
|
385
|
-
|
|
429
|
+
# Lazy import to avoid loading worker module in main process (fast CLI startup)
|
|
430
|
+
from shrinkray.subprocess.worker import ( # noqa: I001, no-import-in-function
|
|
431
|
+
main as worker_entry,
|
|
432
|
+
)
|
|
386
433
|
|
|
387
434
|
worker_entry()
|
|
388
435
|
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""History management for shrink ray reduction sessions.
|
|
2
|
+
|
|
3
|
+
Records initial state and all successful reductions to a .shrinkray directory,
|
|
4
|
+
allowing analysis and reproduction of reduction runs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shlex
|
|
14
|
+
import shutil
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
from attrs import define
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sanitize_for_filename(s: str) -> str:
|
|
21
|
+
"""Replace unsafe characters with underscores, limit length.
|
|
22
|
+
|
|
23
|
+
Keeps alphanumeric characters, dashes, underscores, and dots.
|
|
24
|
+
Collapses multiple underscores and limits to 50 characters.
|
|
25
|
+
"""
|
|
26
|
+
# Replace unsafe characters with underscore
|
|
27
|
+
safe = re.sub(r"[^\w\-.]", "_", s)
|
|
28
|
+
# Collapse multiple underscores
|
|
29
|
+
safe = re.sub(r"_+", "_", safe)
|
|
30
|
+
# Strip leading/trailing underscores and limit length
|
|
31
|
+
return safe[:50].strip("_")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@define
|
|
35
|
+
class HistoryManager:
|
|
36
|
+
"""Manages history directory structure for reduction sessions.
|
|
37
|
+
|
|
38
|
+
Creates and maintains a directory structure like:
|
|
39
|
+
.shrinkray/<run_id>/
|
|
40
|
+
initial/
|
|
41
|
+
<target_file> - Original file
|
|
42
|
+
<test_file> - Copy of interestingness test (if local)
|
|
43
|
+
run.sh - Wrapper script with exact original args
|
|
44
|
+
reductions/
|
|
45
|
+
0001/
|
|
46
|
+
<target_file> - Reduced file
|
|
47
|
+
<target_file>.out - stdout+stderr output
|
|
48
|
+
0002/
|
|
49
|
+
...
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
run_id: str
|
|
53
|
+
history_dir: str # Path to .shrinkray/<run_id>
|
|
54
|
+
target_basename: str
|
|
55
|
+
reduction_counter: int = 0
|
|
56
|
+
also_interesting_counter: int = 0
|
|
57
|
+
initialized: bool = False
|
|
58
|
+
record_reductions: bool = True # If False, only record also-interesting
|
|
59
|
+
is_directory: bool = False # True if target is a directory
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def create(
|
|
63
|
+
cls,
|
|
64
|
+
test: list[str],
|
|
65
|
+
filename: str,
|
|
66
|
+
*,
|
|
67
|
+
record_reductions: bool = True,
|
|
68
|
+
is_directory: bool = False,
|
|
69
|
+
base_dir: str | None = None,
|
|
70
|
+
) -> HistoryManager:
|
|
71
|
+
"""Create a new HistoryManager with a unique run ID.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
test: The interestingness test command (list of strings)
|
|
75
|
+
filename: Path to the target file being reduced
|
|
76
|
+
record_reductions: If True, record successful reductions. If False,
|
|
77
|
+
only record also-interesting cases (useful when --no-history
|
|
78
|
+
but --also-interesting is explicitly passed).
|
|
79
|
+
is_directory: If True, the target is a directory and test cases
|
|
80
|
+
are dict[str, bytes] instead of bytes.
|
|
81
|
+
base_dir: Base directory for .shrinkray folder. Defaults to cwd.
|
|
82
|
+
"""
|
|
83
|
+
# Generate run ID: (test-basename)-(filename)-(datetime)-(random hex)
|
|
84
|
+
test_name = sanitize_for_filename(os.path.basename(test[0]))
|
|
85
|
+
file_name = sanitize_for_filename(os.path.basename(filename))
|
|
86
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
87
|
+
random_hex = os.urandom(4).hex()
|
|
88
|
+
run_id = f"{test_name}-{file_name}-{timestamp}-{random_hex}"
|
|
89
|
+
|
|
90
|
+
if base_dir is None:
|
|
91
|
+
# Check environment variable first, then fall back to cwd
|
|
92
|
+
base_dir = os.environ.get("SHRINKRAY_DIRECTORY") or os.getcwd()
|
|
93
|
+
history_dir = os.path.join(base_dir, ".shrinkray", run_id)
|
|
94
|
+
target_basename = os.path.basename(filename)
|
|
95
|
+
|
|
96
|
+
return cls(
|
|
97
|
+
run_id=run_id,
|
|
98
|
+
history_dir=history_dir,
|
|
99
|
+
target_basename=target_basename,
|
|
100
|
+
record_reductions=record_reductions,
|
|
101
|
+
is_directory=is_directory,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def initialize(
|
|
105
|
+
self, initial_content: bytes, test: list[str], filename: str
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Create initial directory structure and copy files.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
initial_content: The original file content
|
|
111
|
+
test: The interestingness test command
|
|
112
|
+
filename: Path to the original target file
|
|
113
|
+
"""
|
|
114
|
+
if self.initialized:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
# Create directories
|
|
118
|
+
initial_dir = os.path.join(self.history_dir, "initial")
|
|
119
|
+
os.makedirs(initial_dir, exist_ok=True)
|
|
120
|
+
if self.record_reductions:
|
|
121
|
+
os.makedirs(os.path.join(self.history_dir, "reductions"), exist_ok=True)
|
|
122
|
+
|
|
123
|
+
# Copy original target file
|
|
124
|
+
target_path = os.path.join(initial_dir, self.target_basename)
|
|
125
|
+
with open(target_path, "wb") as f:
|
|
126
|
+
f.write(initial_content)
|
|
127
|
+
|
|
128
|
+
# Copy interestingness test if it's a local file
|
|
129
|
+
test_path = test[0]
|
|
130
|
+
copied_test_basename: str | None = None
|
|
131
|
+
if os.path.isfile(test_path):
|
|
132
|
+
copied_test_basename = os.path.basename(test_path)
|
|
133
|
+
shutil.copy2(test_path, os.path.join(initial_dir, copied_test_basename))
|
|
134
|
+
|
|
135
|
+
# Create wrapper script
|
|
136
|
+
self._create_wrapper_script(test, copied_test_basename, initial_dir)
|
|
137
|
+
|
|
138
|
+
self.initialized = True
|
|
139
|
+
|
|
140
|
+
def _create_wrapper_script(
|
|
141
|
+
self,
|
|
142
|
+
test: list[str],
|
|
143
|
+
copied_test_basename: str | None,
|
|
144
|
+
initial_dir: str,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Create run.sh wrapper script.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
test: The original test command
|
|
150
|
+
copied_test_basename: Basename of copied test file, or None if not copied
|
|
151
|
+
initial_dir: Path to the initial directory
|
|
152
|
+
"""
|
|
153
|
+
script_path = os.path.join(initial_dir, "run.sh")
|
|
154
|
+
|
|
155
|
+
# Build command, referencing test file via $(dirname "$0") if it was copied
|
|
156
|
+
if copied_test_basename is not None:
|
|
157
|
+
test_ref = f'"$(dirname "$0")/{copied_test_basename}"'
|
|
158
|
+
else:
|
|
159
|
+
test_ref = shlex.quote(test[0])
|
|
160
|
+
|
|
161
|
+
# Quote remaining arguments
|
|
162
|
+
args = " ".join(shlex.quote(arg) for arg in test[1:])
|
|
163
|
+
if args:
|
|
164
|
+
command = f"{test_ref} {args}"
|
|
165
|
+
else:
|
|
166
|
+
command = test_ref
|
|
167
|
+
|
|
168
|
+
script_content = f"""#!/bin/bash
|
|
169
|
+
# Shrink Ray interestingness test wrapper
|
|
170
|
+
# Run with: ./run.sh [target_file]
|
|
171
|
+
# If no target_file specified, uses the original
|
|
172
|
+
|
|
173
|
+
DIR="$(dirname "$0")"
|
|
174
|
+
TARGET="${{1:-"$DIR/{self.target_basename}"}}"
|
|
175
|
+
|
|
176
|
+
{command} "$TARGET"
|
|
177
|
+
"""
|
|
178
|
+
with open(script_path, "w") as f:
|
|
179
|
+
f.write(script_content)
|
|
180
|
+
os.chmod(script_path, 0o755)
|
|
181
|
+
|
|
182
|
+
def record_reduction(self, test_case: bytes, output: bytes | None = None) -> None:
|
|
183
|
+
"""Record a successful reduction.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
test_case: The reduced file content (or serialized directory content)
|
|
187
|
+
output: Combined stdout/stderr from the test, or None if not captured
|
|
188
|
+
"""
|
|
189
|
+
if not self.record_reductions:
|
|
190
|
+
return
|
|
191
|
+
self.reduction_counter += 1
|
|
192
|
+
subdir = os.path.join(
|
|
193
|
+
self.history_dir,
|
|
194
|
+
"reductions",
|
|
195
|
+
f"{self.reduction_counter:04d}",
|
|
196
|
+
)
|
|
197
|
+
os.makedirs(subdir, exist_ok=True)
|
|
198
|
+
|
|
199
|
+
if self.is_directory:
|
|
200
|
+
# Deserialize and write directory structure
|
|
201
|
+
content = self._deserialize_directory(test_case)
|
|
202
|
+
target_dir = os.path.join(subdir, self.target_basename)
|
|
203
|
+
self._write_directory_content(target_dir, content)
|
|
204
|
+
else:
|
|
205
|
+
# Write reduced file
|
|
206
|
+
with open(os.path.join(subdir, self.target_basename), "wb") as f:
|
|
207
|
+
f.write(test_case)
|
|
208
|
+
|
|
209
|
+
# Write output if available
|
|
210
|
+
if output is not None:
|
|
211
|
+
output_name = f"{self.target_basename}.out"
|
|
212
|
+
with open(os.path.join(subdir, output_name), "wb") as f:
|
|
213
|
+
f.write(output)
|
|
214
|
+
|
|
215
|
+
def record_also_interesting(
|
|
216
|
+
self, test_case: bytes, output: bytes | None = None
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Record an also-interesting test case.
|
|
219
|
+
|
|
220
|
+
These are test cases that don't satisfy the main interestingness test
|
|
221
|
+
but have some other interesting property indicated by a special exit code.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
test_case: The file content (or serialized directory content)
|
|
225
|
+
output: Combined stdout/stderr from the test, or None if not captured
|
|
226
|
+
"""
|
|
227
|
+
self.also_interesting_counter += 1
|
|
228
|
+
subdir = os.path.join(
|
|
229
|
+
self.history_dir,
|
|
230
|
+
"also-interesting",
|
|
231
|
+
f"{self.also_interesting_counter:04d}",
|
|
232
|
+
)
|
|
233
|
+
os.makedirs(subdir, exist_ok=True)
|
|
234
|
+
|
|
235
|
+
if self.is_directory:
|
|
236
|
+
# Deserialize and write directory structure
|
|
237
|
+
content = self._deserialize_directory(test_case)
|
|
238
|
+
target_dir = os.path.join(subdir, self.target_basename)
|
|
239
|
+
self._write_directory_content(target_dir, content)
|
|
240
|
+
else:
|
|
241
|
+
# Write file
|
|
242
|
+
with open(os.path.join(subdir, self.target_basename), "wb") as f:
|
|
243
|
+
f.write(test_case)
|
|
244
|
+
|
|
245
|
+
# Write output if available
|
|
246
|
+
if output is not None:
|
|
247
|
+
output_name = f"{self.target_basename}.out"
|
|
248
|
+
with open(os.path.join(subdir, output_name), "wb") as f:
|
|
249
|
+
f.write(output)
|
|
250
|
+
|
|
251
|
+
def get_reduction_content(self, reduction_number: int) -> bytes:
|
|
252
|
+
"""Get the content of a specific reduction.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
reduction_number: The reduction number (e.g., 3 for 0003)
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
The file content as bytes. For directory mode, returns the serialized
|
|
259
|
+
directory content for use with _set_initial_for_restart.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
FileNotFoundError: If the reduction doesn't exist
|
|
263
|
+
"""
|
|
264
|
+
target_path = os.path.join(
|
|
265
|
+
self.history_dir,
|
|
266
|
+
"reductions",
|
|
267
|
+
f"{reduction_number:04d}",
|
|
268
|
+
self.target_basename,
|
|
269
|
+
)
|
|
270
|
+
if self.is_directory:
|
|
271
|
+
# For directory mode, read and serialize the directory content
|
|
272
|
+
content = self._read_directory_content(target_path)
|
|
273
|
+
return self._serialize_directory(content)
|
|
274
|
+
else:
|
|
275
|
+
with open(target_path, "rb") as f:
|
|
276
|
+
return f.read()
|
|
277
|
+
|
|
278
|
+
def restart_from_reduction(self, reduction_number: int) -> tuple[bytes, set[bytes]]:
|
|
279
|
+
"""Move reductions after reduction_number to also-interesting.
|
|
280
|
+
|
|
281
|
+
This is used for the "restart from this point" feature. All reductions
|
|
282
|
+
after the specified point are moved to also-interesting (so they won't
|
|
283
|
+
be lost) and their contents are returned for exclusion from future
|
|
284
|
+
interestingness tests.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
reduction_number: The reduction to restart from (e.g., 3 for 0003)
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
tuple: (content_of_reduction_N, set_of_excluded_test_cases)
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
FileNotFoundError: If the reduction doesn't exist
|
|
294
|
+
"""
|
|
295
|
+
excluded_test_cases: set[bytes] = set()
|
|
296
|
+
reductions_dir = os.path.join(self.history_dir, "reductions")
|
|
297
|
+
also_interesting_dir = os.path.join(self.history_dir, "also-interesting")
|
|
298
|
+
os.makedirs(also_interesting_dir, exist_ok=True)
|
|
299
|
+
|
|
300
|
+
# Find all reductions after the restart point
|
|
301
|
+
entries_to_move: list[tuple[int, str]] = []
|
|
302
|
+
for entry_name in os.listdir(reductions_dir):
|
|
303
|
+
try:
|
|
304
|
+
entry_num = int(entry_name)
|
|
305
|
+
except ValueError:
|
|
306
|
+
continue # Skip non-numeric entries
|
|
307
|
+
if entry_num > reduction_number:
|
|
308
|
+
entries_to_move.append((entry_num, entry_name))
|
|
309
|
+
|
|
310
|
+
# Sort to process in order
|
|
311
|
+
entries_to_move.sort()
|
|
312
|
+
|
|
313
|
+
for _, entry_name in entries_to_move:
|
|
314
|
+
entry_path = os.path.join(reductions_dir, entry_name)
|
|
315
|
+
target_path = os.path.join(entry_path, self.target_basename)
|
|
316
|
+
|
|
317
|
+
# Read content for exclusion set
|
|
318
|
+
if self.is_directory:
|
|
319
|
+
# For directories, read and serialize content
|
|
320
|
+
content = self._read_directory_content(target_path)
|
|
321
|
+
excluded_test_cases.add(self._serialize_directory(content))
|
|
322
|
+
else:
|
|
323
|
+
with open(target_path, "rb") as f:
|
|
324
|
+
excluded_test_cases.add(f.read())
|
|
325
|
+
|
|
326
|
+
# Move to also-interesting with new numbering
|
|
327
|
+
self.also_interesting_counter += 1
|
|
328
|
+
new_path = os.path.join(
|
|
329
|
+
also_interesting_dir,
|
|
330
|
+
f"{self.also_interesting_counter:04d}",
|
|
331
|
+
)
|
|
332
|
+
shutil.move(entry_path, new_path)
|
|
333
|
+
|
|
334
|
+
# Update reduction counter
|
|
335
|
+
self.reduction_counter = reduction_number
|
|
336
|
+
|
|
337
|
+
# Return content to restart from
|
|
338
|
+
restart_content = self.get_reduction_content(reduction_number)
|
|
339
|
+
return restart_content, excluded_test_cases
|
|
340
|
+
|
|
341
|
+
# === Directory mode methods ===
|
|
342
|
+
|
|
343
|
+
def initialize_directory(
|
|
344
|
+
self, initial_content: dict[str, bytes], test: list[str], filename: str
|
|
345
|
+
) -> None:
|
|
346
|
+
"""Create initial directory structure for directory test cases.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
initial_content: The original directory content as {path: bytes}
|
|
350
|
+
test: The interestingness test command
|
|
351
|
+
filename: Path to the original target directory
|
|
352
|
+
"""
|
|
353
|
+
if self.initialized:
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
# Create directories
|
|
357
|
+
initial_dir = os.path.join(self.history_dir, "initial")
|
|
358
|
+
os.makedirs(initial_dir, exist_ok=True)
|
|
359
|
+
if self.record_reductions:
|
|
360
|
+
os.makedirs(os.path.join(self.history_dir, "reductions"), exist_ok=True)
|
|
361
|
+
|
|
362
|
+
# Copy original target directory structure
|
|
363
|
+
target_dir = os.path.join(initial_dir, self.target_basename)
|
|
364
|
+
self._write_directory_content(target_dir, initial_content)
|
|
365
|
+
|
|
366
|
+
# Copy interestingness test if it's a local file
|
|
367
|
+
test_path = test[0]
|
|
368
|
+
copied_test_basename: str | None = None
|
|
369
|
+
if os.path.isfile(test_path):
|
|
370
|
+
copied_test_basename = os.path.basename(test_path)
|
|
371
|
+
shutil.copy2(test_path, os.path.join(initial_dir, copied_test_basename))
|
|
372
|
+
|
|
373
|
+
# Create wrapper script
|
|
374
|
+
self._create_wrapper_script(test, copied_test_basename, initial_dir)
|
|
375
|
+
|
|
376
|
+
self.initialized = True
|
|
377
|
+
|
|
378
|
+
def _write_directory_content(
|
|
379
|
+
self, target_dir: str, content: dict[str, bytes]
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Write directory content to disk.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
target_dir: The directory to write to
|
|
385
|
+
content: The directory content as {relative_path: bytes}
|
|
386
|
+
"""
|
|
387
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
388
|
+
for rel_path, file_content in content.items():
|
|
389
|
+
file_path = os.path.join(target_dir, rel_path)
|
|
390
|
+
# parent_dir is always non-empty since file_path includes target_dir
|
|
391
|
+
parent_dir = os.path.dirname(file_path)
|
|
392
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
393
|
+
with open(file_path, "wb") as f:
|
|
394
|
+
f.write(file_content)
|
|
395
|
+
|
|
396
|
+
def _read_directory_content(self, target_dir: str) -> dict[str, bytes]:
|
|
397
|
+
"""Read directory content from disk.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
target_dir: The directory to read from
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
The directory content as {relative_path: bytes}
|
|
404
|
+
"""
|
|
405
|
+
content: dict[str, bytes] = {}
|
|
406
|
+
for root, _, files in os.walk(target_dir):
|
|
407
|
+
for file_name in files:
|
|
408
|
+
file_path = os.path.join(root, file_name)
|
|
409
|
+
rel_path = os.path.relpath(file_path, target_dir)
|
|
410
|
+
with open(file_path, "rb") as f:
|
|
411
|
+
content[rel_path] = f.read()
|
|
412
|
+
return content
|
|
413
|
+
|
|
414
|
+
@staticmethod
|
|
415
|
+
def _deserialize_directory(data: bytes) -> dict[str, bytes]:
|
|
416
|
+
return deserialize_directory(data)
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def _serialize_directory(content: dict[str, bytes]) -> bytes:
|
|
420
|
+
return serialize_directory(content)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def serialize_directory(content: dict[str, bytes]) -> bytes:
|
|
424
|
+
"""Serialize directory content to bytes.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
content: The directory content as {relative_path: bytes}
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
JSON-encoded directory content with base64 file contents
|
|
431
|
+
"""
|
|
432
|
+
serialized = {k: base64.b64encode(v).decode() for k, v in sorted(content.items())}
|
|
433
|
+
return json.dumps(serialized, sort_keys=True).encode()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def deserialize_directory(data: bytes) -> dict[str, bytes]:
|
|
437
|
+
"""Deserialize bytes back to directory content.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
data: JSON-encoded directory content with base64 file contents
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
The directory content as {relative_path: bytes}
|
|
444
|
+
"""
|
|
445
|
+
serialized = json.loads(data.decode())
|
|
446
|
+
return {k: base64.b64decode(v) for k, v in serialized.items()}
|