shrinkray 25.12.27.2__py3-none-any.whl → 25.12.28.0__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/passes/bytes.py +0 -125
- shrinkray/passes/clangdelta.py +0 -24
- shrinkray/passes/genericlanguages.py +0 -20
- shrinkray/passes/json.py +0 -8
- shrinkray/passes/patching.py +0 -63
- shrinkray/passes/sequences.py +0 -25
- shrinkray/reducer.py +0 -50
- shrinkray/state.py +192 -56
- shrinkray/subprocess/__init__.py +0 -4
- shrinkray/subprocess/client.py +2 -0
- shrinkray/subprocess/protocol.py +8 -11
- shrinkray/subprocess/worker.py +67 -25
- shrinkray/tui.py +114 -92
- shrinkray/validation.py +403 -0
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.28.0.dist-info}/METADATA +1 -1
- shrinkray-25.12.28.0.dist-info/RECORD +33 -0
- shrinkray/display.py +0 -75
- shrinkray-25.12.27.2.dist-info/RECORD +0 -33
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.28.0.dist-info}/WHEEL +0 -0
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.28.0.dist-info}/entry_points.txt +0 -0
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.28.0.dist-info}/licenses/LICENSE +0 -0
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.28.0.dist-info}/top_level.txt +0 -0
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)
|