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.
@@ -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.4.0
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.4.0"
3
+ version = "3.5.0"
4
4
  description = "mutation testing for Python 3"
5
- keyowrds = ["mutmut", "mutant", "mutation", "test", "testing"]
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: Union[Path, str]) -> None:
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 path in mutmut.config.paths_to_mutate:
194
- output_path: Path = Path('mutants') / path
195
- if isdir(path):
196
- shutil.copytree(path, output_path, dirs_exist_ok=True)
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
- output_path.parent.mkdir(exist_ok=True, parents=True)
199
- shutil.copyfile(path, output_path)
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: Optional[Exception] = None
213
+ warnings: list[Warning] = field(default_factory=list)
214
+ error: Exception | None = None
215
+ unmodified: bool = False
216
+ ignored: bool = False
206
217
 
207
- def create_mutants(max_children: int):
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(warnings=[])
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(warnings=[], error=e)
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.copy(path, destination)
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, hash_by_function_name = write_all_mutants_to_file(out=out, source=source, filename=filename)
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, hash_by_function_name = [], {}
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
- '.'.join([module_name, x]).replace('.__init__.', '.'): None
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
- # TODO: function hashes are currently not used. Reimplement this when needed.
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.hash_by_function_name = {}
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
- assert not meta, f'Meta file {self.meta_path} constains unexpected keys: {set(meta.keys())}'
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: List[str] = mutmut.config.pytest_add_cli_args
429
- self._pytest_add_cli_args_test_selection: List[str] = mutmut.config.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: List[Path]
713
- do_not_mutate: List[str]
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: List[Path]
717
- pytest_add_cli_args: List[str]
718
- pytest_add_cli_args_test_selection: List[str]
719
- tests_dir: List[str]
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: Dict[str, SourceFileMutationData] = {}
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: Union[tuple, list], max_children: Union[None, int]):
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: Dict[int, SourceFileMutationData] = {} # many pids map to one MutationData
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) -> Union[cst.FunctionDef, None]:
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 mutants.")
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 != 'killed' or show_killed:
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}')
@@ -2,7 +2,6 @@ import coverage
2
2
  import importlib
3
3
  import sys
4
4
  from pathlib import Path
5
- import json
6
5
 
7
6
 
8
7
  # Returns a set of lines that are covered in this file gvein the covered_lines dict
@@ -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 build_trampoline, mangle_function_name, trampoline_impl
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
- # trampoline that forwards the calls
251
- trampoline = list(cst.parse_module(build_trampoline(orig_name=name, mutants=mutant_names, class_name=class_name)).body)
252
- trampoline[0] = trampoline[0].with_changes(leading_lines=[cst.EmptyLine()])
253
- nodes.extend(trampoline)
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