shrinkray 26.1.1.0__tar.gz → 26.2.4.1__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-26.1.1.0/src/shrinkray.egg-info → shrinkray-26.2.4.1}/PKG-INFO +1 -1
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/pyproject.toml +1 -1
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/client.py +21 -1
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/tui.py +70 -54
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1/src/shrinkray.egg-info}/PKG-INFO +1 -1
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_subprocess_client.py +120 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_tui.py +128 -31
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/LICENSE +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/README.md +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/setup.cfg +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/__init__.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/__main__.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/cli.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/formatting.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/history.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/__init__.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/bytes.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/clangdelta.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/definitions.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/genericlanguages.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/json.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/patching.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/python.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/sat.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/sequences.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/problem.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/process.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/py.typed +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/reducer.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/state.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/__init__.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/protocol.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/worker.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/ui.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/validation.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/work.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/SOURCES.txt +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/dependency_links.txt +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/entry_points.txt +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/requires.txt +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/top_level.txt +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_byte_reduction_passes.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_clang_delta.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_cli.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_definitions.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_dimacs_cnf.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_formatting.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_generic_language.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_generic_shrinking_properties.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_history.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_json_passes.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_main.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_misc_reduction_performance.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_natural_sort_orders.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_patching.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_problem.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_process.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_python_reducers.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_reducer.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_reduction_passes.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_sat.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_state.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_subprocess_integration.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_subprocess_protocol.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_subprocess_worker.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_tui_snapshots.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_ui.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_validation.py +0 -0
- {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_work.py +0 -0
|
@@ -27,6 +27,7 @@ class SubprocessClient:
|
|
|
27
27
|
self._progress_queue: asyncio.Queue[ProgressUpdate] = asyncio.Queue()
|
|
28
28
|
self._reader_task: asyncio.Task | None = None
|
|
29
29
|
self._completed = False
|
|
30
|
+
self._closed = False
|
|
30
31
|
self._error_message: str | None = None
|
|
31
32
|
self._debug_mode = debug_mode
|
|
32
33
|
self._stderr_log_file: IO[str] | None = None
|
|
@@ -261,6 +262,17 @@ class SubprocessClient:
|
|
|
261
262
|
|
|
262
263
|
async def close(self) -> None:
|
|
263
264
|
"""Close the subprocess."""
|
|
265
|
+
if self._closed:
|
|
266
|
+
return
|
|
267
|
+
self._closed = True
|
|
268
|
+
|
|
269
|
+
# Cancel all pending futures first so any code awaiting send_command
|
|
270
|
+
# responses (e.g. cancel() in action_quit) is unblocked immediately.
|
|
271
|
+
for future in self._pending_responses.values():
|
|
272
|
+
if not future.done():
|
|
273
|
+
future.cancel()
|
|
274
|
+
self._pending_responses.clear()
|
|
275
|
+
|
|
264
276
|
if self._reader_task is not None:
|
|
265
277
|
self._reader_task.cancel()
|
|
266
278
|
try:
|
|
@@ -278,13 +290,21 @@ class SubprocessClient:
|
|
|
278
290
|
if self._process.returncode is None:
|
|
279
291
|
try:
|
|
280
292
|
self._process.terminate()
|
|
281
|
-
await asyncio.wait_for(self._process.wait(), timeout=
|
|
293
|
+
await asyncio.wait_for(self._process.wait(), timeout=2.0)
|
|
282
294
|
except TimeoutError:
|
|
283
295
|
self._process.kill()
|
|
284
296
|
await self._process.wait()
|
|
285
297
|
except ProcessLookupError:
|
|
286
298
|
pass # Process already exited
|
|
287
299
|
|
|
300
|
+
# Always await wait() to ensure the asyncio transport is fully
|
|
301
|
+
# finalized before the event loop closes. This prevents the
|
|
302
|
+
# "Event loop is closed" RuntimeError from BaseSubprocessTransport.__del__.
|
|
303
|
+
if self._process.returncode is not None:
|
|
304
|
+
await self._process.wait()
|
|
305
|
+
# Flush pending event loop callbacks (transport cleanup)
|
|
306
|
+
await asyncio.sleep(0)
|
|
307
|
+
|
|
288
308
|
# Close and remove the stderr log file
|
|
289
309
|
if self._stderr_log_file is not None:
|
|
290
310
|
try:
|
|
@@ -13,7 +13,6 @@ from difflib import unified_diff
|
|
|
13
13
|
from typing import Literal, Protocol, cast
|
|
14
14
|
|
|
15
15
|
import humanize
|
|
16
|
-
from rich.markup import escape as escape_markup
|
|
17
16
|
from rich.text import Text
|
|
18
17
|
from textual import work
|
|
19
18
|
from textual.app import App, ComposeResult
|
|
@@ -46,6 +45,7 @@ from shrinkray.subprocess.protocol import (
|
|
|
46
45
|
|
|
47
46
|
ThemeMode = Literal["auto", "dark", "light"]
|
|
48
47
|
|
|
48
|
+
|
|
49
49
|
# Custom themes with true white/black backgrounds
|
|
50
50
|
SHRINKRAY_LIGHT_THEME = Theme(
|
|
51
51
|
name="shrinkray-light",
|
|
@@ -505,20 +505,20 @@ class ContentPreview(Static):
|
|
|
505
505
|
# Fallback based on common terminal height
|
|
506
506
|
return 30
|
|
507
507
|
|
|
508
|
-
def render(self) ->
|
|
508
|
+
def render(self) -> Text:
|
|
509
509
|
if not self.preview_content:
|
|
510
|
-
return "Loading..."
|
|
510
|
+
return Text("Loading...")
|
|
511
511
|
|
|
512
512
|
available_lines = self._get_available_lines()
|
|
513
513
|
|
|
514
514
|
if self.hex_mode:
|
|
515
|
-
return f"[Hex mode]\n{self.preview_content}"
|
|
515
|
+
return Text(f"[Hex mode]\n{self.preview_content}")
|
|
516
516
|
|
|
517
517
|
lines = self.preview_content.split("\n")
|
|
518
518
|
|
|
519
519
|
# For small files that fit, show full content
|
|
520
520
|
if len(lines) <= available_lines:
|
|
521
|
-
return self.preview_content
|
|
521
|
+
return Text(self.preview_content)
|
|
522
522
|
|
|
523
523
|
# For larger files, show diff if we have previous displayed content
|
|
524
524
|
if (
|
|
@@ -530,10 +530,10 @@ class ContentPreview(Static):
|
|
|
530
530
|
diff = list(unified_diff(prev_lines, curr_lines, lineterm=""))
|
|
531
531
|
if diff:
|
|
532
532
|
# Show as much diff as fits
|
|
533
|
-
return "\n".join(diff[:available_lines])
|
|
533
|
+
return Text("\n".join(diff[:available_lines]))
|
|
534
534
|
|
|
535
535
|
# No diff available, show truncated content
|
|
536
|
-
return (
|
|
536
|
+
return Text(
|
|
537
537
|
"\n".join(lines[:available_lines])
|
|
538
538
|
+ f"\n\n... ({len(lines) - available_lines} more lines)"
|
|
539
539
|
)
|
|
@@ -591,39 +591,46 @@ class OutputPreview(Static):
|
|
|
591
591
|
pass
|
|
592
592
|
return 30
|
|
593
593
|
|
|
594
|
-
def render(self) ->
|
|
594
|
+
def render(self) -> Text:
|
|
595
595
|
# Header line - use return_code to determine if test is running
|
|
596
596
|
# (return_code is None means still running, has value means completed)
|
|
597
597
|
if self.active_test_id is not None and self.last_return_code is None:
|
|
598
|
-
|
|
598
|
+
header_text = f"Test #{self.active_test_id} running..."
|
|
599
|
+
header_style = "green"
|
|
599
600
|
elif self.active_test_id is not None:
|
|
600
|
-
|
|
601
|
+
header_text = (
|
|
602
|
+
f"Test #{self.active_test_id} exited with code {self.last_return_code}"
|
|
603
|
+
)
|
|
604
|
+
header_style = "dim"
|
|
601
605
|
elif self._has_seen_output or self.output_content:
|
|
602
606
|
# Have seen output before - show without header
|
|
603
|
-
|
|
607
|
+
header_text = ""
|
|
608
|
+
header_style = ""
|
|
604
609
|
else:
|
|
605
|
-
|
|
610
|
+
header_text = "No test output yet..."
|
|
611
|
+
header_style = "dim"
|
|
606
612
|
|
|
607
613
|
if not self.output_content:
|
|
608
|
-
return
|
|
614
|
+
return Text(header_text, style=header_style)
|
|
609
615
|
|
|
610
616
|
available_lines = self._get_available_lines()
|
|
611
|
-
|
|
612
|
-
# (test output may contain characters like ^ that have special meaning)
|
|
613
|
-
escaped_content = escape_markup(self.output_content)
|
|
614
|
-
lines = escaped_content.split("\n")
|
|
615
|
-
|
|
616
|
-
# Build prefix (header + newline, or empty if no header)
|
|
617
|
-
prefix = f"{header}\n" if header else ""
|
|
617
|
+
lines = self.output_content.split("\n")
|
|
618
618
|
|
|
619
619
|
# Show tail of output (most recent lines)
|
|
620
|
-
if len(lines)
|
|
621
|
-
|
|
620
|
+
if len(lines) > available_lines:
|
|
621
|
+
truncated_lines = lines[-(available_lines):]
|
|
622
|
+
skipped = len(lines) - available_lines
|
|
623
|
+
content = f"... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
|
|
624
|
+
else:
|
|
625
|
+
content = self.output_content
|
|
622
626
|
|
|
623
|
-
#
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
+
# Build result with styled header and unstyled content
|
|
628
|
+
result = Text()
|
|
629
|
+
if header_text:
|
|
630
|
+
result.append(header_text, style=header_style)
|
|
631
|
+
result.append("\n")
|
|
632
|
+
result.append(content)
|
|
633
|
+
return result
|
|
627
634
|
|
|
628
635
|
|
|
629
636
|
class HelpScreen(ModalScreen[None]):
|
|
@@ -734,21 +741,20 @@ class ExpandedBoxModal(ModalScreen[None]):
|
|
|
734
741
|
self._content_widget_id = content_widget_id
|
|
735
742
|
self._file_path = file_path
|
|
736
743
|
|
|
737
|
-
def _read_file(self, file_path: str) ->
|
|
744
|
+
def _read_file(self, file_path: str) -> Text:
|
|
738
745
|
"""Read file content, decoding as text if possible."""
|
|
739
746
|
if not os.path.isfile(file_path):
|
|
740
|
-
return "
|
|
747
|
+
return Text("File not found", style="dim")
|
|
741
748
|
try:
|
|
742
749
|
with open(file_path, "rb") as f:
|
|
743
750
|
raw_content = f.read()
|
|
744
751
|
# Try to decode as text, fall back to hex display if binary
|
|
745
752
|
encoding, text = try_decode(raw_content)
|
|
746
753
|
if encoding is not None:
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
return "[Binary content - hex display]\n\n" + raw_content.hex()
|
|
754
|
+
return Text(text)
|
|
755
|
+
return Text("Binary content - hex display\n\n" + raw_content.hex())
|
|
750
756
|
except OSError:
|
|
751
|
-
return "
|
|
757
|
+
return Text("Error reading file", style="red")
|
|
752
758
|
|
|
753
759
|
def compose(self) -> ComposeResult:
|
|
754
760
|
with Vertical():
|
|
@@ -781,16 +787,16 @@ class ExpandedBoxModal(ModalScreen[None]):
|
|
|
781
787
|
return "Statistics not available"
|
|
782
788
|
return stats_displays[0].render()
|
|
783
789
|
|
|
784
|
-
def _get_file_content(self, app: "ShrinkRayApp") -> str:
|
|
790
|
+
def _get_file_content(self, app: "ShrinkRayApp") -> str | Text:
|
|
785
791
|
"""Get content from file or preview widget."""
|
|
786
792
|
if self._file_path:
|
|
787
793
|
return self._read_file(self._file_path)
|
|
788
794
|
content_previews = list(app.query("#content-preview").results(ContentPreview))
|
|
789
795
|
if not content_previews:
|
|
790
796
|
return "Content preview not available"
|
|
791
|
-
return content_previews[0].preview_content
|
|
797
|
+
return Text(content_previews[0].preview_content)
|
|
792
798
|
|
|
793
|
-
def _get_output_content(self, app: "ShrinkRayApp") -> str:
|
|
799
|
+
def _get_output_content(self, app: "ShrinkRayApp") -> str | Text:
|
|
794
800
|
"""Get output content from the output preview widget."""
|
|
795
801
|
output_previews = list(app.query("#output-preview").results(OutputPreview))
|
|
796
802
|
if not output_previews:
|
|
@@ -811,21 +817,29 @@ class ExpandedBoxModal(ModalScreen[None]):
|
|
|
811
817
|
)
|
|
812
818
|
has_seen_output = output_preview._has_seen_output
|
|
813
819
|
|
|
814
|
-
# Build header
|
|
820
|
+
# Build header with styling
|
|
815
821
|
if test_id is not None and return_code is None:
|
|
816
|
-
|
|
822
|
+
header_text = f"Test #{test_id} running..."
|
|
823
|
+
header_style = "green"
|
|
817
824
|
elif test_id is not None:
|
|
818
|
-
|
|
825
|
+
header_text = f"Test #{test_id} exited with code {return_code}"
|
|
826
|
+
header_style = "dim"
|
|
819
827
|
else:
|
|
820
|
-
|
|
828
|
+
header_text = ""
|
|
829
|
+
header_style = ""
|
|
821
830
|
|
|
822
831
|
if raw_content:
|
|
823
|
-
|
|
832
|
+
result = Text()
|
|
833
|
+
if header_text:
|
|
834
|
+
result.append(header_text, style=header_style)
|
|
835
|
+
result.append("\n\n")
|
|
836
|
+
result.append(raw_content)
|
|
837
|
+
return result
|
|
824
838
|
elif has_seen_output or test_id is not None:
|
|
825
839
|
# We've seen output before - show header only (no "No test output" message)
|
|
826
|
-
return
|
|
840
|
+
return Text(header_text, style=header_style)
|
|
827
841
|
else:
|
|
828
|
-
return "
|
|
842
|
+
return Text("No test output yet...", style="dim")
|
|
829
843
|
|
|
830
844
|
def on_mount(self) -> None:
|
|
831
845
|
"""Populate content from the source widget."""
|
|
@@ -1447,13 +1461,13 @@ class HistoryExplorerModal(ModalScreen[None]):
|
|
|
1447
1461
|
if os.path.isfile(output_path):
|
|
1448
1462
|
output_content = self._read_file(output_path)
|
|
1449
1463
|
else:
|
|
1450
|
-
output_content = "
|
|
1464
|
+
output_content = Text("No output captured", style="dim")
|
|
1451
1465
|
self.query_one(f"#{output_preview_id}", Static).update(output_content)
|
|
1452
1466
|
|
|
1453
|
-
def _read_file(self, file_path: str) ->
|
|
1467
|
+
def _read_file(self, file_path: str) -> Text:
|
|
1454
1468
|
"""Read file content, decoding as text if possible."""
|
|
1455
1469
|
if not os.path.isfile(file_path):
|
|
1456
|
-
return "
|
|
1470
|
+
return Text("File not found", style="dim")
|
|
1457
1471
|
try:
|
|
1458
1472
|
with open(file_path, "rb") as f:
|
|
1459
1473
|
raw_content = f.read()
|
|
@@ -1465,18 +1479,17 @@ class HistoryExplorerModal(ModalScreen[None]):
|
|
|
1465
1479
|
# Try to decode as text
|
|
1466
1480
|
encoding, text = try_decode(raw_content)
|
|
1467
1481
|
if encoding is not None:
|
|
1468
|
-
|
|
1469
|
-
text = escape_markup(text)
|
|
1482
|
+
result = Text(text)
|
|
1470
1483
|
if truncated:
|
|
1471
|
-
|
|
1472
|
-
return
|
|
1484
|
+
result.append("\n\n... (truncated)", style="dim")
|
|
1485
|
+
return result
|
|
1473
1486
|
# Binary content - hex display
|
|
1474
|
-
|
|
1487
|
+
result = Text("Binary content - hex display\n\n" + raw_content.hex())
|
|
1475
1488
|
if truncated:
|
|
1476
|
-
|
|
1477
|
-
return
|
|
1489
|
+
result.append("\n\n... (truncated)", style="dim")
|
|
1490
|
+
return result
|
|
1478
1491
|
except OSError:
|
|
1479
|
-
return "
|
|
1492
|
+
return Text("Error reading file", style="red")
|
|
1480
1493
|
|
|
1481
1494
|
def action_restart_from_here(self) -> None:
|
|
1482
1495
|
"""Restart reduction from the currently selected history point."""
|
|
@@ -1925,11 +1938,14 @@ class ShrinkRayApp(App[None]):
|
|
|
1925
1938
|
|
|
1926
1939
|
async def action_quit(self) -> None:
|
|
1927
1940
|
"""Quit the application with graceful cancellation."""
|
|
1941
|
+
self.update_status("Shutting down...")
|
|
1928
1942
|
if self._client and not self._completed:
|
|
1929
1943
|
try:
|
|
1930
|
-
await self._client.
|
|
1944
|
+
await self._client.close()
|
|
1931
1945
|
except Exception:
|
|
1932
1946
|
pass # Process may have already exited
|
|
1947
|
+
# Prevent double-close in run_reduction's finally block
|
|
1948
|
+
self._client = None
|
|
1933
1949
|
self.exit()
|
|
1934
1950
|
|
|
1935
1951
|
def action_show_pass_stats(self) -> None:
|
|
@@ -1120,3 +1120,123 @@ def test_subprocess_client_close_handles_stderr_log_file_exception():
|
|
|
1120
1120
|
mock_file.close.assert_called_once()
|
|
1121
1121
|
|
|
1122
1122
|
asyncio.run(run())
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def test_subprocess_client_close_cancels_pending_futures():
|
|
1126
|
+
"""Test close() cancels all pending futures so awaiting code is unblocked."""
|
|
1127
|
+
|
|
1128
|
+
async def run():
|
|
1129
|
+
client = SubprocessClient()
|
|
1130
|
+
|
|
1131
|
+
# Create pending futures
|
|
1132
|
+
loop = asyncio.get_event_loop()
|
|
1133
|
+
future1: asyncio.Future[Response] = loop.create_future()
|
|
1134
|
+
future2: asyncio.Future[Response] = loop.create_future()
|
|
1135
|
+
client._pending_responses["req-1"] = future1
|
|
1136
|
+
client._pending_responses["req-2"] = future2
|
|
1137
|
+
|
|
1138
|
+
await client.close()
|
|
1139
|
+
|
|
1140
|
+
# Both futures should be cancelled
|
|
1141
|
+
assert future1.cancelled()
|
|
1142
|
+
assert future2.cancelled()
|
|
1143
|
+
# Pending responses should be cleared
|
|
1144
|
+
assert len(client._pending_responses) == 0
|
|
1145
|
+
|
|
1146
|
+
asyncio.run(run())
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def test_subprocess_client_close_is_idempotent():
|
|
1150
|
+
"""Test close() is a no-op on second call due to _closed flag."""
|
|
1151
|
+
|
|
1152
|
+
async def run():
|
|
1153
|
+
client = SubprocessClient()
|
|
1154
|
+
|
|
1155
|
+
# Create a pending future
|
|
1156
|
+
loop = asyncio.get_event_loop()
|
|
1157
|
+
future: asyncio.Future[Response] = loop.create_future()
|
|
1158
|
+
client._pending_responses["req-1"] = future
|
|
1159
|
+
|
|
1160
|
+
await client.close()
|
|
1161
|
+
assert future.cancelled()
|
|
1162
|
+
assert client._closed
|
|
1163
|
+
|
|
1164
|
+
# Add another future after close (simulating a race)
|
|
1165
|
+
future2: asyncio.Future[Response] = loop.create_future()
|
|
1166
|
+
client._pending_responses["req-2"] = future2
|
|
1167
|
+
|
|
1168
|
+
# Second close should be a no-op
|
|
1169
|
+
await client.close()
|
|
1170
|
+
|
|
1171
|
+
# The second future should NOT be cancelled (close was a no-op)
|
|
1172
|
+
assert not future2.cancelled()
|
|
1173
|
+
|
|
1174
|
+
asyncio.run(run())
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def test_subprocess_client_close_awaits_wait_after_process_exit():
|
|
1178
|
+
"""Test close() always awaits process.wait() after process exits."""
|
|
1179
|
+
|
|
1180
|
+
async def run():
|
|
1181
|
+
client = SubprocessClient()
|
|
1182
|
+
|
|
1183
|
+
# Create a mock process that has already exited
|
|
1184
|
+
mock_process = MagicMock()
|
|
1185
|
+
mock_process.returncode = 0 # Already exited
|
|
1186
|
+
mock_process.stdin = MagicMock()
|
|
1187
|
+
mock_process.stdin.close = MagicMock()
|
|
1188
|
+
|
|
1189
|
+
wait_called = [False]
|
|
1190
|
+
|
|
1191
|
+
async def mock_wait():
|
|
1192
|
+
wait_called[0] = True
|
|
1193
|
+
|
|
1194
|
+
mock_process.wait = mock_wait
|
|
1195
|
+
client._process = mock_process
|
|
1196
|
+
client._reader_task = None
|
|
1197
|
+
|
|
1198
|
+
await client.close()
|
|
1199
|
+
|
|
1200
|
+
# wait() should have been called even though process already exited
|
|
1201
|
+
assert wait_called[0]
|
|
1202
|
+
|
|
1203
|
+
asyncio.run(run())
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def test_subprocess_client_close_skips_done_futures():
|
|
1207
|
+
"""Test close() skips already-done futures when cancelling."""
|
|
1208
|
+
|
|
1209
|
+
async def run():
|
|
1210
|
+
client = SubprocessClient()
|
|
1211
|
+
|
|
1212
|
+
# Create one done and one pending future
|
|
1213
|
+
loop = asyncio.get_event_loop()
|
|
1214
|
+
done_future: asyncio.Future[Response] = loop.create_future()
|
|
1215
|
+
done_future.set_result(Response(id="done", result={"status": "ok"}))
|
|
1216
|
+
pending_future: asyncio.Future[Response] = loop.create_future()
|
|
1217
|
+
|
|
1218
|
+
client._pending_responses["done"] = done_future
|
|
1219
|
+
client._pending_responses["pending"] = pending_future
|
|
1220
|
+
|
|
1221
|
+
await client.close()
|
|
1222
|
+
|
|
1223
|
+
# Done future should still have its result (not cancelled)
|
|
1224
|
+
assert not done_future.cancelled()
|
|
1225
|
+
assert done_future.result().result == {"status": "ok"}
|
|
1226
|
+
# Pending future should be cancelled
|
|
1227
|
+
assert pending_future.cancelled()
|
|
1228
|
+
|
|
1229
|
+
asyncio.run(run())
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def test_subprocess_client_close_handles_stderr_log_unlink_exception():
|
|
1233
|
+
"""Test close() handles exception when unlinking stderr log file."""
|
|
1234
|
+
|
|
1235
|
+
async def run():
|
|
1236
|
+
client = SubprocessClient()
|
|
1237
|
+
client._stderr_log_path = "/nonexistent/path/that/will/fail"
|
|
1238
|
+
|
|
1239
|
+
# Should not raise even if unlink fails
|
|
1240
|
+
await client.close()
|
|
1241
|
+
|
|
1242
|
+
asyncio.run(run())
|
|
@@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator
|
|
|
9
9
|
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
|
10
10
|
|
|
11
11
|
import pytest
|
|
12
|
+
from rich.text import Text
|
|
12
13
|
from textual.app import App
|
|
13
14
|
from textual.widgets import DataTable, Label, ListView, Static, TabbedContent
|
|
14
15
|
|
|
@@ -112,6 +113,7 @@ class FakeReductionClient:
|
|
|
112
113
|
|
|
113
114
|
async def close(self) -> None:
|
|
114
115
|
self._closed = True
|
|
116
|
+
self._cancelled = True
|
|
115
117
|
|
|
116
118
|
async def get_progress_updates(self) -> AsyncGenerator[ProgressUpdate, None]:
|
|
117
119
|
for update in self._updates:
|
|
@@ -715,6 +717,40 @@ def test_output_preview_get_available_lines_app_zero_height():
|
|
|
715
717
|
assert lines == 30
|
|
716
718
|
|
|
717
719
|
|
|
720
|
+
def test_output_preview_render_returns_text():
|
|
721
|
+
"""Test that OutputPreview.render() returns a Text object, not a markup string.
|
|
722
|
+
|
|
723
|
+
Returning a Text object ensures Textual never parses user content as markup.
|
|
724
|
+
Previously, render() returned a markup string and used Rich's escape() which
|
|
725
|
+
failed on patterns like [key=; causing MarkupError.
|
|
726
|
+
|
|
727
|
+
This fixes the crash from: shrinkray --parallelism 60 interest2.sh bf_simple_yk.c
|
|
728
|
+
"""
|
|
729
|
+
widget = OutputPreview()
|
|
730
|
+
# Content that would crash if parsed as markup
|
|
731
|
+
widget.output_content = "some output\n[key=;value"
|
|
732
|
+
widget.active_test_id = 1
|
|
733
|
+
|
|
734
|
+
rendered = widget.render()
|
|
735
|
+
assert isinstance(rendered, Text)
|
|
736
|
+
assert "[key=;value" in rendered
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def test_content_preview_render_returns_text():
|
|
740
|
+
"""Test that ContentPreview.render() returns a Text object, not a markup string.
|
|
741
|
+
|
|
742
|
+
Returning a Text object ensures Textual never parses user content as markup.
|
|
743
|
+
Previously, render() returned raw strings that would crash on patterns like
|
|
744
|
+
[Key=; when Textual parsed them as markup.
|
|
745
|
+
"""
|
|
746
|
+
widget = ContentPreview()
|
|
747
|
+
widget.update_content("[Key=;value", False)
|
|
748
|
+
|
|
749
|
+
rendered = widget.render()
|
|
750
|
+
assert isinstance(rendered, Text)
|
|
751
|
+
assert "[Key=;value" in rendered
|
|
752
|
+
|
|
753
|
+
|
|
718
754
|
# === ShrinkRayApp with fake client tests ===
|
|
719
755
|
|
|
720
756
|
|
|
@@ -1303,21 +1339,22 @@ def test_expanded_modal_read_file_success(tmp_path):
|
|
|
1303
1339
|
|
|
1304
1340
|
modal = ExpandedBoxModal("Test", "content-container")
|
|
1305
1341
|
content = modal._read_file(str(test_file))
|
|
1306
|
-
assert content
|
|
1342
|
+
assert isinstance(content, Text)
|
|
1343
|
+
assert content.plain == "Hello World"
|
|
1307
1344
|
|
|
1308
1345
|
|
|
1309
|
-
def
|
|
1310
|
-
"""Test _read_file
|
|
1346
|
+
def test_expanded_modal_read_file_preserves_brackets(tmp_path):
|
|
1347
|
+
"""Test _read_file preserves bracket characters in file content."""
|
|
1311
1348
|
test_file = tmp_path / "test.txt"
|
|
1312
1349
|
# Content with brackets that could be interpreted as Rich markup
|
|
1313
1350
|
test_file.write_text("expected [bold] and [red]text[/red]")
|
|
1314
1351
|
|
|
1315
1352
|
modal = ExpandedBoxModal("Test", "content-container")
|
|
1316
1353
|
content = modal._read_file(str(test_file))
|
|
1317
|
-
#
|
|
1318
|
-
|
|
1319
|
-
assert "[bold]"
|
|
1320
|
-
assert "[red]
|
|
1354
|
+
# Returns a Text object so brackets are preserved literally
|
|
1355
|
+
assert isinstance(content, Text)
|
|
1356
|
+
assert "[bold]" in content.plain
|
|
1357
|
+
assert "[red]text[/red]" in content.plain
|
|
1321
1358
|
|
|
1322
1359
|
|
|
1323
1360
|
def test_expanded_modal_read_file_binary(tmp_path):
|
|
@@ -1328,26 +1365,29 @@ def test_expanded_modal_read_file_binary(tmp_path):
|
|
|
1328
1365
|
|
|
1329
1366
|
modal = ExpandedBoxModal("Test", "content-container")
|
|
1330
1367
|
content = modal._read_file(str(test_file))
|
|
1368
|
+
assert isinstance(content, Text)
|
|
1331
1369
|
assert "Binary content" in content
|
|
1332
1370
|
assert "80818283" in content
|
|
1333
1371
|
|
|
1334
1372
|
|
|
1335
1373
|
def test_expanded_modal_read_file_missing(tmp_path):
|
|
1336
|
-
"""Test _read_file returns
|
|
1374
|
+
"""Test _read_file returns styled message for missing file."""
|
|
1337
1375
|
modal = ExpandedBoxModal("Test", "content-container")
|
|
1338
1376
|
result = modal._read_file(str(tmp_path / "nonexistent.txt"))
|
|
1339
|
-
assert
|
|
1377
|
+
assert isinstance(result, Text)
|
|
1378
|
+
assert "File not found" in result
|
|
1340
1379
|
|
|
1341
1380
|
|
|
1342
1381
|
def test_expanded_modal_read_file_oserror(tmp_path):
|
|
1343
|
-
"""Test _read_file returns
|
|
1382
|
+
"""Test _read_file returns styled message on OSError."""
|
|
1344
1383
|
modal = ExpandedBoxModal("Test", "content-container")
|
|
1345
1384
|
# Create a file that exists but can't be read
|
|
1346
1385
|
test_file = tmp_path / "unreadable.txt"
|
|
1347
1386
|
test_file.write_text("content")
|
|
1348
1387
|
with patch("builtins.open", side_effect=OSError("Permission denied")):
|
|
1349
1388
|
result = modal._read_file(str(test_file))
|
|
1350
|
-
assert
|
|
1389
|
+
assert isinstance(result, Text)
|
|
1390
|
+
assert "Error reading file" in result
|
|
1351
1391
|
|
|
1352
1392
|
|
|
1353
1393
|
# === ExpandedBoxModal integration tests ===
|
|
@@ -5367,11 +5407,60 @@ def test_expanded_modal_output_with_empty_content_and_header():
|
|
|
5367
5407
|
asyncio.run(run())
|
|
5368
5408
|
|
|
5369
5409
|
|
|
5410
|
+
def test_expanded_modal_output_content_without_test_id():
|
|
5411
|
+
"""Test output modal with content but no test ID (no header)."""
|
|
5412
|
+
|
|
5413
|
+
async def run():
|
|
5414
|
+
fake_client = FakeReductionClient(updates=[], wait_indefinitely=True)
|
|
5415
|
+
|
|
5416
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
|
5417
|
+
f.write("test")
|
|
5418
|
+
temp_file = f.name
|
|
5419
|
+
|
|
5420
|
+
try:
|
|
5421
|
+
app = ShrinkRayApp(
|
|
5422
|
+
file_path=temp_file,
|
|
5423
|
+
test=["true"],
|
|
5424
|
+
exit_on_completion=False,
|
|
5425
|
+
client=fake_client,
|
|
5426
|
+
)
|
|
5427
|
+
|
|
5428
|
+
async with app.run_test() as pilot:
|
|
5429
|
+
await pilot.pause()
|
|
5430
|
+
|
|
5431
|
+
# Set output preview with content but no test ID
|
|
5432
|
+
output_preview = app.query_one("#output-preview", OutputPreview)
|
|
5433
|
+
output_preview.active_test_id = None
|
|
5434
|
+
output_preview._has_seen_output = True
|
|
5435
|
+
output_preview.output_content = "some raw output"
|
|
5436
|
+
output_preview._pending_content = "some raw output"
|
|
5437
|
+
output_preview._pending_test_id = None
|
|
5438
|
+
|
|
5439
|
+
# Create modal for output-container
|
|
5440
|
+
modal = ExpandedBoxModal("Output", "output-container", None)
|
|
5441
|
+
await app.push_screen(modal)
|
|
5442
|
+
await pilot.pause()
|
|
5443
|
+
|
|
5444
|
+
# Should show content without header
|
|
5445
|
+
content = modal.query_one("#expanded-content", Static)
|
|
5446
|
+
content_str = get_static_content(content)
|
|
5447
|
+
assert "some raw output" in content_str
|
|
5448
|
+
assert "Test #" not in content_str
|
|
5449
|
+
|
|
5450
|
+
await pilot.press("escape")
|
|
5451
|
+
await pilot.press("q")
|
|
5452
|
+
finally:
|
|
5453
|
+
if os.path.exists(temp_file):
|
|
5454
|
+
os.unlink(temp_file)
|
|
5455
|
+
|
|
5456
|
+
asyncio.run(run())
|
|
5457
|
+
|
|
5458
|
+
|
|
5370
5459
|
# === Action method tests ===
|
|
5371
5460
|
|
|
5372
5461
|
|
|
5373
5462
|
def test_action_quit_with_client():
|
|
5374
|
-
"""Test action_quit
|
|
5463
|
+
"""Test action_quit closes client directly before exiting."""
|
|
5375
5464
|
|
|
5376
5465
|
async def run():
|
|
5377
5466
|
fake_client = FakeReductionClient(updates=[], wait_indefinitely=True)
|
|
@@ -5395,8 +5484,10 @@ def test_action_quit_with_client():
|
|
|
5395
5484
|
await pilot.press("q")
|
|
5396
5485
|
await pilot.pause()
|
|
5397
5486
|
|
|
5398
|
-
# Client should have been cancelled
|
|
5399
|
-
assert fake_client.
|
|
5487
|
+
# Client should have been closed directly (not just cancelled)
|
|
5488
|
+
assert fake_client._closed
|
|
5489
|
+
# Client reference should be cleared to prevent double-close
|
|
5490
|
+
assert app._client is None
|
|
5400
5491
|
finally:
|
|
5401
5492
|
if os.path.exists(temp_file):
|
|
5402
5493
|
os.unlink(temp_file)
|
|
@@ -5405,16 +5496,16 @@ def test_action_quit_with_client():
|
|
|
5405
5496
|
|
|
5406
5497
|
|
|
5407
5498
|
def test_action_quit_handles_exception():
|
|
5408
|
-
"""Test action_quit handles exception from
|
|
5499
|
+
"""Test action_quit handles exception from close gracefully."""
|
|
5409
5500
|
|
|
5410
5501
|
async def run():
|
|
5411
5502
|
fake_client = FakeReductionClient(updates=[], wait_indefinitely=True)
|
|
5412
5503
|
|
|
5413
|
-
# Make
|
|
5504
|
+
# Make close raise an exception
|
|
5414
5505
|
async def raise_exception():
|
|
5415
5506
|
raise RuntimeError("Process already exited")
|
|
5416
5507
|
|
|
5417
|
-
fake_client.
|
|
5508
|
+
fake_client.close = raise_exception
|
|
5418
5509
|
|
|
5419
5510
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
|
5420
5511
|
f.write("test")
|
|
@@ -6125,11 +6216,12 @@ def test_history_modal_read_file_success(tmp_path):
|
|
|
6125
6216
|
modal = HistoryExplorerModal(str(tmp_path), "test.txt")
|
|
6126
6217
|
content = modal._read_file(str(test_file))
|
|
6127
6218
|
|
|
6128
|
-
assert content
|
|
6219
|
+
assert isinstance(content, Text)
|
|
6220
|
+
assert content.plain == "Hello World\nLine 2"
|
|
6129
6221
|
|
|
6130
6222
|
|
|
6131
|
-
def
|
|
6132
|
-
"""Test _read_file
|
|
6223
|
+
def test_history_modal_read_file_preserves_brackets(tmp_path):
|
|
6224
|
+
"""Test _read_file preserves bracket characters in file content."""
|
|
6133
6225
|
test_file = tmp_path / "test.txt"
|
|
6134
6226
|
# Content with brackets that look like Rich markup tags
|
|
6135
6227
|
test_file.write_text("error: [bold]text[/bold] failed")
|
|
@@ -6137,9 +6229,9 @@ def test_history_modal_read_file_escapes_markup(tmp_path):
|
|
|
6137
6229
|
modal = HistoryExplorerModal(str(tmp_path), "test.txt")
|
|
6138
6230
|
content = modal._read_file(str(test_file))
|
|
6139
6231
|
|
|
6140
|
-
#
|
|
6141
|
-
assert
|
|
6142
|
-
|
|
6232
|
+
# Returns a Text object so brackets are preserved literally
|
|
6233
|
+
assert isinstance(content, Text)
|
|
6234
|
+
assert "[bold]" in content.plain
|
|
6143
6235
|
assert "error:" in content
|
|
6144
6236
|
assert "failed" in content
|
|
6145
6237
|
|
|
@@ -6153,15 +6245,17 @@ def test_history_modal_read_file_binary(tmp_path):
|
|
|
6153
6245
|
content = modal._read_file(str(test_file))
|
|
6154
6246
|
|
|
6155
6247
|
# Should fall back to hex display
|
|
6248
|
+
assert isinstance(content, Text)
|
|
6156
6249
|
assert "Binary content" in content
|
|
6157
6250
|
|
|
6158
6251
|
|
|
6159
6252
|
def test_history_modal_read_file_missing(tmp_path):
|
|
6160
|
-
"""Test _read_file returns
|
|
6253
|
+
"""Test _read_file returns styled message for missing file."""
|
|
6161
6254
|
modal = HistoryExplorerModal(str(tmp_path), "test.txt")
|
|
6162
6255
|
content = modal._read_file(str(tmp_path / "nonexistent.txt"))
|
|
6163
6256
|
|
|
6164
|
-
assert
|
|
6257
|
+
assert isinstance(content, Text)
|
|
6258
|
+
assert "File not found" in content
|
|
6165
6259
|
|
|
6166
6260
|
|
|
6167
6261
|
def test_history_modal_read_file_truncated_text(tmp_path):
|
|
@@ -6174,9 +6268,10 @@ def test_history_modal_read_file_truncated_text(tmp_path):
|
|
|
6174
6268
|
content = modal._read_file(str(test_file))
|
|
6175
6269
|
|
|
6176
6270
|
# Should be truncated
|
|
6177
|
-
assert
|
|
6178
|
-
|
|
6179
|
-
|
|
6271
|
+
assert isinstance(content, Text)
|
|
6272
|
+
assert "truncated" in content
|
|
6273
|
+
# Content should be limited (50000 chars + truncation message)
|
|
6274
|
+
assert len(content.plain) < 60000
|
|
6180
6275
|
|
|
6181
6276
|
|
|
6182
6277
|
def test_history_modal_read_file_truncated_binary(tmp_path):
|
|
@@ -6189,8 +6284,9 @@ def test_history_modal_read_file_truncated_binary(tmp_path):
|
|
|
6189
6284
|
content = modal._read_file(str(test_file))
|
|
6190
6285
|
|
|
6191
6286
|
# Should be truncated and shown as binary
|
|
6192
|
-
assert
|
|
6193
|
-
assert "
|
|
6287
|
+
assert isinstance(content, Text)
|
|
6288
|
+
assert "Binary content" in content
|
|
6289
|
+
assert "truncated" in content
|
|
6194
6290
|
|
|
6195
6291
|
|
|
6196
6292
|
def test_history_modal_read_file_oserror(tmp_path):
|
|
@@ -6204,7 +6300,8 @@ def test_history_modal_read_file_oserror(tmp_path):
|
|
|
6204
6300
|
with patch("builtins.open", side_effect=OSError("Permission denied")):
|
|
6205
6301
|
content = modal._read_file(str(test_file))
|
|
6206
6302
|
|
|
6207
|
-
assert
|
|
6303
|
+
assert isinstance(content, Text)
|
|
6304
|
+
assert "Error reading file" in content
|
|
6208
6305
|
|
|
6209
6306
|
|
|
6210
6307
|
def test_history_modal_on_list_view_highlighted_no_entries(tmp_path):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|