From cfc03a285228a0a331bd7160db2cc2067aba64f5 Mon Sep 17 00:00:00 2001 From: woutdenolf Date: Mon, 15 Jan 2024 21:46:48 +0100 Subject: [PATCH] module __file__ attribute does not have the right casing --- src/_pytest/_py/os_path.py | 20 +++++++++ src/_pytest/_py/path.py | 3 +- src/_pytest/config/__init__.py | 12 +++-- src/_pytest/fixtures.py | 12 +++-- src/_pytest/helpconfig.py | 3 +- src/_pytest/pathlib.py | 3 +- testing/test_os_utils.py | 82 ++++++++++++++++++++++++++++++++++ testing/test_pluginmanager.py | 19 ++++---- 8 files changed, 130 insertions(+), 24 deletions(-) create mode 100644 src/_pytest/_py/os_path.py create mode 100644 testing/test_os_utils.py diff --git a/src/_pytest/_py/os_path.py b/src/_pytest/_py/os_path.py new file mode 100644 index 00000000000..7651112dcbf --- /dev/null +++ b/src/_pytest/_py/os_path.py @@ -0,0 +1,20 @@ +import os +from types import ModuleType +from typing import Optional + + +def module_casesensitivepath(module: ModuleType) -> Optional[str]: + """Return the canonical __file__ of the module without resolving symlinks.""" + path = module.__file__ + if path is None: + return None + return casesensitivepath(path) + + +def casesensitivepath(path: str) -> str: + """Return the case-sensitive version of the path.""" + resolved_path = os.path.realpath(path) + if resolved_path.lower() == path.lower(): + return resolved_path + # Patch has one or more symlinks. Todo: find the correct path casing. + return path diff --git a/src/_pytest/_py/path.py b/src/_pytest/_py/path.py index 24348525a3e..fb500031762 100644 --- a/src/_pytest/_py/path.py +++ b/src/_pytest/_py/path.py @@ -30,6 +30,7 @@ from typing import TYPE_CHECKING from . import error +from . import os_path # Moved from local.py. iswin32 = sys.platform == "win32" or (getattr(os, "_name", False) == "nt") @@ -1126,7 +1127,7 @@ def pyimport(self, modname=None, ensuresyspath=True): if self.basename == "__init__.py": return mod # we don't check anything as we might # be in a namespace package ... too icky to check - modfile = mod.__file__ + modfile = os_path.module_casesensitivepath(mod) assert modfile is not None if modfile[-4:] in (".pyc", ".pyo"): modfile = modfile[:-1] diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 6986166649c..2e02490d958 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -52,6 +52,7 @@ from _pytest._code import ExceptionInfo from _pytest._code import filter_traceback from _pytest._io import TerminalWriter +from _pytest._py.os_path import module_casesensitivepath from _pytest.outcomes import fail from _pytest.outcomes import Skipped from _pytest.pathlib import absolutepath @@ -631,8 +632,7 @@ def _rget_with_confmod( def _importconftest( self, conftestpath: Path, importmode: Union[str, ImportMode], rootpath: Path ) -> types.ModuleType: - conftestpath_plugin_name = str(conftestpath) - existing = self.get_plugin(conftestpath_plugin_name) + existing = self.get_plugin(str(conftestpath)) if existing is not None: return cast(types.ModuleType, existing) @@ -668,7 +668,7 @@ def _importconftest( ) mods.append(mod) self.trace(f"loading conftestmodule {mod!r}") - self.consider_conftest(mod, registration_name=conftestpath_plugin_name) + self.consider_conftest(mod) return mod def _check_non_top_pytest_plugins( @@ -748,11 +748,9 @@ def consider_pluginarg(self, arg: str) -> None: del self._name2plugin["pytest_" + name] self.import_plugin(arg, consider_entry_points=True) - def consider_conftest( - self, conftestmodule: types.ModuleType, registration_name: str - ) -> None: + def consider_conftest(self, conftestmodule: types.ModuleType) -> None: """:meta private:""" - self.register(conftestmodule, name=registration_name) + self.register(conftestmodule, name=module_casesensitivepath(conftestmodule)) def consider_env(self) -> None: """:meta private:""" diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index c294ec586b5..04343cc2ba5 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -37,6 +37,7 @@ from _pytest._code.code import FormattedExcinfo from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter +from _pytest._py.os_path import casesensitivepath from _pytest.compat import _PytestWrapper from _pytest.compat import assert_never from _pytest.compat import get_real_func @@ -1485,17 +1486,22 @@ def getfixtureinfo( def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None: nodeid = None + try: - p = absolutepath(plugin.__file__) # type: ignore[attr-defined] + module_file: str = plugin.__file__ # type: ignore[attr-defined] except AttributeError: pass else: + module_absfile = Path(casesensitivepath(str(absolutepath(module_file)))) + # Construct the base nodeid which is later used to check # what fixtures are visible for particular tests (as denoted # by their test id). - if p.name == "conftest.py": + if module_absfile.name == "conftest.py": try: - nodeid = str(p.parent.relative_to(self.config.rootpath)) + nodeid = str( + module_absfile.parent.relative_to(self.config.rootpath) + ) except ValueError: nodeid = "" if nodeid == ".": diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 364bf4c4276..92391e61188 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -8,6 +8,7 @@ from typing import Union import pytest +from _pytest._py.os_path import module_casesensitivepath from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import PrintHelp @@ -265,7 +266,7 @@ def pytest_report_header(config: Config) -> List[str]: items = config.pluginmanager.list_name_plugin() for name, plugin in items: if hasattr(plugin, "__file__"): - r = plugin.__file__ + r = module_casesensitivepath(plugin) else: r = repr(plugin) lines.append(f" {name:<20}: {r}") diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 4cd635ed7e1..71371f72f56 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -35,6 +35,7 @@ from typing import TypeVar from typing import Union +from _pytest._py.os_path import module_casesensitivepath from _pytest.compat import assert_never from _pytest.outcomes import skip from _pytest.warning_types import PytestWarning @@ -572,7 +573,7 @@ def import_path( ignore = os.environ.get("PY_IGNORE_IMPORTMISMATCH", "") if ignore != "1": - module_file = mod.__file__ + module_file = module_casesensitivepath(mod) if module_file is None: raise ImportPathMismatchError(module_name, module_file, path) diff --git a/testing/test_os_utils.py b/testing/test_os_utils.py new file mode 100644 index 00000000000..3d7ac2c87c0 --- /dev/null +++ b/testing/test_os_utils.py @@ -0,0 +1,82 @@ +import sys +from pathlib import Path +from types import ModuleType + +from _pytest._py import os_path + +_ON_CASEINSENSITIVE_OS = sys.platform.startswith("win") + + +def test_casesensitivepath(tmp_path: Path) -> None: + dirname_with_caps = tmp_path / "Testdir" + dirname_with_caps.mkdir() + real_filename = dirname_with_caps / "_test_casesensitivepath.py" + with real_filename.open("wb"): + pass + real_linkname = dirname_with_caps / "_test_casesensitivepath_link.py" + real_linkname.symlink_to(real_filename) + + # Test path resolving + + original = str(real_filename) + expected = str(real_filename) + assert os_path.casesensitivepath(original) == expected + + original = str(real_filename).lower() + if _ON_CASEINSENSITIVE_OS: + expected = str(real_filename) + else: + expected = str(real_filename).lower() + assert os_path.casesensitivepath(original) == expected + + # Test symlink preservation + + original = str(real_linkname) + expected = str(real_linkname) + assert os_path.casesensitivepath(original) == expected + + original = str(real_linkname).lower() + expected = str(real_linkname).lower() + assert os_path.casesensitivepath(original) == expected + + +def test_module_casesensitivepath(tmp_path: Path) -> None: + dirname_with_caps = tmp_path / "Testdir" + dirname_with_caps.mkdir() + real_filename = dirname_with_caps / "_test_module_casesensitivepath.py" + with real_filename.open("wb"): + pass + real_linkname = dirname_with_caps / "_test_module_casesensitivepath_link.py" + real_linkname.symlink_to(real_filename) + + mod = ModuleType("dummy.name") + + mod.__file__ = None + assert os_path.module_casesensitivepath(mod) is None + + # Test path resolving + + original = str(real_filename) + expected = str(real_filename) + mod.__file__ = original + assert os_path.module_casesensitivepath(mod) == expected + + original = str(real_filename).lower() + if _ON_CASEINSENSITIVE_OS: + expected = str(real_filename) + else: + expected = str(real_filename).lower() + mod.__file__ = original + assert os_path.module_casesensitivepath(mod) == expected + + # Test symlink preservation + + original = str(real_linkname) + expected = str(real_linkname) + mod.__file__ = original + assert os_path.module_casesensitivepath(mod) == expected + + original = str(real_linkname).lower() + expected = str(real_linkname).lower() + mod.__file__ = original + assert os_path.module_casesensitivepath(mod) == expected diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 8bafde33846..929051e4a2a 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -118,17 +118,14 @@ def test_conftestpath_case_sensitivity(self, pytester: Pytester) -> None: plugin = config.pluginmanager.get_plugin(str(conftest)) assert plugin is mod - mod_uppercase = config.pluginmanager._importconftest( - conftest_upper_case, - importmode="prepend", - rootpath=pytester.path, - ) + with pytest.raises(ValueError, match="Plugin name already registered"): + config.pluginmanager._importconftest( + conftest_upper_case, + importmode="prepend", + rootpath=pytester.path, + ) plugin_uppercase = config.pluginmanager.get_plugin(str(conftest_upper_case)) - assert plugin_uppercase is mod_uppercase - - # No str(conftestpath) normalization so conftest should be imported - # twice and modules should be different objects - assert mod is not mod_uppercase + assert plugin_uppercase is None def test_hook_tracing(self, _config_for_test: Config) -> None: pytestpm = _config_for_test.pluginmanager # fully initialized with plugins @@ -400,7 +397,7 @@ def test_consider_conftest_deps( pytester.makepyfile("pytest_plugins='xyz'"), root=pytester.path ) with pytest.raises(ImportError): - pytestpm.consider_conftest(mod, registration_name="unused") + pytestpm.consider_conftest(mod) class TestPytestPluginManagerBootstrapming: