shrinkray 0.0.0__py3-none-any.whl → 25.12.26.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
shrinkray/__main__.py CHANGED
@@ -1,950 +1,49 @@
1
- import math
1
+ """Main entry point for shrink ray."""
2
+
2
3
  import os
3
- import random
4
- import shlex
5
4
  import shutil
6
5
  import signal
7
- import subprocess
8
6
  import sys
9
- import time
10
7
  import traceback
11
- from abc import ABC, abstractmethod
12
- from datetime import timedelta
13
- from difflib import unified_diff
14
- from enum import Enum, IntEnum, auto
15
- from shutil import which
16
- from tempfile import TemporaryDirectory
17
- from typing import Any, Generic, Iterable, TypeVar
8
+ from typing import Any
18
9
 
19
- import chardet
20
10
  import click
21
- import humanize
22
11
  import trio
23
- import urwid
24
- import urwid.raw_display
25
- from attrs import define
26
- from binaryornot.check import is_binary_string
27
12
 
28
- from shrinkray.passes.clangdelta import C_FILE_EXTENSIONS, ClangDelta, find_clang_delta
29
- from shrinkray.problem import (
30
- BasicReductionProblem,
31
- InvalidInitialExample,
32
- ReductionProblem,
33
- shortlex,
13
+ from shrinkray.cli import (
14
+ EnumChoice,
15
+ InputType,
16
+ UIType,
17
+ validate_command,
18
+ validate_ui,
34
19
  )
35
- from shrinkray.reducer import DirectoryShrinkRay, Reducer, ShrinkRay
36
- from shrinkray.work import Volume, WorkContext
37
-
38
-
39
- def validate_command(ctx: Any, param: Any, value: str) -> list[str]:
40
- parts = shlex.split(value)
41
- command = parts[0]
42
-
43
- if os.path.exists(command):
44
- command = os.path.abspath(command)
45
- else:
46
- what = which(command)
47
- if what is None:
48
- raise click.BadParameter(f"{command}: command not found")
49
- command = os.path.abspath(what)
50
- return [command] + parts[1:]
51
-
52
-
53
- def signal_group(sp: "trio.Process", signal: int) -> None:
54
- gid = os.getpgid(sp.pid)
55
- assert gid != os.getgid()
56
- os.killpg(gid, signal)
57
-
58
-
59
- async def interrupt_wait_and_kill(sp: "trio.Process", delay: float = 0.1) -> None:
60
- await trio.lowlevel.checkpoint()
61
- if sp.returncode is None:
62
- try:
63
- # In case the subprocess forked. Python might hang if you don't close
64
- # all pipes.
65
- for pipe in [sp.stdout, sp.stderr, sp.stdin]:
66
- if pipe:
67
- await pipe.aclose()
68
- signal_group(sp, signal.SIGINT)
69
- for n in range(10):
70
- if sp.poll() is not None:
71
- return
72
- await trio.sleep(delay * 1.5**n * random.random())
73
- except ProcessLookupError: # pragma: no cover
74
- # This is incredibly hard to trigger reliably, because it only happens
75
- # if the process exits at exactly the wrong time.
76
- pass
77
-
78
- if sp.returncode is None:
79
- signal_group(sp, signal.SIGKILL)
80
-
81
- with trio.move_on_after(delay):
82
- await sp.wait()
83
-
84
- if sp.returncode is None:
85
- raise ValueError(
86
- f"Could not kill subprocess with pid {sp.pid}. Something has gone seriously wrong."
87
- )
88
-
89
-
90
- EnumType = TypeVar("EnumType", bound=Enum)
91
-
92
-
93
- class EnumChoice(click.Choice, Generic[EnumType]):
94
- def __init__(self, enum: type[EnumType]) -> None:
95
- self.enum = enum
96
- choices = [str(e.name) for e in enum]
97
- self.__values = {e.name: e for e in enum}
98
- super().__init__(choices)
99
-
100
- def convert(self, value: str, param: Any, ctx: Any) -> EnumType:
101
- return self.__values[value]
102
-
103
-
104
- class InputType(IntEnum):
105
- all = 0
106
- stdin = 1
107
- arg = 2
108
- basename = 3
109
-
110
- def enabled(self, value: "InputType") -> bool:
111
- if self == InputType.all:
112
- return True
113
- return self == value
114
-
115
-
116
- class DisplayMode(IntEnum):
117
- auto = 0
118
- text = 1
119
- hex = 2
120
-
121
-
122
- class UIType(Enum):
123
- urwid = auto()
124
- basic = auto()
125
-
126
-
127
- def try_decode(data: bytes) -> tuple[str | None, str]:
128
- for guess in chardet.detect_all(data):
129
- try:
130
- enc = guess["encoding"]
131
- if enc is not None:
132
- return enc, data.decode(enc)
133
- except UnicodeDecodeError:
134
- pass
135
- return None, ""
136
-
137
-
138
- class TimeoutExceededOnInitial(InvalidInitialExample):
139
- def __init__(self, runtime: float, timeout: float) -> None:
140
- self.runtime = runtime
141
- self.timeout = timeout
142
- super().__init__(
143
- f"Initial test call exceeded timeout of {timeout}s. Try raising or disabling timeout."
144
- )
145
-
146
-
147
- def find_python_command(name: str) -> str | None:
148
- first_attempt = which(name)
149
- if first_attempt is not None:
150
- return first_attempt
151
- second_attempt = os.path.join(os.path.dirname(sys.executable), name)
152
- if os.path.exists(second_attempt):
153
- return second_attempt
154
- return None
155
-
156
-
157
- def default_formatter_command_for(filename):
158
- *_, ext = os.path.splitext(filename)
159
-
160
- if ext in (".c", ".h", ".cpp", ".hpp", ".cc", ".cxx"):
161
- return which("clang-format")
162
-
163
- if ext == ".py":
164
- black = find_python_command("black")
165
- if black is not None:
166
- return [black, "-"]
167
-
168
-
169
- def default_reformat_data(data: bytes) -> bytes:
170
- encoding, decoded = try_decode(data)
171
- if encoding is None:
172
- return data
173
- result = []
174
- indent = 0
175
-
176
- def newline() -> None:
177
- result.append("\n" + indent * " ")
178
-
179
- start_of_newline = True
180
- for i, c in enumerate(decoded):
181
- if c == "\n":
182
- start_of_newline = True
183
- newline()
184
- continue
185
- elif c == " ":
186
- if start_of_newline:
187
- continue
188
- else:
189
- start_of_newline = False
190
- if c == "{":
191
- result.append(c)
192
- indent += 4
193
- if i + 1 == len(decoded) or decoded[i + 1] != "}":
194
- newline()
195
- elif c == "}":
196
- if len(result) > 1 and result[-1].endswith(" "):
197
- result[-1] = result[-1][:-4]
198
- result.append(c)
199
- indent -= 4
200
- newline()
201
- elif c == ";":
202
- result.append(c)
203
- newline()
204
- else:
205
- result.append(c)
206
-
207
- output = "".join(result)
208
- prev = None
209
- while prev != output:
210
- prev = output
211
-
212
- output = output.replace(" \n", "\n")
213
- output = output.replace("\n\n", "\n")
214
-
215
- return output.encode(encoding)
216
-
217
-
218
- TestCase = TypeVar("TestCase")
219
-
220
-
221
- @define(slots=False)
222
- class ShrinkRayState(Generic[TestCase], ABC):
223
- input_type: InputType
224
- test: list[str]
225
- filename: str
226
- timeout: float
227
- base: str
228
- parallelism: int
229
- initial: TestCase
230
- formatter: str
231
- trivial_is_error: bool
232
- seed: int
233
- volume: Volume
234
- clang_delta_executable: ClangDelta | None
235
-
236
- first_call: bool = True
237
- initial_exit_code: int | None = None
238
- parallel_tasks_running: int = 0
239
- can_format: bool = True
240
- formatter_command: list[str] | None = None
241
-
242
- first_call_time: float | None = None
243
-
244
- def __attrs_post_init__(self):
245
- self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
246
- self.setup_formatter()
247
-
248
- @abstractmethod
249
- def setup_formatter(self): ...
250
-
251
- @abstractmethod
252
- def new_reducer(self, problem: ReductionProblem[TestCase]) -> Reducer[TestCase]: ...
253
-
254
- @abstractmethod
255
- async def write_test_case_to_file_impl(self, working: str, test_case: TestCase): ...
256
-
257
- async def write_test_case_to_file(self, working: str, test_case: TestCase):
258
- await self.write_test_case_to_file_impl(working, test_case)
259
-
260
- async def run_script_on_file(
261
- self, working: str, cwd: str, debug: bool = False
262
- ) -> int:
263
- if not os.path.exists(working):
264
- raise ValueError(f"No such file {working}")
265
- if self.input_type.enabled(InputType.arg):
266
- command = self.test + [working]
267
- else:
268
- command = self.test
269
-
270
- kwargs: dict[str, Any] = dict(
271
- universal_newlines=False,
272
- preexec_fn=os.setsid,
273
- cwd=cwd,
274
- check=False,
275
- )
276
- if self.input_type.enabled(InputType.stdin) and not os.path.isdir(working):
277
- with open(working, "rb") as i:
278
- kwargs["stdin"] = i.read()
279
- else:
280
- kwargs["stdin"] = b""
281
-
282
- if not debug:
283
- kwargs["stdout"] = subprocess.DEVNULL
284
- kwargs["stderr"] = subprocess.DEVNULL
285
-
286
- async with trio.open_nursery() as nursery:
287
-
288
- def call_with_kwargs(task_status=trio.TASK_STATUS_IGNORED): # type: ignore
289
- return trio.run_process(command, **kwargs, task_status=task_status)
290
-
291
- start_time = time.time()
292
- sp = await nursery.start(call_with_kwargs)
293
-
294
- try:
295
- with trio.move_on_after(
296
- self.timeout * 10 if self.first_call else self.timeout
297
- ):
298
- await sp.wait()
299
-
300
- runtime = time.time() - start_time
301
-
302
- if sp.returncode is None:
303
- await interrupt_wait_and_kill(sp)
304
-
305
- if runtime >= self.timeout and self.first_call:
306
- raise TimeoutExceededOnInitial(
307
- timeout=self.timeout,
308
- runtime=runtime,
309
- )
310
- finally:
311
- if self.first_call:
312
- self.initial_exit_code = sp.returncode
313
- self.first_call = False
314
-
315
- result: int | None = sp.returncode
316
- assert result is not None
317
- return result
318
-
319
- async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
320
- with TemporaryDirectory() as d:
321
- working = os.path.join(d, self.base)
322
- await self.write_test_case_to_file(working, test_case)
323
-
324
- return await self.run_script_on_file(
325
- working=working,
326
- debug=debug,
327
- cwd=d,
328
- )
329
-
330
- @abstractmethod
331
- async def format_data(self, test_case: TestCase) -> TestCase | None: ...
332
-
333
- @abstractmethod
334
- async def run_formatter_command(
335
- self, command: str | list[str], input: TestCase
336
- ) -> subprocess.CompletedProcess: ...
337
-
338
- @abstractmethod
339
- async def print_exit_message(self, problem): ...
340
-
341
- @property
342
- def reducer(self):
343
- try:
344
- return self.__reducer
345
- except AttributeError:
346
- pass
347
-
348
- work = WorkContext(
349
- random=random.Random(self.seed),
350
- volume=self.volume,
351
- parallelism=self.parallelism,
352
- )
353
-
354
- problem: BasicReductionProblem[TestCase] = BasicReductionProblem(
355
- is_interesting=self.is_interesting,
356
- initial=self.initial,
357
- work=work,
358
- **self.extra_problem_kwargs,
359
- )
360
-
361
- # Writing the file back can't be guaranteed atomic, so we put a lock around
362
- # writing successful reductions back to the original file so we don't
363
- # write some confused combination of reductions.
364
- write_lock = trio.Lock()
365
-
366
- @problem.on_reduce
367
- async def _(test_case: TestCase):
368
- async with write_lock:
369
- await self.write_test_case_to_file(self.filename, test_case)
370
-
371
- self.__reducer = self.new_reducer(problem)
372
- return self.__reducer
373
-
374
- @property
375
- def extra_problem_kwargs(self):
376
- return {}
377
-
378
- @property
379
- def problem(self):
380
- return self.reducer.target
381
-
382
- async def is_interesting(self, test_case: TestCase) -> bool:
383
- if self.first_call_time is None:
384
- self.first_call_time = time.time()
385
- async with self.is_interesting_limiter:
386
- try:
387
- self.parallel_tasks_running += 1
388
- return await self.run_for_exit_code(test_case) == 0
389
- finally:
390
- self.parallel_tasks_running -= 1
391
-
392
- async def attempt_format(self, data: TestCase) -> TestCase:
393
- if not self.can_format:
394
- return data
395
- attempt = await self.format_data(data)
396
- if attempt is None:
397
- self.can_format = False
398
- return data
399
- if attempt == data or await self.is_interesting(attempt):
400
- return attempt
401
- else:
402
- self.can_format = False
403
- return data
404
-
405
- async def check_formatter(self):
406
- if self.formatter_command is None:
407
- return
408
- formatter_result = await self.run_formatter_command(
409
- self.formatter_command, self.initial
410
- )
411
-
412
- if formatter_result.returncode != 0:
413
- print(
414
- "Formatter exited unexpectedly on initial test case. If this is expected, please run with --formatter=none.",
415
- file=sys.stderr,
416
- )
417
- print(
418
- formatter_result.stderr.decode("utf-8").strip(),
419
- file=sys.stderr,
420
- )
421
- sys.exit(1)
422
- reformatted = formatter_result.stdout
423
- if not await self.is_interesting(reformatted) and await self.is_interesting(
424
- self.initial
425
- ):
426
- print(
427
- "Formatting initial test case made it uninteresting. If this is expected, please run with --formatter=none.",
428
- file=sys.stderr,
429
- )
430
- print(
431
- formatter_result.stderr.decode("utf-8").strip(),
432
- file=sys.stderr,
433
- )
434
- sys.exit(1)
435
-
436
- async def report_error(self, e):
437
- print(
438
- "Shrink ray cannot proceed because the initial call of the interestingness test resulted in an uninteresting test case.",
439
- file=sys.stderr,
440
- )
441
- if isinstance(e, TimeoutExceededOnInitial):
442
- print(
443
- f"This is because your initial test case took {e.runtime:.2f}s exceeding your timeout setting of {self.timeout}.",
444
- file=sys.stderr,
445
- )
446
- print(
447
- f"Try rerunning with --timeout={math.ceil(e.runtime * 2)}.",
448
- file=sys.stderr,
449
- )
450
- else:
451
- print(
452
- "Rerunning the interestingness test for debugging purposes...",
453
- file=sys.stderr,
454
- )
455
- exit_code = await self.run_for_exit_code(self.initial, debug=True)
456
- if exit_code != 0:
457
- print(
458
- f"This exited with code {exit_code}, but the script should return 0 for interesting test cases.",
459
- file=sys.stderr,
460
- )
461
- local_exit_code = await self.run_script_on_file(
462
- working=self.filename,
463
- debug=False,
464
- cwd=os.getcwd(),
465
- )
466
- if local_exit_code == 0:
467
- print(
468
- "Note that Shrink Ray runs your script on a copy of the file in a temporary directory. "
469
- "Here are the results of running it in the current directory directory...",
470
- file=sys.stderr,
471
- )
472
- other_exit_code = await self.run_script_on_file(
473
- working=self.filename,
474
- debug=True,
475
- cwd=os.getcwd(),
476
- )
477
- if other_exit_code != local_exit_code:
478
- print(
479
- f"This interestingness is probably flaky as the first time we reran it locally it exited with {local_exit_code}, "
480
- f"but the second time it exited with {other_exit_code}. Please make sure your interestingness test is deterministic.",
481
- file=sys.stderr,
482
- )
483
- else:
484
- print(
485
- "This suggests that your script depends on being run from the current working directory. Please fix it to be directory independent.",
486
- file=sys.stderr,
487
- )
488
- else:
489
- assert self.initial_exit_code not in (None, 0)
490
- print(
491
- f"This exited with code 0, but previously the script exited with {self.initial_exit_code}. "
492
- "This suggests your interestingness test exhibits nondeterministic behaviour.",
493
- file=sys.stderr,
494
- )
495
- sys.exit(1)
496
-
497
-
498
- @define(slots=False)
499
- class ShrinkRayStateSingleFile(ShrinkRayState[bytes]):
500
- def new_reducer(self, problem: ReductionProblem[bytes]) -> Reducer[bytes]:
501
- return ShrinkRay(problem, clang_delta=self.clang_delta_executable)
502
-
503
- def setup_formatter(self):
504
- if self.formatter.lower() == "none":
505
-
506
- async def format_data(test_case: bytes) -> bytes | None:
507
- await trio.lowlevel.checkpoint()
508
- return test_case
509
-
510
- self.can_format = False
511
-
512
- else:
513
- formatter_command = determine_formatter_command(
514
- self.formatter, self.filename
515
- )
516
- if formatter_command is not None:
517
- self.formatter_command = formatter_command
518
-
519
- async def format_data(test_case: bytes) -> bytes | None:
520
- result = await self.run_formatter_command(
521
- formatter_command, test_case
522
- )
523
- if result.returncode != 0:
524
- return None
525
- return result.stdout
526
-
527
- else:
528
-
529
- async def format_data(test_case: bytes) -> bytes | None:
530
- await trio.lowlevel.checkpoint()
531
- return default_reformat_data(test_case)
532
-
533
- self.__format_data = format_data
534
-
535
- async def format_data(self, test_case: bytes) -> bytes | None:
536
- return await self.__format_data(test_case)
537
-
538
- async def run_formatter_command(
539
- self, command: str | list[str], input: bytes
540
- ) -> subprocess.CompletedProcess:
541
- return await trio.run_process(
542
- command,
543
- stdin=input,
544
- capture_stdout=True,
545
- capture_stderr=True,
546
- check=False,
547
- )
548
-
549
- async def write_test_case_to_file_impl(self, working: str, test_case: bytes):
550
- async with await trio.open_file(working, "wb") as o:
551
- await o.write(test_case)
552
-
553
- async def is_interesting(self, test_case: bytes) -> bool:
554
- async with self.is_interesting_limiter:
555
- try:
556
- self.parallel_tasks_running += 1
557
- return await self.run_for_exit_code(test_case) == 0
558
- finally:
559
- self.parallel_tasks_running -= 1
560
-
561
- async def print_exit_message(self, problem):
562
- formatting_increase = 0
563
- final_result = problem.current_test_case
564
- reformatted = await self.attempt_format(final_result)
565
- if reformatted != final_result and reformatted is not None:
566
- if await self.is_interesting(reformatted):
567
- async with await trio.open_file(self.filename, "wb") as o:
568
- await o.write(reformatted)
569
- formatting_increase = max(0, len(reformatted) - len(final_result))
570
- final_result = reformatted
571
-
572
- if len(problem.current_test_case) <= 1 and self.trivial_is_error:
573
- print(
574
- f"Reduced to a trivial test case of size {len(problem.current_test_case)}"
575
- )
576
- print(
577
- "This probably wasn't what you intended. If so, please modify your interestingness test "
578
- "to be more restrictive.\n"
579
- "If you intended this behaviour, you can run with '--trivial-is-not-error' to "
580
- "suppress this message."
581
- )
582
- sys.exit(1)
583
-
584
- else:
585
- print("Reduction completed!")
586
- stats = problem.stats
587
- if self.initial == final_result:
588
- print("Test case was already maximally reduced.")
589
- elif len(final_result) < len(self.initial):
590
- print(
591
- f"Deleted {humanize.naturalsize(stats.initial_test_case_size - len(final_result))} "
592
- f"out of {humanize.naturalsize(stats.initial_test_case_size)} "
593
- f"({(1.0 - len(final_result) / stats.initial_test_case_size) * 100:.2f}% reduction) "
594
- f"in {humanize.precisedelta(timedelta(seconds=time.time() - stats.start_time))}"
595
- )
596
- elif len(final_result) == len(self.initial):
597
- print("Some changes were made but no bytes were deleted")
598
- else:
599
- print(
600
- f"Running reformatting resulted in an increase of {humanize.naturalsize(formatting_increase)}."
601
- )
602
-
603
-
604
- def to_lines(test_case: bytes) -> list[str]:
605
- result = []
606
- for line in test_case.split(b"\n"):
607
- if is_binary_string(line):
608
- result.append(line.hex())
609
- else:
610
- try:
611
- result.append(line.decode("utf-8"))
612
- except UnicodeDecodeError:
613
- result.append(line.hex())
614
- return result
615
-
616
-
617
- def to_blocks(test_case: bytes) -> list[str]:
618
- return [test_case[i : i + 80].hex() for i in range(0, len(test_case), 80)]
619
-
620
-
621
- def format_diff(diff: Iterable[str]) -> str:
622
- results = []
623
- start_writing = False
624
- for line in diff:
625
- if not start_writing and line.startswith("@@"):
626
- start_writing = True
627
- if start_writing:
628
- results.append(line)
629
- if len(results) > 500:
630
- results.append("...")
631
- break
632
- return "\n".join(results)
633
-
634
-
635
- class ShrinkRayDirectoryState(ShrinkRayState[dict[str, bytes]]):
636
- def setup_formatter(self): ...
637
-
638
- @property
639
- def extra_problem_kwargs(self):
640
- def dict_size(test_case: dict[str, bytes]) -> int:
641
- return sum(len(v) for v in test_case.values())
642
-
643
- def dict_sort_key(test_case: dict[str, bytes]) -> Any:
644
- return (
645
- len(test_case),
646
- dict_size(test_case),
647
- sorted((k, shortlex(v)) for k, v in test_case.items()),
648
- )
649
-
650
- return dict(
651
- sort_key=dict_sort_key,
652
- size=dict_size,
653
- )
654
-
655
- def new_reducer(
656
- self, problem: ReductionProblem[dict[str, bytes]]
657
- ) -> Reducer[dict[str, bytes]]:
658
- return DirectoryShrinkRay(
659
- target=problem, clang_delta=self.clang_delta_executable
660
- )
661
-
662
- async def write_test_case_to_file_impl(
663
- self, working: str, test_case: dict[str, bytes]
664
- ):
665
- shutil.rmtree(working, ignore_errors=True)
666
- os.makedirs(working, exist_ok=True)
667
- for k, v in test_case.items():
668
- f = os.path.join(working, k)
669
- os.makedirs(os.path.dirname(f), exist_ok=True)
670
- async with await trio.open_file(f, "wb") as o:
671
- await o.write(v)
672
-
673
- async def format_data(self, test_case: dict[str, bytes]) -> dict[str, bytes] | None:
674
- # TODO: Implement
675
- return None
676
-
677
- async def run_formatter_command(
678
- self, command: str | list[str], input: TestCase
679
- ) -> subprocess.CompletedProcess:
680
- raise AssertionError
681
-
682
- async def print_exit_message(self, problem):
683
- print("All done!")
684
-
685
-
686
- @define(slots=False)
687
- class ShrinkRayUI(Generic[TestCase], ABC):
688
- state: ShrinkRayState[TestCase]
689
-
690
- @property
691
- def reducer(self):
692
- return self.state.reducer
693
-
694
- @property
695
- def problem(self) -> BasicReductionProblem:
696
- return self.reducer.target # type: ignore
697
-
698
- def install_into_nursery(self, nursery: trio.Nursery): ...
699
-
700
- async def run(self, nursery: trio.Nursery): ...
701
-
702
-
703
- class BasicUI(ShrinkRayUI[TestCase]):
704
- async def run(self, nursery: trio.Nursery):
705
- prev_reduction = 0
706
- while True:
707
- initial = self.state.initial
708
- current = self.state.problem.current_test_case
709
- size = self.state.problem.size
710
- reduction = size(initial) - size(current)
711
- if reduction > prev_reduction:
712
- print(
713
- f"Reduced test case to {humanize.naturalsize(size(current))} "
714
- f"(deleted {humanize.naturalsize(reduction)}, "
715
- f"{humanize.naturalsize(reduction - prev_reduction)} since last time)"
716
- )
717
- prev_reduction = reduction
718
- await trio.sleep(5)
719
- else:
720
- await trio.sleep(0.1)
721
-
722
-
723
- @define(slots=False)
724
- class UrwidUI(ShrinkRayUI[TestCase]):
725
- parallel_samples: int = 0
726
- parallel_total: int = 0
727
-
728
- def __attrs_post_init__(self):
729
- frame = self.create_frame()
730
-
731
- screen = urwid.raw_display.Screen()
732
-
733
- def unhandled(key: Any) -> bool:
734
- if key == "q":
735
- raise urwid.ExitMainLoop()
736
- return False
737
-
738
- self.event_loop = urwid.TrioEventLoop()
739
-
740
- self.loop = urwid.MainLoop(
741
- frame,
742
- [],
743
- screen,
744
- unhandled_input=unhandled,
745
- event_loop=self.event_loop,
746
- )
747
-
748
- async def regularly_clear_screen(self):
749
- while True:
750
- self.loop.screen.clear()
751
- await trio.sleep(1)
752
-
753
- async def update_parallelism_stats(self) -> None:
754
- while True:
755
- await trio.sleep(random.expovariate(10.0))
756
- self.parallel_samples += 1
757
- self.parallel_total += self.state.parallel_tasks_running
758
- stats = self.problem.stats
759
- if stats.calls > 0:
760
- wasteage = stats.wasted_interesting_calls / stats.calls
761
- else:
762
- wasteage = 0.0
763
-
764
- average_parallelism = self.parallel_total / self.parallel_samples
765
-
766
- self.parallelism_status.set_text(
767
- f"Current parallel workers: {self.state.parallel_tasks_running} (Average {average_parallelism:.2f}) "
768
- f"(effective parallelism: {average_parallelism * (1.0 - wasteage):.2f})"
769
- )
770
-
771
- async def update_reducer_stats(self) -> None:
772
- while True:
773
- await trio.sleep(0.1)
774
- if self.problem is None:
775
- continue
776
-
777
- self.details_text.set_text(self.problem.stats.display_stats())
778
- self.reducer_status.set_text(f"Reducer status: {self.reducer.status}")
779
-
780
- def install_into_nursery(self, nursery: trio.Nursery):
781
- nursery.start_soon(self.regularly_clear_screen)
782
- nursery.start_soon(self.update_parallelism_stats)
783
- nursery.start_soon(self.update_reducer_stats)
784
-
785
- async def run(self, nursery: trio.Nursery):
786
- with self.loop.start():
787
- await self.event_loop.run_async()
788
- nursery.cancel_scope.cancel()
789
-
790
- def create_frame(self) -> urwid.Frame:
791
- text_header = "Shrink Ray. Press q to exit."
792
- self.parallelism_status = urwid.Text("")
793
-
794
- self.details_text = urwid.Text("")
795
- self.reducer_status = urwid.Text("")
796
-
797
- line = urwid.Divider("─")
798
-
799
- listbox_content = [
800
- line,
801
- self.details_text,
802
- self.reducer_status,
803
- self.parallelism_status,
804
- line,
805
- *self.create_main_ui_elements(),
806
- ]
807
-
808
- header = urwid.AttrMap(urwid.Text(text_header, align="center"), "header")
809
- listbox = urwid.ListBox(urwid.SimpleFocusListWalker(listbox_content))
810
- return urwid.Frame(urwid.AttrMap(listbox, "body"), header=header)
811
-
812
- def create_main_ui_elements(self) -> list[Any]:
813
- return []
814
-
815
-
816
- class ShrinkRayUIDirectory(UrwidUI[dict[str, bytes]]):
817
- def create_main_ui_elements(self) -> list[Any]:
818
- self.col1 = urwid.Text("")
819
- self.col2 = urwid.Text("")
820
- self.col3 = urwid.Text("")
821
-
822
- columns = urwid.Columns(
823
- [
824
- ("weight", 1, self.col1),
825
- ("weight", 1, self.col2),
826
- ("weight", 1, self.col3),
827
- ]
828
- )
829
-
830
- return [columns]
831
-
832
- async def update_file_list(self):
833
- while True:
834
- if self.state.first_call_time is None:
835
- await trio.sleep(0.05)
836
- continue
837
- data = sorted(self.problem.current_test_case.items())
838
-
839
- runtime = time.time() - self.state.first_call_time
840
-
841
- col1_bits = []
842
- col2_bits = []
843
- col3_bits = []
844
-
845
- for k, v in data:
846
- col1_bits.append(k)
847
- col2_bits.append(humanize.naturalsize(len(v)))
848
- reduction_percentage = (1.0 - len(v) / len(self.state.initial[k])) * 100
849
- reduction_rate = (len(self.state.initial[k]) - len(v)) / runtime
850
- reduction_msg = f"{reduction_percentage:.2f}% reduction, {humanize.naturalsize(reduction_rate)} / second"
851
- col3_bits.append(reduction_msg)
852
-
853
- self.col1.set_text("\n".join(col1_bits))
854
- self.col2.set_text("\n".join(col2_bits))
855
- self.col3.set_text("\n".join(col3_bits))
856
- await trio.sleep(0.5)
857
-
858
- def install_into_nursery(self, nursery: trio.Nursery):
859
- super().install_into_nursery(nursery)
860
- nursery.start_soon(self.update_file_list)
861
-
862
-
863
- @define(slots=False)
864
- class ShrinkRayUISingleFile(UrwidUI[bytes]):
865
- hex_mode: bool = False
866
-
867
- def create_main_ui_elements(self) -> list[Any]:
868
- self.diff_to_display = urwid.Text("")
869
- return [self.diff_to_display]
870
-
871
- def file_to_lines(self, test_case: bytes) -> list[str]:
872
- if self.hex_mode:
873
- return to_blocks(test_case)
874
- else:
875
- return to_lines(test_case)
876
-
877
- async def update_diffs(self):
878
- initial = self.problem.current_test_case
879
- self.diff_to_display.set_text("\n".join(self.file_to_lines(initial)[:1000]))
880
- prev_unformatted = self.problem.current_test_case
881
- prev = await self.state.attempt_format(prev_unformatted)
882
-
883
- time_of_last_update = time.time()
884
- while True:
885
- if self.problem.current_test_case == prev_unformatted:
886
- await trio.sleep(0.1)
887
- continue
888
- current = await self.state.attempt_format(self.problem.current_test_case)
889
- lines = self.file_to_lines(current)
890
- if len(lines) <= 50:
891
- display_text = "\n".join(lines)
892
- self.diff_to_display.set_text(display_text)
893
- await trio.sleep(0.1)
894
- continue
895
-
896
- if prev == current:
897
- await trio.sleep(0.1)
898
- continue
899
- diff = format_diff(
900
- unified_diff(self.file_to_lines(prev), self.file_to_lines(current))
901
- )
902
- # When running in parallel sometimes we can produce diffs that have
903
- # a lot of insertions because we undo some work and then immediately
904
- # redo it. this can be quite confusing when it happens in the UI
905
- # (and makes Shrink Ray look bad), so when this happens we pause a
906
- # little bit to try to get a better diff.
907
- if (
908
- diff.count("\n+") > 2 * diff.count("\n-")
909
- and time.time() <= time_of_last_update + 10
910
- ):
911
- await trio.sleep(0.5)
912
- continue
913
- self.diff_to_display.set_text(diff)
914
- prev = current
915
- prev_unformatted = self.problem.current_test_case
916
- time_of_last_update = time.time()
917
- if self.state.can_format:
918
- await trio.sleep(4)
919
- else:
920
- await trio.sleep(2)
921
-
922
- def install_into_nursery(self, nursery: trio.Nursery):
923
- super().install_into_nursery(nursery)
924
- nursery.start_soon(self.update_diffs)
925
-
926
-
927
- def determine_formatter_command(formatter: str, filename: str) -> list[str] | None:
928
- if formatter.lower() == "default":
929
- formatter_command = default_formatter_command_for(filename)
930
- elif formatter.lower() != "none":
931
- formatter_command = formatter
932
- else:
933
- formatter_command = None
934
- if isinstance(formatter_command, str):
935
- formatter_command = [formatter_command]
936
- return formatter_command
20
+ from shrinkray.passes.clangdelta import (
21
+ C_FILE_EXTENSIONS,
22
+ ClangDelta,
23
+ find_clang_delta,
24
+ )
25
+ from shrinkray.problem import InvalidInitialExample
26
+ from shrinkray.state import (
27
+ ShrinkRayDirectoryState,
28
+ ShrinkRayState,
29
+ ShrinkRayStateSingleFile,
30
+ )
31
+ from shrinkray.ui import BasicUI, ShrinkRayUI
32
+ from shrinkray.work import Volume
937
33
 
938
34
 
939
35
  async def run_shrink_ray(
940
- state: ShrinkRayState[TestCase],
941
- ui: ShrinkRayUI[TestCase],
942
- ):
36
+ state: ShrinkRayState[Any],
37
+ ui: ShrinkRayUI[Any],
38
+ ) -> None:
39
+ """Run the shrink ray reduction process."""
943
40
  async with trio.open_nursery() as nursery:
944
41
  problem = state.problem
945
42
  try:
946
43
  await problem.setup()
947
- except InvalidInitialExample as e:
44
+ except* InvalidInitialExample as excs:
45
+ assert len(excs.exceptions) == 1
46
+ (e,) = excs.exceptions
948
47
  await state.report_error(e)
949
48
 
950
49
  reducer = state.reducer
@@ -990,12 +89,6 @@ async def run_shrink_ray(
990
89
  type=click.INT,
991
90
  help=("Random seed to use for any non-deterministic reductions."),
992
91
  )
993
- @click.option(
994
- "--parallelism",
995
- default=os.cpu_count(),
996
- type=click.INT,
997
- help="Number of tests to run in parallel.",
998
- )
999
92
  @click.option(
1000
93
  "--volume",
1001
94
  default="normal",
@@ -1003,10 +96,14 @@ async def run_shrink_ray(
1003
96
  help="Level of output to provide.",
1004
97
  )
1005
98
  @click.option(
1006
- "--display-mode",
1007
- default="auto",
1008
- type=EnumChoice(DisplayMode),
1009
- help="Determines whether ShrinkRay displays files as a textual or hex representation of binary data.",
99
+ "--in-place/--not-in-place",
100
+ default=False,
101
+ help="""
102
+ If `--in-place` is passed, shrinkray will run in the current working directory instead of
103
+ creating a temporary subdirectory. Note that this requires you to either run with no
104
+ parallelism or be very careful about files created in your interestingness
105
+ test not conflicting with each other.
106
+ """,
1010
107
  )
1011
108
  @click.option(
1012
109
  "--input-type",
@@ -1022,16 +119,42 @@ How to pass input to the test function. Options are:
1022
119
  3. `stdin` passes its contents on stdin.
1023
120
 
1024
121
  4. `all` (the default) does all of the above.
122
+
123
+ If --in-place is specified, all will not include basename by default, only arg and stdin.
124
+ If you want basename with --in-place you may pass it explicitly, but note that this is incompatible
125
+ with any parallelism.
1025
126
  """.strip(),
1026
127
  )
128
+ @click.option(
129
+ "--parallelism",
130
+ type=click.INT,
131
+ help="Number of tests to run in parallel. If set to 0 will default to either 1 or number of cpus depending on other options.",
132
+ default=0,
133
+ )
1027
134
  @click.option(
1028
135
  "--ui",
1029
136
  "ui_type",
1030
- default="urwid",
1031
137
  type=EnumChoice(UIType),
1032
138
  help="""
1033
- By default shrinkray runs with a terminal UI based on urwid. If you want a more basic UI
1034
- (e.g. for running in a script), you can specify --ui=basic instead.
139
+ UI mode to use. Options are:
140
+
141
+ * 'textual' (default): Modern terminal UI using the textual library.
142
+ * 'basic': Simple text output, suitable for scripts or non-interactive use.
143
+
144
+ When not specified, defaults to 'textual' for interactive terminals, 'basic' otherwise.
145
+ """.strip(),
146
+ callback=validate_ui,
147
+ )
148
+ @click.option(
149
+ "--theme",
150
+ type=click.Choice(["auto", "dark", "light"]),
151
+ default="auto",
152
+ help="""
153
+ Theme mode for the textual UI. Options are:
154
+
155
+ * 'auto' (default): Detect terminal's color scheme automatically.
156
+ * 'dark': Use dark theme.
157
+ * 'light': Use light theme.
1035
158
  """.strip(),
1036
159
  )
1037
160
  @click.option(
@@ -1063,6 +186,11 @@ print an error message at the end of reduction and exit with non-zero status in
1063
186
  This behaviour can be disabled by passing --trivial-is-not-error.
1064
187
  """,
1065
188
  )
189
+ @click.option(
190
+ "--exit-on-completion/--no-exit-on-completion",
191
+ default=True,
192
+ help="Exit automatically when reduction completes (TUI only). Default: exit on completion.",
193
+ )
1066
194
  @click.option(
1067
195
  "--no-clang-delta",
1068
196
  is_flag=True,
@@ -1077,15 +205,15 @@ This behaviour can be disabled by passing --trivial-is-not-error.
1077
205
  @click.argument("test", callback=validate_command)
1078
206
  @click.argument(
1079
207
  "filename",
1080
- type=click.Path(exists=True, resolve_path=True, dir_okay=True, allow_dash=False),
208
+ type=click.Path(exists=True, resolve_path=False, dir_okay=True, allow_dash=False),
1081
209
  )
1082
210
  def main(
1083
211
  input_type: InputType,
1084
- display_mode: DisplayMode,
1085
212
  backup: str,
1086
213
  filename: str,
1087
214
  test: list[str],
1088
215
  timeout: float,
216
+ in_place: bool,
1089
217
  parallelism: int,
1090
218
  seed: int,
1091
219
  volume: Volume,
@@ -1093,7 +221,9 @@ def main(
1093
221
  no_clang_delta: bool,
1094
222
  clang_delta: str,
1095
223
  trivial_is_error: bool,
224
+ exit_on_completion: bool,
1096
225
  ui_type: UIType,
226
+ theme: str,
1097
227
  ) -> None:
1098
228
  if timeout <= 0:
1099
229
  timeout = float("inf")
@@ -1105,6 +235,17 @@ def main(
1105
235
  )
1106
236
  sys.exit(1)
1107
237
 
238
+ if in_place and input_type == InputType.basename and parallelism > 1:
239
+ raise click.BadParameter(
240
+ f"parallelism cannot be greater than 1 when --in-place and --input-type=basename (got {parallelism})"
241
+ )
242
+
243
+ if parallelism == 0:
244
+ if in_place and input_type == InputType.basename:
245
+ parallelism = 1
246
+ else:
247
+ parallelism = os.cpu_count() or 1
248
+
1108
249
  clang_delta_executable: ClangDelta | None = None
1109
250
  if os.path.splitext(filename)[1] in C_FILE_EXTENSIONS and not no_clang_delta:
1110
251
  if not clang_delta:
@@ -1132,6 +273,7 @@ def main(
1132
273
 
1133
274
  state_kwargs: dict[str, Any] = dict(
1134
275
  input_type=input_type,
276
+ in_place=in_place,
1135
277
  test=test,
1136
278
  timeout=timeout,
1137
279
  base=os.path.basename(filename),
@@ -1165,8 +307,6 @@ def main(
1165
307
 
1166
308
  trio.run(state.check_formatter)
1167
309
 
1168
- ui = ShrinkRayUIDirectory(state)
1169
-
1170
310
  else:
1171
311
  try:
1172
312
  os.remove(backup)
@@ -1179,26 +319,56 @@ def main(
1179
319
  with open(backup, "wb") as writer:
1180
320
  writer.write(initial)
1181
321
 
1182
- if display_mode == DisplayMode.auto:
1183
- hex_mode = is_binary_string(initial)
1184
- else:
1185
- hex_mode = display_mode == DisplayMode.hex
1186
-
1187
322
  state = ShrinkRayStateSingleFile(initial=initial, **state_kwargs)
1188
323
 
1189
324
  trio.run(state.check_formatter)
1190
325
 
1191
- ui = ShrinkRayUISingleFile(state, hex_mode=hex_mode)
326
+ if ui_type == UIType.textual:
327
+ from shrinkray.tui import run_textual_ui
328
+
329
+ run_textual_ui(
330
+ file_path=filename,
331
+ test=test,
332
+ parallelism=parallelism,
333
+ timeout=timeout,
334
+ seed=seed,
335
+ input_type=input_type.name,
336
+ in_place=in_place,
337
+ formatter=formatter,
338
+ volume=volume.name,
339
+ no_clang_delta=no_clang_delta,
340
+ clang_delta=clang_delta,
341
+ trivial_is_error=trivial_is_error,
342
+ exit_on_completion=exit_on_completion,
343
+ theme=theme, # type: ignore[arg-type]
344
+ )
345
+ return
1192
346
 
1193
- if ui_type == UIType.basic:
1194
- ui = BasicUI(state)
347
+ # At this point, ui_type must be UIType.basic since textual returned above
348
+ assert ui_type == UIType.basic
349
+ ui = BasicUI(state)
1195
350
 
1196
- trio.run(
1197
- lambda: run_shrink_ray(
1198
- state=state,
1199
- ui=ui,
351
+ try:
352
+ trio.run(
353
+ lambda: run_shrink_ray(
354
+ state=state,
355
+ ui=ui,
356
+ )
1200
357
  )
1201
- )
358
+ # If you try to sys.exit from within an exception handler, trio will instead
359
+ # put it in an exception group. I wish to register the complaint that this is
360
+ # incredibly fucking stupid, but anyway this is a workaround for it.
361
+ except* SystemExit as eg:
362
+ raise eg.exceptions[0]
363
+ except* KeyboardInterrupt as eg:
364
+ raise eg.exceptions[0]
365
+
366
+
367
+ def worker_main() -> None:
368
+ """Entry point for the worker subprocess."""
369
+ from shrinkray.subprocess.worker import main as worker_entry
370
+
371
+ worker_entry()
1202
372
 
1203
373
 
1204
374
  if __name__ == "__main__": # pragma: no cover