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 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
+ )