mutate4py 0.1.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.
- mutate4py/__init__.py +3 -0
- mutate4py/__main__.py +431 -0
- mutate4py/_cmd.py +25 -0
- mutate4py/_coverage.py +123 -0
- mutate4py/_discovery.py +323 -0
- mutate4py/_ids.py +21 -0
- mutate4py/_manifest.py +172 -0
- mutate4py/_runner.py +629 -0
- mutate4py/_test_selection.py +113 -0
- mutate4py/_workers.py +241 -0
- mutate4py-0.1.0.dist-info/METADATA +70 -0
- mutate4py-0.1.0.dist-info/RECORD +15 -0
- mutate4py-0.1.0.dist-info/WHEEL +4 -0
- mutate4py-0.1.0.dist-info/entry_points.txt +2 -0
- mutate4py-0.1.0.dist-info/licenses/LICENSE +21 -0
mutate4py/__init__.py
ADDED
mutate4py/__main__.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""CLI entry point for mutate4py."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from mutate4py._runner import (
|
|
8
|
+
CoverageError,
|
|
9
|
+
check_manifest,
|
|
10
|
+
run_baseline,
|
|
11
|
+
run_mutations,
|
|
12
|
+
run_scan,
|
|
13
|
+
update_manifest,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
DEFAULT_WARNING_THRESHOLD = 50
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _positive_int(value: str) -> int:
|
|
20
|
+
"""argparse type: parse a positive integer (>= 1)."""
|
|
21
|
+
try:
|
|
22
|
+
n = int(value)
|
|
23
|
+
except ValueError:
|
|
24
|
+
raise argparse.ArgumentTypeError(f"{value!r} is not a valid integer")
|
|
25
|
+
if n < 1:
|
|
26
|
+
raise argparse.ArgumentTypeError(f"{value!r} must be a positive integer (>= 1)")
|
|
27
|
+
return n
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="mutate4py",
|
|
33
|
+
description="Mutation testing for Python with an embedded-in-source manifest",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("file", help="Python source file or directory to analyse")
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--scan", action="store_true", help="Count mutation sites; no test run"
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--mutation-warning",
|
|
41
|
+
type=_positive_int,
|
|
42
|
+
default=DEFAULT_WARNING_THRESHOLD,
|
|
43
|
+
dest="warning_threshold",
|
|
44
|
+
help="Warn when mutation sites exceed N (default: 50)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--update-manifest",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="Embed or refresh the manifest footer in the source file; no test run",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--check-manifest",
|
|
53
|
+
action="store_true",
|
|
54
|
+
dest="check_manifest",
|
|
55
|
+
help="Verify the manifest footer is up to date; exit 1 if missing or stale; no test run",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--cov-cmd",
|
|
59
|
+
dest="cov_cmd",
|
|
60
|
+
default=None,
|
|
61
|
+
help="Shell command to run once to generate LCOV coverage",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--lcov", dest="lcov", default=None, help="Path to a pre-generated LCOV file"
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--reuse-coverage",
|
|
68
|
+
action="store_true",
|
|
69
|
+
dest="reuse_coverage",
|
|
70
|
+
help="Read LCOV from coverage.lcov (default path)",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--test-command",
|
|
74
|
+
dest="test_command",
|
|
75
|
+
default="pytest",
|
|
76
|
+
help="Command to run tests (default: pytest)",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--timeout-factor",
|
|
80
|
+
type=_positive_int,
|
|
81
|
+
default=10,
|
|
82
|
+
dest="timeout_factor",
|
|
83
|
+
help="Mutant timeout = max(--min-timeout, factor × baseline duration) (default: 10)",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--min-timeout",
|
|
87
|
+
type=float,
|
|
88
|
+
default=1.0,
|
|
89
|
+
dest="min_timeout",
|
|
90
|
+
help="Floor for mutant timeout in seconds (default: 1.0)",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--lines",
|
|
94
|
+
dest="lines",
|
|
95
|
+
default=None,
|
|
96
|
+
help="Comma-separated line numbers to mutate",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--since-last-run",
|
|
100
|
+
action="store_true",
|
|
101
|
+
dest="since_last_run",
|
|
102
|
+
help="Only mutate sites in changed functions",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--mutate-all",
|
|
106
|
+
action="store_true",
|
|
107
|
+
dest="mutate_all",
|
|
108
|
+
help="Mutate all covered sites regardless of manifest",
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--max-workers",
|
|
112
|
+
type=_positive_int,
|
|
113
|
+
default=None,
|
|
114
|
+
dest="max_workers",
|
|
115
|
+
help="Number of parallel workers; omit or 0 = serial (default: serial)",
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--verbose",
|
|
119
|
+
action="store_true",
|
|
120
|
+
dest="verbose",
|
|
121
|
+
help="Log actions to stderr",
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--test-contexts",
|
|
125
|
+
dest="test_contexts",
|
|
126
|
+
default=None,
|
|
127
|
+
help="Path to a .coverage SQLite db (pytest --cov-context=test); enables per-mutant test selection",
|
|
128
|
+
)
|
|
129
|
+
return parser
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_coverage_flags(args: argparse.Namespace) -> None:
|
|
133
|
+
"""Exit with error if more than one coverage flag is supplied (ADR 0008)."""
|
|
134
|
+
cov_flags = [args.cov_cmd is not None, args.lcov is not None, args.reuse_coverage]
|
|
135
|
+
if sum(cov_flags) > 1:
|
|
136
|
+
print(
|
|
137
|
+
"error: --cov-cmd, --lcov, and --reuse-coverage are mutually exclusive; "
|
|
138
|
+
"supply at most one.",
|
|
139
|
+
file=sys.stderr,
|
|
140
|
+
)
|
|
141
|
+
sys.exit(2)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _no_run_flag(args: argparse.Namespace) -> str:
|
|
145
|
+
"""Return the active no-run flag name for error messages."""
|
|
146
|
+
if args.scan:
|
|
147
|
+
return "--scan"
|
|
148
|
+
if args.update_manifest:
|
|
149
|
+
return "--update-manifest"
|
|
150
|
+
return "--check-manifest"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _exit_incompatible(flag_a: str, flag_b: str) -> None:
|
|
154
|
+
print(f"error: {flag_a} cannot be combined with {flag_b}.", file=sys.stderr)
|
|
155
|
+
sys.exit(2)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _check_no_run_incompatibilities(args: argparse.Namespace) -> None:
|
|
159
|
+
"""Exit if run-mode flags are paired with no-run-mode flags."""
|
|
160
|
+
flag = _no_run_flag(args)
|
|
161
|
+
if args.lines is not None:
|
|
162
|
+
_exit_incompatible(flag, "--lines")
|
|
163
|
+
if args.since_last_run:
|
|
164
|
+
_exit_incompatible(flag, "--since-last-run")
|
|
165
|
+
if args.mutate_all:
|
|
166
|
+
_exit_incompatible(flag, "--mutate-all")
|
|
167
|
+
if args.max_workers is not None:
|
|
168
|
+
_exit_incompatible(flag, "--max-workers")
|
|
169
|
+
if args.test_contexts is not None:
|
|
170
|
+
_exit_incompatible(flag, "--test-contexts")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _check_scan_only_incompatibilities(args: argparse.Namespace) -> None:
|
|
174
|
+
"""Exit if scan-only flags are paired with non-scan flags."""
|
|
175
|
+
if args.timeout_factor != 10:
|
|
176
|
+
_exit_incompatible("--scan", "--timeout-factor")
|
|
177
|
+
if args.min_timeout != 1.0:
|
|
178
|
+
_exit_incompatible("--scan", "--min-timeout")
|
|
179
|
+
if args.test_command != "pytest":
|
|
180
|
+
_exit_incompatible("--scan", "--test-command")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _check_selection_exclusivity(args: argparse.Namespace) -> None:
|
|
184
|
+
"""Exit if more than one selection flag is set."""
|
|
185
|
+
selection_count = sum(
|
|
186
|
+
[args.since_last_run, args.mutate_all, args.lines is not None]
|
|
187
|
+
)
|
|
188
|
+
if selection_count > 1:
|
|
189
|
+
print(
|
|
190
|
+
"error: --since-last-run, --mutate-all, and --lines are pairwise exclusive; "
|
|
191
|
+
"supply at most one.",
|
|
192
|
+
file=sys.stderr,
|
|
193
|
+
)
|
|
194
|
+
sys.exit(2)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _validate_mutual_exclusions(args: argparse.Namespace) -> None:
|
|
198
|
+
"""Exit with error on illegal flag combinations (ADR 0008, 0014)."""
|
|
199
|
+
no_run = [
|
|
200
|
+
("--scan", args.scan),
|
|
201
|
+
("--update-manifest", args.update_manifest),
|
|
202
|
+
("--check-manifest", args.check_manifest),
|
|
203
|
+
]
|
|
204
|
+
active = [name for name, v in no_run if v]
|
|
205
|
+
if len(active) > 1:
|
|
206
|
+
_exit_incompatible(active[0], active[1])
|
|
207
|
+
if active:
|
|
208
|
+
_check_no_run_incompatibilities(args)
|
|
209
|
+
if args.scan:
|
|
210
|
+
_check_scan_only_incompatibilities(args)
|
|
211
|
+
_check_selection_exclusivity(args)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _load_source(path: str) -> str:
|
|
215
|
+
"""Read source file; exit with error if unreadable."""
|
|
216
|
+
try:
|
|
217
|
+
with open(path) as f:
|
|
218
|
+
return f.read()
|
|
219
|
+
except (FileNotFoundError, PermissionError, IsADirectoryError) as exc:
|
|
220
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
221
|
+
sys.exit(2)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _parse_line_token(token: str) -> int:
|
|
225
|
+
"""Parse a single --lines token into a positive int; exit on error."""
|
|
226
|
+
try:
|
|
227
|
+
n = int(token)
|
|
228
|
+
except ValueError:
|
|
229
|
+
print(
|
|
230
|
+
f"error: --lines value {token!r} is not a valid integer.", file=sys.stderr
|
|
231
|
+
)
|
|
232
|
+
sys.exit(2)
|
|
233
|
+
if n < 1:
|
|
234
|
+
print(
|
|
235
|
+
f"error: --lines value {token!r} must be a positive integer (>= 1).",
|
|
236
|
+
file=sys.stderr,
|
|
237
|
+
)
|
|
238
|
+
sys.exit(2)
|
|
239
|
+
return n
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _parse_lines(lines_str: str | None) -> set[int] | None:
|
|
243
|
+
"""Parse --lines argument into a set of positive ints, or None if not given."""
|
|
244
|
+
if lines_str is None:
|
|
245
|
+
return None
|
|
246
|
+
parts = [p.strip() for p in lines_str.split(",") if p.strip()]
|
|
247
|
+
return {_parse_line_token(p) for p in parts}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _run_scan(args: argparse.Namespace, source: str, cwd: str) -> None:
|
|
251
|
+
"""Execute --scan logic; exits with code 2 on CoverageError."""
|
|
252
|
+
try:
|
|
253
|
+
run_scan(
|
|
254
|
+
path=args.file,
|
|
255
|
+
source=source,
|
|
256
|
+
warning_threshold=args.warning_threshold,
|
|
257
|
+
cov_cmd=args.cov_cmd,
|
|
258
|
+
lcov_path=args.lcov,
|
|
259
|
+
reuse_coverage=args.reuse_coverage,
|
|
260
|
+
cwd=cwd,
|
|
261
|
+
)
|
|
262
|
+
except CoverageError as exc:
|
|
263
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
264
|
+
sys.exit(2)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _collect_py_files(directory: str) -> list[str]:
|
|
268
|
+
result = []
|
|
269
|
+
for root, dirs, files in os.walk(directory):
|
|
270
|
+
dirs[:] = sorted(d for d in dirs if d != "__pycache__")
|
|
271
|
+
for f in sorted(files):
|
|
272
|
+
if f.endswith(".py"):
|
|
273
|
+
result.append(os.path.join(root, f))
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _run_on_file(
|
|
278
|
+
args: argparse.Namespace,
|
|
279
|
+
py_file: str,
|
|
280
|
+
source: str,
|
|
281
|
+
cwd: str,
|
|
282
|
+
baseline_duration: float | None = None,
|
|
283
|
+
) -> int:
|
|
284
|
+
if args.check_manifest:
|
|
285
|
+
return check_manifest(path=py_file, source=source)
|
|
286
|
+
if args.update_manifest:
|
|
287
|
+
update_manifest(path=py_file, source=source)
|
|
288
|
+
return 0
|
|
289
|
+
if args.scan:
|
|
290
|
+
try:
|
|
291
|
+
run_scan(
|
|
292
|
+
path=py_file,
|
|
293
|
+
source=source,
|
|
294
|
+
warning_threshold=args.warning_threshold,
|
|
295
|
+
cov_cmd=args.cov_cmd,
|
|
296
|
+
lcov_path=args.lcov,
|
|
297
|
+
reuse_coverage=args.reuse_coverage,
|
|
298
|
+
cwd=cwd,
|
|
299
|
+
)
|
|
300
|
+
except CoverageError as exc:
|
|
301
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
302
|
+
sys.exit(2)
|
|
303
|
+
return 0
|
|
304
|
+
lines_filter = _parse_lines(args.lines)
|
|
305
|
+
max_workers = args.max_workers if args.max_workers is not None else 0
|
|
306
|
+
return run_mutations(
|
|
307
|
+
path=py_file,
|
|
308
|
+
source=source,
|
|
309
|
+
cov_cmd=args.cov_cmd,
|
|
310
|
+
lcov_path=args.lcov,
|
|
311
|
+
reuse_coverage=args.reuse_coverage,
|
|
312
|
+
test_command=args.test_command,
|
|
313
|
+
timeout_factor=args.timeout_factor,
|
|
314
|
+
min_timeout=args.min_timeout,
|
|
315
|
+
lines_filter=lines_filter,
|
|
316
|
+
since_last_run=args.since_last_run,
|
|
317
|
+
mutate_all=args.mutate_all,
|
|
318
|
+
warning_threshold=args.warning_threshold,
|
|
319
|
+
max_workers=max_workers,
|
|
320
|
+
cwd=cwd,
|
|
321
|
+
baseline_duration=baseline_duration,
|
|
322
|
+
test_contexts_path=args.test_contexts,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _needs_directory_baseline(files: list[str], args: argparse.Namespace) -> bool:
|
|
327
|
+
"""A shared baseline is only needed when the run will actually execute mutants."""
|
|
328
|
+
return bool(files) and not (
|
|
329
|
+
args.scan or args.update_manifest or args.check_manifest
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _prepare_directory_baseline(
|
|
334
|
+
args: argparse.Namespace, files: list[str], cwd: str
|
|
335
|
+
) -> float:
|
|
336
|
+
"""Acquire coverage once and time the baseline for a directory run.
|
|
337
|
+
|
|
338
|
+
Exits the process on failure, matching the single-file dispatch behavior.
|
|
339
|
+
"""
|
|
340
|
+
from mutate4py._coverage import acquire_coverage
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
acquire_coverage(
|
|
344
|
+
cov_cmd=args.cov_cmd,
|
|
345
|
+
lcov_path=args.lcov,
|
|
346
|
+
reuse=args.reuse_coverage,
|
|
347
|
+
cwd=cwd,
|
|
348
|
+
source_path=os.path.abspath(files[0]),
|
|
349
|
+
)
|
|
350
|
+
except CoverageError as exc:
|
|
351
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
352
|
+
sys.exit(1)
|
|
353
|
+
baseline_duration, baseline_error = run_baseline(args.test_command, cwd)
|
|
354
|
+
if baseline_error is not None:
|
|
355
|
+
print(f"baseline failed: {baseline_error}", file=sys.stderr)
|
|
356
|
+
sys.exit(1)
|
|
357
|
+
return baseline_duration
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _dispatch_directory(args: argparse.Namespace) -> None:
|
|
361
|
+
files = _collect_py_files(args.file)
|
|
362
|
+
cwd = os.getcwd()
|
|
363
|
+
baseline_duration = None
|
|
364
|
+
if _needs_directory_baseline(files, args):
|
|
365
|
+
baseline_duration = _prepare_directory_baseline(args, files, cwd)
|
|
366
|
+
exit_code = 0
|
|
367
|
+
for py_file in files:
|
|
368
|
+
if (
|
|
369
|
+
_run_on_file(args, py_file, _load_source(py_file), cwd, baseline_duration)
|
|
370
|
+
!= 0
|
|
371
|
+
):
|
|
372
|
+
exit_code = 1
|
|
373
|
+
sys.exit(exit_code)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _dispatch_single_file(args: argparse.Namespace, source: str, cwd: str) -> None:
|
|
377
|
+
if args.check_manifest:
|
|
378
|
+
sys.exit(check_manifest(path=args.file, source=source))
|
|
379
|
+
if args.scan:
|
|
380
|
+
_run_scan(args, source, cwd)
|
|
381
|
+
return
|
|
382
|
+
if args.update_manifest:
|
|
383
|
+
update_manifest(path=args.file, source=source)
|
|
384
|
+
return
|
|
385
|
+
lines_filter = _parse_lines(args.lines)
|
|
386
|
+
max_workers = args.max_workers if args.max_workers is not None else 0
|
|
387
|
+
sys.exit(
|
|
388
|
+
run_mutations(
|
|
389
|
+
path=args.file,
|
|
390
|
+
source=source,
|
|
391
|
+
cov_cmd=args.cov_cmd,
|
|
392
|
+
lcov_path=args.lcov,
|
|
393
|
+
reuse_coverage=args.reuse_coverage,
|
|
394
|
+
test_command=args.test_command,
|
|
395
|
+
timeout_factor=args.timeout_factor,
|
|
396
|
+
min_timeout=args.min_timeout,
|
|
397
|
+
lines_filter=lines_filter,
|
|
398
|
+
since_last_run=args.since_last_run,
|
|
399
|
+
mutate_all=args.mutate_all,
|
|
400
|
+
warning_threshold=args.warning_threshold,
|
|
401
|
+
max_workers=max_workers,
|
|
402
|
+
cwd=cwd,
|
|
403
|
+
test_contexts_path=args.test_contexts,
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def main() -> None:
|
|
409
|
+
parser = _build_parser()
|
|
410
|
+
args = parser.parse_args()
|
|
411
|
+
_check_coverage_flags(args)
|
|
412
|
+
_validate_mutual_exclusions(args)
|
|
413
|
+
if args.test_contexts is not None and not os.path.isfile(args.test_contexts):
|
|
414
|
+
print(
|
|
415
|
+
f"error: --test-contexts file not found: {args.test_contexts}",
|
|
416
|
+
file=sys.stderr,
|
|
417
|
+
)
|
|
418
|
+
sys.exit(2)
|
|
419
|
+
if os.path.isdir(args.file):
|
|
420
|
+
_dispatch_directory(args)
|
|
421
|
+
return
|
|
422
|
+
_dispatch_single_file(args, _load_source(args.file), os.getcwd())
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
if __name__ == "__main__":
|
|
426
|
+
main()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# mutate4py-manifest-begin
|
|
430
|
+
# {"version":1,"tested_at":"2026-07-02T02:02:20Z","module_hash":"32777e82b0b3b4a9982ff5faa2919627d426bb07ceb1ce081394be0ae88ea3b5","functions":[{"id":"func/_positive_int","name":"_positive_int","line":19,"end_line":27,"hash":"06de4d3f74cf39cb40383657b49523cefc38cdf8566d5f8125553f3fd3c195d3"},{"id":"func/_build_parser","name":"_build_parser","line":30,"end_line":129,"hash":"672fb3d43c0aa60073f06287ace42d2e63d874447bb620bbff3d7e10771074fc"},{"id":"func/_check_coverage_flags","name":"_check_coverage_flags","line":132,"end_line":141,"hash":"42edc6617a1e291bd93d3578d7e32e708f713f9084ccba6be2331ad9b0ec9f87"},{"id":"func/_no_run_flag","name":"_no_run_flag","line":144,"end_line":150,"hash":"59aa486011c3e3158e11848b3f49b3182be927b8b3fc1c89b7b93890a7470ba9"},{"id":"func/_exit_incompatible","name":"_exit_incompatible","line":153,"end_line":155,"hash":"db2fe36913cbe87d3616570466315a8988310564ec93851dc7290d8065b8cee0"},{"id":"func/_check_no_run_incompatibilities","name":"_check_no_run_incompatibilities","line":158,"end_line":170,"hash":"53d50c7e588b2c2c608132917c7e016812ad25cb7f5f8fa6780e4a91ace9431b"},{"id":"func/_check_scan_only_incompatibilities","name":"_check_scan_only_incompatibilities","line":173,"end_line":180,"hash":"77d8a7f28df92300239bc3881b089c51438d9dafd77dd134a1e01964e4d24ada"},{"id":"func/_check_selection_exclusivity","name":"_check_selection_exclusivity","line":183,"end_line":194,"hash":"6ff1d994acd05c8dac1f9d7c4467a6fc74aa37899720361f85876e0f79a40e26"},{"id":"func/_validate_mutual_exclusions","name":"_validate_mutual_exclusions","line":197,"end_line":211,"hash":"6155e6243361768bcdfd18a457def365adbf180f8e41ac3f9e6497e59fe6790b"},{"id":"func/_load_source","name":"_load_source","line":214,"end_line":221,"hash":"b5c0322beb2e960c86d48fc3d00b8e1a910b64d4cfcadc414911fbcac6c7cc04"},{"id":"func/_parse_line_token","name":"_parse_line_token","line":224,"end_line":239,"hash":"3ede4fa8f423d5d27e97f3e7c6aec920c3e2e308159c05b42643d0c474b7fb86"},{"id":"func/_parse_lines","name":"_parse_lines","line":242,"end_line":247,"hash":"4febad296c3c5be36594a0188127548ab4edcba48a3bd946848e9c583b398ac4"},{"id":"func/_run_scan","name":"_run_scan","line":250,"end_line":264,"hash":"8075b51b362f0442df434fb5830df8d0de03eeb295e4bd0b1354700c6c9e29df"},{"id":"func/_collect_py_files","name":"_collect_py_files","line":267,"end_line":274,"hash":"3dd82fa868ce457a6024239044e42205bc3d9218355f35401fb058145e9f8150"},{"id":"func/_run_on_file","name":"_run_on_file","line":277,"end_line":323,"hash":"0c2e68f1e622a962ae8134453c57f1c4d0d25fb1d21330862043d938875ec7ce"},{"id":"func/_needs_directory_baseline","name":"_needs_directory_baseline","line":326,"end_line":330,"hash":"5d52f1c4f16dafb723d1963e0b2dc0e8aa625d0c3fe72a339d9c711bffe48a51"},{"id":"func/_prepare_directory_baseline","name":"_prepare_directory_baseline","line":333,"end_line":357,"hash":"811f2e34fd3d23c47aafd1c0e685a6ded294a940ea0873332b5411777396df3a"},{"id":"func/_dispatch_directory","name":"_dispatch_directory","line":360,"end_line":373,"hash":"1871680f3e0bde79b844ea330bc1980d42f273dd7c018ed3040479fdb3007dfc"},{"id":"func/_dispatch_single_file","name":"_dispatch_single_file","line":376,"end_line":405,"hash":"e20bf33a95e7a7ac5579f11bd2eebefa288d0ed88c066af8beacd93021d18ee7"},{"id":"func/main","name":"main","line":408,"end_line":422,"hash":"58fd6a6fdaf50ad3a0fa1c83943ad39ff9688067d63cdcc5ab0bd73ae2f341f7"}]}
|
|
431
|
+
# mutate4py-manifest-end
|
mutate4py/_cmd.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Low-level subprocess helper shared by serial and parallel mutation runners."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
__all__ = ["run_command"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_command(cmd: str, cwd: str, timeout: float) -> tuple[str, bool]:
|
|
9
|
+
"""Run cmd via shell; return (status, timed_out) where status in {killed,timeout,survived}."""
|
|
10
|
+
try:
|
|
11
|
+
result = subprocess.run(
|
|
12
|
+
cmd,
|
|
13
|
+
shell=True,
|
|
14
|
+
cwd=cwd,
|
|
15
|
+
capture_output=True,
|
|
16
|
+
timeout=timeout,
|
|
17
|
+
)
|
|
18
|
+
return ("survived" if result.returncode == 0 else "killed", False)
|
|
19
|
+
except subprocess.TimeoutExpired:
|
|
20
|
+
return ("timeout", True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# mutate4py-manifest-begin
|
|
24
|
+
# {"version":1,"tested_at":"2026-07-02T14:15:17Z","module_hash":"140b5a19257f812cf84093bca22817acc1b2a4edf5439cd51a49ee5a7137284b","functions":[{"id":"func/run_command","name":"run_command","line":8,"end_line":20,"hash":"d057ada7a6edbfaed83dc3883749643c727c691d1db3ac4ad44704c8754d4287"}]}
|
|
25
|
+
# mutate4py-manifest-end
|
mutate4py/_coverage.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""LCOV coverage acquisition for mutate4py."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
DEFAULT_LCOV_PATH = "coverage.lcov"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CoverageError(Exception):
|
|
10
|
+
"""Raised when coverage acquisition fails (missing file, command error)."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _paths_match_by_suffix(sf_path: str, source_path: str) -> bool:
|
|
14
|
+
"""Return True if one path is a path-suffix of the other."""
|
|
15
|
+
a = sf_path.replace("\\", "/")
|
|
16
|
+
b = source_path.replace("\\", "/")
|
|
17
|
+
return a.endswith(b) or b.endswith(a)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_da_line(line: str) -> int | None:
|
|
21
|
+
"""Return the covered line number from a DA record, or None if uncovered/invalid."""
|
|
22
|
+
parts = line[3:].split(",", 1)
|
|
23
|
+
if len(parts) != 2:
|
|
24
|
+
return None
|
|
25
|
+
try:
|
|
26
|
+
lineno = int(parts[0])
|
|
27
|
+
count = int(parts[1])
|
|
28
|
+
return lineno if count > 0 else None
|
|
29
|
+
except ValueError:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _update_lcov_state(
|
|
34
|
+
line: str,
|
|
35
|
+
in_matching_file: bool,
|
|
36
|
+
source_path: str,
|
|
37
|
+
covered: set[int],
|
|
38
|
+
) -> bool:
|
|
39
|
+
"""Process one LCOV line; return updated in_matching_file flag."""
|
|
40
|
+
if line.startswith("SF:"):
|
|
41
|
+
return _paths_match_by_suffix(line[3:], source_path)
|
|
42
|
+
if line == "end_of_record":
|
|
43
|
+
return False
|
|
44
|
+
if in_matching_file and line.startswith("DA:"):
|
|
45
|
+
lineno = _parse_da_line(line)
|
|
46
|
+
if lineno is not None:
|
|
47
|
+
covered.add(lineno)
|
|
48
|
+
# BRDA records are intentionally ignored (ADR 0007)
|
|
49
|
+
return in_matching_file
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_lcov(lcov_text: str, source_path: str) -> set[int]:
|
|
53
|
+
"""Parse LCOV text and return covered line numbers for source_path.
|
|
54
|
+
|
|
55
|
+
A line is covered iff it has a DA:<line>,<count> record with count > 0.
|
|
56
|
+
BRDA records are ignored. SF path matching is suffix-based.
|
|
57
|
+
"""
|
|
58
|
+
covered: set[int] = set()
|
|
59
|
+
in_matching_file = False
|
|
60
|
+
for raw_line in lcov_text.splitlines():
|
|
61
|
+
in_matching_file = _update_lcov_state(
|
|
62
|
+
raw_line.strip(), in_matching_file, source_path, covered
|
|
63
|
+
)
|
|
64
|
+
return covered
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _read_lcov_file(path: str, source_path: str) -> set[int]:
|
|
68
|
+
"""Read and parse an LCOV file; raise CoverageError if missing."""
|
|
69
|
+
if not os.path.isfile(path):
|
|
70
|
+
raise CoverageError(
|
|
71
|
+
f"LCOV file not found: {path}. "
|
|
72
|
+
"Generate coverage first then supply the path."
|
|
73
|
+
)
|
|
74
|
+
with open(path) as f:
|
|
75
|
+
return parse_lcov(f.read(), source_path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _resolve_lcov_path(
|
|
79
|
+
cov_cmd: str | None,
|
|
80
|
+
lcov_path: str | None,
|
|
81
|
+
reuse: bool,
|
|
82
|
+
cwd: str,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Return the LCOV file path to read, running cov_cmd first if needed."""
|
|
85
|
+
if cov_cmd is not None:
|
|
86
|
+
result = subprocess.run(cov_cmd, shell=True, cwd=cwd)
|
|
87
|
+
if result.returncode != 0:
|
|
88
|
+
raise CoverageError(
|
|
89
|
+
f"Coverage command failed (exit {result.returncode}): {cov_cmd}"
|
|
90
|
+
)
|
|
91
|
+
default = os.path.join(cwd, DEFAULT_LCOV_PATH)
|
|
92
|
+
if not os.path.isfile(default):
|
|
93
|
+
raise CoverageError(
|
|
94
|
+
f"Coverage command did not produce {DEFAULT_LCOV_PATH}. "
|
|
95
|
+
"Ensure your --cov-cmd writes LCOV to coverage.lcov."
|
|
96
|
+
)
|
|
97
|
+
return default
|
|
98
|
+
if lcov_path is not None:
|
|
99
|
+
return lcov_path
|
|
100
|
+
# reuse=True
|
|
101
|
+
return os.path.join(cwd, DEFAULT_LCOV_PATH)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def acquire_coverage(
|
|
105
|
+
*,
|
|
106
|
+
cov_cmd: str | None,
|
|
107
|
+
lcov_path: str | None,
|
|
108
|
+
reuse: bool,
|
|
109
|
+
cwd: str,
|
|
110
|
+
source_path: str,
|
|
111
|
+
) -> set[int]:
|
|
112
|
+
"""Acquire LCOV coverage and return covered line numbers.
|
|
113
|
+
|
|
114
|
+
Exactly one of cov_cmd, lcov_path, or reuse must be active.
|
|
115
|
+
Raises CoverageError if the coverage source is missing or unusable.
|
|
116
|
+
"""
|
|
117
|
+
path = _resolve_lcov_path(cov_cmd, lcov_path, reuse, cwd)
|
|
118
|
+
return _read_lcov_file(path, source_path)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# mutate4py-manifest-begin
|
|
122
|
+
# {"version":1,"tested_at":"2026-07-02T02:02:51Z","module_hash":"f3f88f6d978910a03dfc170d13ca4075d2e3239db28fc7d4c2b4f56389734f33","functions":[{"id":"func/_paths_match_by_suffix","name":"_paths_match_by_suffix","line":13,"end_line":17,"hash":"15fea9e47953b787891a65d7ad6dae4adf24c1a8384cdb48787f61481d26fdc9"},{"id":"func/_parse_da_line","name":"_parse_da_line","line":20,"end_line":30,"hash":"37e090f9c530f8edf6a6764d97e4ad804ab66a804e5d7441e3aa30a56fbb871c"},{"id":"func/_update_lcov_state","name":"_update_lcov_state","line":33,"end_line":49,"hash":"7f0b7fabbad37cd8ecb2fcd093c17f1745e81bde9a99debbfc14c79c0b985b88"},{"id":"func/parse_lcov","name":"parse_lcov","line":52,"end_line":64,"hash":"bc0086fddb957a0eb29e4b78e39eb5c0c7c08bd1290ee596384f96243272861c"},{"id":"func/_read_lcov_file","name":"_read_lcov_file","line":67,"end_line":75,"hash":"2f49c4d94fc92a5a6cbb7065ee4c27710533cc57218e2b512d44e4676d039cdd"},{"id":"func/_resolve_lcov_path","name":"_resolve_lcov_path","line":78,"end_line":101,"hash":"cdea7e1f11c5c0d4f722c8b02ae2f14b7ea622cb3ce06873500b0887991215a9"},{"id":"func/acquire_coverage","name":"acquire_coverage","line":104,"end_line":118,"hash":"5d7e94cc123fdf440dcc9cd4812750b692f1f95f0a357927dc9444943dbfccf3"}]}
|
|
123
|
+
# mutate4py-manifest-end
|