Skip to content
Merged
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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/_pytask/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
51 changes: 45 additions & 6 deletions src/_pytask/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@

from _pytask._hashlib import file_digest
from _pytask.cache import Cache
from _pytask.exceptions import ImportPathMismatchError

if TYPE_CHECKING:
from collections.abc import Sequence

from _pytask.typing import NodePath

__all__ = [
"ImportPathMismatchError",
"find_case_sensitive_path",
"find_closest_ancestor",
"find_common_ancestor",
Expand Down Expand Up @@ -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)

Expand All @@ -211,6 +214,42 @@ 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 = "<unknown>" 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"):
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()))


def _resolve_package_path(path: Path) -> Path | None:
"""Resolve package path.

Expand Down
2 changes: 2 additions & 0 deletions src/pytask/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -103,6 +104,7 @@
"ExecutionReport",
"Exit",
"ExitCode",
"ImportPathMismatchError",
"Mark",
"MarkDecorator",
"MarkGenerator",
Expand Down
3 changes: 2 additions & 1 deletion src/pytask/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
37 changes: 37 additions & 0 deletions tests/test_collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from _pytask.task_utils import validate_unique_task_signatures
from pytask import CollectionOutcome
from pytask import ExitCode
from pytask import ImportPathMismatchError
from pytask import NodeInfo
from pytask import PathNode
from pytask import PickleNode
Expand Down Expand Up @@ -428,6 +429,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 = """
Expand Down
45 changes: 45 additions & 0 deletions tests/test_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +25,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:
Expand Down Expand Up @@ -193,6 +195,49 @@ 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"
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:
Expand Down
Loading