8Enumerate and apply deterministic source mutants over the pinned v1 operator set.
12- Derive every mutant of a module purely from its syntax tree, in source order.
13- Give each mutant a stable ``relpath:line:col:op-code`` identity.
14- Render a readable before/after preview for each mutant without touching disk.
15- Apply exactly one named mutant to a source string and return the new text.
22 L3 - enumeration details
27- The same source text always yields the same mutants with the same ids.
28- Enumeration is read-only; nothing in this module writes to the filesystem.
29- A mutant never reproduces the original source, so no enumerated mutant is a
30 guaranteed survivor by construction.
31- ``SyntaxError`` propagates so callers report an unparsable target honestly.
35- ast (syntax tree, mutation, and source rendering)
36- tokenize (declared source encodings)
42from __future__
import annotations
47from dataclasses
import asdict, dataclass
48from pathlib
import Path
49from typing
import Callable
53 """A named mutant id does not exist in the given source."""
59_COMPARE_SWAPS: dict[type[ast.cmpop], tuple[type[ast.cmpop], str]] = {
60 ast.Eq: (ast.NotEq,
"EQ_NE"),
61 ast.NotEq: (ast.Eq,
"NE_EQ"),
62 ast.Lt: (ast.GtE,
"LT_GE"),
63 ast.GtE: (ast.Lt,
"GE_LT"),
64 ast.Gt: (ast.LtE,
"GT_LE"),
65 ast.LtE: (ast.Gt,
"LE_GT"),
68_BINOP_SWAPS: dict[type[ast.operator], tuple[type[ast.operator], str]] = {
69 ast.Add: (ast.Sub,
"ADD_SUB"),
70 ast.Sub: (ast.Add,
"SUB_ADD"),
71 ast.Mult: (ast.FloorDiv,
"MUL_FLOORDIV"),
72 ast.FloorDiv: (ast.Mult,
"FLOORDIV_MUL"),
75_BOOLOP_SWAPS: dict[type[ast.boolop], tuple[type[ast.boolop], str]] = {
76 ast.And: (ast.Or,
"AND_OR"),
77 ast.Or: (ast.And,
"OR_AND"),
81@dataclass(frozen=True, slots=True)
83 """One enumerated mutant with its stable identity and readable preview."""
94 def to_dict(self) -> dict[str, str | int]:
95 """Return a JSON-ready representation of every field."""
101 """An enumerated mutant bound to the in-tree edit that produces it."""
104 apply: Callable[[],
None]
108 """Walk a tree once, recording every v1 mutation site it contains.
110 The collector records an ``apply`` closure per site instead of a second
111 matching pass, so enumeration and application share one definition of
112 where a mutant lives. Previews are rendered from throwaway copies, which
113 keeps the visited tree pristine until a caller deliberately applies a site.
116 def __init__(self, relpath: str, lines: list[str]) ->
None:
122 """Record one swap per comparison operator in a (possibly chained) test.
124 The anchor is the operand that FOLLOWS the operator rather than the
125 ``Compare`` node itself: in ``a < b < c`` both operators share the
126 node's coordinate, and two sites at one coordinate with one op-code
127 would produce colliding ids.
129 for index, operator
in enumerate(node.ops):
130 swap = _COMPARE_SWAPS.get(type(operator))
133 replacement, op_code = swap
134 anchor = node.comparators[index]
137 lineno=anchor.lineno,
138 col_offset=anchor.col_offset,
140 mutate_copy=
lambda preview, i=index, r=replacement: (
141 preview.ops.__setitem__(i, r())
143 apply_in_place=
lambda i=index, r=replacement: (
144 node.ops.__setitem__(i, r())
147 self.generic_visit(node)
150 """Record the ``and``/``or`` swap for one boolean chain."""
151 swap = _BOOLOP_SWAPS.get(type(node.op))
153 replacement, op_code = swap
157 col_offset=node.col_offset,
159 mutate_copy=
lambda preview, r=replacement: setattr(
162 apply_in_place=
lambda r=replacement: setattr(node,
"op", r()),
164 self.generic_visit(node)
167 """Record an arithmetic swap, anchored on the right-hand operand.
169 ``a + b + c`` parses as a left-nested pair of ``BinOp`` nodes that
170 share a column, so the right operand is the only coordinate that
171 distinguishes the two additions.
173 swap = _BINOP_SWAPS.get(type(node.op))
175 replacement, op_code = swap
178 lineno=node.right.lineno,
179 col_offset=node.right.col_offset,
181 mutate_copy=
lambda preview, r=replacement: setattr(
184 apply_in_place=
lambda r=replacement: setattr(node,
"op", r()),
186 self.generic_visit(node)
189 """Record numeric tweaks, excluding booleans, strings, and complex values.
191 ``n -> 0`` is skipped when ``n`` is already zero: that mutant would be
192 byte-identical to the original and would therefore survive every test
193 suite, inflating the survivor list with a finding no test could ever
197 if isinstance(value, bool)
or not isinstance(value, (int, float)):
198 self.generic_visit(node)
203 col_offset=node.col_offset,
205 mutate_copy=
lambda preview: setattr(preview,
"value", value + 1),
206 apply_in_place=
lambda: setattr(node,
"value", value + 1),
211 zero = type(value)(0)
215 col_offset=node.col_offset,
217 mutate_copy=
lambda preview: setattr(preview,
"value", zero),
218 apply_in_place=
lambda: setattr(node,
"value", zero),
220 self.generic_visit(node)
223 """Record a ``not`` insertion on an ``if`` (or ``elif``) test."""
225 self.generic_visit(node)
228 """Record a ``not`` insertion on a ``while`` test."""
230 self.generic_visit(node)
233 """Record the negation site unless the test is already negated.
235 ``if not not value:`` has exactly the truth value of ``if value:``, so
236 negating an existing ``not`` yields an equivalent mutant that can never
237 be killed. Excluding it keeps the survivor list honest.
240 if isinstance(test, ast.UnaryOp)
and isinstance(test.op, ast.Not):
242 rendered = ast.unparse(test)
248 col_offset=test.col_offset,
250 after=f
"not ({rendered})",
252 apply=
lambda: setattr(
255 ast.fix_missing_locations(
257 ast.UnaryOp(op=ast.Not(), operand=node.test), node.test
271 mutate_copy: Callable[[ast.expr],
None],
272 apply_in_place: Callable[[],
None],
274 """Append one site, rendering its preview from a throwaway copy."""
275 preview = copy.deepcopy(node)
282 col_offset=col_offset,
283 before=ast.unparse(node),
284 after=ast.unparse(preview),
286 apply=apply_in_place,
299 """Assemble one mutant record with its stable id and source line."""
300 line_text = self.
lines[lineno - 1].strip()
if lineno <= len(self.
lines)
else ""
302 mutant_id=f
"{self.relpath}:{lineno}:{col_offset}:{op_code}",
305 col_offset=col_offset,
314 source: str, relpath: str
315) -> tuple[ast.Module, list[_Site]]:
316 """Return the parsed tree alongside the sites that edit it in place."""
317 tree = ast.parse(source, filename=relpath)
319 collector.visit(tree)
324 site.mutant.col_offset,
332 """Return every v1 mutant of ``source`` in deterministic source order."""
336def enumerate_file(path: str | Path, root: str | Path |
None =
None) -> list[Mutant]:
337 """Return every v1 mutant of one file, identified relative to ``root``."""
338 resolved = Path(path).resolve()
339 with tokenize.open(resolved)
as source_file:
340 source = source_file.read()
345 """Return the forward-slash relative path used inside mutant ids.
347 Ids are anchored to ``root`` so they stay stable when the same tree is
348 checked out at a different absolute location; a target outside ``root``
349 falls back to its bare filename rather than leaking an absolute path.
351 resolved = Path(path).resolve()
355 return resolved.relative_to(Path(root).resolve()).as_posix()
361 """Return ``source`` with exactly the named mutant applied.
363 The result is re-rendered by :func:`ast.unparse`, so comments and original
364 formatting are not preserved. That is safe only because the runner writes
365 this text transiently and always restores the pristine bytes afterwards.
369 if site.mutant.mutant_id == mutant_id:
371 return ast.unparse(ast.fix_missing_locations(tree))
None visit_While(self, ast.While node)
None visit_Constant(self, ast.Constant node)
None __init__(self, str relpath, list[str] lines)
None _record_negation(self, ast.If|ast.While node, str op_code)
None visit_BoolOp(self, ast.BoolOp node)
Mutant _build_mutant(self, *, str op_code, int lineno, int col_offset, str before, str after)
None visit_BinOp(self, ast.BinOp node)
None visit_If(self, ast.If node)
None _record(self, *, str op_code, int lineno, int col_offset, ast.expr node, Callable[[ast.expr], None] mutate_copy, Callable[[], None] apply_in_place)
None visit_Compare(self, ast.Compare node)
dict[str, str|int] to_dict(self)
list[Mutant] enumerate_file(str|Path path, str|Path|None root=None)
list[Mutant] enumerate_source(str source, str relpath)
str relative_id(str|Path path, str|Path|None root)
str apply_mutant(str source, str mutant_id, str relpath)
tuple[ast.Module, list[_Site]] _collect_tree_and_sites(str source, str relpath)