shrinkray 25.12.27.1__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/state.py CHANGED
@@ -8,9 +8,10 @@ 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
- from typing import Any
14
+ from typing import Any, TypeVar
14
15
 
15
16
  import humanize
16
17
  import trio
@@ -21,12 +22,15 @@ from shrinkray.problem import (
21
22
  BasicReductionProblem,
22
23
  InvalidInitialExample,
23
24
  ReductionProblem,
24
- shortlex,
25
+ sort_key_for_initial,
25
26
  )
26
27
  from shrinkray.reducer import DirectoryShrinkRay, Reducer, ShrinkRay
27
28
  from shrinkray.work import Volume, WorkContext
28
29
 
29
30
 
31
+ T = TypeVar("T")
32
+
33
+
30
34
  class TimeoutExceededOnInitial(InvalidInitialExample):
31
35
  def __init__(self, runtime: float, timeout: float) -> None:
32
36
  self.runtime = runtime
@@ -55,6 +59,125 @@ def compute_dynamic_timeout(runtime: float) -> float:
55
59
  )
56
60
 
57
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
+
58
181
  @define(slots=False)
59
182
  class ShrinkRayState[TestCase](ABC):
60
183
  input_type: Any # InputType from __main__
@@ -88,6 +211,9 @@ class ShrinkRayState[TestCase](ABC):
88
211
  # Stores the output from the last debug run
89
212
  _last_debug_output: str = ""
90
213
 
214
+ # Optional output manager for capturing test output (TUI mode)
215
+ output_manager: TestOutputManager | None = None
216
+
91
217
  def __attrs_post_init__(self):
92
218
  self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
93
219
  self.setup_formatter()
@@ -172,8 +298,17 @@ class ShrinkRayState[TestCase](ABC):
172
298
 
173
299
  return completed.returncode
174
300
 
175
- # Check if we should stream output to stderr (volume=debug)
176
- if self.volume == Volume.debug:
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:
177
312
  # Inherit stderr from parent process to stream output in real-time
178
313
  kwargs["stderr"] = None # None means inherit
179
314
  kwargs["stdout"] = subprocess.DEVNULL
@@ -182,59 +317,66 @@ class ShrinkRayState[TestCase](ABC):
182
317
  kwargs["stdout"] = subprocess.DEVNULL
183
318
  kwargs["stderr"] = subprocess.DEVNULL
184
319
 
185
- async with trio.open_nursery() as nursery:
186
-
187
- def call_with_kwargs(task_status=trio.TASK_STATUS_IGNORED): # type: ignore
188
- return trio.run_process(command, **kwargs, task_status=task_status)
320
+ try:
321
+ async with trio.open_nursery() as nursery:
189
322
 
190
- start_time = time.time()
191
- sp = await nursery.start(call_with_kwargs)
192
-
193
- try:
194
- # Determine effective timeout for this call
195
- if self.first_call:
196
- # For first call: use calibration timeout if dynamic, otherwise 10x explicit timeout
197
- if self.timeout is None:
198
- effective_timeout = DYNAMIC_TIMEOUT_CALIBRATION_TIMEOUT
199
- else:
200
- effective_timeout = self.timeout * 10
201
- else:
202
- # For subsequent calls, timeout must be set (either explicit or computed)
203
- assert self.timeout is not None
204
- effective_timeout = self.timeout
205
-
206
- with trio.move_on_after(effective_timeout):
207
- await sp.wait()
208
-
209
- runtime = time.time() - start_time
210
-
211
- if sp.returncode is None:
212
- # Process didn't terminate before timeout - kill it
213
- await self._interrupt_wait_and_kill(sp)
214
-
215
- # Check for timeout violation (only when timeout is explicitly set)
216
- if (
217
- self.timeout is not None
218
- and runtime >= self.timeout
219
- and self.first_call
220
- ):
221
- raise TimeoutExceededOnInitial(
222
- timeout=self.timeout,
223
- runtime=runtime,
224
- )
225
- finally:
226
- if self.first_call:
227
- self.initial_exit_code = sp.returncode
228
- # Set dynamic timeout if not explicitly specified
229
- if self.timeout is None:
230
- runtime = time.time() - start_time
231
- self.timeout = compute_dynamic_timeout(runtime)
232
- self.first_call = False
323
+ def call_with_kwargs(task_status=trio.TASK_STATUS_IGNORED): # type: ignore
324
+ return trio.run_process(command, **kwargs, task_status=task_status)
233
325
 
234
- result: int | None = sp.returncode
235
- assert result is not None
326
+ start_time = time.time()
327
+ sp = await nursery.start(call_with_kwargs)
236
328
 
237
- return result
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)
238
380
 
239
381
  async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
240
382
  # Lazy import
@@ -310,6 +452,7 @@ class ShrinkRayState[TestCase](ABC):
310
452
  is_interesting=self.is_interesting,
311
453
  initial=self.initial,
312
454
  work=work,
455
+ sort_key=sort_key_for_initial(self.initial),
313
456
  **self.extra_problem_kwargs,
314
457
  )
315
458
 
@@ -593,20 +736,9 @@ class ShrinkRayDirectoryState(ShrinkRayState[dict[str, bytes]]):
593
736
  def setup_formatter(self): ...
594
737
 
595
738
  @property
596
- def extra_problem_kwargs(self):
597
- def dict_size(test_case: dict[str, bytes]) -> int:
598
- return sum(len(v) for v in test_case.values())
599
-
600
- def dict_sort_key(test_case: dict[str, bytes]) -> Any:
601
- return (
602
- len(test_case),
603
- dict_size(test_case),
604
- sorted((k, shortlex(v)) for k, v in test_case.items()),
605
- )
606
-
739
+ def extra_problem_kwargs(self) -> dict[str, Any]:
607
740
  return {
608
- "sort_key": dict_sort_key,
609
- "size": dict_size,
741
+ "size": lambda tc: sum(len(v) for v in tc.values()),
610
742
  }
611
743
 
612
744
  def new_reducer(
@@ -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
@@ -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)
@@ -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
- await self.problem.setup()
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
- async with trio.open_nursery() as nursery:
473
- await nursery.start(self.read_commands)
515
+ try:
516
+ async with trio.open_nursery() as nursery:
517
+ await nursery.start(self.read_commands)
474
518
 
475
- # Wait for start command
476
- while not self.running:
477
- await trio.sleep(0.01)
519
+ # Wait for start command
520
+ while not self.running:
521
+ await trio.sleep(0.01)
478
522
 
479
- # Start progress updates and reducer
480
- nursery.start_soon(self.emit_progress_updates)
481
- await self.run_reducer()
523
+ # Start progress updates and reducer
524
+ nursery.start_soon(self.emit_progress_updates)
525
+ await self.run_reducer()
482
526
 
483
- # Emit final progress update before completion
484
- final_update = await self._build_progress_update()
485
- if final_update is not None:
486
- await self.emit(final_update)
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
- # Signal completion
489
- await self.emit(Response(id="", result={"status": "completed"}))
490
- nursery.cancel_scope.cancel()
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: