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/__init__.py +1 -0
- shrinkray/__main__.py +1205 -0
- shrinkray/learning.py +221 -0
- shrinkray/passes/__init__.py +0 -0
- shrinkray/passes/bytes.py +547 -0
- shrinkray/passes/clangdelta.py +230 -0
- shrinkray/passes/definitions.py +52 -0
- shrinkray/passes/genericlanguages.py +277 -0
- shrinkray/passes/json.py +91 -0
- shrinkray/passes/patching.py +280 -0
- shrinkray/passes/python.py +176 -0
- shrinkray/passes/sat.py +176 -0
- shrinkray/passes/sequences.py +69 -0
- shrinkray/problem.py +318 -0
- shrinkray/py.typed +0 -0
- shrinkray/reducer.py +430 -0
- shrinkray/work.py +217 -0
- shrinkray-0.0.0.dist-info/LICENSE +21 -0
- shrinkray-0.0.0.dist-info/METADATA +170 -0
- shrinkray-0.0.0.dist-info/RECORD +22 -0
- shrinkray-0.0.0.dist-info/WHEEL +4 -0
- shrinkray-0.0.0.dist-info/entry_points.txt +3 -0
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")
|