Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/mutmut/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,19 @@
config: Config | None = None

_stats: set[str] = set()
_pre_test_stats: set[str] = set()
tests_by_mangled_function_name: defaultdict[str, set[str]] = defaultdict(set)
_covered_lines: dict[str, set[int]] | None = None


def _reset_globals() -> None:
global duration_by_test, stats_time, config, _stats, tests_by_mangled_function_name
global duration_by_test, stats_time, config, _stats, _pre_test_stats, tests_by_mangled_function_name
global _covered_lines

duration_by_test.clear()
stats_time = None
config = None
_stats = set()
_pre_test_stats = set()
tests_by_mangled_function_name = defaultdict(set)
_covered_lines = None
170 changes: 167 additions & 3 deletions src/mutmut/__main__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import importlib
import os
import platform
import sys
Expand Down Expand Up @@ -213,6 +214,41 @@ def copy_src_dir() -> None:
# copy mtime, so we later know that when source_mtime == target_mtime, the file is not (yet) mutated.
shutil.copy2(source_path, target_path)

# Ensure every parent package directory also has its __init__.py
# copied into the mutants tree so that the package structure is
# complete for import resolution.
_copy_parent_init_files()


def _copy_parent_init_files() -> None:
"""Copy ``__init__.py`` for every package directory in the mutants tree.

When ``paths_to_mutate`` targets individual files (e.g.
``["pkg/sub/module.py"]``), mutmut creates ``mutants/pkg/sub/module.py``
but does *not* copy the ``__init__.py`` files in ``pkg/`` or ``pkg/sub/``.
Without them Python's import system cannot recognise ``pkg`` as a package
and falls back to the copy installed in site-packages, skipping the
trampoline-injected mutant code entirely.
"""
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

paths_to_mutate should not cover individual files (#414 )

mutants_root = Path("mutants")
for init_file in mutants_root.rglob("__init__.py"):
# Already present — nothing to do.
pass # pragma: no cover (defensive)

# Walk every directory that exists in mutants/ and check whether an
# __init__.py is missing but present in the original source tree.
for dirpath in sorted(mutants_root.rglob("*")):
if not dirpath.is_dir():
continue
init_in_mutants = dirpath / "__init__.py"
if init_in_mutants.exists():
continue
# Compute the corresponding original path.
rel = dirpath.relative_to(mutants_root)
original_init = rel / "__init__.py"
if original_init.exists():
shutil.copy2(original_init, init_in_mutants)


@dataclass
class FileMutationResult:
Expand Down Expand Up @@ -266,17 +302,118 @@ def create_file_mutants(path: Path) -> FileMutationResult:
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")]
mutated_roots: list[str] = []
for path in source_code_paths:
mutated_path = Path("mutants") / path
if mutated_path.exists():
sys.path.insert(0, str(mutated_path.absolute()))
abs_path = str(mutated_path.absolute())
sys.path.insert(0, abs_path)
mutated_roots.append(abs_path)

# 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]

# When the package-under-test is also installed in site-packages
# (non-editable install), Python may have already imported the
# original (non-mutated) source. Patch __path__ on imported
# packages so submodule lookups prefer the mutants tree, and flush
# leaf modules so they get re-imported with trampoline code.
if mutated_roots:
_patch_imported_packages(mutated_roots)


def _patch_imported_packages(mutated_roots: list[str]) -> None:
"""Make already-imported packages resolve submodules from mutants first.

For each top-level package that exists in both ``sys.modules`` (from
site-packages) and the mutants tree, prepend the mutants path to the
package's ``__path__``. Then flush leaf modules (non-packages) whose
mutated ``.py`` file exists, so they get re-imported from the mutants
directory with trampoline code.
"""
# Discover top-level package names present in the mutants tree.
mutated_packages: dict[str, str] = {} # pkg_name -> mutants_root
for root in mutated_roots:
root_path = Path(root)
if not root_path.is_dir():
continue
for child in root_path.iterdir():
if child.is_dir() and (child / "__init__.py").exists():
mutated_packages[child.name] = root

if not mutated_packages:
return

# Patch __path__ on top-level and nested packages already in sys.modules.
for mod_name, mod in list(sys.modules.items()):
top = mod_name.split(".")[0]
if top not in mutated_packages or not hasattr(mod, "__path__"):
continue
root = mutated_packages[top]
parts = mod_name.split(".")
mutant_dir = str(Path(root, *parts).absolute())
if Path(mutant_dir).is_dir() and mutant_dir not in mod.__path__:
mod.__path__.insert(0, mutant_dir)

# Flush leaf modules so they get re-imported from the mutants path.
to_remove = [
name
for name, mod in sys.modules.items()
if (
name.split(".")[0] in mutated_packages
and mod is not None
and not hasattr(mod, "__path__") # leaf module, not a package
and _has_mutant_file(name, mutated_packages)
)
]
for name in to_remove:
del sys.modules[name]

if to_remove:
importlib.invalidate_caches()


def _has_mutant_file(mod_name: str, mutated_packages: dict[str, str]) -> bool:
"""Check whether a mutated .py file exists for the given module."""
top = mod_name.split(".")[0]
root = mutated_packages.get(top, "")
if not root:
return False
parts = mod_name.split(".")
if len(parts) > 1:
candidate = Path(root, *parts[:-1], parts[-1] + ".py")
else:
candidate = Path(root, parts[0] + ".py")
return candidate.exists()


def _flush_test_modules() -> None:
"""Remove cached test modules from sys.modules so they get re-imported.

Called in the forked child process when testing a mutant that targets
import-time code (e.g. __init_subclass__). Without this, the child
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused, mutmut does not support import-time code mutations. Is this a feature PR to add this?

e.g. in following file:

MY_CONST = 1234

def foo():
  return 1234

Only foo will be mutated, MY_CONST won't be mutated.

inherits cached modules from the parent (stats phase) and
__init_subclass__ never fires again with the mutant active.

Only non-package modules under ``tests`` (or ``conftest``) are flushed.
The mutated package itself stays cached — its trampolines dynamically
check ``MUTANT_UNDER_TEST`` on each call.
"""
to_remove = [
name
for name in sys.modules
if name == "conftest"
or name.startswith("conftest.")
or name.startswith("tests.")
or name.startswith("tests_")
]
for name in to_remove:
del sys.modules[name]
importlib.invalidate_caches()


def store_lines_covered_by_tests() -> None:
assert mutmut.config is not None
Expand Down Expand Up @@ -628,12 +765,23 @@ class StatsCollector:
def pytest_runtest_logstart(self, nodeid: str, location: Any) -> None:
mutmut.duration_by_test[nodeid] = 0

# noinspection PyMethodMayBeStatic
def pytest_collection_finish(self, session: Any) -> None:
unused(session)
# Snapshot trampoline hits from import-time code (e.g. __init_subclass__).
# These fired during collection before any test ran.
mutmut._pre_test_stats = mutmut._stats.copy()

# 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()
# Import-time code (e.g. __init_subclass__) runs once during collection.
# Attribute those hits to every test since any test might detect the mutation.
for function in mutmut._pre_test_stats:
mutmut.tests_by_mangled_function_name[function].add(strip_prefix(item._nodeid, prefix="mutants/"))

# noinspection PyMethodMayBeStatic
def pytest_runtest_makereport(self, item: Any, call: Any) -> None:
Expand Down Expand Up @@ -692,10 +840,21 @@ def run_stats(self, *, tests: Iterable[str]) -> int:

print("Running hammett stats...")

first_test_seen = False

def post_test_callback(_name: str, **_: Any) -> None:
nonlocal first_test_seen
if not first_test_seen:
# Snapshot import-time trampoline hits before the first test clears them.
mutmut._pre_test_stats = mutmut._stats.copy()
first_test_seen = True
for function in mutmut._stats:
mutmut.tests_by_mangled_function_name[function].add(_name)
mutmut._stats.clear()
# Import-time code (e.g. __init_subclass__) runs once at import.
# Attribute those hits to every test since any test might detect the mutation.
for function in mutmut._pre_test_stats:
mutmut.tests_by_mangled_function_name[function].add(_name)

return int(
hammett.main(
Expand Down Expand Up @@ -1083,7 +1242,7 @@ def load_stats() -> bool:
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")
assert not data, data
mutmut._pre_test_stats = set(data.pop("pre_test_stats", []))
did_load = True
except (FileNotFoundError, JSONDecodeError):
pass
Expand All @@ -1097,6 +1256,7 @@ def save_stats() -> None:
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,
pre_test_stats=sorted(mutmut._pre_test_stats),
),
f,
indent=4,
Expand Down Expand Up @@ -1230,7 +1390,10 @@ def stop_all_children(mutants: list[tuple[SourceFileMutationData, str, int | Non


# used to copy the global mutmut.config to subprocesses
set_start_method("fork")
try:
set_start_method("fork")
except RuntimeError:
pass # already set (e.g. re-imported from trampoline during stats collection)
START_TIMES_BY_PID_LOCK = Lock()


Expand Down Expand Up @@ -1381,6 +1544,7 @@ def read_one_child_exit_status() -> None:
os.environ["MUTANT_UNDER_TEST"] = mutant_name
setproctitle(f"mutmut: {mutant_name}")


# Run fast tests first
sorted_tests = sorted(tests, key=lambda test_name: mutmut.duration_by_test[test_name])
if not sorted_tests:
Expand Down
Loading