From e867798f1aa248b851f9bf2f0232df00ac82c9ef Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Mon, 5 Feb 2024 16:09:03 +0100 Subject: [PATCH 1/6] Use shortest module name --- src/_pytest/pathlib.py | 13 +++++++++++-- testing/test_pathlib.py | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 1e0891153e5..351223f76dd 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -607,11 +607,20 @@ 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". """ + candidates = ( + _module_name_from_path(path, dir) + for dir in itertools.chain([root], map(Path, sys.path)) + ) + return ".".join(min(candidates, key=len)) + + +def _module_name_from_path(path: Path, root: Path) -> tuple[str, ...]: path = path.with_suffix("") try: relative_path = path.relative_to(root) @@ -628,7 +637,7 @@ def module_name_from_path(path: Path, root: Path) -> str: if len(path_parts) >= 2 and path_parts[-1] == "__init__": path_parts = path_parts[:-1] - return ".".join(path_parts) + return path_parts def insert_missing_modules(modules: Dict[str, ModuleType], module_name: str) -> None: diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 075259009de..f22add229cc 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -669,6 +669,33 @@ 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): + """ + 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") + + 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 From be27caa26cf843532fd15ac794693b36bf58231a Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Mon, 5 Feb 2024 16:21:08 +0100 Subject: [PATCH 2/6] Docs --- AUTHORS | 1 + changelog/11931.bugfix.rst | 1 + pyproject.toml | 3 +++ src/_pytest/pathlib.py | 2 +- testing/test_pathlib.py | 1 + 5 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 changelog/11931.bugfix.rst diff --git a/AUTHORS b/AUTHORS index 87909762263..4378f4c0dc0 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 15a855ce6f9..560d632039d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,6 +173,9 @@ ignore = [ [tool.ruff.format] docstring-code-format = true +[tool.ruff.lint] +allowed-confusables = ["’", "×"] + [tool.ruff.lint.pycodestyle] # In order to be able to format for 88 char in ruff format max-line-length = 120 diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 351223f76dd..928a3d0db64 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -617,7 +617,7 @@ def module_name_from_path(path: Path, root: Path) -> str: _module_name_from_path(path, dir) for dir in itertools.chain([root], map(Path, sys.path)) ) - return ".".join(min(candidates, key=len)) + return ".".join(min(candidates, key=len)) # type: ignore[arg-type] def _module_name_from_path(path: Path, root: Path) -> tuple[str, ...]: diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index f22add229cc..4f8b5b07ba4 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -691,6 +691,7 @@ def test_importlib_doctest(self, monkeypatch: MonkeyPatch, tmp_path: Path): 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] From e0402fa0d94f530e280d5af95d6357cb5efc4f03 Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Mon, 5 Feb 2024 16:28:27 +0100 Subject: [PATCH 3/6] 3.8 compat --- src/_pytest/pathlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 928a3d0db64..c5f70a0340d 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -620,7 +620,7 @@ def module_name_from_path(path: Path, root: Path) -> str: return ".".join(min(candidates, key=len)) # type: ignore[arg-type] -def _module_name_from_path(path: Path, root: Path) -> tuple[str, ...]: +def _module_name_from_path(path: Path, root: Path) -> "tuple[str, ...]": path = path.with_suffix("") try: relative_path = path.relative_to(root) From 90d15500630eb6ec4bce70e0ba193ed2c61e8a4f Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 6 Feb 2024 09:12:08 +0100 Subject: [PATCH 4/6] faster --- src/_pytest/pathlib.py | 29 ++++++++++++++--------------- testing/test_pathlib.py | 2 +- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index c5f70a0340d..69fc41fecb2 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -613,31 +613,30 @@ def module_name_from_path(path: Path, root: Path) -> str: 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("") candidates = ( - _module_name_from_path(path, dir) + _maybe_relative_parts(path, dir) for dir in itertools.chain([root], map(Path, sys.path)) ) - return ".".join(min(candidates, key=len)) # type: ignore[arg-type] + 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. + if len(path_parts) >= 2 and path_parts[-1] == "__init__": + path_parts = path_parts[:-1] + return ".".join(path_parts) -def _module_name_from_path(path: Path, root: Path) -> "tuple[str, ...]": - path = path.with_suffix("") + +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). - path_parts = path.parts[1:] - else: - # Use the parts for the relative path to the root path. - path_parts = relative_path.parts - - # Module name for packages do not contain the __init__ file, unless - # the `__init__.py` file is at the root. - if len(path_parts) >= 2 and path_parts[-1] == "__init__": - path_parts = path_parts[:-1] - - return path_parts + 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: diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 4f8b5b07ba4..1ff5e7f2fc8 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -669,7 +669,7 @@ 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): + 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 From 1110b6881cdbe75821caa3f31ba60cad78458968 Mon Sep 17 00:00:00 2001 From: Philipp A Date: Mon, 12 Feb 2024 10:06:51 +0100 Subject: [PATCH 5/6] Discard changes to pyproject.toml --- pyproject.toml | 80 -------------------------------------------------- 1 file changed, 80 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 19f6332e715..72988e23387 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -280,86 +280,6 @@ template = "changelog/_template.rst" name = "Trivial/Internal Changes" showcontent = true -[tool.black] -target-version = ['py38'] - -# check-wheel-contents is executed by the build-and-inspect-python-package action. -[tool.check-wheel-contents] -# W009: Wheel contains multiple toplevel library entries -ignore = "W009" - -[tool.ruff] -src = ["src"] -line-length = 88 -select = [ - "B", # bugbear - "D", # pydocstyle - "E", # pycodestyle - "F", # pyflakes - "I", # isort - "PYI", # flake8-pyi - "UP", # pyupgrade - "RUF", # ruff - "W", # pycodestyle -] -ignore = [ - # bugbear ignore - "B004", # Using `hasattr(x, "__call__")` to test if x is callable is unreliable. - "B007", # Loop control variable `i` not used within loop body - "B009", # Do not call `getattr` with a constant attribute value - "B010", # [*] Do not call `setattr` with a constant attribute value. - "B011", # Do not `assert False` (`python -O` removes these calls) - "B028", # No explicit `stacklevel` keyword argument found - # pycodestyle ignore - # pytest can do weird low-level things, and we usually know - # what we're doing when we use type(..) is ... - "E721", # Do not compare types, use `isinstance()` - # pydocstyle ignore - "D100", # Missing docstring in public module - "D101", # Missing docstring in public class - "D102", # Missing docstring in public method - "D103", # Missing docstring in public function - "D104", # Missing docstring in public package - "D105", # Missing docstring in magic method - "D106", # Missing docstring in public nested class - "D107", # Missing docstring in `__init__` - "D209", # [*] Multi-line docstring closing quotes should be on a separate line - "D205", # 1 blank line required between summary line and description - "D400", # First line should end with a period - "D401", # First line of docstring should be in imperative mood - "D402", # First line should not be the function's signature - "D404", # First word of the docstring should not be "This" - "D415", # First line should end with a period, question mark, or exclamation point - # ruff ignore - "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` -] - -[tool.ruff.format] -docstring-code-format = true - -[tool.ruff.lint] -allowed-confusables = ["’", "×"] - -[tool.ruff.lint.pycodestyle] -# In order to be able to format for 88 char in ruff format -max-line-length = 120 - -[tool.ruff.lint.pydocstyle] -convention = "pep257" - -[tool.ruff.lint.isort] -force-single-line = true -combine-as-imports = true -force-sort-within-sections = true -order-by-type = false -known-local-folder = ["pytest", "_pytest"] -lines-after-imports = 2 - -[tool.ruff.lint.per-file-ignores] -"src/_pytest/_py/**/*.py" = ["B", "PYI"] -"src/_pytest/_version.py" = ["I001"] -"testing/python/approx.py" = ["B015"] - [tool.mypy] mypy_path = ["src"] check_untyped_defs = true From 9e6e2635c9a49d4b299b7bb900967d6e00dd4c0c Mon Sep 17 00:00:00 2001 From: Philipp A Date: Mon, 12 Feb 2024 10:14:19 +0100 Subject: [PATCH 6/6] Update pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) 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