Skip to content

Commit

Permalink
Add consider_namespace_packages ini option
Browse files Browse the repository at this point in the history
  • Loading branch information
nicoddemus authored and flying-sheep committed Apr 9, 2024
1 parent a4e7691 commit 14be646
Show file tree
Hide file tree
Showing 10 changed files with 315 additions and 77 deletions.
68 changes: 59 additions & 9 deletions src/_pytest/config/__init__.py
Expand Up @@ -547,6 +547,8 @@ def _set_initial_conftests(
confcutdir: Optional[Path],
invocation_dir: Path,
importmode: Union[ImportMode, str],
*,
consider_namespace_packages: bool,
) -> None:
"""Load initial conftest files given a preparsed "namespace".
Expand All @@ -572,10 +574,20 @@ def _set_initial_conftests(
# Ensure we do not break if what appears to be an anchor
# is in fact a very long option (#10169, #11394).
if safe_exists(anchor):
self._try_load_conftest(anchor, importmode, rootpath)
self._try_load_conftest(
anchor,
importmode,
rootpath,
consider_namespace_packages=consider_namespace_packages,
)
foundanchor = True
if not foundanchor:
self._try_load_conftest(invocation_dir, importmode, rootpath)
self._try_load_conftest(
invocation_dir,
importmode,
rootpath,
consider_namespace_packages=consider_namespace_packages,
)

def _is_in_confcutdir(self, path: Path) -> bool:
"""Whether to consider the given path to load conftests from."""
Expand All @@ -593,17 +605,37 @@ def _is_in_confcutdir(self, path: Path) -> bool:
return path not in self._confcutdir.parents

def _try_load_conftest(
self, anchor: Path, importmode: Union[str, ImportMode], rootpath: Path
self,
anchor: Path,
importmode: Union[str, ImportMode],
rootpath: Path,
*,
consider_namespace_packages: bool,
) -> None:
self._loadconftestmodules(anchor, importmode, rootpath)
self._loadconftestmodules(
anchor,
importmode,
rootpath,
consider_namespace_packages=consider_namespace_packages,
)
# let's also consider test* subdirs
if anchor.is_dir():
for x in anchor.glob("test*"):
if x.is_dir():
self._loadconftestmodules(x, importmode, rootpath)
self._loadconftestmodules(
x,
importmode,
rootpath,
consider_namespace_packages=consider_namespace_packages,
)

def _loadconftestmodules(
self, path: Path, importmode: Union[str, ImportMode], rootpath: Path
self,
path: Path,
importmode: Union[str, ImportMode],
rootpath: Path,
*,
consider_namespace_packages: bool,
) -> None:
if self._noconftest:
return
Expand All @@ -620,7 +652,12 @@ def _loadconftestmodules(
if self._is_in_confcutdir(parent):
conftestpath = parent / "conftest.py"
if conftestpath.is_file():
mod = self._importconftest(conftestpath, importmode, rootpath)
mod = self._importconftest(
conftestpath,
importmode,
rootpath,
consider_namespace_packages=consider_namespace_packages,
)
clist.append(mod)
self._dirpath2confmods[directory] = clist

Expand All @@ -642,7 +679,12 @@ def _rget_with_confmod(
raise KeyError(name)

def _importconftest(
self, conftestpath: Path, importmode: Union[str, ImportMode], rootpath: Path
self,
conftestpath: Path,
importmode: Union[str, ImportMode],
rootpath: Path,
*,
consider_namespace_packages: bool,
) -> types.ModuleType:
conftestpath_plugin_name = str(conftestpath)
existing = self.get_plugin(conftestpath_plugin_name)
Expand All @@ -661,7 +703,12 @@ def _importconftest(
pass

try:
mod = import_path(conftestpath, mode=importmode, root=rootpath)
mod = import_path(
conftestpath,
mode=importmode,
root=rootpath,
consider_namespace_packages=consider_namespace_packages,
)
except Exception as e:
assert e.__traceback__ is not None
raise ConftestImportFailure(conftestpath, cause=e) from e
Expand Down Expand Up @@ -1177,6 +1224,9 @@ def pytest_load_initial_conftests(self, early_config: "Config") -> None:
confcutdir=early_config.known_args_namespace.confcutdir,
invocation_dir=early_config.invocation_params.dir,
importmode=early_config.known_args_namespace.importmode,
consider_namespace_packages=early_config.getini(
"consider_namespace_packages"
),
)

def _initini(self, args: Sequence[str]) -> None:
Expand Down
6 changes: 6 additions & 0 deletions src/_pytest/main.py
Expand Up @@ -222,6 +222,12 @@ def pytest_addoption(parser: Parser) -> None:
help="Prepend/append to sys.path when importing test modules and conftest "
"files. Default: prepend.",
)
parser.addini(
"consider_namespace_packages",
type="bool",
default=False,
help="Consider namespace packages when resolving module names during import",
)

group = parser.getgroup("debugconfig", "test session debugging and configuration")
group.addoption(
Expand Down
16 changes: 10 additions & 6 deletions src/_pytest/pathlib.py
Expand Up @@ -488,6 +488,7 @@ def import_path(
*,
mode: Union[str, ImportMode] = ImportMode.prepend,
root: Path,
consider_namespace_packages: bool,
) -> ModuleType:
"""
Import and return a module from the given path, which can be a file (a module) or
Expand Down Expand Up @@ -515,6 +516,9 @@ def import_path(
a unique name for the module being imported so it can safely be stored
into ``sys.modules``.
:param consider_namespace_packages:
If True, consider namespace packages when resolving module names.
:raises ImportPathMismatchError:
If after importing the given `path` and the module `__file__`
are different. Only raised in `prepend` and `append` modes.
Expand All @@ -530,7 +534,7 @@ def import_path(
# without touching sys.path.
try:
pkg_root, module_name = resolve_pkg_root_and_module_name(
path, consider_ns_packages=True
path, consider_namespace_packages=consider_namespace_packages
)
except CouldNotResolvePathError:
pass
Expand All @@ -556,7 +560,7 @@ def import_path(

try:
pkg_root, module_name = resolve_pkg_root_and_module_name(
path, consider_ns_packages=True
path, consider_namespace_packages=consider_namespace_packages
)
except CouldNotResolvePathError:
pkg_root, module_name = path.parent, path.stem
Expand Down Expand Up @@ -674,7 +678,7 @@ def module_name_from_path(path: Path, root: Path) -> str:
# Module names cannot contain ".", normalize them to "_". This prevents
# a directory having a "." in the name (".env.310" for example) causing extra intermediate modules.
# Also, important to replace "." at the start of paths, as those are considered relative imports.
path_parts = [x.replace(".", "_") for x in path_parts]
path_parts = tuple(x.replace(".", "_") for x in path_parts)

return ".".join(path_parts)

Expand Down Expand Up @@ -738,7 +742,7 @@ def resolve_package_path(path: Path) -> Optional[Path]:


def resolve_pkg_root_and_module_name(
path: Path, *, consider_ns_packages: bool = False
path: Path, *, consider_namespace_packages: bool = False
) -> Tuple[Path, str]:
"""
Return the path to the directory of the root package that contains the
Expand All @@ -753,7 +757,7 @@ def resolve_pkg_root_and_module_name(
Passing the full path to `models.py` will yield Path("src") and "app.core.models".
If consider_ns_packages is True, then we additionally check upwards in the hierarchy
If consider_namespace_packages is True, then we additionally check upwards in the hierarchy
until we find a directory that is reachable from sys.path, which marks it as a namespace package:
https://packaging.python.org/en/latest/guides/packaging-namespace-packages
Expand All @@ -764,7 +768,7 @@ def resolve_pkg_root_and_module_name(
if pkg_path is not None:
pkg_root = pkg_path.parent
# https://packaging.python.org/en/latest/guides/packaging-namespace-packages/
if consider_ns_packages:
if consider_namespace_packages:
# Go upwards in the hierarchy, if we find a parent path included
# in sys.path, it means the package found by resolve_package_path()
# actually belongs to a namespace package.
Expand Down
7 changes: 6 additions & 1 deletion src/_pytest/python.py
Expand Up @@ -516,7 +516,12 @@ def importtestmodule(
# We assume we are only called once per module.
importmode = config.getoption("--import-mode")
try:
mod = import_path(path, mode=importmode, root=config.rootpath)
mod = import_path(
path,
mode=importmode,
root=config.rootpath,
consider_namespace_packages=config.getini("consider_namespace_packages"),
)
except SyntaxError as e:
raise nodes.Collector.CollectError(
ExceptionInfo.from_current().getrepr(style="short")
Expand Down
3 changes: 3 additions & 0 deletions src/_pytest/runner.py
Expand Up @@ -380,6 +380,9 @@ def collect() -> List[Union[Item, Collector]]:
collector.path,
collector.config.getoption("importmode"),
rootpath=collector.config.rootpath,
consider_namespace_packages=collector.config.getini(
"consider_namespace_packages"
),
)

return list(collector.collect())
Expand Down
6 changes: 4 additions & 2 deletions testing/code/test_excinfo.py
Expand Up @@ -180,7 +180,7 @@ def test_traceback_cut(self) -> None:
def test_traceback_cut_excludepath(self, pytester: Pytester) -> None:
p = pytester.makepyfile("def f(): raise ValueError")
with pytest.raises(ValueError) as excinfo:
import_path(p, root=pytester.path).f() # type: ignore[attr-defined]
import_path(p, root=pytester.path, consider_namespace_packages=False).f() # type: ignore[attr-defined]
basedir = Path(pytest.__file__).parent
newtraceback = excinfo.traceback.cut(excludepath=basedir)
for x in newtraceback:
Expand Down Expand Up @@ -543,7 +543,9 @@ def importasmod(source):
tmp_path.joinpath("__init__.py").touch()
modpath.write_text(source, encoding="utf-8")
importlib.invalidate_caches()
return import_path(modpath, root=tmp_path)
return import_path(
modpath, root=tmp_path, consider_namespace_packages=False
)

return importasmod

Expand Down
2 changes: 1 addition & 1 deletion testing/code/test_source.py
Expand Up @@ -296,7 +296,7 @@ def method(self):
)
path = tmp_path.joinpath("a.py")
path.write_text(str(source), encoding="utf-8")
mod: Any = import_path(path, root=tmp_path)
mod: Any = import_path(path, root=tmp_path, consider_namespace_packages=False)
s2 = Source(mod.A)
assert str(source).strip() == str(s2).strip()

Expand Down

0 comments on commit 14be646

Please sign in to comment.