diff --git a/changelog/15187.bugfix.rst b/changelog/15187.bugfix.rst new file mode 100644 index 00000000000..27f285e2804 --- /dev/null +++ b/changelog/15187.bugfix.rst @@ -0,0 +1,3 @@ +Directory collection is now cached when multiple file path arguments share a +parent directory (unless ``--keep-duplicates`` is set), avoiding repeated +directory scans during collection. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index d43a68b4679..ab754a96449 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -992,11 +992,21 @@ def collect(self) -> Iterator[nodes.Item | nodes.Collector]: else: # For backward compat, files given directly multiple # times on the command line should not be deduplicated. - handle_dupes = not ( + # That intent applies to File nodes (handled in genitems); + # Directory/Package parents must still use the collection + # cache, otherwise N file args from the same directory + # re-walk the directory N times (#15187). Keep the cache + # off under --keep-duplicates so a file named twice still + # gets two Module nodes. + file_arg_leaf = ( len(matchparts) == 1 and isinstance(matchparts[0], Path) and matchparts[0].is_file() ) + handle_dupes = ( + isinstance(matchnode, nodes.Directory) + and not self.config.option.keepduplicates + ) or not file_arg_leaf rep, duplicate = self._collect_one_node(matchnode, handle_dupes) if not duplicate and not rep.passed: # Report collection failures here to avoid failing to diff --git a/testing/test_collection.py b/testing/test_collection.py index f7cdc60a1c3..881c560ff59 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -2807,3 +2807,32 @@ def test1(x): "Duplicates: a", ] ) + + +def test_directory_collection_cached_for_multiple_file_args(pytester: Pytester) -> None: + """File args from one directory must not re-collect that directory each time. + + See https://github.com/pytest-dev/pytest/issues/15187 + """ + calls: dict[str, int] = {} + + class Plugin: + def pytest_collect_file(self, file_path: Path) -> None: + calls[file_path.name] = calls.get(file_path.name, 0) + 1 + + for i in range(5): + pytester.makepyfile(**{f"test_{i:04d}": "def test_x():\n pass\n"}) + + files = [pytester.path / f"test_{i:04d}.py" for i in range(5)] + result = pytester.runpytest( + "--collect-only", + "-q", + "-p", + "no:cacheprovider", + *map(str, files), + plugins=[Plugin()], + ) + assert result.ret == 0 + # Each file offered once (plus possibly non-test files; assert the repeated ones) + for i in range(5): + assert calls.get(f"test_{i:04d}.py", 0) == 1, calls