mutgate 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.
- mutgate/__init__.py +22 -0
- mutgate/__main__.py +3 -0
- mutgate/cli.py +82 -0
- mutgate/core.py +453 -0
- mutgate-0.1.0.dist-info/METADATA +157 -0
- mutgate-0.1.0.dist-info/RECORD +9 -0
- mutgate-0.1.0.dist-info/WHEEL +4 -0
- mutgate-0.1.0.dist-info/entry_points.txt +2 -0
- mutgate-0.1.0.dist-info/licenses/LICENSE +21 -0
mutgate/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""mutgate — named mutations as contracts on a test suite.
|
|
2
|
+
|
|
3
|
+
from mutgate import Mutation
|
|
4
|
+
MUTATIONS = [
|
|
5
|
+
Mutation("keep-larger", file="pkg/engine.py",
|
|
6
|
+
old="if birth[a] >= birth[b]:", new="if size[a] >= size[b]:",
|
|
7
|
+
fires=("TestElderRule",)),
|
|
8
|
+
Mutation("tie-to-higher-index", file="pkg/engine.py",
|
|
9
|
+
old="(birth[a], -a) >= (birth[b], -b)", new="(birth[a], a) >= (birth[b], b)",
|
|
10
|
+
invisible=True), # an invariance: must fire nothing
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
Then `mutgate run tests/mutations.py`. Each mutation is applied in a sandbox copy, the
|
|
14
|
+
tests run, and the contract judged: OK, DECORATION (a named guard did not fire),
|
|
15
|
+
OVERREACH (something outside the contract fired — two copies of one convention?),
|
|
16
|
+
VISIBLE (an invariance was broken), NOT_APPLIED (the text was not found), ERROR.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .core import Mutation, Report, Verdict, load, run
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
__all__ = ["Mutation", "Report", "Verdict", "load", "run", "__version__"]
|
mutgate/__main__.py
ADDED
mutgate/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""`mutgate run tests/mutations.py` — check every named mutation's contract.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 every contract holds; 1 a verdict is not OK; 2 the baseline is red or the
|
|
4
|
+
declaration cannot be loaded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .core import load, run
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parser() -> argparse.ArgumentParser:
|
|
19
|
+
ap = argparse.ArgumentParser(prog="mutgate",
|
|
20
|
+
description="Named mutations as contracts on a test suite.")
|
|
21
|
+
ap.add_argument("--version", action="version", version=f"mutgate {__version__}")
|
|
22
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
23
|
+
|
|
24
|
+
r = sub.add_parser("run", help="apply each mutation in a sandbox and judge its contract")
|
|
25
|
+
r.add_argument("file", help="the mutations file (defines MUTATIONS, optionally TESTS/PATHS/ROOT/PYTHON)")
|
|
26
|
+
r.add_argument("--tests", nargs="+", help="pytest targets (override the file's TESTS)")
|
|
27
|
+
r.add_argument("--only", nargs="+", metavar="NAME", help="run only these mutations")
|
|
28
|
+
r.add_argument("--python", help="interpreter to run pytest with (default: this one)")
|
|
29
|
+
r.add_argument("--timeout", type=float, default=None, metavar="SECONDS",
|
|
30
|
+
help="abort a pytest run that takes longer (verdict ERROR)")
|
|
31
|
+
r.add_argument("--pytest-arg", action="append", default=[], metavar="ARG",
|
|
32
|
+
help="extra argument passed to pytest (repeatable)")
|
|
33
|
+
r.add_argument("--markdown", action="store_true", help="print the build-record table instead of the plain one")
|
|
34
|
+
r.add_argument("--keep", action="store_true", help="keep the sandbox directory")
|
|
35
|
+
r.add_argument("-x", "--stop", action="store_true", help="stop at the first verdict that is not OK")
|
|
36
|
+
r.add_argument("-v", "--verbose", action="store_true")
|
|
37
|
+
|
|
38
|
+
ls = sub.add_parser("list", help="list the mutations a file declares")
|
|
39
|
+
ls.add_argument("file")
|
|
40
|
+
return ap
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main(argv=None) -> int:
|
|
44
|
+
args = _parser().parse_args(argv)
|
|
45
|
+
try:
|
|
46
|
+
decl = load(Path(args.file))
|
|
47
|
+
except Exception as exc: # noqa: BLE001 — every load failure is a usage error here
|
|
48
|
+
print(f"mutgate: cannot load {args.file}: {exc}", file=sys.stderr)
|
|
49
|
+
return 2
|
|
50
|
+
if args.cmd == "list":
|
|
51
|
+
w = max(len(m.name) for m in decl.mutations) if decl.mutations else 8
|
|
52
|
+
for m in decl.mutations:
|
|
53
|
+
exp = "invisible" if m.invisible else ", ".join(m.fires)
|
|
54
|
+
print(f"{m.name:<{w}} {m.file} -> {exp}")
|
|
55
|
+
print(f"\nroot {decl.root}\ntests {' '.join(decl.tests)}\npaths {' '.join(decl.paths)}")
|
|
56
|
+
return 0
|
|
57
|
+
log = (lambda s: print(f"mutgate: {s}", file=sys.stderr, flush=True)) if args.verbose else None
|
|
58
|
+
python = args.python or decl.python or sys.executable
|
|
59
|
+
if not (Path(python).is_file() or shutil.which(python)):
|
|
60
|
+
print(f"mutgate: interpreter not found: {python}", file=sys.stderr)
|
|
61
|
+
return 2
|
|
62
|
+
try:
|
|
63
|
+
report = run(decl.mutations, decl.root, args.tests or decl.tests,
|
|
64
|
+
python=python, paths=decl.paths, only=args.only, keep=args.keep,
|
|
65
|
+
stop_early=args.stop, extra_pytest_args=args.pytest_arg, log=log,
|
|
66
|
+
timeout=args.timeout)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
print(f"mutgate: {exc}", file=sys.stderr)
|
|
69
|
+
return 2
|
|
70
|
+
if report.baseline_failed or report.baseline_error:
|
|
71
|
+
print(report.table())
|
|
72
|
+
return 2
|
|
73
|
+
print(report.markdown() if args.markdown else report.table())
|
|
74
|
+
return 0 if report.ok else 1
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_cli() -> None:
|
|
78
|
+
sys.exit(main())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
run_cli()
|
mutgate/core.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Named mutations as contracts on a test suite.
|
|
2
|
+
|
|
3
|
+
A *mutation* is an exact-string replacement in one source file of the project under test,
|
|
4
|
+
applied in a sandbox copy (the working tree is never touched), together with a *contract*:
|
|
5
|
+
the set of tests it must turn red, or the statement that it must turn nothing red.
|
|
6
|
+
|
|
7
|
+
Four verdicts do the work:
|
|
8
|
+
|
|
9
|
+
OK the mutation fired exactly the tests the contract names (plus any it may fire)
|
|
10
|
+
DECORATION a test the contract says must fire did not — the guard is a decoration
|
|
11
|
+
OVERREACH a test outside the contract fired — the mutation reaches further than its
|
|
12
|
+
author believed, which is the tell for two copies of one convention
|
|
13
|
+
VISIBLE an `invisible` mutation (an invariance: swap a convention the measurement
|
|
14
|
+
must not depend on) fired something
|
|
15
|
+
|
|
16
|
+
and two failure modes that are not verdicts about the suite:
|
|
17
|
+
|
|
18
|
+
NOT_APPLIED the `old` text was not found exactly `count` times, so nothing was mutated —
|
|
19
|
+
a mutation that silently does not land looks identical to a working guard
|
|
20
|
+
ERROR the suite could not run (a mutation that does not compile, a collection
|
|
21
|
+
error, a usage error)
|
|
22
|
+
|
|
23
|
+
A red baseline aborts the whole run: mutations are meaningless on a suite that already fails.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import importlib.util
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import tempfile
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Iterable, Optional, Sequence
|
|
38
|
+
|
|
39
|
+
__all__ = ["Mutation", "Verdict", "Report", "Sandbox", "run", "load", "run_pytest"]
|
|
40
|
+
|
|
41
|
+
_IGNORE_DIRS = {".git", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache",
|
|
42
|
+
".ruff_cache", "node_modules", ".tox", ".nox", "dist", "build"}
|
|
43
|
+
_STATUSES = ("OK", "DECORATION", "OVERREACH", "VISIBLE", "NOT_APPLIED", "ERROR")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Mutation:
|
|
48
|
+
"""One named mutation and its contract.
|
|
49
|
+
|
|
50
|
+
file path of the source file, relative to the project root
|
|
51
|
+
old/new exact text to replace; `old` must occur exactly `count` times in the file
|
|
52
|
+
fires test-id fragments (any substring of a pytest node id, e.g. "TestK2ElderRule"
|
|
53
|
+
or "test_x.py::TestK2::test_ratio") that MUST fail under the mutation
|
|
54
|
+
may_fire fragments that are allowed to fail as well, without being required
|
|
55
|
+
invisible True for an invariance: the mutation must fail NOTHING
|
|
56
|
+
count how many occurrences of `old` the file must contain (all are replaced)
|
|
57
|
+
note free text, carried into reports
|
|
58
|
+
"""
|
|
59
|
+
name: str
|
|
60
|
+
file: str
|
|
61
|
+
old: str
|
|
62
|
+
new: str
|
|
63
|
+
fires: tuple[str, ...] = ()
|
|
64
|
+
may_fire: tuple[str, ...] = ()
|
|
65
|
+
invisible: bool = False
|
|
66
|
+
count: int = 1
|
|
67
|
+
note: str = ""
|
|
68
|
+
|
|
69
|
+
def __post_init__(self):
|
|
70
|
+
for attr in ("fires", "may_fire"):
|
|
71
|
+
val = getattr(self, attr)
|
|
72
|
+
if isinstance(val, str):
|
|
73
|
+
# a bare string would become its own letters, each matching every node id,
|
|
74
|
+
# and every contract would read OK (review 2026-09-07, item 3)
|
|
75
|
+
raise ValueError(f"{self.name}: {attr} must be a tuple of fragments, not a string; "
|
|
76
|
+
f"write ({val!r},)")
|
|
77
|
+
val = tuple(val)
|
|
78
|
+
if any(not isinstance(f, str) or not f.strip() for f in val):
|
|
79
|
+
raise ValueError(f"{self.name}: every {attr} entry must be a non-empty string")
|
|
80
|
+
object.__setattr__(self, attr, val)
|
|
81
|
+
if not self.name:
|
|
82
|
+
raise ValueError("a mutation needs a name")
|
|
83
|
+
if self.old == self.new:
|
|
84
|
+
raise ValueError(f"{self.name}: old and new are identical; nothing would change")
|
|
85
|
+
if not self.old:
|
|
86
|
+
raise ValueError(f"{self.name}: old must be non-empty")
|
|
87
|
+
if self.count < 1:
|
|
88
|
+
raise ValueError(f"{self.name}: count must be >= 1")
|
|
89
|
+
if self.invisible and (self.fires or self.may_fire):
|
|
90
|
+
raise ValueError(f"{self.name}: an invisible mutation fires nothing; drop fires/may_fire")
|
|
91
|
+
if not self.invisible and not self.fires:
|
|
92
|
+
raise ValueError(f"{self.name}: name the tests it must fire, or mark it invisible")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class Verdict:
|
|
97
|
+
mutation: Mutation
|
|
98
|
+
status: str
|
|
99
|
+
fired: tuple[str, ...] = () # failing test node ids under the mutation
|
|
100
|
+
missing: tuple[str, ...] = () # contract entries that matched no failing test
|
|
101
|
+
unexpected: tuple[str, ...] = () # failing tests matching neither fires nor may_fire
|
|
102
|
+
detail: str = ""
|
|
103
|
+
|
|
104
|
+
def __post_init__(self):
|
|
105
|
+
if self.status not in _STATUSES:
|
|
106
|
+
raise ValueError(self.status)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def ok(self) -> bool:
|
|
110
|
+
return self.status == "OK"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class Report:
|
|
115
|
+
root: Path
|
|
116
|
+
tests: tuple[str, ...]
|
|
117
|
+
baseline_failed: tuple[str, ...] = ()
|
|
118
|
+
baseline_error: str = ""
|
|
119
|
+
verdicts: list[Verdict] = field(default_factory=list)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def ok(self) -> bool:
|
|
123
|
+
return not self.baseline_failed and not self.baseline_error and all(v.ok for v in self.verdicts)
|
|
124
|
+
|
|
125
|
+
def summary(self) -> str:
|
|
126
|
+
counts = {s: sum(v.status == s for v in self.verdicts) for s in _STATUSES}
|
|
127
|
+
parts = [f"{n} {s}" for s, n in counts.items() if n]
|
|
128
|
+
return ", ".join(parts) if parts else "no mutations"
|
|
129
|
+
|
|
130
|
+
def table(self) -> str:
|
|
131
|
+
lines = []
|
|
132
|
+
if self.baseline_error:
|
|
133
|
+
lines.append(f"BASELINE ERROR: {self.baseline_error}")
|
|
134
|
+
return "\n".join(lines)
|
|
135
|
+
if self.baseline_failed:
|
|
136
|
+
lines.append("BASELINE RED — nothing mutated. Failing before any mutation:")
|
|
137
|
+
lines.extend(f" {t}" for t in self.baseline_failed)
|
|
138
|
+
return "\n".join(lines)
|
|
139
|
+
w = max((len(v.mutation.name) for v in self.verdicts), default=8)
|
|
140
|
+
lines.append(f"{'mutation':<{w}} {'verdict':<11} fired expected")
|
|
141
|
+
for v in self.verdicts:
|
|
142
|
+
exp = "nothing" if v.mutation.invisible else ", ".join(v.mutation.fires)
|
|
143
|
+
lines.append(f"{v.mutation.name:<{w}} {v.status:<11} {len(v.fired):>5} {exp}")
|
|
144
|
+
if v.status == "DECORATION":
|
|
145
|
+
lines.extend(f"{'':<{w}} did not fire: {m}" for m in v.missing)
|
|
146
|
+
if v.status in ("OVERREACH", "DECORATION"):
|
|
147
|
+
lines.extend(f"{'':<{w}} unexpected: {u}" for u in v.unexpected)
|
|
148
|
+
if v.status == "VISIBLE":
|
|
149
|
+
lines.extend(f"{'':<{w}} fired: {f}" for f in v.fired)
|
|
150
|
+
if v.status in ("NOT_APPLIED", "ERROR"):
|
|
151
|
+
lines.append(f"{'':<{w}} {v.detail}")
|
|
152
|
+
lines.append(f"\n{self.summary()}")
|
|
153
|
+
return "\n".join(lines)
|
|
154
|
+
|
|
155
|
+
def markdown(self) -> str:
|
|
156
|
+
"""A table for a design note's build record: what each mutation fires."""
|
|
157
|
+
rows = ["| mutation | must fire | fired | verdict |", "|---|---|---|---|"]
|
|
158
|
+
for v in self.verdicts:
|
|
159
|
+
exp = "*nothing (invariance)*" if v.mutation.invisible else ", ".join(f"`{f}`" for f in v.mutation.fires)
|
|
160
|
+
fired = _collapse(v.fired)
|
|
161
|
+
rows.append(f"| {v.mutation.name} | {exp} | {fired} | {v.status} |")
|
|
162
|
+
return "\n".join(rows)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _collapse(ids: Sequence[str]) -> str:
|
|
166
|
+
"""Failing node ids collapsed to their class or module level, for a readable cell."""
|
|
167
|
+
seen = []
|
|
168
|
+
for nid in ids:
|
|
169
|
+
parts = nid.split("::")
|
|
170
|
+
key = "::".join(parts[:2]) if len(parts) >= 3 else nid
|
|
171
|
+
if key not in seen:
|
|
172
|
+
seen.append(key)
|
|
173
|
+
return ", ".join(f"`{k}`" for k in seen) if seen else "—"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------------------
|
|
177
|
+
# the sandbox: a copy of the project the mutations are applied to
|
|
178
|
+
# ---------------------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def project_files(root: Path) -> list[Path]:
|
|
181
|
+
"""Files to copy: `git ls-files` (tracked plus untracked-but-not-ignored, so uncommitted
|
|
182
|
+
work is included and the venv is not) when `root` is in a git repo; otherwise a walk
|
|
183
|
+
that skips the usual build and cache directories."""
|
|
184
|
+
root = Path(root)
|
|
185
|
+
try:
|
|
186
|
+
out = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others",
|
|
187
|
+
"--exclude-standard"], capture_output=True, check=True)
|
|
188
|
+
rels = [r for r in out.stdout.decode("utf-8", "surrogateescape").split("\0") if r]
|
|
189
|
+
files = [root / r for r in rels]
|
|
190
|
+
return [f for f in files if f.is_file()]
|
|
191
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
192
|
+
pass
|
|
193
|
+
files = []
|
|
194
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
195
|
+
dirnames[:] = [d for d in dirnames if d not in _IGNORE_DIRS and not d.endswith(".egg-info")]
|
|
196
|
+
files.extend(Path(dirpath) / f for f in filenames)
|
|
197
|
+
return files
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Sandbox:
|
|
201
|
+
"""A temporary copy of the project. Mutations are applied and restored here, one at a
|
|
202
|
+
time; the working tree is never written."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, root: Path, keep: bool = False):
|
|
205
|
+
self.root = Path(root).resolve()
|
|
206
|
+
self.keep = keep
|
|
207
|
+
# resolved: on a symlinked TMPDIR (macOS /var -> /private/var) an unresolved dir
|
|
208
|
+
# fails its own containment check in `path` (review 2026-09-07, item 1)
|
|
209
|
+
self.dir = Path(tempfile.mkdtemp(prefix="mutgate-")).resolve()
|
|
210
|
+
for src in project_files(self.root):
|
|
211
|
+
rel = src.relative_to(self.root)
|
|
212
|
+
dst = self.dir / rel
|
|
213
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
shutil.copy2(src, dst)
|
|
215
|
+
|
|
216
|
+
def path(self, rel: str) -> Path:
|
|
217
|
+
p = (self.dir / rel).resolve()
|
|
218
|
+
if self.dir not in p.parents and p != self.dir:
|
|
219
|
+
raise ValueError(f"{rel} escapes the sandbox")
|
|
220
|
+
return p
|
|
221
|
+
|
|
222
|
+
def apply(self, m: Mutation) -> tuple[bool, str, str]:
|
|
223
|
+
"""(applied, original_text, detail). Applied only if `old` occurs exactly `count`
|
|
224
|
+
times; otherwise nothing is written and `detail` says what was found."""
|
|
225
|
+
p = self.path(m.file)
|
|
226
|
+
if not p.is_file():
|
|
227
|
+
return False, "", f"{m.file}: no such file in the project"
|
|
228
|
+
text = p.read_text(encoding="utf-8")
|
|
229
|
+
found = text.count(m.old)
|
|
230
|
+
if found != m.count:
|
|
231
|
+
return False, text, f"{m.file}: `old` found {found} time(s), contract says {m.count}"
|
|
232
|
+
p.write_text(text.replace(m.old, m.new), encoding="utf-8")
|
|
233
|
+
return True, text, ""
|
|
234
|
+
|
|
235
|
+
def restore(self, m: Mutation, original: str) -> None:
|
|
236
|
+
self.path(m.file).write_text(original, encoding="utf-8")
|
|
237
|
+
|
|
238
|
+
def close(self) -> None:
|
|
239
|
+
if not self.keep:
|
|
240
|
+
shutil.rmtree(self.dir, ignore_errors=True)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------------------
|
|
244
|
+
# running pytest and reading which tests failed
|
|
245
|
+
# ---------------------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def _node_id(rest: str) -> str:
|
|
248
|
+
"""The node id at the start of a summary line's remainder: everything up to the first
|
|
249
|
+
" - " that is not inside a parametrisation bracket (ids like `test_x[a - b]` keep it)."""
|
|
250
|
+
depth = 0
|
|
251
|
+
i = 0
|
|
252
|
+
while i < len(rest):
|
|
253
|
+
ch = rest[i]
|
|
254
|
+
if ch == "[":
|
|
255
|
+
depth += 1
|
|
256
|
+
elif ch == "]" and depth:
|
|
257
|
+
depth -= 1
|
|
258
|
+
elif depth == 0 and rest.startswith(" - ", i):
|
|
259
|
+
return rest[:i].strip()
|
|
260
|
+
i += 1
|
|
261
|
+
return rest.strip()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
_CUT_SHORT_LINE = re.compile(r"^!{3,} stopping after \d+ failures? !{3,}\s*$", re.MULTILINE)
|
|
265
|
+
TIMEOUT = -9999 # return code standing for "pytest did not finish in time"
|
|
266
|
+
CUT_SHORT = -9998 # pytest stopped early (maxfail), so the fired set is incomplete
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def run_pytest(cwd: Path, tests: Sequence[str], python: str = sys.executable,
|
|
270
|
+
paths: Sequence[str] = ("src", "."), extra_args: Sequence[str] = (),
|
|
271
|
+
env: Optional[dict] = None, timeout: Optional[float] = None) -> tuple[int, list[str], str]:
|
|
272
|
+
"""Run pytest in `cwd`; return (returncode, failing node ids, tail of the output).
|
|
273
|
+
|
|
274
|
+
Failures are read from pytest's own short summary (`-rfE`), so node ids are exactly the
|
|
275
|
+
ones a contract names. `paths` are prepended to PYTHONPATH relative to `cwd`, so an
|
|
276
|
+
editable install elsewhere cannot shadow the sandbox copy. A run that exceeds `timeout`
|
|
277
|
+
seconds returns `TIMEOUT` as the code."""
|
|
278
|
+
e = dict(os.environ if env is None else env)
|
|
279
|
+
pp = [str(Path(cwd) / p) for p in paths]
|
|
280
|
+
if e.get("PYTHONPATH"):
|
|
281
|
+
pp.append(e["PYTHONPATH"])
|
|
282
|
+
e["PYTHONPATH"] = os.pathsep.join(pp)
|
|
283
|
+
e.setdefault("PYTHONDONTWRITEBYTECODE", "1")
|
|
284
|
+
# mutgate OWNS three flags: -rfE (the summary it reads), no:cacheprovider (a clean
|
|
285
|
+
# sandbox) and --maxfail=0 AFTER every user argument, because a contract needs the whole
|
|
286
|
+
# failure set and a project's addopts "-x" / "--maxfail=N" would truncate it to a
|
|
287
|
+
# consistent-looking single failure (review 2026-09-07, second round)
|
|
288
|
+
cmd = [python, "-m", "pytest", "-q", "-rfE", "-p", "no:cacheprovider", "--no-header",
|
|
289
|
+
*extra_args, "--maxfail=0", *tests]
|
|
290
|
+
try:
|
|
291
|
+
proc = subprocess.run(cmd, cwd=str(cwd), env=e, capture_output=True, text=True, timeout=timeout)
|
|
292
|
+
except subprocess.TimeoutExpired as exc:
|
|
293
|
+
out = (exc.stdout or b"")
|
|
294
|
+
out = out.decode("utf-8", "replace") if isinstance(out, bytes) else out
|
|
295
|
+
return TIMEOUT, [], f"pytest exceeded {timeout} s\n" + "\n".join(out.strip().splitlines()[-25:])
|
|
296
|
+
failed = []
|
|
297
|
+
for line in proc.stdout.splitlines():
|
|
298
|
+
if line.startswith("FAILED ") or line.startswith("ERROR "):
|
|
299
|
+
nid = _node_id(line.split(" ", 1)[1])
|
|
300
|
+
# "ERROR tests/x.py" with no "::" is a COLLECTION error (the file did not
|
|
301
|
+
# import), not a red test; it is reported through the return code instead
|
|
302
|
+
if line.startswith("ERROR ") and "::" not in nid:
|
|
303
|
+
continue
|
|
304
|
+
if nid and nid not in failed:
|
|
305
|
+
failed.append(nid)
|
|
306
|
+
tail = (proc.stdout + proc.stderr).strip().splitlines()[-25:]
|
|
307
|
+
if _CUT_SHORT_LINE.search(proc.stdout):
|
|
308
|
+
# the belt: pytest announces a truncated run ("!!! stopping after N failures !!!");
|
|
309
|
+
# a conftest setting config.option.maxfail can get past the flag above. Anchored to
|
|
310
|
+
# a whole line at column 0: a target's own captured output can quote the phrase
|
|
311
|
+
# (mutgate's suite does), and that must not read as a truncated run
|
|
312
|
+
return CUT_SHORT, failed, "the run was cut short (maxfail); the fired set is incomplete\n" + "\n".join(tail)
|
|
313
|
+
return proc.returncode, failed, "\n".join(tail)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------------------
|
|
317
|
+
# the contract check
|
|
318
|
+
# ---------------------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
_NO_SUMMARY = ("pytest reported failures but none could be read from its short summary; is "
|
|
321
|
+
"the summary suppressed (addopts --no-summary / -p no:terminal)? mutgate needs -rfE")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _classify(m: Mutation, rc: int, fired: list[str], tail: str) -> Verdict:
|
|
325
|
+
fired_t = tuple(fired)
|
|
326
|
+
if rc in (TIMEOUT, CUT_SHORT):
|
|
327
|
+
return Verdict(m, "ERROR", fired_t, detail=tail)
|
|
328
|
+
if rc == 1 and not fired:
|
|
329
|
+
# the gate that cannot fail, in the classifier itself: an invisible mutation that
|
|
330
|
+
# broke the suite would read OK, a firing one DECORATION (review 2026-09-07, item 2)
|
|
331
|
+
return Verdict(m, "ERROR", fired_t, detail=f"{_NO_SUMMARY}\n{tail}")
|
|
332
|
+
if rc not in (0, 1):
|
|
333
|
+
# only 0 and 1 mean the test loop ran to completion. An interrupted run (exit 2)
|
|
334
|
+
# can carry a PARTIAL fired set -- a conftest setting session.shouldstop after the
|
|
335
|
+
# first failure gives one failure in the summary and "!!! Interrupted !!!", not the
|
|
336
|
+
# maxfail line -- and a partial set is never a verdict (review, third round)
|
|
337
|
+
why = {2: "interrupted, or collection failed (does the mutated file still compile?); "
|
|
338
|
+
"any fired set is partial",
|
|
339
|
+
3: "pytest internal error", 4: "pytest usage error", 5: "no tests collected"}.get(rc, f"pytest exit {rc}")
|
|
340
|
+
return Verdict(m, "ERROR", fired_t, detail=f"{why}\n{tail}")
|
|
341
|
+
if m.invisible:
|
|
342
|
+
return Verdict(m, "VISIBLE" if fired else "OK", fired_t)
|
|
343
|
+
missing = tuple(e for e in m.fires if not any(e in f for f in fired))
|
|
344
|
+
allowed = m.fires + m.may_fire
|
|
345
|
+
unexpected = tuple(f for f in fired if not any(e in f for e in allowed))
|
|
346
|
+
if missing:
|
|
347
|
+
return Verdict(m, "DECORATION", fired_t, missing, unexpected)
|
|
348
|
+
if unexpected:
|
|
349
|
+
return Verdict(m, "OVERREACH", fired_t, missing, unexpected)
|
|
350
|
+
return Verdict(m, "OK", fired_t)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def run(mutations: Iterable[Mutation], root: Path, tests: Sequence[str],
|
|
354
|
+
python: str = sys.executable, paths: Sequence[str] = ("src", "."),
|
|
355
|
+
only: Optional[Sequence[str]] = None, keep: bool = False, stop_early: bool = False,
|
|
356
|
+
extra_pytest_args: Sequence[str] = (), log=None, timeout: Optional[float] = None) -> Report:
|
|
357
|
+
"""Apply each mutation in a sandbox copy of `root`, run `tests`, and judge the contract.
|
|
358
|
+
|
|
359
|
+
The baseline (no mutation) runs first; if it is red the report says so and nothing is
|
|
360
|
+
mutated. Each mutation is applied, run, and restored before the next."""
|
|
361
|
+
root = Path(root).resolve()
|
|
362
|
+
tests = tuple(tests)
|
|
363
|
+
report = Report(root=root, tests=tests)
|
|
364
|
+
mutations = list(mutations)
|
|
365
|
+
if only is not None:
|
|
366
|
+
known = {m.name for m in mutations}
|
|
367
|
+
unknown = [n for n in only if n not in known]
|
|
368
|
+
if unknown:
|
|
369
|
+
raise ValueError(f"--only names no declared mutation: {unknown}")
|
|
370
|
+
mutations = [m for m in mutations if m.name in only]
|
|
371
|
+
if not mutations:
|
|
372
|
+
raise ValueError("no mutations to run: an emptied declaration must not pass green")
|
|
373
|
+
log = log or (lambda s: None)
|
|
374
|
+
sb = Sandbox(root, keep=keep)
|
|
375
|
+
try:
|
|
376
|
+
log(f"sandbox {sb.dir}")
|
|
377
|
+
rc, failed, tail = run_pytest(sb.dir, tests, python, paths, extra_pytest_args, timeout=timeout)
|
|
378
|
+
if rc not in (0, 1) or (rc == 1 and not failed):
|
|
379
|
+
note = _NO_SUMMARY if rc == 1 else ""
|
|
380
|
+
report.baseline_error = f"baseline could not run (pytest exit {rc}) {note}\n{tail}"
|
|
381
|
+
return report
|
|
382
|
+
if failed:
|
|
383
|
+
report.baseline_failed = tuple(failed)
|
|
384
|
+
return report
|
|
385
|
+
log("baseline green")
|
|
386
|
+
for m in mutations:
|
|
387
|
+
applied, original, detail = sb.apply(m)
|
|
388
|
+
if not applied:
|
|
389
|
+
v = Verdict(m, "NOT_APPLIED", detail=detail)
|
|
390
|
+
else:
|
|
391
|
+
try:
|
|
392
|
+
rc, failed, tail = run_pytest(sb.dir, tests, python, paths, extra_pytest_args,
|
|
393
|
+
timeout=timeout)
|
|
394
|
+
finally:
|
|
395
|
+
sb.restore(m, original)
|
|
396
|
+
v = _classify(m, rc, failed, tail)
|
|
397
|
+
report.verdicts.append(v)
|
|
398
|
+
log(f"{m.name}: {v.status} ({len(v.fired)} fired)")
|
|
399
|
+
if stop_early and not v.ok:
|
|
400
|
+
break
|
|
401
|
+
finally:
|
|
402
|
+
sb.close()
|
|
403
|
+
return report
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------------------
|
|
407
|
+
# the declaration file
|
|
408
|
+
# ---------------------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
@dataclass(frozen=True)
|
|
411
|
+
class Declaration:
|
|
412
|
+
mutations: tuple[Mutation, ...]
|
|
413
|
+
root: Path
|
|
414
|
+
tests: tuple[str, ...]
|
|
415
|
+
paths: tuple[str, ...]
|
|
416
|
+
python: Optional[str] = None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _find_root(start: Path) -> Path:
|
|
420
|
+
for p in [start, *start.parents]:
|
|
421
|
+
if (p / "pyproject.toml").is_file() or (p / ".git").exists():
|
|
422
|
+
return p
|
|
423
|
+
return start
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def load(path: Path) -> Declaration:
|
|
427
|
+
"""Import a mutations file. It must define `MUTATIONS` (a sequence of `Mutation`);
|
|
428
|
+
optionally `TESTS` (pytest targets, default the file's own directory), `PATHS`
|
|
429
|
+
(PYTHONPATH entries relative to the root, default ("src", ".")), `ROOT` (relative to the
|
|
430
|
+
declaration file; default: the nearest ancestor holding pyproject.toml or .git) and
|
|
431
|
+
`PYTHON` (interpreter)."""
|
|
432
|
+
path = Path(path).resolve()
|
|
433
|
+
spec = importlib.util.spec_from_file_location(f"_mutgate_decl_{path.stem}", path)
|
|
434
|
+
if spec is None or spec.loader is None:
|
|
435
|
+
raise ValueError(f"cannot import {path}")
|
|
436
|
+
mod = importlib.util.module_from_spec(spec)
|
|
437
|
+
spec.loader.exec_module(mod)
|
|
438
|
+
if not hasattr(mod, "MUTATIONS"):
|
|
439
|
+
raise ValueError(f"{path} defines no MUTATIONS")
|
|
440
|
+
muts = tuple(mod.MUTATIONS)
|
|
441
|
+
for m in muts:
|
|
442
|
+
if not isinstance(m, Mutation):
|
|
443
|
+
raise ValueError(f"{path}: MUTATIONS holds a {type(m).__name__}, not a Mutation")
|
|
444
|
+
names = [m.name for m in muts]
|
|
445
|
+
dupes = sorted({n for n in names if names.count(n) > 1})
|
|
446
|
+
if dupes:
|
|
447
|
+
raise ValueError(f"{path}: duplicate mutation names {dupes}")
|
|
448
|
+
root_decl = getattr(mod, "ROOT", None)
|
|
449
|
+
root = (_find_root(path.parent) if root_decl is None else (path.parent / root_decl)).resolve()
|
|
450
|
+
tests_default = str(path.parent.relative_to(root)) if root in path.parent.parents or root == path.parent else "tests"
|
|
451
|
+
tests = tuple(getattr(mod, "TESTS", (tests_default,)))
|
|
452
|
+
paths = tuple(getattr(mod, "PATHS", ("src", ".")))
|
|
453
|
+
return Declaration(muts, root, tests, paths, getattr(mod, "PYTHON", None))
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mutgate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Named mutations as contracts on a test suite: each mutation must turn exactly the tests it names red, or nothing at all.
|
|
5
|
+
Project-URL: Homepage, https://github.com/JimGalasyn/mutgate
|
|
6
|
+
Project-URL: Issues, https://github.com/JimGalasyn/mutgate/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/JimGalasyn/mutgate/blob/main/CHANGELOG.md
|
|
8
|
+
Author: James P. Galasyn, Claude Théodore
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: contracts,mutation-testing,pytest,reproducibility,scientific-software,test-quality,testing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Testing
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest-cov>=4; extra == 'test'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# mutgate
|
|
31
|
+
|
|
32
|
+
**Named mutations as contracts on a test suite.** Each mutation is a deliberate, named
|
|
33
|
+
change to the code under test, together with the list of tests it must turn red — or the
|
|
34
|
+
statement that it must turn nothing red. `mutgate` applies each one in a sandbox copy, runs
|
|
35
|
+
the suite, and judges the contract.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
# tests/mutations.py
|
|
39
|
+
from mutgate import Mutation
|
|
40
|
+
|
|
41
|
+
MUTATIONS = [
|
|
42
|
+
Mutation("keep-larger", file="pkg/engine.py",
|
|
43
|
+
old="if (birth[a], -a) >= (birth[b], -b):",
|
|
44
|
+
new="if size[a] >= size[b]:",
|
|
45
|
+
fires=("TestElderRule",)),
|
|
46
|
+
Mutation("skip-absolute-floor", file="pkg/engine.py",
|
|
47
|
+
old="if u.birth < abs_floor:", new="if False:",
|
|
48
|
+
fires=("TestAbsoluteFloor",)),
|
|
49
|
+
Mutation("tie-to-higher-index", file="pkg/engine.py",
|
|
50
|
+
old="(birth[a], -a) >= (birth[b], -b)",
|
|
51
|
+
new="(birth[a], a) >= (birth[b], b)",
|
|
52
|
+
invisible=True), # an invariance: the measurement must not depend on it
|
|
53
|
+
]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
$ mutgate run tests/mutations.py
|
|
58
|
+
mutation verdict fired expected
|
|
59
|
+
keep-larger OK 3 TestElderRule
|
|
60
|
+
skip-absolute-floor DECORATION 0 TestAbsoluteFloor
|
|
61
|
+
did not fire: TestAbsoluteFloor
|
|
62
|
+
tie-to-higher-index OK 0 nothing
|
|
63
|
+
|
|
64
|
+
2 OK, 1 DECORATION
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Why
|
|
68
|
+
|
|
69
|
+
A green test suite guards only the defects that shaped it. The usual way to find out
|
|
70
|
+
whether a test is load-bearing is to break the code on purpose and see whether the test
|
|
71
|
+
notices — and in practice that is done by hand, in a scratch copy, with `sed`, once, and then
|
|
72
|
+
the result is written into a design note as prose. `mutgate` makes that a checked artefact:
|
|
73
|
+
|
|
74
|
+
- **the mutation is named and exact.** `old` must occur exactly `count` times in the file,
|
|
75
|
+
or the verdict is `NOT_APPLIED`. A mutation that silently does not land looks identical
|
|
76
|
+
to a working guard; this is the trap that motivated the tool.
|
|
77
|
+
- **the contract says which tests must fire.** A named test that does not fire is a
|
|
78
|
+
`DECORATION` — a guard that would not go red.
|
|
79
|
+
- **a test outside the contract firing is a finding, not noise.** `OVERREACH` means the
|
|
80
|
+
mutation reaches further than its author believed. When a convention (a tie-breaking rule,
|
|
81
|
+
a sign, an ordering) is changed and tests that should only *relabel* things fail on
|
|
82
|
+
*values*, the convention exists in two places that have drifted apart.
|
|
83
|
+
- **an invariance is a mutation that must fire nothing.** `invisible=True` pins that the
|
|
84
|
+
suite passes unchanged under the alternative convention; if it does not, the verdict is
|
|
85
|
+
`VISIBLE`. ⚠ An `OK` on an invariance is a *negative* result, and it is only as strong as
|
|
86
|
+
the evidence that the mutated line ran: a dead function, a site the named `TESTS` never
|
|
87
|
+
reach, or a suite that imports an installed copy instead of the sandbox all read `OK`
|
|
88
|
+
honestly. **Pair every invariance with a firing mutation at the same site in the same
|
|
89
|
+
declaration** — the known-positive control — so the file itself proves the site is
|
|
90
|
+
exercised by these tests. An unpaired `OK` on an invariance is unevidenced.
|
|
91
|
+
- **the working tree is never touched.** Each mutation is applied and restored in a
|
|
92
|
+
temporary copy (`git ls-files`, so uncommitted work is included and the venv is not), and
|
|
93
|
+
the copy is put first on `PYTHONPATH` so an editable install elsewhere cannot shadow it.
|
|
94
|
+
- **a red baseline aborts.** Mutations mean nothing on a suite that already fails.
|
|
95
|
+
- **a run whose failures cannot be read is an error, never a verdict.** If the project's
|
|
96
|
+
pytest configuration suppresses the short summary (`--no-summary`), pytest's exit code
|
|
97
|
+
says "failed" while nothing parses; that is reported as `ERROR`, not silently as `OK`.
|
|
98
|
+
And a run cut short is never a verdict either: `mutgate` owns `--maxfail=0` after every
|
|
99
|
+
user argument (a project's `-x` would truncate the fired set to one test), and a
|
|
100
|
+
`conftest` that forces `maxfail` anyway trips pytest's own "stopping after N failures"
|
|
101
|
+
line, which reads as `ERROR`.
|
|
102
|
+
|
|
103
|
+
The sandbox holds the project's files but not its `.git`; a suite that shells out to git
|
|
104
|
+
(a `setuptools_scm`-style version check, say) goes red at baseline and says so.
|
|
105
|
+
|
|
106
|
+
## What it is not
|
|
107
|
+
|
|
108
|
+
It is not automatic mutation testing. Tools like `mutmut` and `cosmic-ray` generate operator
|
|
109
|
+
mutations at random and report a kill rate; that answers "how thorough is this suite?".
|
|
110
|
+
`mutgate` answers a narrower question that random mutation cannot: *does this specific guard
|
|
111
|
+
fire under the specific defect it was written for, and only that?* The two are complementary.
|
|
112
|
+
|
|
113
|
+
## Declaration file
|
|
114
|
+
|
|
115
|
+
A Python file (conventionally `tests/mutations.py`) defining:
|
|
116
|
+
|
|
117
|
+
| name | required | meaning |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `MUTATIONS` | yes | a sequence of `Mutation` |
|
|
120
|
+
| `TESTS` | no | pytest targets; default: the declaration file's own directory |
|
|
121
|
+
| `PATHS` | no | `PYTHONPATH` entries relative to the root; default `("src", ".")` |
|
|
122
|
+
| `ROOT` | no | project root, relative to the declaration file; default: the nearest ancestor with `pyproject.toml` or `.git` |
|
|
123
|
+
| `PYTHON` | no | interpreter to run pytest with; default: the one running `mutgate` |
|
|
124
|
+
|
|
125
|
+
`Mutation(name, file, old, new, fires=(), may_fire=(), invisible=False, count=1, note="")`.
|
|
126
|
+
Entries in `fires` and `may_fire` are substrings of pytest node ids, so `"TestElderRule"`,
|
|
127
|
+
`"test_engine.py::TestElderRule"` and `"test_tie_goes_to_lower_index"` all work.
|
|
128
|
+
|
|
129
|
+
## CLI
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
mutgate run tests/mutations.py [--tests T ...] [--only NAME ...] [--python PY]
|
|
133
|
+
[--pytest-arg ARG] [--markdown] [--keep] [-x] [-v]
|
|
134
|
+
mutgate list tests/mutations.py
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`--markdown` prints a table meant to be pasted into a design note's build record. Exit code
|
|
138
|
+
0 when every contract holds, 1 when a verdict is not `OK`, 2 when the baseline is red or the
|
|
139
|
+
file cannot be loaded.
|
|
140
|
+
|
|
141
|
+
## In CI
|
|
142
|
+
|
|
143
|
+
```yaml
|
|
144
|
+
- run: pip install mutgate
|
|
145
|
+
- run: mutgate run tests/mutations.py
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Each mutation runs the named tests once, so the cost is the suite's cost times the number of
|
|
149
|
+
mutations; point `TESTS` at the module the mutations concern.
|
|
150
|
+
|
|
151
|
+
## Install
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
pip install mutgate
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
No runtime dependencies. Python 3.10+. MIT.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
mutgate/__init__.py,sha256=owlSDq3ktIA-dvpIMBbJ51hI2gPBAkgB9QszrrgvUSQ,1022
|
|
2
|
+
mutgate/__main__.py,sha256=bV6F-JJz7HFmmQqzfQ-_u__AOs-LNYme3uFK7e9FtWE,36
|
|
3
|
+
mutgate/cli.py,sha256=DdZdLlPhGrf1dQmcSRIxzNycqJIA8_oTuAhYCErpT8E,3627
|
|
4
|
+
mutgate/core.py,sha256=dajh1vYNsR02VwHxI0HLJdYw2bdG7Cb2zsvr4PwR1-s,21284
|
|
5
|
+
mutgate-0.1.0.dist-info/METADATA,sha256=d3e9Zf_SiUQJ8dr5-eTBQCPdTBLjSA0_FrsMXaTnW0o,7491
|
|
6
|
+
mutgate-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
7
|
+
mutgate-0.1.0.dist-info/entry_points.txt,sha256=dPl0jY2PgdRhTNUdDCM_m3su4fFwBQfRVxzLPag8Br8,48
|
|
8
|
+
mutgate-0.1.0.dist-info/licenses/LICENSE,sha256=pXi-REGP6qna7DadI_rzujveoXI4u3PxyD-Af-G_XPc,1073
|
|
9
|
+
mutgate-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James P. Galasyn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|