shrinkray 0.0.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 ADDED
@@ -0,0 +1,1205 @@
1
+ import math
2
+ import os
3
+ import random
4
+ import shlex
5
+ import shutil
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ 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
18
+
19
+ import chardet
20
+ import click
21
+ import humanize
22
+ import trio
23
+ import urwid
24
+ import urwid.raw_display
25
+ from attrs import define
26
+ from binaryornot.check import is_binary_string
27
+
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,
34
+ )
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
937
+
938
+
939
+ async def run_shrink_ray(
940
+ state: ShrinkRayState[TestCase],
941
+ ui: ShrinkRayUI[TestCase],
942
+ ):
943
+ async with trio.open_nursery() as nursery:
944
+ problem = state.problem
945
+ try:
946
+ await problem.setup()
947
+ except InvalidInitialExample as e:
948
+ await state.report_error(e)
949
+
950
+ reducer = state.reducer
951
+
952
+ @nursery.start_soon
953
+ async def _() -> None:
954
+ await reducer.run()
955
+ nursery.cancel_scope.cancel()
956
+
957
+ ui.install_into_nursery(nursery)
958
+
959
+ await ui.run(nursery)
960
+
961
+ await state.print_exit_message(problem)
962
+
963
+
964
+ @click.command(
965
+ help="""
966
+ """.strip()
967
+ )
968
+ @click.version_option()
969
+ @click.option(
970
+ "--backup",
971
+ default="",
972
+ help=(
973
+ "Name of the backup file to create. Defaults to adding .bak to the "
974
+ "name of the source file"
975
+ ),
976
+ )
977
+ @click.option(
978
+ "--timeout",
979
+ default=1,
980
+ type=click.FLOAT,
981
+ help=(
982
+ "Time out subprocesses after this many seconds. If set to <= 0 then "
983
+ "no timeout will be used. Any commands that time out will be treated "
984
+ "as failing the test"
985
+ ),
986
+ )
987
+ @click.option(
988
+ "--seed",
989
+ default=0,
990
+ type=click.INT,
991
+ help=("Random seed to use for any non-deterministic reductions."),
992
+ )
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
+ @click.option(
1000
+ "--volume",
1001
+ default="normal",
1002
+ type=EnumChoice(Volume),
1003
+ help="Level of output to provide.",
1004
+ )
1005
+ @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.",
1010
+ )
1011
+ @click.option(
1012
+ "--input-type",
1013
+ default="all",
1014
+ type=EnumChoice(InputType),
1015
+ help="""
1016
+ How to pass input to the test function. Options are:
1017
+
1018
+ 1. `basename` writes it to a file of the same basename as the original, in the current working directory where the test is run.
1019
+
1020
+ 2. `arg` passes it in a file whose name is provided as an argument to the test.
1021
+
1022
+ 3. `stdin` passes its contents on stdin.
1023
+
1024
+ 4. `all` (the default) does all of the above.
1025
+ """.strip(),
1026
+ )
1027
+ @click.option(
1028
+ "--ui",
1029
+ "ui_type",
1030
+ default="urwid",
1031
+ type=EnumChoice(UIType),
1032
+ 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.
1035
+ """.strip(),
1036
+ )
1037
+ @click.option(
1038
+ "--formatter",
1039
+ default="default",
1040
+ help="""
1041
+ Path to a formatter for Shrink Ray to use. This is mostly used for display purposes,
1042
+ and to format the final test case.
1043
+
1044
+ A formatter should accept input on stdin and write to stdout, and exit with a status
1045
+ code of 0. If the formatter exits with a non-zero status code its output will be
1046
+ ignored.
1047
+
1048
+ Special values for this:
1049
+
1050
+ * 'none' turns off formatting.
1051
+ * 'default' causes Shrink Ray to use its default behaviour, which is to look for
1052
+ formatters it knows about on PATH and use one of those if found, otherwise to
1053
+ use a very simple language-agnostic formatter.
1054
+ """,
1055
+ )
1056
+ @click.option(
1057
+ "--trivial-is-error/--trivial-is-not-error",
1058
+ default=True,
1059
+ help="""
1060
+ It's easy to write interestingness tests which accept too much, and one common way this
1061
+ happens is if they accept empty or otherwise trivial files. By default Shrink Ray will
1062
+ print an error message at the end of reduction and exit with non-zero status in this case.
1063
+ This behaviour can be disabled by passing --trivial-is-not-error.
1064
+ """,
1065
+ )
1066
+ @click.option(
1067
+ "--no-clang-delta",
1068
+ is_flag=True,
1069
+ default=False,
1070
+ help="Pass this if you do not want to use clang delta for C/C++ transformations.",
1071
+ )
1072
+ @click.option(
1073
+ "--clang-delta",
1074
+ default="",
1075
+ help="Path to your clang_delta executable.",
1076
+ )
1077
+ @click.argument("test", callback=validate_command)
1078
+ @click.argument(
1079
+ "filename",
1080
+ type=click.Path(exists=True, resolve_path=True, dir_okay=True, allow_dash=False),
1081
+ )
1082
+ def main(
1083
+ input_type: InputType,
1084
+ display_mode: DisplayMode,
1085
+ backup: str,
1086
+ filename: str,
1087
+ test: list[str],
1088
+ timeout: float,
1089
+ parallelism: int,
1090
+ seed: int,
1091
+ volume: Volume,
1092
+ formatter: str,
1093
+ no_clang_delta: bool,
1094
+ clang_delta: str,
1095
+ trivial_is_error: bool,
1096
+ ui_type: UIType,
1097
+ ) -> None:
1098
+ if timeout <= 0:
1099
+ timeout = float("inf")
1100
+
1101
+ if not os.access(test[0], os.X_OK):
1102
+ print(
1103
+ f"Interestingness test {os.path.relpath(test[0])} is not executable.",
1104
+ file=sys.stderr,
1105
+ )
1106
+ sys.exit(1)
1107
+
1108
+ clang_delta_executable: ClangDelta | None = None
1109
+ if os.path.splitext(filename)[1] in C_FILE_EXTENSIONS and not no_clang_delta:
1110
+ if not clang_delta:
1111
+ clang_delta = find_clang_delta()
1112
+ if not clang_delta:
1113
+ raise click.UsageError(
1114
+ "Attempting to reduce a C or C++ file, but clang_delta is not installed. "
1115
+ "Please run with --no-clang-delta, or install creduce on your system. "
1116
+ "If creduce is already installed and you wish to use clang_delta, please "
1117
+ "pass its path with the --clang-delta argument."
1118
+ )
1119
+
1120
+ clang_delta_executable = ClangDelta(clang_delta)
1121
+
1122
+ # This is a debugging option so that when the reducer seems to be taking
1123
+ # a long time you can Ctrl-\ to find out what it's up to. I have no idea
1124
+ # how to test it in a way that shows up in coverage.
1125
+ def dump_trace(signum: int, frame: Any) -> None: # pragma: no cover
1126
+ traceback.print_stack()
1127
+
1128
+ signal.signal(signal.SIGQUIT, dump_trace)
1129
+
1130
+ if not backup:
1131
+ backup = filename + os.extsep + "bak"
1132
+
1133
+ state_kwargs: dict[str, Any] = dict(
1134
+ input_type=input_type,
1135
+ test=test,
1136
+ timeout=timeout,
1137
+ base=os.path.basename(filename),
1138
+ parallelism=parallelism,
1139
+ filename=filename,
1140
+ formatter=formatter,
1141
+ trivial_is_error=trivial_is_error,
1142
+ seed=seed,
1143
+ volume=volume,
1144
+ clang_delta_executable=clang_delta_executable,
1145
+ )
1146
+
1147
+ state: ShrinkRayState[Any]
1148
+ ui: ShrinkRayUI[Any]
1149
+
1150
+ if os.path.isdir(filename):
1151
+ if input_type == InputType.stdin:
1152
+ raise click.UsageError("Cannot pass a directory input on stdin.")
1153
+
1154
+ shutil.rmtree(backup, ignore_errors=True)
1155
+ shutil.copytree(filename, backup)
1156
+
1157
+ files = [os.path.join(d, f) for d, _, fs in os.walk(filename) for f in fs]
1158
+
1159
+ initial = {}
1160
+ for f in files:
1161
+ with open(f, "rb") as i:
1162
+ initial[os.path.relpath(f, filename)] = i.read()
1163
+
1164
+ state = ShrinkRayDirectoryState(initial=initial, **state_kwargs)
1165
+
1166
+ trio.run(state.check_formatter)
1167
+
1168
+ ui = ShrinkRayUIDirectory(state)
1169
+
1170
+ else:
1171
+ try:
1172
+ os.remove(backup)
1173
+ except FileNotFoundError:
1174
+ pass
1175
+
1176
+ with open(filename, "rb") as reader:
1177
+ initial = reader.read()
1178
+
1179
+ with open(backup, "wb") as writer:
1180
+ writer.write(initial)
1181
+
1182
+ if display_mode == DisplayMode.auto:
1183
+ hex_mode = is_binary_string(initial)
1184
+ else:
1185
+ hex_mode = display_mode == DisplayMode.hex
1186
+
1187
+ state = ShrinkRayStateSingleFile(initial=initial, **state_kwargs)
1188
+
1189
+ trio.run(state.check_formatter)
1190
+
1191
+ ui = ShrinkRayUISingleFile(state, hex_mode=hex_mode)
1192
+
1193
+ if ui_type == UIType.basic:
1194
+ ui = BasicUI(state)
1195
+
1196
+ trio.run(
1197
+ lambda: run_shrink_ray(
1198
+ state=state,
1199
+ ui=ui,
1200
+ )
1201
+ )
1202
+
1203
+
1204
+ if __name__ == "__main__": # pragma: no cover
1205
+ main(prog_name="shrinkray")