diff --git a/AUTHORS b/AUTHORS index 25159b8b07f..297b04d318a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -309,6 +309,7 @@ Pavel Karateev Paweł Adamczak Pedro Algarvio Petter Strandmark +Philipp A. Philipp Loose Pierre Sassoulas Pieter Mulder diff --git a/changelog/11931.bugfix.rst b/changelog/11931.bugfix.rst new file mode 100644 index 00000000000..5d71eee8258 --- /dev/null +++ b/changelog/11931.bugfix.rst @@ -0,0 +1 @@ +Fix some instances of importing doctests’ parent modules when using `--import-mode=importlib`. diff --git a/pyproject.toml b/pyproject.toml index 72988e23387..7fb55113695 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,6 +144,7 @@ ignore = [ "PLW2901", # for loop variable overwritten by assignment target "PLR5501", # Use `elif` instead of `else` then `if` ] +allowed-confusables = ["’"] [tool.ruff.lint.pycodestyle] # In order to be able to format for 88 char in ruff format diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 1e0891153e5..69fc41fecb2 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -607,21 +607,18 @@ def _is_same(f1: str, f2: str) -> bool: def module_name_from_path(path: Path, root: Path) -> str: """ - Return a dotted module name based on the given path, anchored on root. + Return a dotted module name based on the given path, + anchored on root or the most likely entry in `sys.path`. For example: path="projects/src/tests/test_foo.py" and root="/projects", the resulting module name will be "src.tests.test_foo". """ path = path.with_suffix("") - try: - relative_path = path.relative_to(root) - except ValueError: - # If we can't get a relative path to root, use the full path, except - # for the first part ("d:\\" or "/" depending on the platform, for example). - path_parts = path.parts[1:] - else: - # Use the parts for the relative path to the root path. - path_parts = relative_path.parts + candidates = ( + _maybe_relative_parts(path, dir) + for dir in itertools.chain([root], map(Path, sys.path)) + ) + path_parts = min(candidates, key=len) # type: ignore[arg-type] # Module name for packages do not contain the __init__ file, unless # the `__init__.py` file is at the root. @@ -631,6 +628,17 @@ def module_name_from_path(path: Path, root: Path) -> str: return ".".join(path_parts) +def _maybe_relative_parts(path: Path, root: Path) -> "tuple[str, ...]": + try: + relative_path = path.relative_to(root) + except ValueError: + # If we can't get a relative path to root, use the full path, except + # for the first part ("d:\\" or "/" depending on the platform, for example). + return path.parts[1:] + # Use the parts for the relative path to the root path. + return relative_path.parts + + def insert_missing_modules(modules: Dict[str, ModuleType], module_name: str) -> None: """ Used by ``import_path`` to create intermediate modules when using mode=importlib. diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 075259009de..1ff5e7f2fc8 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -669,6 +669,34 @@ def __init__(self) -> None: mod = import_path(init, root=tmp_path, mode=ImportMode.importlib) assert len(mod.instance.INSTANCES) == 1 + def test_importlib_doctest(self, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + """ + Importing a package using --importmode=importlib should + import the package using the canonical name + """ + proj_dir = tmp_path / "proj" + proj_dir.mkdir() + pkgs_dir = tmp_path / "pkgs" + pkgs_dir.mkdir() + monkeypatch.chdir(proj_dir) + monkeypatch.syspath_prepend(pkgs_dir) + # this is also there, but shouldn’t be imported from + monkeypatch.syspath_prepend(proj_dir) + + package_name = "importlib_doctest" + # pkgs_dir is second to set `init` + for directory in [proj_dir / "src", pkgs_dir]: + pkgdir = directory / package_name + pkgdir.mkdir(parents=True) + init = pkgdir / "__init__.py" + init.write_text("", encoding="ascii") + + # the PyTest root is `proj_dir`, but the package is imported from `pkgs_dir` + mod = import_path(init, root=proj_dir, mode=ImportMode.importlib) + # assert that it’s imported with the canonical name, not “path.to.package.” + mod_names = [n for n, m in sys.modules.items() if m is mod] + assert mod_names == ["importlib_doctest"] + def test_importlib_root_is_package(self, pytester: Pytester) -> None: """ Regression for importing a `__init__`.py file that is at the root