-
Notifications
You must be signed in to change notification settings - Fork 152
fix: support non-editable installs (site-packages import precedence) #487
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
pythoniste
wants to merge
2
commits into
boxed:main
Choose a base branch
from
inspyration:fix/353-pydantic-classvar
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
@@ -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. | ||
| """ | ||
| 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: | ||
|
|
@@ -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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 1234Only |
||
| 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 | ||
|
|
@@ -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: | ||
|
|
@@ -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( | ||
|
|
@@ -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 | ||
|
|
@@ -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, | ||
|
|
@@ -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() | ||
|
|
||
|
|
||
|
|
@@ -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: | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 )