From 0f572824e9038c62133fb8124b9ebbfca457377f Mon Sep 17 00:00:00 2001 From: NIK-TIGER-BILL Date: Sun, 24 May 2026 23:33:46 +0000 Subject: [PATCH 1/2] feat: add python_dotted_filenames ini option to replace dots in test filenames with underscores This adds an opt-in ini option python_dotted_filenames that replaces dots in test file names with underscores when computing module names. This allows pytest to collect tests from files like test.foo.py. Closes pytest-dev/pytest #14514 Signed-off-by: NIK-TIGER-BILL --- AUTHORS | 1 + changelog/14514.feature.rst | 1 + src/_pytest/pathlib.py | 19 ++++++++++----- src/_pytest/python.py | 7 ++++++ testing/test_pathlib.py | 47 +++++++++++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 changelog/14514.feature.rst diff --git a/AUTHORS b/AUTHORS index 106d9b8f7f2..c0f164ba9ee 100644 --- a/AUTHORS +++ b/AUTHORS @@ -527,3 +527,4 @@ Zachary OBrien Zhouxin Qiu Zoltán Máté Zsolt Cserna +NIK-TIGER-BILL diff --git a/changelog/14514.feature.rst b/changelog/14514.feature.rst new file mode 100644 index 00000000000..420d73eb5c9 --- /dev/null +++ b/changelog/14514.feature.rst @@ -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``. diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 291bdf4ecbf..e5610850016 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -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 @@ -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 @@ -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 @@ -845,7 +850,7 @@ 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 @@ -874,14 +879,14 @@ 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 @@ -914,7 +919,7 @@ 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("") @@ -931,6 +936,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) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index ad5a2c6a59b..cf7329874da 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -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: @@ -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( diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index bd85b7e8fb4..9f014d72c5e 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -242,6 +242,43 @@ 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() @@ -1734,6 +1771,16 @@ 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] From 2f0a10d5268b7efaa39ecc84f039cf3741db957c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 23:37:05 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/pathlib.py | 17 +++++++++++++---- testing/test_pathlib.py | 12 +++++++++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index e5610850016..d4466c79158 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -850,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, dotted_filenames: 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 @@ -879,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, dotted_filenames=dotted_filenames) + 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, dotted_filenames=dotted_filenames) + module_name = compute_module_name( + pkg_root, path, dotted_filenames=dotted_filenames + ) if module_name: return pkg_root, module_name @@ -919,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, *, dotted_filenames: bool = False) -> 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("") diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 9f014d72c5e..a313fe887b2 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -270,7 +270,9 @@ def test_dotted_filename(self, tmp_path: Path, ns_param: bool) -> None: 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") + pytester.makeini( + "[pytest]\npython_dotted_filenames = true\npython_files = test_*.py *.py\n" + ) pkg = pytester.path / "pkg" pkg.mkdir() (pkg / "__init__.py").write_text("") @@ -1773,11 +1775,15 @@ def test_compute_module_name(tmp_path: Path) -> None: # 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) + 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) + compute_module_name( + tmp_path, tmp_path / "src/app/test.bar.py", dotted_filenames=False + ) == "src.app.test.bar" )