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