mutmut 3.4.0__tar.gz → 3.5.0__tar.gz
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.
- {mutmut-3.4.0 → mutmut-3.5.0}/HISTORY.rst +16 -0
- {mutmut-3.4.0 → mutmut-3.5.0}/PKG-INFO +30 -1
- {mutmut-3.4.0 → mutmut-3.5.0}/README.rst +28 -0
- {mutmut-3.4.0 → mutmut-3.5.0}/pyproject.toml +9 -2
- {mutmut-3.4.0 → mutmut-3.5.0}/src/mutmut/__main__.py +250 -61
- {mutmut-3.4.0 → mutmut-3.5.0}/src/mutmut/code_coverage.py +0 -1
- {mutmut-3.4.0 → mutmut-3.5.0}/src/mutmut/file_mutation.py +106 -8
- mutmut-3.5.0/src/mutmut/trampoline_templates.py +56 -0
- mutmut-3.5.0/src/mutmut/type_checking.py +98 -0
- mutmut-3.4.0/src/mutmut/trampoline_templates.py +0 -73
- {mutmut-3.4.0 → mutmut-3.5.0}/LICENSE +0 -0
- {mutmut-3.4.0 → mutmut-3.5.0}/src/mutmut/__init__.py +0 -0
- {mutmut-3.4.0 → mutmut-3.5.0}/src/mutmut/node_mutation.py +0 -0
- {mutmut-3.4.0 → mutmut-3.5.0}/src/mutmut/result_browser_layout.tcss +0 -0
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
Changelog
|
|
2
2
|
---------
|
|
3
3
|
|
|
4
|
+
3.5.0
|
|
5
|
+
~~~~~
|
|
6
|
+
|
|
7
|
+
* Filter mutants based on type annotations. This feature needs to be turned on with the `type_check_command` config.
|
|
8
|
+
|
|
9
|
+
* Support running on Windows
|
|
10
|
+
|
|
11
|
+
* Save mutation stats for use with CI/CD pipeline (#460)
|
|
12
|
+
|
|
13
|
+
* Async functions fixed
|
|
14
|
+
|
|
15
|
+
* Fix for functions with positional only arguments
|
|
16
|
+
|
|
17
|
+
* Support signatures with PEP 649 deferred annotations
|
|
18
|
+
|
|
19
|
+
|
|
4
20
|
3.4.0
|
|
5
21
|
~~~~~
|
|
6
22
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mutmut
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.5.0
|
|
4
4
|
Summary: mutation testing for Python 3
|
|
5
|
+
Keywords: mutmut,mutant,mutation,test,testing
|
|
5
6
|
Author: Anders Hovmöller
|
|
6
7
|
Author-email: Anders Hovmöller <boxed@killingar.net>
|
|
7
8
|
License-Expression: BSD-3-Clause
|
|
@@ -199,6 +200,34 @@ If you only want to mutate lines that are called (according to coverage.py), you
|
|
|
199
200
|
mutate_only_covered_lines=true
|
|
200
201
|
|
|
201
202
|
|
|
203
|
+
Filter generated mutants with type checker
|
|
204
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
205
|
+
|
|
206
|
+
When your project is type checked using `mypy` or `pyrefly`, you can also use it to filter out invalid mutants.
|
|
207
|
+
For instance, mutmut mutates `x: str = 'foo'` to `x: str = None` which can easily caught by type checkers.
|
|
208
|
+
|
|
209
|
+
Using this filter can improve performance and reduce noise, however it can also hide a few relevant mutations:
|
|
210
|
+
|
|
211
|
+
1. `x: str = None` may not be valid, but if your tests do not detect such a change it indicates that
|
|
212
|
+
the value of `x` is not properly tested (even if your type checker would catch this particular modification)
|
|
213
|
+
2. In some edge cases with class properties (usually in the `__init__` method), the way `mypy` and `pyrefly` infer types does not work well
|
|
214
|
+
with the way mutmut mutates code. Some valid mutations like changing `self.x = 123` to `self.x = None` can
|
|
215
|
+
be filtered out, even though the may be valid.
|
|
216
|
+
|
|
217
|
+
To enable this filtering, configure the `type_check_command` to output json results as follows:
|
|
218
|
+
|
|
219
|
+
.. code-block::
|
|
220
|
+
|
|
221
|
+
# for pyrefly
|
|
222
|
+
type_check_command = ['pyrefly', 'check', '--output-format=json']
|
|
223
|
+
# for mypy
|
|
224
|
+
type_check_command = ['mypy', 'traces_parser', '--output', 'json']
|
|
225
|
+
|
|
226
|
+
Currently, only `pyrefly` and `mypy` are supported.
|
|
227
|
+
With `pyright` and `ty`, mutating a class method `Foo.bar()` can break the types of all methods of `Foo`,
|
|
228
|
+
and therefore mutmut cannot match the type error with the mutant that caused the type error.
|
|
229
|
+
|
|
230
|
+
|
|
202
231
|
Enable debug output (increase verbosity)
|
|
203
232
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
204
233
|
|
|
@@ -167,6 +167,34 @@ If you only want to mutate lines that are called (according to coverage.py), you
|
|
|
167
167
|
mutate_only_covered_lines=true
|
|
168
168
|
|
|
169
169
|
|
|
170
|
+
Filter generated mutants with type checker
|
|
171
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
172
|
+
|
|
173
|
+
When your project is type checked using `mypy` or `pyrefly`, you can also use it to filter out invalid mutants.
|
|
174
|
+
For instance, mutmut mutates `x: str = 'foo'` to `x: str = None` which can easily caught by type checkers.
|
|
175
|
+
|
|
176
|
+
Using this filter can improve performance and reduce noise, however it can also hide a few relevant mutations:
|
|
177
|
+
|
|
178
|
+
1. `x: str = None` may not be valid, but if your tests do not detect such a change it indicates that
|
|
179
|
+
the value of `x` is not properly tested (even if your type checker would catch this particular modification)
|
|
180
|
+
2. In some edge cases with class properties (usually in the `__init__` method), the way `mypy` and `pyrefly` infer types does not work well
|
|
181
|
+
with the way mutmut mutates code. Some valid mutations like changing `self.x = 123` to `self.x = None` can
|
|
182
|
+
be filtered out, even though the may be valid.
|
|
183
|
+
|
|
184
|
+
To enable this filtering, configure the `type_check_command` to output json results as follows:
|
|
185
|
+
|
|
186
|
+
.. code-block::
|
|
187
|
+
|
|
188
|
+
# for pyrefly
|
|
189
|
+
type_check_command = ['pyrefly', 'check', '--output-format=json']
|
|
190
|
+
# for mypy
|
|
191
|
+
type_check_command = ['mypy', 'traces_parser', '--output', 'json']
|
|
192
|
+
|
|
193
|
+
Currently, only `pyrefly` and `mypy` are supported.
|
|
194
|
+
With `pyright` and `ty`, mutating a class method `Foo.bar()` can break the types of all methods of `Foo`,
|
|
195
|
+
and therefore mutmut cannot match the type error with the mutant that caused the type error.
|
|
196
|
+
|
|
197
|
+
|
|
170
198
|
Enable debug output (increase verbosity)
|
|
171
199
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
172
200
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mutmut"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.5.0"
|
|
4
4
|
description = "mutation testing for Python 3"
|
|
5
|
-
|
|
5
|
+
keywords = ["mutmut", "mutant", "mutation", "test", "testing"]
|
|
6
6
|
authors = [
|
|
7
7
|
{ name = "Anders Hovmöller", email = "boxed@killingar.net" },
|
|
8
8
|
]
|
|
@@ -53,10 +53,17 @@ source-include = ["HISTORY.rst"]
|
|
|
53
53
|
|
|
54
54
|
[dependency-groups]
|
|
55
55
|
dev = [
|
|
56
|
+
"inline-snapshot>=0.32.0",
|
|
57
|
+
"pyrefly>=0.53.0",
|
|
56
58
|
"pytest-asyncio>=1.0.0",
|
|
59
|
+
"ruff>=0.15.1",
|
|
57
60
|
]
|
|
58
61
|
|
|
59
62
|
[tool.pytest.ini_options]
|
|
60
63
|
testpaths = [
|
|
61
64
|
"tests",
|
|
62
65
|
]
|
|
66
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
67
|
+
|
|
68
|
+
[tool.inline-snapshot]
|
|
69
|
+
format-command="ruff format --stdin-filename {filename}"
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
from typing import Iterable
|
|
2
|
+
from mutmut.type_checking import TypeCheckingError
|
|
3
|
+
from mutmut.type_checking import run_type_checker
|
|
4
|
+
from typing import Any
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import platform
|
|
8
|
+
if platform.system() == 'Windows':
|
|
9
|
+
print('To run mutmut on Windows, please use the WSL. Native windows support is tracked in issue https://github.com/boxed/mutmut/issues/397')
|
|
10
|
+
sys.exit(1)
|
|
1
11
|
import ast
|
|
2
12
|
import fnmatch
|
|
3
13
|
import gc
|
|
@@ -5,12 +15,10 @@ import inspect
|
|
|
5
15
|
import itertools
|
|
6
16
|
import json
|
|
7
17
|
from multiprocessing import Pool, set_start_method, Lock
|
|
8
|
-
import os
|
|
9
18
|
import resource
|
|
10
19
|
import shutil
|
|
11
20
|
import signal
|
|
12
21
|
import subprocess
|
|
13
|
-
import sys
|
|
14
22
|
from abc import ABC
|
|
15
23
|
from collections import defaultdict
|
|
16
24
|
from configparser import (
|
|
@@ -19,7 +27,7 @@ from configparser import (
|
|
|
19
27
|
NoSectionError,
|
|
20
28
|
)
|
|
21
29
|
from contextlib import contextmanager
|
|
22
|
-
from dataclasses import dataclass
|
|
30
|
+
from dataclasses import dataclass, field
|
|
23
31
|
from datetime import (
|
|
24
32
|
datetime,
|
|
25
33
|
timedelta,
|
|
@@ -43,12 +51,6 @@ from time import (
|
|
|
43
51
|
process_time,
|
|
44
52
|
sleep,
|
|
45
53
|
)
|
|
46
|
-
from typing import (
|
|
47
|
-
Dict,
|
|
48
|
-
List,
|
|
49
|
-
Union,
|
|
50
|
-
Optional,
|
|
51
|
-
)
|
|
52
54
|
import warnings
|
|
53
55
|
|
|
54
56
|
import click
|
|
@@ -65,7 +67,6 @@ from mutmut.trampoline_templates import CLASS_NAME_SEPARATOR
|
|
|
65
67
|
# Document: surviving mutants are retested when you ask mutmut to retest them, interactively in the UI or via command line
|
|
66
68
|
|
|
67
69
|
# TODO: pragma no mutate should end up in `skipped` category
|
|
68
|
-
# TODO: hash of function. If hash changes, retest all mutants as mutant IDs are not stable
|
|
69
70
|
|
|
70
71
|
|
|
71
72
|
status_by_exit_code = defaultdict(lambda: 'suspicious', {
|
|
@@ -80,6 +81,7 @@ status_by_exit_code = defaultdict(lambda: 'suspicious', {
|
|
|
80
81
|
34: 'skipped',
|
|
81
82
|
35: 'suspicious',
|
|
82
83
|
36: 'timeout',
|
|
84
|
+
37: 'caught by type check',
|
|
83
85
|
-24: 'timeout', # SIGXCPU
|
|
84
86
|
24: 'timeout', # SIGXCPU
|
|
85
87
|
152: 'timeout', # SIGXCPU
|
|
@@ -94,6 +96,7 @@ emoji_by_status = {
|
|
|
94
96
|
'timeout': '⏰',
|
|
95
97
|
'suspicious': '🤔',
|
|
96
98
|
'skipped': '🔇',
|
|
99
|
+
'caught by type check': '🧙',
|
|
97
100
|
'check was interrupted by user': '🛑',
|
|
98
101
|
'not checked': '?',
|
|
99
102
|
'killed': '🎉',
|
|
@@ -183,34 +186,56 @@ class BadTestExecutionCommandsException(Exception):
|
|
|
183
186
|
|
|
184
187
|
|
|
185
188
|
class InvalidGeneratedSyntaxException(Exception):
|
|
186
|
-
def __init__(self, file:
|
|
189
|
+
def __init__(self, file: Path | str) -> None:
|
|
187
190
|
super().__init__(f'Mutmut generated invalid python syntax for {file}. '
|
|
188
191
|
'If the original file has valid python syntax, please file an issue '
|
|
189
192
|
'with a minimal reproducible example file.')
|
|
190
193
|
|
|
191
194
|
|
|
192
195
|
def copy_src_dir():
|
|
193
|
-
for
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
196
|
+
for root, name in walk_all_files():
|
|
197
|
+
source_path = Path(root) / name
|
|
198
|
+
target_path = Path('mutants') / root / name
|
|
199
|
+
|
|
200
|
+
if target_path.exists():
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
if isdir(source_path):
|
|
204
|
+
shutil.copytree(source_path, target_path)
|
|
197
205
|
else:
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
target_path.parent.mkdir(exist_ok=True, parents=True)
|
|
207
|
+
# copy mtime, so we later know that when source_mtime == target_mtime, the file is not (yet) mutated.
|
|
208
|
+
shutil.copy2(source_path, target_path)
|
|
200
209
|
|
|
201
210
|
@dataclass
|
|
202
211
|
class FileMutationResult:
|
|
203
212
|
"""Dataclass to transfer warnings and errors from child processes to the parent"""
|
|
204
|
-
warnings: list[Warning]
|
|
205
|
-
error:
|
|
213
|
+
warnings: list[Warning] = field(default_factory=list)
|
|
214
|
+
error: Exception | None = None
|
|
215
|
+
unmodified: bool = False
|
|
216
|
+
ignored: bool = False
|
|
206
217
|
|
|
207
|
-
|
|
218
|
+
@dataclass
|
|
219
|
+
class MutantGenerationStats:
|
|
220
|
+
mutated: int = 0
|
|
221
|
+
unmodified: int = 0
|
|
222
|
+
ignored: int = 0
|
|
223
|
+
|
|
224
|
+
def create_mutants(max_children: int) -> MutantGenerationStats:
|
|
225
|
+
stats = MutantGenerationStats()
|
|
208
226
|
with Pool(processes=max_children) as p:
|
|
209
227
|
for result in p.imap_unordered(create_file_mutants, walk_source_files()):
|
|
210
228
|
for warning in result.warnings:
|
|
211
229
|
warnings.warn(warning)
|
|
212
230
|
if result.error:
|
|
213
231
|
raise result.error
|
|
232
|
+
if result.unmodified:
|
|
233
|
+
stats.unmodified += 1
|
|
234
|
+
elif result.ignored:
|
|
235
|
+
stats.ignored += 1
|
|
236
|
+
else:
|
|
237
|
+
stats.mutated += 1
|
|
238
|
+
return stats
|
|
214
239
|
|
|
215
240
|
def create_file_mutants(path: Path) -> FileMutationResult:
|
|
216
241
|
try:
|
|
@@ -220,11 +245,11 @@ def create_file_mutants(path: Path) -> FileMutationResult:
|
|
|
220
245
|
|
|
221
246
|
if mutmut.config.should_ignore_for_mutation(path):
|
|
222
247
|
shutil.copy(path, output_path)
|
|
223
|
-
return FileMutationResult(
|
|
248
|
+
return FileMutationResult(ignored=True)
|
|
224
249
|
else:
|
|
225
250
|
return create_mutants_for_file(path, output_path)
|
|
226
251
|
except Exception as e:
|
|
227
|
-
return FileMutationResult(
|
|
252
|
+
return FileMutationResult(error=e)
|
|
228
253
|
|
|
229
254
|
def setup_source_paths():
|
|
230
255
|
# ensure that the mutated source code can be imported by the tests
|
|
@@ -253,25 +278,43 @@ def copy_also_copy_files():
|
|
|
253
278
|
if not path.exists():
|
|
254
279
|
continue
|
|
255
280
|
if path.is_file():
|
|
256
|
-
shutil.
|
|
281
|
+
shutil.copy2(path, destination)
|
|
257
282
|
else:
|
|
258
283
|
shutil.copytree(path, destination, dirs_exist_ok=True)
|
|
259
284
|
|
|
260
|
-
def create_mutants_for_file(filename, output_path) -> FileMutationResult:
|
|
261
|
-
input_stat = os.stat(filename)
|
|
285
|
+
def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationResult:
|
|
262
286
|
warnings: list[Warning] = []
|
|
263
287
|
|
|
288
|
+
try:
|
|
289
|
+
source_mtime = os.path.getmtime(filename)
|
|
290
|
+
mutant_mtime = os.path.getmtime(output_path)
|
|
291
|
+
# We have three possible cases here:
|
|
292
|
+
# source_mtime > mutant_mtime: the source file was modified after the mutant has been created
|
|
293
|
+
# source_mtime == mutant_mtime: only copied, otherwise the mutant file is untouched
|
|
294
|
+
# source_mtime < mutant_mtime: the mutations have been saved after copying; source file untouched
|
|
295
|
+
if source_mtime < mutant_mtime:
|
|
296
|
+
# reset the mutation stats
|
|
297
|
+
source_file_mutation_data = SourceFileMutationData(path=filename)
|
|
298
|
+
source_file_mutation_data.load()
|
|
299
|
+
for key in source_file_mutation_data.exit_code_by_key:
|
|
300
|
+
source_file_mutation_data.exit_code_by_key[key] = None
|
|
301
|
+
source_file_mutation_data.save()
|
|
302
|
+
|
|
303
|
+
return FileMutationResult(unmodified=True)
|
|
304
|
+
except OSError:
|
|
305
|
+
pass
|
|
306
|
+
|
|
264
307
|
with open(filename) as f:
|
|
265
308
|
source = f.read()
|
|
266
309
|
|
|
267
310
|
with open(output_path, 'w') as out:
|
|
268
311
|
try:
|
|
269
|
-
mutant_names
|
|
312
|
+
mutant_names = write_all_mutants_to_file(out=out, source=source, filename=filename)
|
|
270
313
|
except cst.ParserSyntaxError as e:
|
|
271
314
|
# if libcst cannot parse it, then copy the source without any mutations
|
|
272
315
|
warnings.append(SyntaxWarning(f'Unsupported syntax in {filename} ({str(e)}), skipping'))
|
|
273
316
|
out.write(source)
|
|
274
|
-
mutant_names
|
|
317
|
+
mutant_names = []
|
|
275
318
|
|
|
276
319
|
# validate no syntax errors of mutants
|
|
277
320
|
with open(output_path) as f:
|
|
@@ -283,28 +326,27 @@ def create_mutants_for_file(filename, output_path) -> FileMutationResult:
|
|
|
283
326
|
return FileMutationResult(warnings=warnings, error=invalid_syntax_error)
|
|
284
327
|
|
|
285
328
|
source_file_mutation_data = SourceFileMutationData(path=filename)
|
|
286
|
-
module_name = strip_prefix(str(filename)[:-len(filename.suffix)].replace(os.sep, '.'), prefix='src.')
|
|
287
|
-
|
|
288
329
|
source_file_mutation_data.exit_code_by_key = {
|
|
289
|
-
|
|
290
|
-
for x in mutant_names
|
|
330
|
+
get_mutant_name(filename, mutant_name): None for mutant_name in mutant_names
|
|
291
331
|
}
|
|
292
|
-
source_file_mutation_data.hash_by_function_name = hash_by_function_name
|
|
293
|
-
assert None not in hash_by_function_name
|
|
294
332
|
source_file_mutation_data.save()
|
|
295
333
|
|
|
296
|
-
os.utime(output_path, (input_stat.st_atime, input_stat.st_mtime))
|
|
297
334
|
return FileMutationResult(warnings=warnings)
|
|
298
335
|
|
|
336
|
+
def get_mutant_name(relative_source_path: Path, mutant_method_name: str) -> str:
|
|
337
|
+
module_name = str(relative_source_path)[:-len(relative_source_path.suffix)].replace(os.sep, '.')
|
|
338
|
+
module_name = strip_prefix(module_name, prefix='src.')
|
|
339
|
+
|
|
340
|
+
# FYI, we currently use "mutant_name" inconsistently, for both the whole identifier including the path and only the mangled method name
|
|
341
|
+
mutant_name = f'{module_name}.{mutant_method_name}'
|
|
342
|
+
mutant_name = mutant_name.replace('.__init__.', '.')
|
|
343
|
+
return mutant_name
|
|
299
344
|
|
|
300
345
|
def write_all_mutants_to_file(*, out, source, filename):
|
|
301
346
|
result, mutant_names = mutate_file_contents(filename, source, get_covered_lines_for_file(filename, mutmut._covered_lines))
|
|
302
347
|
out.write(result)
|
|
303
348
|
|
|
304
|
-
|
|
305
|
-
hash_by_function_name = {}
|
|
306
|
-
|
|
307
|
-
return mutant_names, hash_by_function_name
|
|
349
|
+
return mutant_names
|
|
308
350
|
|
|
309
351
|
|
|
310
352
|
class SourceFileMutationData:
|
|
@@ -315,7 +357,7 @@ class SourceFileMutationData:
|
|
|
315
357
|
self.key_by_pid = {}
|
|
316
358
|
self.exit_code_by_key = {}
|
|
317
359
|
self.durations_by_key = {}
|
|
318
|
-
self.
|
|
360
|
+
self.type_check_error_by_key: dict[str, str] = {}
|
|
319
361
|
self.start_time_by_pid = {}
|
|
320
362
|
|
|
321
363
|
def load(self):
|
|
@@ -326,10 +368,10 @@ class SourceFileMutationData:
|
|
|
326
368
|
return
|
|
327
369
|
|
|
328
370
|
self.exit_code_by_key = meta.pop('exit_code_by_key')
|
|
329
|
-
self.hash_by_function_name = meta.pop('hash_by_function_name')
|
|
330
371
|
self.durations_by_key = meta.pop('durations_by_key')
|
|
331
372
|
self.estimated_time_of_tests_by_mutant = meta.pop('estimated_durations_by_key')
|
|
332
|
-
|
|
373
|
+
self.type_check_error_by_key = meta.pop('type_check_error_by_key')
|
|
374
|
+
assert not meta, f'Meta file {self.meta_path} contains unexpected keys: {set(meta.keys())}'
|
|
333
375
|
|
|
334
376
|
def register_pid(self, *, pid, key):
|
|
335
377
|
self.key_by_pid[pid] = key
|
|
@@ -355,11 +397,93 @@ class SourceFileMutationData:
|
|
|
355
397
|
with open(self.meta_path, 'w') as f:
|
|
356
398
|
json.dump(dict(
|
|
357
399
|
exit_code_by_key=self.exit_code_by_key,
|
|
358
|
-
hash_by_function_name=self.hash_by_function_name,
|
|
359
400
|
durations_by_key=self.durations_by_key,
|
|
401
|
+
type_check_error_by_key=self.type_check_error_by_key,
|
|
360
402
|
estimated_durations_by_key=self.estimated_time_of_tests_by_mutant,
|
|
361
403
|
), f, indent=4)
|
|
362
404
|
|
|
405
|
+
def filter_mutants_with_type_checker():
|
|
406
|
+
with change_cwd(Path('mutants')):
|
|
407
|
+
errors = run_type_checker(mutmut.config.type_check_command)
|
|
408
|
+
errors_by_path = group_by_path(errors)
|
|
409
|
+
|
|
410
|
+
mutants_to_skip: dict[str, FailedTypeCheckMutant] = {}
|
|
411
|
+
|
|
412
|
+
for path, errors_of_file in errors_by_path.items():
|
|
413
|
+
with open(path, 'r', encoding='utf-8') as file:
|
|
414
|
+
source = file.read()
|
|
415
|
+
wrapper = cst.MetadataWrapper(cst.parse_module(source))
|
|
416
|
+
visitor = MutatedMethodsCollector(path)
|
|
417
|
+
wrapper.visit(visitor)
|
|
418
|
+
mutated_methods = visitor.found_mutants
|
|
419
|
+
|
|
420
|
+
for error in errors_of_file:
|
|
421
|
+
assert error.file_path == visitor.file
|
|
422
|
+
mutant = next((m for m in mutated_methods if m.line_number_start <= error.line_number <= m.line_number_end), None)
|
|
423
|
+
if mutant is None:
|
|
424
|
+
raise Exception(f'Could not find mutant for type error {error.file_path}:{error.line_number} ({error.error_description}). '
|
|
425
|
+
'Probably, a code mutation influenced types in unexpected locations. '
|
|
426
|
+
'If your project normally has no type errors and uses mypy/pyrefly, please file an issue with steps to reproduce on github.')
|
|
427
|
+
|
|
428
|
+
mutant_name = get_mutant_name(path.relative_to(Path('.').absolute()), mutant.function_name)
|
|
429
|
+
|
|
430
|
+
mutants_to_skip[mutant_name] = FailedTypeCheckMutant(
|
|
431
|
+
method_location=mutant,
|
|
432
|
+
name=mutant_name,
|
|
433
|
+
error=error,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
return mutants_to_skip
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def group_by_path(errors: list[TypeCheckingError]) -> dict[Path, list[TypeCheckingError]]:
|
|
440
|
+
grouped: dict[Path, list[TypeCheckingError]] = defaultdict(list)
|
|
441
|
+
|
|
442
|
+
for error in errors:
|
|
443
|
+
grouped[error.file_path].append(error)
|
|
444
|
+
|
|
445
|
+
return grouped
|
|
446
|
+
|
|
447
|
+
@dataclass
|
|
448
|
+
class MutatedMethodLocation:
|
|
449
|
+
file: Path
|
|
450
|
+
function_name: str
|
|
451
|
+
line_number_start: int
|
|
452
|
+
line_number_end: int
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@dataclass
|
|
456
|
+
class FailedTypeCheckMutant:
|
|
457
|
+
method_location: MutatedMethodLocation
|
|
458
|
+
name: str
|
|
459
|
+
error: TypeCheckingError
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class MutatedMethodsCollector(cst.CSTVisitor):
|
|
463
|
+
METADATA_DEPENDENCIES = (cst.metadata.PositionProvider,)
|
|
464
|
+
|
|
465
|
+
def __init__(self, file: Path):
|
|
466
|
+
self.file = file
|
|
467
|
+
self.found_mutants: list[MutatedMethodLocation] = []
|
|
468
|
+
|
|
469
|
+
def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
|
|
470
|
+
name = node.name.value
|
|
471
|
+
if is_mutated_method_name(name):
|
|
472
|
+
range = self.get_metadata(cst.metadata.PositionProvider, node)
|
|
473
|
+
self.found_mutants.append(MutatedMethodLocation(
|
|
474
|
+
file=self.file,
|
|
475
|
+
function_name=name,
|
|
476
|
+
line_number_start=range.start.line,
|
|
477
|
+
line_number_end=range.end.line,
|
|
478
|
+
))
|
|
479
|
+
|
|
480
|
+
# do not continue visting children of this function
|
|
481
|
+
# mutated methods are not nested within other methods
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
def is_mutated_method_name(name: str):
|
|
485
|
+
return name.startswith(('x_', 'xǁ')) and '__mutmut' in name
|
|
486
|
+
|
|
363
487
|
|
|
364
488
|
def unused(*_):
|
|
365
489
|
pass
|
|
@@ -425,8 +549,8 @@ class ListAllTestsResult:
|
|
|
425
549
|
|
|
426
550
|
class PytestRunner(TestRunner):
|
|
427
551
|
def __init__(self):
|
|
428
|
-
self._pytest_add_cli_args:
|
|
429
|
-
self._pytest_add_cli_args_test_selection:
|
|
552
|
+
self._pytest_add_cli_args: list[str] = mutmut.config.pytest_add_cli_args
|
|
553
|
+
self._pytest_add_cli_args_test_selection: list[str] = mutmut.config.pytest_add_cli_args_test_selection
|
|
430
554
|
|
|
431
555
|
# tests_dir is a special case of a test selection option,
|
|
432
556
|
# so also use pytest_add_cli_args_test_selection for the implementation
|
|
@@ -608,6 +732,7 @@ class Stat:
|
|
|
608
732
|
timeout: int
|
|
609
733
|
check_was_interrupted_by_user: int
|
|
610
734
|
segfault: int
|
|
735
|
+
caught_by_type_check: int
|
|
611
736
|
|
|
612
737
|
|
|
613
738
|
def collect_stat(m: SourceFileMutationData):
|
|
@@ -637,12 +762,13 @@ def calculate_summary_stats(source_file_mutation_data_by_path):
|
|
|
637
762
|
timeout=sum(x.timeout for x in stats),
|
|
638
763
|
check_was_interrupted_by_user=sum(x.check_was_interrupted_by_user for x in stats),
|
|
639
764
|
segfault=sum(x.segfault for x in stats),
|
|
765
|
+
caught_by_type_check=sum(x.caught_by_type_check for x in stats),
|
|
640
766
|
)
|
|
641
767
|
|
|
642
768
|
|
|
643
769
|
def print_stats(source_file_mutation_data_by_path, force_output=False):
|
|
644
770
|
s = calculate_summary_stats(source_file_mutation_data_by_path)
|
|
645
|
-
print_status(f'{(s.total - s.not_checked)}/{s.total} 🎉 {s.killed} 🫥 {s.no_tests} ⏰ {s.timeout} 🤔 {s.suspicious} 🙁 {s.survived} 🔇 {s.skipped}', force_output=force_output)
|
|
771
|
+
print_status(f'{(s.total - s.not_checked)}/{s.total} 🎉 {s.killed} 🫥 {s.no_tests} ⏰ {s.timeout} 🤔 {s.suspicious} 🙁 {s.survived} 🔇 {s.skipped} 🧙 {s.caught_by_type_check}', force_output=force_output)
|
|
646
772
|
|
|
647
773
|
|
|
648
774
|
def run_forced_fail_test(runner):
|
|
@@ -709,15 +835,16 @@ class CatchOutput:
|
|
|
709
835
|
|
|
710
836
|
@dataclass
|
|
711
837
|
class Config:
|
|
712
|
-
also_copy:
|
|
713
|
-
do_not_mutate:
|
|
838
|
+
also_copy: list[Path]
|
|
839
|
+
do_not_mutate: list[str]
|
|
714
840
|
max_stack_depth: int
|
|
715
841
|
debug: bool
|
|
716
|
-
paths_to_mutate:
|
|
717
|
-
pytest_add_cli_args:
|
|
718
|
-
pytest_add_cli_args_test_selection:
|
|
719
|
-
tests_dir:
|
|
842
|
+
paths_to_mutate: list[Path]
|
|
843
|
+
pytest_add_cli_args: list[str]
|
|
844
|
+
pytest_add_cli_args_test_selection: list[str]
|
|
845
|
+
tests_dir: list[str]
|
|
720
846
|
mutate_only_covered_lines: bool
|
|
847
|
+
type_check_command: list[str]
|
|
721
848
|
|
|
722
849
|
def should_ignore_for_mutation(self, path):
|
|
723
850
|
if not str(path).endswith('.py'):
|
|
@@ -754,7 +881,7 @@ def config_reader():
|
|
|
754
881
|
config_parser = ConfigParser()
|
|
755
882
|
config_parser.read('setup.cfg')
|
|
756
883
|
|
|
757
|
-
def s(key: str, default):
|
|
884
|
+
def s(key: str, default) -> Any:
|
|
758
885
|
try:
|
|
759
886
|
result = config_parser.get('mutmut', key)
|
|
760
887
|
except (NoOptionError, NoSectionError):
|
|
@@ -790,6 +917,8 @@ def load_config():
|
|
|
790
917
|
Path('test/'),
|
|
791
918
|
Path('setup.cfg'),
|
|
792
919
|
Path('pyproject.toml'),
|
|
920
|
+
Path('pytest.ini'),
|
|
921
|
+
Path('.gitignore'),
|
|
793
922
|
] + list(Path('.').glob('test*.py')),
|
|
794
923
|
max_stack_depth=s('max_stack_depth', -1),
|
|
795
924
|
debug=s('debug', False),
|
|
@@ -801,6 +930,7 @@ def load_config():
|
|
|
801
930
|
tests_dir=s('tests_dir', []),
|
|
802
931
|
pytest_add_cli_args=s('pytest_add_cli_args', []),
|
|
803
932
|
pytest_add_cli_args_test_selection=s('pytest_add_cli_args_test_selection', []),
|
|
933
|
+
type_check_command=s('type_check_command', []),
|
|
804
934
|
)
|
|
805
935
|
|
|
806
936
|
|
|
@@ -899,9 +1029,53 @@ def save_stats():
|
|
|
899
1029
|
stats_time=mutmut.stats_time,
|
|
900
1030
|
), f, indent=4)
|
|
901
1031
|
|
|
1032
|
+
def save_cicd_stats(source_file_mutation_data_by_path):
|
|
1033
|
+
s = calculate_summary_stats(source_file_mutation_data_by_path)
|
|
1034
|
+
with open('mutants/mutmut-cicd-stats.json', 'w') as f:
|
|
1035
|
+
json.dump(dict(
|
|
1036
|
+
killed=s.killed,
|
|
1037
|
+
survived=s.survived,
|
|
1038
|
+
total=s.total,
|
|
1039
|
+
no_tests=s.no_tests,
|
|
1040
|
+
skipped=s.skipped,
|
|
1041
|
+
suspicious=s.suspicious,
|
|
1042
|
+
timeout=s.timeout,
|
|
1043
|
+
check_was_interrupted_by_user=s.check_was_interrupted_by_user,
|
|
1044
|
+
segfault=s.segfault
|
|
1045
|
+
), f, indent=4)
|
|
1046
|
+
|
|
1047
|
+
# exports CI/CD stats to block pull requests from merging if mutation score is too low, or used in other ways in CI/CD pipelines
|
|
1048
|
+
@cli.command()
|
|
1049
|
+
def export_cicd_stats():
|
|
1050
|
+
ensure_config_loaded()
|
|
1051
|
+
|
|
1052
|
+
source_file_mutation_data_by_path: dict[str, SourceFileMutationData] = {}
|
|
1053
|
+
|
|
1054
|
+
for path in walk_source_files():
|
|
1055
|
+
if mutmut.config.should_ignore_for_mutation(path):
|
|
1056
|
+
continue
|
|
1057
|
+
|
|
1058
|
+
meta_path = Path('mutants') / (str(path) + '.meta')
|
|
1059
|
+
if not meta_path.exists():
|
|
1060
|
+
continue
|
|
1061
|
+
|
|
1062
|
+
m = SourceFileMutationData(path=path)
|
|
1063
|
+
m.load()
|
|
1064
|
+
if not m.exit_code_by_key:
|
|
1065
|
+
continue
|
|
1066
|
+
|
|
1067
|
+
source_file_mutation_data_by_path[str(path)] = m
|
|
1068
|
+
|
|
1069
|
+
if not source_file_mutation_data_by_path:
|
|
1070
|
+
print('No previous mutation data found. Run "mutmut run" first.')
|
|
1071
|
+
return
|
|
1072
|
+
|
|
1073
|
+
save_cicd_stats(source_file_mutation_data_by_path)
|
|
1074
|
+
print('Saved CI/CD stats to mutants/mutmut-cicd-stats.json')
|
|
1075
|
+
|
|
902
1076
|
|
|
903
1077
|
def collect_source_file_mutation_data(*, mutant_names):
|
|
904
|
-
source_file_mutation_data_by_path:
|
|
1078
|
+
source_file_mutation_data_by_path: dict[str, SourceFileMutationData] = {}
|
|
905
1079
|
|
|
906
1080
|
for path in walk_source_files():
|
|
907
1081
|
if mutmut.config.should_ignore_for_mutation(path):
|
|
@@ -1006,7 +1180,7 @@ def run(mutant_names, *, max_children):
|
|
|
1006
1180
|
_run(mutant_names, max_children)
|
|
1007
1181
|
|
|
1008
1182
|
# separate function, so we can call it directly from the tests
|
|
1009
|
-
def _run(mutant_names:
|
|
1183
|
+
def _run(mutant_names: tuple | list, max_children: None | int):
|
|
1010
1184
|
# TODO: run no-ops once in a while to detect if we get false negatives
|
|
1011
1185
|
# TODO: we should be able to get information on which tests killed mutants, which means we can get a list of tests and how many mutants each test kills. Those that kill zero mutants are redundant!
|
|
1012
1186
|
os.environ['MUTANT_UNDER_TEST'] = 'mutant_generation'
|
|
@@ -1022,10 +1196,16 @@ def _run(mutant_names: Union[tuple, list], max_children: Union[None, int]):
|
|
|
1022
1196
|
copy_also_copy_files()
|
|
1023
1197
|
setup_source_paths()
|
|
1024
1198
|
store_lines_covered_by_tests()
|
|
1025
|
-
create_mutants(max_children)
|
|
1199
|
+
stats = create_mutants(max_children)
|
|
1026
1200
|
|
|
1027
1201
|
time = datetime.now() - start
|
|
1028
|
-
print(f' done in {round(time.total_seconds()*1000)}ms', )
|
|
1202
|
+
print(f' done in {round(time.total_seconds()*1000)}ms ({stats.mutated} files mutated, {stats.ignored} ignored, {stats.unmodified} unmodified)', )
|
|
1203
|
+
|
|
1204
|
+
if mutmut.config.type_check_command:
|
|
1205
|
+
with CatchOutput(spinner_title='Filtering mutations with type checker'):
|
|
1206
|
+
mutants_caught_by_type_checker = filter_mutants_with_type_checker()
|
|
1207
|
+
else:
|
|
1208
|
+
mutants_caught_by_type_checker = {}
|
|
1029
1209
|
|
|
1030
1210
|
# TODO: config/option for runner
|
|
1031
1211
|
# runner = HammettRunner()
|
|
@@ -1061,7 +1241,7 @@ def _run(mutant_names: Union[tuple, list], max_children: Union[None, int]):
|
|
|
1061
1241
|
print(' worker exit code', exit_code)
|
|
1062
1242
|
source_file_mutation_data_by_pid[pid].register_result(pid=pid, exit_code=exit_code)
|
|
1063
1243
|
|
|
1064
|
-
source_file_mutation_data_by_pid:
|
|
1244
|
+
source_file_mutation_data_by_pid: dict[int, SourceFileMutationData] = {} # many pids map to one MutationData
|
|
1065
1245
|
running_children = 0
|
|
1066
1246
|
count_tried = 0
|
|
1067
1247
|
|
|
@@ -1095,12 +1275,18 @@ def _run(mutant_names: Union[tuple, list], max_children: Union[None, int]):
|
|
|
1095
1275
|
|
|
1096
1276
|
tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), [])
|
|
1097
1277
|
|
|
1098
|
-
# print(tests)
|
|
1099
1278
|
if not tests:
|
|
1100
1279
|
m.exit_code_by_key[mutant_name] = 33
|
|
1101
1280
|
m.save()
|
|
1102
1281
|
continue
|
|
1103
1282
|
|
|
1283
|
+
failed_type_check_mutant = mutants_caught_by_type_checker.get(mutant_name)
|
|
1284
|
+
if failed_type_check_mutant:
|
|
1285
|
+
m.exit_code_by_key[mutant_name] = 37
|
|
1286
|
+
m.type_check_error_by_key[mutant_name] = failed_type_check_mutant.error.error_description
|
|
1287
|
+
m.save()
|
|
1288
|
+
continue
|
|
1289
|
+
|
|
1104
1290
|
pid = os.fork()
|
|
1105
1291
|
if not pid:
|
|
1106
1292
|
# In the child
|
|
@@ -1205,7 +1391,7 @@ def read_orig_module(path) -> cst.Module:
|
|
|
1205
1391
|
return cst.parse_module(f.read())
|
|
1206
1392
|
|
|
1207
1393
|
|
|
1208
|
-
def find_top_level_function_or_method(module: cst.Module, name: str) ->
|
|
1394
|
+
def find_top_level_function_or_method(module: cst.Module, name: str) -> cst.FunctionDef | None:
|
|
1209
1395
|
name = name.split('.')[-1]
|
|
1210
1396
|
for child in module.body:
|
|
1211
1397
|
if isinstance(child, cst.FunctionDef) and child.name.value == name:
|
|
@@ -1315,7 +1501,7 @@ def apply_mutant(mutant_name):
|
|
|
1315
1501
|
|
|
1316
1502
|
|
|
1317
1503
|
@cli.command()
|
|
1318
|
-
@click.option("--show-killed", is_flag=True, default=False, help="Display killed
|
|
1504
|
+
@click.option("--show-killed", is_flag=True, default=False, help="Display mutants killed by tests and type checker.")
|
|
1319
1505
|
def browse(show_killed):
|
|
1320
1506
|
ensure_config_loaded()
|
|
1321
1507
|
|
|
@@ -1417,7 +1603,7 @@ def browse(show_killed):
|
|
|
1417
1603
|
source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[event.row_key.value]
|
|
1418
1604
|
for k, v in source_file_mutation_data.exit_code_by_key.items():
|
|
1419
1605
|
status = status_by_exit_code[v]
|
|
1420
|
-
if status
|
|
1606
|
+
if status not in ('killed', 'caught by type check') or show_killed:
|
|
1421
1607
|
mutants_table.add_row(k, emoji_by_status[status], key=k)
|
|
1422
1608
|
else:
|
|
1423
1609
|
assert event.data_table.id == 'mutants'
|
|
@@ -1432,6 +1618,7 @@ def browse(show_killed):
|
|
|
1432
1618
|
status = status_by_exit_code[exit_code]
|
|
1433
1619
|
estimated_duration = source_file_mutation_data.estimated_time_of_tests_by_mutant.get(mutant_name, '?')
|
|
1434
1620
|
duration = source_file_mutation_data.durations_by_key.get(mutant_name, '?')
|
|
1621
|
+
type_check_error = source_file_mutation_data.type_check_error_by_key.get(mutant_name, '?')
|
|
1435
1622
|
|
|
1436
1623
|
view_tests_description = f'(press t to view tests executed for this mutant)'
|
|
1437
1624
|
|
|
@@ -1444,6 +1631,8 @@ def browse(show_killed):
|
|
|
1444
1631
|
description = f'Skipped ({exit_code=})'
|
|
1445
1632
|
case 'check was interrupted by user':
|
|
1446
1633
|
description = f'User interrupted ({exit_code=})'
|
|
1634
|
+
case 'caught by type check':
|
|
1635
|
+
description = f'Caught by type checker ({exit_code=}): {type_check_error}'
|
|
1447
1636
|
case 'timeout':
|
|
1448
1637
|
description = (f'Timeout ({exit_code=}): Timed out because tests did not finish within {duration:.3f} seconds. '
|
|
1449
1638
|
f'Tests without mutation took {estimated_duration:.3f} seconds. {view_tests_description}')
|
|
@@ -3,16 +3,14 @@
|
|
|
3
3
|
from collections import defaultdict
|
|
4
4
|
from collections.abc import Iterable, Sequence, Mapping
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from pathlib import Path
|
|
7
6
|
from typing import Union
|
|
8
|
-
import warnings
|
|
9
7
|
import libcst as cst
|
|
10
8
|
from libcst.metadata import PositionProvider, MetadataWrapper
|
|
11
9
|
import libcst.matchers as m
|
|
12
|
-
from mutmut.trampoline_templates import
|
|
10
|
+
from mutmut.trampoline_templates import create_trampoline_lookup, mangle_function_name, trampoline_impl
|
|
13
11
|
from mutmut.node_mutation import mutation_operators, OPERATORS_TYPE
|
|
14
12
|
|
|
15
|
-
NEVER_MUTATE_FUNCTION_NAMES = { "__getattribute__", "__setattr__", "__new__"
|
|
13
|
+
NEVER_MUTATE_FUNCTION_NAMES = { "__getattribute__", "__setattr__", "__new__"}
|
|
16
14
|
NEVER_MUTATE_FUNCTION_CALLS = { "len", "isinstance" }
|
|
17
15
|
|
|
18
16
|
@dataclass
|
|
@@ -236,6 +234,10 @@ def function_trampoline_arrangement(function: cst.FunctionDef, mutants: Iterable
|
|
|
236
234
|
name = function.name.value
|
|
237
235
|
mangled_name = mangle_function_name(name=name, class_name=class_name) + '__mutmut'
|
|
238
236
|
|
|
237
|
+
# trampoline with same signature, that forwards the calls to the activated mutant/original method
|
|
238
|
+
# (put first, s.t. it stays next to @overload definitions of this function. mypy needs this)
|
|
239
|
+
nodes.append(create_trampoline_wrapper(function, mangled_name, class_name))
|
|
240
|
+
|
|
239
241
|
# copy of original function
|
|
240
242
|
nodes.append(function.with_changes(name=cst.Name(mangled_name + '_orig')))
|
|
241
243
|
|
|
@@ -247,14 +249,88 @@ def function_trampoline_arrangement(function: cst.FunctionDef, mutants: Iterable
|
|
|
247
249
|
mutated_method = deep_replace(mutated_method, mutant.original_node, mutant.mutated_node)
|
|
248
250
|
nodes.append(mutated_method) # type: ignore
|
|
249
251
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
nodes.extend(
|
|
252
|
+
mutants_dict = list(cst.parse_module(create_trampoline_lookup(orig_name=name, mutants=mutant_names, class_name=class_name)).body)
|
|
253
|
+
mutants_dict[0] = mutants_dict[0].with_changes(leading_lines=[cst.EmptyLine()])
|
|
254
|
+
|
|
255
|
+
nodes.extend(mutants_dict)
|
|
254
256
|
|
|
255
257
|
return nodes, mutant_names
|
|
256
258
|
|
|
257
259
|
|
|
260
|
+
def create_trampoline_wrapper(function: cst.FunctionDef, mangled_name: str, class_name: str | None) -> cst.FunctionDef:
|
|
261
|
+
args: list[cst.Element | cst.StarredElement] = []
|
|
262
|
+
for pos_only_param in function.params.posonly_params:
|
|
263
|
+
args.append(cst.Element(pos_only_param.name))
|
|
264
|
+
for param in function.params.params:
|
|
265
|
+
args.append(cst.Element(param.name))
|
|
266
|
+
if isinstance(function.params.star_arg, cst.Param):
|
|
267
|
+
args.append(cst.StarredElement(function.params.star_arg.name))
|
|
268
|
+
|
|
269
|
+
if class_name is not None:
|
|
270
|
+
# remove self arg (handled by the trampoline function)
|
|
271
|
+
args = args[1:]
|
|
272
|
+
|
|
273
|
+
args_assignemnt = cst.Assign([cst.AssignTarget(cst.Name(value='args'))], cst.List(args))
|
|
274
|
+
|
|
275
|
+
kwargs: list[cst.DictElement | cst.StarredDictElement] = []
|
|
276
|
+
for param in function.params.kwonly_params:
|
|
277
|
+
kwargs.append(cst.DictElement(cst.SimpleString(f"'{param.name.value}'"), param.name))
|
|
278
|
+
if isinstance(function.params.star_kwarg, cst.Param):
|
|
279
|
+
kwargs.append(cst.StarredDictElement(function.params.star_kwarg.name))
|
|
280
|
+
|
|
281
|
+
kwargs_assignment = cst.Assign([cst.AssignTarget(cst.Name(value='kwargs'))], cst.Dict(kwargs))
|
|
282
|
+
|
|
283
|
+
def _get_local_name(func_name: str) -> cst.BaseExpression:
|
|
284
|
+
# for top level, simply return the name
|
|
285
|
+
if class_name is None:
|
|
286
|
+
return cst.Name(func_name)
|
|
287
|
+
# for class methods, use object.__getattribute__(self, name)
|
|
288
|
+
return cst.Call(
|
|
289
|
+
func=cst.Attribute(cst.Name('object'), cst.Name('__getattribute__')),
|
|
290
|
+
args=[cst.Arg(cst.Name('self')), cst.Arg(cst.SimpleString(f"'{func_name}'"))]
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
result: cst.BaseExpression = cst.Call(
|
|
294
|
+
func=cst.Name('_mutmut_trampoline'),
|
|
295
|
+
args=[
|
|
296
|
+
cst.Arg(_get_local_name(f'{mangled_name}_orig')),
|
|
297
|
+
cst.Arg(_get_local_name(f'{mangled_name}_mutants')),
|
|
298
|
+
cst.Arg(cst.Name('args')),
|
|
299
|
+
cst.Arg(cst.Name('kwargs')),
|
|
300
|
+
cst.Arg(cst.Name('None' if class_name is None else 'self')),
|
|
301
|
+
],
|
|
302
|
+
)
|
|
303
|
+
# for non-async functions, simply return the value or generator
|
|
304
|
+
result_statement = cst.SimpleStatementLine([cst.Return(result)])
|
|
305
|
+
|
|
306
|
+
if function.asynchronous:
|
|
307
|
+
is_generator = _is_generator(function)
|
|
308
|
+
if is_generator:
|
|
309
|
+
# async for i in _mutmut_trampoline(...): yield i
|
|
310
|
+
result_statement = cst.For(
|
|
311
|
+
target=cst.Name('i'),
|
|
312
|
+
iter=result,
|
|
313
|
+
body=cst.IndentedBlock([cst.SimpleStatementLine([cst.Expr(cst.Yield(cst.Name('i')))])]),
|
|
314
|
+
asynchronous=cst.Asynchronous(),
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
# return await _mutmut_trampoline(...)
|
|
318
|
+
result_statement = cst.SimpleStatementLine([cst.Return(cst.Await(result))])
|
|
319
|
+
|
|
320
|
+
type_ignore_whitespace = cst.TrailingWhitespace(comment=cst.Comment('# type: ignore'))
|
|
321
|
+
|
|
322
|
+
function.whitespace_after_type_parameters
|
|
323
|
+
return function.with_changes(
|
|
324
|
+
body=cst.IndentedBlock(
|
|
325
|
+
[
|
|
326
|
+
cst.SimpleStatementLine([args_assignemnt], trailing_whitespace=type_ignore_whitespace),
|
|
327
|
+
cst.SimpleStatementLine([kwargs_assignment], trailing_whitespace=type_ignore_whitespace),
|
|
328
|
+
result_statement,
|
|
329
|
+
],
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
258
334
|
def get_statements_until_func_or_class(statements: Sequence[MODULE_STATEMENT]) -> list[MODULE_STATEMENT]:
|
|
259
335
|
"""Get all statements until we encounter the first function or class definition"""
|
|
260
336
|
result = []
|
|
@@ -302,3 +378,25 @@ class ChildReplacementTransformer(cst.CSTTransformer):
|
|
|
302
378
|
self.replaced_node = True
|
|
303
379
|
return self.new_node
|
|
304
380
|
return updated_node
|
|
381
|
+
|
|
382
|
+
def _is_generator(function: cst.FunctionDef) -> bool:
|
|
383
|
+
"""Return True if the function has yield statement(s)."""
|
|
384
|
+
visitor = IsGeneratorVisitor(function)
|
|
385
|
+
function.visit(visitor)
|
|
386
|
+
return visitor.is_generator
|
|
387
|
+
|
|
388
|
+
class IsGeneratorVisitor(cst.CSTVisitor):
|
|
389
|
+
"""Check if a function is a generator.
|
|
390
|
+
We do so by checking if any child is a Yield statement, but not looking into inner function definitions."""
|
|
391
|
+
def __init__(self, original_function: cst.FunctionDef):
|
|
392
|
+
self.is_generator = False
|
|
393
|
+
self.original_function: cst.FunctionDef = original_function
|
|
394
|
+
|
|
395
|
+
def visit_FunctionDef(self, node):
|
|
396
|
+
# do not recurse into inner function definitions
|
|
397
|
+
if self.original_function != node:
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
def visit_Yield(self, node):
|
|
401
|
+
self.is_generator = True
|
|
402
|
+
return False
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
CLASS_NAME_SEPARATOR = 'ǁ'
|
|
2
|
+
|
|
3
|
+
def create_trampoline_lookup(*, orig_name, mutants, class_name):
|
|
4
|
+
mangled_name = mangle_function_name(name=orig_name, class_name=class_name)
|
|
5
|
+
|
|
6
|
+
mutants_dict = f'{mangled_name}__mutmut_mutants : ClassVar[MutantDict] = {{ # type: ignore\n' + ', \n '.join(f'{repr(m)}: {m}' for m in mutants) + '\n}'
|
|
7
|
+
return f"""
|
|
8
|
+
{mutants_dict}
|
|
9
|
+
{mangled_name}__mutmut_orig.__name__ = '{mangled_name}'
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def mangle_function_name(*, name, class_name):
|
|
13
|
+
assert CLASS_NAME_SEPARATOR not in name
|
|
14
|
+
if class_name:
|
|
15
|
+
assert CLASS_NAME_SEPARATOR not in class_name
|
|
16
|
+
prefix = f'x{CLASS_NAME_SEPARATOR}{class_name}{CLASS_NAME_SEPARATOR}'
|
|
17
|
+
else:
|
|
18
|
+
prefix = 'x_'
|
|
19
|
+
return f'{prefix}{name}'
|
|
20
|
+
|
|
21
|
+
# noinspection PyUnresolvedReferences
|
|
22
|
+
# language=python
|
|
23
|
+
trampoline_impl = """
|
|
24
|
+
from typing import Annotated
|
|
25
|
+
from typing import Callable
|
|
26
|
+
from typing import ClassVar
|
|
27
|
+
|
|
28
|
+
MutantDict = Annotated[dict[str, Callable], "Mutant"] # type: ignore
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _mutmut_trampoline(orig, mutants, call_args, call_kwargs, self_arg = None): # type: ignore
|
|
32
|
+
\"""Forward call to original or mutated function, depending on the environment\"""
|
|
33
|
+
import os # type: ignore
|
|
34
|
+
mutant_under_test = os.environ['MUTANT_UNDER_TEST'] # type: ignore
|
|
35
|
+
if mutant_under_test == 'fail': # type: ignore
|
|
36
|
+
from mutmut.__main__ import MutmutProgrammaticFailException # type: ignore
|
|
37
|
+
raise MutmutProgrammaticFailException('Failed programmatically') # type: ignore
|
|
38
|
+
elif mutant_under_test == 'stats': # type: ignore
|
|
39
|
+
from mutmut.__main__ import record_trampoline_hit # type: ignore
|
|
40
|
+
record_trampoline_hit(orig.__module__ + '.' + orig.__name__) # type: ignore
|
|
41
|
+
# (for class methods, orig is bound and thus does not need the explicit self argument)
|
|
42
|
+
result = orig(*call_args, **call_kwargs) # type: ignore
|
|
43
|
+
return result # type: ignore
|
|
44
|
+
prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_' # type: ignore
|
|
45
|
+
if not mutant_under_test.startswith(prefix): # type: ignore
|
|
46
|
+
result = orig(*call_args, **call_kwargs) # type: ignore
|
|
47
|
+
return result # type: ignore
|
|
48
|
+
mutant_name = mutant_under_test.rpartition('.')[-1] # type: ignore
|
|
49
|
+
if self_arg is not None: # type: ignore
|
|
50
|
+
# call to a class method where self is not bound
|
|
51
|
+
result = mutants[mutant_name](self_arg, *call_args, **call_kwargs) # type: ignore
|
|
52
|
+
else:
|
|
53
|
+
result = mutants[mutant_name](*call_args, **call_kwargs) # type: ignore
|
|
54
|
+
return result # type: ignore
|
|
55
|
+
|
|
56
|
+
"""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from typing import cast
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class TypeCheckingError:
|
|
10
|
+
file_path: Path
|
|
11
|
+
line_number: int
|
|
12
|
+
"""line number (first line is 1)"""
|
|
13
|
+
error_description: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_type_checker(type_check_command: list[str]) -> list[TypeCheckingError]:
|
|
17
|
+
errors = []
|
|
18
|
+
|
|
19
|
+
completed_process = subprocess.run(type_check_command, capture_output=True, encoding='utf-8')
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
if 'mypy' in type_check_command:
|
|
23
|
+
report = [json.loads(line) for line in completed_process.stdout.splitlines()]
|
|
24
|
+
else:
|
|
25
|
+
report = json.loads(completed_process.stdout)
|
|
26
|
+
except json.JSONDecodeError as e:
|
|
27
|
+
raise Exception(f'type check command did not return JSON. Got: {completed_process.stdout} (stderr: {completed_process.stderr})')
|
|
28
|
+
|
|
29
|
+
if 'pyrefly' in type_check_command:
|
|
30
|
+
errors = parse_pyrefly_report(cast(dict, report))
|
|
31
|
+
elif 'mypy' in type_check_command:
|
|
32
|
+
errors = parse_mypy_report(report)
|
|
33
|
+
elif 'ty' in type_check_command:
|
|
34
|
+
errors = parse_ty_report(report)
|
|
35
|
+
else:
|
|
36
|
+
errors = parse_pyright_report(cast(dict, report))
|
|
37
|
+
|
|
38
|
+
return errors
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_pyright_report(result: dict) -> list[TypeCheckingError]:
|
|
42
|
+
if not 'generalDiagnostics' in result:
|
|
43
|
+
raise Exception(f'Invalid pyright report. Could not find key "generalDiagnostics". Found: {set(result.keys())}')
|
|
44
|
+
|
|
45
|
+
errors = []
|
|
46
|
+
for diagnostic in result['generalDiagnostics']:
|
|
47
|
+
errors.append(TypeCheckingError(
|
|
48
|
+
file_path=Path(diagnostic['file']),
|
|
49
|
+
line_number=diagnostic['range']['start']['line'] + 1,
|
|
50
|
+
error_description=diagnostic['message'],
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
return errors
|
|
54
|
+
|
|
55
|
+
def parse_pyrefly_report(result: dict) -> list[TypeCheckingError]:
|
|
56
|
+
if not 'errors' in result:
|
|
57
|
+
raise Exception(f'Invalid pyrefly report. Could not find key "errors". Found: {set(result.keys())}')
|
|
58
|
+
|
|
59
|
+
errors = []
|
|
60
|
+
|
|
61
|
+
for error in result['errors']:
|
|
62
|
+
errors.append(TypeCheckingError(
|
|
63
|
+
file_path=Path(error['path']).absolute(),
|
|
64
|
+
line_number=error['line'],
|
|
65
|
+
error_description=error['concise_description'],
|
|
66
|
+
))
|
|
67
|
+
|
|
68
|
+
return errors
|
|
69
|
+
|
|
70
|
+
def parse_mypy_report(result: list[dict]) -> list[TypeCheckingError]:
|
|
71
|
+
errors = []
|
|
72
|
+
|
|
73
|
+
for diagnostic in result:
|
|
74
|
+
if diagnostic['severity'] != 'error':
|
|
75
|
+
continue
|
|
76
|
+
errors.append(TypeCheckingError(
|
|
77
|
+
file_path=Path(diagnostic['file']).absolute(),
|
|
78
|
+
line_number=diagnostic['line'],
|
|
79
|
+
error_description=diagnostic['message'],
|
|
80
|
+
))
|
|
81
|
+
|
|
82
|
+
return errors
|
|
83
|
+
|
|
84
|
+
def parse_ty_report(result: list[dict]) -> list[TypeCheckingError]:
|
|
85
|
+
errors = []
|
|
86
|
+
|
|
87
|
+
for diagnostic in result:
|
|
88
|
+
# assuming the gitlab code quality report format, these severities seem okay
|
|
89
|
+
# https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format
|
|
90
|
+
if diagnostic['severity'] not in ('major', 'critical', 'blocker'):
|
|
91
|
+
continue
|
|
92
|
+
errors.append(TypeCheckingError(
|
|
93
|
+
file_path=Path(diagnostic['location']['path']).absolute(),
|
|
94
|
+
line_number=diagnostic['location']['positions']['begin']['line'],
|
|
95
|
+
error_description=diagnostic['description'],
|
|
96
|
+
))
|
|
97
|
+
|
|
98
|
+
return errors
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
CLASS_NAME_SEPARATOR = 'ǁ'
|
|
2
|
-
|
|
3
|
-
def build_trampoline(*, orig_name, mutants, class_name):
|
|
4
|
-
mangled_name = mangle_function_name(name=orig_name, class_name=class_name)
|
|
5
|
-
|
|
6
|
-
mutants_dict = f'{mangled_name}__mutmut_mutants : ClassVar[MutantDict] = {{\n' + ', \n '.join(f'{repr(m)}: {m}' for m in mutants) + '\n}'
|
|
7
|
-
access_prefix = ''
|
|
8
|
-
access_suffix = ''
|
|
9
|
-
self_arg = ''
|
|
10
|
-
if class_name is not None:
|
|
11
|
-
access_prefix = f'object.__getattribute__(self, "'
|
|
12
|
-
access_suffix = '")'
|
|
13
|
-
self_arg = ', self'
|
|
14
|
-
|
|
15
|
-
trampoline_name = '_mutmut_trampoline'
|
|
16
|
-
|
|
17
|
-
return f"""
|
|
18
|
-
{mutants_dict}
|
|
19
|
-
|
|
20
|
-
def {orig_name}({'self, ' if class_name is not None else ''}*args, **kwargs):
|
|
21
|
-
result = {trampoline_name}({access_prefix}{mangled_name}__mutmut_orig{access_suffix}, {access_prefix}{mangled_name}__mutmut_mutants{access_suffix}, args, kwargs{self_arg})
|
|
22
|
-
return result
|
|
23
|
-
|
|
24
|
-
{orig_name}.__signature__ = _mutmut_signature({mangled_name}__mutmut_orig)
|
|
25
|
-
{mangled_name}__mutmut_orig.__name__ = '{mangled_name}'
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
def mangle_function_name(*, name, class_name):
|
|
29
|
-
assert CLASS_NAME_SEPARATOR not in name
|
|
30
|
-
if class_name:
|
|
31
|
-
assert CLASS_NAME_SEPARATOR not in class_name
|
|
32
|
-
prefix = f'x{CLASS_NAME_SEPARATOR}{class_name}{CLASS_NAME_SEPARATOR}'
|
|
33
|
-
else:
|
|
34
|
-
prefix = 'x_'
|
|
35
|
-
return f'{prefix}{name}'
|
|
36
|
-
|
|
37
|
-
# noinspection PyUnresolvedReferences
|
|
38
|
-
# language=python
|
|
39
|
-
trampoline_impl = """
|
|
40
|
-
from inspect import signature as _mutmut_signature
|
|
41
|
-
from typing import Annotated
|
|
42
|
-
from typing import Callable
|
|
43
|
-
from typing import ClassVar
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
MutantDict = Annotated[dict[str, Callable], "Mutant"]
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _mutmut_trampoline(orig, mutants, call_args, call_kwargs, self_arg = None):
|
|
50
|
-
\"""Forward call to original or mutated function, depending on the environment\"""
|
|
51
|
-
import os
|
|
52
|
-
mutant_under_test = os.environ['MUTANT_UNDER_TEST']
|
|
53
|
-
if mutant_under_test == 'fail':
|
|
54
|
-
from mutmut.__main__ import MutmutProgrammaticFailException
|
|
55
|
-
raise MutmutProgrammaticFailException('Failed programmatically')
|
|
56
|
-
elif mutant_under_test == 'stats':
|
|
57
|
-
from mutmut.__main__ import record_trampoline_hit
|
|
58
|
-
record_trampoline_hit(orig.__module__ + '.' + orig.__name__)
|
|
59
|
-
result = orig(*call_args, **call_kwargs)
|
|
60
|
-
return result
|
|
61
|
-
prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_'
|
|
62
|
-
if not mutant_under_test.startswith(prefix):
|
|
63
|
-
result = orig(*call_args, **call_kwargs)
|
|
64
|
-
return result
|
|
65
|
-
mutant_name = mutant_under_test.rpartition('.')[-1]
|
|
66
|
-
if self_arg is not None:
|
|
67
|
-
# call to a class method where self is not bound
|
|
68
|
-
result = mutants[mutant_name](self_arg, *call_args, **call_kwargs)
|
|
69
|
-
else:
|
|
70
|
-
result = mutants[mutant_name](*call_args, **call_kwargs)
|
|
71
|
-
return result
|
|
72
|
-
|
|
73
|
-
"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|