From 7437f99fc68fec7b469ee40ea12ac58af577a25f Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Sun, 2 Aug 2026 19:04:30 +0200 Subject: [PATCH 1/4] Detect import path mismatches --- src/_pytask/exceptions.py | 4 ++++ src/_pytask/path.py | 46 ++++++++++++++++++++++++++++++++++----- src/pytask/__init__.py | 2 ++ src/pytask/path.py | 3 ++- tests/test_collect.py | 37 +++++++++++++++++++++++++++++++ tests/test_path.py | 23 ++++++++++++++++++++ 6 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/_pytask/exceptions.py b/src/_pytask/exceptions.py index 726512c94..328ef312f 100644 --- a/src/_pytask/exceptions.py +++ b/src/_pytask/exceptions.py @@ -33,3 +33,7 @@ class ResolvingDependenciesError(PytaskError): class ExecutionError(PytaskError): """Exception during execution.""" + + +class ImportPathMismatchError(ImportError): + """Exception for a cached module imported from a different path.""" diff --git a/src/_pytask/path.py b/src/_pytask/path.py index eb2f8c922..2e56f55f4 100644 --- a/src/_pytask/path.py +++ b/src/_pytask/path.py @@ -17,6 +17,7 @@ from _pytask._hashlib import file_digest from _pytask.cache import Cache +from _pytask.exceptions import ImportPathMismatchError if TYPE_CHECKING: from collections.abc import Sequence @@ -24,6 +25,7 @@ from _pytask.typing import NodePath __all__ = [ + "ImportPathMismatchError", "find_case_sensitive_path", "find_closest_ancestor", "find_common_ancestor", @@ -182,21 +184,22 @@ def import_path(path: Path, root: Path) -> ModuleType: except CouldNotResolvePathError: pass else: - # If the given module name is already in sys.modules, do not import it again. - with contextlib.suppress(KeyError): - return sys.modules[module_name] + cached_module = _get_cached_module(module_name, path) + if cached_module is not None: + return cached_module mod = _import_module_using_spec(module_name, path, pkg_root) if mod is not None: return mod module_name = _module_name_from_path(path, root) - with contextlib.suppress(KeyError): - return sys.modules[module_name] + cached_module = _get_cached_module(module_name, path) + if cached_module is not None: + return cached_module spec = importlib.util.spec_from_file_location(module_name, str(path)) - if spec is None: + if spec is None or spec.loader is None: msg = f"Can't find module {module_name!r} at location {path}." raise ImportError(msg) @@ -207,6 +210,37 @@ def import_path(path: Path, root: Path) -> ModuleType: return mod +def _get_cached_module(module_name: str, path: Path) -> ModuleType | None: + """Return a cached module only when it originates from the requested path.""" + module = sys.modules.get(module_name) + if module is None: + return None + + module_file = getattr(module, "__file__", None) + if module_file is not None and _normalize_import_path(module_file) == ( + _normalize_import_path(path) + ): + return module + + imported_path = "" if module_file is None else str(module_file) + msg = ( + f"Module {module_name!r} was already imported from:\n{imported_path}\n\n" + f"Pytask is trying to collect:\n{path}\n\n" + "Pytask will not reuse a module from a different path.\n\n" + "Use a unique package or module name, or start the build in a fresh process " + "or notebook kernel." + ) + raise ImportPathMismatchError(msg) + + +def _normalize_import_path(path: str | os.PathLike[str]) -> str: + """Normalize a module path for cache comparisons.""" + raw_path = os.fspath(path) + if raw_path.endswith((".pyc", ".pyo")): + raw_path = raw_path[:-1] + return os.path.normcase(str(Path(raw_path).resolve())) + + def _resolve_package_path(path: Path) -> Path | None: """Resolve package path. diff --git a/src/pytask/__init__.py b/src/pytask/__init__.py index 4352ccd92..707143915 100644 --- a/src/pytask/__init__.py +++ b/src/pytask/__init__.py @@ -27,6 +27,7 @@ from _pytask.exceptions import CollectionError from _pytask.exceptions import ConfigurationError from _pytask.exceptions import ExecutionError +from _pytask.exceptions import ImportPathMismatchError from _pytask.exceptions import NodeNotCollectedError from _pytask.exceptions import NodeLoadError from _pytask.exceptions import NodeNotFoundError @@ -103,6 +104,7 @@ "ExecutionReport", "Exit", "ExitCode", + "ImportPathMismatchError", "Mark", "MarkDecorator", "MarkGenerator", diff --git a/src/pytask/path.py b/src/pytask/path.py index 7e6d28726..4d50ee11e 100644 --- a/src/pytask/path.py +++ b/src/pytask/path.py @@ -2,7 +2,8 @@ from __future__ import annotations +from _pytask.exceptions import ImportPathMismatchError from _pytask.path import hash_path from _pytask.path import import_path -__all__ = ["hash_path", "import_path"] +__all__ = ["ImportPathMismatchError", "hash_path", "import_path"] diff --git a/tests/test_collect.py b/tests/test_collect.py index 3b6587e70..7362db187 100644 --- a/tests/test_collect.py +++ b/tests/test_collect.py @@ -14,6 +14,7 @@ from _pytask.node_protocols import PPathNode from pytask import CollectionOutcome from pytask import ExitCode +from pytask import ImportPathMismatchError from pytask import NodeInfo from pytask import PickleNode from pytask import Session @@ -376,6 +377,42 @@ def test_collect_tasks_from_modules_with_the_same_name(tmp_path): assert modules == {"a.task_module", "b.task_module"} +def test_repeated_builds_report_import_path_mismatch(tmp_path): + first_root = tmp_path / "first" + second_root = tmp_path / "second" + first_root.mkdir() + second_root.mkdir() + module_name = "task_repeated_build_import_path" + first_path = first_root / f"{module_name}.py" + second_path = second_root / f"{module_name}.py" + first_path.write_text( + "from pathlib import Path\n" + "def task_example():\n" + " Path(__file__).with_name('result.txt').write_text('first')\n" + ) + second_path.write_text( + "from pathlib import Path\n" + "def task_example():\n" + " Path(__file__).with_name('result.txt').write_text('second')\n" + ) + + first_session = build(paths=first_root) + second_session = build(paths=second_root) + + assert first_session.exit_code == ExitCode.OK + assert first_root.joinpath("result.txt").read_text() == "first" + assert second_session.exit_code == ExitCode.COLLECTION_FAILED + assert not second_root.joinpath("result.txt").exists() + failed_reports = [ + report + for report in second_session.collection_reports + if report.outcome == CollectionOutcome.FAIL + ] + assert len(failed_reports) == 1 + assert failed_reports[0].exc_info is not None + assert isinstance(failed_reports[0].exc_info[1], ImportPathMismatchError) + + def test_collect_module_name(tmp_path): """We need to add a task module to the sys.modules. See #373 and #374.""" source = """ diff --git a/tests/test_path.py b/tests/test_path.py index 0a0098b85..6cc32fb50 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -24,6 +24,7 @@ from _pytask.path import normalize_local_upath from _pytask.path import relative_to from _pytask.path import shorten_path +from pytask.path import ImportPathMismatchError from pytask.path import import_path if TYPE_CHECKING: @@ -193,6 +194,28 @@ def test_remembers_previous_imports(simple_module: Path, tmp_path: Path) -> None assert module1 is module2 +def test_rejects_cached_module_from_different_path(tmp_path: Path) -> None: + module_name = "task_import_path_mismatch" + first_path = tmp_path / "first" / f"{module_name}.py" + second_path = tmp_path / "second" / f"{module_name}.py" + first_path.parent.mkdir() + second_path.parent.mkdir() + first_path.write_text("VALUE = 'first'") + second_path.write_text("VALUE = 'second'") + + first_module = import_path(first_path, root=first_path.parent) + + assert first_module.VALUE == "first" + with pytest.raises(ImportPathMismatchError) as exc_info: + import_path(second_path, root=second_path.parent) + + message = str(exc_info.value) + assert module_name in message + assert str(first_path) in message + assert str(second_path) in message + assert "fresh process or notebook kernel" in message + + def test_no_meta_path_found( simple_module: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: From e20904024b0b5aa79fd43fd7a4926178224bd393 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:05:22 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_mark.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_mark.py b/tests/test_mark.py index 1fb7713e2..622185085 100644 --- a/tests/test_mark.py +++ b/tests/test_mark.py @@ -191,8 +191,10 @@ def task_func(arg=arg): [ ( "foo or", - ("at column 7: expected not OR left parenthesis OR identifier; got end of " - "input"), + ( + "at column 7: expected not OR left parenthesis OR identifier; got end of " + "input" + ), ), ( "foo or or", From 62da13c4e081a8c7711a8b95ab8db0e6f642bfcd Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Tue, 22 Sep 2026 23:10:13 +0200 Subject: [PATCH 3/4] fix: map cached bytecode to its source path --- src/_pytask/path.py | 7 ++++++- tests/test_path.py | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/_pytask/path.py b/src/_pytask/path.py index 92124a135..4ae88aa73 100644 --- a/src/_pytask/path.py +++ b/src/_pytask/path.py @@ -240,7 +240,12 @@ def _get_cached_module(module_name: str, path: Path) -> ModuleType | None: def _normalize_import_path(path: str | os.PathLike[str]) -> str: """Normalize a module path for cache comparisons.""" raw_path = os.fspath(path) - if raw_path.endswith((".pyc", ".pyo")): + if raw_path.endswith(".pyc"): + try: + raw_path = importlib.util.source_from_cache(raw_path) + except ValueError: + raw_path = raw_path[:-1] + elif raw_path.endswith(".pyo"): raw_path = raw_path[:-1] return os.path.normcase(str(Path(raw_path).resolve())) diff --git a/tests/test_path.py b/tests/test_path.py index 6cc32fb50..1de901325 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -2,6 +2,7 @@ import importlib import importlib.util +import py_compile import sys import textwrap from contextlib import ExitStack as does_not_raise # noqa: N813 @@ -194,6 +195,27 @@ def test_remembers_previous_imports(simple_module: Path, tmp_path: Path) -> None assert module1 is module2 +@pytest.mark.parametrize("legacy", [False, True], ids=["pycache", "adjacent"]) +def test_reuses_cached_module_loaded_from_bytecode( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, legacy: bool +) -> None: + module_name = "task_import_cached_bytecode" + source_path = tmp_path / f"{module_name}.py" + source_path.write_text("VALUE = 42\n") + bytecode_path = py_compile.compile( + str(source_path), + cfile=str(source_path.with_suffix(".pyc")) if legacy else None, + ) + spec = importlib.util.spec_from_file_location(module_name, bytecode_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, module_name, module) + spec.loader.exec_module(module) + + assert import_path(source_path, root=tmp_path) is module + + def test_rejects_cached_module_from_different_path(tmp_path: Path) -> None: module_name = "task_import_path_mismatch" first_path = tmp_path / "first" / f"{module_name}.py" From f7064f85cb557097e97b4dad5d63ebf9a579bb69 Mon Sep 17 00:00:00 2001 From: Tobias Raabe Date: Fri, 25 Sep 2026 22:55:58 +0200 Subject: [PATCH 4/4] docs: add import path mismatch changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97cd88f77..0e60c297c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and ## Unreleased +- [#935](https://github.com/pytask-dev/pytask/pull/935) detects when a cached task + module comes from a different path and raises + `ImportPathMismatchError`, while recognizing cached bytecode modules by their source + path. - [#936](https://github.com/pytask-dev/pytask/pull/936) runs tasks without paths when their source cannot be inspected, while still rejecting path-backed tasks with missing source files.