shrinkray 25.12.27.2__py3-none-any.whl → 25.12.27.3__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.
- shrinkray/__main__.py +25 -11
- shrinkray/state.py +191 -52
- shrinkray/subprocess/client.py +2 -0
- shrinkray/subprocess/protocol.py +8 -0
- shrinkray/subprocess/worker.py +67 -17
- shrinkray/tui.py +114 -92
- shrinkray/validation.py +403 -0
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.27.3.dist-info}/METADATA +1 -1
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.27.3.dist-info}/RECORD +13 -12
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.27.3.dist-info}/WHEEL +0 -0
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.27.3.dist-info}/entry_points.txt +0 -0
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.27.3.dist-info}/licenses/LICENSE +0 -0
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.27.3.dist-info}/top_level.txt +0 -0
shrinkray/__main__.py
CHANGED
|
@@ -17,18 +17,19 @@ from shrinkray.cli import (
|
|
|
17
17
|
validate_command,
|
|
18
18
|
validate_ui,
|
|
19
19
|
)
|
|
20
|
+
from shrinkray.formatting import determine_formatter_command
|
|
20
21
|
from shrinkray.passes.clangdelta import (
|
|
21
22
|
C_FILE_EXTENSIONS,
|
|
22
23
|
ClangDelta,
|
|
23
24
|
find_clang_delta,
|
|
24
25
|
)
|
|
25
|
-
from shrinkray.problem import InvalidInitialExample
|
|
26
26
|
from shrinkray.state import (
|
|
27
27
|
ShrinkRayDirectoryState,
|
|
28
28
|
ShrinkRayState,
|
|
29
29
|
ShrinkRayStateSingleFile,
|
|
30
30
|
)
|
|
31
31
|
from shrinkray.ui import BasicUI, ShrinkRayUI
|
|
32
|
+
from shrinkray.validation import run_validation
|
|
32
33
|
from shrinkray.work import Volume
|
|
33
34
|
|
|
34
35
|
|
|
@@ -39,12 +40,9 @@ async def run_shrink_ray(
|
|
|
39
40
|
"""Run the shrink ray reduction process."""
|
|
40
41
|
async with trio.open_nursery() as nursery:
|
|
41
42
|
problem = state.problem
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
assert len(excs.exceptions) == 1
|
|
46
|
-
(e,) = excs.exceptions
|
|
47
|
-
await state.report_error(e)
|
|
43
|
+
# Validation runs before run_shrink_ray is called, so setup() should
|
|
44
|
+
# always succeed. If it doesn't, there's a bug and we want it to propagate.
|
|
45
|
+
await problem.setup()
|
|
48
46
|
|
|
49
47
|
reducer = state.reducer
|
|
50
48
|
|
|
@@ -273,6 +271,26 @@ def main(
|
|
|
273
271
|
if not backup:
|
|
274
272
|
backup = filename + os.extsep + "bak"
|
|
275
273
|
|
|
274
|
+
# Run initial validation before any state setup
|
|
275
|
+
# This validates the interestingness test and formatter with proper output streaming
|
|
276
|
+
formatter_command = None
|
|
277
|
+
if not os.path.isdir(filename) and formatter.lower() != "none":
|
|
278
|
+
formatter_command = determine_formatter_command(formatter, filename)
|
|
279
|
+
|
|
280
|
+
validation_result = run_validation(
|
|
281
|
+
file_path=filename,
|
|
282
|
+
test=test,
|
|
283
|
+
input_type=input_type,
|
|
284
|
+
in_place=in_place,
|
|
285
|
+
formatter_command=formatter_command,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
if not validation_result.success:
|
|
289
|
+
print(f"\nError: {validation_result.error_message}", file=sys.stderr)
|
|
290
|
+
sys.exit(1)
|
|
291
|
+
|
|
292
|
+
print("\nStarting reduction...", file=sys.stderr, flush=True)
|
|
293
|
+
|
|
276
294
|
state_kwargs: dict[str, Any] = {
|
|
277
295
|
"input_type": input_type,
|
|
278
296
|
"in_place": in_place,
|
|
@@ -307,8 +325,6 @@ def main(
|
|
|
307
325
|
|
|
308
326
|
state = ShrinkRayDirectoryState(initial=initial, **state_kwargs)
|
|
309
327
|
|
|
310
|
-
trio.run(state.check_formatter)
|
|
311
|
-
|
|
312
328
|
else:
|
|
313
329
|
try:
|
|
314
330
|
os.remove(backup)
|
|
@@ -323,8 +339,6 @@ def main(
|
|
|
323
339
|
|
|
324
340
|
state = ShrinkRayStateSingleFile(initial=initial, **state_kwargs)
|
|
325
341
|
|
|
326
|
-
trio.run(state.check_formatter)
|
|
327
|
-
|
|
328
342
|
if ui_type == UIType.textual:
|
|
329
343
|
from shrinkray.tui import run_textual_ui
|
|
330
344
|
|
shrinkray/state.py
CHANGED
|
@@ -8,6 +8,7 @@ import subprocess
|
|
|
8
8
|
import sys
|
|
9
9
|
import time
|
|
10
10
|
from abc import ABC, abstractmethod
|
|
11
|
+
from collections import deque
|
|
11
12
|
from datetime import timedelta
|
|
12
13
|
from tempfile import TemporaryDirectory
|
|
13
14
|
from typing import Any, TypeVar
|
|
@@ -58,6 +59,125 @@ def compute_dynamic_timeout(runtime: float) -> float:
|
|
|
58
59
|
)
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
@define
|
|
63
|
+
class TestOutputManager:
|
|
64
|
+
"""Manages temporary files for test output capture.
|
|
65
|
+
|
|
66
|
+
Allocates unique files for each test's stdout/stderr output,
|
|
67
|
+
tracks active and completed tests, and cleans up old files.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
output_dir: str
|
|
71
|
+
max_files: int = 50
|
|
72
|
+
max_age_seconds: float = 60.0
|
|
73
|
+
min_display_seconds: float = 1.0 # Minimum time to show completed output
|
|
74
|
+
grace_period_seconds: float = (
|
|
75
|
+
0.5 # Extra time to wait for new test after min_display
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
_sequence: int = 0
|
|
79
|
+
_active_outputs: dict[int, str] = {}
|
|
80
|
+
_completed_outputs: deque[tuple[int, str, float]] = deque()
|
|
81
|
+
|
|
82
|
+
def __attrs_post_init__(self) -> None:
|
|
83
|
+
# Initialize mutable defaults
|
|
84
|
+
self._active_outputs = {}
|
|
85
|
+
self._completed_outputs = deque()
|
|
86
|
+
|
|
87
|
+
def allocate_output_file(self) -> tuple[int, str]:
|
|
88
|
+
"""Allocate a new output file for a test. Returns (test_id, file_path)."""
|
|
89
|
+
test_id = self._sequence
|
|
90
|
+
self._sequence += 1
|
|
91
|
+
file_path = os.path.join(self.output_dir, f"test_{test_id}.log")
|
|
92
|
+
self._active_outputs[test_id] = file_path
|
|
93
|
+
return test_id, file_path
|
|
94
|
+
|
|
95
|
+
def mark_completed(self, test_id: int) -> None:
|
|
96
|
+
"""Mark a test as completed and move to completed queue."""
|
|
97
|
+
if test_id in self._active_outputs:
|
|
98
|
+
file_path = self._active_outputs.pop(test_id)
|
|
99
|
+
self._completed_outputs.append((test_id, file_path, time.time()))
|
|
100
|
+
self._cleanup_old_files()
|
|
101
|
+
|
|
102
|
+
def _cleanup_old_files(self) -> None:
|
|
103
|
+
"""Remove old output files based on count and age limits."""
|
|
104
|
+
now = time.time()
|
|
105
|
+
# Remove files older than max_age_seconds
|
|
106
|
+
while (
|
|
107
|
+
self._completed_outputs
|
|
108
|
+
and now - self._completed_outputs[0][2] > self.max_age_seconds
|
|
109
|
+
):
|
|
110
|
+
_, file_path, _ = self._completed_outputs.popleft()
|
|
111
|
+
self._safe_delete(file_path)
|
|
112
|
+
# Remove excess files beyond max_files
|
|
113
|
+
while len(self._completed_outputs) > self.max_files:
|
|
114
|
+
_, file_path, _ = self._completed_outputs.popleft()
|
|
115
|
+
self._safe_delete(file_path)
|
|
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
|
|
132
|
+
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def get_current_output_path(self) -> str | None:
|
|
136
|
+
"""Get the most relevant output file path.
|
|
137
|
+
|
|
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.
|
|
141
|
+
"""
|
|
142
|
+
# Active tests always take priority
|
|
143
|
+
if self._active_outputs:
|
|
144
|
+
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)
|
|
151
|
+
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.
|
|
157
|
+
|
|
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
|
|
163
|
+
|
|
164
|
+
def cleanup_all(self) -> None:
|
|
165
|
+
"""Clean up all output files (called on shutdown)."""
|
|
166
|
+
for file_path in self._active_outputs.values():
|
|
167
|
+
self._safe_delete(file_path)
|
|
168
|
+
for _, file_path, _ in self._completed_outputs:
|
|
169
|
+
self._safe_delete(file_path)
|
|
170
|
+
self._active_outputs.clear()
|
|
171
|
+
self._completed_outputs.clear()
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _safe_delete(path: str) -> None:
|
|
175
|
+
try:
|
|
176
|
+
os.unlink(path)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
61
181
|
@define(slots=False)
|
|
62
182
|
class ShrinkRayState[TestCase](ABC):
|
|
63
183
|
input_type: Any # InputType from __main__
|
|
@@ -91,6 +211,9 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
91
211
|
# Stores the output from the last debug run
|
|
92
212
|
_last_debug_output: str = ""
|
|
93
213
|
|
|
214
|
+
# Optional output manager for capturing test output (TUI mode)
|
|
215
|
+
output_manager: TestOutputManager | None = None
|
|
216
|
+
|
|
94
217
|
def __attrs_post_init__(self):
|
|
95
218
|
self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
|
|
96
219
|
self.setup_formatter()
|
|
@@ -175,8 +298,17 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
175
298
|
|
|
176
299
|
return completed.returncode
|
|
177
300
|
|
|
178
|
-
#
|
|
179
|
-
|
|
301
|
+
# Determine output handling
|
|
302
|
+
test_id: int | None = None
|
|
303
|
+
output_file_handle = None
|
|
304
|
+
|
|
305
|
+
if self.output_manager is not None:
|
|
306
|
+
# Capture output to a file for TUI display
|
|
307
|
+
test_id, output_path = self.output_manager.allocate_output_file()
|
|
308
|
+
output_file_handle = open(output_path, "wb")
|
|
309
|
+
kwargs["stdout"] = output_file_handle.fileno()
|
|
310
|
+
kwargs["stderr"] = subprocess.STDOUT # Combine stderr into stdout
|
|
311
|
+
elif self.volume == Volume.debug:
|
|
180
312
|
# Inherit stderr from parent process to stream output in real-time
|
|
181
313
|
kwargs["stderr"] = None # None means inherit
|
|
182
314
|
kwargs["stdout"] = subprocess.DEVNULL
|
|
@@ -185,59 +317,66 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
185
317
|
kwargs["stdout"] = subprocess.DEVNULL
|
|
186
318
|
kwargs["stderr"] = subprocess.DEVNULL
|
|
187
319
|
|
|
188
|
-
|
|
320
|
+
try:
|
|
321
|
+
async with trio.open_nursery() as nursery:
|
|
189
322
|
|
|
190
|
-
|
|
191
|
-
|
|
323
|
+
def call_with_kwargs(task_status=trio.TASK_STATUS_IGNORED): # type: ignore
|
|
324
|
+
return trio.run_process(command, **kwargs, task_status=task_status)
|
|
192
325
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
try:
|
|
197
|
-
# Determine effective timeout for this call
|
|
198
|
-
if self.first_call:
|
|
199
|
-
# For first call: use calibration timeout if dynamic, otherwise 10x explicit timeout
|
|
200
|
-
if self.timeout is None:
|
|
201
|
-
effective_timeout = DYNAMIC_TIMEOUT_CALIBRATION_TIMEOUT
|
|
202
|
-
else:
|
|
203
|
-
effective_timeout = self.timeout * 10
|
|
204
|
-
else:
|
|
205
|
-
# For subsequent calls, timeout must be set (either explicit or computed)
|
|
206
|
-
assert self.timeout is not None
|
|
207
|
-
effective_timeout = self.timeout
|
|
208
|
-
|
|
209
|
-
with trio.move_on_after(effective_timeout):
|
|
210
|
-
await sp.wait()
|
|
211
|
-
|
|
212
|
-
runtime = time.time() - start_time
|
|
213
|
-
|
|
214
|
-
if sp.returncode is None:
|
|
215
|
-
# Process didn't terminate before timeout - kill it
|
|
216
|
-
await self._interrupt_wait_and_kill(sp)
|
|
217
|
-
|
|
218
|
-
# Check for timeout violation (only when timeout is explicitly set)
|
|
219
|
-
if (
|
|
220
|
-
self.timeout is not None
|
|
221
|
-
and runtime >= self.timeout
|
|
222
|
-
and self.first_call
|
|
223
|
-
):
|
|
224
|
-
raise TimeoutExceededOnInitial(
|
|
225
|
-
timeout=self.timeout,
|
|
226
|
-
runtime=runtime,
|
|
227
|
-
)
|
|
228
|
-
finally:
|
|
229
|
-
if self.first_call:
|
|
230
|
-
self.initial_exit_code = sp.returncode
|
|
231
|
-
# Set dynamic timeout if not explicitly specified
|
|
232
|
-
if self.timeout is None:
|
|
233
|
-
runtime = time.time() - start_time
|
|
234
|
-
self.timeout = compute_dynamic_timeout(runtime)
|
|
235
|
-
self.first_call = False
|
|
326
|
+
start_time = time.time()
|
|
327
|
+
sp = await nursery.start(call_with_kwargs)
|
|
236
328
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
329
|
+
try:
|
|
330
|
+
# Determine effective timeout for this call
|
|
331
|
+
if self.first_call:
|
|
332
|
+
# For first call: use calibration timeout if dynamic, otherwise 10x explicit timeout
|
|
333
|
+
if self.timeout is None:
|
|
334
|
+
effective_timeout = DYNAMIC_TIMEOUT_CALIBRATION_TIMEOUT
|
|
335
|
+
else:
|
|
336
|
+
effective_timeout = self.timeout * 10
|
|
337
|
+
else:
|
|
338
|
+
# For subsequent calls, timeout must be set (either explicit or computed)
|
|
339
|
+
assert self.timeout is not None
|
|
340
|
+
effective_timeout = self.timeout
|
|
341
|
+
|
|
342
|
+
with trio.move_on_after(effective_timeout):
|
|
343
|
+
await sp.wait()
|
|
344
|
+
|
|
345
|
+
runtime = time.time() - start_time
|
|
346
|
+
|
|
347
|
+
if sp.returncode is None:
|
|
348
|
+
# Process didn't terminate before timeout - kill it
|
|
349
|
+
await self._interrupt_wait_and_kill(sp)
|
|
350
|
+
|
|
351
|
+
# Check for timeout violation (only when timeout is explicitly set)
|
|
352
|
+
if (
|
|
353
|
+
self.timeout is not None
|
|
354
|
+
and runtime >= self.timeout
|
|
355
|
+
and self.first_call
|
|
356
|
+
):
|
|
357
|
+
raise TimeoutExceededOnInitial(
|
|
358
|
+
timeout=self.timeout,
|
|
359
|
+
runtime=runtime,
|
|
360
|
+
)
|
|
361
|
+
finally:
|
|
362
|
+
if self.first_call:
|
|
363
|
+
self.initial_exit_code = sp.returncode
|
|
364
|
+
# Set dynamic timeout if not explicitly specified
|
|
365
|
+
if self.timeout is None:
|
|
366
|
+
runtime = time.time() - start_time
|
|
367
|
+
self.timeout = compute_dynamic_timeout(runtime)
|
|
368
|
+
self.first_call = False
|
|
369
|
+
|
|
370
|
+
result: int | None = sp.returncode
|
|
371
|
+
assert result is not None
|
|
372
|
+
|
|
373
|
+
return result
|
|
374
|
+
finally:
|
|
375
|
+
# Clean up output file handle and mark test as completed
|
|
376
|
+
if output_file_handle is not None:
|
|
377
|
+
output_file_handle.close()
|
|
378
|
+
if test_id is not None and self.output_manager is not None:
|
|
379
|
+
self.output_manager.mark_completed(test_id)
|
|
241
380
|
|
|
242
381
|
async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
|
|
243
382
|
# Lazy import
|
shrinkray/subprocess/client.py
CHANGED
|
@@ -136,6 +136,7 @@ class SubprocessClient:
|
|
|
136
136
|
no_clang_delta: bool = False,
|
|
137
137
|
clang_delta: str = "",
|
|
138
138
|
trivial_is_error: bool = True,
|
|
139
|
+
skip_validation: bool = False,
|
|
139
140
|
) -> Response:
|
|
140
141
|
"""Start the reduction process."""
|
|
141
142
|
params: dict[str, Any] = {
|
|
@@ -149,6 +150,7 @@ class SubprocessClient:
|
|
|
149
150
|
"no_clang_delta": no_clang_delta,
|
|
150
151
|
"clang_delta": clang_delta,
|
|
151
152
|
"trivial_is_error": trivial_is_error,
|
|
153
|
+
"skip_validation": skip_validation,
|
|
152
154
|
}
|
|
153
155
|
if parallelism is not None:
|
|
154
156
|
params["parallelism"] = parallelism
|
shrinkray/subprocess/protocol.py
CHANGED
|
@@ -56,6 +56,10 @@ class ProgressUpdate:
|
|
|
56
56
|
current_pass_name: str = ""
|
|
57
57
|
# List of disabled pass names
|
|
58
58
|
disabled_passes: list[str] = field(default_factory=list)
|
|
59
|
+
# Test output preview (last 4KB of current/recent test output)
|
|
60
|
+
test_output_preview: str = ""
|
|
61
|
+
# Currently running test ID (None if no test running)
|
|
62
|
+
active_test_id: int | None = None
|
|
59
63
|
|
|
60
64
|
|
|
61
65
|
@dataclass
|
|
@@ -115,6 +119,8 @@ def serialize(msg: Request | Response | ProgressUpdate) -> str:
|
|
|
115
119
|
],
|
|
116
120
|
"current_pass_name": msg.current_pass_name,
|
|
117
121
|
"disabled_passes": msg.disabled_passes,
|
|
122
|
+
"test_output_preview": msg.test_output_preview,
|
|
123
|
+
"active_test_id": msg.active_test_id,
|
|
118
124
|
},
|
|
119
125
|
}
|
|
120
126
|
else:
|
|
@@ -162,6 +168,8 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
|
|
|
162
168
|
pass_stats=pass_stats_data,
|
|
163
169
|
current_pass_name=d.get("current_pass_name", ""),
|
|
164
170
|
disabled_passes=d.get("disabled_passes", []),
|
|
171
|
+
test_output_preview=d.get("test_output_preview", ""),
|
|
172
|
+
active_test_id=d.get("active_test_id"),
|
|
165
173
|
)
|
|
166
174
|
|
|
167
175
|
# Check for response (has "result" or "error" field)
|
shrinkray/subprocess/worker.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Worker subprocess that runs the reducer with trio and communicates via JSON protocol."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import shutil
|
|
4
5
|
import sys
|
|
6
|
+
import tempfile
|
|
5
7
|
import time
|
|
6
8
|
import traceback
|
|
7
9
|
from contextlib import aclosing
|
|
@@ -62,6 +64,8 @@ class ReducerWorker:
|
|
|
62
64
|
# I/O streams - None means use stdin/stdout
|
|
63
65
|
self._input_stream = input_stream
|
|
64
66
|
self._output_stream = output_stream
|
|
67
|
+
# Output directory for test output capture (cleaned up on shutdown)
|
|
68
|
+
self._output_dir: str | None = None
|
|
65
69
|
|
|
66
70
|
async def emit(self, msg: Response | ProgressUpdate) -> None:
|
|
67
71
|
"""Write a message to the output stream."""
|
|
@@ -163,6 +167,7 @@ class ReducerWorker:
|
|
|
163
167
|
from shrinkray.state import (
|
|
164
168
|
ShrinkRayDirectoryState,
|
|
165
169
|
ShrinkRayStateSingleFile,
|
|
170
|
+
TestOutputManager,
|
|
166
171
|
)
|
|
167
172
|
from shrinkray.work import Volume
|
|
168
173
|
|
|
@@ -178,6 +183,7 @@ class ReducerWorker:
|
|
|
178
183
|
no_clang_delta = params.get("no_clang_delta", False)
|
|
179
184
|
clang_delta_path = params.get("clang_delta", "")
|
|
180
185
|
trivial_is_error = params.get("trivial_is_error", True)
|
|
186
|
+
skip_validation = params.get("skip_validation", False)
|
|
181
187
|
|
|
182
188
|
clang_delta_executable = None
|
|
183
189
|
if os.path.splitext(filename)[1] in C_FILE_EXTENSIONS and not no_clang_delta:
|
|
@@ -213,12 +219,18 @@ class ReducerWorker:
|
|
|
213
219
|
initial = reader.read()
|
|
214
220
|
self.state = ShrinkRayStateSingleFile(initial=initial, **state_kwargs)
|
|
215
221
|
|
|
222
|
+
# Create output manager for test output capture (always enabled for TUI)
|
|
223
|
+
self._output_dir = tempfile.mkdtemp(prefix="shrinkray-output-")
|
|
224
|
+
self.state.output_manager = TestOutputManager(output_dir=self._output_dir)
|
|
225
|
+
|
|
216
226
|
self.problem = self.state.problem
|
|
217
227
|
self.reducer = self.state.reducer
|
|
218
228
|
|
|
219
229
|
# Validate initial example before starting - this will raise
|
|
220
|
-
# InvalidInitialExample if the initial test case fails
|
|
221
|
-
|
|
230
|
+
# InvalidInitialExample if the initial test case fails.
|
|
231
|
+
# Skip if validation was already done by the caller (e.g., main()).
|
|
232
|
+
if not skip_validation:
|
|
233
|
+
await self.problem.setup()
|
|
222
234
|
|
|
223
235
|
self.running = True
|
|
224
236
|
|
|
@@ -300,6 +312,32 @@ class ReducerWorker:
|
|
|
300
312
|
return Response(id=request_id, result={"status": "skipped"})
|
|
301
313
|
return Response(id=request_id, error="Reducer does not support pass control")
|
|
302
314
|
|
|
315
|
+
def _get_test_output_preview(self) -> tuple[str, int | None]:
|
|
316
|
+
"""Get preview of current test output and active test ID."""
|
|
317
|
+
if self.state is None or self.state.output_manager is None:
|
|
318
|
+
return "", None
|
|
319
|
+
|
|
320
|
+
manager = self.state.output_manager
|
|
321
|
+
active_test_id = manager.get_active_test_id()
|
|
322
|
+
output_path = manager.get_current_output_path()
|
|
323
|
+
|
|
324
|
+
if output_path is None:
|
|
325
|
+
return "", active_test_id
|
|
326
|
+
|
|
327
|
+
# Read last 4KB of file
|
|
328
|
+
try:
|
|
329
|
+
with open(output_path, "rb") as f:
|
|
330
|
+
f.seek(0, 2) # Seek to end
|
|
331
|
+
size = f.tell()
|
|
332
|
+
if size > 4096:
|
|
333
|
+
f.seek(-4096, 2)
|
|
334
|
+
else:
|
|
335
|
+
f.seek(0)
|
|
336
|
+
data = f.read()
|
|
337
|
+
return data.decode("utf-8", errors="replace"), active_test_id
|
|
338
|
+
except OSError:
|
|
339
|
+
return "", active_test_id
|
|
340
|
+
|
|
303
341
|
def _get_content_preview(self) -> tuple[str, bool]:
|
|
304
342
|
"""Get a preview of the current test case content."""
|
|
305
343
|
if self.problem is None:
|
|
@@ -402,6 +440,9 @@ class ReducerWorker:
|
|
|
402
440
|
else:
|
|
403
441
|
disabled_passes = []
|
|
404
442
|
|
|
443
|
+
# Get test output preview
|
|
444
|
+
test_output_preview, active_test_id = self._get_test_output_preview()
|
|
445
|
+
|
|
405
446
|
return ProgressUpdate(
|
|
406
447
|
status=self.reducer.status if self.reducer else "",
|
|
407
448
|
size=stats.current_test_case_size,
|
|
@@ -420,6 +461,8 @@ class ReducerWorker:
|
|
|
420
461
|
pass_stats=pass_stats_list,
|
|
421
462
|
current_pass_name=current_pass_name,
|
|
422
463
|
disabled_passes=disabled_passes,
|
|
464
|
+
test_output_preview=test_output_preview,
|
|
465
|
+
active_test_id=active_test_id,
|
|
423
466
|
)
|
|
424
467
|
|
|
425
468
|
async def emit_progress_updates(self) -> None:
|
|
@@ -469,25 +512,32 @@ class ReducerWorker:
|
|
|
469
512
|
|
|
470
513
|
async def run(self) -> None:
|
|
471
514
|
"""Main entry point for the worker."""
|
|
472
|
-
|
|
473
|
-
|
|
515
|
+
try:
|
|
516
|
+
async with trio.open_nursery() as nursery:
|
|
517
|
+
await nursery.start(self.read_commands)
|
|
474
518
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
519
|
+
# Wait for start command
|
|
520
|
+
while not self.running:
|
|
521
|
+
await trio.sleep(0.01)
|
|
478
522
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
523
|
+
# Start progress updates and reducer
|
|
524
|
+
nursery.start_soon(self.emit_progress_updates)
|
|
525
|
+
await self.run_reducer()
|
|
482
526
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
527
|
+
# Emit final progress update before completion
|
|
528
|
+
final_update = await self._build_progress_update()
|
|
529
|
+
if final_update is not None:
|
|
530
|
+
await self.emit(final_update)
|
|
487
531
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
532
|
+
# Signal completion
|
|
533
|
+
await self.emit(Response(id="", result={"status": "completed"}))
|
|
534
|
+
nursery.cancel_scope.cancel()
|
|
535
|
+
finally:
|
|
536
|
+
# Clean up test output files and temp directory
|
|
537
|
+
if self.state is not None and self.state.output_manager is not None:
|
|
538
|
+
self.state.output_manager.cleanup_all()
|
|
539
|
+
if self._output_dir is not None and os.path.isdir(self._output_dir):
|
|
540
|
+
shutil.rmtree(self._output_dir, ignore_errors=True)
|
|
491
541
|
|
|
492
542
|
|
|
493
543
|
def main() -> None:
|
shrinkray/tui.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Textual-based TUI for Shrink Ray."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import time
|
|
4
5
|
import traceback
|
|
5
6
|
from collections.abc import AsyncGenerator
|
|
6
7
|
from contextlib import aclosing
|
|
@@ -11,14 +12,18 @@ import humanize
|
|
|
11
12
|
from rich.text import Text
|
|
12
13
|
from textual import work
|
|
13
14
|
from textual.app import App, ComposeResult
|
|
14
|
-
from textual.containers import Vertical, VerticalScroll
|
|
15
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
15
16
|
from textual.reactive import reactive
|
|
16
17
|
from textual.screen import ModalScreen
|
|
17
18
|
from textual.theme import Theme
|
|
18
19
|
from textual.widgets import DataTable, Footer, Header, Label, Static
|
|
19
20
|
|
|
20
21
|
from shrinkray.subprocess.client import SubprocessClient
|
|
21
|
-
from shrinkray.subprocess.protocol import
|
|
22
|
+
from shrinkray.subprocess.protocol import (
|
|
23
|
+
PassStatsData,
|
|
24
|
+
ProgressUpdate,
|
|
25
|
+
Response,
|
|
26
|
+
)
|
|
22
27
|
|
|
23
28
|
|
|
24
29
|
ThemeMode = Literal["auto", "dark", "light"]
|
|
@@ -229,8 +234,6 @@ class ContentPreview(Static):
|
|
|
229
234
|
_pending_hex_mode: bool = False
|
|
230
235
|
|
|
231
236
|
def update_content(self, content: str, hex_mode: bool) -> None:
|
|
232
|
-
import time
|
|
233
|
-
|
|
234
237
|
# Store the pending content
|
|
235
238
|
self._pending_content = content
|
|
236
239
|
self._pending_hex_mode = hex_mode
|
|
@@ -305,6 +308,67 @@ class ContentPreview(Static):
|
|
|
305
308
|
)
|
|
306
309
|
|
|
307
310
|
|
|
311
|
+
class OutputPreview(Static):
|
|
312
|
+
"""Widget to display test output preview."""
|
|
313
|
+
|
|
314
|
+
output_content = reactive("")
|
|
315
|
+
active_test_id: reactive[int | None] = reactive(None)
|
|
316
|
+
_last_update_time: float = 0.0
|
|
317
|
+
_last_seen_test_id: int | None = None # Track last test ID for "completed" message
|
|
318
|
+
|
|
319
|
+
def update_output(self, content: str, test_id: int | None) -> None:
|
|
320
|
+
# Throttle updates to every 200ms
|
|
321
|
+
now = time.time()
|
|
322
|
+
if now - self._last_update_time < 0.2:
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
self._last_update_time = now
|
|
326
|
+
self.output_content = content
|
|
327
|
+
# Track the last test ID we've seen (for showing in "completed" message)
|
|
328
|
+
if test_id is not None:
|
|
329
|
+
self._last_seen_test_id = test_id
|
|
330
|
+
self.active_test_id = test_id
|
|
331
|
+
self.refresh(layout=True)
|
|
332
|
+
|
|
333
|
+
def _get_available_lines(self) -> int:
|
|
334
|
+
"""Get the number of lines available for display based on container size."""
|
|
335
|
+
try:
|
|
336
|
+
parent = self.parent
|
|
337
|
+
if parent and hasattr(parent, "size"):
|
|
338
|
+
parent_size = parent.size # type: ignore[union-attr]
|
|
339
|
+
if parent_size.height > 0:
|
|
340
|
+
return max(10, parent_size.height - 3)
|
|
341
|
+
if self.app and self.app.size.height > 0:
|
|
342
|
+
return max(10, self.app.size.height - 15)
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
return 30
|
|
346
|
+
|
|
347
|
+
def render(self) -> str:
|
|
348
|
+
# Header line
|
|
349
|
+
if self.active_test_id is not None:
|
|
350
|
+
header = f"[green]Test #{self.active_test_id} running...[/green]"
|
|
351
|
+
elif self.output_content and self._last_seen_test_id is not None:
|
|
352
|
+
header = f"[dim]Test #{self._last_seen_test_id} completed[/dim]"
|
|
353
|
+
else:
|
|
354
|
+
header = "[dim]No test output yet...[/dim]"
|
|
355
|
+
|
|
356
|
+
if not self.output_content:
|
|
357
|
+
return header
|
|
358
|
+
|
|
359
|
+
available_lines = self._get_available_lines()
|
|
360
|
+
lines = self.output_content.split("\n")
|
|
361
|
+
|
|
362
|
+
# Show tail of output (most recent lines)
|
|
363
|
+
if len(lines) <= available_lines:
|
|
364
|
+
return f"{header}\n{self.output_content}"
|
|
365
|
+
|
|
366
|
+
# Truncate from the beginning
|
|
367
|
+
truncated_lines = lines[-(available_lines):]
|
|
368
|
+
skipped = len(lines) - available_lines
|
|
369
|
+
return f"{header}\n... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
|
|
370
|
+
|
|
371
|
+
|
|
308
372
|
class HelpScreen(ModalScreen[None]):
|
|
309
373
|
"""Modal screen showing keyboard shortcuts help."""
|
|
310
374
|
|
|
@@ -595,10 +659,32 @@ class ShrinkRayApp(App[None]):
|
|
|
595
659
|
margin: 0 1;
|
|
596
660
|
}
|
|
597
661
|
|
|
662
|
+
#content-area {
|
|
663
|
+
height: 1fr;
|
|
664
|
+
}
|
|
665
|
+
|
|
598
666
|
#content-container {
|
|
599
667
|
border: solid blue;
|
|
600
668
|
margin: 1;
|
|
601
|
-
|
|
669
|
+
padding: 1;
|
|
670
|
+
width: 1fr;
|
|
671
|
+
height: 100%;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
#content-container:dark {
|
|
675
|
+
border: solid lightskyblue;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
#output-container {
|
|
679
|
+
border: solid blue;
|
|
680
|
+
margin: 1;
|
|
681
|
+
padding: 1;
|
|
682
|
+
width: 1fr;
|
|
683
|
+
height: 100%;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
#output-container:dark {
|
|
687
|
+
border: solid lightskyblue;
|
|
602
688
|
}
|
|
603
689
|
"""
|
|
604
690
|
|
|
@@ -661,8 +747,13 @@ class ShrinkRayApp(App[None]):
|
|
|
661
747
|
)
|
|
662
748
|
with Vertical(id="stats-container"):
|
|
663
749
|
yield StatsDisplay(id="stats-display")
|
|
664
|
-
with
|
|
665
|
-
|
|
750
|
+
with Horizontal(id="content-area"):
|
|
751
|
+
with VerticalScroll(id="content-container") as content_scroll:
|
|
752
|
+
content_scroll.border_title = "Recent Reductions"
|
|
753
|
+
yield ContentPreview(id="content-preview")
|
|
754
|
+
with VerticalScroll(id="output-container") as output_scroll:
|
|
755
|
+
output_scroll.border_title = "Test Output"
|
|
756
|
+
yield OutputPreview(id="output-preview")
|
|
666
757
|
yield Footer()
|
|
667
758
|
|
|
668
759
|
async def on_mount(self) -> None:
|
|
@@ -695,7 +786,7 @@ class ShrinkRayApp(App[None]):
|
|
|
695
786
|
|
|
696
787
|
await self._client.start()
|
|
697
788
|
|
|
698
|
-
# Start the reduction
|
|
789
|
+
# Start the reduction - validation was already done by main()
|
|
699
790
|
response = await self._client.start_reduction(
|
|
700
791
|
file_path=self._file_path,
|
|
701
792
|
test=self._test,
|
|
@@ -709,6 +800,7 @@ class ShrinkRayApp(App[None]):
|
|
|
709
800
|
no_clang_delta=self._no_clang_delta,
|
|
710
801
|
clang_delta=self._clang_delta,
|
|
711
802
|
trivial_is_error=self._trivial_is_error,
|
|
803
|
+
skip_validation=True,
|
|
712
804
|
)
|
|
713
805
|
|
|
714
806
|
if response.error:
|
|
@@ -719,6 +811,7 @@ class ShrinkRayApp(App[None]):
|
|
|
719
811
|
# Monitor progress (client is already started and reduction is running)
|
|
720
812
|
stats_display = self.query_one("#stats-display", StatsDisplay)
|
|
721
813
|
content_preview = self.query_one("#content-preview", ContentPreview)
|
|
814
|
+
output_preview = self.query_one("#output-preview", OutputPreview)
|
|
722
815
|
|
|
723
816
|
async with aclosing(self._client.get_progress_updates()) as updates:
|
|
724
817
|
async for update in updates:
|
|
@@ -726,6 +819,9 @@ class ShrinkRayApp(App[None]):
|
|
|
726
819
|
content_preview.update_content(
|
|
727
820
|
update.content_preview, update.hex_mode
|
|
728
821
|
)
|
|
822
|
+
output_preview.update_output(
|
|
823
|
+
update.test_output_preview, update.active_test_id
|
|
824
|
+
)
|
|
729
825
|
self._latest_pass_stats = update.pass_stats
|
|
730
826
|
self._current_pass_name = update.current_pass_name
|
|
731
827
|
self._disabled_passes = update.disabled_passes
|
|
@@ -741,7 +837,10 @@ class ShrinkRayApp(App[None]):
|
|
|
741
837
|
# Check if there was an error from the worker
|
|
742
838
|
if self._client.error_message:
|
|
743
839
|
# Exit immediately on error, printing the error message
|
|
744
|
-
self.exit(
|
|
840
|
+
self.exit(
|
|
841
|
+
return_code=1,
|
|
842
|
+
message=f"Error: {self._client.error_message}",
|
|
843
|
+
)
|
|
745
844
|
return
|
|
746
845
|
elif self._exit_on_completion:
|
|
747
846
|
self.exit()
|
|
@@ -804,54 +903,6 @@ class ShrinkRayApp(App[None]):
|
|
|
804
903
|
return self._completed
|
|
805
904
|
|
|
806
905
|
|
|
807
|
-
async def _validate_initial_example(
|
|
808
|
-
file_path: str,
|
|
809
|
-
test: list[str],
|
|
810
|
-
parallelism: int | None,
|
|
811
|
-
timeout: float | None,
|
|
812
|
-
seed: int,
|
|
813
|
-
input_type: str,
|
|
814
|
-
in_place: bool,
|
|
815
|
-
formatter: str,
|
|
816
|
-
volume: str,
|
|
817
|
-
no_clang_delta: bool,
|
|
818
|
-
clang_delta: str,
|
|
819
|
-
trivial_is_error: bool,
|
|
820
|
-
) -> str | None:
|
|
821
|
-
"""Validate initial example before showing TUI.
|
|
822
|
-
|
|
823
|
-
Returns error_message if validation failed, None if it passed.
|
|
824
|
-
"""
|
|
825
|
-
debug_mode = volume == "debug"
|
|
826
|
-
client = SubprocessClient(debug_mode=debug_mode)
|
|
827
|
-
try:
|
|
828
|
-
await client.start()
|
|
829
|
-
|
|
830
|
-
response = await client.start_reduction(
|
|
831
|
-
file_path=file_path,
|
|
832
|
-
test=test,
|
|
833
|
-
parallelism=parallelism,
|
|
834
|
-
timeout=timeout,
|
|
835
|
-
seed=seed,
|
|
836
|
-
input_type=input_type,
|
|
837
|
-
in_place=in_place,
|
|
838
|
-
formatter=formatter,
|
|
839
|
-
volume=volume,
|
|
840
|
-
no_clang_delta=no_clang_delta,
|
|
841
|
-
clang_delta=clang_delta,
|
|
842
|
-
trivial_is_error=trivial_is_error,
|
|
843
|
-
)
|
|
844
|
-
|
|
845
|
-
if response.error:
|
|
846
|
-
return response.error
|
|
847
|
-
|
|
848
|
-
# Validation passed - cancel this reduction since TUI will start fresh
|
|
849
|
-
await client.cancel()
|
|
850
|
-
return None
|
|
851
|
-
finally:
|
|
852
|
-
await client.close()
|
|
853
|
-
|
|
854
|
-
|
|
855
906
|
def run_textual_ui(
|
|
856
907
|
file_path: str,
|
|
857
908
|
test: list[str],
|
|
@@ -868,43 +919,14 @@ def run_textual_ui(
|
|
|
868
919
|
exit_on_completion: bool = True,
|
|
869
920
|
theme: ThemeMode = "auto",
|
|
870
921
|
) -> None:
|
|
871
|
-
"""Run the textual TUI.
|
|
872
|
-
import asyncio
|
|
873
|
-
import sys
|
|
874
|
-
|
|
875
|
-
print("Validating initial example...", flush=True)
|
|
876
|
-
|
|
877
|
-
# Validate initial example before showing TUI
|
|
878
|
-
async def validate():
|
|
879
|
-
return await _validate_initial_example(
|
|
880
|
-
file_path=file_path,
|
|
881
|
-
test=test,
|
|
882
|
-
parallelism=parallelism,
|
|
883
|
-
timeout=timeout,
|
|
884
|
-
seed=seed,
|
|
885
|
-
input_type=input_type,
|
|
886
|
-
in_place=in_place,
|
|
887
|
-
formatter=formatter,
|
|
888
|
-
volume=volume,
|
|
889
|
-
no_clang_delta=no_clang_delta,
|
|
890
|
-
clang_delta=clang_delta,
|
|
891
|
-
trivial_is_error=trivial_is_error,
|
|
892
|
-
)
|
|
922
|
+
"""Run the textual TUI.
|
|
893
923
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
traceback.print_exc()
|
|
900
|
-
print(f"Error: {e}", file=sys.stderr)
|
|
901
|
-
sys.exit(1)
|
|
902
|
-
|
|
903
|
-
if error:
|
|
904
|
-
print(f"Error: {error}", file=sys.stderr)
|
|
905
|
-
sys.exit(1)
|
|
924
|
+
Note: Validation must be done before calling this function.
|
|
925
|
+
The caller (main()) is responsible for running run_validation() first.
|
|
926
|
+
"""
|
|
927
|
+
import sys
|
|
906
928
|
|
|
907
|
-
#
|
|
929
|
+
# Start the TUI app - validation has already been done by main()
|
|
908
930
|
app = ShrinkRayApp(
|
|
909
931
|
file_path=file_path,
|
|
910
932
|
test=test,
|
shrinkray/validation.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Initial validation of interestingness tests before reduction.
|
|
2
|
+
|
|
3
|
+
This module provides validation that runs in the main process using trio,
|
|
4
|
+
before the TUI is launched. It prints commands and temporary directories
|
|
5
|
+
to stderr so users can understand what's happening with slow tests, and
|
|
6
|
+
preserves temporary directories on failure for debugging.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import traceback
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
import trio
|
|
20
|
+
|
|
21
|
+
from shrinkray.cli import InputType
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ValidationResult:
|
|
26
|
+
"""Result of initial validation."""
|
|
27
|
+
|
|
28
|
+
success: bool
|
|
29
|
+
error_message: str | None = None
|
|
30
|
+
exit_code: int | None = None
|
|
31
|
+
# Temp directories to clean up only on success
|
|
32
|
+
temp_dirs: list[str] | None = None
|
|
33
|
+
# Whether formatter is usable (None if no formatter specified)
|
|
34
|
+
formatter_works: bool | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_command(
|
|
38
|
+
test: list[str],
|
|
39
|
+
working_file: str,
|
|
40
|
+
input_type: InputType,
|
|
41
|
+
) -> list[str]:
|
|
42
|
+
"""Build the command to run, adding test file path if needed."""
|
|
43
|
+
if input_type.enabled(InputType.arg):
|
|
44
|
+
return test + [working_file]
|
|
45
|
+
return list(test)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _format_command_for_display(command: list[str], cwd: str) -> str:
|
|
49
|
+
"""Format a command for display, with cd on its own line and relative paths.
|
|
50
|
+
|
|
51
|
+
Returns a multi-line string with:
|
|
52
|
+
- cd <directory>
|
|
53
|
+
- <command with relative paths for files in cwd>
|
|
54
|
+
"""
|
|
55
|
+
# Convert absolute paths within cwd to relative paths for readability
|
|
56
|
+
display_parts = []
|
|
57
|
+
for part in command:
|
|
58
|
+
if part.startswith(cwd + os.sep):
|
|
59
|
+
# Convert to relative path
|
|
60
|
+
display_parts.append(os.path.relpath(part, cwd))
|
|
61
|
+
else:
|
|
62
|
+
display_parts.append(part)
|
|
63
|
+
|
|
64
|
+
quoted = " ".join(shlex.quote(part) for part in display_parts)
|
|
65
|
+
return f"cd {shlex.quote(cwd)}\n{quoted}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _run_validation_test(
|
|
69
|
+
test: list[str],
|
|
70
|
+
initial_content: bytes,
|
|
71
|
+
base: str,
|
|
72
|
+
input_type: InputType,
|
|
73
|
+
in_place: bool,
|
|
74
|
+
filename: str,
|
|
75
|
+
) -> ValidationResult:
|
|
76
|
+
"""Run the interestingness test once and check if it passes.
|
|
77
|
+
|
|
78
|
+
Returns ValidationResult with success=True if the test passed (exit code 0),
|
|
79
|
+
or success=False with error details if it failed.
|
|
80
|
+
"""
|
|
81
|
+
temp_dirs: list[str] = []
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
# Determine working directory and file path
|
|
85
|
+
if in_place:
|
|
86
|
+
if input_type == InputType.basename:
|
|
87
|
+
working = filename
|
|
88
|
+
cwd = os.getcwd()
|
|
89
|
+
# Write directly to original file
|
|
90
|
+
async with await trio.open_file(working, "wb") as f:
|
|
91
|
+
await f.write(initial_content)
|
|
92
|
+
else:
|
|
93
|
+
# Create a temp file in same directory with random suffix
|
|
94
|
+
base_name, ext = os.path.splitext(filename)
|
|
95
|
+
working = base_name + "-" + os.urandom(16).hex() + ext
|
|
96
|
+
cwd = os.getcwd()
|
|
97
|
+
async with await trio.open_file(working, "wb") as f:
|
|
98
|
+
await f.write(initial_content)
|
|
99
|
+
temp_dirs.append(working) # Track for cleanup
|
|
100
|
+
else:
|
|
101
|
+
# Create a temporary directory
|
|
102
|
+
temp_dir = tempfile.mkdtemp(prefix="shrinkray-validate-")
|
|
103
|
+
temp_dirs.append(temp_dir)
|
|
104
|
+
working = os.path.join(temp_dir, base)
|
|
105
|
+
cwd = temp_dir
|
|
106
|
+
async with await trio.open_file(working, "wb") as f:
|
|
107
|
+
await f.write(initial_content)
|
|
108
|
+
|
|
109
|
+
# Build command
|
|
110
|
+
command = _build_command(test, working, input_type)
|
|
111
|
+
|
|
112
|
+
# Print what we're doing to stderr
|
|
113
|
+
print(
|
|
114
|
+
"\nRunning interestingness test:",
|
|
115
|
+
file=sys.stderr,
|
|
116
|
+
flush=True,
|
|
117
|
+
)
|
|
118
|
+
print(
|
|
119
|
+
_format_command_for_display(command, cwd),
|
|
120
|
+
file=sys.stderr,
|
|
121
|
+
flush=True,
|
|
122
|
+
)
|
|
123
|
+
print(file=sys.stderr, flush=True)
|
|
124
|
+
|
|
125
|
+
# Handle stdin if needed
|
|
126
|
+
stdin_data: bytes | None = None
|
|
127
|
+
if input_type.enabled(InputType.stdin) and not os.path.isdir(working):
|
|
128
|
+
with open(working, "rb") as f:
|
|
129
|
+
stdin_data = f.read()
|
|
130
|
+
|
|
131
|
+
# Run subprocess with real-time output streaming
|
|
132
|
+
# We use subprocess.run in a thread because trio.run_process doesn't
|
|
133
|
+
# properly support file descriptor inheritance for streaming output.
|
|
134
|
+
def run_subprocess() -> subprocess.CompletedProcess[bytes]:
|
|
135
|
+
# Try to stream output directly to stderr if possible
|
|
136
|
+
# This allows real-time output visibility for slow tests
|
|
137
|
+
try:
|
|
138
|
+
stderr_fd = sys.stderr.fileno()
|
|
139
|
+
return subprocess.run(
|
|
140
|
+
command,
|
|
141
|
+
cwd=cwd,
|
|
142
|
+
stdin=subprocess.DEVNULL if stdin_data is None else None,
|
|
143
|
+
stdout=stderr_fd,
|
|
144
|
+
stderr=stderr_fd,
|
|
145
|
+
input=stdin_data,
|
|
146
|
+
check=False,
|
|
147
|
+
)
|
|
148
|
+
except (io.UnsupportedOperation, OSError):
|
|
149
|
+
# Falls back to capturing if stderr doesn't have a real file
|
|
150
|
+
# descriptor (e.g., when running under pytest with capture)
|
|
151
|
+
return subprocess.run(
|
|
152
|
+
command,
|
|
153
|
+
cwd=cwd,
|
|
154
|
+
stdin=subprocess.DEVNULL if stdin_data is None else None,
|
|
155
|
+
stdout=subprocess.PIPE,
|
|
156
|
+
stderr=subprocess.PIPE,
|
|
157
|
+
input=stdin_data,
|
|
158
|
+
check=False,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
result = await trio.to_thread.run_sync(run_subprocess)
|
|
162
|
+
|
|
163
|
+
# If we captured output (fallback mode), print it now
|
|
164
|
+
if result.stdout:
|
|
165
|
+
sys.stderr.buffer.write(result.stdout)
|
|
166
|
+
sys.stderr.flush()
|
|
167
|
+
if result.stderr:
|
|
168
|
+
sys.stderr.buffer.write(result.stderr)
|
|
169
|
+
sys.stderr.flush()
|
|
170
|
+
|
|
171
|
+
print(file=sys.stderr, flush=True)
|
|
172
|
+
print(
|
|
173
|
+
f"Exit code: {result.returncode}",
|
|
174
|
+
file=sys.stderr,
|
|
175
|
+
flush=True,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if result.returncode != 0:
|
|
179
|
+
return ValidationResult(
|
|
180
|
+
success=False,
|
|
181
|
+
error_message=(
|
|
182
|
+
f"Interestingness test exited with code {result.returncode}, "
|
|
183
|
+
f"but should return 0 for interesting test cases.\n\n"
|
|
184
|
+
f"To reproduce:\n{_format_command_for_display(command, cwd)}"
|
|
185
|
+
),
|
|
186
|
+
exit_code=result.returncode,
|
|
187
|
+
temp_dirs=temp_dirs,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return ValidationResult(
|
|
191
|
+
success=True,
|
|
192
|
+
exit_code=0,
|
|
193
|
+
temp_dirs=temp_dirs,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
except Exception as e:
|
|
197
|
+
traceback.print_exc()
|
|
198
|
+
return ValidationResult(
|
|
199
|
+
success=False,
|
|
200
|
+
error_message=f"Error running interestingness test: {e}",
|
|
201
|
+
temp_dirs=temp_dirs,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _run_formatter(
|
|
206
|
+
formatter_command: list[str],
|
|
207
|
+
content: bytes,
|
|
208
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
209
|
+
"""Run the formatter command on content, streaming output to stderr."""
|
|
210
|
+
|
|
211
|
+
print("\nRunning formatter:", file=sys.stderr, flush=True)
|
|
212
|
+
print(
|
|
213
|
+
" ".join(shlex.quote(part) for part in formatter_command),
|
|
214
|
+
file=sys.stderr,
|
|
215
|
+
flush=True,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def run_subprocess() -> subprocess.CompletedProcess[bytes]:
|
|
219
|
+
return subprocess.run(
|
|
220
|
+
formatter_command,
|
|
221
|
+
input=content,
|
|
222
|
+
capture_output=True,
|
|
223
|
+
check=False,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
result = await trio.to_thread.run_sync(run_subprocess)
|
|
227
|
+
|
|
228
|
+
# Show stderr from formatter if any
|
|
229
|
+
if result.stderr:
|
|
230
|
+
sys.stderr.buffer.write(result.stderr)
|
|
231
|
+
sys.stderr.flush()
|
|
232
|
+
|
|
233
|
+
print(
|
|
234
|
+
f"Formatter exit code: {result.returncode}",
|
|
235
|
+
file=sys.stderr,
|
|
236
|
+
flush=True,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def validate_initial_example(
|
|
243
|
+
file_path: str,
|
|
244
|
+
test: list[str],
|
|
245
|
+
input_type: InputType,
|
|
246
|
+
in_place: bool,
|
|
247
|
+
formatter_command: list[str] | None = None,
|
|
248
|
+
) -> ValidationResult:
|
|
249
|
+
"""Validate that the initial example passes the interestingness test.
|
|
250
|
+
|
|
251
|
+
This runs directly in the main process using trio, streaming output
|
|
252
|
+
to stderr so users can see progress for slow tests. Also checks the
|
|
253
|
+
formatter if one is specified.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
file_path: Path to the file to reduce
|
|
257
|
+
test: The interestingness test command
|
|
258
|
+
input_type: How to pass input to the test
|
|
259
|
+
in_place: Whether to run in the current directory
|
|
260
|
+
formatter_command: Optional formatter command to validate
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
ValidationResult indicating success or failure with details.
|
|
264
|
+
On failure, temp_dirs are preserved for debugging.
|
|
265
|
+
"""
|
|
266
|
+
# Read the initial content
|
|
267
|
+
if os.path.isdir(file_path):
|
|
268
|
+
# For directories, we need different handling
|
|
269
|
+
# For now, just validate that it's a valid directory
|
|
270
|
+
return ValidationResult(success=True)
|
|
271
|
+
|
|
272
|
+
with open(file_path, "rb") as f:
|
|
273
|
+
initial_content = f.read()
|
|
274
|
+
|
|
275
|
+
base = os.path.basename(file_path)
|
|
276
|
+
|
|
277
|
+
print("Validating interestingness test...", file=sys.stderr, flush=True)
|
|
278
|
+
|
|
279
|
+
result = await _run_validation_test(
|
|
280
|
+
test=test,
|
|
281
|
+
initial_content=initial_content,
|
|
282
|
+
base=base,
|
|
283
|
+
input_type=input_type,
|
|
284
|
+
in_place=in_place,
|
|
285
|
+
filename=file_path,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
if not result.success:
|
|
289
|
+
# On failure, keep temp directories and tell user
|
|
290
|
+
if result.temp_dirs:
|
|
291
|
+
print(
|
|
292
|
+
"\nTemporary files preserved for debugging:",
|
|
293
|
+
file=sys.stderr,
|
|
294
|
+
flush=True,
|
|
295
|
+
)
|
|
296
|
+
for path in result.temp_dirs:
|
|
297
|
+
print(f" {path}", file=sys.stderr, flush=True)
|
|
298
|
+
return result
|
|
299
|
+
|
|
300
|
+
# Clean up temp directories from initial test
|
|
301
|
+
if result.temp_dirs:
|
|
302
|
+
for path in result.temp_dirs:
|
|
303
|
+
try:
|
|
304
|
+
if os.path.isdir(path):
|
|
305
|
+
shutil.rmtree(path)
|
|
306
|
+
elif os.path.exists(path):
|
|
307
|
+
os.unlink(path)
|
|
308
|
+
except Exception:
|
|
309
|
+
pass # Best effort cleanup
|
|
310
|
+
|
|
311
|
+
print("Initial validation passed.", file=sys.stderr, flush=True)
|
|
312
|
+
|
|
313
|
+
# Now check formatter if specified
|
|
314
|
+
formatter_works: bool | None = None
|
|
315
|
+
if formatter_command is not None:
|
|
316
|
+
formatter_result = await _run_formatter(formatter_command, initial_content)
|
|
317
|
+
|
|
318
|
+
if formatter_result.returncode != 0:
|
|
319
|
+
return ValidationResult(
|
|
320
|
+
success=False,
|
|
321
|
+
error_message=(
|
|
322
|
+
"Formatter exited unexpectedly on initial test case. "
|
|
323
|
+
"If this is expected, please run with --formatter=none.\n\n"
|
|
324
|
+
f"Formatter stderr:\n{formatter_result.stderr.decode('utf-8', errors='replace').strip()}"
|
|
325
|
+
),
|
|
326
|
+
exit_code=formatter_result.returncode,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
reformatted = formatter_result.stdout
|
|
330
|
+
|
|
331
|
+
# If formatter changed the content, verify it's still interesting
|
|
332
|
+
if reformatted != initial_content:
|
|
333
|
+
print(
|
|
334
|
+
"\nChecking if formatted version is still interesting...",
|
|
335
|
+
file=sys.stderr,
|
|
336
|
+
flush=True,
|
|
337
|
+
)
|
|
338
|
+
formatted_result = await _run_validation_test(
|
|
339
|
+
test=test,
|
|
340
|
+
initial_content=reformatted,
|
|
341
|
+
base=base,
|
|
342
|
+
input_type=input_type,
|
|
343
|
+
in_place=in_place,
|
|
344
|
+
filename=file_path,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Clean up temp dirs from formatted test
|
|
348
|
+
if formatted_result.temp_dirs:
|
|
349
|
+
for path in formatted_result.temp_dirs:
|
|
350
|
+
try:
|
|
351
|
+
if os.path.isdir(path):
|
|
352
|
+
shutil.rmtree(path)
|
|
353
|
+
elif os.path.exists(path):
|
|
354
|
+
os.unlink(path)
|
|
355
|
+
except Exception:
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
if not formatted_result.success:
|
|
359
|
+
return ValidationResult(
|
|
360
|
+
success=False,
|
|
361
|
+
error_message=(
|
|
362
|
+
"Formatting initial test case made it uninteresting. "
|
|
363
|
+
"If this is expected, please run with --formatter=none.\n\n"
|
|
364
|
+
f"Formatter stderr:\n{formatter_result.stderr.decode('utf-8', errors='replace').strip()}"
|
|
365
|
+
),
|
|
366
|
+
exit_code=formatted_result.exit_code,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
print("Formatted version is also interesting.", file=sys.stderr, flush=True)
|
|
370
|
+
|
|
371
|
+
formatter_works = True
|
|
372
|
+
|
|
373
|
+
return ValidationResult(
|
|
374
|
+
success=True,
|
|
375
|
+
exit_code=0,
|
|
376
|
+
formatter_works=formatter_works,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def run_validation(
|
|
381
|
+
file_path: str,
|
|
382
|
+
test: list[str],
|
|
383
|
+
input_type: InputType,
|
|
384
|
+
in_place: bool,
|
|
385
|
+
formatter_command: list[str] | None = None,
|
|
386
|
+
) -> ValidationResult:
|
|
387
|
+
"""Run initial validation synchronously using trio.run().
|
|
388
|
+
|
|
389
|
+
This is the main entry point for validation from the CLI/TUI.
|
|
390
|
+
It runs validation directly in the main process before any asyncio
|
|
391
|
+
event loop is started.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
async def _run() -> ValidationResult:
|
|
395
|
+
return await validate_initial_example(
|
|
396
|
+
file_path,
|
|
397
|
+
test,
|
|
398
|
+
input_type,
|
|
399
|
+
in_place,
|
|
400
|
+
formatter_command,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
return trio.run(_run)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
shrinkray/__init__.py,sha256=b5MvcvhsEGYya3GRXNbCJAlAL5IZHSsETLK_vtfmXRY,18
|
|
2
|
-
shrinkray/__main__.py,sha256=
|
|
2
|
+
shrinkray/__main__.py,sha256=HuDZFXgD9LH3XPBK2QBvP4YkyTFJvduK9-PVtz9fKpk,11762
|
|
3
3
|
shrinkray/cli.py,sha256=1-qjaIchyCDd-YCdGWtK7q9j9qr6uX6AqtwW8m5QCQg,1697
|
|
4
4
|
shrinkray/display.py,sha256=WYN05uqmUVpZhwi2pxr1U-wLHWZ9KiL0RUlTCBJ1N3E,2430
|
|
5
5
|
shrinkray/formatting.py,sha256=tXCGnhJn-WJGpHMaLHRCAXK8aKJBbrOdiW9QGERrQEk,3121
|
|
@@ -7,9 +7,10 @@ shrinkray/problem.py,sha256=_edENYk8OC5o_2ng0WZrhIfilhlY5IuOrqt0qWBZAuM,25979
|
|
|
7
7
|
shrinkray/process.py,sha256=-eP8h5X0ESbkcTic8FFEzkd4-vwaZ0YI5tLxUR25L8U,1599
|
|
8
8
|
shrinkray/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
9
|
shrinkray/reducer.py,sha256=t2BBjq-EZ0qBbsez4ep0tFYCkDT8RwKYnjJ2fRsXubg,20096
|
|
10
|
-
shrinkray/state.py,sha256=
|
|
11
|
-
shrinkray/tui.py,sha256=
|
|
10
|
+
shrinkray/state.py,sha256=a8Xq7IQwLz7CyZ58zU0Z91uoglgPJmhQUvbcdgsIoKk,29593
|
|
11
|
+
shrinkray/tui.py,sha256=Ovd6Kbk_cmOYJlwnjEyl6ka_2g1qJAtwxojh5CWEzNA,33001
|
|
12
12
|
shrinkray/ui.py,sha256=xuDUwU-MM3AetvwUB7bfzav0P_drUsBrKFPhON_Nr-k,2251
|
|
13
|
+
shrinkray/validation.py,sha256=piBCO-k9he_id6TWC4EHMK3GfuyPqRcNfkNJPVjxEaU,13366
|
|
13
14
|
shrinkray/work.py,sha256=GEZ14Kk3bvwUxAnACvY-wom2lVWaGrELMNxrDjv03dk,8110
|
|
14
15
|
shrinkray/passes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
16
|
shrinkray/passes/bytes.py,sha256=U1sNAuqUlsaLpRSJuS9X4H7S2jOiilTUIIE9-WjSd4k,23967
|
|
@@ -22,12 +23,12 @@ shrinkray/passes/python.py,sha256=3WN1lZTf5oVL8FCTGomhrCuE04wIX9ocKcmFV86NMZA,68
|
|
|
22
23
|
shrinkray/passes/sat.py,sha256=OboY6jsKf6lph3pAFh535plvhNOVzEF8HJ66WEqsNm4,19483
|
|
23
24
|
shrinkray/passes/sequences.py,sha256=jCK1fWBxCz79u7JWSps9wf7Yru7W_FAsJwdgg--CLxU,3040
|
|
24
25
|
shrinkray/subprocess/__init__.py,sha256=FyV2y05uwQ1RTZGwREI0aAVaLX1jiwRcWsdsksFmdbM,451
|
|
25
|
-
shrinkray/subprocess/client.py,sha256=
|
|
26
|
-
shrinkray/subprocess/protocol.py,sha256=
|
|
27
|
-
shrinkray/subprocess/worker.py,sha256=
|
|
28
|
-
shrinkray-25.12.27.
|
|
29
|
-
shrinkray-25.12.27.
|
|
30
|
-
shrinkray-25.12.27.
|
|
31
|
-
shrinkray-25.12.27.
|
|
32
|
-
shrinkray-25.12.27.
|
|
33
|
-
shrinkray-25.12.27.
|
|
26
|
+
shrinkray/subprocess/client.py,sha256=abBkrXaJcA6cd3l_avPuteO_kYnjU5IRG7VtlmiAJgE,9428
|
|
27
|
+
shrinkray/subprocess/protocol.py,sha256=fbY29q-j98KnAuZJ1Y_ARoi7BskkUu37SoybwVWb-pA,6636
|
|
28
|
+
shrinkray/subprocess/worker.py,sha256=IBAXZq3E27bTuN9kHGgU5xhU6E8m_5-Ioz1dubVTuN8,21449
|
|
29
|
+
shrinkray-25.12.27.3.dist-info/licenses/LICENSE,sha256=iMKX79AuokJfIZUnGUARdUp30vVAoIPOJ7ek8TY63kk,1072
|
|
30
|
+
shrinkray-25.12.27.3.dist-info/METADATA,sha256=BUSNccYFT8TdtojoQHdt-YR9DEVtjsG8fNvlybnRsB0,7600
|
|
31
|
+
shrinkray-25.12.27.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
32
|
+
shrinkray-25.12.27.3.dist-info/entry_points.txt,sha256=wIZvnGyOdVeaLTiv2klnSyTe-EKkkwn4SwHh9bmJ7qk,104
|
|
33
|
+
shrinkray-25.12.27.3.dist-info/top_level.txt,sha256=fLif8-rFoFOnf5h8-vs3ECkKNWQopTQh3xvl1s7pchQ,10
|
|
34
|
+
shrinkray-25.12.27.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|