Skip to content

Commit b55bedd

Browse files
committed
module __file__ attribute is not the canonical path
1 parent 9af6d46 commit b55bedd

File tree

7 files changed

+75
-28
lines changed

7 files changed

+75
-28
lines changed

src/_pytest/_py/path.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,7 @@ def pyimport(self, modname=None, ensuresyspath=True):
11281128
# be in a namespace package ... too icky to check
11291129
modfile = mod.__file__
11301130
assert modfile is not None
1131+
modfile = os.path.realpath(modfile)
11311132
if modfile[-4:] in (".pyc", ".pyo"):
11321133
modfile = modfile[:-1]
11331134
elif modfile.endswith("$py.class"):

src/_pytest/config/__init__.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
from _pytest.pathlib import bestrelpath
5959
from _pytest.pathlib import import_path
6060
from _pytest.pathlib import ImportMode
61+
from _pytest.pathlib import module_realfile
6162
from _pytest.pathlib import resolve_package_path
6263
from _pytest.pathlib import safe_exists
6364
from _pytest.stash import Stash
@@ -631,8 +632,7 @@ def _rget_with_confmod(
631632
def _importconftest(
632633
self, conftestpath: Path, importmode: Union[str, ImportMode], rootpath: Path
633634
) -> types.ModuleType:
634-
conftestpath_plugin_name = str(conftestpath)
635-
existing = self.get_plugin(conftestpath_plugin_name)
635+
existing = self.get_plugin(str(conftestpath))
636636
if existing is not None:
637637
return cast(types.ModuleType, existing)
638638

@@ -668,7 +668,7 @@ def _importconftest(
668668
)
669669
mods.append(mod)
670670
self.trace(f"loading conftestmodule {mod!r}")
671-
self.consider_conftest(mod, registration_name=conftestpath_plugin_name)
671+
self.consider_conftest(mod)
672672
return mod
673673

674674
def _check_non_top_pytest_plugins(
@@ -748,11 +748,9 @@ def consider_pluginarg(self, arg: str) -> None:
748748
del self._name2plugin["pytest_" + name]
749749
self.import_plugin(arg, consider_entry_points=True)
750750

751-
def consider_conftest(
752-
self, conftestmodule: types.ModuleType, registration_name: str
753-
) -> None:
751+
def consider_conftest(self, conftestmodule: types.ModuleType) -> None:
754752
""":meta private:"""
755-
self.register(conftestmodule, name=registration_name)
753+
self.register(conftestmodule, name=module_realfile(conftestmodule))
756754

757755
def consider_env(self) -> None:
758756
""":meta private:"""

src/_pytest/fixtures.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
from _pytest.outcomes import TEST_OUTCOME
6363
from _pytest.pathlib import absolutepath
6464
from _pytest.pathlib import bestrelpath
65+
from _pytest.pathlib import module_realfile
6566
from _pytest.scope import _ScopeName
6667
from _pytest.scope import HIGH_SCOPES
6768
from _pytest.scope import Scope
@@ -1486,16 +1487,20 @@ def getfixtureinfo(
14861487
def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None:
14871488
nodeid = None
14881489
try:
1489-
p = absolutepath(plugin.__file__) # type: ignore[attr-defined]
1490+
module_file = module_realfile(plugin) # type: ignore[arg-type]
14901491
except AttributeError:
1491-
pass
1492-
else:
1493-
# Construct the base nodeid which is later used to check
1494-
# what fixtures are visible for particular tests (as denoted
1495-
# by their test id).
1496-
if p.name == "conftest.py":
1492+
module_file = None
1493+
1494+
# Construct the base nodeid which is later used to check
1495+
# what fixtures are visible for particular tests (as denoted
1496+
# by their test id).
1497+
if module_file is not None:
1498+
module_purefile = Path(module_file)
1499+
if module_purefile.name == "conftest.py":
14971500
try:
1498-
nodeid = str(p.parent.relative_to(self.config.rootpath))
1501+
nodeid = str(
1502+
module_purefile.parent.relative_to(self.config.rootpath)
1503+
)
14991504
except ValueError:
15001505
nodeid = ""
15011506
if nodeid == ".":

src/_pytest/helpconfig.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from _pytest.config import ExitCode
1313
from _pytest.config import PrintHelp
1414
from _pytest.config.argparsing import Parser
15+
from _pytest.pathlib import module_realfile
1516
from _pytest.terminal import TerminalReporter
1617

1718

@@ -265,7 +266,7 @@ def pytest_report_header(config: Config) -> List[str]:
265266
items = config.pluginmanager.list_name_plugin()
266267
for name, plugin in items:
267268
if hasattr(plugin, "__file__"):
268-
r = plugin.__file__
269+
r = module_realfile(plugin)
269270
else:
270271
r = repr(plugin)
271272
lines.append(f" {name:<20}: {r}")

src/_pytest/pathlib.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,7 @@ def import_path(
572572

573573
ignore = os.environ.get("PY_IGNORE_IMPORTMISMATCH", "")
574574
if ignore != "1":
575-
module_file = mod.__file__
575+
module_file = module_realfile(mod)
576576
if module_file is None:
577577
raise ImportPathMismatchError(module_name, module_file, path)
578578

@@ -788,3 +788,14 @@ def safe_exists(p: Path) -> bool:
788788
# ValueError: stat: path too long for Windows
789789
# OSError: [WinError 123] The filename, directory name, or volume label syntax is incorrect
790790
return False
791+
792+
793+
def module_realfile(module: ModuleType) -> Optional[str]:
794+
"""Return the canonical __file__ of the module without resolving symlinks."""
795+
filename = module.__file__
796+
if filename is None:
797+
return None
798+
resolved_filename = os.path.realpath(filename)
799+
if resolved_filename.lower() == filename.lower():
800+
return resolved_filename
801+
return filename

testing/test_pathlib.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from _pytest.pathlib import insert_missing_modules
2525
from _pytest.pathlib import maybe_delete_a_numbered_dir
2626
from _pytest.pathlib import module_name_from_path
27+
from _pytest.pathlib import module_realfile
2728
from _pytest.pathlib import resolve_package_path
2829
from _pytest.pathlib import safe_exists
2930
from _pytest.pathlib import symlink_or_skip
@@ -712,3 +713,36 @@ def test_safe_exists(tmp_path: Path) -> None:
712713
side_effect=ValueError("name too long"),
713714
):
714715
assert safe_exists(p) is False
716+
717+
718+
@pytest.mark.skipif(
719+
not sys.platform.startswith("win"),
720+
reason="requires a case-insensitive file system",
721+
)
722+
def test_module_realfile(tmp_path: Path) -> None:
723+
dirname_with_caps = tmp_path / "Testdir"
724+
dirname_with_caps.mkdir()
725+
filename = dirname_with_caps / "_test_safe_exists.py"
726+
linkname = dirname_with_caps / "_test_safe_exists_link.py"
727+
linkname.symlink_to(filename)
728+
729+
mod = ModuleType("dummy.name")
730+
731+
mod.__file__ = None
732+
assert module_realfile(mod) is None
733+
734+
# Test path resolving
735+
736+
mod.__file__ = str(filename)
737+
assert module_realfile(mod) == str(filename)
738+
739+
mod.__file__ = str(filename).lower()
740+
assert module_realfile(mod) == str(filename)
741+
742+
# Test symlink preservation
743+
744+
mod.__file__ = str(linkname)
745+
assert module_realfile(mod) == mod.__file__
746+
747+
mod.__file__ = str(linkname).lower()
748+
assert module_realfile(mod) == mod.__file__

testing/test_pluginmanager.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -118,17 +118,14 @@ def test_conftestpath_case_sensitivity(self, pytester: Pytester) -> None:
118118
plugin = config.pluginmanager.get_plugin(str(conftest))
119119
assert plugin is mod
120120

121-
mod_uppercase = config.pluginmanager._importconftest(
122-
conftest_upper_case,
123-
importmode="prepend",
124-
rootpath=pytester.path,
125-
)
121+
with pytest.raises(ValueError, match="Plugin name already registered"):
122+
config.pluginmanager._importconftest(
123+
conftest_upper_case,
124+
importmode="prepend",
125+
rootpath=pytester.path,
126+
)
126127
plugin_uppercase = config.pluginmanager.get_plugin(str(conftest_upper_case))
127-
assert plugin_uppercase is mod_uppercase
128-
129-
# No str(conftestpath) normalization so conftest should be imported
130-
# twice and modules should be different objects
131-
assert mod is not mod_uppercase
128+
assert plugin_uppercase is None
132129

133130
def test_hook_tracing(self, _config_for_test: Config) -> None:
134131
pytestpm = _config_for_test.pluginmanager # fully initialized with plugins
@@ -400,7 +397,7 @@ def test_consider_conftest_deps(
400397
pytester.makepyfile("pytest_plugins='xyz'"), root=pytester.path
401398
)
402399
with pytest.raises(ImportError):
403-
pytestpm.consider_conftest(mod, registration_name="unused")
400+
pytestpm.consider_conftest(mod)
404401

405402

406403
class TestPytestPluginManagerBootstrapming:

0 commit comments

Comments
 (0)