pysqlmut 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.
- pysqlmut/__init__.py +1 -0
- pysqlmut/accepted.py +40 -0
- pysqlmut/cli.py +347 -0
- pysqlmut/config.py +149 -0
- pysqlmut/mutants.py +186 -0
- pysqlmut/operators.py +575 -0
- pysqlmut/pytest_worker.py +143 -0
- pysqlmut/report.py +78 -0
- pysqlmut/runner.py +426 -0
- pysqlmut/source.py +139 -0
- pysqlmut-0.1.0.dist-info/METADATA +244 -0
- pysqlmut-0.1.0.dist-info/RECORD +15 -0
- pysqlmut-0.1.0.dist-info/WHEEL +4 -0
- pysqlmut-0.1.0.dist-info/entry_points.txt +3 -0
- pysqlmut-0.1.0.dist-info/licenses/LICENSE +21 -0
pysqlmut/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Mutation testing for SQL."""
|
pysqlmut/accepted.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Survivors a team has reviewed and accepted, so later runs neither rerun nor report them.
|
|
2
|
+
|
|
3
|
+
A survivor is identified by its file, operator, description and the exact line before and after the change,
|
|
4
|
+
not by its line number, so accepting it survives edits elsewhere in the file. Identical lines in one file share
|
|
5
|
+
an identity and are accepted together.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pysqlmut.runner import Result
|
|
15
|
+
|
|
16
|
+
Fingerprint = tuple[str, str, str, str, str]
|
|
17
|
+
_FIELDS = ("path", "operator", "description", "before", "after")
|
|
18
|
+
_VERSION = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fingerprint(path: str, operator: str, description: str, before: str, after: str) -> Fingerprint:
|
|
22
|
+
return (path, operator, description, before.strip(), after.strip())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def of_result(result: "Result") -> Fingerprint:
|
|
26
|
+
return fingerprint(result.path, result.operator, result.description, result.before, result.after)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load(path: Path) -> set[Fingerprint]:
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return set()
|
|
32
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
if not isinstance(data, dict) or data.get("version") != _VERSION:
|
|
34
|
+
raise ValueError(f"{path} is not a pysqlmut accepted survivors file of version {_VERSION}")
|
|
35
|
+
return {fingerprint(*(item[field] for field in _FIELDS)) for item in data["accepted"]}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def save(path: Path, fingerprints: Iterable[Fingerprint]) -> None:
|
|
39
|
+
items = [dict(zip(_FIELDS, item, strict=True)) for item in sorted(set(fingerprints))]
|
|
40
|
+
path.write_text(json.dumps({"version": _VERSION, "accepted": items}, indent=2) + "\n", encoding="utf-8")
|
pysqlmut/cli.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 when every mutant was caught or accepted, 1 when mutants survived, were not covered, timed out or
|
|
4
|
+
ended in an error, 2 on a usage or settings error, 3 when the tests cannot tell mutants apart (they fail on the
|
|
5
|
+
unchanged project, or read the project instead of its copy), 4 when pysqlmut itself fails, and 130 when a run
|
|
6
|
+
is interrupted.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import shlex
|
|
10
|
+
import signal
|
|
11
|
+
import sys
|
|
12
|
+
import traceback
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
16
|
+
from contextlib import nullcontext
|
|
17
|
+
from dataclasses import replace
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated, NoReturn
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from sqlglot.dialects.dialect import Dialect
|
|
23
|
+
from sqlglot.errors import SqlglotError
|
|
24
|
+
|
|
25
|
+
from pysqlmut import accepted, report
|
|
26
|
+
from pysqlmut.config import Config, load_config
|
|
27
|
+
from pysqlmut.mutants import Generation, generate
|
|
28
|
+
from pysqlmut.operators import ALL_OPERATORS
|
|
29
|
+
from pysqlmut.runner import (
|
|
30
|
+
ACCEPTED,
|
|
31
|
+
CAUGHT,
|
|
32
|
+
ERROR,
|
|
33
|
+
NOT_COVERED,
|
|
34
|
+
SURVIVED,
|
|
35
|
+
TIMEOUT,
|
|
36
|
+
Result,
|
|
37
|
+
SetupError,
|
|
38
|
+
run,
|
|
39
|
+
)
|
|
40
|
+
from pysqlmut.source import SqlSource
|
|
41
|
+
|
|
42
|
+
_STATUSES = (CAUGHT, SURVIVED, NOT_COVERED, ACCEPTED, TIMEOUT, ERROR)
|
|
43
|
+
_DEFAULT_ACCEPTED = "pysqlmut-accepted.json"
|
|
44
|
+
EXIT_FINDINGS, EXIT_USAGE, EXIT_SETUP, EXIT_CRASH, EXIT_INTERRUPTED = 1, 2, 3, 4, 130
|
|
45
|
+
|
|
46
|
+
app = typer.Typer(
|
|
47
|
+
name="pysqlmut", help="Mutation testing for SQL.", no_args_is_help=True, pretty_exceptions_enable=False
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
Files = Annotated[
|
|
51
|
+
list[Path] | None,
|
|
52
|
+
# The backslash stops rich markup from reading [tool.pysqlmut] as a style tag.
|
|
53
|
+
typer.Argument(help="SQL files. Default: the files setting in \\[tool.pysqlmut].", show_default=False),
|
|
54
|
+
]
|
|
55
|
+
Dialect_ = Annotated[str | None, typer.Option("--dialect", help="sqlglot dialect, e.g. snowflake, duckdb, bigquery.")]
|
|
56
|
+
Operators = Annotated[str | None, typer.Option(help=f"Comma-separated subset of: {', '.join(ALL_OPERATORS)}.")]
|
|
57
|
+
Project = Annotated[Path, typer.Option(help="Project root.", exists=True, file_okay=False)]
|
|
58
|
+
AcceptedFile = Annotated[
|
|
59
|
+
Path | None,
|
|
60
|
+
typer.Option(
|
|
61
|
+
"--accepted",
|
|
62
|
+
help=f"Accepted survivors file. Default: the accepted setting, or {_DEFAULT_ACCEPTED} in the project.",
|
|
63
|
+
),
|
|
64
|
+
]
|
|
65
|
+
Workers = Annotated[int | None, typer.Option(min=1, help="Processes that verify mutants, then parallel test runs.")]
|
|
66
|
+
Top = Annotated[int, typer.Option(min=0, help="How many groups to print.")]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fail(message: str, code: int = EXIT_USAGE) -> NoReturn:
|
|
70
|
+
typer.echo(f"pysqlmut: {message}", err=True)
|
|
71
|
+
raise typer.Exit(code)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _names(value: str) -> tuple[str, ...]:
|
|
75
|
+
return tuple(name.strip() for name in value.split(",") if name.strip())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _config(project: Path, dialect: str | None, operators: str | None) -> Config:
|
|
79
|
+
try:
|
|
80
|
+
config = load_config(project)
|
|
81
|
+
if dialect:
|
|
82
|
+
config = replace(config, dialect=dialect)
|
|
83
|
+
if operators is not None:
|
|
84
|
+
config = replace(config, operators=_names(operators))
|
|
85
|
+
except ValueError as error:
|
|
86
|
+
_fail(str(error))
|
|
87
|
+
return config
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _shown(path: Path) -> str:
|
|
91
|
+
"""A path relative to the working directory when it lies below it."""
|
|
92
|
+
resolved, here = path.resolve(), Path.cwd().resolve()
|
|
93
|
+
return str(resolved.relative_to(here)) if resolved.is_relative_to(here) else str(path)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _accepted_path(project: Path, option: Path | None, config: Config) -> Path:
|
|
97
|
+
"""An --accepted path is relative to the working directory, a setting relative to the project."""
|
|
98
|
+
return option if option is not None else project / (config.accepted or _DEFAULT_ACCEPTED)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _generations(files: list[Path] | None, project: Path, config: Config, workers: int) -> Iterator[Generation]:
|
|
102
|
+
if config.dialect is None:
|
|
103
|
+
_fail("no dialect: pass --dialect or set dialect in [tool.pysqlmut]")
|
|
104
|
+
try:
|
|
105
|
+
Dialect.get_or_raise(config.dialect)
|
|
106
|
+
except ValueError as error:
|
|
107
|
+
_fail(str(error))
|
|
108
|
+
project = project.resolve()
|
|
109
|
+
paths = []
|
|
110
|
+
for path in files or config.files_in(project):
|
|
111
|
+
resolved = path.resolve()
|
|
112
|
+
if not resolved.is_file():
|
|
113
|
+
_fail(f"{path}: no such file")
|
|
114
|
+
if not resolved.is_relative_to(project):
|
|
115
|
+
_fail(f"{path} is outside the project {project}; pass --project")
|
|
116
|
+
if resolved not in paths:
|
|
117
|
+
paths.append(resolved)
|
|
118
|
+
if not paths:
|
|
119
|
+
_fail("no SQL files: pass them or set files in [tool.pysqlmut]")
|
|
120
|
+
with ProcessPoolExecutor(workers) if workers > 1 else nullcontext() as executor:
|
|
121
|
+
for path in paths:
|
|
122
|
+
try:
|
|
123
|
+
source = SqlSource.read(path, config.dialect)
|
|
124
|
+
except (SqlglotError, UnicodeDecodeError) as error:
|
|
125
|
+
typer.echo(f"{path}: skipped, cannot read it: {error}", err=True)
|
|
126
|
+
continue
|
|
127
|
+
yield generate(source, config.operators_for(path.relative_to(project), project), executor)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def generate_command(
|
|
131
|
+
files: Files = None,
|
|
132
|
+
dialect: Dialect_ = None,
|
|
133
|
+
operators: Operators = None,
|
|
134
|
+
project: Project = Path(),
|
|
135
|
+
show: Annotated[bool, typer.Option(help="Print every mutant with its line before and after.")] = False,
|
|
136
|
+
workers: Workers = None,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Generate and verify the mutants of SQL files without running tests.
|
|
139
|
+
|
|
140
|
+
Mutants are not stored anywhere, so listing them means generating them.
|
|
141
|
+
"""
|
|
142
|
+
config = _config(project, dialect, operators)
|
|
143
|
+
produced: Counter[str] = Counter()
|
|
144
|
+
rejected: Counter[tuple[str, str]] = Counter()
|
|
145
|
+
for generation in _generations(files, project, config, workers if workers is not None else config.workers):
|
|
146
|
+
produced.update(m.operator for m in generation.mutants)
|
|
147
|
+
rejected.update(generation.rejected)
|
|
148
|
+
source = generation.source
|
|
149
|
+
typer.echo(f"{source.path}: {len(source.statements)} statements, {len(generation.mutants)} mutants")
|
|
150
|
+
if show:
|
|
151
|
+
for mutant in generation.mutants:
|
|
152
|
+
before, after = mutant.diff(source.text)
|
|
153
|
+
typer.echo(f" {mutant.line:5} {mutant.operator:15} {mutant.description}")
|
|
154
|
+
typer.echo(f" - {before.strip()}")
|
|
155
|
+
typer.echo(f" + {after.strip()}")
|
|
156
|
+
typer.echo("\nper operator: mutants / rejected")
|
|
157
|
+
for name in ALL_OPERATORS:
|
|
158
|
+
reasons = {reason: count for (operator, reason), count in rejected.items() if operator == name}
|
|
159
|
+
if produced[name] or reasons:
|
|
160
|
+
typer.echo(f" {name:15} {produced[name]:5} / {sum(reasons.values()):<5} {reasons or ''}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
app.command("generate")(generate_command)
|
|
164
|
+
app.command("list", help="Alias for generate.")(generate_command)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _interrupt(signum: int, frame: object) -> NoReturn:
|
|
168
|
+
raise KeyboardInterrupt
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.command("run")
|
|
172
|
+
def run_command(
|
|
173
|
+
files: Files = None,
|
|
174
|
+
dialect: Dialect_ = None,
|
|
175
|
+
operators: Operators = None,
|
|
176
|
+
project: Project = Path(),
|
|
177
|
+
command: Annotated[
|
|
178
|
+
str | None,
|
|
179
|
+
typer.Option(
|
|
180
|
+
help="Shell command that runs your tests, e.g. 'uv run pytest' or 'python -m unittest'; started anew for "
|
|
181
|
+
"every mutant.",
|
|
182
|
+
),
|
|
183
|
+
] = None,
|
|
184
|
+
pytest: Annotated[
|
|
185
|
+
str | None,
|
|
186
|
+
typer.Option(
|
|
187
|
+
metavar="PYTHON",
|
|
188
|
+
help="Command that starts your project's Python, where pytest and your dependencies are installed, e.g. "
|
|
189
|
+
"'uv run python' or '.venv/bin/python'. Keeps pytest running and runs only the tests that read the "
|
|
190
|
+
"mutated file.",
|
|
191
|
+
),
|
|
192
|
+
] = None,
|
|
193
|
+
tests: Annotated[list[str] | None, typer.Option(help="Pytest path to collect; repeatable.")] = None,
|
|
194
|
+
pytest_args: Annotated[str | None, typer.Option(help="Extra pytest options.")] = None,
|
|
195
|
+
workers: Workers = None,
|
|
196
|
+
timeout: Annotated[float | None, typer.Option(min=1.0, help="Seconds before a run counts as timeout.")] = None,
|
|
197
|
+
accepted_file: AcceptedFile = None,
|
|
198
|
+
report_file: Annotated[Path | None, typer.Option("--report", help="Write every result as JSON.")] = None,
|
|
199
|
+
top: Top = 30,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Run the tests against every mutant.
|
|
202
|
+
|
|
203
|
+
Exit codes: 0 when every mutant was caught or accepted; 1 when mutants survived, were not covered, timed out or
|
|
204
|
+
ended in an error; 2 on a usage or settings error; 3 when the tests fail on the unchanged project or read the
|
|
205
|
+
project instead of its copy; 130 when interrupted.
|
|
206
|
+
"""
|
|
207
|
+
if command is not None and pytest is not None:
|
|
208
|
+
_fail("give either --command or --pytest, not both")
|
|
209
|
+
config = _config(project, dialect, operators)
|
|
210
|
+
command = command if command is not None else None if pytest is not None else config.command
|
|
211
|
+
pytest_python = pytest if pytest is not None else None if command is not None else config.pytest_python
|
|
212
|
+
if (command is None) == (pytest_python is None):
|
|
213
|
+
_fail("give --command or --pytest, or set command or [tool.pysqlmut.pytest] python")
|
|
214
|
+
project = project.resolve()
|
|
215
|
+
workers = workers if workers is not None else config.workers
|
|
216
|
+
# The generation processes end before the test workers start.
|
|
217
|
+
mutants = [mutant for generation in _generations(files, project, config, workers) for mutant in generation.mutants]
|
|
218
|
+
try:
|
|
219
|
+
known = accepted.load(_accepted_path(project, accepted_file, config))
|
|
220
|
+
except ValueError as error:
|
|
221
|
+
_fail(str(error))
|
|
222
|
+
how = command or f"pytest workers ({pytest_python})"
|
|
223
|
+
typer.echo(f"{len(mutants)} mutants, {workers} worker{'' if workers == 1 else 's'}: {how}")
|
|
224
|
+
|
|
225
|
+
done = 0
|
|
226
|
+
|
|
227
|
+
def progress(result: Result) -> None:
|
|
228
|
+
nonlocal done
|
|
229
|
+
done += 1
|
|
230
|
+
if done % 50 == 0 or done == len(mutants):
|
|
231
|
+
typer.echo(f" {done}/{len(mutants)}")
|
|
232
|
+
|
|
233
|
+
previous = signal.signal(signal.SIGTERM, _interrupt)
|
|
234
|
+
try:
|
|
235
|
+
results = run(
|
|
236
|
+
mutants,
|
|
237
|
+
project,
|
|
238
|
+
command=command,
|
|
239
|
+
pytest_python=pytest_python,
|
|
240
|
+
tests=tests if tests is not None else config.tests,
|
|
241
|
+
pytest_args=shlex.split(pytest_args) if pytest_args is not None else config.pytest_args,
|
|
242
|
+
workers=workers,
|
|
243
|
+
timeout=timeout if timeout is not None else config.timeout,
|
|
244
|
+
accepted=known,
|
|
245
|
+
progress=progress,
|
|
246
|
+
)
|
|
247
|
+
except SetupError as error:
|
|
248
|
+
_fail(str(error), EXIT_SETUP)
|
|
249
|
+
except ValueError as error:
|
|
250
|
+
_fail(str(error))
|
|
251
|
+
except KeyboardInterrupt:
|
|
252
|
+
_fail("interrupted; the project is unchanged", EXIT_INTERRUPTED)
|
|
253
|
+
finally:
|
|
254
|
+
signal.signal(signal.SIGTERM, previous)
|
|
255
|
+
if report_file:
|
|
256
|
+
report.save(results, report_file)
|
|
257
|
+
if _summarize(results, top):
|
|
258
|
+
raise typer.Exit(EXIT_FINDINGS)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _summarize(results: list[Result], top: int) -> bool:
|
|
262
|
+
"""Print the counts and the survivor groups; true when anything needs attention."""
|
|
263
|
+
counts: Counter[tuple[str, str]] = Counter((r.operator, r.status) for r in results)
|
|
264
|
+
typer.echo("\nper operator: " + " / ".join(_STATUSES))
|
|
265
|
+
for name in sorted({r.operator for r in results}):
|
|
266
|
+
typer.echo(f" {name:15} " + " / ".join(f"{counts[name, status]:5}" for status in _STATUSES))
|
|
267
|
+
survivors = [r for r in results if r.status in {SURVIVED, NOT_COVERED}]
|
|
268
|
+
groups = report.group(survivors)
|
|
269
|
+
typer.echo(f"\n{len(survivors)} of {len(results)} mutants survived or were not covered, in {len(groups)} groups")
|
|
270
|
+
if groups:
|
|
271
|
+
typer.echo(report.render(groups, top))
|
|
272
|
+
if any(r.status == NOT_COVERED for r in results):
|
|
273
|
+
typer.echo(
|
|
274
|
+
"\nNot covered means no test was seen reading the file. With --pytest, reads by subprocesses, "
|
|
275
|
+
"pytest-xdist workers or native code are not seen; --command runs every test instead."
|
|
276
|
+
)
|
|
277
|
+
problems = sum(r.status in {TIMEOUT, ERROR} for r in results)
|
|
278
|
+
if problems:
|
|
279
|
+
typer.echo(
|
|
280
|
+
f"\n{problems} mutants timed out or ended in an error: the tests did not finish, or pytest could not run "
|
|
281
|
+
"them (for example no tests collected). Run the tests on one of these mutants to see why."
|
|
282
|
+
)
|
|
283
|
+
return bool(survivors or problems)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _statuses(value: str) -> set[str]:
|
|
287
|
+
statuses = set(_names(value))
|
|
288
|
+
unknown = sorted(statuses - set(_STATUSES))
|
|
289
|
+
if unknown:
|
|
290
|
+
_fail(f"unknown statuses: {', '.join(unknown)}; known: {', '.join(_STATUSES)}")
|
|
291
|
+
return statuses
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _load_report(path: Path) -> list[Result]:
|
|
295
|
+
try:
|
|
296
|
+
return report.load(path)
|
|
297
|
+
except ValueError as error:
|
|
298
|
+
_fail(str(error))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@app.command("report")
|
|
302
|
+
def report_command(
|
|
303
|
+
report_file: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
|
|
304
|
+
status: Annotated[str, typer.Option(help="Comma-separated statuses.")] = f"{SURVIVED},{NOT_COVERED}",
|
|
305
|
+
top: Top = 30,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Group the results of a JSON report by the code they changed."""
|
|
308
|
+
statuses = _statuses(status)
|
|
309
|
+
results = [r for r in _load_report(report_file) if r.status in statuses]
|
|
310
|
+
groups = report.group(results)
|
|
311
|
+
typer.echo(f"{len(results)} results with status {', '.join(sorted(statuses))}, in {len(groups)} groups")
|
|
312
|
+
typer.echo(report.render(groups, top))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@app.command("accept")
|
|
316
|
+
def accept_command(
|
|
317
|
+
report_file: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
|
|
318
|
+
operators: Annotated[
|
|
319
|
+
str | None, typer.Option(help="Accept only survivors of these comma-separated operators.")
|
|
320
|
+
] = None,
|
|
321
|
+
project: Project = Path(),
|
|
322
|
+
accepted_file: AcceptedFile = None,
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Accept the survivors of a JSON report after review; later runs neither rerun nor report them."""
|
|
325
|
+
chosen = set(_names(operators)) if operators is not None else None
|
|
326
|
+
survivors = [
|
|
327
|
+
r
|
|
328
|
+
for r in _load_report(report_file)
|
|
329
|
+
if r.status in {SURVIVED, NOT_COVERED} and (chosen is None or r.operator in chosen)
|
|
330
|
+
]
|
|
331
|
+
path = _accepted_path(project.resolve(), accepted_file, _config(project, None, None))
|
|
332
|
+
try:
|
|
333
|
+
known = accepted.load(path)
|
|
334
|
+
except ValueError as error:
|
|
335
|
+
_fail(str(error))
|
|
336
|
+
new = {accepted.of_result(r) for r in survivors} - known
|
|
337
|
+
accepted.save(path, known | new)
|
|
338
|
+
typer.echo(f"accepted {len(new)} more survivors; {_shown(path)} now holds {len(known | new)}")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def main() -> None:
|
|
342
|
+
try:
|
|
343
|
+
app()
|
|
344
|
+
except Exception: # noqa: BLE001 - any failure that reaches here is a bug in pysqlmut, reported with its trace
|
|
345
|
+
traceback.print_exc()
|
|
346
|
+
typer.echo("pysqlmut: internal error; please report it with the trace above", err=True)
|
|
347
|
+
sys.exit(EXIT_CRASH)
|
pysqlmut/config.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Settings from [tool.pysqlmut] in the project's pyproject.toml; the README lists them with an example.
|
|
2
|
+
|
|
3
|
+
Command line options override these settings. Paths in the settings are relative to the project.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pysqlmut.operators import ALL_OPERATORS
|
|
12
|
+
|
|
13
|
+
DEFAULT_WORKERS = 1
|
|
14
|
+
DEFAULT_TIMEOUT = 300.0
|
|
15
|
+
|
|
16
|
+
# Directories no mutant is taken from: the project copies share the virtual environment, and the rest is cache.
|
|
17
|
+
IGNORED_DIRECTORIES = (".git", ".venv", "__pycache__", ".pytest_cache", ".ruff_cache", "node_modules")
|
|
18
|
+
|
|
19
|
+
_KEYS = {
|
|
20
|
+
"dialect",
|
|
21
|
+
"files",
|
|
22
|
+
"operators",
|
|
23
|
+
"exclude-operators",
|
|
24
|
+
"file",
|
|
25
|
+
"accepted",
|
|
26
|
+
"command",
|
|
27
|
+
"pytest",
|
|
28
|
+
"workers",
|
|
29
|
+
"timeout",
|
|
30
|
+
}
|
|
31
|
+
_PYTEST_KEYS = {"python", "tests", "args"}
|
|
32
|
+
_FILE_KEYS = {"pattern", "exclude-operators"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class FileRule:
|
|
37
|
+
"""Operators left out for the files matching a glob pattern relative to the project."""
|
|
38
|
+
|
|
39
|
+
pattern: str
|
|
40
|
+
exclude_operators: tuple[str, ...] = ()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Config:
|
|
45
|
+
dialect: str | None = None
|
|
46
|
+
files: tuple[str, ...] = ()
|
|
47
|
+
# None means every operator.
|
|
48
|
+
operators: tuple[str, ...] | None = None
|
|
49
|
+
exclude_operators: tuple[str, ...] = ()
|
|
50
|
+
file_rules: tuple[FileRule, ...] = ()
|
|
51
|
+
accepted: str | None = None
|
|
52
|
+
command: str | None = None
|
|
53
|
+
pytest_python: str | None = None
|
|
54
|
+
tests: tuple[str, ...] = ()
|
|
55
|
+
pytest_args: tuple[str, ...] = ()
|
|
56
|
+
workers: int = DEFAULT_WORKERS
|
|
57
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
if self.operators is not None and not self.operators:
|
|
61
|
+
raise ValueError("operators names no operator; leave it out to run every operator")
|
|
62
|
+
named = [
|
|
63
|
+
*(self.operators or ()),
|
|
64
|
+
*self.exclude_operators,
|
|
65
|
+
*(name for rule in self.file_rules for name in rule.exclude_operators),
|
|
66
|
+
]
|
|
67
|
+
unknown = sorted(set(named) - set(ALL_OPERATORS))
|
|
68
|
+
if unknown:
|
|
69
|
+
raise ValueError(f"unknown operators: {', '.join(unknown)}; known: {', '.join(ALL_OPERATORS)}")
|
|
70
|
+
|
|
71
|
+
def files_in(self, project: Path) -> list[Path]:
|
|
72
|
+
"""The files the file patterns match, as absolute paths."""
|
|
73
|
+
return sorted({project / path for pattern in self.files for path in _glob(project.resolve(), pattern)})
|
|
74
|
+
|
|
75
|
+
def operators_for(self, relative: Path, project: Path) -> list[str]:
|
|
76
|
+
"""The operators to run on a file, given its path relative to the project."""
|
|
77
|
+
excluded = set(self.exclude_operators)
|
|
78
|
+
for rule in self.file_rules:
|
|
79
|
+
if relative in _glob(project.resolve(), rule.pattern):
|
|
80
|
+
excluded.update(rule.exclude_operators)
|
|
81
|
+
return [name for name in (self.operators or ALL_OPERATORS) if name not in excluded]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _glob(project: Path, pattern: str) -> frozenset[Path]:
|
|
85
|
+
"""The files a glob pattern matches, relative to the project, outside the ignored directories."""
|
|
86
|
+
matches = (path.relative_to(project) for path in project.glob(pattern) if path.is_file())
|
|
87
|
+
return frozenset(path for path in matches if not set(path.parts) & set(IGNORED_DIRECTORIES))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _check_keys(settings: dict[str, Any], known: set[str], table: str) -> None:
|
|
91
|
+
unknown = sorted(set(settings) - known)
|
|
92
|
+
if unknown:
|
|
93
|
+
raise ValueError(f"unknown {table} settings: {', '.join(unknown)}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _strings(settings: dict[str, Any], key: str, table: str) -> tuple[str, ...]:
|
|
97
|
+
value = settings.get(key, [])
|
|
98
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
99
|
+
raise ValueError(f"{table} {key} must be a list of strings")
|
|
100
|
+
return tuple(value)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _string(settings: dict[str, Any], key: str, table: str) -> str | None:
|
|
104
|
+
value = settings.get(key)
|
|
105
|
+
if value is not None and not isinstance(value, str):
|
|
106
|
+
raise ValueError(f"{table} {key} must be a string")
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _number(settings: dict[str, Any], key: str, default: float, table: str) -> float:
|
|
111
|
+
value = settings.get(key, default)
|
|
112
|
+
if isinstance(value, bool) or not isinstance(value, int | float) or value <= 0:
|
|
113
|
+
raise ValueError(f"{table} {key} must be a positive number")
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_config(project: Path) -> Config:
|
|
118
|
+
pyproject = project / "pyproject.toml"
|
|
119
|
+
if not pyproject.exists():
|
|
120
|
+
return Config()
|
|
121
|
+
settings = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("tool", {}).get("pysqlmut", {})
|
|
122
|
+
_check_keys(settings, _KEYS, "[tool.pysqlmut]")
|
|
123
|
+
pytest_settings = settings.get("pytest", {})
|
|
124
|
+
_check_keys(pytest_settings, _PYTEST_KEYS, "[tool.pysqlmut.pytest]")
|
|
125
|
+
rules = []
|
|
126
|
+
for rule in settings.get("file", []):
|
|
127
|
+
_check_keys(rule, _FILE_KEYS, "[[tool.pysqlmut.file]]")
|
|
128
|
+
pattern = _string(rule, "pattern", "[[tool.pysqlmut.file]]")
|
|
129
|
+
if pattern is None:
|
|
130
|
+
raise ValueError("every [[tool.pysqlmut.file]] needs a pattern")
|
|
131
|
+
rules.append(FileRule(pattern, _strings(rule, "exclude-operators", "[[tool.pysqlmut.file]]")))
|
|
132
|
+
workers = _number(settings, "workers", DEFAULT_WORKERS, "[tool.pysqlmut]")
|
|
133
|
+
if not isinstance(workers, int):
|
|
134
|
+
raise ValueError("[tool.pysqlmut] workers must be a whole number")
|
|
135
|
+
table = "[tool.pysqlmut]"
|
|
136
|
+
return Config(
|
|
137
|
+
dialect=_string(settings, "dialect", table),
|
|
138
|
+
files=_strings(settings, "files", table),
|
|
139
|
+
operators=_strings(settings, "operators", table) if "operators" in settings else None,
|
|
140
|
+
exclude_operators=_strings(settings, "exclude-operators", table),
|
|
141
|
+
file_rules=tuple(rules),
|
|
142
|
+
accepted=_string(settings, "accepted", table),
|
|
143
|
+
command=_string(settings, "command", table),
|
|
144
|
+
pytest_python=_string(pytest_settings, "python", "[tool.pysqlmut.pytest]"),
|
|
145
|
+
tests=_strings(pytest_settings, "tests", "[tool.pysqlmut.pytest]"),
|
|
146
|
+
pytest_args=_strings(pytest_settings, "args", "[tool.pysqlmut.pytest]"),
|
|
147
|
+
workers=workers,
|
|
148
|
+
timeout=float(_number(settings, "timeout", DEFAULT_TIMEOUT, table)),
|
|
149
|
+
)
|