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 +130 -960
- shrinkray/cli.py +70 -0
- shrinkray/display.py +75 -0
- shrinkray/formatting.py +108 -0
- shrinkray/passes/bytes.py +217 -10
- shrinkray/passes/clangdelta.py +47 -17
- shrinkray/passes/definitions.py +84 -4
- shrinkray/passes/genericlanguages.py +61 -7
- shrinkray/passes/json.py +6 -0
- shrinkray/passes/patching.py +65 -57
- shrinkray/passes/python.py +66 -23
- shrinkray/passes/sat.py +505 -91
- shrinkray/passes/sequences.py +26 -6
- shrinkray/problem.py +206 -27
- shrinkray/process.py +49 -0
- shrinkray/reducer.py +187 -25
- shrinkray/state.py +599 -0
- shrinkray/subprocess/__init__.py +24 -0
- shrinkray/subprocess/client.py +253 -0
- shrinkray/subprocess/protocol.py +190 -0
- shrinkray/subprocess/worker.py +491 -0
- shrinkray/tui.py +915 -0
- shrinkray/ui.py +72 -0
- shrinkray/work.py +34 -6
- {shrinkray-0.0.0.dist-info → shrinkray-25.12.26.0.dist-info}/METADATA +44 -27
- shrinkray-25.12.26.0.dist-info/RECORD +33 -0
- {shrinkray-0.0.0.dist-info → shrinkray-25.12.26.0.dist-info}/WHEEL +2 -1
- shrinkray-25.12.26.0.dist-info/entry_points.txt +3 -0
- shrinkray-25.12.26.0.dist-info/top_level.txt +1 -0
- shrinkray/learning.py +0 -221
- shrinkray-0.0.0.dist-info/RECORD +0 -22
- shrinkray-0.0.0.dist-info/entry_points.txt +0 -3
- {shrinkray-0.0.0.dist-info → shrinkray-25.12.26.0.dist-info/licenses}/LICENSE +0 -0
shrinkray/__main__.py
CHANGED
|
@@ -1,950 +1,49 @@
|
|
|
1
|
-
|
|
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
|
|
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.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
13
|
+
from shrinkray.cli import (
|
|
14
|
+
EnumChoice,
|
|
15
|
+
InputType,
|
|
16
|
+
UIType,
|
|
17
|
+
validate_command,
|
|
18
|
+
validate_ui,
|
|
34
19
|
)
|
|
35
|
-
from shrinkray.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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[
|
|
941
|
-
ui: ShrinkRayUI[
|
|
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
|
|
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
|
-
"--
|
|
1007
|
-
default=
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
1034
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
1194
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|