Skip to content

Commit

Permalink
module __file__ attribute does not have the right casing
Browse files Browse the repository at this point in the history
  • Loading branch information
woutdenolf committed Jan 16, 2024
1 parent 9af6d46 commit da14c05
Show file tree
Hide file tree
Showing 8 changed files with 130 additions and 24 deletions.
20 changes: 20 additions & 0 deletions src/_pytest/_py/os_path.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion src/_pytest/_py/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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]
Expand Down
12 changes: 5 additions & 7 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:"""
Expand Down
12 changes: 9 additions & 3 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 == ".":
Expand Down
3 changes: 2 additions & 1 deletion src/_pytest/helpconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down
3 changes: 2 additions & 1 deletion src/_pytest/pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
82 changes: 82 additions & 0 deletions testing/test_os_utils.py
Original file line number Diff line number Diff line change
@@ -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)

Check warning on line 27 in testing/test_os_utils.py

View check run for this annotation

Codecov / codecov/patch

testing/test_os_utils.py#L27

Added line #L27 was not covered by tests
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)

Check warning on line 66 in testing/test_os_utils.py

View check run for this annotation

Codecov / codecov/patch

testing/test_os_utils.py#L66

Added line #L66 was not covered by tests
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
19 changes: 8 additions & 11 deletions testing/test_pluginmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Check warning on line 122 in testing/test_pluginmanager.py

View check run for this annotation

Codecov / codecov/patch

testing/test_pluginmanager.py#L122

Added line #L122 was not covered by tests
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

Check warning on line 128 in testing/test_pluginmanager.py

View check run for this annotation

Codecov / codecov/patch

testing/test_pluginmanager.py#L128

Added line #L128 was not covered by tests

def test_hook_tracing(self, _config_for_test: Config) -> None:
pytestpm = _config_for_test.pluginmanager # fully initialized with plugins
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit da14c05

Please sign in to comment.