Skip to content

Use shortest module name for importlib imports #11931

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

Closed
wants to merge 7 commits into from
Closed
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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ Pavel Karateev
Paweł Adamczak
Pedro Algarvio
Petter Strandmark
Philipp A.
Philipp Loose
Pierre Sassoulas
Pieter Mulder
Expand Down
1 change: 1 addition & 0 deletions changelog/11931.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix some instances of importing doctests’ parent modules when using `--import-mode=importlib`.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 18 additions & 10 deletions src/_pytest/pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions testing/test_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>”
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
Expand Down