shrinkray 26.1.1.0__py3-none-any.whl → 26.2.4.1__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.
@@ -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=5.0)
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:
shrinkray/tui.py CHANGED
@@ -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) -> str:
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) -> str:
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
- header = f"[green]Test #{self.active_test_id} running...[/green]"
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
- header = f"[dim]Test #{self.active_test_id} exited with code {self.last_return_code}[/dim]"
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
- header = ""
607
+ header_text = ""
608
+ header_style = ""
604
609
  else:
605
- header = "[dim]No test output yet...[/dim]"
610
+ header_text = "No test output yet..."
611
+ header_style = "dim"
606
612
 
607
613
  if not self.output_content:
608
- return header
614
+ return Text(header_text, style=header_style)
609
615
 
610
616
  available_lines = self._get_available_lines()
611
- # Escape the output content to prevent Rich markup interpretation
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) <= available_lines:
621
- return f"{prefix}{escaped_content}"
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
- # Truncate from the beginning
624
- truncated_lines = lines[-(available_lines):]
625
- skipped = len(lines) - available_lines
626
- return f"{prefix}... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
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) -> 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 "[dim]File not found[/dim]"
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
- # Escape Rich markup to prevent interpretation of [ ] etc
748
- return escape_markup(text)
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 "[red]Error reading file[/red]"
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 - return_code is None means test is still running
820
+ # Build header with styling
815
821
  if test_id is not None and return_code is None:
816
- header = f"[green]Test #{test_id} running...[/green]\n\n"
822
+ header_text = f"Test #{test_id} running..."
823
+ header_style = "green"
817
824
  elif test_id is not None:
818
- header = f"[dim]Test #{test_id} exited with code {return_code}[/dim]\n\n"
825
+ header_text = f"Test #{test_id} exited with code {return_code}"
826
+ header_style = "dim"
819
827
  else:
820
- header = ""
828
+ header_text = ""
829
+ header_style = ""
821
830
 
822
831
  if raw_content:
823
- return header + raw_content
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 header.rstrip("\n") if header else ""
840
+ return Text(header_text, style=header_style)
827
841
  else:
828
- return "[dim]No test output yet...[/dim]"
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 = "[dim]No output captured[/dim]"
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) -> 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 "[dim]File not found[/dim]"
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
- # Escape Rich markup to prevent interpretation of [ ] etc
1469
- text = escape_markup(text)
1482
+ result = Text(text)
1470
1483
  if truncated:
1471
- text += "\n\n[dim]... (truncated)[/dim]"
1472
- return text
1484
+ result.append("\n\n... (truncated)", style="dim")
1485
+ return result
1473
1486
  # Binary content - hex display
1474
- hex_display = "[Binary content - hex display]\n\n" + raw_content.hex()
1487
+ result = Text("Binary content - hex display\n\n" + raw_content.hex())
1475
1488
  if truncated:
1476
- hex_display += "\n\n[dim]... (truncated)[/dim]"
1477
- return hex_display
1489
+ result.append("\n\n... (truncated)", style="dim")
1490
+ return result
1478
1491
  except OSError:
1479
- return "[red]Error reading file[/red]"
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.cancel()
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrinkray
3
- Version: 26.1.1.0
3
+ Version: 26.2.4.1
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -8,7 +8,7 @@ shrinkray/process.py,sha256=-eP8h5X0ESbkcTic8FFEzkd4-vwaZ0YI5tLxUR25L8U,1599
8
8
  shrinkray/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  shrinkray/reducer.py,sha256=8CF_SxkfVMBxiikQKwv-rlrQawTLhQxy6QnVwIWWiws,18601
10
10
  shrinkray/state.py,sha256=gDKz1jov84lEih-7jtYcYoWfIS2k2YtBtWuJmcAx6Is,38478
11
- shrinkray/tui.py,sha256=FKg5ETDC0gxurCYJpW0BLJGm9rPGRCiQC7PD0WYXfOg,73572
11
+ shrinkray/tui.py,sha256=40od9sbfOER-Pg_ZRL1MZ8XhOnH8r5axbtJcb827qPo,73924
12
12
  shrinkray/ui.py,sha256=xuDUwU-MM3AetvwUB7bfzav0P_drUsBrKFPhON_Nr-k,2251
13
13
  shrinkray/validation.py,sha256=piBCO-k9he_id6TWC4EHMK3GfuyPqRcNfkNJPVjxEaU,13366
14
14
  shrinkray/work.py,sha256=GEZ14Kk3bvwUxAnACvY-wom2lVWaGrELMNxrDjv03dk,8110
@@ -23,12 +23,12 @@ shrinkray/passes/python.py,sha256=3WN1lZTf5oVL8FCTGomhrCuE04wIX9ocKcmFV86NMZA,68
23
23
  shrinkray/passes/sat.py,sha256=OboY6jsKf6lph3pAFh535plvhNOVzEF8HJ66WEqsNm4,19483
24
24
  shrinkray/passes/sequences.py,sha256=-5ajmMeHnS7onjjppbxLiP0F6mRSqiFI5DspBTj2x_M,2206
25
25
  shrinkray/subprocess/__init__.py,sha256=qxZ19Nzizbm7H0MkKL38OqfP7U-VuOAvaqBVkmHFivY,375
26
- shrinkray/subprocess/client.py,sha256=c430-qYlLlVDHoaCt2Ho2ZjbIKaGYroyt6qXfp3tvkQ,11336
26
+ shrinkray/subprocess/client.py,sha256=4nMFiHfqGSLQHTZ1jgt4dq2GUCBXwgTqvzPw6Z1N1q4,12199
27
27
  shrinkray/subprocess/protocol.py,sha256=86sSxexQpPpr4W2C1y0V5Ddqoqb-1LfHa2wKjJSzmJA,7340
28
28
  shrinkray/subprocess/worker.py,sha256=Spy2DjzJGHgFmr9PTxkd68q1REoymWcA4Ls4iDD36tE,31373
29
- shrinkray-26.1.1.0.dist-info/licenses/LICENSE,sha256=iMKX79AuokJfIZUnGUARdUp30vVAoIPOJ7ek8TY63kk,1072
30
- shrinkray-26.1.1.0.dist-info/METADATA,sha256=2KKaq4d5_2a4VuHR_paZ_4zOz8YrtkxvOzo-K7fnOTc,7836
31
- shrinkray-26.1.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
32
- shrinkray-26.1.1.0.dist-info/entry_points.txt,sha256=wIZvnGyOdVeaLTiv2klnSyTe-EKkkwn4SwHh9bmJ7qk,104
33
- shrinkray-26.1.1.0.dist-info/top_level.txt,sha256=fLif8-rFoFOnf5h8-vs3ECkKNWQopTQh3xvl1s7pchQ,10
34
- shrinkray-26.1.1.0.dist-info/RECORD,,
29
+ shrinkray-26.2.4.1.dist-info/licenses/LICENSE,sha256=iMKX79AuokJfIZUnGUARdUp30vVAoIPOJ7ek8TY63kk,1072
30
+ shrinkray-26.2.4.1.dist-info/METADATA,sha256=t4FlQASaioxkzPRRxdjaMxgd3JU_pFeQuHanZ3sOPFE,7836
31
+ shrinkray-26.2.4.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
32
+ shrinkray-26.2.4.1.dist-info/entry_points.txt,sha256=wIZvnGyOdVeaLTiv2klnSyTe-EKkkwn4SwHh9bmJ7qk,104
33
+ shrinkray-26.2.4.1.dist-info/top_level.txt,sha256=fLif8-rFoFOnf5h8-vs3ECkKNWQopTQh3xvl1s7pchQ,10
34
+ shrinkray-26.2.4.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5