Skip to content

Commit cfc03a2

Browse files
committed
module __file__ attribute does not have the right casing
1 parent 9af6d46 commit cfc03a2

File tree

8 files changed

+130
-24
lines changed

8 files changed

+130
-24
lines changed

src/_pytest/_py/os_path.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import os
2+
from types import ModuleType
3+
from typing import Optional
4+
5+
6+
def module_casesensitivepath(module: ModuleType) -> Optional[str]:
7+
"""Return the canonical __file__ of the module without resolving symlinks."""
8+
path = module.__file__
9+
if path is None:
10+
return None
11+
return casesensitivepath(path)
12+
13+
14+
def casesensitivepath(path: str) -> str:
15+
"""Return the case-sensitive version of the path."""
16+
resolved_path = os.path.realpath(path)
17+
if resolved_path.lower() == path.lower():
18+
return resolved_path
19+
# Patch has one or more symlinks. Todo: find the correct path casing.
20+
return path

src/_pytest/_py/path.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from typing import TYPE_CHECKING
3131

3232
from . import error
33+
from . import os_path
3334

3435
# Moved from local.py.
3536
iswin32 = sys.platform == "win32" or (getattr(os, "_name", False) == "nt")
@@ -1126,7 +1127,7 @@ def pyimport(self, modname=None, ensuresyspath=True):
11261127
if self.basename == "__init__.py":
11271128
return mod # we don't check anything as we might
11281129
# be in a namespace package ... too icky to check
1129-
modfile = mod.__file__
1130+
modfile = os_path.module_casesensitivepath(mod)
11301131
assert modfile is not None
11311132
if modfile[-4:] in (".pyc", ".pyo"):
11321133
modfile = modfile[:-1]

src/_pytest/config/__init__.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from _pytest._code import ExceptionInfo
5353
from _pytest._code import filter_traceback
5454
from _pytest._io import TerminalWriter
55+
from _pytest._py.os_path import module_casesensitivepath
5556
from _pytest.outcomes import fail
5657
from _pytest.outcomes import Skipped
5758
from _pytest.pathlib import absolutepath
@@ -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_casesensitivepath(conftestmodule))
756754

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

src/_pytest/fixtures.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from _pytest._code.code import FormattedExcinfo
3838
from _pytest._code.code import TerminalRepr
3939
from _pytest._io import TerminalWriter
40+
from _pytest._py.os_path import casesensitivepath
4041
from _pytest.compat import _PytestWrapper
4142
from _pytest.compat import assert_never
4243
from _pytest.compat import get_real_func
@@ -1485,17 +1486,22 @@ def getfixtureinfo(
14851486

14861487
def pytest_plugin_registered(self, plugin: _PluggyPlugin) -> None:
14871488
nodeid = None
1489+
14881490
try:
1489-
p = absolutepath(plugin.__file__) # type: ignore[attr-defined]
1491+
module_file: str = plugin.__file__ # type: ignore[attr-defined]
14901492
except AttributeError:
14911493
pass
14921494
else:
1495+
module_absfile = Path(casesensitivepath(str(absolutepath(module_file))))
1496+
14931497
# Construct the base nodeid which is later used to check
14941498
# what fixtures are visible for particular tests (as denoted
14951499
# by their test id).
1496-
if p.name == "conftest.py":
1500+
if module_absfile.name == "conftest.py":
14971501
try:
1498-
nodeid = str(p.parent.relative_to(self.config.rootpath))
1502+
nodeid = str(
1503+
module_absfile.parent.relative_to(self.config.rootpath)
1504+
)
14991505
except ValueError:
15001506
nodeid = ""
15011507
if nodeid == ".":

src/_pytest/helpconfig.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import Union
99

1010
import pytest
11+
from _pytest._py.os_path import module_casesensitivepath
1112
from _pytest.config import Config
1213
from _pytest.config import ExitCode
1314
from _pytest.config import PrintHelp
@@ -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_casesensitivepath(plugin)
269270
else:
270271
r = repr(plugin)
271272
lines.append(f" {name:<20}: {r}")

src/_pytest/pathlib.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from typing import TypeVar
3636
from typing import Union
3737

38+
from _pytest._py.os_path import module_casesensitivepath
3839
from _pytest.compat import assert_never
3940
from _pytest.outcomes import skip
4041
from _pytest.warning_types import PytestWarning
@@ -572,7 +573,7 @@ def import_path(
572573

573574
ignore = os.environ.get("PY_IGNORE_IMPORTMISMATCH", "")
574575
if ignore != "1":
575-
module_file = mod.__file__
576+
module_file = module_casesensitivepath(mod)
576577
if module_file is None:
577578
raise ImportPathMismatchError(module_name, module_file, path)
578579

testing/test_os_utils.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import sys
2+
from pathlib import Path
3+
from types import ModuleType
4+
5+
from _pytest._py import os_path
6+
7+
_ON_CASEINSENSITIVE_OS = sys.platform.startswith("win")
8+
9+
10+
def test_casesensitivepath(tmp_path: Path) -> None:
11+
dirname_with_caps = tmp_path / "Testdir"
12+
dirname_with_caps.mkdir()
13+
real_filename = dirname_with_caps / "_test_casesensitivepath.py"
14+
with real_filename.open("wb"):
15+
pass
16+
real_linkname = dirname_with_caps / "_test_casesensitivepath_link.py"
17+
real_linkname.symlink_to(real_filename)
18+
19+
# Test path resolving
20+
21+
original = str(real_filename)
22+
expected = str(real_filename)
23+
assert os_path.casesensitivepath(original) == expected
24+
25+
original = str(real_filename).lower()
26+
if _ON_CASEINSENSITIVE_OS:
27+
expected = str(real_filename)
28+
else:
29+
expected = str(real_filename).lower()
30+
assert os_path.casesensitivepath(original) == expected
31+
32+
# Test symlink preservation
33+
34+
original = str(real_linkname)
35+
expected = str(real_linkname)
36+
assert os_path.casesensitivepath(original) == expected
37+
38+
original = str(real_linkname).lower()
39+
expected = str(real_linkname).lower()
40+
assert os_path.casesensitivepath(original) == expected
41+
42+
43+
def test_module_casesensitivepath(tmp_path: Path) -> None:
44+
dirname_with_caps = tmp_path / "Testdir"
45+
dirname_with_caps.mkdir()
46+
real_filename = dirname_with_caps / "_test_module_casesensitivepath.py"
47+
with real_filename.open("wb"):
48+
pass
49+
real_linkname = dirname_with_caps / "_test_module_casesensitivepath_link.py"
50+
real_linkname.symlink_to(real_filename)
51+
52+
mod = ModuleType("dummy.name")
53+
54+
mod.__file__ = None
55+
assert os_path.module_casesensitivepath(mod) is None
56+
57+
# Test path resolving
58+
59+
original = str(real_filename)
60+
expected = str(real_filename)
61+
mod.__file__ = original
62+
assert os_path.module_casesensitivepath(mod) == expected
63+
64+
original = str(real_filename).lower()
65+
if _ON_CASEINSENSITIVE_OS:
66+
expected = str(real_filename)
67+
else:
68+
expected = str(real_filename).lower()
69+
mod.__file__ = original
70+
assert os_path.module_casesensitivepath(mod) == expected
71+
72+
# Test symlink preservation
73+
74+
original = str(real_linkname)
75+
expected = str(real_linkname)
76+
mod.__file__ = original
77+
assert os_path.module_casesensitivepath(mod) == expected
78+
79+
original = str(real_linkname).lower()
80+
expected = str(real_linkname).lower()
81+
mod.__file__ = original
82+
assert os_path.module_casesensitivepath(mod) == expected

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)