From 21f2a9584197a0d5111e240287970ba211eee8a4 Mon Sep 17 00:00:00 2001 From: nicklafleur <55208706+nicklafleur@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:26:47 -0400 Subject: [PATCH 1/9] refactor(config): unify Config/MutmutState to function-based singletons Config still exposed Config.get()/ensure_loaded()/reset() classmethods while MutmutState (added by #509) already used a bare config()/state()-style accessor. Replace the classmethod API with module-level config()/reset_config() functions to match, and migrate the remaining module-level globals in mutmut/__init__.py (stats_time, duration_by_test, tests_by_mangled_function_name, _stats, _covered_lines) onto MutmutState, keeping deprecated __getattr__ shims for external readers of the old mutmut.* attributes. Renames local variables that shadowed the new config()/state() function names where needed to avoid UnboundLocalError, and repoints test fixtures/monkeypatches that patched Config.get() at the class/module level to instead mutate the live config()/state() singleton instances in place, so patches stay visible to modules that already imported config by reference. --- src/mutmut/__init__.py | 41 ++++--- src/mutmut/__main__.py | 156 +++++++++++------------- src/mutmut/configuration.py | 28 ++--- src/mutmut/mutation/file_mutation.py | 4 +- src/mutmut/mutation/pragma_handling.py | 4 +- src/mutmut/state.py | 6 + src/mutmut/utils/safe_setproctitle.py | 4 +- tests/conftest.py | 20 +-- tests/e2e/e2e_utils.py | 8 +- tests/mutation/test_mutation.py | 64 +++++----- tests/test_check_associations.py | 18 +-- tests/test_configuration.py | 32 ++--- tests/test_generation_error_handling.py | 4 +- 13 files changed, 188 insertions(+), 201 deletions(-) diff --git a/src/mutmut/__init__.py b/src/mutmut/__init__.py index 51026469..e2b7804c 100644 --- a/src/mutmut/__init__.py +++ b/src/mutmut/__init__.py @@ -2,43 +2,46 @@ import importlib.metadata import warnings -from collections import defaultdict -from mutmut.configuration import Config +from mutmut.configuration import config +from mutmut.configuration import reset_config from mutmut.state import reset_state +from mutmut.state import state __version__ = importlib.metadata.version("mutmut") -stats_time: float | None = None -duration_by_test: dict[str, float] = defaultdict(float) -tests_by_mangled_function_name: dict[str, set[str]] = defaultdict(set) - -_stats: set[str] = set() -_covered_lines: dict[str, set[int]] | None = None +_DEPRECATED_STATE_ATTRS = frozenset( + { + "stats_time", + "duration_by_test", + "tests_by_mangled_function_name", + "_stats", + "_covered_lines", + } +) def __getattr__(name: str) -> object: match name: case "config": warnings.warn( - "mutmut.config is deprecated as of 3.4.1, use mutmut.configuration.Config.get() instead", + "mutmut.config is deprecated as of 3.4.1, use mutmut.configuration.config() instead", + FutureWarning, + stacklevel=2, + ) + return config() + case name if name in _DEPRECATED_STATE_ATTRS: + warnings.warn( + f"mutmut.{name} is deprecated, use mutmut.state.state().{name} instead", FutureWarning, stacklevel=2, ) - return Config.get() + return getattr(state(), name) case _: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") def _reset_globals() -> None: - global duration_by_test, stats_time, _stats, tests_by_mangled_function_name - global _covered_lines - - duration_by_test.clear() - stats_time = None - Config.reset() - _stats = set() - tests_by_mangled_function_name = defaultdict(set) - _covered_lines = None + reset_config() reset_state() diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 8a1bc80e..99b058dc 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -57,10 +57,10 @@ import libcst as cst from rich.text import Text -import mutmut from mutmut.code_coverage import gather_coverage from mutmut.code_coverage import get_covered_lines_for_file from mutmut.configuration import Config +from mutmut.configuration import config from mutmut.mutation.data import MutantLineSpans from mutmut.mutation.data import SourceFileMutationData from mutmut.mutation.file_mutation import FailedTypeCheckMutant @@ -122,11 +122,11 @@ def record_trampoline_hit(name: str, caller: str | None = None) -> None: assert not name.startswith("src."), "Failed trampoline hit. Module name starts with `src.`, which is invalid" - mutated_source_paths = Config.get().resolved_mutated_source_paths + mutated_source_paths = config().resolved_mutated_source_paths - if Config.get().max_stack_depth != -1: + if config().max_stack_depth != -1: f = inspect.currentframe() - c = Config.get().max_stack_depth + c = config().max_stack_depth while c and f: filename = f.f_code.co_filename f = f.f_back @@ -140,13 +140,13 @@ def record_trampoline_hit(name: str, caller: str | None = None) -> None: if not c: return - mutmut._stats.add(name) - if caller is not None and Config.get().track_dependencies: + state()._stats.add(name) + if caller is not None and config().track_dependencies: state().function_dependencies[name].add(caller) def walk_all_files() -> Iterator[tuple[str, str]]: - for path in Config.get().source_paths: + for path in config().source_paths: if not isdir(path): if isfile(path): yield "", str(path) @@ -163,9 +163,9 @@ def walk_source_files() -> Iterator[Path]: def walk_mutatable_files() -> Iterator[Path]: - config = Config.get() + cfg = config() for path in walk_source_files(): - if config.should_mutate(path): + if cfg.should_mutate(path): yield path @@ -252,7 +252,7 @@ def create_file_mutants(path: Path) -> FileMutationResult: output_path = Path("mutants") / path makedirs(output_path.parent, exist_ok=True) - if Config.get().should_mutate(path): + if config().should_mutate(path): return create_mutants_for_file(path, output_path) else: shutil.copy(path, output_path) @@ -277,13 +277,13 @@ def setup_source_paths() -> None: def store_lines_covered_by_tests() -> None: - if Config.get().mutate_only_covered_lines: - mutmut._covered_lines = gather_coverage(PytestRunner(), list(walk_source_files())) + if config().mutate_only_covered_lines: + state()._covered_lines = gather_coverage(PytestRunner(), list(walk_source_files())) def copy_also_copy_files() -> None: - assert isinstance(Config.get().also_copy, list) - for path in Config.get().also_copy: + assert isinstance(config().also_copy, list) + for path in config().also_copy: print(" also copying", path) path = Path(path) destination = Path("mutants") / path @@ -371,7 +371,7 @@ def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationRe def write_all_mutants_to_file(*, out: TextIOBase, source: str, filename: Path) -> MutatedFile: mutated_file = mutate_file_contents( - str(filename), source, get_covered_lines_for_file(str(filename), mutmut._covered_lines) + str(filename), source, get_covered_lines_for_file(str(filename), state()._covered_lines) ) out.write(mutated_file.code) @@ -403,7 +403,7 @@ def list_all_tests(self) -> ListAllTestsResult: def collected_test_names() -> set[str]: - return set(mutmut.duration_by_test.keys()) + return set(state().duration_by_test.keys()) class ListAllTestsResult: @@ -412,15 +412,15 @@ def __init__(self, *, ids: set[str]) -> None: self.ids = ids def clear_out_obsolete_test_names(self) -> None: - count_before = sum(len(x) for x in mutmut.tests_by_mangled_function_name) - mutmut.tests_by_mangled_function_name = defaultdict( + count_before = sum(len(x) for x in state().tests_by_mangled_function_name) + state().tests_by_mangled_function_name = defaultdict( set, **{ k: {test_name for test_name in test_names if test_name in self.ids} - for k, test_names in mutmut.tests_by_mangled_function_name.items() + for k, test_names in state().tests_by_mangled_function_name.items() }, ) - count_after = sum(len(x) for x in mutmut.tests_by_mangled_function_name) + count_after = sum(len(x) for x in state().tests_by_mangled_function_name) if count_before != count_after: print(f"Removed {count_before - count_after} obsolete test names") save_stats() @@ -431,19 +431,19 @@ def new_tests(self) -> set[str]: class PytestRunner(TestRunner): def __init__(self) -> None: - self._pytest_add_cli_args: list[str] = Config.get().pytest_add_cli_args - self._pytest_add_cli_args_test_selection: list[str] = Config.get().pytest_add_cli_args_test_selection + self._pytest_add_cli_args: list[str] = config().pytest_add_cli_args + self._pytest_add_cli_args_test_selection: list[str] = config().pytest_add_cli_args_test_selection # noinspection PyMethodMayBeStatic def execute_pytest(self, params: list[str], **kwargs: Any) -> int: import pytest params = ["--rootdir=.", "--tb=native"] + params + self._pytest_add_cli_args - if Config.get().debug: + if config().debug: params = ["-vv"] + params print("python -m pytest ", " ".join([f'"{param}"' for param in params])) exit_code = int(pytest.main(params, **kwargs)) - if Config.get().debug: + if config().debug: print(" exit code", exit_code) if exit_code == 4: raise BadTestExecutionCommandsException(params) @@ -461,18 +461,18 @@ def run_stats(self, *, tests: Iterable[str]) -> int: class StatsCollector: # noinspection PyMethodMayBeStatic def pytest_runtest_logstart(self, nodeid: str, location: Any) -> None: - mutmut.duration_by_test[nodeid] = 0 + state().duration_by_test[nodeid] = 0 # noinspection PyMethodMayBeStatic def pytest_runtest_teardown(self, item: Any, nextitem: Any) -> None: unused(nextitem) - for function in mutmut._stats: - mutmut.tests_by_mangled_function_name[function].add(strip_prefix(item._nodeid, prefix="mutants/")) - mutmut._stats.clear() + for function in state()._stats: + state().tests_by_mangled_function_name[function].add(strip_prefix(item._nodeid, prefix="mutants/")) + state()._stats.clear() # noinspection PyMethodMayBeStatic def pytest_runtest_makereport(self, item: Any, call: Any) -> None: - mutmut.duration_by_test[item.nodeid] += call.duration + state().duration_by_test[item.nodeid] += call.duration stats_collector = StatsCollector() @@ -526,9 +526,9 @@ def run_stats(self, *, tests: Iterable[str]) -> int: print("Running hammett stats...") def post_test_callback(_name: str, **_: Any) -> None: - for function in mutmut._stats: - mutmut.tests_by_mangled_function_name[function].add(_name) - mutmut._stats.clear() + for function in state()._stats: + state().tests_by_mangled_function_name[function].add(_name) + state()._stats.clear() return int( hammett.main( @@ -690,7 +690,7 @@ def __init__( ) -> None: self.strings: list[str] = [] self.spinner_title = spinner_title or "" - if Config.get().debug: + if config().debug: self.spinner_title += "\n" class StdOutRedirect(TextIOBase): @@ -716,7 +716,7 @@ def start(self) -> None: print_status(self.spinner_title) sys.stdout = self.redirect sys.stderr = self.redirect - if Config.get().debug: + if config().debug: self.stop() def dump_output(self) -> None: @@ -752,7 +752,7 @@ def run_stats_collection(runner: TestRunner, tests: Iterable[str] | None = None) os.environ["MUTANT_UNDER_TEST"] = "stats" os.environ["PY_IGNORE_IMPORTMISMATCH"] = "1" - depth = Config.get().dependency_tracking_depth + depth = config().dependency_tracking_depth os.environ["MUTMUT_DEPENDENCY_DEPTH"] = str(depth) start_cpu_time = process_time() @@ -762,13 +762,13 @@ def run_stats_collection(runner: TestRunner, tests: Iterable[str] | None = None) output_catcher.dump_output() print(f"failed to collect stats. runner returned {collect_stats_exit_code}") exit(1) - num_associated_tests = sum(len(tests) for tests in mutmut.tests_by_mangled_function_name.values()) + num_associated_tests = sum(len(tests) for tests in state().tests_by_mangled_function_name.values()) if num_associated_tests == 0: output_catcher.dump_output() print( "Stopping early, because we could not find any test case for any mutant. It seems that the selected tests do not cover any code that we mutated." ) - if not Config.get().debug: + if not config().debug: print("You can set debug=true to see the executed test names in the output above.") else: print("In the last pytest run above, you can see which tests we executed.") @@ -780,7 +780,7 @@ def run_stats_collection(runner: TestRunner, tests: Iterable[str] | None = None) print(" done") if not tests: # again, meaning all - mutmut.stats_time = process_time() - start_cpu_time + state().stats_time = process_time() - start_cpu_time if not collected_test_names(): print("failed to collect stats, no active tests found") @@ -795,9 +795,9 @@ def _cleanup_stale_stats() -> None: def _is_valid_key(key: str) -> bool: return get_module_from_key(key) in valid_modules - stale_keys = [k for k in mutmut.tests_by_mangled_function_name if not _is_valid_key(k)] + stale_keys = [k for k in state().tests_by_mangled_function_name if not _is_valid_key(k)] for k in stale_keys: - del mutmut.tests_by_mangled_function_name[k] + del state().tests_by_mangled_function_name[k] stale_dep_keys = [k for k in state().function_dependencies if not _is_valid_key(k)] for k in stale_dep_keys: @@ -879,7 +879,7 @@ def _hash_files(paths: Iterable[str]) -> dict[str, str]: def compute_watched_file_hashes() -> dict[str, str]: """Map watched-file path -> content hash for the default set plus user globs.""" - patterns = list(_DEFAULT_WATCHED_FILES) + list(Config.get().cache_invalidation_files) + patterns = list(_DEFAULT_WATCHED_FILES) + list(config().cache_invalidation_files) paths = [str(path) for pattern in patterns for path in sorted(Path(".").glob(pattern))] return _hash_files(paths) @@ -966,18 +966,18 @@ def _changed_dependency_files() -> set[str]: unavailable. Silent on the first run (no baseline to compare against). Noisy files (see ``_DEFAULT_INVALIDATION_EXCLUDE`` and ``cache_invalidation_exclude``) are dropped. """ - config = Config.get() + cfg = config() old_commit = state().old_git_commit - if config.use_git_change_detection and old_commit is not None: + if cfg.use_git_change_detection and old_commit is not None: git_changed = git_changed_non_py_files(old_commit) if git_changed is not None: # also catch explicitly-registered files that git ignores - changed = git_changed | _changed_hashed_files(restrict_to=config.cache_invalidation_files) + changed = git_changed | _changed_hashed_files(restrict_to=cfg.cache_invalidation_files) else: changed = _changed_hashed_files() else: changed = _changed_hashed_files() - return {p for p in changed if not _is_excluded(p, config)} + return {p for p in changed if not _is_excluded(p, cfg)} def _compute_baseline_file_hashes() -> dict[str, str]: @@ -985,12 +985,12 @@ def _compute_baseline_file_hashes() -> dict[str, str]: files; when git is available it also records every tracked non-.py file (minus noise) so a later git-less run can still detect changes to them. """ - config = Config.get() + cfg = config() hashes = compute_watched_file_hashes() - if config.use_git_change_detection: + if cfg.use_git_change_detection: tracked = git_tracked_non_py_files() if tracked is not None: - hashes.update(_hash_files(sorted(p for p in tracked if not _is_excluded(p, config)))) + hashes.update(_hash_files(sorted(p for p in tracked if not _is_excluded(p, cfg)))) return hashes @@ -1037,7 +1037,7 @@ def _report_watched_file_changes() -> bool: if not changed: return False - policy = Config.get().on_dependency_change + policy = config().on_dependency_change if policy == "ignore": return False listed = sorted(changed) @@ -1058,7 +1058,7 @@ def _apply_config_change_invalidation(mutants_caught_by_type_checker: dict[str, or an opt-in dependency rerun), in which case all results have already been reset. """ old_fp = state().old_config_fingerprint - new_fp = Config.get().config_fingerprint() + new_fp = config().config_fingerprint() changed_groups = {g for g in new_fp if old_fp.get(g) != new_fp[g]} if old_fp else set() dependency_rerun = _report_watched_file_changes() @@ -1067,8 +1067,8 @@ def _apply_config_change_invalidation(mutants_caught_by_type_checker: dict[str, # subset of results is safe to keep -> full reset and full stats recollection. if changed_groups & {"test_execution", "test_selection"} or dependency_rerun: _reset_mutant_results(lambda key, exit_code: True) - mutmut.duration_by_test.clear() - mutmut.tests_by_mangled_function_name.clear() + state().duration_by_test.clear() + state().tests_by_mangled_function_name.clear() state().function_dependencies.clear() return True @@ -1105,7 +1105,7 @@ def collect_or_load_stats( run_stats_collection(runner) else: _cleanup_stale_stats() - if Config.get().track_dependencies and invalidate_stale_callers: + if config().track_dependencies and invalidate_stale_callers: _invalidate_stale_dependency_edges() save_stats() @@ -1134,9 +1134,9 @@ def load_stats() -> bool: with open("mutants/mutmut-stats.json") as f: data = json.load(f) for k, v in data.pop("tests_by_mangled_function_name").items(): - mutmut.tests_by_mangled_function_name[k] |= set(v) - mutmut.duration_by_test = data.pop("duration_by_test") - mutmut.stats_time = data.pop("stats_time") + state().tests_by_mangled_function_name[k] |= set(v) + state().duration_by_test = data.pop("duration_by_test") + state().stats_time = data.pop("stats_time") state().old_function_hashes = data.pop("function_hashes", {}) for k, v in data.pop("function_dependencies", {}).items(): state().function_dependencies[k] = set(v) @@ -1157,12 +1157,12 @@ def save_stats() -> None: with open("mutants/mutmut-stats.json", "w") as f: json.dump( dict( - tests_by_mangled_function_name={k: list(v) for k, v in mutmut.tests_by_mangled_function_name.items()}, - duration_by_test=mutmut.duration_by_test, - stats_time=mutmut.stats_time, + tests_by_mangled_function_name={k: list(v) for k, v in state().tests_by_mangled_function_name.items()}, + duration_by_test=state().duration_by_test, + stats_time=state().stats_time, function_hashes=state().current_function_hashes, function_dependencies={k: list(v) for k, v in state().function_dependencies.items()}, - config_fingerprint=Config.get().config_fingerprint(), + config_fingerprint=config().config_fingerprint(), watched_file_hashes=state().watched_file_hashes, git_commit=state().git_commit, ), @@ -1194,7 +1194,6 @@ def save_cicd_stats(source_file_mutation_data_by_path: dict[str, SourceFileMutat # exports CI/CD stats to block pull requests from merging if mutation score is too low, or used in other ways in CI/CD pipelines @cli.command() def export_cicd_stats() -> None: - Config.ensure_loaded() source_file_mutation_data_by_path: dict[str, SourceFileMutationData] = {} @@ -1266,7 +1265,7 @@ def _check_test_to_mutant_associations( This check exits with an actionable message instead of producing the silent all-No-Tests outcome. """ - recorded = set(mutmut.tests_by_mangled_function_name.keys()) + recorded = set(state().tests_by_mangled_function_name.keys()) if not recorded: # No hits at all - the existing zero-check in run_stats_collection # already covers this path; nothing to add here. @@ -1298,15 +1297,14 @@ def _check_test_to_mutant_associations( def estimated_worst_case_time(mutant_name: str) -> float: - tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), set()) - return sum(mutmut.duration_by_test[t] for t in tests) + tests = state().tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), set()) + return sum(state().duration_by_test[t] for t in tests) @cli.command() @click.argument("mutant_names", required=False, nargs=-1) def print_time_estimates(mutant_names: tuple[str, ...]) -> None: assert isinstance(mutant_names, (tuple, list)), mutant_names - Config.ensure_loaded() runner = PytestRunner() runner.prepare_main_test_run() @@ -1369,7 +1367,6 @@ def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) -> # TODO: run no-ops once in a while to detect if we get false negatives # 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! os.environ["MUTANT_UNDER_TEST"] = "mutant_generation" - Config.ensure_loaded() if max_children is None: max_children = os.cpu_count() or 4 @@ -1389,7 +1386,7 @@ def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) -> ) mutants_caught_by_type_checker: dict[str, FailedTypeCheckMutant] = {} - if Config.get().type_check_command: + if config().type_check_command: with CatchOutput(spinner_title="Filtering mutations with type checker"): mutants_caught_by_type_checker = filter_mutants_with_type_checker() @@ -1429,7 +1426,7 @@ def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) -> def read_one_child_exit_status() -> None: pid, wait_status = os.wait() exit_code = os.waitstatus_to_exitcode(wait_status) - if Config.get().debug: + if config().debug: print(" worker exit code", exit_code) source_file_mutation_data_by_pid[pid].register_result(pid=pid, exit_code=exit_code) @@ -1447,8 +1444,8 @@ def read_one_child_exit_status() -> None: # Now do mutation for mutation_data, mutant_name, result in mutants: mutant_name = mutant_name.replace("__init__.", "") - tests = mutmut.tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), set()) - estimated_time_of_tests = sum(mutmut.duration_by_test[test_name] for test_name in tests) + tests = state().tests_by_mangled_function_name.get(mangled_name_from_mutant_name(mutant_name), set()) + estimated_time_of_tests = sum(state().duration_by_test[test_name] for test_name in tests) mutation_data.estimated_time_of_tests_by_mutant[mutant_name] = estimated_time_of_tests print_stats(source_file_mutation_data_by_path) @@ -1468,7 +1465,7 @@ def read_one_child_exit_status() -> None: mutation_data.save() continue - config = Config.get() + cfg = config() pid = os.fork() if pid == 0: # In the child @@ -1476,12 +1473,12 @@ def read_one_child_exit_status() -> None: setproctitle(f"mutmut: {mutant_name}") # Run fast tests first - sorted_tests = sorted(tests, key=lambda test_name: mutmut.duration_by_test[test_name]) + sorted_tests = sorted(tests, key=lambda test_name: state().duration_by_test[test_name]) if not sorted_tests: os._exit(33) cpu_time_limit_s = ceil( - (estimated_time_of_tests + config.timeout_constant) * config.timeout_multiplier * 2 + process_time() + (estimated_time_of_tests + cfg.timeout_constant) * cfg.timeout_multiplier * 2 + process_time() ) # signal SIGXCPU after . One second later signal SIGKILL if it is still running resource.setrlimit(resource.RLIMIT_CPU, (cpu_time_limit_s, cpu_time_limit_s + 1)) @@ -1494,7 +1491,7 @@ def read_one_child_exit_status() -> None: os._exit(result) else: # in the parent - wall_time_limit_s = (estimated_time_of_tests + config.timeout_constant) * config.timeout_multiplier + wall_time_limit_s = (estimated_time_of_tests + cfg.timeout_constant) * cfg.timeout_multiplier register_timeout(pid=pid, timeout_s=wall_time_limit_s) source_file_mutation_data_by_pid[pid] = mutation_data mutation_data.register_pid(pid=pid, key=mutant_name) @@ -1543,18 +1540,17 @@ def tests_for_mutant_names(mutant_names: tuple[str, ...] | list[str]) -> set[str tests = set() for mutant_name in mutant_names: if "*" in mutant_name: - for name, tests_of_this_name in mutmut.tests_by_mangled_function_name.items(): + for name, tests_of_this_name in state().tests_by_mangled_function_name.items(): if fnmatch.fnmatch(name, mutant_name): tests |= set(tests_of_this_name) else: - tests |= set(mutmut.tests_by_mangled_function_name[mangled_name_from_mutant_name(mutant_name)]) + tests |= set(state().tests_by_mangled_function_name[mangled_name_from_mutant_name(mutant_name)]) return tests @cli.command() @click.option("--all", default=False) def results(all: bool) -> None: - Config.ensure_loaded() for path in walk_mutatable_files(): m = SourceFileMutationData(path=path) m.load() @@ -1717,7 +1713,6 @@ def get_diff_for_mutant( @cli.command() @click.argument("mutant_name") def show(mutant_name: str) -> None: - Config.ensure_loaded() m = find_mutant(mutant_name) print(f"# {mutant_name}: {status_by_exit_code[m.exit_code_by_key[mutant_name]]}") print(get_diff_for_mutant(mutant_name, path=m.path)) @@ -1728,7 +1723,6 @@ def show(mutant_name: str) -> None: @click.argument("mutant_name") def apply(mutant_name: str) -> None: # try: - Config.ensure_loaded() apply_mutant(mutant_name) # except FileNotFoundError as e: # print(e) @@ -1762,7 +1756,6 @@ def apply_mutant(mutant_name: str) -> None: @cli.command() @click.option("--show-killed", is_flag=True, default=False, help="Display mutants killed by tests and type checker.") def browse(show_killed: bool) -> None: - Config.ensure_loaded() from rich.console import RenderableType from rich.syntax import Syntax @@ -1820,7 +1813,6 @@ def on_mount(self) -> None: self.populate_files_table() def read_data(self) -> None: - Config.ensure_loaded() self.source_file_mutation_data_and_stat_by_path = {} self.path_by_name: dict[str, Path] = {} @@ -1926,7 +1918,6 @@ def load_diff(self, mutant_name: str, path: Path | None, diff_view: Static) -> N if worker.is_cancelled: return - Config.ensure_loaded() try: update: RenderableType = Syntax(get_diff_for_mutant(mutant_name, path=path), "diff") except Exception as e: @@ -1982,7 +1973,6 @@ def action_retest_module(self) -> None: self.retest(name.rpartition(".")[0] + ".*") def action_apply_mutant(self) -> None: - Config.ensure_loaded() # noinspection PyTypeChecker mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] if mutants_table.cursor_row is None or not mutants_table.is_valid_row_index(mutants_table.cursor_row): diff --git a/src/mutmut/configuration.py b/src/mutmut/configuration.py index 5d7361df..e5710dd8 100644 --- a/src/mutmut/configuration.py +++ b/src/mutmut/configuration.py @@ -233,20 +233,14 @@ def _should_ignore_for_mutation(self, path: Path | str) -> bool: return True return False - @staticmethod - def ensure_loaded() -> None: - global _config - if _config is None: - _config = _load_config() - - @staticmethod - def get() -> Config: - global _config - Config.ensure_loaded() - assert _config is not None - return _config - - @staticmethod - def reset() -> None: - global _config - _config = None + +def config() -> Config: + global _config + if _config is None: + _config = _load_config() + return _config + + +def reset_config() -> None: + global _config + _config = None diff --git a/src/mutmut/mutation/file_mutation.py b/src/mutmut/mutation/file_mutation.py index cc8b0d76..04f5c6c5 100644 --- a/src/mutmut/mutation/file_mutation.py +++ b/src/mutmut/mutation/file_mutation.py @@ -18,7 +18,7 @@ from libcst.metadata import MetadataWrapper from libcst.metadata import PositionProvider -from mutmut.configuration import Config +from mutmut.configuration import config from mutmut.mutation.data import LineSpan from mutmut.mutation.mutators import OPERATORS_TYPE from mutmut.mutation.mutators import mutation_operators @@ -597,7 +597,7 @@ def group_by_path(errors: list[TypeCheckingError]) -> dict[Path, list[TypeChecki def filter_mutants_with_type_checker() -> dict[str, FailedTypeCheckMutant]: with change_cwd(Path("mutants")): - errors = run_type_checker(Config.get().type_check_command) + errors = run_type_checker(config().type_check_command) errors_by_path = group_by_path(errors) mutants_to_skip: dict[str, FailedTypeCheckMutant] = {} diff --git a/src/mutmut/mutation/pragma_handling.py b/src/mutmut/mutation/pragma_handling.py index af67f2c9..02cd5a8e 100644 --- a/src/mutmut/mutation/pragma_handling.py +++ b/src/mutmut/mutation/pragma_handling.py @@ -9,7 +9,7 @@ import libcst as cst from libcst.metadata import PositionProvider -from mutmut.configuration import Config +from mutmut.configuration import config @dataclass @@ -36,7 +36,7 @@ def get_ignored_lines(filename: str, source: str, metadata_wrapper: cst.Metadata def get_lines_ignored_by_pattern(source: str) -> set[int]: matching_lines = set() - for pattern in Config.get().do_not_mutate_patterns: + for pattern in config().do_not_mutate_patterns: compiled_pattern = re.compile(pattern) for i, line in enumerate(source.splitlines()): if compiled_pattern.search(line): diff --git a/src/mutmut/state.py b/src/mutmut/state.py index c1020898..a7745807 100644 --- a/src/mutmut/state.py +++ b/src/mutmut/state.py @@ -19,6 +19,12 @@ class MutmutState: watched_file_hashes: dict[str, str] = field(default_factory=dict) old_git_commit: str | None = None git_commit: str | None = None + # Migrated from module-level globals in mutmut/__init__.py. + stats_time: float | None = None + duration_by_test: defaultdict[str, float] = field(default_factory=lambda: defaultdict(float)) + tests_by_mangled_function_name: defaultdict[str, set[str]] = field(default_factory=lambda: defaultdict(set)) + _stats: set[str] = field(default_factory=set) + _covered_lines: dict[str, set[int]] | None = None _state: MutmutState | None = None diff --git a/src/mutmut/utils/safe_setproctitle.py b/src/mutmut/utils/safe_setproctitle.py index 427ba09a..2f12f4e6 100644 --- a/src/mutmut/utils/safe_setproctitle.py +++ b/src/mutmut/utils/safe_setproctitle.py @@ -10,9 +10,9 @@ Related: https://github.com/boxed/mutmut/pull/450#issuecomment-4002571055 """ -from mutmut.configuration import Config +from mutmut.configuration import config -if Config.get().use_setproctitle: +if config().use_setproctitle: from setproctitle import setproctitle as _setproctitle def safe_setproctitle(title: str) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 04c5360f..c17b931f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,21 +7,21 @@ @pytest.fixture(autouse=True) def reset_config(): - mutmut.configuration.Config.reset() + mutmut.configuration.reset_config() @pytest.fixture(name="patch_config") def monkeypatch_config_get(monkeypatch): - """Utility to overwrite values in the loaded Config""" - orig_get = mutmut.configuration.Config.get + """Utility to overwrite values in the loaded Config. - def patch_config(config_name: str, value: Any): - def patched_get(): - config = orig_get() - assert hasattr(config, config_name) - setattr(config, config_name, value) - return config + Mutates the singleton instance in place (rather than patching the ``config()`` + accessor) so the change is visible to every module that already imported + ``config`` by reference (``from mutmut.configuration import config``). + """ - monkeypatch.setattr(mutmut.configuration.Config, "get", patched_get) + def patch_config(config_name: str, value: Any): + cfg = mutmut.configuration.config() + assert hasattr(cfg, config_name) + monkeypatch.setattr(cfg, config_name, value) return patch_config diff --git a/tests/e2e/e2e_utils.py b/tests/e2e/e2e_utils.py index 1b7ca2d4..d4c1ee25 100644 --- a/tests/e2e/e2e_utils.py +++ b/tests/e2e/e2e_utils.py @@ -9,7 +9,8 @@ from mutmut.__main__ import SourceFileMutationData from mutmut.__main__ import _run from mutmut.__main__ import walk_source_files -from mutmut.configuration import Config +from mutmut.configuration import config +from mutmut.configuration import reset_config @contextmanager @@ -25,12 +26,11 @@ def change_cwd(path): def read_all_stats_for_project(project_path: Path) -> dict[str, dict]: """Create a single dict from all mutant results in *.meta files""" with change_cwd(project_path): - Config.reset() - Config.ensure_loaded() + reset_config() stats = {} for p in walk_source_files(): - if not Config.get().should_mutate(p): + if not config().should_mutate(p): continue data = SourceFileMutationData(path=p) data.load() diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index 6c602d98..b890b02e 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -1321,14 +1321,14 @@ def test_record_trampoline_hit_records_caller(monkeypatch): """record_trampoline_hit(name, caller=...) stores the edge in function_dependencies.""" reset_state() - mutmut._stats.clear() + state()._stats.clear() cfg = Mock(spec=Config) cfg.max_stack_depth = -1 cfg.source_paths = [] cfg.resolved_mutated_source_paths = [] cfg.track_dependencies = True - monkeypatch.setattr(Config, "get", lambda: cfg) + monkeypatch.setattr(mutmut.__main__, "config", lambda: cfg) record_trampoline_hit("my_module.x_foo", caller="my_module.x_bar") @@ -1340,14 +1340,14 @@ def test_record_trampoline_hit_skips_caller_when_disabled(monkeypatch): """record_trampoline_hit does not record dependencies when track_dependencies=False.""" reset_state() - mutmut._stats.clear() + state()._stats.clear() cfg = Mock(spec=Config) cfg.max_stack_depth = -1 cfg.source_paths = [] cfg.resolved_mutated_source_paths = [] cfg.track_dependencies = False - monkeypatch.setattr(Config, "get", lambda: cfg) + monkeypatch.setattr(mutmut.__main__, "config", lambda: cfg) record_trampoline_hit("my_module.x_foo", caller="my_module.x_bar") @@ -1359,21 +1359,21 @@ def test_cleanup_stale_stats_removes_unknown_modules(monkeypatch): """_cleanup_stale_stats removes test associations for modules not in current_function_hashes.""" reset_state() - old_stats = mutmut.tests_by_mangled_function_name - mutmut.tests_by_mangled_function_name = defaultdict(set) + old_stats = state().tests_by_mangled_function_name + state().tests_by_mangled_function_name = defaultdict(set) state().current_function_hashes["live_mod.x_foo"] = "aabbcc" - mutmut.tests_by_mangled_function_name["live_mod.x_foo__mutmut_orig"] = {"test_alive"} - mutmut.tests_by_mangled_function_name["dead_mod.x_bar__mutmut_orig"] = {"test_dead"} + state().tests_by_mangled_function_name["live_mod.x_foo__mutmut_orig"] = {"test_alive"} + state().tests_by_mangled_function_name["dead_mod.x_bar__mutmut_orig"] = {"test_dead"} state().function_dependencies["live_mod.x_baz"] = {"dead_mod.x_bar"} _cleanup_stale_stats() - assert "live_mod.x_foo__mutmut_orig" in mutmut.tests_by_mangled_function_name - assert "dead_mod.x_bar__mutmut_orig" not in mutmut.tests_by_mangled_function_name + assert "live_mod.x_foo__mutmut_orig" in state().tests_by_mangled_function_name + assert "dead_mod.x_bar__mutmut_orig" not in state().tests_by_mangled_function_name assert "dead_mod.x_bar" not in state().function_dependencies["live_mod.x_baz"] - mutmut.tests_by_mangled_function_name = old_stats + state().tests_by_mangled_function_name = old_stats reset_state() @@ -1459,7 +1459,7 @@ def _load_results(src_rel="src/mymod.py"): def test_reset_mutant_results_resets_only_matching(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation()) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation()) _write_meta({"a": 36, "b": 0, "c": None}) # timeout, survived, uncached reset = _reset_mutant_results(lambda key, exit_code: exit_code == 36) @@ -1476,7 +1476,7 @@ def test_timeout_config_change_resets_only_timeouts(tmp_path, monkeypatch): old_cfg = _config_for_invalidation() state().old_config_fingerprint = old_cfg.config_fingerprint() - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation(timeout_multiplier=30.0)) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation(timeout_multiplier=30.0)) _write_meta({"timed_out": 36, "killed": 1, "survived": 0}) force_full = _apply_config_change_invalidation({}) @@ -1493,7 +1493,7 @@ def test_type_check_config_change_resets_symmetric_difference(tmp_path, monkeypa old_cfg = _config_for_invalidation(type_check_command=["old"]) state().old_config_fingerprint = old_cfg.config_fingerprint() - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation(type_check_command=["new"])) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation(type_check_command=["new"])) # was_caught: cached 37 but no longer caught -> reset; now_caught: survived but newly caught -> reset; # still_caught: 37 and still caught -> keep; untouched: survived and not caught -> keep _write_meta({"was_caught": 37, "now_caught": 0, "still_caught": 37, "untouched": 0}) @@ -1515,18 +1515,18 @@ def test_global_pytest_change_forces_full_rerun(tmp_path, monkeypatch): reset_state() monkeypatch.chdir(tmp_path) state().old_config_fingerprint = _config_for_invalidation().config_fingerprint() - mutmut.duration_by_test["test_x"] = 1.0 - mutmut.tests_by_mangled_function_name["mod.x_foo"] = {"test_x"} + state().duration_by_test["test_x"] = 1.0 + state().tests_by_mangled_function_name["mod.x_foo"] = {"test_x"} - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation(pytest_add_cli_args=["-x"])) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation(pytest_add_cli_args=["-x"])) _write_meta({"a": 1, "b": 0, "c": 36}) force_full = _apply_config_change_invalidation({}) assert force_full is True assert all(v is None for v in _load_results().values()) - assert not mutmut.duration_by_test - assert not mutmut.tests_by_mangled_function_name + assert not state().duration_by_test + assert not state().tests_by_mangled_function_name reset_state() @@ -1535,7 +1535,7 @@ def test_no_config_change_keeps_all_results(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) cfg = _config_for_invalidation() state().old_config_fingerprint = cfg.config_fingerprint() - monkeypatch.setattr(Config, "get", lambda: cfg) + monkeypatch.setattr(mutmut.__main__, "config", lambda: cfg) _write_meta({"a": 1, "b": 0, "c": 36}) force_full = _apply_config_change_invalidation({}) @@ -1550,7 +1550,7 @@ def test_absent_fingerprint_is_silent(tmp_path, monkeypatch): reset_state() monkeypatch.chdir(tmp_path) # old_config_fingerprint left empty - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation(pytest_add_cli_args=["-x"])) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation(pytest_add_cli_args=["-x"])) _write_meta({"a": 1, "b": 0}) force_full = _apply_config_change_invalidation({}) @@ -1563,7 +1563,7 @@ def test_absent_fingerprint_is_silent(tmp_path, monkeypatch): def test_watched_file_change_warn_keeps_cache(tmp_path, monkeypatch, capsys): reset_state() monkeypatch.chdir(tmp_path) - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation()) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation()) pathlib.Path("pyproject.toml").write_text("[project]\nname='x'\n") state().old_watched_file_hashes = {"pyproject.toml": "deadbeef0000"} @@ -1577,7 +1577,7 @@ def test_watched_file_change_warn_keeps_cache(tmp_path, monkeypatch, capsys): def test_watched_file_change_rerun_policy(tmp_path, monkeypatch): reset_state() monkeypatch.chdir(tmp_path) - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation(on_dependency_change="rerun")) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation(on_dependency_change="rerun")) pathlib.Path("uv.lock").write_text("changed") state().old_watched_file_hashes = {"uv.lock": "deadbeef0000"} @@ -1588,7 +1588,7 @@ def test_watched_file_change_rerun_policy(tmp_path, monkeypatch): def test_watched_file_absent_old_hashes_is_silent(tmp_path, monkeypatch, capsys): reset_state() monkeypatch.chdir(tmp_path) - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation()) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation()) pathlib.Path("pyproject.toml").write_text("[project]\nname='x'\n") # old_watched_file_hashes left empty @@ -1599,7 +1599,7 @@ def test_watched_file_absent_old_hashes_is_silent(tmp_path, monkeypatch, capsys) def test_compute_watched_file_hashes_includes_user_globs(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation(cache_invalidation_files=["*.sql"])) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation(cache_invalidation_files=["*.sql"])) pathlib.Path("pyproject.toml").write_text("x") pathlib.Path("query.sql").write_text("select 1") @@ -1683,7 +1683,7 @@ def test_changed_dependency_files_prefers_git_over_curated_list(tmp_path, monkey (tmp_path / "config.yaml").write_text("a: 1") _commit_all(tmp_path) state().old_git_commit = git_head() - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation()) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation()) (tmp_path / "config.yaml").write_text("a: 2") @@ -1699,7 +1699,7 @@ def test_use_git_change_detection_false_falls_back_to_curated(tmp_path, monkeypa (tmp_path / "config.yaml").write_text("a: 1") _commit_all(tmp_path) state().old_git_commit = git_head() - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation(use_git_change_detection=False)) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation(use_git_change_detection=False)) (tmp_path / "config.yaml").write_text("a: 2") @@ -1718,7 +1718,7 @@ def test_default_exclude_drops_noisy_files(tmp_path, monkeypatch): (tmp_path / "config.yaml").write_text("a: 1") _commit_all(tmp_path) state().old_git_commit = git_head() - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation()) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation()) (tmp_path / "README.md").write_text("changed") (tmp_path / "config.yaml").write_text("a: 2") @@ -1737,7 +1737,7 @@ def test_user_exclude_pattern_drops_file(tmp_path, monkeypatch): (tmp_path / "noisy.json").write_text("1") _commit_all(tmp_path) state().old_git_commit = git_head() - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation(cache_invalidation_exclude=["*.json"])) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation(cache_invalidation_exclude=["*.json"])) (tmp_path / "noisy.json").write_text("2") @@ -1754,7 +1754,7 @@ def test_registered_file_is_immune_to_exclusion(tmp_path, monkeypatch): (tmp_path / "notes.md").write_text("a") # *.md is excluded by default _commit_all(tmp_path) state().old_git_commit = git_head() - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation(cache_invalidation_files=["notes.md"])) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation(cache_invalidation_files=["notes.md"])) (tmp_path / "notes.md").write_text("b") @@ -1788,7 +1788,7 @@ def test_baseline_records_git_files_for_gitless_fallback(tmp_path, monkeypatch): (tmp_path / "config.yaml").write_text("a: 1") (tmp_path / "README.md").write_text("hi") # excluded by default _commit_all(tmp_path) - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation()) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation()) _refresh_change_detection_baseline() baseline = state().watched_file_hashes @@ -1798,7 +1798,7 @@ def test_baseline_records_git_files_for_gitless_fallback(tmp_path, monkeypatch): # simulate a later run in an environment without git state().old_watched_file_hashes = baseline state().old_git_commit = None - monkeypatch.setattr(Config, "get", lambda: _config_for_invalidation(use_git_change_detection=False)) + monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation(use_git_change_detection=False)) (tmp_path / "config.yaml").write_text("a: 2") assert "config.yaml" in _changed_dependency_files() diff --git a/tests/test_check_associations.py b/tests/test_check_associations.py index 5c001aae..ce5c9cc2 100644 --- a/tests/test_check_associations.py +++ b/tests/test_check_associations.py @@ -5,17 +5,17 @@ import pytest -import mutmut from mutmut.__main__ import _check_test_to_mutant_associations from mutmut.mutation.data import SourceFileMutationData +from mutmut.state import state @pytest.fixture(autouse=True) def reset_tests_by_mangled_function_name(): - saved = mutmut.tests_by_mangled_function_name - mutmut.tests_by_mangled_function_name = defaultdict(set) + saved = state().tests_by_mangled_function_name + state().tests_by_mangled_function_name = defaultdict(set) yield - mutmut.tests_by_mangled_function_name = saved + state().tests_by_mangled_function_name = saved def _make_sfmd(mutant_names_per_path: dict[str, list[str]]) -> dict[str, SourceFileMutationData]: @@ -37,20 +37,20 @@ def test_no_recorded_keys_is_noop(self): def test_overlapping_keys_is_noop(self): # Healthy case: recorded key matches a mutant lookup key. - mutmut.tests_by_mangled_function_name["pkg.foo.x_add"].add("tests/test_foo.py::test_add") + state().tests_by_mangled_function_name["pkg.foo.x_add"].add("tests/test_foo.py::test_add") sfmd = _make_sfmd({"pkg/foo.py": ["pkg.foo.x_add__mutmut_1"]}) _check_test_to_mutant_associations(sfmd) # must not exit def test_no_expected_keys_is_noop(self): # No mutants generated yet -> nothing to compare against. - mutmut.tests_by_mangled_function_name["whatever.x_add"].add("tests/test_foo.py::test_add") + state().tests_by_mangled_function_name["whatever.x_add"].add("tests/test_foo.py::test_add") _check_test_to_mutant_associations({}) # must not exit def test_disjoint_keys_exits_with_diagnostic(self, capsys): # The bug case: trampolines were hit but recorded under a key shape # (no path prefix) that no mutant lookup can ever match. - mutmut.tests_by_mangled_function_name["foo.x_add"].add("tests/test_foo.py::test_add") - mutmut.tests_by_mangled_function_name["foo.x_is_positive"].add("tests/test_foo.py::test_is_positive") + state().tests_by_mangled_function_name["foo.x_add"].add("tests/test_foo.py::test_add") + state().tests_by_mangled_function_name["foo.x_is_positive"].add("tests/test_foo.py::test_is_positive") sfmd = _make_sfmd({"pkg/foo.py": ["pkg.foo.x_add__mutmut_1", "pkg.foo.x_is_positive__mutmut_1"]}) with pytest.raises(SystemExit) as exc_info: @@ -67,6 +67,6 @@ def test_partial_overlap_is_noop(self): # Even if only a subset matches, we don't bail - the per-mutant # lookups for unmatched ones will simply yield "No Tests" which is # legitimate (e.g. uncovered code). - mutmut.tests_by_mangled_function_name["pkg.foo.x_add"].add("tests/test_foo.py::test_add") + state().tests_by_mangled_function_name["pkg.foo.x_add"].add("tests/test_foo.py::test_add") sfmd = _make_sfmd({"pkg/foo.py": ["pkg.foo.x_add__mutmut_1", "pkg.foo.x_uncovered__mutmut_1"]}) _check_test_to_mutant_associations(sfmd) # must not exit diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 00192e3c..21a3f74b 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -6,14 +6,16 @@ from mutmut.configuration import _config_reader from mutmut.configuration import _guess_source_paths from mutmut.configuration import _load_config +from mutmut.configuration import config +from mutmut.configuration import reset_config @pytest.fixture(autouse=True) -def reset_config(): +def reset_config_singleton(): """Reset config singleton before and after each test.""" - Config.reset() + reset_config() yield - Config.reset() + reset_config() @pytest.fixture @@ -26,31 +28,23 @@ def in_tmp_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): class TestConfigSingleton: def test_get_loads_config(self, in_tmp_dir: Path): (in_tmp_dir / "src").mkdir() - config = Config.get() - assert config is not None - assert isinstance(config, Config) + cfg = config() + assert cfg is not None + assert isinstance(cfg, Config) def test_get_returns_same_instance(self, in_tmp_dir: Path): (in_tmp_dir / "src").mkdir() - config1 = Config.get() - config2 = Config.get() + config1 = config() + config2 = config() assert config1 is config2 def test_reset_clears_singleton(self, in_tmp_dir: Path): (in_tmp_dir / "src").mkdir() - config1 = Config.get() - Config.reset() - config2 = Config.get() + config1 = config() + reset_config() + config2 = config() assert config1 is not config2 - def test_ensure_loaded_is_idempotent(self, in_tmp_dir: Path): - (in_tmp_dir / "src").mkdir() - Config.ensure_loaded() - config1 = Config.get() - Config.ensure_loaded() - config2 = Config.get() - assert config1 is config2 - class TestShouldMutateFile: @staticmethod diff --git a/tests/test_generation_error_handling.py b/tests/test_generation_error_handling.py index ad3e087f..44af6a5b 100644 --- a/tests/test_generation_error_handling.py +++ b/tests/test_generation_error_handling.py @@ -6,7 +6,7 @@ import mutmut.__main__ from mutmut.__main__ import InvalidGeneratedSyntaxException from mutmut.__main__ import create_mutants -from mutmut.configuration import Config +from mutmut.configuration import config source_dir = Path(__file__).parent / "data" / "test_generation" source_dir = source_dir.relative_to(Path.cwd()) @@ -25,7 +25,7 @@ def test_mutant_generation_raises_exception_on_invalid_syntax(monkeypatch): source_dir / "invalid_syntax.py", ] monkeypatch.setattr(mutmut.__main__, "walk_source_files", lambda: source_files) - monkeypatch.setattr(Config.get(), "should_mutate", lambda _path: True) + monkeypatch.setattr(config(), "should_mutate", lambda _path: True) # should raise an exception, because we copy the invalid_syntax.py file and then verify # if it is valid syntax From f4323257e9ef17441ca06c06b7bfd6c33766c72b Mon Sep 17 00:00:00 2001 From: nicklafleur <55208706+nicklafleur@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:34:00 -0400 Subject: [PATCH 2/9] refactor(workers): create workers package Move timeout management from threading/ to workers/ package. - Move src/mutmut/threading/timeout.py -> src/mutmut/workers/timeout.py - Update __main__.py import to use new location - Move tests/threading/ -> tests/workers/ --- src/mutmut/__main__.py | 2 +- src/mutmut/threading/__init__.py | 0 src/mutmut/workers/__init__.py | 1 + src/mutmut/{threading => workers}/timeout.py | 0 tests/threading/__init__.py | 0 tests/workers/__init__.py | 1 + tests/{threading => workers}/test_timeout.py | 2 +- 7 files changed, 4 insertions(+), 2 deletions(-) delete mode 100644 src/mutmut/threading/__init__.py create mode 100644 src/mutmut/workers/__init__.py rename src/mutmut/{threading => workers}/timeout.py (100%) delete mode 100644 tests/threading/__init__.py create mode 100644 tests/workers/__init__.py rename tests/{threading => workers}/test_timeout.py (99%) diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 99b058dc..133b6e7e 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -68,8 +68,8 @@ from mutmut.mutation.file_mutation import filter_mutants_with_type_checker from mutmut.mutation.file_mutation import mutate_file_contents from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR -from mutmut.threading.timeout import register_timeout from mutmut.utils.safe_setproctitle import safe_setproctitle as setproctitle +from mutmut.workers.timeout import register_timeout if TYPE_CHECKING: from coverage import Coverage diff --git a/src/mutmut/threading/__init__.py b/src/mutmut/threading/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/mutmut/workers/__init__.py b/src/mutmut/workers/__init__.py new file mode 100644 index 00000000..81187ccd --- /dev/null +++ b/src/mutmut/workers/__init__.py @@ -0,0 +1 @@ +"""Worker infrastructure for mutmut.""" diff --git a/src/mutmut/threading/timeout.py b/src/mutmut/workers/timeout.py similarity index 100% rename from src/mutmut/threading/timeout.py rename to src/mutmut/workers/timeout.py diff --git a/tests/threading/__init__.py b/tests/threading/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/workers/__init__.py b/tests/workers/__init__.py new file mode 100644 index 00000000..687e7e97 --- /dev/null +++ b/tests/workers/__init__.py @@ -0,0 +1 @@ +"""Tests for worker infrastructure.""" diff --git a/tests/threading/test_timeout.py b/tests/workers/test_timeout.py similarity index 99% rename from tests/threading/test_timeout.py rename to tests/workers/test_timeout.py index 111042d8..a58a4322 100644 --- a/tests/threading/test_timeout.py +++ b/tests/workers/test_timeout.py @@ -6,7 +6,7 @@ import pytest -from mutmut.threading import timeout +from mutmut.workers import timeout @pytest.fixture(autouse=True) From dc097693ee1c31ba1573842abd9f4415b4955a4e Mon Sep 17 00:00:00 2001 From: nicklafleur <55208706+nicklafleur@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:42:31 -0400 Subject: [PATCH 3/9] refactor: extract __main__.py components into dedicated modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-functional reorganization: move cohesive groups of code out of the oversized __main__.py into dedicated modules. No behavior change. New/extended modules: - stats.py — status/emoji maps, Stat, collect_stat, calculate_summary_stats, print_stats, load_stats, save_stats - runners/harness.py — TestRunner ABC, PytestRunner, HammettRunner, ListAllTestsResult, collected_test_names, unused, and the test-runner exceptions (Collect/BadTestExecutionCommands) - ui/browse.py — ResultBrowser Textual app wrapped in run_result_browser(); get_diff_for_mutant/apply_mutant are injected to avoid a circular import back into __main__. Uses upstream #543's @work/Lock/get_current_worker diff-loading model. - ui/terminal.py — spinner + status_printer/print_status - utils/file_utils.py — walk_all_files/walk_source_files/ walk_mutatable_files, copy_src_dir, copy_also_copy_files, setup_source_paths (alongside the existing change_cwd) Move result_browser_layout.tcss into ui/ so Textual's CSS_PATH resolves relative to ui/browse.py. __main__.py re-imports the moved public names, so external `from mutmut.__main__ import ...` call sites keep working unchanged. Repoint code_coverage.py's TYPE_CHECKING import to runners.harness. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mutmut/__main__.py | 700 +----------------- src/mutmut/code_coverage.py | 2 +- src/mutmut/runners/__init__.py | 1 + src/mutmut/runners/harness.py | 214 ++++++ src/mutmut/stats.py | 146 ++++ src/mutmut/ui/__init__.py | 1 + src/mutmut/ui/browse.py | 261 +++++++ .../{ => ui}/result_browser_layout.tcss | 0 src/mutmut/ui/terminal.py | 35 + src/mutmut/utils/file_utils.py | 78 ++ 10 files changed, 760 insertions(+), 678 deletions(-) create mode 100644 src/mutmut/runners/__init__.py create mode 100644 src/mutmut/runners/harness.py create mode 100644 src/mutmut/stats.py create mode 100644 src/mutmut/ui/__init__.py create mode 100644 src/mutmut/ui/browse.py rename src/mutmut/{ => ui}/result_browser_layout.tcss (100%) create mode 100644 src/mutmut/ui/terminal.py diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 133b6e7e..3114012c 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -4,15 +4,12 @@ import platform import sys from collections.abc import Iterable -from collections.abc import Iterator from typing import TYPE_CHECKING from typing import Any from mutmut.state import state -from mutmut.utils.file_utils import change_cwd from mutmut.utils.format_utils import get_module_from_key from mutmut.utils.format_utils import get_mutant_name -from mutmut.utils.format_utils import strip_prefix if platform.system() == "Windows": print( @@ -24,38 +21,28 @@ import gc import hashlib import inspect -import itertools import json import resource import shutil import subprocess import warnings -from abc import ABC -from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass from dataclasses import field from datetime import datetime -from datetime import timedelta from difflib import unified_diff from io import TextIOBase -from json import JSONDecodeError from math import ceil from multiprocessing import Pool from multiprocessing import get_start_method from multiprocessing import set_start_method from os import makedirs -from os import walk -from os.path import isdir -from os.path import isfile from pathlib import Path -from threading import Lock from time import process_time from types import TracebackType import click import libcst as cst -from rich.text import Text from mutmut.code_coverage import gather_coverage from mutmut.code_coverage import get_covered_lines_for_file @@ -68,57 +55,34 @@ from mutmut.mutation.file_mutation import filter_mutants_with_type_checker from mutmut.mutation.file_mutation import mutate_file_contents from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR +from mutmut.runners.harness import CollectTestsFailedException +from mutmut.runners.harness import PytestRunner +from mutmut.runners.harness import TestRunner +from mutmut.runners.harness import collected_test_names +from mutmut.stats import calculate_summary_stats +from mutmut.stats import emoji_by_status +from mutmut.stats import load_stats +from mutmut.stats import print_stats +from mutmut.stats import save_stats +from mutmut.stats import status_by_exit_code +from mutmut.ui.browse import run_result_browser +from mutmut.ui.terminal import print_status +from mutmut.utils.file_utils import copy_also_copy_files +from mutmut.utils.file_utils import copy_src_dir +from mutmut.utils.file_utils import setup_source_paths +from mutmut.utils.file_utils import walk_mutatable_files +from mutmut.utils.file_utils import walk_source_files from mutmut.utils.safe_setproctitle import safe_setproctitle as setproctitle from mutmut.workers.timeout import register_timeout if TYPE_CHECKING: - from coverage import Coverage + pass # Document: surviving mutants are retested when you ask mutmut to retest them, interactively in the UI or via command line # TODO: pragma no mutate should end up in `skipped` category -status_by_exit_code = defaultdict( - lambda: "suspicious", - { - 1: "killed", - 3: "killed", # internal error in pytest means a kill - -24: "killed", - 0: "survived", - 5: "no tests", - 2: "check was interrupted by user", - None: "not checked", - 33: "no tests", - 34: "skipped", - 35: "suspicious", - 36: "timeout", - 37: "caught by type check", - -24: "timeout", # SIGXCPU - 24: "timeout", # SIGXCPU - 152: "timeout", # SIGXCPU - 255: "timeout", - -11: "segfault", - -9: "segfault", - }, -) - -emoji_by_status = { - "survived": "🙁", - "no tests": "🫥", - "timeout": "⏰", - "suspicious": "🤔", - "skipped": "🔇", - "caught by type check": "🧙", - "check was interrupted by user": "🛑", - "not checked": "?", - "killed": "🎉", - "segfault": "💥", -} - -exit_code_to_emoji = {exit_code: emoji_by_status[status] for exit_code, status in status_by_exit_code.items()} - - def record_trampoline_hit(name: str, caller: str | None = None) -> None: assert not name.startswith("src."), "Failed trampoline hit. Module name starts with `src.`, which is invalid" @@ -145,44 +109,10 @@ def record_trampoline_hit(name: str, caller: str | None = None) -> None: state().function_dependencies[name].add(caller) -def walk_all_files() -> Iterator[tuple[str, str]]: - for path in config().source_paths: - if not isdir(path): - if isfile(path): - yield "", str(path) - continue - for root, dirs, files in walk(path): - for filename in files: - yield root, filename - - -def walk_source_files() -> Iterator[Path]: - for root, filename in walk_all_files(): - if filename.endswith(".py"): - yield Path(root) / filename - - -def walk_mutatable_files() -> Iterator[Path]: - cfg = config() - for path in walk_source_files(): - if cfg.should_mutate(path): - yield path - - class MutmutProgrammaticFailException(Exception): pass -class CollectTestsFailedException(Exception): - pass - - -class BadTestExecutionCommandsException(Exception): - def __init__(self, pytest_args: list[str]) -> None: - msg = f"Failed to run pytest with args: {pytest_args}. If your config sets debug=true, the original pytest error should be above." - super().__init__(msg) - - class InvalidGeneratedSyntaxException(Exception): def __init__(self, file: Path | str) -> None: super().__init__( @@ -192,22 +122,6 @@ def __init__(self, file: Path | str) -> None: ) -def copy_src_dir() -> None: - for root, name in walk_all_files(): - source_path = Path(root) / name - target_path = Path("mutants") / root / name - - if target_path.exists(): - continue - - if isdir(source_path): - shutil.copytree(source_path, target_path) - else: - target_path.parent.mkdir(exist_ok=True, parents=True) - # copy mtime, so we later know that when source_mtime == target_mtime, the file is not (yet) mutated. - shutil.copy2(source_path, target_path) - - @dataclass class FileMutationResult: """Dataclass to transfer warnings and errors from child processes to the parent""" @@ -261,40 +175,11 @@ def create_file_mutants(path: Path) -> FileMutationResult: return FileMutationResult(error=e) -def setup_source_paths() -> None: - # ensure that the mutated source code can be imported by the tests - source_code_paths = [Path("."), Path("src"), Path("source")] - for path in source_code_paths: - mutated_path = Path("mutants") / path - if mutated_path.exists(): - sys.path.insert(0, str(mutated_path.absolute())) - - # ensure that the original code CANNOT be imported by the tests - for path in source_code_paths: - for i in range(len(sys.path)): - while i < len(sys.path) and Path(sys.path[i]).resolve() == path.resolve(): - del sys.path[i] - - def store_lines_covered_by_tests() -> None: if config().mutate_only_covered_lines: state()._covered_lines = gather_coverage(PytestRunner(), list(walk_source_files())) -def copy_also_copy_files() -> None: - assert isinstance(config().also_copy, list) - for path in config().also_copy: - print(" also copying", path) - path = Path(path) - destination = Path("mutants") / path - if not path.exists(): - continue - if path.is_file(): - shutil.copy2(path, destination) - else: - shutil.copytree(path, destination, dirs_exist_ok=True) - - def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationResult: warnings: list[Warning] = [] @@ -378,194 +263,6 @@ def write_all_mutants_to_file(*, out: TextIOBase, source: str, filename: Path) - return mutated_file -def unused(*_: object) -> None: - pass - - -class TestRunner(ABC): - def run_stats(self, *, tests: Iterable[str]) -> int: - raise NotImplementedError() - - def run_forced_fail(self) -> int: - raise NotImplementedError() - - def prepare_main_test_run(self) -> None: - pass - - def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: - raise NotImplementedError() - - def collect_main_test_coverage(self, cov: Coverage) -> int: - raise NotImplementedError() - - def list_all_tests(self) -> ListAllTestsResult: - raise NotImplementedError() - - -def collected_test_names() -> set[str]: - return set(state().duration_by_test.keys()) - - -class ListAllTestsResult: - def __init__(self, *, ids: set[str]) -> None: - assert isinstance(ids, set) - self.ids = ids - - def clear_out_obsolete_test_names(self) -> None: - count_before = sum(len(x) for x in state().tests_by_mangled_function_name) - state().tests_by_mangled_function_name = defaultdict( - set, - **{ - k: {test_name for test_name in test_names if test_name in self.ids} - for k, test_names in state().tests_by_mangled_function_name.items() - }, - ) - count_after = sum(len(x) for x in state().tests_by_mangled_function_name) - if count_before != count_after: - print(f"Removed {count_before - count_after} obsolete test names") - save_stats() - - def new_tests(self) -> set[str]: - return self.ids - collected_test_names() - - -class PytestRunner(TestRunner): - def __init__(self) -> None: - self._pytest_add_cli_args: list[str] = config().pytest_add_cli_args - self._pytest_add_cli_args_test_selection: list[str] = config().pytest_add_cli_args_test_selection - - # noinspection PyMethodMayBeStatic - def execute_pytest(self, params: list[str], **kwargs: Any) -> int: - import pytest - - params = ["--rootdir=.", "--tb=native"] + params + self._pytest_add_cli_args - if config().debug: - params = ["-vv"] + params - print("python -m pytest ", " ".join([f'"{param}"' for param in params])) - exit_code = int(pytest.main(params, **kwargs)) - if config().debug: - print(" exit code", exit_code) - if exit_code == 4: - raise BadTestExecutionCommandsException(params) - return exit_code - - def _pytest_args_regular_run(self, tests: Iterable[str]) -> list[str]: - pytest_args = ["-x", "-q", "-p", "no:randomly", "-p", "no:random-order"] - if tests: - pytest_args += list(tests) - else: - pytest_args += self._pytest_add_cli_args_test_selection - return pytest_args - - def run_stats(self, *, tests: Iterable[str]) -> int: - class StatsCollector: - # noinspection PyMethodMayBeStatic - def pytest_runtest_logstart(self, nodeid: str, location: Any) -> None: - state().duration_by_test[nodeid] = 0 - - # noinspection PyMethodMayBeStatic - def pytest_runtest_teardown(self, item: Any, nextitem: Any) -> None: - unused(nextitem) - for function in state()._stats: - state().tests_by_mangled_function_name[function].add(strip_prefix(item._nodeid, prefix="mutants/")) - state()._stats.clear() - - # noinspection PyMethodMayBeStatic - def pytest_runtest_makereport(self, item: Any, call: Any) -> None: - state().duration_by_test[item.nodeid] += call.duration - - stats_collector = StatsCollector() - - with change_cwd("mutants"): - return int(self.execute_pytest(self._pytest_args_regular_run(tests), plugins=[stats_collector])) - - def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: - with change_cwd("mutants"): - return int(self.execute_pytest(self._pytest_args_regular_run(tests))) - - def collect_main_test_coverage(self, cov: Coverage) -> int: - with change_cwd("mutants"), cov.collect(): - self.prepare_main_test_run() - return int(self.execute_pytest(self._pytest_args_regular_run([]))) - - def run_forced_fail(self) -> int: - return self.run_tests(mutant_name=None, tests=[]) - - def list_all_tests(self) -> ListAllTestsResult: - class TestsCollector: - def __init__(self) -> None: - self.collected_nodeids: set[str] = set() - self.deselected_nodeids: set[str] = set() - - def pytest_collection_modifyitems(self, items: Any) -> None: - self.collected_nodeids |= {item.nodeid for item in items} - - def pytest_deselected(self, items: Any) -> None: - self.deselected_nodeids |= {item.nodeid for item in items} - - collector = TestsCollector() - - pytest_args = ["-x", "-q", "--collect-only"] + self._pytest_add_cli_args_test_selection - - with change_cwd("mutants"): - exit_code = int(self.execute_pytest(pytest_args, plugins=[collector])) - if exit_code != 0: - raise CollectTestsFailedException() - - selected_nodeids = collector.collected_nodeids - collector.deselected_nodeids - return ListAllTestsResult(ids=selected_nodeids) - - -class HammettRunner(TestRunner): - def __init__(self) -> None: - self.hammett_kwargs: Any = None - - def run_stats(self, *, tests: Iterable[str]) -> int: - import hammett - - print("Running hammett stats...") - - def post_test_callback(_name: str, **_: Any) -> None: - for function in state()._stats: - state().tests_by_mangled_function_name[function].add(_name) - state()._stats.clear() - - return int( - hammett.main( - quiet=True, - fail_fast=True, - disable_assert_analyze=True, - post_test_callback=post_test_callback, - use_cache=False, - insert_cwd=False, - ) - ) - - def run_forced_fail(self) -> int: - import hammett - - return int( - hammett.main(quiet=True, fail_fast=True, disable_assert_analyze=True, use_cache=False, insert_cwd=False) - ) - - def prepare_main_test_run(self) -> None: - import hammett - - self.hammett_kwargs = hammett.main_setup( - quiet=True, - fail_fast=True, - disable_assert_analyze=True, - use_cache=False, - insert_cwd=False, - ) - - def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: - import hammett - - hammett.Config.workerinput = dict(workerinput=f"_{mutant_name}") - return int(hammett.main_run_tests(**self.hammett_kwargs, tests=tests)) - - def mangled_name_from_mutant_name(mutant_name: str) -> str: assert "__mutmut_" in mutant_name, mutant_name return mutant_name.partition("__mutmut_")[0] @@ -584,90 +281,6 @@ def orig_function_and_class_names_from_key(mutant_name: str) -> tuple[str, str | return r, class_name -spinner = itertools.cycle("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") - - -def status_printer() -> Callable[..., None]: - """Manage the printing and in-place updating of a line of characters - - .. note:: - If the string is longer than a line, then in-place updating may not - work (it will print a new line at each refresh). - """ - last_len = [0] - last_update = [datetime(1900, 1, 1)] - update_threshold = timedelta(seconds=0.1) - - def p(s: str, *, force_output: bool = False) -> None: - if not force_output and (datetime.now() - last_update[0]) < update_threshold: - return - s = next(spinner) + " " + s - len_s = len(s) - output = "\r" + s + (" " * max(last_len[0] - len_s, 0)) - assert sys.__stdout__ is not None - sys.__stdout__.write(output) - sys.__stdout__.flush() - last_len[0] = len_s - - return p - - -print_status = status_printer() - - -@dataclass -class Stat: - not_checked: int - killed: int - survived: int - total: int - no_tests: int - skipped: int - suspicious: int - timeout: int - check_was_interrupted_by_user: int - segfault: int - caught_by_type_check: int - - -def collect_stat(m: SourceFileMutationData) -> Stat: - r = {k.replace(" ", "_"): 0 for k in status_by_exit_code.values()} - for k, v in m.exit_code_by_key.items(): - # noinspection PyTypeChecker - r[status_by_exit_code[v].replace(" ", "_")] += 1 - return Stat( - **r, - total=sum(r.values()), - ) - - -def calculate_summary_stats(source_file_mutation_data_by_path: dict[str, SourceFileMutationData]) -> Stat: - stats = [collect_stat(x) for x in source_file_mutation_data_by_path.values()] - return Stat( - not_checked=sum(x.not_checked for x in stats), - killed=sum(x.killed for x in stats), - survived=sum(x.survived for x in stats), - total=sum(x.total for x in stats), - no_tests=sum(x.no_tests for x in stats), - skipped=sum(x.skipped for x in stats), - suspicious=sum(x.suspicious for x in stats), - timeout=sum(x.timeout for x in stats), - check_was_interrupted_by_user=sum(x.check_was_interrupted_by_user for x in stats), - segfault=sum(x.segfault for x in stats), - caught_by_type_check=sum(x.caught_by_type_check for x in stats), - ) - - -def print_stats( - source_file_mutation_data_by_path: dict[str, SourceFileMutationData], force_output: bool = False -) -> None: - s = calculate_summary_stats(source_file_mutation_data_by_path) - print_status( - f"{(s.total - s.not_checked)}/{s.total} 🎉 {s.killed} 🫥 {s.no_tests} ⏰ {s.timeout} 🤔 {s.suspicious} 🙁 {s.survived} 🔇 {s.skipped} 🧙 {s.caught_by_type_check}", - force_output=force_output, - ) - - def run_forced_fail_test(runner: TestRunner) -> None: os.environ["MUTANT_UNDER_TEST"] = "fail" with CatchOutput(spinner_title="Running forced fail test") as catcher: @@ -1128,49 +741,6 @@ def collect_or_load_stats( run_stats_collection(runner, tests=new_tests) -def load_stats() -> bool: - did_load = False - try: - with open("mutants/mutmut-stats.json") as f: - data = json.load(f) - for k, v in data.pop("tests_by_mangled_function_name").items(): - state().tests_by_mangled_function_name[k] |= set(v) - state().duration_by_test = data.pop("duration_by_test") - state().stats_time = data.pop("stats_time") - state().old_function_hashes = data.pop("function_hashes", {}) - for k, v in data.pop("function_dependencies", {}).items(): - state().function_dependencies[k] = set(v) - state().old_config_fingerprint = data.pop("config_fingerprint", {}) - state().old_watched_file_hashes = data.pop("watched_file_hashes", {}) - state().old_git_commit = data.pop("git_commit", None) - # Preserve the loaded baseline; only a full run refreshes it. - state().watched_file_hashes = state().old_watched_file_hashes - state().git_commit = state().old_git_commit - assert not data, data - did_load = True - except (FileNotFoundError, JSONDecodeError): - pass - return did_load - - -def save_stats() -> None: - with open("mutants/mutmut-stats.json", "w") as f: - json.dump( - dict( - tests_by_mangled_function_name={k: list(v) for k, v in state().tests_by_mangled_function_name.items()}, - duration_by_test=state().duration_by_test, - stats_time=state().stats_time, - function_hashes=state().current_function_hashes, - function_dependencies={k: list(v) for k, v in state().function_dependencies.items()}, - config_fingerprint=config().config_fingerprint(), - watched_file_hashes=state().watched_file_hashes, - git_commit=state().git_commit, - ), - f, - indent=4, - ) - - def save_cicd_stats(source_file_mutation_data_by_path: dict[str, SourceFileMutationData]) -> None: s = calculate_summary_stats(source_file_mutation_data_by_path) with open("mutants/mutmut-cicd-stats.json", "w") as f: @@ -1756,235 +1326,11 @@ def apply_mutant(mutant_name: str) -> None: @cli.command() @click.option("--show-killed", is_flag=True, default=False, help="Display mutants killed by tests and type checker.") def browse(show_killed: bool) -> None: - - from rich.console import RenderableType - from rich.syntax import Syntax - from textual import work - from textual.app import App - from textual.containers import Container - from textual.widget import Widget - from textual.widgets import DataTable - from textual.widgets import Footer - from textual.widgets import Static - from textual.worker import get_current_worker - - class ResultBrowser(App[None]): - CSS_PATH = "result_browser_layout.tcss" - BINDINGS = [ - ("q", "quit()", "Quit"), - ("r", "retest_mutant()", "Retest mutant"), - ("f", "retest_function()", "Retest function"), - ("m", "retest_module()", "Retest module"), - ("a", "apply_mutant()", "Apply mutant to disk"), - ("t", "view_tests()", "View tests for mutant"), - ] - - columns = [ - ("path", "Path"), - ] + [(status, Text(emoji, justify="right")) for status, emoji in emoji_by_status.items()] - - cursor_type = "row" - source_file_mutation_data_and_stat_by_path: dict[str, tuple[SourceFileMutationData, Stat]] = {} - path_by_name: dict[str, Path] = {} - diff_load_lock = Lock() - - def compose(self) -> Iterable[Any]: - with Container(classes="container"): - yield DataTable(id="files") - yield DataTable(id="mutants") - with Widget(id="diff_view_widget"): - yield Static(id="description") - yield Static(id="diff_view") - yield Footer() - - def on_mount(self) -> None: - # noinspection PyTypeChecker - files_table: DataTable[Any] = self.query_one("#files") # type: ignore[assignment] - files_table.cursor_type = "row" - for key, label in self.columns: - files_table.add_column(key=key, label=label) - - # noinspection PyTypeChecker - mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] - mutants_table.cursor_type = "row" - mutants_table.add_columns("name", "status") - - self.read_data() - self.populate_files_table() - - def read_data(self) -> None: - self.source_file_mutation_data_and_stat_by_path = {} - self.path_by_name: dict[str, Path] = {} - - for p in walk_mutatable_files(): - source_file_mutation_data = SourceFileMutationData(path=p) - source_file_mutation_data.load() - stat = collect_stat(source_file_mutation_data) - - self.source_file_mutation_data_and_stat_by_path[str(p)] = source_file_mutation_data, stat - for name in source_file_mutation_data.exit_code_by_key: - self.path_by_name[name] = p - - def populate_files_table(self) -> None: - # noinspection PyTypeChecker - files_table: DataTable[Any] = self.query_one("#files") # type: ignore[assignment] - # TODO: restore selection - selected_row = files_table.cursor_row - files_table.clear() - - for p, (source_file_mutation_data, stat) in sorted(self.source_file_mutation_data_and_stat_by_path.items()): - row = [p] + [ - Text(str(getattr(stat, k.replace(" ", "_"))), justify="right") for k, _ in self.columns[1:] - ] - files_table.add_row(*row, key=str(p)) - - files_table.move_cursor(row=selected_row) - - def on_data_table_row_highlighted(self, event: Any) -> None: - if not event.row_key or not event.row_key.value: - return - if event.data_table.id == "files": - # noinspection PyTypeChecker - mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] - mutants_table.clear() - source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[event.row_key.value] - for k, v in source_file_mutation_data.exit_code_by_key.items(): - status = status_by_exit_code[v] - if status not in ("killed", "caught by type check") or show_killed: - mutants_table.add_row(k, emoji_by_status[status], key=k) - else: - assert event.data_table.id == "mutants" - # noinspection PyTypeChecker - description_view: Static = self.query_one("#description") # type: ignore[assignment] - mutant_name = event.row_key.value - path = self.path_by_name.get(mutant_name) - source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[str(path)] - - exit_code = source_file_mutation_data.exit_code_by_key[mutant_name] - status = status_by_exit_code[exit_code] - estimated_duration = source_file_mutation_data.estimated_time_of_tests_by_mutant.get(mutant_name, "?") - duration = source_file_mutation_data.durations_by_key.get(mutant_name, "?") - type_check_error = source_file_mutation_data.type_check_error_by_key.get(mutant_name, "?") - - view_tests_description = "(press t to view tests executed for this mutant)" - - match status: - case "killed": - description = f"Killed ({exit_code=}): Mutant caused a test to fail 🎉" - case "survived": - description = f"Survived ({exit_code=}): No test detected this mutant. {view_tests_description}" - case "skipped": - description = f"Skipped ({exit_code=})" - case "check was interrupted by user": - description = f"User interrupted ({exit_code=})" - case "caught by type check": - description = f"Caught by type checker ({exit_code=}): {type_check_error}" - case "timeout": - description = ( - f"Timeout ({exit_code=}): Timed out because tests did not finish within {duration:.3f} seconds. " - f"Tests without mutation took {estimated_duration:.3f} seconds. {view_tests_description}" - ) - case "no tests": - description = ( - f"Untested ({exit_code=}): Skipped because selected tests do not execute this code." - ) - case "segfault": - description = f"Segfault ({exit_code=}): Running pytest with this mutant segfaulted." - case "suspicious": - description = ( - f"Unknown ({exit_code=}): Running pytest with this mutant resulted in an unknown exit code." - ) - case "not checked": - description = "Not checked in the last mutmut run." - case _: - description = f"Unknown status ({exit_code=}, {status=})" - description_view.update(f"\n {description}\n") - - diff_view: Static = self.query_one("#diff_view") # type: ignore[assignment] - diff_view.update("") - - self.load_diff(mutant_name, path, diff_view) - - @work(exclusive=True, thread=True, group="load_diff") - def load_diff(self, mutant_name: str, path: Path | None, diff_view: Static) -> None: - """Load the diff for a mutant and display it. - - Only one diff is loaded at a time, and moving on to another mutant cancels the loads - that have not started yet. Otherwise moving through a long list of mutants piles up - loads for diffs that are never going to be displayed.""" - worker = get_current_worker() - - with self.diff_load_lock: - if worker.is_cancelled: - return - - try: - update: RenderableType = Syntax(get_diff_for_mutant(mutant_name, path=path), "diff") - except Exception as e: - update = f"<{type(e)} {e}>" - - if worker.is_cancelled: - return - - self.call_from_thread(diff_view.update, update) - - def retest(self, pattern: str | None) -> None: - if pattern is None: - return - self._run_subprocess_command("run", [pattern]) - - def view_tests(self, mutant_name: str | None) -> None: - if mutant_name is None: - return - self._run_subprocess_command("tests-for-mutant", [mutant_name]) - - def _run_subprocess_command(self, command: str, args: list[str]) -> None: - with self.suspend(): - browse_index = sys.argv.index("browse") - initial_args = sys.argv[:browse_index] - subprocess_args = [sys.executable, *initial_args, command, *args] - print(">", *subprocess_args) - subprocess.run(subprocess_args) - input("press enter to return to browser") - - self.read_data() - self.populate_files_table() - - def get_mutant_name_from_selection(self) -> str | None: - # noinspection PyTypeChecker - mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] - if mutants_table.cursor_row is None or not mutants_table.is_valid_row_index(mutants_table.cursor_row): - return None - - result: str = mutants_table.get_row_at(mutants_table.cursor_row)[0] - return result - - def action_retest_mutant(self) -> None: - self.retest(self.get_mutant_name_from_selection()) - - def action_retest_function(self) -> None: - name = self.get_mutant_name_from_selection() - if name is not None: - self.retest(name.rpartition("__mutmut_")[0] + "__mutmut_*") - - def action_retest_module(self) -> None: - name = self.get_mutant_name_from_selection() - if name is not None: - self.retest(name.rpartition(".")[0] + ".*") - - def action_apply_mutant(self) -> None: - # noinspection PyTypeChecker - mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] - if mutants_table.cursor_row is None or not mutants_table.is_valid_row_index(mutants_table.cursor_row): - return - apply_mutant(mutants_table.get_row_at(mutants_table.cursor_row)[0]) - - def action_view_tests(self) -> None: - name = self.get_mutant_name_from_selection() - if name is not None: - self.view_tests(name) - - ResultBrowser().run() + run_result_browser( + show_killed=show_killed, + get_diff_for_mutant=get_diff_for_mutant, + apply_mutant=apply_mutant, + ) if __name__ == "__main__": diff --git a/src/mutmut/code_coverage.py b/src/mutmut/code_coverage.py index bbf401b7..e70e608c 100644 --- a/src/mutmut/code_coverage.py +++ b/src/mutmut/code_coverage.py @@ -11,7 +11,7 @@ from coverage import CoverageData if TYPE_CHECKING: - from mutmut.__main__ import TestRunner + from mutmut.runners.harness import TestRunner # Returns a set of lines that are covered in this file gvein the covered_lines dict diff --git a/src/mutmut/runners/__init__.py b/src/mutmut/runners/__init__.py new file mode 100644 index 00000000..8f266eb4 --- /dev/null +++ b/src/mutmut/runners/__init__.py @@ -0,0 +1 @@ +"""Test runner implementations for mutmut.""" diff --git a/src/mutmut/runners/harness.py b/src/mutmut/runners/harness.py new file mode 100644 index 00000000..686ec508 --- /dev/null +++ b/src/mutmut/runners/harness.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from abc import ABC +from collections import defaultdict +from collections.abc import Iterable +from typing import TYPE_CHECKING +from typing import Any + +from mutmut.configuration import config +from mutmut.state import state +from mutmut.stats import save_stats +from mutmut.utils.file_utils import change_cwd +from mutmut.utils.format_utils import strip_prefix + +if TYPE_CHECKING: + from coverage import Coverage + + +class CollectTestsFailedException(Exception): + pass + + +class BadTestExecutionCommandsException(Exception): + def __init__(self, pytest_args: list[str]) -> None: + msg = f"Failed to run pytest with args: {pytest_args}. If your config sets debug=true, the original pytest error should be above." + super().__init__(msg) + + +def unused(*_: object) -> None: + pass + + +class TestRunner(ABC): + def run_stats(self, *, tests: Iterable[str]) -> int: + raise NotImplementedError() + + def run_forced_fail(self) -> int: + raise NotImplementedError() + + def prepare_main_test_run(self) -> None: + pass + + def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: + raise NotImplementedError() + + def collect_main_test_coverage(self, cov: Coverage) -> int: + raise NotImplementedError() + + def list_all_tests(self) -> ListAllTestsResult: + raise NotImplementedError() + + +def collected_test_names() -> set[str]: + return set(state().duration_by_test.keys()) + + +class ListAllTestsResult: + def __init__(self, *, ids: set[str]) -> None: + assert isinstance(ids, set) + self.ids = ids + + def clear_out_obsolete_test_names(self) -> None: + count_before = sum(len(x) for x in state().tests_by_mangled_function_name) + state().tests_by_mangled_function_name = defaultdict( + set, + **{ + k: {test_name for test_name in test_names if test_name in self.ids} + for k, test_names in state().tests_by_mangled_function_name.items() + }, + ) + count_after = sum(len(x) for x in state().tests_by_mangled_function_name) + if count_before != count_after: + print(f"Removed {count_before - count_after} obsolete test names") + save_stats() + + def new_tests(self) -> set[str]: + return self.ids - collected_test_names() + + +class PytestRunner(TestRunner): + def __init__(self) -> None: + self._pytest_add_cli_args: list[str] = config().pytest_add_cli_args + self._pytest_add_cli_args_test_selection: list[str] = config().pytest_add_cli_args_test_selection + + # noinspection PyMethodMayBeStatic + def execute_pytest(self, params: list[str], **kwargs: Any) -> int: + import pytest + + params = ["--rootdir=.", "--tb=native"] + params + self._pytest_add_cli_args + if config().debug: + params = ["-vv"] + params + print("python -m pytest ", " ".join([f'"{param}"' for param in params])) + exit_code = int(pytest.main(params, **kwargs)) + if config().debug: + print(" exit code", exit_code) + if exit_code == 4: + raise BadTestExecutionCommandsException(params) + return exit_code + + def _pytest_args_regular_run(self, tests: Iterable[str]) -> list[str]: + pytest_args = ["-x", "-q", "-p", "no:randomly", "-p", "no:random-order"] + if tests: + pytest_args += list(tests) + else: + pytest_args += self._pytest_add_cli_args_test_selection + return pytest_args + + def run_stats(self, *, tests: Iterable[str]) -> int: + class StatsCollector: + # noinspection PyMethodMayBeStatic + def pytest_runtest_logstart(self, nodeid: str, location: Any) -> None: + state().duration_by_test[nodeid] = 0 + + # noinspection PyMethodMayBeStatic + def pytest_runtest_teardown(self, item: Any, nextitem: Any) -> None: + unused(nextitem) + for function in state()._stats: + state().tests_by_mangled_function_name[function].add(strip_prefix(item._nodeid, prefix="mutants/")) + state()._stats.clear() + + # noinspection PyMethodMayBeStatic + def pytest_runtest_makereport(self, item: Any, call: Any) -> None: + state().duration_by_test[item.nodeid] += call.duration + + stats_collector = StatsCollector() + + with change_cwd("mutants"): + return int(self.execute_pytest(self._pytest_args_regular_run(tests), plugins=[stats_collector])) + + def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: + with change_cwd("mutants"): + return int(self.execute_pytest(self._pytest_args_regular_run(tests))) + + def collect_main_test_coverage(self, cov: Coverage) -> int: + with change_cwd("mutants"), cov.collect(): + self.prepare_main_test_run() + return int(self.execute_pytest(self._pytest_args_regular_run([]))) + + def run_forced_fail(self) -> int: + return self.run_tests(mutant_name=None, tests=[]) + + def list_all_tests(self) -> ListAllTestsResult: + class TestsCollector: + def __init__(self) -> None: + self.collected_nodeids: set[str] = set() + self.deselected_nodeids: set[str] = set() + + def pytest_collection_modifyitems(self, items: Any) -> None: + self.collected_nodeids |= {item.nodeid for item in items} + + def pytest_deselected(self, items: Any) -> None: + self.deselected_nodeids |= {item.nodeid for item in items} + + collector = TestsCollector() + + pytest_args = ["-x", "-q", "--collect-only"] + self._pytest_add_cli_args_test_selection + + with change_cwd("mutants"): + exit_code = int(self.execute_pytest(pytest_args, plugins=[collector])) + if exit_code != 0: + raise CollectTestsFailedException() + + selected_nodeids = collector.collected_nodeids - collector.deselected_nodeids + return ListAllTestsResult(ids=selected_nodeids) + + +class HammettRunner(TestRunner): + def __init__(self) -> None: + self.hammett_kwargs: Any = None + + def run_stats(self, *, tests: Iterable[str]) -> int: + import hammett + + print("Running hammett stats...") + + def post_test_callback(_name: str, **_: Any) -> None: + for function in state()._stats: + state().tests_by_mangled_function_name[function].add(_name) + state()._stats.clear() + + return int( + hammett.main( + quiet=True, + fail_fast=True, + disable_assert_analyze=True, + post_test_callback=post_test_callback, + use_cache=False, + insert_cwd=False, + ) + ) + + def run_forced_fail(self) -> int: + import hammett + + return int( + hammett.main(quiet=True, fail_fast=True, disable_assert_analyze=True, use_cache=False, insert_cwd=False) + ) + + def prepare_main_test_run(self) -> None: + import hammett + + self.hammett_kwargs = hammett.main_setup( + quiet=True, + fail_fast=True, + disable_assert_analyze=True, + use_cache=False, + insert_cwd=False, + ) + + def run_tests(self, *, mutant_name: str | None, tests: Iterable[str]) -> int: + import hammett + + hammett.Config.workerinput = dict(workerinput=f"_{mutant_name}") + return int(hammett.main_run_tests(**self.hammett_kwargs, tests=tests)) diff --git a/src/mutmut/stats.py b/src/mutmut/stats.py new file mode 100644 index 00000000..170dd45d --- /dev/null +++ b/src/mutmut/stats.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +from collections import defaultdict +from dataclasses import dataclass +from json import JSONDecodeError + +from mutmut.configuration import config +from mutmut.mutation.data import SourceFileMutationData +from mutmut.state import state +from mutmut.ui.terminal import print_status + +status_by_exit_code = defaultdict( + lambda: "suspicious", + { + 1: "killed", + 3: "killed", # internal error in pytest means a kill + -24: "killed", + 0: "survived", + 5: "no tests", + 2: "check was interrupted by user", + None: "not checked", + 33: "no tests", + 34: "skipped", + 35: "suspicious", + 36: "timeout", + 37: "caught by type check", + -24: "timeout", # SIGXCPU + 24: "timeout", # SIGXCPU + 152: "timeout", # SIGXCPU + 255: "timeout", + -11: "segfault", + -9: "segfault", + }, +) + +emoji_by_status = { + "survived": "🙁", + "no tests": "🫥", + "timeout": "⏰", + "suspicious": "🤔", + "skipped": "🔇", + "caught by type check": "🧙", + "check was interrupted by user": "🛑", + "not checked": "?", + "killed": "🎉", + "segfault": "💥", +} + +exit_code_to_emoji = {exit_code: emoji_by_status[status] for exit_code, status in status_by_exit_code.items()} + + +@dataclass +class Stat: + not_checked: int + killed: int + survived: int + total: int + no_tests: int + skipped: int + suspicious: int + timeout: int + check_was_interrupted_by_user: int + segfault: int + caught_by_type_check: int + + +def collect_stat(m: SourceFileMutationData) -> Stat: + r = {k.replace(" ", "_"): 0 for k in status_by_exit_code.values()} + for k, v in m.exit_code_by_key.items(): + # noinspection PyTypeChecker + r[status_by_exit_code[v].replace(" ", "_")] += 1 + return Stat( + **r, + total=sum(r.values()), + ) + + +def calculate_summary_stats(source_file_mutation_data_by_path: dict[str, SourceFileMutationData]) -> Stat: + stats = [collect_stat(x) for x in source_file_mutation_data_by_path.values()] + return Stat( + not_checked=sum(x.not_checked for x in stats), + killed=sum(x.killed for x in stats), + survived=sum(x.survived for x in stats), + total=sum(x.total for x in stats), + no_tests=sum(x.no_tests for x in stats), + skipped=sum(x.skipped for x in stats), + suspicious=sum(x.suspicious for x in stats), + timeout=sum(x.timeout for x in stats), + check_was_interrupted_by_user=sum(x.check_was_interrupted_by_user for x in stats), + segfault=sum(x.segfault for x in stats), + caught_by_type_check=sum(x.caught_by_type_check for x in stats), + ) + + +def print_stats( + source_file_mutation_data_by_path: dict[str, SourceFileMutationData], force_output: bool = False +) -> None: + s = calculate_summary_stats(source_file_mutation_data_by_path) + print_status( + f"{(s.total - s.not_checked)}/{s.total} 🎉 {s.killed} 🫥 {s.no_tests} ⏰ {s.timeout} 🤔 {s.suspicious} 🙁 {s.survived} 🔇 {s.skipped} 🧙 {s.caught_by_type_check}", + force_output=force_output, + ) + + +def load_stats() -> bool: + did_load = False + try: + with open("mutants/mutmut-stats.json") as f: + data = json.load(f) + for k, v in data.pop("tests_by_mangled_function_name").items(): + state().tests_by_mangled_function_name[k] |= set(v) + state().duration_by_test = data.pop("duration_by_test") + state().stats_time = data.pop("stats_time") + state().old_function_hashes = data.pop("function_hashes", {}) + for k, v in data.pop("function_dependencies", {}).items(): + state().function_dependencies[k] = set(v) + state().old_config_fingerprint = data.pop("config_fingerprint", {}) + state().old_watched_file_hashes = data.pop("watched_file_hashes", {}) + state().old_git_commit = data.pop("git_commit", None) + # Preserve the loaded baseline; only a full run refreshes it. + state().watched_file_hashes = state().old_watched_file_hashes + state().git_commit = state().old_git_commit + assert not data, data + did_load = True + except (FileNotFoundError, JSONDecodeError): + pass + return did_load + + +def save_stats() -> None: + with open("mutants/mutmut-stats.json", "w") as f: + json.dump( + dict( + tests_by_mangled_function_name={k: list(v) for k, v in state().tests_by_mangled_function_name.items()}, + duration_by_test=state().duration_by_test, + stats_time=state().stats_time, + function_hashes=state().current_function_hashes, + function_dependencies={k: list(v) for k, v in state().function_dependencies.items()}, + config_fingerprint=config().config_fingerprint(), + watched_file_hashes=state().watched_file_hashes, + git_commit=state().git_commit, + ), + f, + indent=4, + ) diff --git a/src/mutmut/ui/__init__.py b/src/mutmut/ui/__init__.py new file mode 100644 index 00000000..8cdf46f9 --- /dev/null +++ b/src/mutmut/ui/__init__.py @@ -0,0 +1 @@ +"""UI components for mutmut.""" diff --git a/src/mutmut/ui/browse.py b/src/mutmut/ui/browse.py new file mode 100644 index 00000000..32952eb2 --- /dev/null +++ b/src/mutmut/ui/browse.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import subprocess +import sys +from collections.abc import Callable +from collections.abc import Iterable +from pathlib import Path +from threading import Lock +from typing import Any + +from rich.text import Text + +from mutmut.mutation.data import SourceFileMutationData +from mutmut.stats import Stat +from mutmut.stats import collect_stat +from mutmut.stats import emoji_by_status +from mutmut.stats import status_by_exit_code +from mutmut.utils.file_utils import walk_mutatable_files + + +def run_result_browser( + *, + show_killed: bool, + get_diff_for_mutant: Callable[..., str], + apply_mutant: Callable[[str], None], +) -> None: + """Run the interactive result browser. + + Creates and runs the ResultBrowser Textual app. The ``get_diff_for_mutant`` and + ``apply_mutant`` callables are injected to avoid a circular import back into + ``mutmut.__main__``. + """ + + from rich.console import RenderableType + from rich.syntax import Syntax + from textual import work + from textual.app import App + from textual.containers import Container + from textual.widget import Widget + from textual.widgets import DataTable + from textual.widgets import Footer + from textual.widgets import Static + from textual.worker import get_current_worker + + class ResultBrowser(App[None]): + CSS_PATH = "result_browser_layout.tcss" + BINDINGS = [ + ("q", "quit()", "Quit"), + ("r", "retest_mutant()", "Retest mutant"), + ("f", "retest_function()", "Retest function"), + ("m", "retest_module()", "Retest module"), + ("a", "apply_mutant()", "Apply mutant to disk"), + ("t", "view_tests()", "View tests for mutant"), + ] + + columns = [ + ("path", "Path"), + ] + [(status, Text(emoji, justify="right")) for status, emoji in emoji_by_status.items()] + + cursor_type = "row" + source_file_mutation_data_and_stat_by_path: dict[str, tuple[SourceFileMutationData, Stat]] = {} + path_by_name: dict[str, Path] = {} + diff_load_lock = Lock() + + def compose(self) -> Iterable[Any]: + with Container(classes="container"): + yield DataTable(id="files") + yield DataTable(id="mutants") + with Widget(id="diff_view_widget"): + yield Static(id="description") + yield Static(id="diff_view") + yield Footer() + + def on_mount(self) -> None: + # noinspection PyTypeChecker + files_table: DataTable[Any] = self.query_one("#files") # type: ignore[assignment] + files_table.cursor_type = "row" + for key, label in self.columns: + files_table.add_column(key=key, label=label) + + # noinspection PyTypeChecker + mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] + mutants_table.cursor_type = "row" + mutants_table.add_columns("name", "status") + + self.read_data() + self.populate_files_table() + + def read_data(self) -> None: + self.source_file_mutation_data_and_stat_by_path = {} + self.path_by_name: dict[str, Path] = {} + + for p in walk_mutatable_files(): + source_file_mutation_data = SourceFileMutationData(path=p) + source_file_mutation_data.load() + stat = collect_stat(source_file_mutation_data) + + self.source_file_mutation_data_and_stat_by_path[str(p)] = source_file_mutation_data, stat + for name in source_file_mutation_data.exit_code_by_key: + self.path_by_name[name] = p + + def populate_files_table(self) -> None: + # noinspection PyTypeChecker + files_table: DataTable[Any] = self.query_one("#files") # type: ignore[assignment] + # TODO: restore selection + selected_row = files_table.cursor_row + files_table.clear() + + for p, (source_file_mutation_data, stat) in sorted(self.source_file_mutation_data_and_stat_by_path.items()): + row = [p] + [ + Text(str(getattr(stat, k.replace(" ", "_"))), justify="right") for k, _ in self.columns[1:] + ] + files_table.add_row(*row, key=str(p)) + + files_table.move_cursor(row=selected_row) + + def on_data_table_row_highlighted(self, event: Any) -> None: + if not event.row_key or not event.row_key.value: + return + if event.data_table.id == "files": + # noinspection PyTypeChecker + mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] + mutants_table.clear() + source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[event.row_key.value] + for k, v in source_file_mutation_data.exit_code_by_key.items(): + status = status_by_exit_code[v] + if status not in ("killed", "caught by type check") or show_killed: + mutants_table.add_row(k, emoji_by_status[status], key=k) + else: + assert event.data_table.id == "mutants" + # noinspection PyTypeChecker + description_view: Static = self.query_one("#description") # type: ignore[assignment] + mutant_name = event.row_key.value + path = self.path_by_name.get(mutant_name) + source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[str(path)] + + exit_code = source_file_mutation_data.exit_code_by_key[mutant_name] + status = status_by_exit_code[exit_code] + estimated_duration = source_file_mutation_data.estimated_time_of_tests_by_mutant.get(mutant_name, "?") + duration = source_file_mutation_data.durations_by_key.get(mutant_name, "?") + type_check_error = source_file_mutation_data.type_check_error_by_key.get(mutant_name, "?") + + view_tests_description = "(press t to view tests executed for this mutant)" + + match status: + case "killed": + description = f"Killed ({exit_code=}): Mutant caused a test to fail 🎉" + case "survived": + description = f"Survived ({exit_code=}): No test detected this mutant. {view_tests_description}" + case "skipped": + description = f"Skipped ({exit_code=})" + case "check was interrupted by user": + description = f"User interrupted ({exit_code=})" + case "caught by type check": + description = f"Caught by type checker ({exit_code=}): {type_check_error}" + case "timeout": + description = ( + f"Timeout ({exit_code=}): Timed out because tests did not finish within {duration:.3f} seconds. " + f"Tests without mutation took {estimated_duration:.3f} seconds. {view_tests_description}" + ) + case "no tests": + description = ( + f"Untested ({exit_code=}): Skipped because selected tests do not execute this code." + ) + case "segfault": + description = f"Segfault ({exit_code=}): Running pytest with this mutant segfaulted." + case "suspicious": + description = ( + f"Unknown ({exit_code=}): Running pytest with this mutant resulted in an unknown exit code." + ) + case "not checked": + description = "Not checked in the last mutmut run." + case _: + description = f"Unknown status ({exit_code=}, {status=})" + description_view.update(f"\n {description}\n") + + diff_view: Static = self.query_one("#diff_view") # type: ignore[assignment] + diff_view.update("") + + self.load_diff(mutant_name, path, diff_view) + + @work(exclusive=True, thread=True, group="load_diff") + def load_diff(self, mutant_name: str, path: Path | None, diff_view: Static) -> None: + """Load the diff for a mutant and display it. + + Only one diff is loaded at a time, and moving on to another mutant cancels the loads + that have not started yet. Otherwise moving through a long list of mutants piles up + loads for diffs that are never going to be displayed.""" + worker = get_current_worker() + + with self.diff_load_lock: + if worker.is_cancelled: + return + + try: + update: RenderableType = Syntax(get_diff_for_mutant(mutant_name, path=path), "diff") + except Exception as e: + update = f"<{type(e)} {e}>" + + if worker.is_cancelled: + return + + self.call_from_thread(diff_view.update, update) + + def retest(self, pattern: str | None) -> None: + if pattern is None: + return + self._run_subprocess_command("run", [pattern]) + + def view_tests(self, mutant_name: str | None) -> None: + if mutant_name is None: + return + self._run_subprocess_command("tests-for-mutant", [mutant_name]) + + def _run_subprocess_command(self, command: str, args: list[str]) -> None: + with self.suspend(): + browse_index = sys.argv.index("browse") + initial_args = sys.argv[:browse_index] + subprocess_args = [sys.executable, *initial_args, command, *args] + print(">", *subprocess_args) + subprocess.run(subprocess_args) + input("press enter to return to browser") + + self.read_data() + self.populate_files_table() + + def get_mutant_name_from_selection(self) -> str | None: + # noinspection PyTypeChecker + mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] + if mutants_table.cursor_row is None or not mutants_table.is_valid_row_index(mutants_table.cursor_row): + return None + + result: str = mutants_table.get_row_at(mutants_table.cursor_row)[0] + return result + + def action_retest_mutant(self) -> None: + self.retest(self.get_mutant_name_from_selection()) + + def action_retest_function(self) -> None: + name = self.get_mutant_name_from_selection() + if name is not None: + self.retest(name.rpartition("__mutmut_")[0] + "__mutmut_*") + + def action_retest_module(self) -> None: + name = self.get_mutant_name_from_selection() + if name is not None: + self.retest(name.rpartition(".")[0] + ".*") + + def action_apply_mutant(self) -> None: + # noinspection PyTypeChecker + mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] + if mutants_table.cursor_row is None or not mutants_table.is_valid_row_index(mutants_table.cursor_row): + return + apply_mutant(mutants_table.get_row_at(mutants_table.cursor_row)[0]) + + def action_view_tests(self) -> None: + name = self.get_mutant_name_from_selection() + if name is not None: + self.view_tests(name) + + ResultBrowser().run() diff --git a/src/mutmut/result_browser_layout.tcss b/src/mutmut/ui/result_browser_layout.tcss similarity index 100% rename from src/mutmut/result_browser_layout.tcss rename to src/mutmut/ui/result_browser_layout.tcss diff --git a/src/mutmut/ui/terminal.py b/src/mutmut/ui/terminal.py new file mode 100644 index 00000000..79f78e62 --- /dev/null +++ b/src/mutmut/ui/terminal.py @@ -0,0 +1,35 @@ +import itertools +import sys +from collections.abc import Callable +from datetime import datetime +from datetime import timedelta + +spinner = itertools.cycle("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") + + +def status_printer() -> Callable[..., None]: + """Manage the printing and in-place updating of a line of characters + + .. note:: + If the string is longer than a line, then in-place updating may not + work (it will print a new line at each refresh). + """ + last_len = [0] + last_update = [datetime(1900, 1, 1)] + update_threshold = timedelta(seconds=0.1) + + def p(s: str, *, force_output: bool = False) -> None: + if not force_output and (datetime.now() - last_update[0]) < update_threshold: + return + s = next(spinner) + " " + s + len_s = len(s) + output = "\r" + s + (" " * max(last_len[0] - len_s, 0)) + assert sys.__stdout__ is not None + sys.__stdout__.write(output) + sys.__stdout__.flush() + last_len[0] = len_s + + return p + + +print_status = status_printer() diff --git a/src/mutmut/utils/file_utils.py b/src/mutmut/utils/file_utils.py index d3e73be8..82ef9498 100644 --- a/src/mutmut/utils/file_utils.py +++ b/src/mutmut/utils/file_utils.py @@ -1,8 +1,17 @@ +from __future__ import annotations + import os +import shutil +import sys from collections.abc import Iterator from contextlib import contextmanager +from os import walk +from os.path import isdir +from os.path import isfile from pathlib import Path +from mutmut.configuration import config + @contextmanager def change_cwd(path: Path | str) -> Iterator[None]: @@ -12,3 +21,72 @@ def change_cwd(path: Path | str) -> Iterator[None]: yield finally: os.chdir(old_cwd) + + +def walk_all_files() -> Iterator[tuple[str, str]]: + for path in config().source_paths: + if not isdir(path): + if isfile(path): + yield "", str(path) + continue + for root, dirs, files in walk(path): + for filename in files: + yield root, filename + + +def walk_source_files() -> Iterator[Path]: + for root, filename in walk_all_files(): + if filename.endswith(".py"): + yield Path(root) / filename + + +def walk_mutatable_files() -> Iterator[Path]: + cfg = config() + for path in walk_source_files(): + if cfg.should_mutate(path): + yield path + + +def copy_src_dir() -> None: + for root, name in walk_all_files(): + source_path = Path(root) / name + target_path = Path("mutants") / root / name + + if target_path.exists(): + continue + + if isdir(source_path): + shutil.copytree(source_path, target_path) + else: + target_path.parent.mkdir(exist_ok=True, parents=True) + # copy mtime, so we later know that when source_mtime == target_mtime, the file is not (yet) mutated. + shutil.copy2(source_path, target_path) + + +def copy_also_copy_files() -> None: + assert isinstance(config().also_copy, list) + for path in config().also_copy: + print(" also copying", path) + path = Path(path) + destination = Path("mutants") / path + if not path.exists(): + continue + if path.is_file(): + shutil.copy2(path, destination) + else: + shutil.copytree(path, destination, dirs_exist_ok=True) + + +def setup_source_paths() -> None: + # ensure that the mutated source code can be imported by the tests + source_code_paths = [Path("."), Path("src"), Path("source")] + for path in source_code_paths: + mutated_path = Path("mutants") / path + if mutated_path.exists(): + sys.path.insert(0, str(mutated_path.absolute())) + + # ensure that the original code CANNOT be imported by the tests + for path in source_code_paths: + for i in range(len(sys.path)): + while i < len(sys.path) and Path(sys.path[i]).resolve() == path.resolve(): + del sys.path[i] From 22730ca8c8a545a4c79a2272068da30d3b56d686 Mon Sep 17 00:00:00 2001 From: nicklafleur <55208706+nicklafleur@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:49:55 -0400 Subject: [PATCH 4/9] feat(workers): add fork isolation utilities and OrchestratorCrashError Add pipe-based fork isolation helpers (run_in_fork_with_result, run_in_fork) that run functions in forked children so the parent process never imports pytest/conftest and stays fork-safe. This is the foundation for the upcoming hot-fork runner where the parent acts purely as an orchestrator. Also add OrchestratorCrashError, which reports the crashed orchestrator's exit code, the lost in-flight mutants (truncated past 10), an optional crash-log path, and instructions to resume with 'mutmut run'. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mutmut/workers/isolation.py | 138 ++++++++++++++++++++++++ tests/workers/test_isolation.py | 179 ++++++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 src/mutmut/workers/isolation.py create mode 100644 tests/workers/test_isolation.py diff --git a/src/mutmut/workers/isolation.py b/src/mutmut/workers/isolation.py new file mode 100644 index 00000000..561ea476 --- /dev/null +++ b/src/mutmut/workers/isolation.py @@ -0,0 +1,138 @@ +""" +Fork isolation utilities for keeping the parent process clean. + +The main process must not import pytest/test code directly, because test +conftest.py files may call gevent.monkey.patch_all() or import grpc, which +makes the process fork-unsafe. + +These utilities run operations in forked children so the parent stays clean. +Uses pipe-based IPC for reduced overhead (no temp files, no cleanup needed). +""" + +from __future__ import annotations + +import os +import pickle +from collections.abc import Callable +from typing import Any + + +def run_in_fork_with_result(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + """Fork a child to run a function, return its result via pipe. + + The parent process stays clean - child's imports don't affect parent. + Uses os.pipe() for IPC - lower overhead than temp files. + + Args: + fn: Function that returns a picklable result. + *args, **kwargs: Arguments to pass to fn. + + Returns: + The return value of fn(*args, **kwargs). + + Raises: + ChildProcessError: If child exits with non-zero status. + """ + # Create pipe for result transfer + read_fd, write_fd = os.pipe() + + pid = os.fork() + if pid == 0: + # Child: close read end, run function, write result + os.close(read_fd) + try: + result = fn(*args, **kwargs) + with os.fdopen(write_fd, "wb") as f: + pickle.dump({"ok": True, "value": result}, f) + os._exit(0) + except Exception as e: + try: + with os.fdopen(write_fd, "wb") as f: + pickle.dump({"ok": False, "error": str(e)}, f) + except Exception: + pass + os._exit(1) + + # Parent: close write end, wait for child, read result + os.close(write_fd) + _, status = os.waitpid(pid, 0) + exit_code = os.waitstatus_to_exitcode(status) + + # Read result from pipe + with os.fdopen(read_fd, "rb") as f: + try: + data = pickle.load(f) + except Exception: + data = None + + if exit_code != 0 or data is None: + error_msg = f"Child exited with code {exit_code}" + if data and not data.get("ok") and "error" in data: + error_msg += f": {data['error']}" + raise ChildProcessError(error_msg) + + if not data.get("ok"): + raise ChildProcessError(f"Child failed: {data.get('error', 'unknown')}") + + return data["value"] + + +def run_in_fork(fn: Callable[..., int], *args: Any, **kwargs: Any) -> int: + """Fork a child to run a function, return its exit code. + + Use for operations that only need pass/fail result (clean test, forced fail). + Parent process stays clean - child's imports don't affect parent. + + Args: + fn: Function that returns an exit code (0-255). + *args, **kwargs: Arguments to pass to fn. + + Returns: + Exit code from the child process. + """ + pid = os.fork() + if pid == 0: + # Child: run function and exit with its return code + try: + exit_code = fn(*args, **kwargs) + os._exit(exit_code if isinstance(exit_code, int) else 0) + except Exception: + os._exit(1) + + # Parent waits for child + _, status = os.waitpid(pid, 0) + return os.waitstatus_to_exitcode(status) + + +class OrchestratorCrashError(Exception): + """Raised when the hot-fork orchestrator crashes unexpectedly. + + The orchestrator manages all mutant test runs. If it crashes, any + in-flight mutants are lost. The user can resume by running + `mutmut run` again - completed results are preserved. + """ + + def __init__(self, exit_code: int, lost_mutants: list[str], crash_log: str | None = None) -> None: + self.exit_code = exit_code + self.lost_mutants = lost_mutants + self.crash_log = crash_log + + # Build detailed message + details = [ + f"Hot-fork orchestrator crashed unexpectedly (exit code: {exit_code})", + f"Lost {len(lost_mutants)} in-flight mutant(s):", + ] + for m in lost_mutants[:10]: + details.append(f" - {m}") + if len(lost_mutants) > 10: + details.append(f" ... and {len(lost_mutants) - 10} more") + + details.append("") + details.append("This usually indicates a bug in pytest or conftest.py.") + if crash_log: + details.append(f"Crash log: {crash_log}") + details.append("") + details.append("To resume: mutmut run") + details.append("(Completed mutants are saved; lost ones will be re-run)") + + super().__init__("\n".join(details)) diff --git a/tests/workers/test_isolation.py b/tests/workers/test_isolation.py new file mode 100644 index 00000000..36e20510 --- /dev/null +++ b/tests/workers/test_isolation.py @@ -0,0 +1,179 @@ +"""Tests for fork isolation utilities.""" + +import os + +import pytest + +from mutmut.workers.isolation import OrchestratorCrashError +from mutmut.workers.isolation import run_in_fork +from mutmut.workers.isolation import run_in_fork_with_result + + +@pytest.mark.skipif(os.name == "nt", reason="Forking not supported on Windows") +class TestRunInForkWithResult: + """Tests for run_in_fork_with_result.""" + + def test_returns_simple_value(self): + """Function return value is passed back to parent.""" + result = run_in_fork_with_result(lambda: 42) + assert result == 42 + + def test_returns_complex_value(self): + """Complex picklable objects are returned correctly.""" + result = run_in_fork_with_result(lambda: {"a": [1, 2, 3], "b": "hello"}) + assert result == {"a": [1, 2, 3], "b": "hello"} + + def test_passes_args_and_kwargs(self): + """Arguments are passed to the function.""" + + def add(a, b, multiplier=1): + return (a + b) * multiplier + + result = run_in_fork_with_result(add, 2, 3, multiplier=10) + assert result == 50 + + def test_child_import_does_not_affect_parent(self): + """Imports in child don't pollute parent's namespace.""" + + def import_and_use(): + import json + + return json.dumps({"test": True}) + + result = run_in_fork_with_result(import_and_use) + assert result == '{"test": true}' + + def test_child_crash_raises_error(self): + """Child process crash raises ChildProcessError.""" + + def crash(): + raise RuntimeError("boom") + + with pytest.raises(ChildProcessError): + run_in_fork_with_result(crash) + + def test_child_crash_includes_error_message(self): + """ChildProcessError includes the original exception message.""" + + def crash(): + raise RuntimeError("specific error message") + + with pytest.raises(ChildProcessError) as exc_info: + run_in_fork_with_result(crash) + + assert "specific error message" in str(exc_info.value) + + def test_no_temp_files_created(self, tmp_path, monkeypatch): + """Pipe-based transport doesn't create temp files.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "mutants").mkdir() + + run_in_fork_with_result(lambda: "test") + + # No pickle files should exist + assert not list(tmp_path.glob("**/*.pickle")) + + +@pytest.mark.skipif(os.name == "nt", reason="Forking not supported on Windows") +class TestRunInFork: + """Tests for run_in_fork (returns exit code).""" + + def test_returns_zero_on_success(self): + """Returns 0 when function returns 0.""" + result = run_in_fork(lambda: 0) + assert result == 0 + + def test_returns_function_exit_code(self): + """Returns the exit code from the function.""" + result = run_in_fork(lambda: 42) + assert result == 42 + + def test_returns_one_on_exception(self): + """Returns 1 when function raises.""" + + def crash(): + raise RuntimeError("boom") + + result = run_in_fork(crash) + assert result == 1 + + def test_side_effects_in_child(self, tmp_path): + """Side effects happen in child (verifiable via file).""" + marker = tmp_path / "marker.txt" + + def create_marker(): + marker.write_text("created") + return 0 + + run_in_fork(create_marker) + + assert marker.read_text() == "created" + + +class TestOrchestratorCrashError: + """Tests for OrchestratorCrashError exception.""" + + def test_error_message_includes_exit_code(self): + """Exit code is included in message.""" + err = OrchestratorCrashError(exit_code=1, lost_mutants=[]) + assert "exit code: 1" in str(err) + + def test_error_message_lists_lost_mutants(self): + """Lost mutants are listed in message.""" + err = OrchestratorCrashError(exit_code=1, lost_mutants=["mutant_1", "mutant_2"]) + assert "mutant_1" in str(err) + assert "mutant_2" in str(err) + assert "2 in-flight mutant(s)" in str(err) + + def test_truncates_long_mutant_list(self): + """Only first 10 mutants shown, rest summarized.""" + mutants = [f"mutant_{i}" for i in range(15)] + err = OrchestratorCrashError(exit_code=1, lost_mutants=mutants) + + assert "mutant_0" in str(err) + assert "mutant_9" in str(err) + assert "mutant_10" not in str(err) + assert "5 more" in str(err) + + def test_includes_resume_instructions(self): + """Message includes how to resume.""" + err = OrchestratorCrashError(exit_code=1, lost_mutants=[]) + assert "mutmut run" in str(err) + + def test_includes_crash_log_path(self): + """Crash log path is shown if provided.""" + err = OrchestratorCrashError(exit_code=1, lost_mutants=[], crash_log="mutants/.orchestrator-crash.log") + assert ".orchestrator-crash.log" in str(err) + + def test_attributes_accessible(self): + """Exception attributes are accessible.""" + err = OrchestratorCrashError(exit_code=42, lost_mutants=["a", "b"], crash_log="/path/to/log") + assert err.exit_code == 42 + assert err.lost_mutants == ["a", "b"] + assert err.crash_log == "/path/to/log" + + def test_empty_lost_mutants(self): + """Works correctly with empty lost mutants list.""" + err = OrchestratorCrashError(exit_code=0, lost_mutants=[]) + assert "0 in-flight mutant(s)" in str(err) + + def test_no_crash_log(self): + """Works correctly without crash log.""" + err = OrchestratorCrashError(exit_code=1, lost_mutants=["m1"]) + # Should not raise and should not include "Crash log:" + msg = str(err) + assert "Crash log:" not in msg + + def test_is_exception(self): + """OrchestratorCrashError is an Exception subclass.""" + err = OrchestratorCrashError(exit_code=1, lost_mutants=[]) + assert isinstance(err, Exception) + + def test_can_be_raised_and_caught(self): + """Exception can be raised and caught properly.""" + with pytest.raises(OrchestratorCrashError) as exc_info: + raise OrchestratorCrashError(exit_code=255, lost_mutants=["test_mutant"], crash_log="/tmp/crash.log") + + assert exc_info.value.exit_code == 255 + assert exc_info.value.lost_mutants == ["test_mutant"] + assert exc_info.value.crash_log == "/tmp/crash.log" From 277d1bfd8ff10ce1a3004709813d49bdcd92b8a8 Mon Sep 17 00:00:00 2001 From: nicklafleur <55208706+nicklafleur@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:01:34 -0400 Subject: [PATCH 5/9] feat(runners): extract MutantRunner abstraction with ForkRunner Introduce a MutantRunner ABC that owns the process-isolation strategy for testing mutants and also fronts the surrounding test operations (stats collection, clean tests, forced-fail, test listing), so __main__ no longer drives os.fork() directly. ForkRunner encapsulates the traditional os.fork()-per-mutant loop that lived inline in _run(): submit() forks a child under a CPU/wall timeout, and wait_for_result() reaps one child into a MutantResult. get_mutant_runner() selects the runner from the new process_isolation config (ProcessIsolation enum; only 'fork' is wired up here, 'hot-fork' raises pending a later commit). _run() now drives the runner through submit/has_capacity/wait_for_result/ pending_count/shutdown and registers results by mutant name. The stale-stats protections are preserved verbatim: _check_test_to_mutant_associations() still runs, and collect_or_load_stats() keeps its apply_config_invalidation path (now routed through MutantRunner.collect_stats/list_all_tests). Behavior for the default fork path is unchanged; the full suite (incl. the e2e run) is green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mutmut/__main__.py | 116 +++++-------- src/mutmut/configuration.py | 21 +++ src/mutmut/workers/isolation.py | 280 +++++++++++++++++++++++++++++++- tests/mutation/test_mutation.py | 10 +- tests/test_configuration.py | 2 + 5 files changed, 349 insertions(+), 80 deletions(-) diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 3114012c..fb753258 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -18,11 +18,9 @@ sys.exit(1) import ast import fnmatch -import gc import hashlib import inspect import json -import resource import shutil import subprocess import warnings @@ -57,7 +55,6 @@ from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR from mutmut.runners.harness import CollectTestsFailedException from mutmut.runners.harness import PytestRunner -from mutmut.runners.harness import TestRunner from mutmut.runners.harness import collected_test_names from mutmut.stats import calculate_summary_stats from mutmut.stats import emoji_by_status @@ -72,8 +69,9 @@ from mutmut.utils.file_utils import setup_source_paths from mutmut.utils.file_utils import walk_mutatable_files from mutmut.utils.file_utils import walk_source_files -from mutmut.utils.safe_setproctitle import safe_setproctitle as setproctitle -from mutmut.workers.timeout import register_timeout +from mutmut.workers.isolation import MutantResult +from mutmut.workers.isolation import MutantRunner +from mutmut.workers.isolation import get_mutant_runner if TYPE_CHECKING: pass @@ -281,7 +279,7 @@ def orig_function_and_class_names_from_key(mutant_name: str) -> tuple[str, str | return r, class_name -def run_forced_fail_test(runner: TestRunner) -> None: +def run_forced_fail_test(runner: MutantRunner) -> None: os.environ["MUTANT_UNDER_TEST"] = "fail" with CatchOutput(spinner_title="Running forced fail test") as catcher: try: @@ -359,7 +357,7 @@ def cli() -> None: pass -def run_stats_collection(runner: TestRunner, tests: Iterable[str] | None = None) -> None: +def run_stats_collection(runner: MutantRunner, tests: Iterable[str] | None = None) -> None: if tests is None: tests = [] # Meaning all... @@ -370,7 +368,7 @@ def run_stats_collection(runner: TestRunner, tests: Iterable[str] | None = None) start_cpu_time = process_time() with CatchOutput(spinner_title="Running stats") as output_catcher: - collect_stats_exit_code = runner.run_stats(tests=tests) + collect_stats_exit_code = runner.collect_stats(tests) if collect_stats_exit_code != 0: output_catcher.dump_output() print(f"failed to collect stats. runner returned {collect_stats_exit_code}") @@ -699,7 +697,7 @@ def _apply_config_change_invalidation(mutants_caught_by_type_checker: dict[str, def collect_or_load_stats( - runner: TestRunner, + runner: MutantRunner, *, mutants_caught_by_type_checker: dict[str, Any] | None = None, apply_config_invalidation: bool = False, @@ -876,8 +874,7 @@ def estimated_worst_case_time(mutant_name: str) -> float: def print_time_estimates(mutant_names: tuple[str, ...]) -> None: assert isinstance(mutant_names, (tuple, list)), mutant_names - runner = PytestRunner() - runner.prepare_main_test_run() + runner = get_mutant_runner() collect_or_load_stats(runner) @@ -906,9 +903,15 @@ def tests_for_mutant(mutant_name: str) -> None: print(test) -def stop_all_children(mutants: list[tuple[SourceFileMutationData, str, int | None]]) -> None: - for m, _, _ in mutants: - m.stop_children() +def _register_mutant_result( + result: MutantResult, + mutation_data_by_mutant_name: dict[str, SourceFileMutationData], +) -> None: + """Record a completed mutant's exit code and duration onto its mutation data.""" + mutation_data = mutation_data_by_mutant_name[result.mutant_name] + mutation_data.exit_code_by_key[result.mutant_name] = result.exit_code + mutation_data.durations_by_key[result.mutant_name] = result.duration + mutation_data.save() # Guard against "context has already been set" when mutmut.__main__ is @@ -960,10 +963,8 @@ def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) -> with CatchOutput(spinner_title="Filtering mutations with type checker"): mutants_caught_by_type_checker = filter_mutants_with_type_checker() - # TODO: config/option for runner - # runner = HammettRunner() - runner = PytestRunner() - runner.prepare_main_test_run() + # TODO: config/option for the test runner (e.g. HammettRunner) + runner: MutantRunner = get_mutant_runner(max_children) # TODO: run these steps only if we have mutants to test @@ -981,7 +982,7 @@ def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) -> with CatchOutput(spinner_title="Running clean tests") as output_catcher: tests = tests_for_mutant_names(mutant_names) - clean_test_exit_code = runner.run_tests(mutant_name=None, tests=tests) + clean_test_exit_code = runner.run_clean_tests(tests=tests) if clean_test_exit_code != 0: output_catcher.dump_output() print("Failed to run clean test") @@ -991,24 +992,23 @@ def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) -> # this can't be the first thing, because it can fail deep inside pytest/django setup and then everything is destroyed run_forced_fail_test(runner) - runner.prepare_main_test_run() + # Maps each submitted mutant to its mutation data, for result registration. + mutation_data_by_mutant_name: dict[str, SourceFileMutationData] = {} + count_tried = 0 - def read_one_child_exit_status() -> None: - pid, wait_status = os.wait() - exit_code = os.waitstatus_to_exitcode(wait_status) + def drain_one_result() -> None: + nonlocal count_tried + result = runner.wait_for_result() if config().debug: - print(" worker exit code", exit_code) - source_file_mutation_data_by_pid[pid].register_result(pid=pid, exit_code=exit_code) - - source_file_mutation_data_by_pid: dict[int, SourceFileMutationData] = {} # many pids map to one MutationData - running_children = 0 - count_tried = 0 + print(" worker exit code", result.exit_code) + _register_mutant_result(result, mutation_data_by_mutant_name) + count_tried += 1 # Run estimated fast mutants first, calculated as the estimated time for a surviving mutant. mutants = sorted(mutants, key=lambda x: estimated_worst_case_time(x[1])) start = datetime.now() + runner.startup() try: - gc.freeze() print("Running mutation testing") # Now do mutation @@ -1036,54 +1036,28 @@ def read_one_child_exit_status() -> None: continue cfg = config() - pid = os.fork() - if pid == 0: - # In the child - os.environ["MUTANT_UNDER_TEST"] = mutant_name - setproctitle(f"mutmut: {mutant_name}") - - # Run fast tests first - sorted_tests = sorted(tests, key=lambda test_name: state().duration_by_test[test_name]) - if not sorted_tests: - os._exit(33) - - cpu_time_limit_s = ceil( - (estimated_time_of_tests + cfg.timeout_constant) * cfg.timeout_multiplier * 2 + process_time() - ) - # signal SIGXCPU after . One second later signal SIGKILL if it is still running - resource.setrlimit(resource.RLIMIT_CPU, (cpu_time_limit_s, cpu_time_limit_s + 1)) - - with CatchOutput(): - result = runner.run_tests(mutant_name=mutant_name, tests=sorted_tests) - - if result != 0: - pass - os._exit(result) - else: - # in the parent - wall_time_limit_s = (estimated_time_of_tests + cfg.timeout_constant) * cfg.timeout_multiplier - register_timeout(pid=pid, timeout_s=wall_time_limit_s) - source_file_mutation_data_by_pid[pid] = mutation_data - mutation_data.register_pid(pid=pid, key=mutant_name) - running_children += 1 - - if running_children >= max_children: - read_one_child_exit_status() - count_tried += 1 - running_children -= 1 + # signal SIGXCPU after this many CPU seconds; the runner adds one more before SIGKILL. + cpu_time_limit_s = ceil((estimated_time_of_tests + cfg.timeout_constant) * cfg.timeout_multiplier * 2) + + # Block for a free worker slot before submitting more work. + while not runner.has_capacity(): + drain_one_result() + + mutation_data_by_mutant_name[mutant_name] = mutation_data + runner.submit(mutant_name, list(tests), cpu_time_limit_s, estimated_time_of_tests) + + runner.signal_work_complete() try: - while running_children: - read_one_child_exit_status() - count_tried += 1 - running_children -= 1 + while runner.pending_count() > 0: + drain_one_result() except ChildProcessError: pass except KeyboardInterrupt: print("Stopping...") - stop_all_children(mutants) + runner.stop_all_workers() finally: - gc.unfreeze() + runner.shutdown() elapsed_time = datetime.now() - start diff --git a/src/mutmut/configuration.py b/src/mutmut/configuration.py index e5710dd8..ef5a3cb2 100644 --- a/src/mutmut/configuration.py +++ b/src/mutmut/configuration.py @@ -11,12 +11,24 @@ from configparser import NoOptionError from configparser import NoSectionError from dataclasses import dataclass +from enum import Enum from os.path import isdir from os.path import isfile from pathlib import Path from typing import Any +class ProcessIsolation(str, Enum): + """Valid values for the ``process_isolation`` config. + + Subclassing ``str`` allows direct string comparison while still giving us + validation and IDE support. + """ + + FORK = "fork" # Default: fork the (test-polluted) parent per mutant. + HOT_FORK = "hot-fork" # Fork-safe orchestrator for gevent/grpc/torch. + + def _config_reader() -> Callable[[str, Any], Any]: path = Path("pyproject.toml") if path.exists(): @@ -122,6 +134,13 @@ def _load_config() -> Config: f'The configs only_mutate and do_not_mutate expect glob patterns like "src/api/*" or "src/main.py". Following patterns are likely invalid: {invalid_patterns}' ) + isolation_str = s("process_isolation", "fork") + try: + process_isolation = ProcessIsolation(isolation_str) + except ValueError: + valid = [e.value for e in ProcessIsolation] + raise ValueError(f"Invalid process_isolation value: {isolation_str!r}. Expected one of: {valid}") from None + return Config( only_mutate=only_mutate, do_not_mutate=do_not_mutate, @@ -157,6 +176,7 @@ def _load_config() -> Config: cache_invalidation_exclude=s("cache_invalidation_exclude", []), on_dependency_change=s("on_dependency_change", "warn"), use_git_change_detection=s("use_git_change_detection", True), + process_isolation=process_isolation, ) @@ -186,6 +206,7 @@ class Config: cache_invalidation_exclude: list[str] on_dependency_change: str use_git_change_detection: bool + process_isolation: ProcessIsolation def config_fingerprint(self) -> dict[str, str]: """Hash the config fields that can change cached mutant *results*, grouped so the diff --git a/src/mutmut/workers/isolation.py b/src/mutmut/workers/isolation.py index 561ea476..d34ca189 100644 --- a/src/mutmut/workers/isolation.py +++ b/src/mutmut/workers/isolation.py @@ -1,20 +1,43 @@ """ -Fork isolation utilities for keeping the parent process clean. +Fork isolation utilities and mutation-test runners. The main process must not import pytest/test code directly, because test conftest.py files may call gevent.monkey.patch_all() or import grpc, which makes the process fork-unsafe. -These utilities run operations in forked children so the parent stays clean. -Uses pipe-based IPC for reduced overhead (no temp files, no cleanup needed). +The low-level ``run_in_fork*`` helpers run operations in forked children so the +parent stays clean. On top of them, ``MutantRunner`` abstracts the process +isolation strategy used to test each mutant. ``ForkRunner`` is the traditional +os.fork()-per-mutant approach; ``HotForkRunner`` (single orchestrator) will be +layered on later for fork-unsafe libraries. """ from __future__ import annotations +import gc import os import pickle +import resource +import signal +import sys +from abc import ABC +from abc import abstractmethod from collections.abc import Callable +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import datetime +from time import process_time from typing import Any +from typing import NamedTuple + +from mutmut.configuration import ProcessIsolation +from mutmut.configuration import config +from mutmut.runners.harness import ListAllTestsResult +from mutmut.runners.harness import PytestRunner +from mutmut.runners.harness import TestRunner +from mutmut.state import state +from mutmut.utils.safe_setproctitle import safe_setproctitle as setproctitle +from mutmut.workers.timeout import register_timeout def run_in_fork_with_result(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: @@ -53,18 +76,21 @@ def run_in_fork_with_result(fn: Callable[..., Any], *args: Any, **kwargs: Any) - pass os._exit(1) - # Parent: close write end, wait for child, read result + # Parent: close write end, read result BEFORE waiting for the child. + # IMPORTANT: read before waitpid to avoid deadlock. If the pickled data + # exceeds the pipe buffer (~64KB) the child blocks on write until the parent + # reads; if the parent waits on the child first that is a deadlock. os.close(write_fd) - _, status = os.waitpid(pid, 0) - exit_code = os.waitstatus_to_exitcode(status) - # Read result from pipe with os.fdopen(read_fd, "rb") as f: try: data = pickle.load(f) except Exception: data = None + _, status = os.waitpid(pid, 0) + exit_code = os.waitstatus_to_exitcode(status) + if exit_code != 0 or data is None: error_msg = f"Child exited with code {exit_code}" if data and not data.get("ok") and "error" in data: @@ -136,3 +162,243 @@ def __init__(self, exit_code: int, lost_mutants: list[str], crash_log: str | Non details.append("(Completed mutants are saved; lost ones will be re-run)") super().__init__("\n".join(details)) + + +@dataclass +class MutantResult: + """Result of testing a single mutant.""" + + mutant_name: str + exit_code: int + duration: float + output: str | None = None + + +class ActiveWorker(NamedTuple): + """Info about an active worker, used for timeout checking.""" + + pid: int + start_time: datetime + mutant_name: str + estimated_time: float + + +class RunningWorker(NamedTuple): + """Tracks an in-flight mutation-test worker for ForkRunner.""" + + mutant_name: str + start_time: datetime + estimated_time: float + + +class MutantRunner(ABC): + """Abstract base class for mutation-test runners. + + A runner owns the process-isolation strategy for testing mutants and also + exposes the surrounding test operations (stats collection, clean tests, + forced-fail, test listing) so the caller never has to touch a raw test + runner directly. + + Usage:: + + runner = get_mutant_runner(max_children) + runner.startup() + for mutant in mutants: + while not runner.has_capacity(): + register_result(runner.wait_for_result()) + runner.submit(mutant_name, tests, cpu_time_limit, estimated_time) + runner.signal_work_complete() + while runner.pending_count() > 0: + register_result(runner.wait_for_result()) + runner.shutdown() + """ + + @abstractmethod + def startup(self) -> None: + """Called once before mutation testing begins.""" + + @abstractmethod + def submit(self, mutant_name: str, tests: list[str], cpu_time_limit: int, estimated_time: float) -> None: + """Submit a mutant for testing. Non-blocking. + + Args: + mutant_name: The mutant identifier (e.g. 'module.func__mutmut_1'). + tests: Test node ids to run. + cpu_time_limit: CPU-time limit in seconds for the test run. + estimated_time: Estimated test duration, used for timeout tracking. + """ + + @abstractmethod + def has_capacity(self) -> bool: + """True if we can submit more work without exceeding max workers.""" + + @abstractmethod + def wait_for_result(self, timeout: float | None = None) -> MutantResult: + """Block until one result is available and return it.""" + + @abstractmethod + def pending_count(self) -> int: + """Number of in-flight mutants awaiting results.""" + + @abstractmethod + def get_active_workers(self) -> list[ActiveWorker]: + """Return active workers for timeout checking.""" + + @abstractmethod + def signal_work_complete(self) -> None: + """Signal that no more work will be submitted. + + Called after all mutants have been submitted but before waiting for the + final results. Runners with a coordinator process use this to close the + work pipe; others treat it as a no-op. + """ + + def stop_all_workers(self) -> None: + """Terminate all in-flight workers (best effort). Default is a no-op.""" + + def get_orchestrator_restart_count(self) -> int: + """Number of orchestrator restarts. Only meaningful for HotForkRunner.""" + return 0 + + @abstractmethod + def shutdown(self) -> None: + """Called after all mutants are tested. Clean up resources.""" + + @abstractmethod + def collect_stats(self, tests: Iterable[str] | None) -> int: + """Run stats collection. Returns an exit code.""" + + @abstractmethod + def run_clean_tests(self, tests: Iterable[str]) -> int: + """Run the clean (unmutated) tests. Returns an exit code.""" + + @abstractmethod + def run_forced_fail(self) -> int: + """Run the forced-fail test. Returns an exit code.""" + + @abstractmethod + def list_all_tests(self) -> ListAllTestsResult: + """List all tests in the test suite.""" + + +class ForkRunner(MutantRunner): + """Runner that uses os.fork() for process isolation. + + This is the traditional mutmut approach - fast, but it can misbehave with + libraries like gevent, grpc, and torch when forking from a parent process + that has already imported test code. For those, use HotForkRunner instead. + """ + + def __init__(self, max_workers: int, test_runner: TestRunner, debug: bool = False) -> None: + self.max_workers = max_workers + self.test_runner = test_runner + self.debug = debug + self._running: dict[int, RunningWorker] = {} # pid -> RunningWorker + self._no_tests_results: list[MutantResult] = [] + + def startup(self) -> None: + # Freeze the GC so the forked children inherit a stable heap and do not + # thrash collecting objects the parent already owns. + gc.freeze() + + def submit(self, mutant_name: str, tests: list[str], cpu_time_limit: int, estimated_time: float) -> None: + if not tests: + self._no_tests_results.append(MutantResult(mutant_name=mutant_name, exit_code=33, duration=0.0)) + return + + pid = os.fork() + if pid == 0: + # In the child. + os.environ["MUTANT_UNDER_TEST"] = mutant_name + setproctitle(f"mutmut: {mutant_name}") + + # Run fast tests first. + tests_sorted = sorted(tests, key=lambda test_name: state().duration_by_test[test_name]) + + # Signal SIGXCPU after the CPU limit, and SIGKILL one second later if + # it is still running. + limit = cpu_time_limit + int(process_time()) + resource.setrlimit(resource.RLIMIT_CPU, (limit, limit + 1)) + + if not self.debug: + sys.stdout = sys.stderr = open(os.devnull, "w") + + result = self.test_runner.run_tests(mutant_name=mutant_name, tests=tests_sorted) + os._exit(result) + else: + # In the parent. + cfg = config() + wall_time_limit_s = (estimated_time + cfg.timeout_constant) * cfg.timeout_multiplier + register_timeout(pid=pid, timeout_s=wall_time_limit_s) + self._running[pid] = RunningWorker(mutant_name, datetime.now(), estimated_time) + + def has_capacity(self) -> bool: + return len(self._running) < self.max_workers + + def wait_for_result(self, timeout: float | None = None) -> MutantResult: + if self._no_tests_results: + return self._no_tests_results.pop(0) + + pid, wait_status = os.wait() + exit_code = os.waitstatus_to_exitcode(wait_status) + + worker = self._running.pop(pid) + duration = (datetime.now() - worker.start_time).total_seconds() + return MutantResult(mutant_name=worker.mutant_name, exit_code=exit_code, duration=duration) + + def pending_count(self) -> int: + return len(self._running) + + def get_active_workers(self) -> list[ActiveWorker]: + return [ActiveWorker(pid, w.start_time, w.mutant_name, w.estimated_time) for pid, w in self._running.items()] + + def signal_work_complete(self) -> None: + """No-op for ForkRunner (there is no orchestrator to signal).""" + + def stop_all_workers(self) -> None: + for pid in list(self._running): + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + + def shutdown(self) -> None: + while self._running: + try: + self.wait_for_result() + except ChildProcessError: + break + gc.unfreeze() + + def collect_stats(self, tests: Iterable[str] | None) -> int: + # Already in a clean process, so run stats directly without forking. + return self.test_runner.run_stats(tests=tests or []) + + def run_clean_tests(self, tests: Iterable[str]) -> int: + return self.test_runner.run_tests(mutant_name=None, tests=tests) + + def run_forced_fail(self) -> int: + return self.test_runner.run_forced_fail() + + def list_all_tests(self) -> ListAllTestsResult: + return self.test_runner.list_all_tests() + + +def get_mutant_runner(max_workers: int = 1) -> MutantRunner: + """Create a MutantRunner based on the configured ``process_isolation``. + + Args: + max_workers: Maximum number of concurrent workers. + + Returns: + A MutantRunner instance. + """ + if max_workers < 1: + raise ValueError("max_workers must be at least 1") + + if config().process_isolation == ProcessIsolation.HOT_FORK: + raise NotImplementedError("process_isolation = 'hot-fork' is not available yet in this build; use 'fork'.") + + pytest_runner = PytestRunner() + pytest_runner.prepare_main_test_run() + return ForkRunner(max_workers=max_workers, test_runner=pytest_runner, debug=config().debug) diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index b890b02e..3ab2787a 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -33,6 +33,7 @@ from mutmut.__main__ import record_trampoline_hit from mutmut.__main__ import run_forced_fail_test from mutmut.configuration import Config +from mutmut.configuration import ProcessIsolation from mutmut.mutation.data import MutantLineSpans from mutmut.mutation.data import SourceFileMutationData from mutmut.mutation.file_mutation import compute_function_hashes @@ -1434,6 +1435,7 @@ def _config_for_invalidation(**overrides): cache_invalidation_exclude=[], on_dependency_change="warn", use_git_change_detection=True, + process_isolation=ProcessIsolation.FORK, ) base.update(overrides) return Config(**base) @@ -1737,7 +1739,9 @@ def test_user_exclude_pattern_drops_file(tmp_path, monkeypatch): (tmp_path / "noisy.json").write_text("1") _commit_all(tmp_path) state().old_git_commit = git_head() - monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation(cache_invalidation_exclude=["*.json"])) + monkeypatch.setattr( + mutmut.__main__, "config", lambda: _config_for_invalidation(cache_invalidation_exclude=["*.json"]) + ) (tmp_path / "noisy.json").write_text("2") @@ -1754,7 +1758,9 @@ def test_registered_file_is_immune_to_exclusion(tmp_path, monkeypatch): (tmp_path / "notes.md").write_text("a") # *.md is excluded by default _commit_all(tmp_path) state().old_git_commit = git_head() - monkeypatch.setattr(mutmut.__main__, "config", lambda: _config_for_invalidation(cache_invalidation_files=["notes.md"])) + monkeypatch.setattr( + mutmut.__main__, "config", lambda: _config_for_invalidation(cache_invalidation_files=["notes.md"]) + ) (tmp_path / "notes.md").write_text("b") diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 21a3f74b..5a4794d2 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -3,6 +3,7 @@ import pytest from mutmut.configuration import Config +from mutmut.configuration import ProcessIsolation from mutmut.configuration import _config_reader from mutmut.configuration import _guess_source_paths from mutmut.configuration import _load_config @@ -72,6 +73,7 @@ def _get_config(only_mutate: list[str], do_not_mutate: list[str]) -> Config: cache_invalidation_exclude=[], on_dependency_change="warn", use_git_change_detection=True, + process_isolation=ProcessIsolation.FORK, ) def test_ignores_non_python_files(self): From 065040db6ba19f30e30f2a45bababe9a73468481 Mon Sep 17 00:00:00 2001 From: nicklafleur <55208706+nicklafleur@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:14:11 -0400 Subject: [PATCH 6/9] feat(runners): add HotForkRunner for fork-safe mutation testing Add a single-orchestrator process-isolation strategy for projects whose test setup makes the parent fork-unsafe (gevent monkey-patching, grpc, torch): parent (clean) -> orchestrator (imports pytest once) -> N grandchildren The parent never imports pytest/conftest. The orchestrator imports pytest a single time, warms up (configurable via hot_fork_warmup: collect/import/none), then forks one grandchild per mutant, streaming results back over a pipe and reaping via a SIGCHLD self-pipe. If the orchestrator crashes, in-flight mutants are re-submitted to a fresh orchestrator up to max_orchestrator_restarts times before raising OrchestratorCrashError. Stats/clean-test/forced-fail/test-listing all run in short-lived forks (StatsResult carries the collected mapping back to the parent), so the parent stays clean throughout. Selected via process_isolation = "hot-fork"; get_mutant_runner() now builds it. Supporting pieces: HotForkWarmup config + validation, TestRunner.warm_up(), models/results.StatsResult, and utils/logging_utils for the orchestrator's file-only logging and crash logs. Validated end-to-end: on the my_lib project, hot-fork produces byte-identical verdicts to fork (112 mutants, same 37/64/10/1 distribution). Adds a subprocess e2e smoke test (hot_fork_basic) plus factory/config-validation unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e_projects/hot_fork_basic/pyproject.toml | 14 + .../hot_fork_basic/src/hf_calc/__init__.py | 14 + .../hot_fork_basic/tests/test_hf_calc.py | 15 + src/mutmut/configuration.py | 35 ++ src/mutmut/models/__init__.py | 1 + src/mutmut/models/results.py | 44 ++ src/mutmut/runners/harness.py | 39 ++ src/mutmut/utils/logging_utils.py | 69 +++ src/mutmut/workers/isolation.py | 528 +++++++++++++++++- tests/e2e/test_e2e_hot_fork.py | 54 ++ tests/mutation/test_mutation.py | 6 + tests/test_configuration.py | 34 ++ tests/workers/test_isolation.py | 33 ++ 13 files changed, 885 insertions(+), 1 deletion(-) create mode 100644 e2e_projects/hot_fork_basic/pyproject.toml create mode 100644 e2e_projects/hot_fork_basic/src/hf_calc/__init__.py create mode 100644 e2e_projects/hot_fork_basic/tests/test_hf_calc.py create mode 100644 src/mutmut/models/__init__.py create mode 100644 src/mutmut/models/results.py create mode 100644 src/mutmut/utils/logging_utils.py create mode 100644 tests/e2e/test_e2e_hot_fork.py diff --git a/e2e_projects/hot_fork_basic/pyproject.toml b/e2e_projects/hot_fork_basic/pyproject.toml new file mode 100644 index 00000000..88b8f9cd --- /dev/null +++ b/e2e_projects/hot_fork_basic/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "hf-calc" +version = "0.1.0" +requires-python = ">=3.10" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.mutmut] +process_isolation = "hot-fork" + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" diff --git a/e2e_projects/hot_fork_basic/src/hf_calc/__init__.py b/e2e_projects/hot_fork_basic/src/hf_calc/__init__.py new file mode 100644 index 00000000..e302c89b --- /dev/null +++ b/e2e_projects/hot_fork_basic/src/hf_calc/__init__.py @@ -0,0 +1,14 @@ +def add(a, b): + return a + b + + +def sub(a, b): + return a - b + + +def mul(a, b): + return a * b + + +def untested(x): + return x + 1 diff --git a/e2e_projects/hot_fork_basic/tests/test_hf_calc.py b/e2e_projects/hot_fork_basic/tests/test_hf_calc.py new file mode 100644 index 00000000..ad535d84 --- /dev/null +++ b/e2e_projects/hot_fork_basic/tests/test_hf_calc.py @@ -0,0 +1,15 @@ +from hf_calc import add +from hf_calc import mul +from hf_calc import sub + + +def test_add(): + assert add(1, 2) == 3 + + +def test_sub(): + assert sub(5, 2) == 3 + + +def test_mul(): + assert mul(3, 4) == 12 diff --git a/src/mutmut/configuration.py b/src/mutmut/configuration.py index ef5a3cb2..0d612840 100644 --- a/src/mutmut/configuration.py +++ b/src/mutmut/configuration.py @@ -29,6 +29,24 @@ class ProcessIsolation(str, Enum): HOT_FORK = "hot-fork" # Fork-safe orchestrator for gevent/grpc/torch. +class HotForkWarmup(str, Enum): + """Warmup strategy for the hot-fork orchestrator. + + Controls what the orchestrator does after importing the test runner but + before forking any grandchildren: + + - COLLECT: run ``pytest --collect-only`` to pre-load conftest, plugins, and + test modules (default; biggest speedup for most projects). + - IMPORT: import the modules listed in ``preload_modules_file`` (useful when + test collection has side effects you do not want shared). + - NONE: import nothing extra beyond what running a test needs. + """ + + COLLECT = "collect" + IMPORT = "import" + NONE = "none" + + def _config_reader() -> Callable[[str, Any], Any]: path = Path("pyproject.toml") if path.exists(): @@ -141,6 +159,13 @@ def _load_config() -> Config: valid = [e.value for e in ProcessIsolation] raise ValueError(f"Invalid process_isolation value: {isolation_str!r}. Expected one of: {valid}") from None + warmup_str = s("hot_fork_warmup", "collect") + try: + hot_fork_warmup = HotForkWarmup(warmup_str) + except ValueError: + valid = [e.value for e in HotForkWarmup] + raise ValueError(f"Invalid hot_fork_warmup value: {warmup_str!r}. Expected one of: {valid}") from None + return Config( only_mutate=only_mutate, do_not_mutate=do_not_mutate, @@ -177,6 +202,11 @@ def _load_config() -> Config: on_dependency_change=s("on_dependency_change", "warn"), use_git_change_detection=s("use_git_change_detection", True), process_isolation=process_isolation, + hot_fork_warmup=hot_fork_warmup, + max_orchestrator_restarts=s("max_orchestrator_restarts", 3), + preload_modules_file=s("preload_modules_file", None), + log_to_file=s("log_to_file", False), + log_file_path=s("log_file_path", "mutants/mutmut-debug.log"), ) @@ -207,6 +237,11 @@ class Config: on_dependency_change: str use_git_change_detection: bool process_isolation: ProcessIsolation + hot_fork_warmup: HotForkWarmup + max_orchestrator_restarts: int + preload_modules_file: str | None + log_to_file: bool + log_file_path: str def config_fingerprint(self) -> dict[str, str]: """Hash the config fields that can change cached mutant *results*, grouped so the diff --git a/src/mutmut/models/__init__.py b/src/mutmut/models/__init__.py new file mode 100644 index 00000000..e6fabf88 --- /dev/null +++ b/src/mutmut/models/__init__.py @@ -0,0 +1 @@ +"""Data models for mutmut.""" diff --git a/src/mutmut/models/results.py b/src/mutmut/models/results.py new file mode 100644 index 00000000..44ca6640 --- /dev/null +++ b/src/mutmut/models/results.py @@ -0,0 +1,44 @@ +"""Result data models for worker/orchestrator communication.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class StatsResult: + """Stats collected by a forked child, serialized back to the parent. + + The hot-fork runner collects stats inside a forked child (so the parent + never imports pytest). The child cannot mutate the parent's ``state()`` + directly, so it packs the collected data into this picklable structure and + the parent merges it back in. + """ + + exit_code: int + tests_by_mangled_function_name: dict[str, set[str]] + duration_by_test: dict[str, float] + stats_time: float + function_dependencies: dict[str, set[str]] + + def to_dict(self) -> dict[str, Any]: + """Serialize to a picklable dict (sets become lists).""" + return { + "exit_code": self.exit_code, + "tests_by_mangled_function_name": {k: list(v) for k, v in self.tests_by_mangled_function_name.items()}, + "duration_by_test": self.duration_by_test, + "stats_time": self.stats_time, + "function_dependencies": {k: list(v) for k, v in self.function_dependencies.items()}, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> StatsResult: + """Deserialize from the dict produced by :meth:`to_dict`.""" + return cls( + exit_code=data["exit_code"], + tests_by_mangled_function_name={k: set(v) for k, v in data["tests_by_mangled_function_name"].items()}, + duration_by_test=data["duration_by_test"], + stats_time=data["stats_time"], + function_dependencies={k: set(v) for k, v in data["function_dependencies"].items()}, + ) diff --git a/src/mutmut/runners/harness.py b/src/mutmut/runners/harness.py index 686ec508..03de37ca 100644 --- a/src/mutmut/runners/harness.py +++ b/src/mutmut/runners/harness.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from typing import Any +from mutmut.configuration import HotForkWarmup from mutmut.configuration import config from mutmut.state import state from mutmut.stats import save_stats @@ -49,6 +50,16 @@ def collect_main_test_coverage(self, cov: Coverage) -> int: def list_all_tests(self) -> ListAllTestsResult: raise NotImplementedError() + def warm_up(self) -> None: + """Pre-import expensive modules so forked children inherit them. + + Called by HotForkRunner inside the orchestrator after the test runner is + created. Importing pytest (and optionally running collection) here means + the grandchildren fork with everything already in memory. The default is + a no-op for runners that do not benefit from it. + """ + return + def collected_test_names() -> set[str]: return set(state().duration_by_test.keys()) @@ -82,6 +93,34 @@ def __init__(self) -> None: self._pytest_add_cli_args: list[str] = config().pytest_add_cli_args self._pytest_add_cli_args_test_selection: list[str] = config().pytest_add_cli_args_test_selection + def warm_up(self) -> None: + """Pre-load test infrastructure per the ``hot_fork_warmup`` config. + + - COLLECT (default): run ``pytest --collect-only`` to import conftest, + plugins, and test modules (biggest speedup for most projects). + - IMPORT: import the modules listed in ``preload_modules_file``. + - NONE: import nothing beyond what running a test already needs. + """ + warmup = config().hot_fork_warmup + + if warmup == HotForkWarmup.COLLECT: + with change_cwd("mutants"): + self.execute_pytest(["--collect-only", "-qqq"] + self._pytest_add_cli_args_test_selection) + elif warmup == HotForkWarmup.IMPORT: + preload_file = config().preload_modules_file + if preload_file: + import importlib + + with open(preload_file) as f: + for line in f: + module_name = line.strip() + if module_name and not module_name.startswith("#"): + try: + importlib.import_module(module_name) + except ImportError: + pass # Best effort. + # HotForkWarmup.NONE -> no-op. + # noinspection PyMethodMayBeStatic def execute_pytest(self, params: list[str], **kwargs: Any) -> int: import pytest diff --git a/src/mutmut/utils/logging_utils.py b/src/mutmut/utils/logging_utils.py new file mode 100644 index 00000000..fcfa4f62 --- /dev/null +++ b/src/mutmut/utils/logging_utils.py @@ -0,0 +1,69 @@ +"""File-based logging helpers, used mainly by the hot-fork orchestrator. + +Child/orchestrator processes cannot easily log to the console without +corrupting the interactive terminal output, so mutmut logs to a rotating file +instead. Logging is opt-in (``log_to_file``/``debug`` config) and off by default. +""" + +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + +from mutmut.configuration import config + + +def get_logger(name: str) -> logging.Logger: + """Return a logger under the ``mutmut.`` namespace.""" + return logging.getLogger(name if name.startswith("mutmut.") else f"mutmut.{name}") + + +logger = get_logger(__name__) + + +_file_handler: logging.Handler | None = None + + +def setup_file_logging(log_file: str | None = None, level: int = logging.DEBUG) -> None: + """Attach a rotating file handler to the ``mutmut`` logger (idempotent). + + Useful for debugging child processes which cannot log to the console. + + Args: + log_file: Path to the log file (defaults to ``config().log_file_path``). + level: Logging level (default: DEBUG). + """ + global _file_handler + + if _file_handler is not None: + return + + log_path = Path(log_file if log_file is not None else config().log_file_path) + log_path.parent.mkdir(parents=True, exist_ok=True) + + _file_handler = RotatingFileHandler( + log_path, + maxBytes=10 * 1024 * 1024, # 10 MB + backupCount=3, + ) + _file_handler.setLevel(level) + _file_handler.setFormatter( + logging.Formatter( + "%(asctime)s.%(msecs)03d [%(process)d] %(name)s %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + + # File only - do not propagate to the root logger (which would hit stdout). + root_logger = logging.getLogger("mutmut") + root_logger.addHandler(_file_handler) + root_logger.setLevel(level) + root_logger.propagate = False + + logger.debug(f"File logging initialized: {log_path}") + + +def get_log_file_path() -> Path: + """Return the configured debug-log file path.""" + return Path(config().log_file_path) diff --git a/src/mutmut/workers/isolation.py b/src/mutmut/workers/isolation.py index d34ca189..0ad2596e 100644 --- a/src/mutmut/workers/isolation.py +++ b/src/mutmut/workers/isolation.py @@ -15,27 +15,37 @@ from __future__ import annotations import gc +import io +import logging import os import pickle import resource +import select import signal import sys +import time +import traceback from abc import ABC from abc import abstractmethod from collections.abc import Callable from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime +from queue import Empty from time import process_time from typing import Any from typing import NamedTuple from mutmut.configuration import ProcessIsolation from mutmut.configuration import config +from mutmut.models.results import StatsResult from mutmut.runners.harness import ListAllTestsResult from mutmut.runners.harness import PytestRunner from mutmut.runners.harness import TestRunner from mutmut.state import state +from mutmut.utils.logging_utils import get_log_file_path +from mutmut.utils.logging_utils import get_logger +from mutmut.utils.logging_utils import setup_file_logging from mutmut.utils.safe_setproctitle import safe_setproctitle as setproctitle from mutmut.workers.timeout import register_timeout @@ -281,6 +291,516 @@ def list_all_tests(self) -> ListAllTestsResult: """List all tests in the test suite.""" +class HotForkRunner(MutantRunner): + """Fork-safe mutation runner using a single hot orchestrator. + + Architecture:: + + Parent (clean) -> Orchestrator (imports pytest) -> N concurrent children + + The parent never imports pytest/conftest, so it stays fork-safe. The + orchestrator imports pytest exactly once, then forks a grandchild per + mutant; each grandchild runs one mutant's tests and exits. This is both + faster than forking a fresh pytest per mutant (one import instead of N) and + compatible with fork-unsafe libraries like gevent, grpc, and torch. + + If the orchestrator crashes, in-flight mutants are re-submitted to a fresh + orchestrator up to ``max_restarts`` times before an OrchestratorCrashError + is raised. + """ + + class RunningChild(NamedTuple): + """Info about a grandchild currently running a mutant's tests.""" + + mutant_name: str + start_time: float + wall_timeout: float + + # Default maximum number of orchestrator restarts before giving up. + DEFAULT_MAX_RESTARTS = 3 + + def __init__( + self, + max_workers: int, + test_runner_class: type, + test_runner_args: dict[str, Any], + debug: bool = False, + max_restarts: int | None = None, + ) -> None: + self._logger = get_logger(__name__) + self.max_workers = max_workers + self.test_runner_class = test_runner_class + self.test_runner_args = test_runner_args + self.debug = debug + self.max_restarts = max_restarts if max_restarts is not None else self.DEFAULT_MAX_RESTARTS + + self.work_pipe_read: int | None = None + self.work_pipe_write: int | None = None + self.result_pipe_read: int | None = None + self.result_pipe_write: int | None = None + + self.orchestrator_pid: int | None = None + self._pending: set[str] = set() # mutant_names in flight + # mutant_name -> (tests, cpu_time_limit, estimated_time, start_time) + self._pending_work: dict[str, tuple[list[str], int, float, datetime]] = {} + self._result_file: io.BufferedReader | None = None + self._shutting_down = False + self._restart_count = 0 + self._crash_exit_codes: list[int] = [] + + def startup(self) -> None: + gc.freeze() + self._start_orchestrator() + + def _start_orchestrator(self) -> None: + """Fork a fresh orchestrator process with new pipes. + + Can be called more than once for crash recovery; each call creates fresh + pipes and a new orchestrator. + """ + if self._result_file is not None: + try: + self._result_file.close() + except Exception: + pass + self._result_file = None + + self.work_pipe_read, self.work_pipe_write = os.pipe() + self.result_pipe_read, self.result_pipe_write = os.pipe() + + pid = os.fork() + if pid == 0: + # Child: become the orchestrator. + os.close(self.work_pipe_write) + os.close(self.result_pipe_read) + try: + self._orchestrator_main(self.work_pipe_read, self.result_pipe_write) + except Exception as e: + self._write_crash_log(e) + os._exit(1) + os._exit(0) + + # Parent: close the child's ends. + os.close(self.work_pipe_read) + os.close(self.result_pipe_write) + self.orchestrator_pid = pid + self._logger.info(f"HotForkRunner started orchestrator (pid={pid})") + + def _restart_orchestrator_with_pending_work(self, exit_code: int = -1) -> None: + """Restart the orchestrator and re-submit all pending work. + + Raises OrchestratorCrashError once ``max_restarts`` is exceeded. + """ + self._restart_count += 1 + self._crash_exit_codes.append(exit_code) + + crash_log_path = get_log_file_path().parent / ".orchestrator-crash.log" + pending_mutants = list(self._pending) + self._logger.error( + f"Orchestrator crashed with exit code {exit_code}. " + f"Check {crash_log_path} and {get_log_file_path()} for details." + ) + self._logger.error(f"Pending mutants at time of crash ({len(pending_mutants)}): {pending_mutants}") + + if self._restart_count > self.max_restarts: + raise OrchestratorCrashError( + exit_code=-1, + lost_mutants=list(self._pending), + crash_log=str(crash_log_path) if crash_log_path.exists() else None, + ) + + lost_count = len(self._pending) + self._logger.warning( + f"Orchestrator crashed, restarting (attempt {self._restart_count}/{self.max_restarts}), " + f"re-submitting {lost_count} pending mutant(s)" + ) + + pending_work_copy = dict(self._pending_work) + + self._start_orchestrator() + + if self.work_pipe_write is None: + raise RuntimeError("Failed to restart orchestrator - work pipe not created") + for mutant_name, (tests, cpu_time_limit, estimated_time, _) in pending_work_copy.items(): + os.write(self.work_pipe_write, pickle.dumps((mutant_name, list(tests), cpu_time_limit))) + self._pending_work[mutant_name] = (tests, cpu_time_limit, estimated_time, datetime.now()) + self._logger.debug(f"Re-submitted {mutant_name} to new orchestrator") + + self._logger.info(f"Orchestrator restarted, {lost_count} mutant(s) re-submitted") + + def _write_crash_log(self, exception: Exception) -> None: + """Best-effort dump of orchestrator crash info for debugging.""" + crash_file = get_log_file_path().parent / ".orchestrator-crash.log" + try: + crash_file.parent.mkdir(parents=True, exist_ok=True) + with open(crash_file, "w") as f: + f.write(f"Orchestrator crash at {datetime.now()}\n") + f.write(f"Exception: {exception}\n") + f.write(traceback.format_exc()) + except Exception: + pass + + def _setup_sigchld_pipe(self) -> tuple[int, int]: + """Set up a self-pipe so SIGCHLD wakes the orchestrator's select().""" + sigchld_pipe_r, sigchld_pipe_w = os.pipe() + os.set_blocking(sigchld_pipe_r, False) + os.set_blocking(sigchld_pipe_w, False) + + def sigchld_handler(signum: int, frame: Any) -> None: + # Write a byte to wake up select(); ignore a full pipe. + try: + os.write(sigchld_pipe_w, b"c") + except (BlockingIOError, OSError): + pass + + signal.signal(signal.SIGCHLD, sigchld_handler) + return sigchld_pipe_r, sigchld_pipe_w + + def _wait_for_child_event(self, sigchld_pipe_r: int, timeout: float | None) -> bool: + """Block until a child exits (SIGCHLD) or the timeout elapses. + + Returns True if a child may be ready to reap, False on timeout. + """ + try: + readable, _, _ = select.select([sigchld_pipe_r], [], [], timeout) + if readable: + try: + while os.read(sigchld_pipe_r, 1024): + pass + except BlockingIOError: + pass # Expected once the pipe is drained. + return True + return False + except InterruptedError: + return True # Interrupted by a signal; check anyway. + + def _orchestrator_main(self, work_fd: int, result_fd: int) -> None: + """Orchestrator: import pytest once, then fork a grandchild per mutant.""" + # The parent owns shutdown, so ignore SIGINT here. + signal.signal(signal.SIGINT, signal.SIG_IGN) + + setup_file_logging() + orchestrator_logger = get_logger("mutmut.hotfork.orchestrator") + orchestrator_logger.info(f"Hot-fork orchestrator starting (pid={os.getpid()})") + + test_runner: TestRunner = self.test_runner_class(**self.test_runner_args) + + # Warm up with stdout/stderr suppressed so collection output does not + # corrupt the interactive terminal. + old_stdout, old_stderr = sys.stdout, sys.stderr + sys.stdout = sys.stderr = open(os.devnull, "w") + try: + test_runner.warm_up() + finally: + sys.stdout, sys.stderr = old_stdout, old_stderr + orchestrator_logger.info("Test runner initialized, ready for work") + + # Set up the SIGCHLD pipe AFTER warm_up: libraries like gevent may + # monkey-patch signals during import/collection. + sigchld_pipe_r, sigchld_pipe_w = self._setup_sigchld_pipe() + orchestrator_logger.debug("SIGCHLD notification pipe set up") + + work_file = os.fdopen(work_fd, "rb") + result_file = os.fdopen(result_fd, "wb", buffering=0) + + running: dict[int, HotForkRunner.RunningChild] = {} + + while True: + self._reap_children(running, result_file, orchestrator_logger, sigchld_pipe_r, block=False) + + while len(running) >= self.max_workers: + self._reap_children(running, result_file, orchestrator_logger, sigchld_pipe_r, block=True, timeout=1.0) + + readable, _, _ = select.select([work_fd, sigchld_pipe_r], [], [], 1.0) + + if sigchld_pipe_r in readable: + self._reap_children(running, result_file, orchestrator_logger, sigchld_pipe_r, block=False) + + if work_fd not in readable: + continue + + try: + msg = pickle.load(work_file) + except EOFError: + break + + if msg is None: + break + + mutant_name, tests, cpu_time_limit = msg + + # Wall-clock timeout is shorter than the CPU limit: multi-threaded + # code can burn N*wall CPU seconds across N cores, so half the CPU + # limit is a reasonable wall bound. + wall_timeout = cpu_time_limit / 2 + orchestrator_logger.debug( + f"Received mutant: {mutant_name} ({len(tests)} tests, " + f"cpu_limit={cpu_time_limit}s, wall_timeout={wall_timeout}s)" + ) + + child_pid = os.fork() + if child_pid == 0: + # Grandchild: run this mutant's tests under a CPU limit. + worker_logger = get_logger(f"mutmut.hotfork.worker.{os.getpid()}") + worker_logger.debug(f"Starting {mutant_name} ({len(tests)} tests)") + + sys.stdout = sys.stderr = open(os.devnull, "w") + + limit = cpu_time_limit + int(process_time()) + resource.setrlimit(resource.RLIMIT_CPU, (limit, limit + 1)) + + os.environ["MUTANT_UNDER_TEST"] = mutant_name + try: + exit_code = test_runner.run_tests(mutant_name=mutant_name, tests=tests) + except Exception: + exit_code = -1 + + worker_logger.debug(f"Finished {mutant_name}: exit={exit_code}") + os._exit(exit_code) + + running[child_pid] = self.RunningChild(mutant_name, time.time(), wall_timeout) + # A background thread sends SIGXCPU when the wall timeout expires. + register_timeout(child_pid, wall_timeout) + + orchestrator_logger.info("Work queue exhausted, waiting for remaining children") + while running: + self._reap_children(running, result_file, orchestrator_logger, sigchld_pipe_r, block=True, timeout=1.0) + + try: + os.close(sigchld_pipe_r) + os.close(sigchld_pipe_w) + except OSError: + pass + orchestrator_logger.info("Orchestrator shutting down cleanly") + + def _reap_children( + self, + running: dict[int, RunningChild], + result_file: io.FileIO, + orchestrator_logger: logging.Logger, + sigchld_pipe_r: int, + block: bool, + timeout: float | None = None, + ) -> None: + """Reap completed grandchildren and stream their results to the parent.""" + if block: + if not self._wait_for_child_event(sigchld_pipe_r, timeout): + return # Timeout reached, no child ready. + + # Reap ALL ready children in a loop: the kernel coalesces SIGCHLD, so a + # single signal can cover several exits. Reaping only one per signal + # would leave children unreaped and could hang select() forever. + while True: + try: + pid, status = os.waitpid(-1, os.WNOHANG) + except ChildProcessError: + return # No children. + if pid == 0: + return # No more children ready. + + # Unknown pids (coverage helpers, pytest plugins, ...) are ignored. + if pid not in running: + continue + + child = running.pop(pid) + exit_code = os.waitstatus_to_exitcode(status) + duration = time.time() - child.start_time + orchestrator_logger.debug(f"Completed {child.mutant_name}: exit={exit_code} ({duration:.3f}s)") + pickle.dump((child.mutant_name, exit_code), result_file) + + def submit(self, mutant_name: str, tests: list[str], cpu_time_limit: int, estimated_time: float) -> None: + if self.work_pipe_write is None: + raise RuntimeError("HotForkRunner not started - call startup() first") + os.write(self.work_pipe_write, pickle.dumps((mutant_name, list(tests), cpu_time_limit))) + self._pending.add(mutant_name) + self._pending_work[mutant_name] = (list(tests), cpu_time_limit, estimated_time, datetime.now()) + + def has_capacity(self) -> bool: + return len(self._pending) < self.max_workers + + def signal_work_complete(self) -> None: + """Close the work pipe so the orchestrator sees EOF and drains workers.""" + if self.work_pipe_write is None: + return + try: + os.close(self.work_pipe_write) + except OSError: + pass + finally: + self.work_pipe_write = None + self._logger.debug("Work pipe closed, orchestrator will drain remaining workers") + + def _check_orchestrator_alive(self) -> None: + """Detect an orchestrator crash and restart it, re-submitting pending work. + + A clean exit (code 0) is left alone. Raises OrchestratorCrashError once + the restart budget is exhausted. + """ + if self.orchestrator_pid is None: + return + try: + pid, status = os.waitpid(self.orchestrator_pid, os.WNOHANG) + if pid == self.orchestrator_pid: + exit_code = os.waitstatus_to_exitcode(status) + self.orchestrator_pid = None + + if exit_code == 0: + self._logger.debug(f"Orchestrator (pid={pid}) exited cleanly") + return + + self._logger.warning(f"Orchestrator (pid={pid}) crashed with exit code {exit_code}") + self._restart_orchestrator_with_pending_work(exit_code=exit_code) + except ChildProcessError: + self._logger.warning("Orchestrator process not found") + self.orchestrator_pid = None + self._restart_orchestrator_with_pending_work(exit_code=-1) + + def wait_for_result(self, timeout: float | None = None) -> MutantResult: + if self.result_pipe_read is None: + raise RuntimeError("HotForkRunner not started - call startup() first") + while True: + self._check_orchestrator_alive() + + r, _, _ = select.select([self.result_pipe_read], [], [], timeout or 1.0) + if not r: + if timeout is not None: + raise Empty() + continue + + if self._result_file is None: + self._result_file = os.fdopen(self.result_pipe_read, "rb") + + try: + mutant_name, exit_code = pickle.load(self._result_file) + except EOFError as err: + self._check_orchestrator_alive() + raise OrchestratorCrashError(exit_code=-1, lost_mutants=list(self._pending), crash_log=None) from err + + self._pending.discard(mutant_name) + self._pending_work.pop(mutant_name, None) + + return MutantResult(mutant_name=mutant_name, exit_code=exit_code, duration=0.0) + + def pending_count(self) -> int: + return len(self._pending) + + def get_orchestrator_restart_count(self) -> int: + return self._restart_count + + def get_active_workers(self) -> list[ActiveWorker]: + """Best-effort view of in-flight mutants (grandchild pids are not visible). + + The orchestrator pid stands in for each pending mutant's worker. + """ + if not self.orchestrator_pid: + return [] + return [ + ActiveWorker(self.orchestrator_pid, start_time, mutant_name, estimated_time) + for mutant_name, (_, _, estimated_time, start_time) in self._pending_work.items() + ] + + def stop_all_workers(self) -> None: + if self.orchestrator_pid: + try: + os.kill(self.orchestrator_pid, signal.SIGTERM) + except ProcessLookupError: + pass + + def shutdown(self) -> None: + """Close the work pipe, drain remaining results, and reap the orchestrator.""" + if self._shutting_down: + return + self._shutting_down = True + + self._logger.info("HotForkRunner shutting down") + + self.signal_work_complete() + + # Drain any results still in flight so completed work is captured. + while self._pending: + try: + result = self.wait_for_result(timeout=1.0) + self._pending.discard(result.mutant_name) + self._pending_work.pop(result.mutant_name, None) + except (Empty, OrchestratorCrashError): + break + + if self.orchestrator_pid: + try: + os.waitpid(self.orchestrator_pid, 0) + except ChildProcessError: + pass + + if self._result_file: + try: + self._result_file.close() + except Exception: + pass + elif self.result_pipe_read is not None: + try: + os.close(self.result_pipe_read) + except OSError: + pass + + gc.unfreeze() + self._logger.info("HotForkRunner shutdown complete") + + def collect_stats(self, tests: Iterable[str] | None) -> int: + """Collect stats in a forked child so the parent never imports pytest. + + The child runs stats and packs the collected mapping into a StatsResult, + which the parent merges back into ``state()``. + """ + tests_list = list(tests) if tests is not None else None + + def _run_stats() -> dict[str, Any]: + child_runner: TestRunner = self.test_runner_class(**self.test_runner_args) + exit_code = child_runner.run_stats(tests=tests_list or []) + return StatsResult( + exit_code=exit_code, + tests_by_mangled_function_name=dict(state().tests_by_mangled_function_name), + duration_by_test=dict(state().duration_by_test), + stats_time=state().stats_time or 0.0, + function_dependencies=dict(state().function_dependencies), + ).to_dict() + + result = StatsResult.from_dict(run_in_fork_with_result(_run_stats)) + + for k, v in result.tests_by_mangled_function_name.items(): + state().tests_by_mangled_function_name[k] |= v + state().duration_by_test.update(result.duration_by_test) + state().stats_time = result.stats_time + for k, v in result.function_dependencies.items(): + state().function_dependencies[k] = v + + return result.exit_code + + def run_clean_tests(self, tests: Iterable[str]) -> int: + tests_list = list(tests) + + def _run_tests() -> int: + child_runner: TestRunner = self.test_runner_class(**self.test_runner_args) + return child_runner.run_tests(mutant_name=None, tests=tests_list) + + return run_in_fork(_run_tests) + + def run_forced_fail(self) -> int: + def _run_forced_fail() -> int: + child_runner: TestRunner = self.test_runner_class(**self.test_runner_args) + return child_runner.run_forced_fail() + + return run_in_fork(_run_forced_fail) + + def list_all_tests(self) -> ListAllTestsResult: + def _list_all_tests() -> dict[str, Any]: + child_runner: TestRunner = self.test_runner_class(**self.test_runner_args) + result = child_runner.list_all_tests() + return {"ids": list(result.ids)} + + data = run_in_fork_with_result(_list_all_tests) + return ListAllTestsResult(ids=set(data["ids"])) + + class ForkRunner(MutantRunner): """Runner that uses os.fork() for process isolation. @@ -397,7 +917,13 @@ def get_mutant_runner(max_workers: int = 1) -> MutantRunner: raise ValueError("max_workers must be at least 1") if config().process_isolation == ProcessIsolation.HOT_FORK: - raise NotImplementedError("process_isolation = 'hot-fork' is not available yet in this build; use 'fork'.") + return HotForkRunner( + max_workers=max_workers, + test_runner_class=PytestRunner, + test_runner_args={}, + debug=config().debug, + max_restarts=config().max_orchestrator_restarts, + ) pytest_runner = PytestRunner() pytest_runner.prepare_main_test_run() diff --git a/tests/e2e/test_e2e_hot_fork.py b/tests/e2e/test_e2e_hot_fork.py new file mode 100644 index 00000000..2b715290 --- /dev/null +++ b/tests/e2e/test_e2e_hot_fork.py @@ -0,0 +1,54 @@ +"""End-to-end smoke test for the hot-fork process-isolation runner. + +The hot-fork orchestrator forks itself and installs signal handlers, so it is +exercised in a subprocess (with a hard timeout) rather than in-process, keeping +any hang or signal interaction out of the pytest session running this test. +""" + +import json +import shutil +import subprocess +import sys +from collections import Counter +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +E2E_PROJECTS = REPO_ROOT / "e2e_projects" + + +def _read_verdicts(project_path: Path) -> dict[str, int]: + verdicts: dict[str, int] = {} + for meta in sorted((project_path / "mutants").rglob("*.meta")): + data = json.loads(meta.read_text()) + verdicts.update(data["exit_code_by_key"]) + return verdicts + + +def test_hot_fork_runs_end_to_end(tmp_path: Path): + project = tmp_path / "hot_fork_basic" + shutil.copytree(E2E_PROJECTS / "hot_fork_basic", project) + + result = subprocess.run( + [sys.executable, "-m", "mutmut", "run"], + cwd=project, + capture_output=True, + text=True, + timeout=240, + ) + + assert result.returncode == 0, f"mutmut run failed:\n{result.stdout}\n{result.stderr}" + + verdicts = _read_verdicts(project) + assert verdicts, "no mutant results were written" + + counts = Counter(verdicts.values()) + # 0 = survived, 1 = killed, 33 = no tests. The tested functions (add/sub/mul) + # must produce some killed mutants, and `untested` must produce no-tests ones. + assert counts.get(1, 0) > 0, f"expected some killed mutants, got {dict(counts)}" + assert counts.get(33, 0) > 0, f"expected some no-tests mutants, got {dict(counts)}" + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index 3ab2787a..8a6219e8 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -33,6 +33,7 @@ from mutmut.__main__ import record_trampoline_hit from mutmut.__main__ import run_forced_fail_test from mutmut.configuration import Config +from mutmut.configuration import HotForkWarmup from mutmut.configuration import ProcessIsolation from mutmut.mutation.data import MutantLineSpans from mutmut.mutation.data import SourceFileMutationData @@ -1436,6 +1437,11 @@ def _config_for_invalidation(**overrides): on_dependency_change="warn", use_git_change_detection=True, process_isolation=ProcessIsolation.FORK, + hot_fork_warmup=HotForkWarmup.COLLECT, + max_orchestrator_restarts=3, + preload_modules_file=None, + log_to_file=False, + log_file_path="mutants/mutmut-debug.log", ) base.update(overrides) return Config(**base) diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 5a4794d2..a2df6374 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -3,6 +3,7 @@ import pytest from mutmut.configuration import Config +from mutmut.configuration import HotForkWarmup from mutmut.configuration import ProcessIsolation from mutmut.configuration import _config_reader from mutmut.configuration import _guess_source_paths @@ -47,6 +48,34 @@ def test_reset_clears_singleton(self, in_tmp_dir: Path): assert config1 is not config2 +class TestProcessIsolationConfig: + def _write_pyproject(self, in_tmp_dir: Path, body: str) -> None: + (in_tmp_dir / "src").mkdir() + (in_tmp_dir / "pyproject.toml").write_text(f"[tool.mutmut]\n{body}\n") + + def test_defaults(self, in_tmp_dir: Path): + self._write_pyproject(in_tmp_dir, "") + cfg = config() + assert cfg.process_isolation == ProcessIsolation.FORK + assert cfg.hot_fork_warmup == HotForkWarmup.COLLECT + assert cfg.max_orchestrator_restarts == 3 + assert cfg.preload_modules_file is None + + def test_hot_fork_selected(self, in_tmp_dir: Path): + self._write_pyproject(in_tmp_dir, 'process_isolation = "hot-fork"') + assert config().process_isolation == ProcessIsolation.HOT_FORK + + def test_invalid_process_isolation_raises(self, in_tmp_dir: Path): + self._write_pyproject(in_tmp_dir, 'process_isolation = "bogus"') + with pytest.raises(ValueError, match="Invalid process_isolation value"): + _load_config() + + def test_invalid_hot_fork_warmup_raises(self, in_tmp_dir: Path): + self._write_pyproject(in_tmp_dir, 'hot_fork_warmup = "bogus"') + with pytest.raises(ValueError, match="Invalid hot_fork_warmup value"): + _load_config() + + class TestShouldMutateFile: @staticmethod def _get_config(only_mutate: list[str], do_not_mutate: list[str]) -> Config: @@ -74,6 +103,11 @@ def _get_config(only_mutate: list[str], do_not_mutate: list[str]) -> Config: on_dependency_change="warn", use_git_change_detection=True, process_isolation=ProcessIsolation.FORK, + hot_fork_warmup=HotForkWarmup.COLLECT, + max_orchestrator_restarts=3, + preload_modules_file=None, + log_to_file=False, + log_file_path="mutants/mutmut-debug.log", ) def test_ignores_non_python_files(self): diff --git a/tests/workers/test_isolation.py b/tests/workers/test_isolation.py index 36e20510..72fa1d19 100644 --- a/tests/workers/test_isolation.py +++ b/tests/workers/test_isolation.py @@ -4,7 +4,13 @@ import pytest +from mutmut.configuration import ProcessIsolation +from mutmut.configuration import config +from mutmut.configuration import reset_config +from mutmut.workers.isolation import ForkRunner +from mutmut.workers.isolation import HotForkRunner from mutmut.workers.isolation import OrchestratorCrashError +from mutmut.workers.isolation import get_mutant_runner from mutmut.workers.isolation import run_in_fork from mutmut.workers.isolation import run_in_fork_with_result @@ -177,3 +183,30 @@ def test_can_be_raised_and_caught(self): assert exc_info.value.exit_code == 255 assert exc_info.value.lost_mutants == ["test_mutant"] assert exc_info.value.crash_log == "/tmp/crash.log" + + +class TestGetMutantRunner: + """Tests for the get_mutant_runner factory.""" + + @pytest.fixture + def in_project_dir(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "src").mkdir() + reset_config() + return tmp_path + + def test_selects_fork_by_default(self, in_project_dir): + runner = get_mutant_runner(2) + assert isinstance(runner, ForkRunner) + assert runner.max_workers == 2 + + def test_selects_hot_fork(self, in_project_dir, monkeypatch): + monkeypatch.setattr(config(), "process_isolation", ProcessIsolation.HOT_FORK) + runner = get_mutant_runner(4) + assert isinstance(runner, HotForkRunner) + assert runner.max_workers == 4 + assert runner.max_restarts == config().max_orchestrator_restarts + + def test_rejects_zero_workers(self, in_project_dir): + with pytest.raises(ValueError, match="at least 1"): + get_mutant_runner(0) From 2abd062017e0f078ce3bd7dc73f54256a1bcc24a Mon Sep 17 00:00:00 2001 From: nicklafleur <55208706+nicklafleur@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:32:53 -0400 Subject: [PATCH 7/9] feat(ui): add cache-status and dependency-graph helpers Groundwork for the result browser's dependency visualization and cache tracking. Pure logic, no Textual imports, so it is unit-tested on its own: - models/cache_status.CacheStatus (CACHED/STALE_DEPENDENCY/INVALID) with severity ordering + CACHE_STATUS_EMOJI. - ui/helpers: dependency BFS over state().function_dependencies (expand_changed_functions, get_ordered_upstream_and_downstream_functions, compute_funcs_with_invalid_deps, find_invalid_dependencies) and per-mutant get_cache_status. - format_utils.mangled_name_from_mutant_name + raw_func_name_from_mangled (mangled<->raw name conversion for dependency lookup). - Config.get_effective_dependency_depth() (tracking depth clamped to max_stack_depth), adapted to this branch's -1 sentinel. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mutmut/configuration.py | 13 +++ src/mutmut/models/cache_status.py | 57 ++++++++++ src/mutmut/ui/helpers.py | 181 ++++++++++++++++++++++++++++++ src/mutmut/utils/format_utils.py | 26 +++++ tests/ui/__init__.py | 0 tests/ui/test_helpers.py | 95 ++++++++++++++++ 6 files changed, 372 insertions(+) create mode 100644 src/mutmut/models/cache_status.py create mode 100644 src/mutmut/ui/helpers.py create mode 100644 tests/ui/__init__.py create mode 100644 tests/ui/test_helpers.py diff --git a/src/mutmut/configuration.py b/src/mutmut/configuration.py index 0d612840..d21382cb 100644 --- a/src/mutmut/configuration.py +++ b/src/mutmut/configuration.py @@ -266,6 +266,19 @@ def _hash(value: object) -> str: "type_check": _hash(tuple(self.type_check_command)), } + def get_effective_dependency_depth(self) -> int: + """The dependency-tracking depth clamped to ``max_stack_depth``. + + ``-1`` means "unlimited/unset" for both fields. When the tracking depth + is unset it falls back to the stack depth; otherwise it is the smaller + of the two (a stack depth of ``-1`` imposes no clamp). + """ + if self.dependency_tracking_depth == -1: + return self.max_stack_depth + if self.max_stack_depth == -1: + return self.dependency_tracking_depth + return min(self.dependency_tracking_depth, self.max_stack_depth) + def should_mutate(self, path: Path | str) -> bool: return self._should_include_for_mutation(path) and not self._should_ignore_for_mutation(path) diff --git a/src/mutmut/models/cache_status.py b/src/mutmut/models/cache_status.py new file mode 100644 index 00000000..00c9bb16 --- /dev/null +++ b/src/mutmut/models/cache_status.py @@ -0,0 +1,57 @@ +"""Cache status model for mutation testing results. + +Defines the CacheStatus enum used to indicate whether a cached mutant result is +still valid, stale because a dependency changed, or invalid because the mutated +function itself changed. +""" + +from __future__ import annotations + +from enum import Enum + + +class CacheStatus(str, Enum): + """Validity of a cached mutant result.""" + + CACHED = "cached" # tested, function unchanged + STALE_DEPENDENCY = "stale" # function unchanged but a dependency changed + INVALID = "invalid" # function changed (or untested), needs retest + + def __str__(self) -> str: + return self.value + + def _severity(self) -> int: + """Severity order: CACHED < STALE_DEPENDENCY < INVALID.""" + order = [CacheStatus.CACHED, CacheStatus.STALE_DEPENDENCY, CacheStatus.INVALID] + return order.index(self) + + def __lt__(self, other: object) -> bool: + if not isinstance(other, CacheStatus): + return NotImplemented + return self._severity() < other._severity() + + def __le__(self, other: object) -> bool: + if not isinstance(other, CacheStatus): + return NotImplemented + return self._severity() <= other._severity() + + def __gt__(self, other: object) -> bool: + if not isinstance(other, CacheStatus): + return NotImplemented + return self._severity() > other._severity() + + def __ge__(self, other: object) -> bool: + if not isinstance(other, CacheStatus): + return NotImplemented + return self._severity() >= other._severity() + + def worst(self, other: CacheStatus) -> CacheStatus: + """Return the worse (higher severity) of two statuses.""" + return self if self._severity() > other._severity() else other + + +CACHE_STATUS_EMOJI: dict[CacheStatus, str] = { + CacheStatus.CACHED: "✓", + CacheStatus.STALE_DEPENDENCY: "⚠️", + CacheStatus.INVALID: "🚫", +} diff --git a/src/mutmut/ui/helpers.py b/src/mutmut/ui/helpers.py new file mode 100644 index 00000000..4f4d8205 --- /dev/null +++ b/src/mutmut/ui/helpers.py @@ -0,0 +1,181 @@ +"""Dependency-graph and cache-status helpers for the result browser. + +These are pure functions (no Textual imports) so they can be unit-tested in +isolation from the UI. The dependency graph they operate on is +``state().function_dependencies``: a mapping of callee -> set of callers, keyed +by mangled names. +""" + +from __future__ import annotations + +from mutmut.models.cache_status import CacheStatus +from mutmut.utils.format_utils import mangled_name_from_mutant_name +from mutmut.utils.format_utils import raw_func_name_from_mangled + + +def expand_changed_functions(changed: set[str], deps: dict[str, set[str]]) -> set[str]: + """Transitively expand changed functions to include all their callers. + + Walks backwards through the dependency graph: if ``baz`` changed and the + chain is ``test -> foo -> bar -> baz``, returns ``{baz, bar, foo, test}``. + + Args: + changed: Function names (any naming scheme) that changed directly. + deps: Dependency graph mapping callee -> set of callers. + + Returns: + The changed set plus every function that transitively depends on it. + """ + result = set(changed) + queue = list(changed) + + while queue: + func = queue.pop() + for caller in deps.get(func, set()): + if caller not in result: + result.add(caller) + queue.append(caller) + return result + + +def get_ordered_upstream_and_downstream_functions( + raw_func_name: str, raw_deps: dict[str, set[str]], max_depth: int = 1 +) -> tuple[list[tuple[str, int]], list[tuple[str, int]]]: + """Get the upstream (callers) and downstream (callees) of a function. + + Args: + raw_func_name: The raw function name to expand around. + raw_deps: Dependency graph (callee -> callers) keyed by raw names. + max_depth: Maximum expansion depth (<= 0 means unlimited). + + Returns: + ``(upstreams, downstreams)``, each a list of ``(name, depth)`` sorted by depth. + """ + up_queue = [(raw_func_name, 0)] + upstreams: dict[str, int] = {} + + while up_queue: + func, depth = up_queue.pop() + for caller in raw_deps.get(func, set()): + if caller == raw_func_name: + continue + if caller not in upstreams: + upstreams[caller] = depth + 1 + if max_depth <= 0 or depth + 1 < max_depth: + up_queue.append((caller, depth + 1)) + + upstreams_sorted = sorted(upstreams.items(), key=lambda x: x[1]) + + down_queue = [(raw_func_name, 0)] + downstreams: dict[str, int] = {} + + while down_queue: + func, depth = down_queue.pop() + for callee, callers in raw_deps.items(): + if callee == raw_func_name: + continue + if func in callers and callee not in downstreams: + downstreams[callee] = depth + 1 + if max_depth <= 0 or depth + 1 < max_depth: + down_queue.append((callee, depth + 1)) + + downstreams_sorted = sorted(downstreams.items(), key=lambda x: x[1]) + + return upstreams_sorted, downstreams_sorted + + +def _raw_deps_from(deps: dict[str, set[str]]) -> dict[str, set[str]]: + """Convert a mangled callee->callers graph to raw (canonical) names.""" + raw_deps: dict[str, set[str]] = {} + for callee, callers in deps.items(): + raw_callee = raw_func_name_from_mangled(callee) + raw_deps.setdefault(raw_callee, set()) + for caller in callers: + raw_deps[raw_callee].add(raw_func_name_from_mangled(caller)) + return raw_deps + + +def compute_funcs_with_invalid_deps(invalid_raw_funcs: set[str], deps: dict[str, set[str]]) -> set[str]: + """Functions that transitively depend on an invalid function. + + Computed once at load time (rather than per-row) to keep the UI responsive. + The invalid functions themselves are excluded from the result, so a tested + mutant still shows CACHED even when a sibling mutant of the same function is + untested. + + Args: + invalid_raw_funcs: Raw names of functions with invalid mutants. + deps: Original mangled callee -> callers graph. + + Returns: + Raw names of functions that CALL (depend on) any invalid function. + """ + if not invalid_raw_funcs or not deps: + return set() + + raw_deps = _raw_deps_from(deps) + all_affected = expand_changed_functions(invalid_raw_funcs, raw_deps) + return all_affected - invalid_raw_funcs + + +def get_cache_status( + mutant_name: str, + exit_code: int | None, + funcs_with_invalid_deps: set[str], +) -> CacheStatus: + """Determine the cache status for a single mutant. + + Returns INVALID when untested (``exit_code is None``), STALE_DEPENDENCY when + the function is unchanged but a dependency changed, else CACHED. + """ + if exit_code is None: + return CacheStatus.INVALID + + if not funcs_with_invalid_deps: + return CacheStatus.CACHED + + raw_func_name = raw_func_name_from_mangled(mangled_name_from_mutant_name(mutant_name)) + + if raw_func_name in funcs_with_invalid_deps: + return CacheStatus.STALE_DEPENDENCY + + return CacheStatus.CACHED + + +def find_invalid_dependencies( + raw_func_name: str, + invalid_raw_funcs: set[str], + deps: dict[str, set[str]], +) -> set[str]: + """Which invalid functions ``raw_func_name`` transitively calls. + + Builds a caller -> callees graph from ``deps`` and walks forward, returning + the reachable functions that are in ``invalid_raw_funcs``. + """ + if not invalid_raw_funcs or not deps: + return set() + + callees_by_caller: dict[str, set[str]] = {} + for callee, callers in deps.items(): + raw_callee = raw_func_name_from_mangled(callee) + for caller in callers: + raw_caller = raw_func_name_from_mangled(caller) + callees_by_caller.setdefault(raw_caller, set()).add(raw_callee) + + visited: set[str] = set() + queue = [raw_func_name] + invalid_deps: set[str] = set() + + while queue: + func = queue.pop() + if func in visited: + continue + visited.add(func) + + for callee in callees_by_caller.get(func, set()): + if callee in invalid_raw_funcs: + invalid_deps.add(callee) + if callee not in visited: + queue.append(callee) + + return invalid_deps diff --git a/src/mutmut/utils/format_utils.py b/src/mutmut/utils/format_utils.py index 19b82ae4..deb79ca8 100644 --- a/src/mutmut/utils/format_utils.py +++ b/src/mutmut/utils/format_utils.py @@ -58,6 +58,32 @@ def get_module_from_key(key: str) -> str: return key.rsplit(".", 1)[0] if "." in key else key +def mangled_name_from_mutant_name(mutant_name: str) -> str: + """Strip the ``__mutmut_`` suffix off a mutant key. + + ``module.x_foo__mutmut_1`` -> ``module.x_foo``. + """ + assert "__mutmut_" in mutant_name, mutant_name + return mutant_name.partition("__mutmut_")[0] + + +def raw_func_name_from_mangled(mangled: str) -> str: + """Convert a mangled name to its raw (canonical) form. + + ``module.x_funcname`` -> ``module.funcname`` and, for methods, + ``module.xǁClassǁmethod`` -> ``module.Class.method``. Used to key the + dependency graph by human-readable names. + """ + module_part, _, func_part = mangled.rpartition(".") + if CLASS_NAME_SEPARATOR in func_part: + class_name = func_part[func_part.index(CLASS_NAME_SEPARATOR) + 1 : func_part.rindex(CLASS_NAME_SEPARATOR)] + method_name = func_part[func_part.rindex(CLASS_NAME_SEPARATOR) + 1 :] + func_part = f"{class_name}.{method_name}" + elif func_part.startswith("x_"): + func_part = func_part[2:] + return f"{module_part}.{func_part}" if module_part else func_part + + def get_mutant_name(relative_source_path: Path, mutant_method_name: str) -> str: module_name = str(relative_source_path)[: -len(relative_source_path.suffix)].replace(os.sep, ".") module_name = strip_prefix(module_name, prefix="src.") diff --git a/tests/ui/__init__.py b/tests/ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ui/test_helpers.py b/tests/ui/test_helpers.py new file mode 100644 index 00000000..6d61848d --- /dev/null +++ b/tests/ui/test_helpers.py @@ -0,0 +1,95 @@ +"""Tests for the browser dependency/cache-status helpers.""" + +from mutmut.models.cache_status import CacheStatus +from mutmut.ui.helpers import compute_funcs_with_invalid_deps +from mutmut.ui.helpers import expand_changed_functions +from mutmut.ui.helpers import find_invalid_dependencies +from mutmut.ui.helpers import get_cache_status +from mutmut.ui.helpers import get_ordered_upstream_and_downstream_functions + +# Call chain test -> a -> b -> c, expressed as callee -> callers (mangled names). +MANGLED_DEPS = { + "m.x_c": {"m.x_b"}, + "m.x_b": {"m.x_a"}, +} +# Same graph with raw names. +RAW_DEPS = { + "m.c": {"m.b"}, + "m.b": {"m.a"}, +} + + +class TestExpandChangedFunctions: + def test_walks_callers_transitively(self): + assert expand_changed_functions({"m.c"}, RAW_DEPS) == {"m.c", "m.b", "m.a"} + + def test_leaf_change_only_itself(self): + assert expand_changed_functions({"m.a"}, RAW_DEPS) == {"m.a"} + + def test_empty(self): + assert expand_changed_functions(set(), RAW_DEPS) == set() + + +class TestComputeFuncsWithInvalidDeps: + def test_excludes_the_invalid_function_itself(self): + result = compute_funcs_with_invalid_deps({"m.c"}, MANGLED_DEPS) + assert result == {"m.b", "m.a"} + + def test_empty_inputs(self): + assert compute_funcs_with_invalid_deps(set(), MANGLED_DEPS) == set() + assert compute_funcs_with_invalid_deps({"m.c"}, {}) == set() + + +class TestGetCacheStatus: + def test_untested_is_invalid(self): + assert get_cache_status("m.x_c__mutmut_1", None, set()) == CacheStatus.INVALID + + def test_no_invalid_deps_is_cached(self): + assert get_cache_status("m.x_b__mutmut_1", 0, set()) == CacheStatus.CACHED + + def test_function_with_invalid_dep_is_stale(self): + assert get_cache_status("m.x_b__mutmut_1", 0, {"m.b", "m.a"}) == CacheStatus.STALE_DEPENDENCY + + def test_function_not_in_invalid_deps_is_cached(self): + assert get_cache_status("m.x_z__mutmut_1", 0, {"m.b", "m.a"}) == CacheStatus.CACHED + + +class TestFindInvalidDependencies: + def test_forward_walk_finds_invalid_callee(self): + assert find_invalid_dependencies("m.a", {"m.c"}, MANGLED_DEPS) == {"m.c"} + + def test_no_invalid_deps_when_none_reachable(self): + assert find_invalid_dependencies("m.c", {"m.c"}, MANGLED_DEPS) == set() + + def test_empty_inputs(self): + assert find_invalid_dependencies("m.a", set(), MANGLED_DEPS) == set() + + +class TestUpstreamDownstream: + def test_one_level(self): + up, down = get_ordered_upstream_and_downstream_functions("m.b", RAW_DEPS, max_depth=1) + assert up == [("m.a", 1)] + assert down == [("m.c", 1)] + + def test_full_depth_expands_transitively(self): + up, down = get_ordered_upstream_and_downstream_functions("m.b", RAW_DEPS, max_depth=0) + # b's only caller is a (upstream); b's only callee is c (downstream). + assert up == [("m.a", 1)] + assert down == [("m.c", 1)] + + def test_middle_of_longer_chain(self): + # test -> a -> b -> c -> d : from c, upstream = b,a ; downstream = d + raw = {"m.b": {"m.a"}, "m.c": {"m.b"}, "m.d": {"m.c"}} + up, down = get_ordered_upstream_and_downstream_functions("m.c", raw, max_depth=0) + assert up == [("m.b", 1), ("m.a", 2)] + assert down == [("m.d", 1)] + + +class TestCacheStatusOrdering: + def test_severity_ordering(self): + assert CacheStatus.CACHED < CacheStatus.STALE_DEPENDENCY < CacheStatus.INVALID + + def test_worst(self): + assert CacheStatus.CACHED.worst(CacheStatus.INVALID) == CacheStatus.INVALID + assert CacheStatus.STALE_DEPENDENCY.worst(CacheStatus.CACHED) == CacheStatus.STALE_DEPENDENCY + assert CacheStatus.INVALID.worst(CacheStatus.STALE_DEPENDENCY) == CacheStatus.INVALID From a9c012ab2cdad680b94bd1c8849309d771b4672a Mon Sep 17 00:00:00 2001 From: nicklafleur <55208706+nicklafleur@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:42:58 -0400 Subject: [PATCH 8/9] feat(ui): dependency visualization + cache tracking in result browser Layer the dependency/cache features onto the #543 browser (keeping its @work/Lock/get_current_worker diff loader untouched): - A two-column upstream(callers)/downstream(callees) dependencies table shown beside the diff (3:2), populated by BFS over state().function_dependencies. - A cache-status column on both the files and mutants tables (CACHED/STALE/ INVALID) computed from per-function hash changes + invalid-dependency propagation; the files table rolls up to its worst mutant. - A depth toggle (v) cycling 1-lvl / configured-depth / full, with per-(func, depth) BFS memoization. - Retest-invalid-dependencies (d) and, to make the browser's 'g' binding real, a new `generate` CLI command that regenerates mutants and refreshes hashes/ stats without running the mutation loop (`--no-invalidate-callers` keeps the caller edges). The generation prefix of `_run` is extracted into a shared `_generate_mutants_and_collect_stats` helper so both commands stay in sync. Kept this branch's string-based status handling (status_by_exit_code/ emoji_by_status) rather than pulling in the reference's MutantStatus enum, so the diff stays confined to the new features. Validated: headless Pilot mount test (files/mutants/deps tables populate, cache column present, depth toggle works) + a subprocess `generate` e2e. Full suite 399 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mutmut/__main__.py | 49 +++++- src/mutmut/ui/browse.py | 209 ++++++++++++++++++++--- src/mutmut/ui/result_browser_layout.tcss | 22 ++- tests/e2e/test_e2e_browser.py | 123 +++++++++++++ tests/e2e/test_e2e_generate.py | 48 ++++++ 5 files changed, 427 insertions(+), 24 deletions(-) create mode 100644 tests/e2e/test_e2e_browser.py create mode 100644 tests/e2e/test_e2e_generate.py diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index fb753258..8ee363f8 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -935,10 +935,36 @@ def run(mutant_names: tuple[str, ...] | list[str], *, max_children: int | None) _run(mutant_names, max_children) -# separate function, so we can call it directly from the tests -def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) -> None: - # TODO: run no-ops once in a while to detect if we get false negatives - # 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! +@cli.command() +@click.option( + "--no-invalidate-callers", + is_flag=True, + default=False, + help="Keep dependency caller edges instead of pruning ones whose callee changed.", +) +def generate(no_invalidate_callers: bool) -> None: + """Regenerate mutants and refresh hashes/stats without running the mutation tests.""" + _generate_mutants_and_collect_stats([], None, invalidate_stale_callers=not no_invalidate_callers) + print("Mutants generated. Run 'mutmut run' to test them, or 'mutmut browse' to view results.") + + +def _generate_mutants_and_collect_stats( + mutant_names: tuple[str, ...] | list[str], + max_children: int | None, + *, + invalidate_stale_callers: bool = True, +) -> tuple[ + MutantRunner, + dict[str, FailedTypeCheckMutant], + list[tuple[SourceFileMutationData, str, int | None]], + dict[str, SourceFileMutationData], +]: + """Generate mutants, type-check-filter them, and (re)collect stats. + + Shared prefix of ``run`` and ``generate``: everything up to but not including + the clean-test / forced-fail / mutation-testing loop. Returns the runner, the + type-check verdicts, and the collected mutant list + per-path mutation data. + """ os.environ["MUTANT_UNDER_TEST"] = "mutant_generation" if max_children is None: @@ -972,12 +998,27 @@ def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) -> runner, mutants_caught_by_type_checker=mutants_caught_by_type_checker, apply_config_invalidation=True, + invalidate_stale_callers=invalidate_stale_callers, ) mutants, source_file_mutation_data_by_path = collect_source_file_mutation_data(mutant_names=mutant_names) _check_test_to_mutant_associations(source_file_mutation_data_by_path) + return runner, mutants_caught_by_type_checker, mutants, source_file_mutation_data_by_path + + +# separate function, so we can call it directly from the tests +def _run(mutant_names: tuple[str, ...] | list[str], max_children: int | None) -> None: + # TODO: run no-ops once in a while to detect if we get false negatives + # 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! + ( + runner, + mutants_caught_by_type_checker, + mutants, + source_file_mutation_data_by_path, + ) = _generate_mutants_and_collect_stats(mutant_names, max_children) + os.environ["MUTANT_UNDER_TEST"] = "" with CatchOutput(spinner_title="Running clean tests") as output_catcher: tests = tests_for_mutant_names(mutant_names) diff --git a/src/mutmut/ui/browse.py b/src/mutmut/ui/browse.py index 32952eb2..37613f9a 100644 --- a/src/mutmut/ui/browse.py +++ b/src/mutmut/ui/browse.py @@ -10,12 +10,29 @@ from rich.text import Text +from mutmut.configuration import config +from mutmut.models.cache_status import CACHE_STATUS_EMOJI +from mutmut.models.cache_status import CacheStatus from mutmut.mutation.data import SourceFileMutationData +from mutmut.mutation.file_mutation import compute_function_hashes +from mutmut.state import state from mutmut.stats import Stat from mutmut.stats import collect_stat from mutmut.stats import emoji_by_status +from mutmut.stats import load_stats from mutmut.stats import status_by_exit_code +from mutmut.ui.helpers import compute_funcs_with_invalid_deps +from mutmut.ui.helpers import find_invalid_dependencies +from mutmut.ui.helpers import get_cache_status +from mutmut.ui.helpers import get_ordered_upstream_and_downstream_functions from mutmut.utils.file_utils import walk_mutatable_files +from mutmut.utils.format_utils import mangled_name_from_mutant_name +from mutmut.utils.format_utils import raw_func_name_from_mangled + + +def _run_browser_app(app: Any) -> None: # pragma: no cover - trivial passthrough, overridden in tests + """Run the Textual app. Split out so tests can drive it headless.""" + app.run() def run_result_browser( @@ -48,16 +65,22 @@ class ResultBrowser(App[None]): ("q", "quit()", "Quit"), ("r", "retest_mutant()", "Retest mutant"), ("f", "retest_function()", "Retest function"), + ("d", "retest_dependencies()", "Test invalid deps"), ("m", "retest_module()", "Retest module"), ("a", "apply_mutant()", "Apply mutant to disk"), ("t", "view_tests()", "View tests for mutant"), + ("v", "toggle_dep_level()", "Toggle dep. level"), + ("g", "generate()", "Generate mutants"), ] - columns = [ + columns: list[tuple[str, str | Text]] = [ # type: ignore[assignment] ("path", "Path"), + ("cache", Text("Cache", justify="right")), ] + [(status, Text(emoji, justify="right")) for status, emoji in emoji_by_status.items()] cursor_type = "row" + deps_available = False + dep_level_index = 0 source_file_mutation_data_and_stat_by_path: dict[str, tuple[SourceFileMutationData, Stat]] = {} path_by_name: dict[str, Path] = {} diff_load_lock = Lock() @@ -65,10 +88,13 @@ class ResultBrowser(App[None]): def compose(self) -> Iterable[Any]: with Container(classes="container"): yield DataTable(id="files") - yield DataTable(id="mutants") - with Widget(id="diff_view_widget"): - yield Static(id="description") - yield Static(id="diff_view") + with Container(id="mutants_container"): + yield DataTable(id="mutants") + with Container(id="depth_options"): + with Widget(id="diff_view_widget"): + yield Static(id="description") + yield Static(id="diff_view") + yield DataTable(id="dependencies") yield Footer() def on_mount(self) -> None: @@ -81,14 +107,41 @@ def on_mount(self) -> None: # noinspection PyTypeChecker mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] mutants_table.cursor_type = "row" - mutants_table.add_columns("name", "status") + mutants_table.add_columns("name", "status", "cache") + + configured_depth = config().get_effective_dependency_depth() + if configured_depth in (-1, 0, 1): + self._depth_options: list[tuple[int, str]] = [(1, "1-lvl"), (0, "full")] + else: + self._depth_options = [(1, "1-lvl"), (configured_depth, f"{configured_depth}-lvl"), (0, "full")] + + # noinspection PyTypeChecker + deps_table: DataTable[Any] = self.query_one("#dependencies") # type: ignore[assignment] + deps_table.show_header = True + deps_table.show_cursor = False + deps_table.add_columns("↑ Upstream", "↓ Downstream") self.read_data() self.populate_files_table() def read_data(self) -> None: self.source_file_mutation_data_and_stat_by_path = {} - self.path_by_name: dict[str, Path] = {} + self.path_by_name = {} + self.cached_by_path: dict[str, CacheStatus] = {} + + self.deps_available = load_stats() + + # Hash the current source so we can spot functions whose logic changed + # since the mutants were generated (their cached verdicts are stale). + fresh_hashes: dict[str, str] = {} + for p in walk_mutatable_files(): + try: + fresh_hashes.update(compute_function_hashes(p.read_text())) + except (OSError, UnicodeDecodeError): + continue + + self.invalid_raw_funcs: set[str] = set() + self.hash_changed_mutants: set[str] = set() for p in walk_mutatable_files(): source_file_mutation_data = SourceFileMutationData(path=p) @@ -96,8 +149,45 @@ def read_data(self) -> None: stat = collect_stat(source_file_mutation_data) self.source_file_mutation_data_and_stat_by_path[str(p)] = source_file_mutation_data, stat - for name in source_file_mutation_data.exit_code_by_key: + for name, exit_code in source_file_mutation_data.exit_code_by_key.items(): self.path_by_name[name] = p + raw_name = raw_func_name_from_mangled(mangled_name_from_mutant_name(name)) + + if exit_code is None: + self.invalid_raw_funcs.add(raw_name) + elif fresh_hashes: + mangled = mangled_name_from_mutant_name(name) + func_name = mangled.rsplit(".", 1)[-1] if "." in mangled else mangled + stored_hash = source_file_mutation_data.hash_by_function_name.get(func_name) + current_hash = fresh_hashes.get(func_name) + if stored_hash and current_hash and stored_hash != current_hash: + self.invalid_raw_funcs.add(raw_name) + self.hash_changed_mutants.add(name) + + if self.deps_available: + self.funcs_with_invalid_deps = compute_funcs_with_invalid_deps( + self.invalid_raw_funcs, state().function_dependencies + ) + self._raw_deps: dict[str, set[str]] = {} + for callee, callers in state().function_dependencies.items(): + raw_callee = raw_func_name_from_mangled(callee) + self._raw_deps.setdefault(raw_callee, set()) + for caller in callers: + self._raw_deps[raw_callee].add(raw_func_name_from_mangled(caller)) + else: + self.funcs_with_invalid_deps = set() + self._raw_deps = {} + + self._deps_cache: dict[tuple[str, int], tuple[list[tuple[str, int]], list[tuple[str, int]]]] = {} + + for path_str, (source_file_mutation_data, _) in self.source_file_mutation_data_and_stat_by_path.items(): + worst = CacheStatus.CACHED + for name, exit_code in source_file_mutation_data.exit_code_by_key.items(): + effective_exit_code = None if name in self.hash_changed_mutants else exit_code + worst = worst.worst(get_cache_status(name, effective_exit_code, self.funcs_with_invalid_deps)) + if worst == CacheStatus.INVALID: + break + self.cached_by_path[path_str] = worst def populate_files_table(self) -> None: # noinspection PyTypeChecker @@ -107,25 +197,36 @@ def populate_files_table(self) -> None: files_table.clear() for p, (source_file_mutation_data, stat) in sorted(self.source_file_mutation_data_and_stat_by_path.items()): - row = [p] + [ - Text(str(getattr(stat, k.replace(" ", "_"))), justify="right") for k, _ in self.columns[1:] - ] + cached_status = self.cached_by_path.get(p, CacheStatus.CACHED) + row = ( + [p] + + [CACHE_STATUS_EMOJI[cached_status]] + + [Text(str(getattr(stat, k.replace(" ", "_"))), justify="right") for k, _ in self.columns[2:]] + ) files_table.add_row(*row, key=str(p)) files_table.move_cursor(row=selected_row) + def _mutant_row_visible(self, status: str, validity: CacheStatus) -> bool: + return status not in ("killed", "caught by type check") or show_killed or validity != CacheStatus.CACHED + + def _populate_mutants_table(self, source_file_mutation_data: SourceFileMutationData) -> None: + # noinspection PyTypeChecker + mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] + mutants_table.clear() + for k, v in source_file_mutation_data.exit_code_by_key.items(): + status = status_by_exit_code[v] + effective_exit_code = None if k in self.hash_changed_mutants else v + validity = get_cache_status(k, effective_exit_code, self.funcs_with_invalid_deps) + if self._mutant_row_visible(status, validity): + mutants_table.add_row(k, emoji_by_status[status], CACHE_STATUS_EMOJI[validity], key=k) + def on_data_table_row_highlighted(self, event: Any) -> None: if not event.row_key or not event.row_key.value: return if event.data_table.id == "files": - # noinspection PyTypeChecker - mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] - mutants_table.clear() source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[event.row_key.value] - for k, v in source_file_mutation_data.exit_code_by_key.items(): - status = status_by_exit_code[v] - if status not in ("killed", "caught by type check") or show_killed: - mutants_table.add_row(k, emoji_by_status[status], key=k) + self._populate_mutants_table(source_file_mutation_data) else: assert event.data_table.id == "mutants" # noinspection PyTypeChecker @@ -174,6 +275,8 @@ def on_data_table_row_highlighted(self, event: Any) -> None: description = f"Unknown status ({exit_code=}, {status=})" description_view.update(f"\n {description}\n") + self._update_dependencies_display(mutant_name) + diff_view: Static = self.query_one("#diff_view") # type: ignore[assignment] diff_view.update("") @@ -202,6 +305,40 @@ def load_diff(self, mutant_name: str, path: Path | None, diff_view: Static) -> N self.call_from_thread(diff_view.update, update) + def _update_dependencies_display(self, mutant_name: str) -> None: + """Refresh the two-column upstream/downstream dependencies table.""" + # noinspection PyTypeChecker + deps_table: DataTable[Any] = self.query_one("#dependencies") # type: ignore[assignment] + deps_table.clear() + + if not self.deps_available: + return + + raw_func_name = raw_func_name_from_mangled(mangled_name_from_mutant_name(mutant_name)) + max_depth, _ = self._depth_options[self.dep_level_index] + + cache_key = (raw_func_name, max_depth) + if cache_key in self._deps_cache: + upstreams, downstreams = self._deps_cache[cache_key] + else: + upstreams, downstreams = get_ordered_upstream_and_downstream_functions( + raw_func_name, self._raw_deps, max_depth=max_depth + ) + self._deps_cache[cache_key] = (upstreams, downstreams) + + for i in range(max(len(upstreams), len(downstreams))): + up_name, up_depth = upstreams[i] if i < len(upstreams) else ("", "") + down_name, down_depth = downstreams[i] if i < len(downstreams) else ("", "") + deps_table.add_row(f"{up_depth} {up_name}".strip(), f"{down_depth} {down_name}".strip()) + + def action_toggle_dep_level(self) -> None: + self.dep_level_index = (self.dep_level_index + 1) % len(self._depth_options) + _, mode_label = self._depth_options[self.dep_level_index] + self.notify(f"Dependency depth: {mode_label}", severity="information") + mutant_name = self.get_mutant_name_from_selection() + if mutant_name is not None: + self._update_dependencies_display(mutant_name) + def retest(self, pattern: str | None) -> None: if pattern is None: return @@ -246,6 +383,36 @@ def action_retest_module(self) -> None: if name is not None: self.retest(name.rpartition(".")[0] + ".*") + def action_retest_dependencies(self) -> None: + """Retest the invalid functions the selected mutant transitively depends on.""" + if not self.deps_available: + self.notify( + "Dependency tracking not available. Run mutmut with track_dependencies=true first.", + severity="warning", + ) + return + + mutant_name = self.get_mutant_name_from_selection() + if mutant_name is None: + return + + raw_func_name = raw_func_name_from_mangled(mangled_name_from_mutant_name(mutant_name)) + invalid_deps = find_invalid_dependencies( + raw_func_name, self.invalid_raw_funcs, state().function_dependencies + ) + + if not invalid_deps: + self.notify("No invalid dependencies found for this function.", severity="information") + return + + patterns = [] + for dep in invalid_deps: + module_part, _, func_part = dep.rpartition(".") + patterns.append(f"{module_part}.x_{func_part}__mutmut_*" if module_part else f"x_{func_part}__mutmut_*") + + self.notify(f"Testing {len(invalid_deps)} invalid dependencies...", severity="information") + self._run_subprocess_command("run", patterns) + def action_apply_mutant(self) -> None: # noinspection PyTypeChecker mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment] @@ -258,4 +425,8 @@ def action_view_tests(self) -> None: if name is not None: self.view_tests(name) - ResultBrowser().run() + def action_generate(self) -> None: + """Regenerate mutants and refresh hashes without running any tests.""" + self._run_subprocess_command("generate", ["--no-invalidate-callers"]) + + _run_browser_app(ResultBrowser()) diff --git a/src/mutmut/ui/result_browser_layout.tcss b/src/mutmut/ui/result_browser_layout.tcss index 6aac6aa2..ae87dd6c 100644 --- a/src/mutmut/ui/result_browser_layout.tcss +++ b/src/mutmut/ui/result_browser_layout.tcss @@ -6,6 +6,25 @@ Screen { height: 50%; } +#mutants_container { + layout: vertical; + width: 1fr; +} + +#mutants { + height: 1fr; +} + +#depth_options { + layout: horizontal; + height: 50%; +} + +#dependencies { + width: 2fr; + height: 100%; + color: #808080; +} DataTable { color: #c0c0c0; @@ -26,7 +45,8 @@ DataTable:focus .datatable--cursor { } #diff_view_widget { - height: 50%; + width: 3fr; + height: 100%; overflow-y: scroll; } diff --git a/tests/e2e/test_e2e_browser.py b/tests/e2e/test_e2e_browser.py new file mode 100644 index 00000000..623f24f3 --- /dev/null +++ b/tests/e2e/test_e2e_browser.py @@ -0,0 +1,123 @@ +"""End-to-end test for the result browser's dependency/cache UI. + +Builds mutants for a tiny project in-process, then drives the Textual app +headless via Pilot to verify it mounts and wires up the new dependency table, +cache column, and depth toggle. +""" + +import asyncio +import textwrap +from pathlib import Path + +import pytest + +import mutmut +import mutmut.ui.browse as browse +from mutmut.__main__ import _run +from mutmut.__main__ import apply_mutant +from mutmut.__main__ import get_diff_for_mutant +from mutmut.configuration import reset_config +from tests.e2e.e2e_utils import change_cwd + + +def _write_project(root: Path) -> None: + (root / "src" / "calc").mkdir(parents=True) + (root / "tests").mkdir() + (root / "src" / "calc" / "__init__.py").write_text( + textwrap.dedent( + """ + def add(a, b): + return a + b + + def double(x): + return add(x, x) + + def untested(x): + return x - 1 + """ + ).lstrip() + ) + (root / "tests" / "test_calc.py").write_text( + textwrap.dedent( + """ + from calc import add, double + + def test_add(): + assert add(1, 2) == 3 + + def test_double(): + assert double(4) == 8 + """ + ).lstrip() + ) + (root / "pyproject.toml").write_text( + textwrap.dedent( + """ + [project] + name = "calc" + version = "0.1.0" + requires-python = ">=3.10" + + [tool.mutmut] + track_dependencies = true + + [tool.pytest.ini_options] + asyncio_default_fixture_loop_scope = "function" + """ + ).lstrip() + ) + + +def test_browser_mounts_with_dependency_and_cache_ui(tmp_path: Path): + project = tmp_path / "calc_proj" + project.mkdir() + _write_project(project) + + captured: dict = {} + + def fake_runner(app): + async def drive(): + async with app.run_test() as pilot: + await pilot.pause() + files = app.query_one("#files") + deps = app.query_one("#dependencies") + captured["files_rows"] = files.row_count + captured["cache_col"] = "cache" in [k.value for k in files.columns] + captured["deps_cols"] = len(deps.columns) + + app.set_focus(files) + await pilot.pause() + captured["mutants_rows"] = app.query_one("#mutants").row_count + + # The depth toggle must not raise. + await pilot.press("v") + await pilot.pause() + captured["ok"] = True + + asyncio.run(drive()) + + with change_cwd(project): + mutmut._reset_globals() + reset_config() + _run([], None) + + original = browse._run_browser_app + browse._run_browser_app = fake_runner + try: + browse.run_result_browser( + show_killed=True, + get_diff_for_mutant=get_diff_for_mutant, + apply_mutant=apply_mutant, + ) + finally: + browse._run_browser_app = original + + assert captured.get("ok"), "browser did not mount" + assert captured["files_rows"] > 0 + assert captured["cache_col"], "files table missing the cache column" + assert captured["deps_cols"] == 2, "dependencies table should have upstream + downstream columns" + assert captured["mutants_rows"] > 0 + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/tests/e2e/test_e2e_generate.py b/tests/e2e/test_e2e_generate.py new file mode 100644 index 00000000..6a987949 --- /dev/null +++ b/tests/e2e/test_e2e_generate.py @@ -0,0 +1,48 @@ +"""End-to-end test for the `generate` command. + +`generate` regenerates mutants and refreshes hashes/stats without running the +mutation-testing loop. It is exercised in a subprocess (with a timeout) so any +signal/fork interaction stays out of the pytest session. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +E2E_PROJECTS = REPO_ROOT / "e2e_projects" + + +def test_generate_creates_mutant_metadata(tmp_path: Path): + project = tmp_path / "hot_fork_basic" + shutil.copytree(E2E_PROJECTS / "hot_fork_basic", project) + # Use the default fork isolation for this smoke test. + pyproject = project / "pyproject.toml" + pyproject.write_text(pyproject.read_text().replace('process_isolation = "hot-fork"\n', "")) + + result = subprocess.run( + [sys.executable, "-m", "mutmut", "generate", "--no-invalidate-callers"], + cwd=project, + capture_output=True, + text=True, + timeout=180, + ) + + assert result.returncode == 0, f"mutmut generate failed:\n{result.stdout}\n{result.stderr}" + + metas = list((project / "mutants").rglob("*.meta")) + assert metas, "generate did not produce any mutant metadata" + + # generate must populate mutant keys and per-function hashes, without running + # the mutation loop (so verdicts stay uncommitted / None here). + data = json.loads(metas[0].read_text()) + assert data["exit_code_by_key"], "no mutants were recorded" + assert data["hash_by_function_name"], "function hashes were not written" + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) From 06ce4cf729870e7b939730f5a3169b78953abce0 Mon Sep 17 00:00:00 2001 From: nicklafleur <55208706+nicklafleur@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:01:09 -0400 Subject: [PATCH 9/9] refactor: relocate name-formatting utils into format_utils Consolidate the mangled-name helpers into `utils/format_utils.py` so they live next to the other key/name utilities and can be imported without pulling in `__main__` or `trampoline_templates`: - Move `CLASS_NAME_SEPARATOR` + `mangle_function_name` out of `mutation/trampoline_templates.py` (which no longer referenced them beyond the definition) into `format_utils`; `file_mutation` now imports `mangle_function_name` from there. - Move `mangled_name_from_mutant_name` + `orig_function_and_class_names_from_key` out of `__main__` into `format_utils`, removing the temporary `mangled_name_from_mutant_name` duplicate introduced with the browser helpers. `__main__` re-imports both (so `from mutmut.__main__ import ...` still works), and `mutation/trampoline.py` now imports the helper from `format_utils`, dropping one `__main__` import cycle. `format_utils` is now a leaf module (stdlib only). Pure move, no behavior change; full suite is green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mutmut/__main__.py | 21 ++--------------- src/mutmut/mutation/file_mutation.py | 2 +- src/mutmut/mutation/trampoline.py | 2 +- src/mutmut/mutation/trampoline_templates.py | 12 ---------- src/mutmut/utils/format_utils.py | 25 ++++++++++++++++++++- tests/mutation/test_mutation.py | 10 ++++----- 6 files changed, 32 insertions(+), 40 deletions(-) diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 8ee363f8..0308cb39 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -10,6 +10,8 @@ from mutmut.state import state from mutmut.utils.format_utils import get_module_from_key from mutmut.utils.format_utils import get_mutant_name +from mutmut.utils.format_utils import mangled_name_from_mutant_name +from mutmut.utils.format_utils import orig_function_and_class_names_from_key if platform.system() == "Windows": print( @@ -52,7 +54,6 @@ from mutmut.mutation.file_mutation import MutatedFile from mutmut.mutation.file_mutation import filter_mutants_with_type_checker from mutmut.mutation.file_mutation import mutate_file_contents -from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR from mutmut.runners.harness import CollectTestsFailedException from mutmut.runners.harness import PytestRunner from mutmut.runners.harness import collected_test_names @@ -261,24 +262,6 @@ def write_all_mutants_to_file(*, out: TextIOBase, source: str, filename: Path) - return mutated_file -def mangled_name_from_mutant_name(mutant_name: str) -> str: - assert "__mutmut_" in mutant_name, mutant_name - return mutant_name.partition("__mutmut_")[0] - - -def orig_function_and_class_names_from_key(mutant_name: str) -> tuple[str, str | None]: - r = mangled_name_from_mutant_name(mutant_name) - _, _, r = r.rpartition(".") - class_name = None - if CLASS_NAME_SEPARATOR in r: - class_name = r[r.index(CLASS_NAME_SEPARATOR) + 1 : r.rindex(CLASS_NAME_SEPARATOR)] - r = r[r.rindex(CLASS_NAME_SEPARATOR) + 1 :] - else: - assert r.startswith("x_"), r - r = r[2:] - return r, class_name - - def run_forced_fail_test(runner: MutantRunner) -> None: os.environ["MUTANT_UNDER_TEST"] = "fail" with CatchOutput(spinner_title="Running forced fail test") as catcher: diff --git a/src/mutmut/mutation/file_mutation.py b/src/mutmut/mutation/file_mutation.py index 04f5c6c5..a8dd4944 100644 --- a/src/mutmut/mutation/file_mutation.py +++ b/src/mutmut/mutation/file_mutation.py @@ -25,13 +25,13 @@ from mutmut.mutation.pragma_handling import IgnoredCode from mutmut.mutation.pragma_handling import get_ignored_lines from mutmut.mutation.trampoline_templates import build_mutants_dict_and_name -from mutmut.mutation.trampoline_templates import mangle_function_name from mutmut.mutation.trampoline_templates import trampoline_imports from mutmut.type_checking import TypeCheckingError from mutmut.type_checking import run_type_checker from mutmut.utils.file_utils import change_cwd from mutmut.utils.format_utils import get_mutant_name from mutmut.utils.format_utils import is_mutated_method_name +from mutmut.utils.format_utils import mangle_function_name NEVER_MUTATE_FUNCTION_NAMES = {"__getattribute__", "__setattr__", "__new__"} NEVER_MUTATE_FUNCTION_CALLS = {"len", "isinstance"} diff --git a/src/mutmut/mutation/trampoline.py b/src/mutmut/mutation/trampoline.py index d70f98c4..7c20263f 100644 --- a/src/mutmut/mutation/trampoline.py +++ b/src/mutmut/mutation/trampoline.py @@ -8,9 +8,9 @@ from typing import TypeVar from mutmut.__main__ import MutmutProgrammaticFailException -from mutmut.__main__ import mangled_name_from_mutant_name from mutmut.__main__ import record_trampoline_hit from mutmut.core import MutmutCallStack +from mutmut.utils.format_utils import mangled_name_from_mutant_name TReturn = TypeVar("TReturn") MutantDict = Annotated[dict[str, Callable[..., TReturn]], "Mutant"] diff --git a/src/mutmut/mutation/trampoline_templates.py b/src/mutmut/mutation/trampoline_templates.py index d77ff340..eb155e18 100644 --- a/src/mutmut/mutation/trampoline_templates.py +++ b/src/mutmut/mutation/trampoline_templates.py @@ -1,5 +1,3 @@ -CLASS_NAME_SEPARATOR = "ǁ" - GENERATED_MARKER = "# type: ignore # mutmut generated" @@ -14,16 +12,6 @@ def _mark_generated(code: str) -> str: return "\n".join(lines) -def mangle_function_name(*, name: str, class_name: str | None) -> str: - assert CLASS_NAME_SEPARATOR not in name - if class_name: - assert CLASS_NAME_SEPARATOR not in class_name - prefix = f"x{CLASS_NAME_SEPARATOR}{class_name}{CLASS_NAME_SEPARATOR}" - else: - prefix = "x_" - return f"{prefix}{name}" - - def build_mutants_dict_and_name( *, mangled_name: str, diff --git a/src/mutmut/utils/format_utils.py b/src/mutmut/utils/format_utils.py index deb79ca8..08c7830c 100644 --- a/src/mutmut/utils/format_utils.py +++ b/src/mutmut/utils/format_utils.py @@ -3,7 +3,17 @@ import os from pathlib import Path -from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR +CLASS_NAME_SEPARATOR = "ǁ" + + +def mangle_function_name(*, name: str, class_name: str | None) -> str: + assert CLASS_NAME_SEPARATOR not in name + if class_name: + assert CLASS_NAME_SEPARATOR not in class_name + prefix = f"x{CLASS_NAME_SEPARATOR}{class_name}{CLASS_NAME_SEPARATOR}" + else: + prefix = "x_" + return f"{prefix}{name}" def make_mutant_key(func_name: str, class_name: str | None = None) -> str: @@ -84,6 +94,19 @@ def raw_func_name_from_mangled(mangled: str) -> str: return f"{module_part}.{func_part}" if module_part else func_part +def orig_function_and_class_names_from_key(mutant_name: str) -> tuple[str, str | None]: + r = mangled_name_from_mutant_name(mutant_name) + _, _, r = r.rpartition(".") + class_name = None + if CLASS_NAME_SEPARATOR in r: + class_name = r[r.index(CLASS_NAME_SEPARATOR) + 1 : r.rindex(CLASS_NAME_SEPARATOR)] + r = r[r.rindex(CLASS_NAME_SEPARATOR) + 1 :] + else: + assert r.startswith("x_"), r + r = r[2:] + return r, class_name + + def get_mutant_name(relative_source_path: Path, mutant_method_name: str) -> str: module_name = str(relative_source_path)[: -len(relative_source_path.suffix)].replace(os.sep, ".") module_name = strip_prefix(module_name, prefix="src.") diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index 8a6219e8..49b458a8 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -28,8 +28,6 @@ from mutmut.__main__ import git_changed_non_py_files from mutmut.__main__ import git_head from mutmut.__main__ import git_tracked_non_py_files -from mutmut.__main__ import mangled_name_from_mutant_name -from mutmut.__main__ import orig_function_and_class_names_from_key from mutmut.__main__ import record_trampoline_hit from mutmut.__main__ import run_forced_fail_test from mutmut.configuration import Config @@ -40,11 +38,13 @@ from mutmut.mutation.file_mutation import compute_function_hashes from mutmut.mutation.file_mutation import create_mutations from mutmut.mutation.file_mutation import mutate_file_contents -from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR -from mutmut.mutation.trampoline_templates import mangle_function_name from mutmut.state import reset_state from mutmut.state import state +from mutmut.utils.format_utils import CLASS_NAME_SEPARATOR from mutmut.utils.format_utils import get_mutant_name +from mutmut.utils.format_utils import mangle_function_name +from mutmut.utils.format_utils import mangled_name_from_mutant_name +from mutmut.utils.format_utils import orig_function_and_class_names_from_key def mutants_for_source(source: str, covered_lines: set[int] | None = None) -> list[str]: @@ -1171,8 +1171,6 @@ def bar(self): return 1 """.strip() hashes = compute_function_hashes(source) - from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR - method_key = f"x{CLASS_NAME_SEPARATOR}Foo{CLASS_NAME_SEPARATOR}bar" assert method_key in hashes