mutmut 3.1.0__tar.gz → 3.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {mutmut-3.1.0 → mutmut-3.2.1}/AUTHORS.rst +1 -0
  2. {mutmut-3.1.0 → mutmut-3.2.1}/HISTORY.rst +27 -0
  3. {mutmut-3.1.0 → mutmut-3.2.1}/PKG-INFO +2 -1
  4. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut/.DS_Store +0 -0
  5. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut/__init__.py +12 -3
  6. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut/__main__.py +214 -101
  7. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut/__pycache__/__init__.cpython-311.pyc +0 -0
  8. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut/__pycache__/__init__.cpython-312.pyc +0 -0
  9. mutmut-3.2.1/mutmut/__pycache__/__main__.cpython-311.pyc +0 -0
  10. mutmut-3.2.1/mutmut/__pycache__/__main__.cpython-312.pyc +0 -0
  11. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut/result_browser_layout.tcss +8 -0
  12. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut.egg-info/PKG-INFO +2 -1
  13. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut.egg-info/requires.txt +3 -0
  14. {mutmut-3.1.0 → mutmut-3.2.1}/requirements.txt +1 -0
  15. mutmut-3.1.0/mutmut/__pycache__/__main__.cpython-311.pyc +0 -0
  16. mutmut-3.1.0/mutmut/__pycache__/__main__.cpython-312.pyc +0 -0
  17. {mutmut-3.1.0 → mutmut-3.2.1}/LICENSE +0 -0
  18. {mutmut-3.1.0 → mutmut-3.2.1}/MANIFEST.in +0 -0
  19. {mutmut-3.1.0 → mutmut-3.2.1}/README.rst +0 -0
  20. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut.egg-info/SOURCES.txt +0 -0
  21. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut.egg-info/dependency_links.txt +0 -0
  22. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut.egg-info/entry_points.txt +0 -0
  23. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut.egg-info/not-zip-safe +0 -0
  24. {mutmut-3.1.0 → mutmut-3.2.1}/mutmut.egg-info/top_level.txt +0 -0
  25. {mutmut-3.1.0 → mutmut-3.2.1}/setup.cfg +0 -0
  26. {mutmut-3.1.0 → mutmut-3.2.1}/setup.py +0 -0
@@ -20,3 +20,4 @@ Credits
20
20
  * Emil Stenström <emil@emilstenstrom.se>
21
21
  * Roxane Bellott <roxane.bellot@gmail.com>
22
22
  * Tomáš Chvátal <tchvatal@suse.com>
23
+ * Frank Hoffmann <15r10nk-git@polarbit.de>
@@ -1,6 +1,33 @@
1
1
  Changelog
2
2
  ---------
3
3
 
4
+ 3.2.1
5
+ ~~~~~
6
+
7
+ * Read `paths_to_mutate` from config file
8
+
9
+ * Mutate `break` to `return` to avoid timeouts
10
+
11
+ * Added debug mode. Enable with `debug=True` in `setup.cfg` under `[mutmut]`
12
+
13
+ * Fixed new test detection. The old code incorrectly detected new tests when there were none, creating a much slower interaction loop for fixing mutants.
14
+
15
+ * And many more fixes
16
+
17
+ 3.2.0
18
+ ~~~~~
19
+
20
+ * Timeouts for mutants implemented.
21
+
22
+ * Browser: syntax highlighting for diff view
23
+
24
+ * More fixes for generators.
25
+
26
+ * Fix for `src`-style layout of projects.
27
+
28
+ * Fixed bug where mutmut would recollect all tests on every run, slowing down startup.
29
+
30
+
4
31
  3.1.0
5
32
  ~~~~~
6
33
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mutmut
3
- Version: 3.1.0
3
+ Version: 3.2.1
4
4
  Summary: mutation testing for Python 3
5
5
  Home-page: https://github.com/boxed/mutmut
6
6
  Author: Anders Hovmöller
@@ -20,6 +20,7 @@ Requires-Dist: click
20
20
  Requires-Dist: junit-xml==1.8
21
21
  Requires-Dist: setproctitle
22
22
  Requires-Dist: textual
23
+ Requires-Dist: toml>=0.10.2; python_version < "3.11"
23
24
 
24
25
  mutmut - python mutation tester
25
26
  ===============================
@@ -1,10 +1,12 @@
1
+ from collections import defaultdict
2
+
1
3
  from parso.python.tree import (
2
4
  Keyword,
3
5
  Name,
4
6
  Number,
5
7
  )
6
8
 
7
- __version__ = '3.1.0'
9
+ __version__ = '3.2.1'
8
10
 
9
11
 
10
12
  # We have a global whitelist for constants of the pattern __all__, __version__, etc
@@ -22,6 +24,13 @@ dunder_whitelist = [
22
24
  'copyright',
23
25
  ]
24
26
 
27
+ duration_by_test = {}
28
+ stats_time = None
29
+ config = None
30
+
31
+ _stats = set()
32
+ tests_by_mangled_function_name = defaultdict(set)
33
+
25
34
 
26
35
  class SkipException(Exception):
27
36
  pass
@@ -156,8 +165,8 @@ def keyword_mutation(value, context, **_):
156
165
  'not': '',
157
166
  'is': 'is not', # this will cause "is not not" sometimes, so there's a hack to fix that later
158
167
  'in': 'not in',
159
- # 'break': 'continue',
160
- # 'continue': 'break',
168
+ 'break': 'return',
169
+ 'continue': 'break',
161
170
  'True': 'False',
162
171
  'False': 'True',
163
172
  }.get(value)
@@ -5,7 +5,9 @@ import inspect
5
5
  import itertools
6
6
  import json
7
7
  import os
8
+ import resource
8
9
  import shutil
10
+ import signal
9
11
  import sys
10
12
  from abc import ABC
11
13
  from collections import defaultdict
@@ -25,11 +27,15 @@ from functools import lru_cache
25
27
  from hashlib import md5
26
28
  from io import TextIOBase
27
29
  from json import JSONDecodeError
30
+ from math import ceil
28
31
  from os import (
29
32
  makedirs,
30
33
  walk,
31
34
  )
32
- from os.path import isdir
35
+ from os.path import (
36
+ isdir,
37
+ isfile,
38
+ )
33
39
  from pathlib import Path
34
40
  from signal import SIGTERM
35
41
  from textwrap import (
@@ -37,6 +43,10 @@ from textwrap import (
37
43
  indent,
38
44
  )
39
45
  from threading import Thread
46
+ from time import (
47
+ process_time,
48
+ sleep,
49
+ )
40
50
  from typing import (
41
51
  Dict,
42
52
  List,
@@ -47,37 +57,26 @@ from parso import (
47
57
  parse,
48
58
  ParserSyntaxError,
49
59
  )
60
+ from rich.text import Text
50
61
  from setproctitle import setproctitle
51
62
 
52
63
  import mutmut
53
64
 
54
- mutmut.duration_by_test = {}
55
-
56
65
  # Document: surviving mutants are retested when you ask mutmut to retest them, interactively in the UI or via command line
57
66
 
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
67
  # TODO: pragma no mutate should end up in `skipped` category
63
68
  # 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
 
69
70
 
70
71
  NEVER_MUTATE_FUNCTION_NAMES = {'__getattribute__', '__setattr__'}
71
72
  NEVER_MUTATE_FUNCTION_CALLS = {'isinstance', 'len'}
72
73
  CLASS_NAME_SEPARATOR = 'ǁ'
73
74
 
74
- mutmut._stats = set()
75
- mutmut.tests_by_mangled_function_name = defaultdict(set)
76
-
77
75
 
78
76
  status_by_exit_code = {
79
77
  1: 'killed',
80
78
  3: 'killed', # internal error in pytest means a kill
79
+ -24: 'killed',
81
80
  0: 'survived',
82
81
  5: 'no tests',
83
82
  2: 'check was interrupted by user',
@@ -86,6 +85,9 @@ status_by_exit_code = {
86
85
  34: 'skipped',
87
86
  35: 'suspicious',
88
87
  36: 'timeout',
88
+ 24: 'timeout', # SIGXCPU
89
+ 152: 'timeout', # SIGXCPU
90
+ 255: 'timeout',
89
91
  }
90
92
 
91
93
  emoji_by_status = {
@@ -112,23 +114,24 @@ def guess_paths_to_mutate():
112
114
  """
113
115
  this_dir = os.getcwd().split(os.sep)[-1]
114
116
  if isdir('lib'):
115
- return 'lib'
117
+ return ['lib']
116
118
  elif isdir('src'):
117
- return 'src'
119
+ return ['src']
118
120
  elif isdir(this_dir):
119
- return this_dir
121
+ return [this_dir]
120
122
  elif isdir(this_dir.replace('-', '_')):
121
- return this_dir.replace('-', '_')
123
+ return [this_dir.replace('-', '_')]
122
124
  elif isdir(this_dir.replace(' ', '_')):
123
- return this_dir.replace(' ', '_')
125
+ return [this_dir.replace(' ', '_')]
124
126
  elif isdir(this_dir.replace('-', '')):
125
- return this_dir.replace('-', '')
127
+ return [this_dir.replace('-', '')]
126
128
  elif isdir(this_dir.replace(' ', '')):
127
- return this_dir.replace(' ', '')
129
+ return [this_dir.replace(' ', '')]
130
+ if isfile(this_dir + '.py'):
131
+ return [this_dir + '.py']
128
132
  raise FileNotFoundError(
129
133
  '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.')
134
+ 'Please specify it by adding "paths_to_mutate=code_dir" in setup.cfg to the [mutmut] section.')
132
135
 
133
136
 
134
137
  def record_trampoline_hit(name):
@@ -148,8 +151,7 @@ def record_trampoline_hit(name):
148
151
 
149
152
 
150
153
  def walk_all_files():
151
- paths = [guess_paths_to_mutate()]
152
- for path in paths:
154
+ for path in mutmut.config.paths_to_mutate:
153
155
  for root, dirs, files in walk(path):
154
156
  for filename in files:
155
157
  yield root, filename
@@ -176,6 +178,8 @@ class CollectTestsFailedException(Exception):
176
178
  class BadTestExecutionCommandsException(Exception):
177
179
  pass
178
180
 
181
+
182
+ # noinspection PyUnresolvedReferences
179
183
  # language=python
180
184
  trampoline_impl = """
181
185
  from inspect import signature as _mutmut_signature
@@ -189,22 +193,23 @@ def _mutmut_trampoline(orig, mutants, *args, **kwargs):
189
193
  elif mutant_under_test == 'stats':
190
194
  from mutmut.__main__ import record_trampoline_hit
191
195
  record_trampoline_hit(orig.__module__ + '.' + orig.__name__)
192
- ret orig(*args, **kwargs)
193
- return # for the yield case
196
+ result = orig(*args, **kwargs)
197
+ return result # for the yield case
194
198
  prefix = orig.__module__ + '.' + orig.__name__ + '__mutmut_'
195
199
  if not mutant_under_test.startswith(prefix):
196
- ret orig(*args, **kwargs)
197
- return # for the yield case
200
+ result = orig(*args, **kwargs)
201
+ return result # for the yield case
198
202
  mutant_name = mutant_under_test.rpartition('.')[-1]
199
- ret mutants[mutant_name](*args, **kwargs)
203
+ result = mutants[mutant_name](*args, **kwargs)
204
+ return result
200
205
 
201
206
  """
202
- yield_from_trampoline_impl = trampoline_impl.replace('ret ', 'yield from ').replace('_mutmut_trampoline', '_mutmut_yield_from_trampoline')
203
- trampoline_impl = trampoline_impl.replace('ret ', 'return ')
207
+ yield_from_trampoline_impl = trampoline_impl.replace('result = ', 'result = yield from ').replace('_mutmut_trampoline', '_mutmut_yield_from_trampoline')
204
208
 
205
209
 
206
210
  def create_mutants():
207
211
  for path in walk_source_files():
212
+ print(path)
208
213
  output_path = Path('mutants') / path
209
214
  makedirs(output_path.parent, exist_ok=True)
210
215
 
@@ -217,6 +222,7 @@ def create_mutants():
217
222
  def copy_also_copy_files():
218
223
  assert isinstance(mutmut.config.also_copy, list)
219
224
  for path in mutmut.config.also_copy:
225
+ print(' also copying', path)
220
226
  path = Path(path)
221
227
  destination = Path('mutants') / path
222
228
  if not path.exists():
@@ -257,7 +263,8 @@ def create_mutants_for_file(filename, output_path):
257
263
  exit(1)
258
264
 
259
265
  source_file_mutation_data = SourceFileMutationData(path=filename)
260
- module_name = str(filename)[:-len(filename.suffix)].replace(os.sep, '.')
266
+ module_name = strip_prefix(str(filename)[:-len(filename.suffix)].replace(os.sep, '.'), prefix='src.')
267
+
261
268
  source_file_mutation_data.exit_code_by_key = {
262
269
  '.'.join([module_name, x]).replace('.__init__.', '.'): None
263
270
  for x in mutant_names
@@ -314,17 +321,18 @@ def build_trampoline(*, orig_name, mutants, class_name, is_generator):
314
321
  access_suffix = '")'
315
322
 
316
323
  if is_generator:
317
- return_or_yield_statement = 'yield from'
324
+ yield_statement = 'yield from ' # note the space at the end!
318
325
  trampoline_name = '_mutmut_yield_from_trampoline'
319
326
  else:
320
- return_or_yield_statement = 'return'
327
+ yield_statement = ''
321
328
  trampoline_name = '_mutmut_trampoline'
322
329
 
323
330
  return f"""
324
331
  {mutants_dict}
325
332
 
326
333
  def {orig_name}({'self, ' if class_name is not None else ''}*args, **kwargs):
327
- {return_or_yield_statement} {trampoline_name}({access_prefix}{mangled_name}__mutmut_orig{access_suffix}, {access_prefix}{mangled_name}__mutmut_mutants{access_suffix}, *args, **kwargs)
334
+ result = {yield_statement}{trampoline_name}({access_prefix}{mangled_name}__mutmut_orig{access_suffix}, {access_prefix}{mangled_name}__mutmut_mutants{access_suffix}, *args, **kwargs)
335
+ return result
328
336
 
329
337
  {orig_name}.__signature__ = _mutmut_signature({mangled_name}__mutmut_orig)
330
338
  {mangled_name}__mutmut_orig.__name__ = '{mangled_name}'
@@ -574,6 +582,7 @@ def yield_mutants_for_module(node, no_mutate_lines):
574
582
 
575
583
  class SourceFileMutationData:
576
584
  def __init__(self, *, path):
585
+ self.estimated_time_of_tests_by_mutant = {}
577
586
  self.path = path
578
587
  self.meta_path = Path('mutants') / (str(path) + '.meta')
579
588
  self.meta = None
@@ -581,6 +590,7 @@ class SourceFileMutationData:
581
590
  self.exit_code_by_key = {}
582
591
  self.hash_by_function_name = {}
583
592
  self.start_time_by_pid = {}
593
+ self.estimated_time_of_tests_by_pid = {}
584
594
 
585
595
  def load(self):
586
596
  try:
@@ -593,13 +603,14 @@ class SourceFileMutationData:
593
603
  self.hash_by_function_name = self.meta.pop('hash_by_function_name')
594
604
  assert not self.meta, self.meta # We should read all the data!
595
605
 
596
- def register_pid(self, *, pid, key):
606
+ def register_pid(self, *, pid, key, estimated_time_of_tests):
597
607
  self.key_by_pid[pid] = key
598
608
  self.start_time_by_pid[pid] = datetime.now()
609
+ self.estimated_time_of_tests_by_pid[pid] = estimated_time_of_tests
599
610
 
600
611
  def register_result(self, *, pid, exit_code):
601
612
  assert self.key_by_pid[pid] in self.exit_code_by_key
602
- self.exit_code_by_key[self.key_by_pid[pid]] = (0xFF00 & exit_code) >> 8 # The high byte contains the exit code
613
+ self.exit_code_by_key[self.key_by_pid[pid]] = exit_code
603
614
  # TODO: maybe rate limit this? Saving on each result can slow down mutation testing a lot if the test run is fast.
604
615
  del self.key_by_pid[pid]
605
616
  del self.start_time_by_pid[pid]
@@ -655,46 +666,55 @@ def change_cwd(path):
655
666
  os.chdir(old_cwd)
656
667
 
657
668
 
669
+ def collected_test_names():
670
+ return set(mutmut.duration_by_test.keys())
671
+
672
+
658
673
  class ListAllTestsResult:
659
674
  def __init__(self, *, ids):
660
675
  assert isinstance(ids, set)
661
676
  self.ids = ids
662
677
 
663
- @staticmethod
664
- def old_tests():
665
- return {
666
- test_name
667
- for _, test_names in mutmut.tests_by_mangled_function_name.items()
668
- for test_name in test_names
669
- }
670
-
671
678
  def clear_out_obsolete_test_names(self):
672
- mutmut.tests_by_mangled_function_name = {
673
- k: {test_name for test_name in test_names if test_name not in self.ids}
679
+ count_before = sum(len(x) for x in mutmut.tests_by_mangled_function_name)
680
+ mutmut.tests_by_mangled_function_name = defaultdict(set, **{
681
+ k: {test_name for test_name in test_names if test_name in self.ids}
674
682
  for k, test_names in mutmut.tests_by_mangled_function_name.items()
675
- }
676
- save_stats()
683
+ })
684
+ count_after = sum(len(x) for x in mutmut.tests_by_mangled_function_name)
685
+ if count_before != count_after:
686
+ print(f'Removed {count_before - count_after} obsolete test names')
687
+ save_stats()
677
688
 
678
689
  def new_tests(self):
679
- return self.ids - self.old_tests()
690
+ return self.ids - collected_test_names()
680
691
 
681
692
 
682
693
  class PytestRunner(TestRunner):
694
+ # noinspection PyMethodMayBeStatic
683
695
  def execute_pytest(self, params, **kwargs):
684
696
  import pytest
697
+ params += ['--rootdir=.']
698
+ if mutmut.config.debug:
699
+ params = ['-vv'] + params
700
+ print('python -m pytest ', ' '.join(params))
685
701
  exit_code = int(pytest.main(params, **kwargs))
702
+ if mutmut.config.debug:
703
+ print(' exit code', exit_code)
686
704
  if exit_code == 4:
687
705
  raise BadTestExecutionCommandsException(params)
688
706
  return exit_code
689
707
 
690
708
  def run_stats(self, *, tests):
691
709
  class StatsCollector:
710
+ # noinspection PyMethodMayBeStatic
692
711
  def pytest_runtest_teardown(self, item, nextitem):
693
712
  unused(nextitem)
694
713
  for function in mutmut._stats:
695
714
  mutmut.tests_by_mangled_function_name[function].add(strip_prefix(item._nodeid, prefix='mutants/'))
696
715
  mutmut._stats.clear()
697
716
 
717
+ # noinspection PyMethodMayBeStatic
698
718
  def pytest_runtest_makereport(self, item, call):
699
719
  mutmut.duration_by_test[item.nodeid] = call.duration
700
720
 
@@ -732,7 +752,7 @@ class HammettRunner(TestRunner):
732
752
 
733
753
  def run_stats(self, *, tests):
734
754
  import hammett
735
- print('running hammett stats...')
755
+ print('Running hammett stats...')
736
756
 
737
757
  def post_test_callback(_name, **_):
738
758
  for function in mutmut._stats:
@@ -763,7 +783,6 @@ class HammettRunner(TestRunner):
763
783
 
764
784
  def mangle_function_name(*, name, class_name):
765
785
  assert CLASS_NAME_SEPARATOR not in name
766
- prefix = ''
767
786
  if class_name:
768
787
  assert CLASS_NAME_SEPARATOR not in class_name
769
788
  prefix = f'x{CLASS_NAME_SEPARATOR}{class_name}{CLASS_NAME_SEPARATOR}'
@@ -779,12 +798,13 @@ def mangled_name_from_mutant_name(mutant_name):
779
798
 
780
799
  def orig_function_and_class_names_from_key(mutant_name):
781
800
  r = mangled_name_from_mutant_name(mutant_name)
801
+ _, _, r = r.rpartition('.')
782
802
  class_name = None
783
803
  if CLASS_NAME_SEPARATOR in r:
784
804
  class_name = r[r.index(CLASS_NAME_SEPARATOR) + 1: r.rindex(CLASS_NAME_SEPARATOR)]
785
805
  r = r[r.rindex(CLASS_NAME_SEPARATOR) + 1:]
786
806
  else:
787
- assert r.startswith('x_')
807
+ assert r.startswith('x_'), r
788
808
  r = r[2:]
789
809
  return r, class_name
790
810
 
@@ -837,6 +857,7 @@ def collect_stat(m: SourceFileMutationData):
837
857
  for k in status_by_exit_code.values()
838
858
  }
839
859
  for k, v in m.exit_code_by_key.items():
860
+ # noinspection PyTypeChecker
840
861
  r[status_by_exit_code[v].replace(' ', '_')] += 1
841
862
  return Stat(
842
863
  **r,
@@ -866,11 +887,10 @@ def print_stats(source_file_mutation_data_by_path, force_output=False):
866
887
 
867
888
  def run_forced_fail(runner):
868
889
  os.environ['MUTANT_UNDER_TEST'] = 'fail'
869
- with CatchOutput(show_spinner=True, spinner_title='running forced fail test') as catcher:
890
+ with CatchOutput(spinner_title='Running forced fail test') as catcher:
870
891
  try:
871
892
  if runner.run_forced_fail() == 0:
872
- catcher.stop()
873
- print('\n'.join(catcher.strings))
893
+ catcher.dump_output()
874
894
  print("FAILED")
875
895
  os._exit(1)
876
896
  except MutmutProgrammaticFailException:
@@ -880,9 +900,8 @@ def run_forced_fail(runner):
880
900
 
881
901
 
882
902
  class CatchOutput:
883
- def __init__(self, callback=lambda s: None, show_spinner=False, spinner_title=None):
903
+ def __init__(self, callback=lambda s: None, spinner_title=None):
884
904
  self.strings = []
885
- self.show_spinner = show_spinner
886
905
  self.spinner_title = spinner_title or ''
887
906
 
888
907
  class StdOutRedirect(TextIOBase):
@@ -891,24 +910,29 @@ class CatchOutput:
891
910
 
892
911
  def write(self, s):
893
912
  callback(s)
894
- if show_spinner:
913
+ if spinner_title:
895
914
  print_status(spinner_title)
896
915
  self.catcher.strings.append(s)
897
916
  return len(s)
898
917
  self.redirect = StdOutRedirect(self)
899
918
 
919
+ # noinspection PyMethodMayBeStatic
900
920
  def stop(self):
901
921
  sys.stdout = sys.__stdout__
902
922
  sys.stderr = sys.__stderr__
903
923
 
904
924
  def start(self):
925
+ if self.spinner_title:
926
+ print_status(self.spinner_title)
905
927
  sys.stdout = self.redirect
906
928
  sys.stderr = self.redirect
929
+ if mutmut.config.debug:
930
+ self.stop()
907
931
 
908
932
  def dump_output(self):
909
933
  self.stop()
910
- for l in self.strings:
911
- print(l, end='')
934
+ for line in self.strings:
935
+ print(line, end='')
912
936
 
913
937
  def __enter__(self):
914
938
  self.start()
@@ -916,7 +940,7 @@ class CatchOutput:
916
940
 
917
941
  def __exit__(self, exc_type, exc_val, exc_tb):
918
942
  self.stop()
919
- if self.show_spinner:
943
+ if self.spinner_title:
920
944
  print()
921
945
 
922
946
 
@@ -925,6 +949,8 @@ class Config:
925
949
  also_copy: List[Path]
926
950
  do_not_mutate: List[str]
927
951
  max_stack_depth: int
952
+ debug: bool
953
+ paths_to_mutate: List[Path]
928
954
 
929
955
  def should_ignore_for_mutation(self, path):
930
956
  if not str(path).endswith('.py'):
@@ -935,36 +961,67 @@ class Config:
935
961
  return False
936
962
 
937
963
 
938
- @lru_cache()
939
- def read_config():
964
+ def config_reader():
965
+ path = Path('pyproject.toml')
966
+ if path.exists():
967
+ if sys.version_info >= (3, 11):
968
+ from tomllib import loads
969
+ else:
970
+ # noinspection PyPackageRequirements
971
+ from toml import loads
972
+ data = loads(path.read_text('utf-8'))
973
+
974
+ try:
975
+ config = data['tool']['mutmut']
976
+ except KeyError:
977
+ pass
978
+ else:
979
+ def s(key, default):
980
+ try:
981
+ result = config[key]
982
+ except KeyError:
983
+ return default
984
+ return result
985
+ return s
986
+
940
987
  config_parser = ConfigParser()
941
988
  config_parser.read('setup.cfg')
942
989
 
943
990
  def s(key, default):
944
991
  try:
945
- return config_parser.get('mutmut', key)
992
+ result = config_parser.get('mutmut', key)
946
993
  except (NoOptionError, NoSectionError):
947
994
  return default
995
+ if isinstance(default, list):
996
+ result = [x for x in result.split("\n") if x]
997
+ elif isinstance(default, bool):
998
+ result = result.lower() in ('1', 't', 'true')
999
+ elif isinstance(default, int):
1000
+ result = int(result)
1001
+ return result
1002
+ return s
1003
+
1004
+
1005
+ @lru_cache()
1006
+ def read_config():
1007
+ s = config_reader()
948
1008
 
949
1009
  mutmut.config = Config(
950
- do_not_mutate=[
951
- x
952
- for x in s('do_not_mutate', '').split('\n')
953
- if x
954
- ],
1010
+ do_not_mutate=s('do_not_mutate', []),
955
1011
  also_copy=[
956
1012
  Path(y)
957
- for y in [
958
- x
959
- for x in s('also_copy', '').split('\n')
960
- if x
961
- ]
1013
+ for y in s('also_copy', [])
962
1014
  ]+[
963
1015
  Path('tests/'),
964
1016
  Path('test/'),
965
1017
  Path('tests.py'),
966
1018
  ],
967
- max_stack_depth=int(s('max_stack_depth', '-1'))
1019
+ max_stack_depth=s('max_stack_depth', -1),
1020
+ debug=s('debug', False),
1021
+ paths_to_mutate=[
1022
+ Path(y)
1023
+ for y in s('paths_to_mutate', [])
1024
+ ] or guess_paths_to_mutate()
968
1025
  )
969
1026
 
970
1027
 
@@ -979,7 +1036,9 @@ def run_stats_collection(runner, tests=None):
979
1036
 
980
1037
  os.environ['MUTANT_UNDER_TEST'] = 'stats'
981
1038
  os.environ['PY_IGNORE_IMPORTMISMATCH'] = '1'
982
- with CatchOutput(show_spinner=True, spinner_title='running stats') as output_catcher:
1039
+ start_cpu_time = process_time()
1040
+
1041
+ with CatchOutput(spinner_title='Running stats') as output_catcher:
983
1042
  collect_stats_exit_code = runner.run_stats(tests=tests)
984
1043
  if collect_stats_exit_code != 0:
985
1044
  output_catcher.dump_output()
@@ -987,8 +1046,10 @@ def run_stats_collection(runner, tests=None):
987
1046
  exit(1)
988
1047
 
989
1048
  print(' done')
1049
+ if not tests: # again, meaning all
1050
+ mutmut.stats_time = process_time() - start_cpu_time
990
1051
 
991
- if not mutmut.tests_by_mangled_function_name:
1052
+ if not collected_test_names():
992
1053
  print('failed to collect stats, no active tests found')
993
1054
  exit(1)
994
1055
 
@@ -1003,7 +1064,7 @@ def collect_or_load_stats(runner):
1003
1064
  run_stats_collection(runner)
1004
1065
  else:
1005
1066
  # Run incremental stats
1006
- with CatchOutput(show_spinner=True, spinner_title='collecting stats') as output_catcher:
1067
+ with CatchOutput(spinner_title='Listing all tests') as output_catcher:
1007
1068
  os.environ['MUTANT_UNDER_TEST'] = 'list_all_tests'
1008
1069
  try:
1009
1070
  all_tests_result = runner.list_all_tests()
@@ -1017,6 +1078,7 @@ def collect_or_load_stats(runner):
1017
1078
  new_tests = all_tests_result.new_tests()
1018
1079
 
1019
1080
  if new_tests:
1081
+ print(f'Found {len(new_tests)} new tests, rerunning stats collection')
1020
1082
  run_stats_collection(runner, tests=new_tests)
1021
1083
 
1022
1084
 
@@ -1028,6 +1090,7 @@ def load_stats():
1028
1090
  for k, v in data.pop('tests_by_mangled_function_name').items():
1029
1091
  mutmut.tests_by_mangled_function_name[k] |= set(v)
1030
1092
  mutmut.duration_by_test = data.pop('duration_by_test')
1093
+ mutmut.stats_time = data.pop('stats_time')
1031
1094
  assert not data, data
1032
1095
  did_load = True
1033
1096
  except (FileNotFoundError, JSONDecodeError):
@@ -1040,6 +1103,7 @@ def save_stats():
1040
1103
  json.dump(dict(
1041
1104
  tests_by_mangled_function_name={k: list(v) for k, v in mutmut.tests_by_mangled_function_name.items()},
1042
1105
  duration_by_test=mutmut.duration_by_test,
1106
+ stats_time=mutmut.stats_time,
1043
1107
  ), f, indent=4)
1044
1108
 
1045
1109
 
@@ -1118,6 +1182,23 @@ def stop_all_children(mutants):
1118
1182
  m.stop_children()
1119
1183
 
1120
1184
 
1185
+ def timeout_checker(mutants):
1186
+ def inner_timout_checker():
1187
+ while True:
1188
+ sleep(1)
1189
+
1190
+ now = datetime.now()
1191
+ for m, mutant_name, result in mutants:
1192
+ for pid, start_time in m.start_time_by_pid.items():
1193
+ run_time = now - start_time
1194
+ if run_time.total_seconds() > (m.estimated_time_of_tests_by_mutant[mutant_name] + 1) * 4:
1195
+ try:
1196
+ os.kill(pid, signal.SIGXCPU)
1197
+ except ProcessLookupError:
1198
+ pass
1199
+ return inner_timout_checker
1200
+
1201
+
1121
1202
  @cli.command()
1122
1203
  @click.option('--max-children', type=int)
1123
1204
  @click.argument('mutant_names', required=False, nargs=-1)
@@ -1126,13 +1207,15 @@ def run(mutant_names, *, max_children):
1126
1207
 
1127
1208
  # TODO: run no-ops once in a while to detect if we get false negatives
1128
1209
  # 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!
1129
-
1130
- start = datetime.now()
1131
- print('generating mutants')
1132
1210
  os.environ['MUTANT_UNDER_TEST'] = 'mutant_generation'
1133
1211
  read_config()
1134
- create_mutants()
1135
- copy_also_copy_files()
1212
+
1213
+ start = datetime.now()
1214
+ makedirs(Path('mutants'), exist_ok=True)
1215
+ with CatchOutput(spinner_title='Generating mutants'):
1216
+ create_mutants()
1217
+ copy_also_copy_files()
1218
+
1136
1219
  time = datetime.now() - start
1137
1220
  print(f' done in {round(time.total_seconds()*1000)}ms', )
1138
1221
 
@@ -1157,13 +1240,13 @@ def run(mutant_names, *, max_children):
1157
1240
  mutants, source_file_mutation_data_by_path = collect_source_file_mutation_data(mutant_names=mutant_names)
1158
1241
 
1159
1242
  os.environ['MUTANT_UNDER_TEST'] = ''
1160
- with CatchOutput(show_spinner=True, spinner_title='running clean tests') as output_catcher:
1243
+ with CatchOutput(spinner_title='Running clean tests') as output_catcher:
1161
1244
  tests = tests_for_mutant_names(mutant_names)
1162
1245
 
1163
1246
  clean_test_exit_code = runner.run_tests(mutant_name=None, tests=tests)
1164
1247
  if clean_test_exit_code != 0:
1165
1248
  output_catcher.dump_output()
1166
- print('failed to run clean test')
1249
+ print('Failed to run clean test')
1167
1250
  exit(1)
1168
1251
  print(' done')
1169
1252
 
@@ -1173,7 +1256,10 @@ def run(mutant_names, *, max_children):
1173
1256
  runner.prepare_main_test_run()
1174
1257
 
1175
1258
  def read_one_child_exit_status():
1176
- pid, exit_code = os.wait()
1259
+ pid, wait_status = os.wait()
1260
+ exit_code = os.waitstatus_to_exitcode(wait_status)
1261
+ if mutmut.config.debug:
1262
+ print(' worker exit code', exit_code)
1177
1263
  source_file_mutation_data_by_pid[pid].register_result(pid=pid, exit_code=exit_code)
1178
1264
 
1179
1265
  source_file_mutation_data_by_pid: Dict[int, SourceFileMutationData] = {} # many pids map to one MutationData
@@ -1192,6 +1278,16 @@ def run(mutant_names, *, max_children):
1192
1278
  try:
1193
1279
  print('Running mutation testing')
1194
1280
 
1281
+ # Calculate times of tests
1282
+ for m, mutant_name, result in mutants:
1283
+ mutant_name = mutant_name.replace('__init__.', '')
1284
+ tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), [])
1285
+ estimated_time_of_tests = sum(mutmut.duration_by_test[test_name] for test_name in tests)
1286
+ m.estimated_time_of_tests_by_mutant[mutant_name] = estimated_time_of_tests
1287
+
1288
+ Thread(target=timeout_checker(mutants), daemon=True).start()
1289
+
1290
+ # Now do mutation
1195
1291
  for m, mutant_name, result in mutants:
1196
1292
  print_stats(source_file_mutation_data_by_path)
1197
1293
 
@@ -1220,6 +1316,10 @@ def run(mutant_names, *, max_children):
1220
1316
  if not tests:
1221
1317
  os._exit(33)
1222
1318
 
1319
+ estimated_time_of_tests = m.estimated_time_of_tests_by_mutant[mutant_name]
1320
+ cpu_time_limit = ceil((estimated_time_of_tests + 1) * 2 + process_time()) * 10
1321
+ resource.setrlimit(resource.RLIMIT_CPU, (cpu_time_limit, cpu_time_limit))
1322
+
1223
1323
  with CatchOutput():
1224
1324
  result = runner.run_tests(mutant_name=mutant_name, tests=tests)
1225
1325
 
@@ -1230,7 +1330,7 @@ def run(mutant_names, *, max_children):
1230
1330
  else:
1231
1331
  # in the parent
1232
1332
  source_file_mutation_data_by_pid[pid] = m
1233
- m.register_pid(pid=pid, key=mutant_name)
1333
+ m.register_pid(pid=pid, key=mutant_name, estimated_time_of_tests=estimated_time_of_tests)
1234
1334
  running_children += 1
1235
1335
 
1236
1336
  if running_children >= max_children:
@@ -1246,7 +1346,7 @@ def run(mutant_names, *, max_children):
1246
1346
  except ChildProcessError:
1247
1347
  pass
1248
1348
  except KeyboardInterrupt:
1249
- print('stopping')
1349
+ print('Stopping...')
1250
1350
  stop_all_children(mutants)
1251
1351
 
1252
1352
  t = datetime.now() - start
@@ -1427,7 +1527,9 @@ def browse():
1427
1527
  from textual.containers import Container
1428
1528
  from textual.widgets import Footer
1429
1529
  from textual.widgets import DataTable
1430
- from textual.widgets import TextArea
1530
+ from textual.widgets import Static
1531
+ from textual.widget import Widget
1532
+ from rich.syntax import Syntax
1431
1533
 
1432
1534
  class ResultBrowser(App):
1433
1535
  loading_id = None
@@ -1443,7 +1545,7 @@ def browse():
1443
1545
  columns = [
1444
1546
  ('path', 'Path'),
1445
1547
  ] + [
1446
- (status, emoji)
1548
+ (status, Text(emoji, justify='right'))
1447
1549
  for status, emoji in emoji_by_status.items()
1448
1550
  ]
1449
1551
 
@@ -1454,17 +1556,20 @@ def browse():
1454
1556
  with Container(classes='container'):
1455
1557
  yield DataTable(id='files')
1456
1558
  yield DataTable(id='mutants')
1457
- yield TextArea(id='diff_view')
1559
+ with Widget(id="diff_view_widget"):
1560
+ yield Static(id='diff_view')
1458
1561
  yield Footer()
1459
1562
 
1460
1563
  def on_mount(self):
1461
1564
  # files table
1565
+ # noinspection PyTypeChecker
1462
1566
  files_table: DataTable = self.query_one('#files')
1463
1567
  files_table.cursor_type = 'row'
1464
1568
  for key, label in self.columns:
1465
1569
  files_table.add_column(key=key, label=label)
1466
1570
 
1467
1571
  # mutants table
1572
+ # noinspection PyTypeChecker
1468
1573
  mutants_table: DataTable = self.query_one('#mutants')
1469
1574
  mutants_table.cursor_type = 'row'
1470
1575
  mutants_table.add_columns('name', 'status')
@@ -1483,17 +1588,21 @@ def browse():
1483
1588
  source_file_mutation_data.load()
1484
1589
  stat = collect_stat(source_file_mutation_data)
1485
1590
 
1486
- self.source_file_mutation_data_and_stat_by_path[p] = source_file_mutation_data, stat
1591
+ self.source_file_mutation_data_and_stat_by_path[str(p)] = source_file_mutation_data, stat
1487
1592
 
1488
1593
  def populate_files_table(self):
1594
+ # noinspection PyTypeChecker
1489
1595
  files_table: DataTable = self.query_one('#files')
1490
1596
  # TODO: restore selection
1491
1597
  selected_row = files_table.cursor_row
1492
1598
  files_table.clear()
1493
1599
 
1494
1600
  for p, (source_file_mutation_data, stat) in sorted(self.source_file_mutation_data_and_stat_by_path.items()):
1495
- row = [p] + [getattr(stat, k.replace(' ', '_')) for k, _ in self.columns[1:]]
1496
- files_table.add_row(*row, key=p)
1601
+ row = [p] + [
1602
+ Text(str(getattr(stat, k.replace(' ', '_'))), justify="right")
1603
+ for k, _ in self.columns[1:]
1604
+ ]
1605
+ files_table.add_row(*row, key=str(p))
1497
1606
 
1498
1607
  files_table.move_cursor(row=selected_row)
1499
1608
 
@@ -1501,6 +1610,7 @@ def browse():
1501
1610
  if not event.row_key or not event.row_key.value:
1502
1611
  return
1503
1612
  if event.data_table.id == 'files':
1613
+ # noinspection PyTypeChecker
1504
1614
  mutants_table: DataTable = self.query_one('#mutants')
1505
1615
  mutants_table.clear()
1506
1616
  source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[event.row_key.value]
@@ -1511,11 +1621,12 @@ def browse():
1511
1621
  mutants_table.add_row(k, emoji_by_status[status], key=k)
1512
1622
  else:
1513
1623
  assert event.data_table.id == 'mutants'
1514
- diff_view = self.query_one('#diff_view')
1624
+ # noinspection PyTypeChecker
1625
+ diff_view: Static = self.query_one('#diff_view')
1515
1626
  if event.row_key.value is None:
1516
- diff_view.text = ''
1627
+ diff_view.update('')
1517
1628
  else:
1518
- diff_view.text = '<loading...>'
1629
+ diff_view.update('<loading...>')
1519
1630
  self.loading_id = event.row_key.value
1520
1631
 
1521
1632
  def load_thread():
@@ -1523,9 +1634,9 @@ def browse():
1523
1634
  try:
1524
1635
  d = get_diff_for_mutant(event.row_key.value)
1525
1636
  if event.row_key.value == self.loading_id:
1526
- diff_view.text = d
1637
+ diff_view.update(Syntax(d, "diff"))
1527
1638
  except Exception as e:
1528
- diff_view.text = f'<{e}>'
1639
+ diff_view.update(f"<{type(e)} {e}>")
1529
1640
 
1530
1641
  t = Thread(target=load_thread)
1531
1642
  t.start()
@@ -1541,6 +1652,7 @@ def browse():
1541
1652
  self.populate_files_table()
1542
1653
 
1543
1654
  def get_mutant_name_from_selection(self):
1655
+ # noinspection PyTypeChecker
1544
1656
  mutants_table: DataTable = self.query_one('#mutants')
1545
1657
  if mutants_table.cursor_row is None:
1546
1658
  return
@@ -1558,6 +1670,7 @@ def browse():
1558
1670
 
1559
1671
  def action_apply_mutant(self):
1560
1672
  read_config()
1673
+ # noinspection PyTypeChecker
1561
1674
  mutants_table: DataTable = self.query_one('#mutants')
1562
1675
  if mutants_table.cursor_row is None:
1563
1676
  return
@@ -24,3 +24,11 @@ DataTable:focus .datatable--cursor {
24
24
  color: black;
25
25
  background: orange;
26
26
  }
27
+
28
+ #diff_view_widget {
29
+ height: 50%;
30
+ overflow-y: scroll;
31
+ }
32
+
33
+ #diff_view {
34
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mutmut
3
- Version: 3.1.0
3
+ Version: 3.2.1
4
4
  Summary: mutation testing for Python 3
5
5
  Home-page: https://github.com/boxed/mutmut
6
6
  Author: Anders Hovmöller
@@ -20,6 +20,7 @@ Requires-Dist: click
20
20
  Requires-Dist: junit-xml==1.8
21
21
  Requires-Dist: setproctitle
22
22
  Requires-Dist: textual
23
+ Requires-Dist: toml>=0.10.2; python_version < "3.11"
23
24
 
24
25
  mutmut - python mutation tester
25
26
  ===============================
@@ -3,3 +3,6 @@ click
3
3
  junit-xml==1.8
4
4
  setproctitle
5
5
  textual
6
+
7
+ [:python_version < "3.11"]
8
+ toml>=0.10.2
@@ -3,3 +3,4 @@ click
3
3
  junit-xml==1.8
4
4
  setproctitle
5
5
  textual
6
+ toml>=0.10.2; python_version < '3.11'
File without changes
File without changes
File without changes
File without changes
File without changes