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/__init__.py +317 -0
- mutmut/__main__.py +1695 -0
- mutmut/result_browser_layout.tcss +34 -0
- mutmut-3.2.3.dist-info/AUTHORS.rst +23 -0
- mutmut-3.2.3.dist-info/LICENSE +12 -0
- mutmut-3.2.3.dist-info/METADATA +215 -0
- mutmut-3.2.3.dist-info/RECORD +13 -0
- mutmut-3.2.3.dist-info/WHEEL +5 -0
- mutmut-3.2.3.dist-info/entry_points.txt +2 -0
- mutmut-3.2.3.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_mutation.py +409 -0
- tests/test_mutmut3.py +83 -0
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()
|