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/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, TypeVar
14
+ from typing import Any
14
15
 
15
16
  import humanize
16
17
  import trio
@@ -27,9 +28,6 @@ from shrinkray.reducer import DirectoryShrinkRay, Reducer, ShrinkRay
27
28
  from shrinkray.work import Volume, WorkContext
28
29
 
29
30
 
30
- T = TypeVar("T")
31
-
32
-
33
31
  class TimeoutExceededOnInitial(InvalidInitialExample):
34
32
  def __init__(self, runtime: float, timeout: float) -> None:
35
33
  self.runtime = runtime
@@ -58,6 +56,125 @@ def compute_dynamic_timeout(runtime: float) -> float:
58
56
  )
59
57
 
60
58
 
59
+ @define
60
+ class TestOutputManager:
61
+ """Manages temporary files for test output capture.
62
+
63
+ Allocates unique files for each test's stdout/stderr output,
64
+ tracks active and completed tests, and cleans up old files.
65
+ """
66
+
67
+ output_dir: str
68
+ max_files: int = 50
69
+ max_age_seconds: float = 60.0
70
+ min_display_seconds: float = 1.0 # Minimum time to show completed output
71
+ grace_period_seconds: float = (
72
+ 0.5 # Extra time to wait for new test after min_display
73
+ )
74
+
75
+ _sequence: int = 0
76
+ _active_outputs: dict[int, str] = {}
77
+ _completed_outputs: deque[tuple[int, str, float]] = deque()
78
+
79
+ def __attrs_post_init__(self) -> None:
80
+ # Initialize mutable defaults
81
+ self._active_outputs = {}
82
+ self._completed_outputs = deque()
83
+
84
+ def allocate_output_file(self) -> tuple[int, str]:
85
+ """Allocate a new output file for a test. Returns (test_id, file_path)."""
86
+ test_id = self._sequence
87
+ self._sequence += 1
88
+ file_path = os.path.join(self.output_dir, f"test_{test_id}.log")
89
+ self._active_outputs[test_id] = file_path
90
+ return test_id, file_path
91
+
92
+ def mark_completed(self, test_id: int) -> None:
93
+ """Mark a test as completed and move to completed queue."""
94
+ if test_id in self._active_outputs:
95
+ file_path = self._active_outputs.pop(test_id)
96
+ self._completed_outputs.append((test_id, file_path, time.time()))
97
+ self._cleanup_old_files()
98
+
99
+ def _cleanup_old_files(self) -> None:
100
+ """Remove old output files based on count and age limits."""
101
+ now = time.time()
102
+ # Remove files older than max_age_seconds
103
+ while (
104
+ self._completed_outputs
105
+ and now - self._completed_outputs[0][2] > self.max_age_seconds
106
+ ):
107
+ _, file_path, _ = self._completed_outputs.popleft()
108
+ self._safe_delete(file_path)
109
+ # Remove excess files beyond max_files
110
+ while len(self._completed_outputs) > self.max_files:
111
+ _, file_path, _ = self._completed_outputs.popleft()
112
+ self._safe_delete(file_path)
113
+
114
+ def _should_show_completed(self) -> tuple[int, str] | None:
115
+ """Check if we should show a completed test's output.
116
+
117
+ Note: This method is only called when there are no active tests.
118
+ It returns the completed test info if within the display window
119
+ (min_display_seconds + grace_period).
120
+ """
121
+ if not self._completed_outputs:
122
+ return None
123
+ test_id, file_path, completion_time = self._completed_outputs[-1]
124
+ elapsed = time.time() - completion_time
125
+
126
+ # Show completed test during the full display window
127
+ if elapsed < self.min_display_seconds + self.grace_period_seconds:
128
+ return test_id, file_path
129
+
130
+ return None
131
+
132
+ def get_current_output_path(self) -> str | None:
133
+ """Get the most relevant output file path.
134
+
135
+ Active tests always take priority. If no active test, shows
136
+ recently completed test output for min_display_seconds, plus
137
+ an additional grace_period if no new test has started.
138
+ """
139
+ # Active tests always take priority
140
+ if self._active_outputs:
141
+ max_id = max(self._active_outputs.keys())
142
+ return self._active_outputs[max_id]
143
+ # Then check for recently completed test that should stay visible
144
+ recent = self._should_show_completed()
145
+ if recent is not None:
146
+ return recent[1]
147
+ # Fall back to most recent completed (even if past display window)
148
+ if self._completed_outputs:
149
+ return self._completed_outputs[-1][1]
150
+ return None
151
+
152
+ def get_active_test_id(self) -> int | None:
153
+ """Get the currently running test ID, if any.
154
+
155
+ Returns the active test ID if one is running, None otherwise.
156
+ """
157
+ if self._active_outputs:
158
+ return max(self._active_outputs.keys())
159
+ return None
160
+
161
+ def cleanup_all(self) -> None:
162
+ """Clean up all output files (called on shutdown)."""
163
+ for file_path in self._active_outputs.values():
164
+ self._safe_delete(file_path)
165
+ for _, file_path, _ in self._completed_outputs:
166
+ self._safe_delete(file_path)
167
+ self._active_outputs.clear()
168
+ self._completed_outputs.clear()
169
+
170
+ @staticmethod
171
+ def _safe_delete(path: str) -> None:
172
+ try:
173
+ os.unlink(path)
174
+ except OSError:
175
+ pass
176
+
177
+
61
178
  @define(slots=False)
62
179
  class ShrinkRayState[TestCase](ABC):
63
180
  input_type: Any # InputType from __main__
@@ -91,6 +208,9 @@ class ShrinkRayState[TestCase](ABC):
91
208
  # Stores the output from the last debug run
92
209
  _last_debug_output: str = ""
93
210
 
211
+ # Optional output manager for capturing test output (TUI mode)
212
+ output_manager: TestOutputManager | None = None
213
+
94
214
  def __attrs_post_init__(self):
95
215
  self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
96
216
  self.setup_formatter()
@@ -175,8 +295,17 @@ class ShrinkRayState[TestCase](ABC):
175
295
 
176
296
  return completed.returncode
177
297
 
178
- # Check if we should stream output to stderr (volume=debug)
179
- if self.volume == Volume.debug:
298
+ # Determine output handling
299
+ test_id: int | None = None
300
+ output_file_handle = None
301
+
302
+ if self.output_manager is not None:
303
+ # Capture output to a file for TUI display
304
+ test_id, output_path = self.output_manager.allocate_output_file()
305
+ output_file_handle = open(output_path, "wb")
306
+ kwargs["stdout"] = output_file_handle.fileno()
307
+ kwargs["stderr"] = subprocess.STDOUT # Combine stderr into stdout
308
+ elif self.volume == Volume.debug:
180
309
  # Inherit stderr from parent process to stream output in real-time
181
310
  kwargs["stderr"] = None # None means inherit
182
311
  kwargs["stdout"] = subprocess.DEVNULL
@@ -185,59 +314,66 @@ class ShrinkRayState[TestCase](ABC):
185
314
  kwargs["stdout"] = subprocess.DEVNULL
186
315
  kwargs["stderr"] = subprocess.DEVNULL
187
316
 
188
- async with trio.open_nursery() as nursery:
189
-
190
- def call_with_kwargs(task_status=trio.TASK_STATUS_IGNORED): # type: ignore
191
- return trio.run_process(command, **kwargs, task_status=task_status)
317
+ try:
318
+ async with trio.open_nursery() as nursery:
192
319
 
193
- start_time = time.time()
194
- sp = await nursery.start(call_with_kwargs)
195
-
196
- try:
197
- # Determine effective timeout for this call
198
- if self.first_call:
199
- # For first call: use calibration timeout if dynamic, otherwise 10x explicit timeout
200
- if self.timeout is None:
201
- effective_timeout = DYNAMIC_TIMEOUT_CALIBRATION_TIMEOUT
202
- else:
203
- effective_timeout = self.timeout * 10
204
- else:
205
- # For subsequent calls, timeout must be set (either explicit or computed)
206
- assert self.timeout is not None
207
- effective_timeout = self.timeout
208
-
209
- with trio.move_on_after(effective_timeout):
210
- await sp.wait()
211
-
212
- runtime = time.time() - start_time
213
-
214
- if sp.returncode is None:
215
- # Process didn't terminate before timeout - kill it
216
- await self._interrupt_wait_and_kill(sp)
217
-
218
- # Check for timeout violation (only when timeout is explicitly set)
219
- if (
220
- self.timeout is not None
221
- and runtime >= self.timeout
222
- and self.first_call
223
- ):
224
- raise TimeoutExceededOnInitial(
225
- timeout=self.timeout,
226
- runtime=runtime,
227
- )
228
- finally:
229
- if self.first_call:
230
- self.initial_exit_code = sp.returncode
231
- # Set dynamic timeout if not explicitly specified
232
- if self.timeout is None:
233
- runtime = time.time() - start_time
234
- self.timeout = compute_dynamic_timeout(runtime)
235
- self.first_call = False
320
+ def call_with_kwargs(task_status=trio.TASK_STATUS_IGNORED): # type: ignore
321
+ return trio.run_process(command, **kwargs, task_status=task_status)
236
322
 
237
- result: int | None = sp.returncode
238
- assert result is not None
323
+ start_time = time.time()
324
+ sp = await nursery.start(call_with_kwargs)
239
325
 
240
- return result
326
+ try:
327
+ # Determine effective timeout for this call
328
+ if self.first_call:
329
+ # For first call: use calibration timeout if dynamic, otherwise 10x explicit timeout
330
+ if self.timeout is None:
331
+ effective_timeout = DYNAMIC_TIMEOUT_CALIBRATION_TIMEOUT
332
+ else:
333
+ effective_timeout = self.timeout * 10
334
+ else:
335
+ # For subsequent calls, timeout must be set (either explicit or computed)
336
+ assert self.timeout is not None
337
+ effective_timeout = self.timeout
338
+
339
+ with trio.move_on_after(effective_timeout):
340
+ await sp.wait()
341
+
342
+ runtime = time.time() - start_time
343
+
344
+ if sp.returncode is None:
345
+ # Process didn't terminate before timeout - kill it
346
+ await self._interrupt_wait_and_kill(sp)
347
+
348
+ # Check for timeout violation (only when timeout is explicitly set)
349
+ if (
350
+ self.timeout is not None
351
+ and runtime >= self.timeout
352
+ and self.first_call
353
+ ):
354
+ raise TimeoutExceededOnInitial(
355
+ timeout=self.timeout,
356
+ runtime=runtime,
357
+ )
358
+ finally:
359
+ if self.first_call:
360
+ self.initial_exit_code = sp.returncode
361
+ # Set dynamic timeout if not explicitly specified
362
+ if self.timeout is None:
363
+ runtime = time.time() - start_time
364
+ self.timeout = compute_dynamic_timeout(runtime)
365
+ self.first_call = False
366
+
367
+ result: int | None = sp.returncode
368
+ assert result is not None
369
+
370
+ return result
371
+ finally:
372
+ # Clean up output file handle and mark test as completed
373
+ if output_file_handle is not None:
374
+ output_file_handle.close()
375
+ if test_id is not None and self.output_manager is not None:
376
+ self.output_manager.mark_completed(test_id)
241
377
 
242
378
  async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
243
379
  # Lazy import
@@ -5,9 +5,7 @@ from shrinkray.subprocess.protocol import (
5
5
  ProgressUpdate,
6
6
  Request,
7
7
  Response,
8
- decode_bytes,
9
8
  deserialize,
10
- encode_bytes,
11
9
  serialize,
12
10
  )
13
11
 
@@ -18,7 +16,5 @@ __all__ = [
18
16
  "ProgressUpdate",
19
17
  "serialize",
20
18
  "deserialize",
21
- "encode_bytes",
22
- "decode_bytes",
23
19
  "SubprocessClient",
24
20
  ]
@@ -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
@@ -1,6 +1,5 @@
1
1
  """Line-oriented JSON protocol for subprocess communication."""
2
2
 
3
- import base64
4
3
  import json
5
4
  from dataclasses import dataclass, field
6
5
  from typing import Any
@@ -56,6 +55,10 @@ class ProgressUpdate:
56
55
  current_pass_name: str = ""
57
56
  # List of disabled pass names
58
57
  disabled_passes: list[str] = field(default_factory=list)
58
+ # Test output preview (last 4KB of current/recent test output)
59
+ test_output_preview: str = ""
60
+ # Currently running test ID (None if no test running)
61
+ active_test_id: int | None = None
59
62
 
60
63
 
61
64
  @dataclass
@@ -115,6 +118,8 @@ def serialize(msg: Request | Response | ProgressUpdate) -> str:
115
118
  ],
116
119
  "current_pass_name": msg.current_pass_name,
117
120
  "disabled_passes": msg.disabled_passes,
121
+ "test_output_preview": msg.test_output_preview,
122
+ "active_test_id": msg.active_test_id,
118
123
  },
119
124
  }
120
125
  else:
@@ -162,6 +167,8 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
162
167
  pass_stats=pass_stats_data,
163
168
  current_pass_name=d.get("current_pass_name", ""),
164
169
  disabled_passes=d.get("disabled_passes", []),
170
+ test_output_preview=d.get("test_output_preview", ""),
171
+ active_test_id=d.get("active_test_id"),
165
172
  )
166
173
 
167
174
  # Check for response (has "result" or "error" field)
@@ -178,13 +185,3 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
178
185
  command=data["command"],
179
186
  params=data.get("params", {}),
180
187
  )
181
-
182
-
183
- def encode_bytes(data: bytes) -> str:
184
- """Encode bytes to base64 string for JSON transport."""
185
- return base64.b64encode(data).decode("ascii")
186
-
187
-
188
- def decode_bytes(data: str) -> bytes:
189
- """Decode base64 string back to bytes."""
190
- return base64.b64decode(data.encode("ascii"))
@@ -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
@@ -35,14 +37,6 @@ class OutputStream(Protocol):
35
37
  async def send(self, data: bytes) -> None: ...
36
38
 
37
39
 
38
- class StdoutStream:
39
- """Wrapper around sys.stdout for the OutputStream protocol."""
40
-
41
- async def send(self, data: bytes) -> None:
42
- sys.stdout.write(data.decode("utf-8"))
43
- sys.stdout.flush()
44
-
45
-
46
40
  class ReducerWorker:
47
41
  """Runs the reducer in a subprocess with JSON protocol communication."""
48
42
 
@@ -62,6 +56,8 @@ class ReducerWorker:
62
56
  # I/O streams - None means use stdin/stdout
63
57
  self._input_stream = input_stream
64
58
  self._output_stream = output_stream
59
+ # Output directory for test output capture (cleaned up on shutdown)
60
+ self._output_dir: str | None = None
65
61
 
66
62
  async def emit(self, msg: Response | ProgressUpdate) -> None:
67
63
  """Write a message to the output stream."""
@@ -163,6 +159,7 @@ class ReducerWorker:
163
159
  from shrinkray.state import (
164
160
  ShrinkRayDirectoryState,
165
161
  ShrinkRayStateSingleFile,
162
+ TestOutputManager,
166
163
  )
167
164
  from shrinkray.work import Volume
168
165
 
@@ -178,6 +175,7 @@ class ReducerWorker:
178
175
  no_clang_delta = params.get("no_clang_delta", False)
179
176
  clang_delta_path = params.get("clang_delta", "")
180
177
  trivial_is_error = params.get("trivial_is_error", True)
178
+ skip_validation = params.get("skip_validation", False)
181
179
 
182
180
  clang_delta_executable = None
183
181
  if os.path.splitext(filename)[1] in C_FILE_EXTENSIONS and not no_clang_delta:
@@ -213,12 +211,18 @@ class ReducerWorker:
213
211
  initial = reader.read()
214
212
  self.state = ShrinkRayStateSingleFile(initial=initial, **state_kwargs)
215
213
 
214
+ # Create output manager for test output capture (always enabled for TUI)
215
+ self._output_dir = tempfile.mkdtemp(prefix="shrinkray-output-")
216
+ self.state.output_manager = TestOutputManager(output_dir=self._output_dir)
217
+
216
218
  self.problem = self.state.problem
217
219
  self.reducer = self.state.reducer
218
220
 
219
221
  # Validate initial example before starting - this will raise
220
- # InvalidInitialExample if the initial test case fails
221
- await self.problem.setup()
222
+ # InvalidInitialExample if the initial test case fails.
223
+ # Skip if validation was already done by the caller (e.g., main()).
224
+ if not skip_validation:
225
+ await self.problem.setup()
222
226
 
223
227
  self.running = True
224
228
 
@@ -300,6 +304,32 @@ class ReducerWorker:
300
304
  return Response(id=request_id, result={"status": "skipped"})
301
305
  return Response(id=request_id, error="Reducer does not support pass control")
302
306
 
307
+ def _get_test_output_preview(self) -> tuple[str, int | None]:
308
+ """Get preview of current test output and active test ID."""
309
+ if self.state is None or self.state.output_manager is None:
310
+ return "", None
311
+
312
+ manager = self.state.output_manager
313
+ active_test_id = manager.get_active_test_id()
314
+ output_path = manager.get_current_output_path()
315
+
316
+ if output_path is None:
317
+ return "", active_test_id
318
+
319
+ # Read last 4KB of file
320
+ try:
321
+ with open(output_path, "rb") as f:
322
+ f.seek(0, 2) # Seek to end
323
+ size = f.tell()
324
+ if size > 4096:
325
+ f.seek(-4096, 2)
326
+ else:
327
+ f.seek(0)
328
+ data = f.read()
329
+ return data.decode("utf-8", errors="replace"), active_test_id
330
+ except OSError:
331
+ return "", active_test_id
332
+
303
333
  def _get_content_preview(self) -> tuple[str, bool]:
304
334
  """Get a preview of the current test case content."""
305
335
  if self.problem is None:
@@ -402,6 +432,9 @@ class ReducerWorker:
402
432
  else:
403
433
  disabled_passes = []
404
434
 
435
+ # Get test output preview
436
+ test_output_preview, active_test_id = self._get_test_output_preview()
437
+
405
438
  return ProgressUpdate(
406
439
  status=self.reducer.status if self.reducer else "",
407
440
  size=stats.current_test_case_size,
@@ -420,6 +453,8 @@ class ReducerWorker:
420
453
  pass_stats=pass_stats_list,
421
454
  current_pass_name=current_pass_name,
422
455
  disabled_passes=disabled_passes,
456
+ test_output_preview=test_output_preview,
457
+ active_test_id=active_test_id,
423
458
  )
424
459
 
425
460
  async def emit_progress_updates(self) -> None:
@@ -469,25 +504,32 @@ class ReducerWorker:
469
504
 
470
505
  async def run(self) -> None:
471
506
  """Main entry point for the worker."""
472
- async with trio.open_nursery() as nursery:
473
- await nursery.start(self.read_commands)
507
+ try:
508
+ async with trio.open_nursery() as nursery:
509
+ await nursery.start(self.read_commands)
474
510
 
475
- # Wait for start command
476
- while not self.running:
477
- await trio.sleep(0.01)
511
+ # Wait for start command
512
+ while not self.running:
513
+ await trio.sleep(0.01)
478
514
 
479
- # Start progress updates and reducer
480
- nursery.start_soon(self.emit_progress_updates)
481
- await self.run_reducer()
515
+ # Start progress updates and reducer
516
+ nursery.start_soon(self.emit_progress_updates)
517
+ await self.run_reducer()
482
518
 
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)
519
+ # Emit final progress update before completion
520
+ final_update = await self._build_progress_update()
521
+ if final_update is not None:
522
+ await self.emit(final_update)
487
523
 
488
- # Signal completion
489
- await self.emit(Response(id="", result={"status": "completed"}))
490
- nursery.cancel_scope.cancel()
524
+ # Signal completion
525
+ await self.emit(Response(id="", result={"status": "completed"}))
526
+ nursery.cancel_scope.cancel()
527
+ finally:
528
+ # Clean up test output files and temp directory
529
+ if self.state is not None and self.state.output_manager is not None:
530
+ self.state.output_manager.cleanup_all()
531
+ if self._output_dir is not None and os.path.isdir(self._output_dir):
532
+ shutil.rmtree(self._output_dir, ignore_errors=True)
491
533
 
492
534
 
493
535
  def main() -> None: