From b55bedde3ccae6461311f4435fe2290bfff4d357 Mon Sep 17 00:00:00 2001 From: woutdenolf Date: Mon, 15 Jan 2024 21:46:48 +0100 Subject: [PATCH] module __file__ attribute is not the canonical path --- src/_pytest/_py/path.py | 1 + src/_pytest/config/__init__.py | 12 +++++------- src/_pytest/fixtures.py | 21 +++++++++++++-------- src/_pytest/helpconfig.py | 3 ++- src/_pytest/pathlib.py | 13 ++++++++++++- testing/test_pathlib.py | 34 ++++++++++++++++++++++++++++++++++ testing/test_pluginmanager.py | 19 ++++++++----------- 7 files changed, 75 insertions(+), 28 deletions(-) diff --git a/src/_pytest/_py/path.py b/src/_pytest/_py/path.py index 24348525a3e..e96daee1558 100644 --- a/src/_pytest/_py/path.py +++ b/src/_pytest/_py/path.py @@ -1128,6 +1128,7 @@ def pyimport(self, modname=None, ensuresyspath=True): # be in a namespace package ... too icky to check modfile = mod.__file__ assert modfile is not None + modfile = os.path.realpath(modfile) if modfile[-4:] in (".pyc", ".pyo"): modfile = modfile[:-1] elif modfile.endswith("$py.class"): diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 6986166649c..46320d63508 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -58,6 +58,7 @@ from _pytest.pathlib import bestrelpath from _pytest.pathlib import import_path from _pytest.pathlib import ImportMode +from _pytest.pathlib import module_realfile from _pytest.pathlib import resolve_package_path from _pytest.pathlib import safe_exists from _pytest.stash import Stash @@ -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_realfile(conftestmodule)) def consider_env(self) -> None: """:meta private:""" diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index c294ec586b5..4a3d11421bb 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -62,6 +62,7 @@ from _pytest.outcomes import TEST_OUTCOME from _pytest.pathlib import absolutepath from _pytest.pathlib import bestrelpath +from _pytest.pathlib import module_realfile from _pytest.scope import _ScopeName from _pytest.scope import HIGH_SCOPES from _pytest.scope import Scope @@ -1486,16 +1487,20 @@ def getfixtureinfo( def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None: nodeid = None try: - p = absolutepath(plugin.__file__) # type: ignore[attr-defined] + module_file = module_realfile(plugin) # type: ignore[arg-type] except AttributeError: - pass - else: - # 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": + module_file = None + + # Construct the base nodeid which is later used to check + # what fixtures are visible for particular tests (as denoted + # by their test id). + if module_file is not None: + module_purefile = Path(module_file) + if module_purefile.name == "conftest.py": try: - nodeid = str(p.parent.relative_to(self.config.rootpath)) + nodeid = str( + module_purefile.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..5bea0be9d14 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -12,6 +12,7 @@ from _pytest.config import ExitCode from _pytest.config import PrintHelp from _pytest.config.argparsing import Parser +from _pytest.pathlib import module_realfile from _pytest.terminal import TerminalReporter @@ -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_realfile(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..81bab079a4c 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -572,7 +572,7 @@ def import_path( ignore = os.environ.get("PY_IGNORE_IMPORTMISMATCH", "") if ignore != "1": - module_file = mod.__file__ + module_file = module_realfile(mod) if module_file is None: raise ImportPathMismatchError(module_name, module_file, path) @@ -788,3 +788,14 @@ def safe_exists(p: Path) -> bool: # ValueError: stat: path too long for Windows # OSError: [WinError 123] The filename, directory name, or volume label syntax is incorrect return False + + +def module_realfile(module: ModuleType) -> Optional[str]: + """Return the canonical __file__ of the module without resolving symlinks.""" + filename = module.__file__ + if filename is None: + return None + resolved_filename = os.path.realpath(filename) + if resolved_filename.lower() == filename.lower(): + return resolved_filename + return filename diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 3e1d2265bb7..fdff7cf2877 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -24,6 +24,7 @@ from _pytest.pathlib import insert_missing_modules from _pytest.pathlib import maybe_delete_a_numbered_dir from _pytest.pathlib import module_name_from_path +from _pytest.pathlib import module_realfile from _pytest.pathlib import resolve_package_path from _pytest.pathlib import safe_exists from _pytest.pathlib import symlink_or_skip @@ -712,3 +713,36 @@ def test_safe_exists(tmp_path: Path) -> None: side_effect=ValueError("name too long"), ): assert safe_exists(p) is False + + +@pytest.mark.skipif( + not sys.platform.startswith("win"), + reason="requires a case-insensitive file system", +) +def test_module_realfile(tmp_path: Path) -> None: + dirname_with_caps = tmp_path / "Testdir" + dirname_with_caps.mkdir() + filename = dirname_with_caps / "_test_safe_exists.py" + linkname = dirname_with_caps / "_test_safe_exists_link.py" + linkname.symlink_to(filename) + + mod = ModuleType("dummy.name") + + mod.__file__ = None + assert module_realfile(mod) is None + + # Test path resolving + + mod.__file__ = str(filename) + assert module_realfile(mod) == str(filename) + + mod.__file__ = str(filename).lower() + assert module_realfile(mod) == str(filename) + + # Test symlink preservation + + mod.__file__ = str(linkname) + assert module_realfile(mod) == mod.__file__ + + mod.__file__ = str(linkname).lower() + assert module_realfile(mod) == mod.__file__ 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: