Skip to content
Open
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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -527,3 +527,4 @@ Zachary OBrien
Zhouxin Qiu
Zoltán Máté
Zsolt Cserna
NIK-TIGER-BILL
1 change: 1 addition & 0 deletions changelog/14514.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added ``python_dotted_filenames`` ini option to replace dots in test file names with underscores when computing module names, enabling pytest to collect tests from files like ``test.foo.py``.
28 changes: 22 additions & 6 deletions src/_pytest/pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ def import_path(
mode: str | ImportMode = ImportMode.prepend,
root: Path,
consider_namespace_packages: bool,
dotted_filenames: bool = False,
) -> ModuleType:
"""
Import and return a module from the given path, which can be a file (a module) or
Expand Down Expand Up @@ -550,7 +551,9 @@ def import_path(
# without touching sys.path.
try:
_, module_name = resolve_pkg_root_and_module_name(
path, consider_namespace_packages=consider_namespace_packages
path,
consider_namespace_packages=consider_namespace_packages,
dotted_filenames=dotted_filenames,
)
except CouldNotResolvePathError:
pass
Expand All @@ -576,7 +579,9 @@ def import_path(

try:
pkg_root, module_name = resolve_pkg_root_and_module_name(
path, consider_namespace_packages=consider_namespace_packages
path,
consider_namespace_packages=consider_namespace_packages,
dotted_filenames=dotted_filenames,
)
except CouldNotResolvePathError:
pkg_root, module_name = path.parent, path.stem
Expand Down Expand Up @@ -845,7 +850,10 @@ def resolve_package_path(path: Path) -> Path | None:


def resolve_pkg_root_and_module_name(
path: Path, *, consider_namespace_packages: bool = False
path: Path,
*,
consider_namespace_packages: bool = False,
dotted_filenames: bool = False,
) -> tuple[Path, str]:
"""
Return the path to the directory of the root package that contains the
Expand Down Expand Up @@ -874,14 +882,18 @@ def resolve_pkg_root_and_module_name(
if consider_namespace_packages:
start = pkg_root if pkg_root is not None else path.parent
for candidate in (start, *start.parents):
module_name = compute_module_name(candidate, path)
module_name = compute_module_name(
candidate, path, dotted_filenames=dotted_filenames
)
if module_name and is_importable(module_name, path):
# Point the pkg_root to the root of the namespace package.
pkg_root = candidate
break

if pkg_root is not None:
module_name = compute_module_name(pkg_root, path)
module_name = compute_module_name(
pkg_root, path, dotted_filenames=dotted_filenames
)
if module_name:
return pkg_root, module_name

Expand Down Expand Up @@ -914,7 +926,9 @@ def is_importable(module_name: str, module_path: Path) -> bool:
return spec_matches_module_path(spec, module_path)


def compute_module_name(root: Path, module_path: Path) -> str | None:
def compute_module_name(
root: Path, module_path: Path, *, dotted_filenames: bool = False
) -> str | None:
"""Compute a module name based on a path and a root anchor."""
try:
path_without_suffix = module_path.with_suffix("")
Expand All @@ -931,6 +945,8 @@ def compute_module_name(root: Path, module_path: Path) -> str | None:
return None
if names[-1] == "__init__":
names.pop()
if dotted_filenames:
names = [name.replace(".", "_") for name in names]
return ".".join(names)


Expand Down
7 changes: 7 additions & 0 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ def pytest_addoption(parser: Parser) -> None:
default=None,
help="Emit an error if non-unique parameter set IDs are detected",
)
parser.addini(
"python_dotted_filenames",
type="bool",
default=False,
help="Replace dots in test file names with underscores when computing module names",
)


def pytest_generate_tests(metafunc: Metafunc) -> None:
Expand Down Expand Up @@ -510,6 +516,7 @@ def importtestmodule(
mode=importmode,
root=config.rootpath,
consider_namespace_packages=config.getini("consider_namespace_packages"),
dotted_filenames=config.getini("python_dotted_filenames"),
)
except SyntaxError as e:
raise nodes.Collector.CollectError(
Expand Down
53 changes: 53 additions & 0 deletions testing/test_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,45 @@ def test_messy_name(self, tmp_path: Path, ns_param: bool) -> None:
module = import_path(path, root=tmp_path, consider_namespace_packages=ns_param)
assert module.__name__ == "foo__init__"

def test_dotted_filename(self, tmp_path: Path, ns_param: bool) -> None:
"""import_path handles dotted filenames when dotted_filenames=True."""
# Create a package so resolve_pkg_root_and_module_name works.
pkg = tmp_path / "pkg"
pkg.mkdir()
(pkg / "__init__.py").touch()
p = pkg / "test.bar.py"
p.touch()
mod = import_path(
p,
root=tmp_path,
mode=ImportMode.importlib,
consider_namespace_packages=ns_param,
dotted_filenames=True,
)
assert mod.__name__ == "pkg.test_bar"
# Without dotted_filenames, the dot is kept in the module name.
mod2 = import_path(
p,
root=tmp_path,
mode=ImportMode.importlib,
consider_namespace_packages=ns_param,
dotted_filenames=False,
)
assert mod2.__name__ == "pkg.test.bar"

def test_dotted_filename_ini(self, pytester: Pytester) -> None:
"""python_dotted_filenames ini option replaces dots in test file names."""
pytester.makeini(
"[pytest]\npython_dotted_filenames = true\npython_files = test_*.py *.py\n"
)
pkg = pytester.path / "pkg"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
(pkg / "test.bar.py").write_text("def test_foo(): pass\n")
result = pytester.runpytest("-v", "--import-mode=importlib")
result.assert_outcomes(passed=1)
result.stdout.fnmatch_lines(["*pkg/test.bar.py::test_foo PASSED*"])

def test_dir(self, tmp_path: Path, ns_param: bool) -> None:
p = tmp_path / "hello_123"
p.mkdir()
Expand Down Expand Up @@ -1734,6 +1773,20 @@ def test_compute_module_name(tmp_path: Path) -> None:
== "src.app.bar"
)

# dotted_filenames replaces dots in file names with underscores.
assert (
compute_module_name(
tmp_path, tmp_path / "src/app/test.bar.py", dotted_filenames=True
)
== "src.app.test_bar"
)
assert (
compute_module_name(
tmp_path, tmp_path / "src/app/test.bar.py", dotted_filenames=False
)
== "src.app.test.bar"
)


def validate_namespace_package(
pytester: Pytester, paths: Sequence[Path], modules: Sequence[str]
Expand Down
Loading