Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

module __file__ attribute is not the canonical path #11821

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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 @@
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