mutmut 3.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mutmut/__main__.py ADDED
@@ -0,0 +1,1695 @@
1
+ import ast
2
+ import fnmatch
3
+ import gc
4
+ import inspect
5
+ import itertools
6
+ import json
7
+ import os
8
+ import resource
9
+ import shutil
10
+ import signal
11
+ import sys
12
+ from abc import ABC
13
+ from collections import defaultdict
14
+ from configparser import (
15
+ ConfigParser,
16
+ NoOptionError,
17
+ NoSectionError,
18
+ )
19
+ from contextlib import contextmanager
20
+ from dataclasses import dataclass
21
+ from datetime import (
22
+ datetime,
23
+ timedelta,
24
+ )
25
+ from difflib import unified_diff
26
+ from functools import lru_cache
27
+ from hashlib import md5
28
+ from io import TextIOBase
29
+ from json import JSONDecodeError
30
+ from math import ceil
31
+ from os import (
32
+ makedirs,
33
+ walk,
34
+ )
35
+ from os.path import (
36
+ isdir,
37
+ isfile,
38
+ )
39
+ from pathlib import Path
40
+ from signal import SIGTERM
41
+ from textwrap import (
42
+ dedent,
43
+ indent,
44
+ )
45
+ from threading import Thread
46
+ from time import (
47
+ process_time,
48
+ sleep,
49
+ )
50
+ from typing import (
51
+ Dict,
52
+ List,
53
+ )
54
+
55
+ import click
56
+ from parso import (
57
+ parse,
58
+ ParserSyntaxError,
59
+ )
60
+ from rich.text import Text
61
+ from setproctitle import setproctitle
62
+
63
+ import mutmut
64
+
65
+ # Document: surviving mutants are retested when you ask mutmut to retest them, interactively in the UI or via command line
66
+
67
+ # 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
+
71
+ NEVER_MUTATE_FUNCTION_NAMES = {'__getattribute__', '__setattr__'}
72
+ NEVER_MUTATE_FUNCTION_CALLS = {'isinstance', 'len'}
73
+ CLASS_NAME_SEPARATOR = 'ǁ'
74
+
75
+
76
+ status_by_exit_code = {
77
+ 1: 'killed',
78
+ 3: 'killed', # internal error in pytest means a kill
79
+ -24: 'killed',
80
+ 0: 'survived',
81
+ 5: 'no tests',
82
+ 2: 'check was interrupted by user',
83
+ None: 'not checked',
84
+ 33: 'no tests',
85
+ 34: 'skipped',
86
+ 35: 'suspicious',
87
+ 36: 'timeout',
88
+ 24: 'timeout', # SIGXCPU
89
+ 152: 'timeout', # SIGXCPU
90
+ 255: 'timeout',
91
+ }
92
+
93
+ emoji_by_status = {
94
+ 'survived': '🙁',
95
+ 'no tests': '🫥',
96
+ 'timeout': '⏰',
97
+ 'suspicious': '🤔',
98
+ 'skipped': '🔇',
99
+ 'check was interrupted by user': '🛑',
100
+ 'not checked': '?',
101
+ 'killed': '🎉',
102
+ }
103
+
104
+ exit_code_to_emoji = {
105
+ exit_code: emoji_by_status[status]
106
+ for exit_code, status in status_by_exit_code.items()
107
+ }
108
+
109
+
110
+ def guess_paths_to_mutate():
111
+ """Guess the path to source code to mutate
112
+
113
+ :rtype: str
114
+ """
115
+ this_dir = os.getcwd().split(os.sep)[-1]
116
+ if isdir('lib'):
117
+ return ['lib']
118
+ elif isdir('src'):
119
+ return ['src']
120
+ elif isdir(this_dir):
121
+ return [this_dir]
122
+ elif isdir(this_dir.replace('-', '_')):
123
+ return [this_dir.replace('-', '_')]
124
+ elif isdir(this_dir.replace(' ', '_')):
125
+ return [this_dir.replace(' ', '_')]
126
+ elif isdir(this_dir.replace('-', '')):
127
+ return [this_dir.replace('-', '')]
128
+ elif isdir(this_dir.replace(' ', '')):
129
+ return [this_dir.replace(' ', '')]
130
+ if isfile(this_dir + '.py'):
131
+ return [this_dir + '.py']
132
+ raise FileNotFoundError(
133
+ 'Could not figure out where the code to mutate is. '
134
+ 'Please specify it by adding "paths_to_mutate=code_dir" in setup.cfg to the [mutmut] section.')
135
+
136
+
137
+ def record_trampoline_hit(name):
138
+ assert not name.startswith('src.'), f'Failed trampoline hit. Module name starts with `src.`, which is invalid'
139
+ if mutmut.config.max_stack_depth != -1:
140
+ f = inspect.currentframe()
141
+ c = mutmut.config.max_stack_depth
142
+ while c and f:
143
+ if 'pytest' in f.f_code.co_filename or 'hammett' in f.f_code.co_filename:
144
+ break
145
+ f = f.f_back
146
+ c -= 1
147
+
148
+ if not c:
149
+ return
150
+
151
+ mutmut._stats.add(name)
152
+
153
+
154
+ def walk_all_files():
155
+ for path in mutmut.config.paths_to_mutate:
156
+ if not isdir(path):
157
+ if isfile(path):
158
+ yield '', str(path)
159
+ continue
160
+ for root, dirs, files in walk(path):
161
+ for filename in files:
162
+ yield root, filename
163
+
164
+
165
+ def walk_source_files():
166
+ for root, filename in walk_all_files():
167
+ if filename.endswith('.py'):
168
+ yield Path(root) / filename
169
+
170
+
171
+ class InvalidMutantException(Exception):
172
+ pass
173
+
174
+
175
+ class MutmutProgrammaticFailException(Exception):
176
+ pass
177
+
178
+
179
+ class CollectTestsFailedException(Exception):
180
+ pass
181
+
182
+
183
+ class BadTestExecutionCommandsException(Exception):
184
+ pass
185
+
186
+
187
+ # noinspection PyUnresolvedReferences
188
+ # language=python
189
+ trampoline_impl = """
190
+ from inspect import signature as _mutmut_signature
191
+
192
+ def _mutmut_trampoline(orig, mutants, *args, **kwargs):
193
+ import os
194
+ mutant_under_test = os.environ['MUTANT_UNDER_TEST']
195
+ if mutant_under_test == 'fail':
196
+ from mutmut.__main__ import MutmutProgrammaticFailException
197
+ raise MutmutProgrammaticFailException('Failed programmatically')
198
+ elif mutant_under_test == 'stats':
199
+ from mutmut.__main__ import record_trampoline_hit
200
+ record_trampoline_hit(orig.__module__ + '.' + orig.__name__)
201
+ result = orig(*args, **kwargs)
202
+ return result # for the yield case
203
+ prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_'
204
+ if not mutant_under_test.startswith(prefix):
205
+ result = orig(*args, **kwargs)
206
+ return result # for the yield case
207
+ mutant_name = mutant_under_test.rpartition('.')[-1]
208
+ result = mutants[mutant_name](*args, **kwargs)
209
+ return result
210
+
211
+ """
212
+ yield_from_trampoline_impl = trampoline_impl.replace('result = ', 'result = yield from ').replace('_mutmut_trampoline', '_mutmut_yield_from_trampoline')
213
+
214
+
215
+ def create_mutants():
216
+ for path in walk_source_files():
217
+ print(path)
218
+ output_path = Path('mutants') / path
219
+ makedirs(output_path.parent, exist_ok=True)
220
+
221
+ if mutmut.config.should_ignore_for_mutation(path):
222
+ shutil.copy(path, output_path)
223
+ else:
224
+ create_mutants_for_file(path, output_path)
225
+
226
+
227
+ def copy_also_copy_files():
228
+ assert isinstance(mutmut.config.also_copy, list)
229
+ for path in mutmut.config.also_copy:
230
+ print(' also copying', path)
231
+ path = Path(path)
232
+ destination = Path('mutants') / path
233
+ if not path.exists():
234
+ continue
235
+ if path.is_file():
236
+ shutil.copy(path, destination)
237
+ else:
238
+ shutil.copytree(path, destination, dirs_exist_ok=True)
239
+
240
+
241
+ def pragma_no_mutate_lines(source):
242
+ return {
243
+ i + 1
244
+ for i, line in enumerate(source.split('\n'))
245
+ if '# pragma:' in line and 'no mutate' in line.partition('# pragma:')[-1]
246
+ }
247
+
248
+
249
+ def create_mutants_for_file(filename, output_path):
250
+ input_stat = os.stat(filename)
251
+
252
+ if output_path.exists() and output_path.stat().st_mtime == input_stat.st_mtime:
253
+ # print(' skipped', output_path, 'already up to date')
254
+ return
255
+
256
+ with open(filename) as f:
257
+ source = f.read()
258
+
259
+ with open(output_path, 'w') as out:
260
+ mutant_names, hash_by_function_name = write_all_mutants_to_file(out=out, source=source, filename=filename)
261
+
262
+ # validate no syntax errors of mutants
263
+ with open(output_path) as f:
264
+ try:
265
+ ast.parse(f.read())
266
+ except (IndentationError, SyntaxError) as e:
267
+ print(output_path, 'has invalid syntax: ', e)
268
+ exit(1)
269
+
270
+ source_file_mutation_data = SourceFileMutationData(path=filename)
271
+ module_name = strip_prefix(str(filename)[:-len(filename.suffix)].replace(os.sep, '.'), prefix='src.')
272
+
273
+ source_file_mutation_data.exit_code_by_key = {
274
+ '.'.join([module_name, x]).replace('.__init__.', '.'): None
275
+ for x in mutant_names
276
+ }
277
+ source_file_mutation_data.hash_by_function_name = hash_by_function_name
278
+ assert None not in hash_by_function_name
279
+ source_file_mutation_data.save()
280
+
281
+ os.utime(output_path, (input_stat.st_atime, input_stat.st_mtime))
282
+
283
+
284
+ def ensure_ends_with_newline(source):
285
+ if not source.endswith('\n'):
286
+ return source + '\n'
287
+ else:
288
+ return source
289
+
290
+
291
+ def write_all_mutants_to_file(*, out, source, filename):
292
+ no_mutate_lines = pragma_no_mutate_lines(source)
293
+
294
+ hash_by_function_name = {}
295
+ mutant_names = []
296
+
297
+ try:
298
+ ast = parse(ensure_ends_with_newline(source), error_recovery=False)
299
+ except ParserSyntaxError:
300
+ print(f'Warning: unsupported syntax in {filename}, skipping')
301
+ out.write(source)
302
+ return [], {}
303
+
304
+ for type_, x, name_and_hash, mutant_name in yield_mutants_for_module(ast, no_mutate_lines):
305
+ out.write(x)
306
+ if mutant_name:
307
+ mutant_names.append(mutant_name)
308
+ if name_and_hash:
309
+ assert type_ == 'orig'
310
+ name, hash = name_and_hash
311
+ hash_by_function_name[name] = hash
312
+
313
+ return mutant_names, hash_by_function_name
314
+
315
+
316
+ def build_trampoline(*, orig_name, mutants, class_name, is_generator):
317
+ assert orig_name not in NEVER_MUTATE_FUNCTION_NAMES
318
+
319
+ mangled_name = mangle_function_name(name=orig_name, class_name=class_name)
320
+
321
+ mutants_dict = f'{mangled_name}__mutmut_mutants = {{\n' + ', \n '.join(f'{repr(m)}: {m}' for m in mutants) + '\n}'
322
+ access_prefix = ''
323
+ access_suffix = ''
324
+ if class_name is not None:
325
+ access_prefix = f'object.__getattribute__(self, "'
326
+ access_suffix = '")'
327
+
328
+ if is_generator:
329
+ yield_statement = 'yield from ' # note the space at the end!
330
+ trampoline_name = '_mutmut_yield_from_trampoline'
331
+ else:
332
+ yield_statement = ''
333
+ trampoline_name = '_mutmut_trampoline'
334
+
335
+ return f"""
336
+ {mutants_dict}
337
+
338
+ def {orig_name}({'self, ' if class_name is not None else ''}*args, **kwargs):
339
+ result = {yield_statement}{trampoline_name}({access_prefix}{mangled_name}__mutmut_orig{access_suffix}, {access_prefix}{mangled_name}__mutmut_mutants{access_suffix}, *args, **kwargs)
340
+ return result
341
+
342
+ {orig_name}.__signature__ = _mutmut_signature({mangled_name}__mutmut_orig)
343
+ {mangled_name}__mutmut_orig.__name__ = '{mangled_name}'
344
+ """
345
+
346
+
347
+ @contextmanager
348
+ def rename_function_node(node, *, suffix, class_name):
349
+ orig_name = node.name.value
350
+
351
+ mangled_name = mangle_function_name(name=orig_name, class_name=class_name)
352
+
353
+ node.name.value = mangled_name + f'__mutmut_{suffix}'
354
+ yield
355
+ node.name.value = orig_name
356
+
357
+
358
+ sentinel = object()
359
+
360
+
361
+ def filter_funcdef_children(children):
362
+ # Throw away type annotation for return type
363
+ r = []
364
+ in_annotation = False
365
+ for c in children:
366
+ if c.type == 'operator':
367
+ if c.value == '->':
368
+ in_annotation = True
369
+ if c.value == ':':
370
+ in_annotation = False
371
+
372
+ if not in_annotation:
373
+ r.append(c)
374
+ return r
375
+
376
+
377
+ def yield_mutants_for_node(*, func_node, class_name=None, context, node):
378
+ # do not mutate static typing annotations
379
+ if node.type == 'tfpdef':
380
+ return
381
+
382
+ # Some functions should not be mutated
383
+ if node.type == 'atom_expr' and node.children[0].type == 'name' and node.children[0].value in NEVER_MUTATE_FUNCTION_CALLS:
384
+ return
385
+
386
+ # The rest
387
+ if hasattr(node, 'children'):
388
+ children = node.children
389
+ if node.type == 'funcdef':
390
+ children = filter_funcdef_children(children)
391
+ for child_node in children:
392
+ context.stack.append(child_node)
393
+ try:
394
+ yield from yield_mutants_for_node(func_node=func_node, class_name=class_name, context=context, node=child_node)
395
+ finally:
396
+ context.stack.pop()
397
+
398
+ mutation = mutmut.mutation_by_ast_type.get(node.type)
399
+ if not mutation:
400
+ return
401
+
402
+ if context.exclude_node(node):
403
+ return
404
+
405
+ old_value = getattr(node, 'value', sentinel)
406
+ old_children = getattr(node, 'children', sentinel)
407
+
408
+ for m in mutation(
409
+ context=context,
410
+ node=node,
411
+ value=getattr(node, 'value', None),
412
+ children=getattr(node, 'children', None),
413
+ ):
414
+ new_value = m.get('value', sentinel)
415
+ new_children = m.get('children', sentinel)
416
+ assert new_value is not sentinel or new_children is not sentinel
417
+ if new_value is not sentinel:
418
+ assert old_value != new_value
419
+ setattr(node, 'value', new_value)
420
+ if new_children is not sentinel:
421
+ assert isinstance(new_children, list)
422
+ assert old_children != new_children
423
+ setattr(node, 'children', new_children)
424
+
425
+ # noinspection PyArgumentList
426
+ with rename_function_node(func_node, suffix=f'{context.count}', class_name=class_name):
427
+ code = func_node.get_code()
428
+ if valid_syntax(code):
429
+ context.count += 1
430
+
431
+ context.mutants.append(func_node.name.value)
432
+ yield 'mutant', code, None, func_node.name.value
433
+
434
+ if old_value is not sentinel:
435
+ setattr(node, 'value', old_value)
436
+ if old_children is not sentinel:
437
+ setattr(node, 'children', old_children)
438
+
439
+
440
+ def valid_syntax(code):
441
+ try:
442
+ ast.parse(dedent(code))
443
+ return True
444
+ except (SyntaxError, IndentationError):
445
+ return False
446
+
447
+
448
+ class FuncContext:
449
+ def __init__(self, no_mutate_lines=None, dict_synonyms=None):
450
+ self.count = 1
451
+ self.mutants = []
452
+ self.stack = []
453
+ self.dict_synonyms = {'dict'} | (dict_synonyms or set())
454
+ self.no_mutate_lines = no_mutate_lines or []
455
+
456
+ def exclude_node(self, node):
457
+ if node.start_pos[0] in self.no_mutate_lines:
458
+ return True
459
+ return False
460
+
461
+ def is_inside_annassign(self):
462
+ for node in self.stack:
463
+ if node.type == 'annassign':
464
+ return True
465
+ return False
466
+
467
+ def is_inside_dict_synonym_call(self):
468
+ for node in self.stack:
469
+ if node.type == 'atom_expr' and node.children[0].type == 'name' and node.children[0].value in self.dict_synonyms:
470
+ return True
471
+ return False
472
+
473
+
474
+ def is_generator(node):
475
+ assert node.type == 'funcdef'
476
+
477
+ def _is_generator(n):
478
+ if n is not node and n.type in ('funcdef', 'classdef'):
479
+ return False
480
+
481
+ if n.type == 'keyword' and n.value == 'yield':
482
+ return True
483
+
484
+ for c in getattr(n, 'children', []):
485
+ if _is_generator(c):
486
+ return True
487
+ return False
488
+ return _is_generator(node)
489
+
490
+
491
+ def yield_mutants_for_function(node, *, class_name=None, no_mutate_lines):
492
+ assert node.type == 'funcdef'
493
+
494
+ if node.name.value in NEVER_MUTATE_FUNCTION_NAMES:
495
+ yield 'filler', node.get_code(), None, None
496
+ return
497
+
498
+ hash_of_orig = md5(node.get_code().encode()).hexdigest()
499
+
500
+ orig_name = node.name.value
501
+ # noinspection PyArgumentList
502
+ with rename_function_node(node, suffix='orig', class_name=class_name):
503
+ yield 'orig', node.get_code(), (orig_name, hash_of_orig), None
504
+
505
+ context = FuncContext(no_mutate_lines=no_mutate_lines)
506
+
507
+ return_annotation_started = False
508
+
509
+ for child_node in node.children:
510
+ if child_node.type == 'operator' and child_node.value == '->':
511
+ return_annotation_started = True
512
+
513
+ if return_annotation_started and child_node.type == 'operator' and child_node.value == ':':
514
+ return_annotation_started = False
515
+
516
+ if return_annotation_started:
517
+ continue
518
+
519
+ context.stack.append(child_node)
520
+ try:
521
+ yield from yield_mutants_for_node(func_node=node, class_name=class_name, node=child_node, context=context)
522
+ finally:
523
+ context.stack.pop()
524
+
525
+ trampoline = build_trampoline(orig_name=node.name.value, mutants=context.mutants, class_name=class_name, is_generator=is_generator(node))
526
+ if class_name is not None:
527
+ trampoline = indent(trampoline, ' ')
528
+ yield 'trampoline', trampoline, None, None
529
+ yield 'filler', '\n\n', None, None
530
+
531
+
532
+ def yield_mutants_for_class(node, no_mutate_lines):
533
+ assert node.type == 'classdef'
534
+ for child_node in node.children:
535
+ if child_node.type == 'suite':
536
+ yield from yield_mutants_for_class_body(child_node, no_mutate_lines=no_mutate_lines)
537
+ else:
538
+ yield 'filler', child_node.get_code(), None, None
539
+
540
+
541
+ def yield_mutants_for_class_body(node, no_mutate_lines):
542
+ assert node.type == 'suite'
543
+ class_name = node.parent.name.value
544
+
545
+ for child_node in node.children:
546
+ if child_node.type == 'funcdef':
547
+ yield from yield_mutants_for_function(child_node, class_name=class_name, no_mutate_lines=no_mutate_lines)
548
+ else:
549
+ yield 'filler', child_node.get_code(), None, None
550
+
551
+
552
+ def is_from_future_import_node(c):
553
+ if c.type == 'simple_stmt':
554
+ if c.children:
555
+ c2 = c.children[0]
556
+ if c2.type == 'import_from' and c2.children[1].type == 'name' and c2.children[1].value == '__future__':
557
+ return True
558
+ return False
559
+
560
+
561
+ def yield_future_imports(node):
562
+ for c in node.children:
563
+ if is_from_future_import_node(c):
564
+ yield 'filler', c.get_code(), None, None
565
+
566
+
567
+ def yield_mutants_for_module(node, no_mutate_lines):
568
+ assert node.type == 'file_input'
569
+
570
+ # First yield `from __future__`, then the rest
571
+ yield from yield_future_imports(node)
572
+
573
+ yield 'trampoline_impl', trampoline_impl, None, None
574
+ yield 'trampoline_impl', yield_from_trampoline_impl, None, None
575
+ yield 'filler', '\n', None, None
576
+ for child_node in node.children:
577
+ if child_node.type == 'funcdef':
578
+ yield from yield_mutants_for_function(child_node, no_mutate_lines=no_mutate_lines)
579
+ elif child_node.type == 'classdef':
580
+ yield from yield_mutants_for_class(child_node, no_mutate_lines=no_mutate_lines)
581
+ elif is_from_future_import_node(child_node):
582
+ # Don't yield `from __future__` after trampoline
583
+ pass
584
+ else:
585
+ yield 'filler', child_node.get_code(), None, None
586
+
587
+
588
+ class SourceFileMutationData:
589
+ def __init__(self, *, path):
590
+ self.estimated_time_of_tests_by_mutant = {}
591
+ self.path = path
592
+ self.meta_path = Path('mutants') / (str(path) + '.meta')
593
+ self.meta = None
594
+ self.key_by_pid = {}
595
+ self.exit_code_by_key = {}
596
+ self.hash_by_function_name = {}
597
+ self.start_time_by_pid = {}
598
+ self.estimated_time_of_tests_by_pid = {}
599
+
600
+ def load(self):
601
+ try:
602
+ with open(self.meta_path) as f:
603
+ self.meta = json.load(f)
604
+ except FileNotFoundError:
605
+ return
606
+
607
+ self.exit_code_by_key = self.meta.pop('exit_code_by_key')
608
+ self.hash_by_function_name = self.meta.pop('hash_by_function_name')
609
+ assert not self.meta, self.meta # We should read all the data!
610
+
611
+ def register_pid(self, *, pid, key, estimated_time_of_tests):
612
+ self.key_by_pid[pid] = key
613
+ self.start_time_by_pid[pid] = datetime.now()
614
+ self.estimated_time_of_tests_by_pid[pid] = estimated_time_of_tests
615
+
616
+ def register_result(self, *, pid, exit_code):
617
+ assert self.key_by_pid[pid] in self.exit_code_by_key
618
+ self.exit_code_by_key[self.key_by_pid[pid]] = exit_code
619
+ # TODO: maybe rate limit this? Saving on each result can slow down mutation testing a lot if the test run is fast.
620
+ del self.key_by_pid[pid]
621
+ del self.start_time_by_pid[pid]
622
+ self.save()
623
+
624
+ def stop_children(self):
625
+ for pid in self.key_by_pid.keys():
626
+ os.kill(pid, SIGTERM)
627
+
628
+ def save(self):
629
+ with open(self.meta_path, 'w') as f:
630
+ json.dump(dict(
631
+ exit_code_by_key=self.exit_code_by_key,
632
+ hash_by_function_name=self.hash_by_function_name,
633
+ ), f, indent=4)
634
+
635
+
636
+ def unused(*_):
637
+ pass
638
+
639
+
640
+ def strip_prefix(s, *, prefix, strict=False):
641
+ if s.startswith(prefix):
642
+ return s[len(prefix):]
643
+ assert strict is False, f"String '{s}' does not start with prefix '{prefix}'"
644
+ return s
645
+
646
+
647
+ class TestRunner(ABC):
648
+ def run_stats(self, *, tests):
649
+ raise NotImplementedError()
650
+
651
+ def run_forced_fail(self):
652
+ raise NotImplementedError()
653
+
654
+ def prepare_main_test_run(self):
655
+ pass
656
+
657
+ def run_tests(self, *, mutant_name, tests):
658
+ raise NotImplementedError()
659
+
660
+ def list_all_tests(self):
661
+ raise NotImplementedError()
662
+
663
+
664
+ @contextmanager
665
+ def change_cwd(path):
666
+ old_cwd = os.path.abspath(os.getcwd())
667
+ os.chdir(path)
668
+ try:
669
+ yield
670
+ finally:
671
+ os.chdir(old_cwd)
672
+
673
+
674
+ def collected_test_names():
675
+ return set(mutmut.duration_by_test.keys())
676
+
677
+
678
+ class ListAllTestsResult:
679
+ def __init__(self, *, ids):
680
+ assert isinstance(ids, set)
681
+ self.ids = ids
682
+
683
+ def clear_out_obsolete_test_names(self):
684
+ count_before = sum(len(x) for x in mutmut.tests_by_mangled_function_name)
685
+ mutmut.tests_by_mangled_function_name = defaultdict(set, **{
686
+ k: {test_name for test_name in test_names if test_name in self.ids}
687
+ for k, test_names in mutmut.tests_by_mangled_function_name.items()
688
+ })
689
+ count_after = sum(len(x) for x in mutmut.tests_by_mangled_function_name)
690
+ if count_before != count_after:
691
+ print(f'Removed {count_before - count_after} obsolete test names')
692
+ save_stats()
693
+
694
+ def new_tests(self):
695
+ return self.ids - collected_test_names()
696
+
697
+
698
+ class PytestRunner(TestRunner):
699
+ # noinspection PyMethodMayBeStatic
700
+ def execute_pytest(self, params, **kwargs):
701
+ import pytest
702
+ params += ['--rootdir=.']
703
+ if mutmut.config.debug:
704
+ params = ['-vv'] + params
705
+ print('python -m pytest ', ' '.join(params))
706
+ exit_code = int(pytest.main(params, **kwargs))
707
+ if mutmut.config.debug:
708
+ print(' exit code', exit_code)
709
+ if exit_code == 4:
710
+ raise BadTestExecutionCommandsException(params)
711
+ return exit_code
712
+
713
+ def run_stats(self, *, tests):
714
+ class StatsCollector:
715
+ # noinspection PyMethodMayBeStatic
716
+ def pytest_runtest_teardown(self, item, nextitem):
717
+ unused(nextitem)
718
+ for function in mutmut._stats:
719
+ mutmut.tests_by_mangled_function_name[function].add(strip_prefix(item._nodeid, prefix='mutants/'))
720
+ mutmut._stats.clear()
721
+
722
+ # noinspection PyMethodMayBeStatic
723
+ def pytest_runtest_makereport(self, item, call):
724
+ mutmut.duration_by_test[item.nodeid] = call.duration
725
+
726
+ stats_collector = StatsCollector()
727
+
728
+ with change_cwd('mutants'):
729
+ return int(self.execute_pytest(['-x', '-q', '--import-mode=append'] + list(tests), plugins=[stats_collector]))
730
+
731
+ def run_tests(self, *, mutant_name, tests):
732
+ with change_cwd('mutants'):
733
+ return int(self.execute_pytest(['-x', '-q', '--import-mode=append'] + list(tests)))
734
+
735
+ def run_forced_fail(self):
736
+ with change_cwd('mutants'):
737
+ return int(self.execute_pytest(['-x', '-q', '--import-mode=append']))
738
+
739
+ def list_all_tests(self):
740
+ class TestsCollector:
741
+ def pytest_collection_modifyitems(self, items):
742
+ self.nodeids = {item.nodeid for item in items}
743
+
744
+ collector = TestsCollector()
745
+
746
+ with change_cwd('mutants'):
747
+ exit_code = int(self.execute_pytest(['-x', '-q', '--collect-only'], plugins=[collector]))
748
+ if exit_code != 0:
749
+ raise CollectTestsFailedException()
750
+
751
+ return ListAllTestsResult(ids=collector.nodeids)
752
+
753
+
754
+ class HammettRunner(TestRunner):
755
+ def __init__(self):
756
+ self.hammett_kwargs = None
757
+
758
+ def run_stats(self, *, tests):
759
+ import hammett
760
+ print('Running hammett stats...')
761
+
762
+ def post_test_callback(_name, **_):
763
+ for function in mutmut._stats:
764
+ mutmut.tests_by_mangled_function_name[function].add(_name)
765
+ mutmut._stats.clear()
766
+
767
+ return hammett.main(quiet=True, fail_fast=True, disable_assert_analyze=True, post_test_callback=post_test_callback, use_cache=False, insert_cwd=False)
768
+
769
+ def run_forced_fail(self):
770
+ import hammett
771
+ return hammett.main(quiet=True, fail_fast=True, disable_assert_analyze=True, use_cache=False, insert_cwd=False)
772
+
773
+ def prepare_main_test_run(self):
774
+ import hammett
775
+ self.hammett_kwargs = hammett.main_setup(
776
+ quiet=True,
777
+ fail_fast=True,
778
+ disable_assert_analyze=True,
779
+ use_cache=False,
780
+ insert_cwd=False,
781
+ )
782
+
783
+ def run_tests(self, *, mutant_name, tests):
784
+ import hammett
785
+ hammett.Config.workerinput = dict(workerinput=f'_{mutant_name}')
786
+ return hammett.main_run_tests(**self.hammett_kwargs, tests=tests)
787
+
788
+
789
+ def mangle_function_name(*, name, class_name):
790
+ assert CLASS_NAME_SEPARATOR not in name
791
+ if class_name:
792
+ assert CLASS_NAME_SEPARATOR not in class_name
793
+ prefix = f'x{CLASS_NAME_SEPARATOR}{class_name}{CLASS_NAME_SEPARATOR}'
794
+ else:
795
+ prefix = 'x_'
796
+ return f'{prefix}{name}'
797
+
798
+
799
+ def mangled_name_from_mutant_name(mutant_name):
800
+ assert '__mutmut_' in mutant_name, mutant_name
801
+ return mutant_name.partition('__mutmut_')[0]
802
+
803
+
804
+ def orig_function_and_class_names_from_key(mutant_name):
805
+ r = mangled_name_from_mutant_name(mutant_name)
806
+ _, _, r = r.rpartition('.')
807
+ class_name = None
808
+ if CLASS_NAME_SEPARATOR in r:
809
+ class_name = r[r.index(CLASS_NAME_SEPARATOR) + 1: r.rindex(CLASS_NAME_SEPARATOR)]
810
+ r = r[r.rindex(CLASS_NAME_SEPARATOR) + 1:]
811
+ else:
812
+ assert r.startswith('x_'), r
813
+ r = r[2:]
814
+ return r, class_name
815
+
816
+
817
+ spinner = itertools.cycle('⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏')
818
+
819
+
820
+ def status_printer():
821
+ """Manage the printing and in-place updating of a line of characters
822
+
823
+ .. note::
824
+ If the string is longer than a line, then in-place updating may not
825
+ work (it will print a new line at each refresh).
826
+ """
827
+ last_len = [0]
828
+ last_update = [datetime(1900, 1, 1)]
829
+ update_threshold = timedelta(seconds=0.1)
830
+
831
+ def p(s, *, force_output=False):
832
+ if not force_output and (datetime.now() - last_update[0]) < update_threshold:
833
+ return
834
+ s = next(spinner) + ' ' + s
835
+ len_s = len(s)
836
+ output = '\r' + s + (' ' * max(last_len[0] - len_s, 0))
837
+ sys.__stdout__.write(output)
838
+ sys.__stdout__.flush()
839
+ last_len[0] = len_s
840
+ return p
841
+
842
+
843
+ print_status = status_printer()
844
+
845
+
846
+ @dataclass
847
+ class Stat:
848
+ not_checked: int
849
+ killed: int
850
+ survived: int
851
+ total: int
852
+ no_tests: int
853
+ skipped: int
854
+ suspicious: int
855
+ timeout: int
856
+ check_was_interrupted_by_user: int
857
+
858
+
859
+ def collect_stat(m: SourceFileMutationData):
860
+ r = {
861
+ k.replace(' ', '_'): 0
862
+ for k in status_by_exit_code.values()
863
+ }
864
+ for k, v in m.exit_code_by_key.items():
865
+ # noinspection PyTypeChecker
866
+ r[status_by_exit_code[v].replace(' ', '_')] += 1
867
+ return Stat(
868
+ **r,
869
+ total=sum(r.values()),
870
+ )
871
+
872
+
873
+ def calculate_summary_stats(source_file_mutation_data_by_path):
874
+ stats = [collect_stat(x) for x in source_file_mutation_data_by_path.values()]
875
+ return Stat(
876
+ not_checked=sum(x.not_checked for x in stats),
877
+ killed=sum(x.killed for x in stats),
878
+ survived=sum(x.survived for x in stats),
879
+ total=sum(x.total for x in stats),
880
+ no_tests=sum(x.no_tests for x in stats),
881
+ skipped=sum(x.skipped for x in stats),
882
+ suspicious=sum(x.suspicious for x in stats),
883
+ timeout=sum(x.timeout for x in stats),
884
+ check_was_interrupted_by_user=sum(x.check_was_interrupted_by_user for x in stats),
885
+ )
886
+
887
+
888
+ def print_stats(source_file_mutation_data_by_path, force_output=False):
889
+ s = calculate_summary_stats(source_file_mutation_data_by_path)
890
+ 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)
891
+
892
+
893
+ def run_forced_fail(runner):
894
+ os.environ['MUTANT_UNDER_TEST'] = 'fail'
895
+ with CatchOutput(spinner_title='Running forced fail test') as catcher:
896
+ try:
897
+ if runner.run_forced_fail() == 0:
898
+ catcher.dump_output()
899
+ print("FAILED")
900
+ os._exit(1)
901
+ except MutmutProgrammaticFailException:
902
+ pass
903
+ os.environ['MUTANT_UNDER_TEST'] = ''
904
+ print(' done')
905
+
906
+
907
+ class CatchOutput:
908
+ def __init__(self, callback=lambda s: None, spinner_title=None):
909
+ self.strings = []
910
+ self.spinner_title = spinner_title or ''
911
+
912
+ class StdOutRedirect(TextIOBase):
913
+ def __init__(self, catcher):
914
+ self.catcher = catcher
915
+
916
+ def write(self, s):
917
+ callback(s)
918
+ if spinner_title:
919
+ print_status(spinner_title)
920
+ self.catcher.strings.append(s)
921
+ return len(s)
922
+ self.redirect = StdOutRedirect(self)
923
+
924
+ # noinspection PyMethodMayBeStatic
925
+ def stop(self):
926
+ sys.stdout = sys.__stdout__
927
+ sys.stderr = sys.__stderr__
928
+
929
+ def start(self):
930
+ if self.spinner_title:
931
+ print_status(self.spinner_title)
932
+ sys.stdout = self.redirect
933
+ sys.stderr = self.redirect
934
+ if mutmut.config.debug:
935
+ self.stop()
936
+
937
+ def dump_output(self):
938
+ self.stop()
939
+ for line in self.strings:
940
+ print(line, end='')
941
+
942
+ def __enter__(self):
943
+ self.start()
944
+ return self
945
+
946
+ def __exit__(self, exc_type, exc_val, exc_tb):
947
+ self.stop()
948
+ if self.spinner_title:
949
+ print()
950
+
951
+
952
+ @dataclass
953
+ class Config:
954
+ also_copy: List[Path]
955
+ do_not_mutate: List[str]
956
+ max_stack_depth: int
957
+ debug: bool
958
+ paths_to_mutate: List[Path]
959
+
960
+ def should_ignore_for_mutation(self, path):
961
+ if not str(path).endswith('.py'):
962
+ return True
963
+ for p in self.do_not_mutate:
964
+ if fnmatch.fnmatch(path, p):
965
+ return True
966
+ return False
967
+
968
+
969
+ def config_reader():
970
+ path = Path('pyproject.toml')
971
+ if path.exists():
972
+ if sys.version_info >= (3, 11):
973
+ from tomllib import loads
974
+ else:
975
+ # noinspection PyPackageRequirements
976
+ from toml import loads
977
+ data = loads(path.read_text('utf-8'))
978
+
979
+ try:
980
+ config = data['tool']['mutmut']
981
+ except KeyError:
982
+ pass
983
+ else:
984
+ def s(key, default):
985
+ try:
986
+ result = config[key]
987
+ except KeyError:
988
+ return default
989
+ return result
990
+ return s
991
+
992
+ config_parser = ConfigParser()
993
+ config_parser.read('setup.cfg')
994
+
995
+ def s(key, default):
996
+ try:
997
+ result = config_parser.get('mutmut', key)
998
+ except (NoOptionError, NoSectionError):
999
+ return default
1000
+ if isinstance(default, list):
1001
+ if '\n' in result:
1002
+ result = [x for x in result.split("\n") if x]
1003
+ else:
1004
+ result = [result]
1005
+ elif isinstance(default, bool):
1006
+ result = result.lower() in ('1', 't', 'true')
1007
+ elif isinstance(default, int):
1008
+ result = int(result)
1009
+ return result
1010
+ return s
1011
+
1012
+
1013
+ @lru_cache()
1014
+ def read_config():
1015
+ s = config_reader()
1016
+
1017
+ mutmut.config = Config(
1018
+ do_not_mutate=s('do_not_mutate', []),
1019
+ also_copy=[
1020
+ Path(y)
1021
+ for y in s('also_copy', [])
1022
+ ] + [
1023
+ Path('tests/'),
1024
+ Path('test/'),
1025
+ Path('setup.cfg'),
1026
+ Path('pyproject.toml'),
1027
+ ] + list(Path('.').glob('test*.py')),
1028
+ max_stack_depth=s('max_stack_depth', -1),
1029
+ debug=s('debug', False),
1030
+ paths_to_mutate=[
1031
+ Path(y)
1032
+ for y in s('paths_to_mutate', [])
1033
+ ] or guess_paths_to_mutate()
1034
+ )
1035
+
1036
+
1037
+ @click.group()
1038
+ def cli():
1039
+ pass
1040
+
1041
+
1042
+ def run_stats_collection(runner, tests=None):
1043
+ if tests is None:
1044
+ tests = [] # Meaning all...
1045
+
1046
+ os.environ['MUTANT_UNDER_TEST'] = 'stats'
1047
+ os.environ['PY_IGNORE_IMPORTMISMATCH'] = '1'
1048
+ start_cpu_time = process_time()
1049
+
1050
+ with CatchOutput(spinner_title='Running stats') as output_catcher:
1051
+ collect_stats_exit_code = runner.run_stats(tests=tests)
1052
+ if collect_stats_exit_code != 0:
1053
+ output_catcher.dump_output()
1054
+ print(f'failed to collect stats. runner returned {collect_stats_exit_code}')
1055
+ exit(1)
1056
+
1057
+ print(' done')
1058
+ if not tests: # again, meaning all
1059
+ mutmut.stats_time = process_time() - start_cpu_time
1060
+
1061
+ if not collected_test_names():
1062
+ print('failed to collect stats, no active tests found')
1063
+ exit(1)
1064
+
1065
+ save_stats()
1066
+
1067
+
1068
+ def collect_or_load_stats(runner):
1069
+ did_load = load_stats()
1070
+
1071
+ if not did_load:
1072
+ # Run full stats
1073
+ run_stats_collection(runner)
1074
+ else:
1075
+ # Run incremental stats
1076
+ with CatchOutput(spinner_title='Listing all tests') as output_catcher:
1077
+ os.environ['MUTANT_UNDER_TEST'] = 'list_all_tests'
1078
+ try:
1079
+ all_tests_result = runner.list_all_tests()
1080
+ except CollectTestsFailedException:
1081
+ output_catcher.dump_output()
1082
+ print('Failed to collect list of tests')
1083
+ exit(1)
1084
+
1085
+ all_tests_result.clear_out_obsolete_test_names()
1086
+
1087
+ new_tests = all_tests_result.new_tests()
1088
+
1089
+ if new_tests:
1090
+ print(f'Found {len(new_tests)} new tests, rerunning stats collection')
1091
+ run_stats_collection(runner, tests=new_tests)
1092
+
1093
+
1094
+ def load_stats():
1095
+ did_load = False
1096
+ try:
1097
+ with open('mutants/mutmut-stats.json') as f:
1098
+ data = json.load(f)
1099
+ for k, v in data.pop('tests_by_mangled_function_name').items():
1100
+ mutmut.tests_by_mangled_function_name[k] |= set(v)
1101
+ mutmut.duration_by_test = data.pop('duration_by_test')
1102
+ mutmut.stats_time = data.pop('stats_time')
1103
+ assert not data, data
1104
+ did_load = True
1105
+ except (FileNotFoundError, JSONDecodeError):
1106
+ pass
1107
+ return did_load
1108
+
1109
+
1110
+ def save_stats():
1111
+ with open('mutants/mutmut-stats.json', 'w') as f:
1112
+ json.dump(dict(
1113
+ tests_by_mangled_function_name={k: list(v) for k, v in mutmut.tests_by_mangled_function_name.items()},
1114
+ duration_by_test=mutmut.duration_by_test,
1115
+ stats_time=mutmut.stats_time,
1116
+ ), f, indent=4)
1117
+
1118
+
1119
+ def collect_source_file_mutation_data(*, mutant_names):
1120
+ source_file_mutation_data_by_path: Dict[str, SourceFileMutationData] = {}
1121
+
1122
+ for path in walk_source_files():
1123
+ if mutmut.config.should_ignore_for_mutation(path):
1124
+ continue
1125
+ assert path not in source_file_mutation_data_by_path
1126
+ m = SourceFileMutationData(path=path)
1127
+ m.load()
1128
+ source_file_mutation_data_by_path[str(path)] = m
1129
+
1130
+ mutants = [
1131
+ (m, mutant_name, result)
1132
+ for path, m in source_file_mutation_data_by_path.items()
1133
+ for mutant_name, result in m.exit_code_by_key.items()
1134
+ ]
1135
+
1136
+ if mutant_names:
1137
+ filtered_mutants = [
1138
+ (m, key, result)
1139
+ for m, key, result in mutants
1140
+ if key in mutant_names or any(fnmatch.fnmatch(key, mutant_name) for mutant_name in mutant_names)
1141
+ ]
1142
+ assert filtered_mutants, f'Filtered for specific mutants, but nothing matches\n\nFilter: {mutant_names}'
1143
+ mutants = filtered_mutants
1144
+ return mutants, source_file_mutation_data_by_path
1145
+
1146
+
1147
+ def estimated_worst_case_time(mutant_name):
1148
+ tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), set())
1149
+ return sum(mutmut.duration_by_test[t] for t in tests)
1150
+
1151
+
1152
+ @cli.command()
1153
+ @click.argument('mutant_names', required=False, nargs=-1)
1154
+ def print_time_estimates(mutant_names):
1155
+ assert isinstance(mutant_names, (tuple, list)), mutant_names
1156
+ read_config()
1157
+
1158
+ runner = PytestRunner()
1159
+ runner.prepare_main_test_run()
1160
+
1161
+ collect_or_load_stats(runner)
1162
+
1163
+ mutants, source_file_mutation_data_by_path = collect_source_file_mutation_data(mutant_names=mutant_names)
1164
+
1165
+ times_and_keys = [
1166
+ (estimated_worst_case_time(mutant_name), mutant_name)
1167
+ for m, mutant_name, result in mutants
1168
+ ]
1169
+
1170
+ for time, key in sorted(times_and_keys):
1171
+ if not time:
1172
+ print(f'<no tests>', key)
1173
+ else:
1174
+ print(f'{int(time*1000)}ms', key)
1175
+
1176
+
1177
+ @cli.command()
1178
+ @click.argument('mutant_name', required=True, nargs=1)
1179
+ def tests_for_mutant(mutant_name):
1180
+ if not load_stats():
1181
+ print('Failed to load stats. Please run mutmut first to collect stats.')
1182
+ exit(1)
1183
+
1184
+ tests = tests_for_mutant_names([mutant_name])
1185
+ for test in sorted(tests):
1186
+ print(test)
1187
+
1188
+
1189
+ def stop_all_children(mutants):
1190
+ for m, _, _ in mutants:
1191
+ m.stop_children()
1192
+
1193
+
1194
+ def timeout_checker(mutants):
1195
+ def inner_timout_checker():
1196
+ while True:
1197
+ sleep(1)
1198
+
1199
+ now = datetime.now()
1200
+ for m, mutant_name, result in mutants:
1201
+ for pid, start_time in m.start_time_by_pid.items():
1202
+ run_time = now - start_time
1203
+ if run_time.total_seconds() > (m.estimated_time_of_tests_by_mutant[mutant_name] + 1) * 4:
1204
+ try:
1205
+ os.kill(pid, signal.SIGXCPU)
1206
+ except ProcessLookupError:
1207
+ pass
1208
+ return inner_timout_checker
1209
+
1210
+
1211
+ @cli.command()
1212
+ @click.option('--max-children', type=int)
1213
+ @click.argument('mutant_names', required=False, nargs=-1)
1214
+ def run(mutant_names, *, max_children):
1215
+ assert isinstance(mutant_names, (tuple, list)), mutant_names
1216
+
1217
+ # TODO: run no-ops once in a while to detect if we get false negatives
1218
+ # 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!
1219
+ os.environ['MUTANT_UNDER_TEST'] = 'mutant_generation'
1220
+ read_config()
1221
+
1222
+ start = datetime.now()
1223
+ makedirs(Path('mutants'), exist_ok=True)
1224
+ with CatchOutput(spinner_title='Generating mutants'):
1225
+ create_mutants()
1226
+ copy_also_copy_files()
1227
+
1228
+ time = datetime.now() - start
1229
+ print(f' done in {round(time.total_seconds()*1000)}ms', )
1230
+
1231
+ src_path = (Path('mutants') / 'src')
1232
+ source_path = (Path('mutants') / 'source')
1233
+ if src_path.exists():
1234
+ sys.path.insert(0, str(src_path.absolute()))
1235
+ elif source_path.exists():
1236
+ sys.path.insert(0, str(source_path.absolute))
1237
+ else:
1238
+ sys.path.insert(0, os.path.abspath('mutants'))
1239
+
1240
+ # TODO: config/option for runner
1241
+ # runner = HammettRunner()
1242
+ runner = PytestRunner()
1243
+ runner.prepare_main_test_run()
1244
+
1245
+ # TODO: run these steps only if we have mutants to test
1246
+
1247
+ collect_or_load_stats(runner)
1248
+
1249
+ mutants, source_file_mutation_data_by_path = collect_source_file_mutation_data(mutant_names=mutant_names)
1250
+
1251
+ os.environ['MUTANT_UNDER_TEST'] = ''
1252
+ with CatchOutput(spinner_title='Running clean tests') as output_catcher:
1253
+ tests = tests_for_mutant_names(mutant_names)
1254
+
1255
+ clean_test_exit_code = runner.run_tests(mutant_name=None, tests=tests)
1256
+ if clean_test_exit_code != 0:
1257
+ output_catcher.dump_output()
1258
+ print('Failed to run clean test')
1259
+ exit(1)
1260
+ print(' done')
1261
+
1262
+ # this can't be the first thing, because it can fail deep inside pytest/django setup and then everything is destroyed
1263
+ run_forced_fail(runner)
1264
+
1265
+ runner.prepare_main_test_run()
1266
+
1267
+ def read_one_child_exit_status():
1268
+ pid, wait_status = os.wait()
1269
+ exit_code = os.waitstatus_to_exitcode(wait_status)
1270
+ if mutmut.config.debug:
1271
+ print(' worker exit code', exit_code)
1272
+ source_file_mutation_data_by_pid[pid].register_result(pid=pid, exit_code=exit_code)
1273
+
1274
+ source_file_mutation_data_by_pid: Dict[int, SourceFileMutationData] = {} # many pids map to one MutationData
1275
+ running_children = 0
1276
+ if max_children is None:
1277
+ max_children = os.cpu_count() or 4
1278
+
1279
+ count_tried = 0
1280
+
1281
+ # Run estimated fast mutants first, calculated as the estimated time for a surviving mutant.
1282
+ mutants = sorted(mutants, key=lambda x: estimated_worst_case_time(x[1]))
1283
+
1284
+ gc.freeze()
1285
+
1286
+ start = datetime.now()
1287
+ try:
1288
+ print('Running mutation testing')
1289
+
1290
+ # Calculate times of tests
1291
+ for m, mutant_name, result in mutants:
1292
+ mutant_name = mutant_name.replace('__init__.', '')
1293
+ tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), [])
1294
+ estimated_time_of_tests = sum(mutmut.duration_by_test[test_name] for test_name in tests)
1295
+ m.estimated_time_of_tests_by_mutant[mutant_name] = estimated_time_of_tests
1296
+
1297
+ Thread(target=timeout_checker(mutants), daemon=True).start()
1298
+
1299
+ # Now do mutation
1300
+ for m, mutant_name, result in mutants:
1301
+ print_stats(source_file_mutation_data_by_path)
1302
+
1303
+ mutant_name = mutant_name.replace('__init__.', '')
1304
+
1305
+ # Rerun mutant if it's explicitly mentioned, but otherwise let the result stand
1306
+ if not mutant_names and result is not None:
1307
+ continue
1308
+
1309
+ tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), [])
1310
+
1311
+ # print(tests)
1312
+ if not tests:
1313
+ m.exit_code_by_key[mutant_name] = 33
1314
+ m.save()
1315
+ continue
1316
+
1317
+ pid = os.fork()
1318
+ if not pid:
1319
+ # In the child
1320
+ os.environ['MUTANT_UNDER_TEST'] = mutant_name
1321
+ setproctitle(f'mutmut: {mutant_name}')
1322
+
1323
+ # Run fast tests first
1324
+ tests = sorted(tests, key=lambda test_name: mutmut.duration_by_test[test_name])
1325
+ if not tests:
1326
+ os._exit(33)
1327
+
1328
+ estimated_time_of_tests = m.estimated_time_of_tests_by_mutant[mutant_name]
1329
+ cpu_time_limit = ceil((estimated_time_of_tests + 1) * 2 + process_time()) * 10
1330
+ resource.setrlimit(resource.RLIMIT_CPU, (cpu_time_limit, cpu_time_limit))
1331
+
1332
+ with CatchOutput():
1333
+ result = runner.run_tests(mutant_name=mutant_name, tests=tests)
1334
+
1335
+ if result != 0:
1336
+ # TODO: write failure information to stdout?
1337
+ pass
1338
+ os._exit(result)
1339
+ else:
1340
+ # in the parent
1341
+ source_file_mutation_data_by_pid[pid] = m
1342
+ m.register_pid(pid=pid, key=mutant_name, estimated_time_of_tests=estimated_time_of_tests)
1343
+ running_children += 1
1344
+
1345
+ if running_children >= max_children:
1346
+ read_one_child_exit_status()
1347
+ count_tried += 1
1348
+ running_children -= 1
1349
+
1350
+ try:
1351
+ while running_children:
1352
+ read_one_child_exit_status()
1353
+ count_tried += 1
1354
+ running_children -= 1
1355
+ except ChildProcessError:
1356
+ pass
1357
+ except KeyboardInterrupt:
1358
+ print('Stopping...')
1359
+ stop_all_children(mutants)
1360
+
1361
+ t = datetime.now() - start
1362
+
1363
+ print_stats(source_file_mutation_data_by_path, force_output=True)
1364
+ print()
1365
+ print(f'{count_tried / t.total_seconds():.2f} mutations/second')
1366
+
1367
+ if mutant_names:
1368
+ print()
1369
+ print('Mutant results')
1370
+ print('--------------')
1371
+ exit_code_by_key = {}
1372
+ # If the user gave a specific list of mutants, print result for these specifically
1373
+ for m, mutant_name, result in mutants:
1374
+ exit_code_by_key[mutant_name] = m.exit_code_by_key[mutant_name]
1375
+
1376
+ for mutant_name, exit_code in sorted(exit_code_by_key.items()):
1377
+ print(emoji_by_status.get(status_by_exit_code.get(exit_code), '?'), mutant_name)
1378
+
1379
+ print()
1380
+
1381
+
1382
+ def tests_for_mutant_names(mutant_names):
1383
+ tests = set()
1384
+ for mutant_name in mutant_names:
1385
+ if '*' in mutant_name:
1386
+ for name, tests_of_this_name in mutmut.tests_by_mangled_function_name.items():
1387
+ if fnmatch.fnmatch(name, mutant_name):
1388
+ tests |= set(tests_of_this_name)
1389
+ else:
1390
+ tests |= set(mutmut.tests_by_mangled_function_name[mangled_name_from_mutant_name(mutant_name)])
1391
+ return tests
1392
+
1393
+
1394
+ @cli.command()
1395
+ @click.option('--all', default=False)
1396
+ def results(all):
1397
+ read_config()
1398
+ for path in walk_source_files():
1399
+ if not str(path).endswith('.py'):
1400
+ continue
1401
+ m = SourceFileMutationData(path=path)
1402
+ m.load()
1403
+ for k, v in m.exit_code_by_key.items():
1404
+ status = status_by_exit_code[v]
1405
+ if status == 'killed' and not all:
1406
+ continue
1407
+ print(f' {k}: {status}')
1408
+
1409
+
1410
+ def read_mutants_ast(path):
1411
+ with open(Path('mutants') / path) as f:
1412
+ return parse(f.read(), error_recovery=False)
1413
+
1414
+
1415
+ def read_orig_ast(path):
1416
+ with open(path) as f:
1417
+ return parse(f.read())
1418
+
1419
+
1420
+ def find_ast_node(ast, function_name, orig_function_name):
1421
+ function_name = function_name.rpartition('.')[-1]
1422
+ orig_function_name = orig_function_name.rpartition('.')[-1]
1423
+
1424
+ for node in ast.children:
1425
+ if node.type == 'classdef':
1426
+ (body,) = [x for x in node.children if x.type == 'suite']
1427
+ result = find_ast_node(body, function_name=function_name, orig_function_name=orig_function_name)
1428
+ if result:
1429
+ return result
1430
+ if node.type == 'funcdef' and node.name.value == function_name:
1431
+ node.name.value = orig_function_name
1432
+ return node
1433
+
1434
+
1435
+ def read_original_ast_node(ast, mutant_name):
1436
+ orig_function_name, class_name = orig_function_and_class_names_from_key(mutant_name)
1437
+ orig_name = mangled_name_from_mutant_name(mutant_name) + '__mutmut_orig'
1438
+
1439
+ result = find_ast_node(ast, function_name=orig_name, orig_function_name=orig_function_name)
1440
+ if not result:
1441
+ raise FileNotFoundError(f'Could not find original function "{orig_function_name}"')
1442
+ return result
1443
+
1444
+
1445
+ def read_mutant_ast_node(ast, mutant_name):
1446
+ orig_function_name, class_name = orig_function_and_class_names_from_key(mutant_name)
1447
+ result = find_ast_node(ast, function_name=mutant_name, orig_function_name=orig_function_name)
1448
+ if not result:
1449
+ raise FileNotFoundError(f'Could not find mutant "{mutant_name}"')
1450
+ return result
1451
+
1452
+
1453
+ def find_mutant(mutant_name):
1454
+ for path in walk_source_files():
1455
+ if mutmut.config.should_ignore_for_mutation(path):
1456
+ continue
1457
+
1458
+ m = SourceFileMutationData(path=path)
1459
+ m.load()
1460
+ if mutant_name in m.exit_code_by_key:
1461
+ return m
1462
+
1463
+ raise FileNotFoundError(f'Could not find mutant {mutant_name}')
1464
+
1465
+
1466
+ def get_diff_for_mutant(mutant_name, source=None, path=None):
1467
+ if path is None:
1468
+ m = find_mutant(mutant_name)
1469
+ path = m.path
1470
+ status = status_by_exit_code[m.exit_code_by_key[mutant_name]]
1471
+ else:
1472
+ status = 'not checked'
1473
+
1474
+ print(f'# {mutant_name}: {status}')
1475
+
1476
+ if source is None:
1477
+ ast = read_mutants_ast(path)
1478
+ else:
1479
+ ast = parse(source, error_recovery=False)
1480
+ orig_code = read_original_ast_node(ast, mutant_name).get_code().strip()
1481
+ mutant_code = read_mutant_ast_node(ast, mutant_name).get_code().strip()
1482
+
1483
+ path = str(path) # difflib requires str, not Path
1484
+ return '\n'.join([
1485
+ line
1486
+ for line in unified_diff(orig_code.split('\n'), mutant_code.split('\n'), fromfile=path, tofile=path, lineterm='')
1487
+ ])
1488
+
1489
+
1490
+ @cli.command()
1491
+ @click.argument('mutant_name')
1492
+ def show(mutant_name):
1493
+ read_config()
1494
+ print(get_diff_for_mutant(mutant_name))
1495
+ return
1496
+
1497
+
1498
+ @cli.command()
1499
+ @click.argument('mutant_name')
1500
+ def apply(mutant_name):
1501
+ # try:
1502
+ read_config()
1503
+ apply_mutant(mutant_name)
1504
+ # except FileNotFoundError as e:
1505
+ # print(e)
1506
+
1507
+
1508
+ def apply_mutant(mutant_name):
1509
+ m = find_mutant(mutant_name)
1510
+ path = m.path
1511
+
1512
+ orig_function_name, class_name = orig_function_and_class_names_from_key(mutant_name)
1513
+ orig_function_name = orig_function_name.rpartition('.')[-1]
1514
+
1515
+ orig_ast = read_orig_ast(path)
1516
+ mutants_ast = read_mutants_ast(path)
1517
+ mutant_ast_node = read_mutant_ast_node(mutants_ast, mutant_name=mutant_name)
1518
+
1519
+ mutant_ast_node.name.value = orig_function_name
1520
+
1521
+ for node in orig_ast.children:
1522
+ if node.type == 'funcdef' and node.name.value == orig_function_name:
1523
+ node.children = mutant_ast_node.children
1524
+ break
1525
+ else:
1526
+ raise FileNotFoundError(f'Could not apply mutant {mutant_name}')
1527
+
1528
+ with open(path, 'w') as f:
1529
+ f.write(orig_ast.get_code())
1530
+
1531
+
1532
+ # TODO: junitxml, html commands
1533
+
1534
+ @cli.command()
1535
+ def browse():
1536
+ read_config()
1537
+
1538
+ from textual.app import App
1539
+ from textual.containers import Container
1540
+ from textual.widgets import Footer
1541
+ from textual.widgets import DataTable
1542
+ from textual.widgets import Static
1543
+ from textual.widget import Widget
1544
+ from rich.syntax import Syntax
1545
+
1546
+ class ResultBrowser(App):
1547
+ loading_id = None
1548
+ CSS_PATH = "result_browser_layout.tcss"
1549
+ BINDINGS = [
1550
+ ("q", "quit()", "Quit"),
1551
+ ("r", "retest_mutant()", "Retest mutant"),
1552
+ ("f", "retest_function()", "Retest function"),
1553
+ ("m", "retest_module()", "Retest module"),
1554
+ ("a", "apply_mutant()", "Apply mutant to disk"),
1555
+ ]
1556
+
1557
+ columns = [
1558
+ ('path', 'Path'),
1559
+ ] + [
1560
+ (status, Text(emoji, justify='right'))
1561
+ for status, emoji in emoji_by_status.items()
1562
+ ]
1563
+
1564
+ cursor_type = 'row'
1565
+ source_file_mutation_data_and_stat_by_path = None
1566
+
1567
+ def compose(self):
1568
+ with Container(classes='container'):
1569
+ yield DataTable(id='files')
1570
+ yield DataTable(id='mutants')
1571
+ with Widget(id="diff_view_widget"):
1572
+ yield Static(id='diff_view')
1573
+ yield Footer()
1574
+
1575
+ def on_mount(self):
1576
+ # files table
1577
+ # noinspection PyTypeChecker
1578
+ files_table: DataTable = self.query_one('#files')
1579
+ files_table.cursor_type = 'row'
1580
+ for key, label in self.columns:
1581
+ files_table.add_column(key=key, label=label)
1582
+
1583
+ # mutants table
1584
+ # noinspection PyTypeChecker
1585
+ mutants_table: DataTable = self.query_one('#mutants')
1586
+ mutants_table.cursor_type = 'row'
1587
+ mutants_table.add_columns('name', 'status')
1588
+
1589
+ self.read_data()
1590
+ self.populate_files_table()
1591
+
1592
+ def read_data(self):
1593
+ read_config()
1594
+ self.source_file_mutation_data_and_stat_by_path = {}
1595
+
1596
+ for p in walk_source_files():
1597
+ if mutmut.config.should_ignore_for_mutation(p):
1598
+ continue
1599
+ source_file_mutation_data = SourceFileMutationData(path=p)
1600
+ source_file_mutation_data.load()
1601
+ stat = collect_stat(source_file_mutation_data)
1602
+
1603
+ self.source_file_mutation_data_and_stat_by_path[str(p)] = source_file_mutation_data, stat
1604
+
1605
+ def populate_files_table(self):
1606
+ # noinspection PyTypeChecker
1607
+ files_table: DataTable = self.query_one('#files')
1608
+ # TODO: restore selection
1609
+ selected_row = files_table.cursor_row
1610
+ files_table.clear()
1611
+
1612
+ for p, (source_file_mutation_data, stat) in sorted(self.source_file_mutation_data_and_stat_by_path.items()):
1613
+ row = [p] + [
1614
+ Text(str(getattr(stat, k.replace(' ', '_'))), justify="right")
1615
+ for k, _ in self.columns[1:]
1616
+ ]
1617
+ files_table.add_row(*row, key=str(p))
1618
+
1619
+ files_table.move_cursor(row=selected_row)
1620
+
1621
+ def on_data_table_row_highlighted(self, event):
1622
+ if not event.row_key or not event.row_key.value:
1623
+ return
1624
+ if event.data_table.id == 'files':
1625
+ # noinspection PyTypeChecker
1626
+ mutants_table: DataTable = self.query_one('#mutants')
1627
+ mutants_table.clear()
1628
+ source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[event.row_key.value]
1629
+ for k, v in source_file_mutation_data.exit_code_by_key.items():
1630
+ status = status_by_exit_code[v]
1631
+ if status == 'killed':
1632
+ continue
1633
+ mutants_table.add_row(k, emoji_by_status[status], key=k)
1634
+ else:
1635
+ assert event.data_table.id == 'mutants'
1636
+ # noinspection PyTypeChecker
1637
+ diff_view: Static = self.query_one('#diff_view')
1638
+ if event.row_key.value is None:
1639
+ diff_view.update('')
1640
+ else:
1641
+ diff_view.update('<loading...>')
1642
+ self.loading_id = event.row_key.value
1643
+
1644
+ def load_thread():
1645
+ read_config()
1646
+ try:
1647
+ d = get_diff_for_mutant(event.row_key.value)
1648
+ if event.row_key.value == self.loading_id:
1649
+ diff_view.update(Syntax(d, "diff"))
1650
+ except Exception as e:
1651
+ diff_view.update(f"<{type(e)} {e}>")
1652
+
1653
+ t = Thread(target=load_thread)
1654
+ t.start()
1655
+
1656
+ def retest(self, pattern):
1657
+ with self.suspend():
1658
+ assert sys.argv[-1] == 'browse'
1659
+ command = ' '.join([sys.executable] + sys.argv[:-1])
1660
+ os.system(f'{command} run "{pattern}"')
1661
+ input('press enter to return to browser')
1662
+
1663
+ self.read_data()
1664
+ self.populate_files_table()
1665
+
1666
+ def get_mutant_name_from_selection(self):
1667
+ # noinspection PyTypeChecker
1668
+ mutants_table: DataTable = self.query_one('#mutants')
1669
+ if mutants_table.cursor_row is None:
1670
+ return
1671
+
1672
+ return mutants_table.get_row_at(mutants_table.cursor_row)[0]
1673
+
1674
+ def action_retest_mutant(self):
1675
+ self.retest(self.get_mutant_name_from_selection())
1676
+
1677
+ def action_retest_function(self):
1678
+ self.retest(self.get_mutant_name_from_selection().rpartition('__mutmut_')[0] + '__mutmut_*')
1679
+
1680
+ def action_retest_module(self):
1681
+ self.retest(self.get_mutant_name_from_selection().rpartition('.')[0] + '.*')
1682
+
1683
+ def action_apply_mutant(self):
1684
+ read_config()
1685
+ # noinspection PyTypeChecker
1686
+ mutants_table: DataTable = self.query_one('#mutants')
1687
+ if mutants_table.cursor_row is None:
1688
+ return
1689
+ apply_mutant(mutants_table.get_row_at(mutants_table.cursor_row)[0])
1690
+
1691
+ ResultBrowser().run()
1692
+
1693
+
1694
+ if __name__ == '__main__':
1695
+ cli()