From 1efe0db4d1bfb6f8bf1a6656b5ed72a98d6e3a63 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 11 Sep 2025 21:48:23 +0300 Subject: [PATCH 1/2] main: put parametrization in a separate field on CollectionArgument This is for the benefit of the next commit. That commit wants to check whether a CollectionArgument is subsumed by another. According to pytest semantics: `test_it.py::TestIt::test_it[a]` subsumed by `test_it.py::TestIt::test_it` However the `parts` are ["TestIt", test_it[a]"] ["TestIt", test_it"] which means a simple list prefix cannot be used. By splitting the parametrization `"[a]"` part to its own attribute, it can be handled cleanly. I also think this is a reasonable change regardless. We'd probably want something like this when the "collection structure contains parametrization" TODO is tackled. --- src/_pytest/main.py | 31 ++++++++++++++++++++----------- testing/test_main.py | 21 ++++++++++++++++++++- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index b1eb22f1f61..0276313b365 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -859,6 +859,7 @@ def collect(self) -> Iterator[nodes.Item | nodes.Collector]: argpath = collection_argument.path names = collection_argument.parts + parametrization = collection_argument.parametrization module_name = collection_argument.module_name # resolve_collection_argument() ensures this. @@ -943,12 +944,18 @@ def collect(self) -> Iterator[nodes.Item | nodes.Collector]: # Name part e.g. `TestIt` in `/a/b/test_file.py::TestIt::test_it`. else: - # TODO: Remove parametrized workaround once collection structure contains - # parametrization. - is_match = ( - node.name == matchparts[0] - or node.name.split("[")[0] == matchparts[0] - ) + if len(matchparts) == 1: + # This the last part, one parametrization goes. + if parametrization is not None: + # A parametrized arg must match exactly. + is_match = node.name == matchparts[0] + parametrization + else: + # A non-parameterized arg matches all parametrizations (if any). + # TODO: Remove the hacky split once the collection structure + # contains parametrization. + is_match = node.name.split("[")[0] == matchparts[0] + else: + is_match = node.name == matchparts[0] if is_match: work.append((node, matchparts[1:])) any_matched_in_collector = True @@ -1024,6 +1031,7 @@ class CollectionArgument: path: Path parts: Sequence[str] + parametrization: str | None module_name: str | None @@ -1052,7 +1060,7 @@ def resolve_collection_argument( When as_pypath is True, expects that the command-line argument actually contains module paths instead of file-system paths: - "pkg.tests.test_foo::TestClass::test_foo" + "pkg.tests.test_foo::TestClass::test_foo[a,b]" In which case we search sys.path for a matching module, and then return the *path* to the found module, which may look like this: @@ -1060,6 +1068,7 @@ def resolve_collection_argument( CollectionArgument( path=Path("/home/u/myvenv/lib/site-packages/pkg/tests/test_foo.py"), parts=["TestClass", "test_foo"], + parametrization="[a,b]", module_name="pkg.tests.test_foo", ) @@ -1068,10 +1077,9 @@ def resolve_collection_argument( """ base, squacket, rest = arg.partition("[") strpath, *parts = base.split("::") - if squacket: - if not parts: - raise UsageError(f"path cannot contain [] parametrization: {arg}") - parts[-1] = f"{parts[-1]}{squacket}{rest}" + if squacket and not parts: + raise UsageError(f"path cannot contain [] parametrization: {arg}") + parametrization = f"{squacket}{rest}" if squacket else None module_name = None if as_pypath: pyarg_strpath = search_pypath( @@ -1099,5 +1107,6 @@ def resolve_collection_argument( return CollectionArgument( path=fspath, parts=parts, + parametrization=parametrization, module_name=module_name, ) diff --git a/testing/test_main.py b/testing/test_main.py index 3f173ec4e9f..37b887b0c66 100644 --- a/testing/test_main.py +++ b/testing/test_main.py @@ -125,6 +125,7 @@ def test_file(self, invocation_path: Path) -> None: ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=[], + parametrization=None, module_name=None, ) assert resolve_collection_argument( @@ -132,6 +133,7 @@ def test_file(self, invocation_path: Path) -> None: ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=[""], + parametrization=None, module_name=None, ) assert resolve_collection_argument( @@ -139,6 +141,7 @@ def test_file(self, invocation_path: Path) -> None: ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=["foo", "bar"], + parametrization=None, module_name=None, ) assert resolve_collection_argument( @@ -146,6 +149,15 @@ def test_file(self, invocation_path: Path) -> None: ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=["foo", "bar", ""], + parametrization=None, + module_name=None, + ) + assert resolve_collection_argument( + invocation_path, "src/pkg/test.py::foo::bar[a,b,c]" + ) == CollectionArgument( + path=invocation_path / "src/pkg/test.py", + parts=["foo", "bar"], + parametrization="[a,b,c]", module_name=None, ) @@ -156,6 +168,7 @@ def test_dir(self, invocation_path: Path) -> None: ) == CollectionArgument( path=invocation_path / "src/pkg", parts=[], + parametrization=None, module_name=None, ) @@ -181,6 +194,7 @@ def test_pypath(self, namespace_package: bool, invocation_path: Path) -> None: ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=[], + parametrization=None, module_name="pkg.test", ) assert resolve_collection_argument( @@ -188,6 +202,7 @@ def test_pypath(self, namespace_package: bool, invocation_path: Path) -> None: ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=["foo", "bar"], + parametrization=None, module_name="pkg.test", ) assert resolve_collection_argument( @@ -198,6 +213,7 @@ def test_pypath(self, namespace_package: bool, invocation_path: Path) -> None: ) == CollectionArgument( path=invocation_path / "src/pkg", parts=[], + parametrization=None, module_name="pkg", ) @@ -216,7 +232,8 @@ def test_parametrized_name_with_colons(self, invocation_path: Path) -> None: invocation_path, "src/pkg/test.py::test[a::b]" ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", - parts=["test[a::b]"], + parts=["test"], + parametrization="[a::b]", module_name=None, ) @@ -257,6 +274,7 @@ def test_absolute_paths_are_resolved_correctly(self, invocation_path: Path) -> N ) == CollectionArgument( path=Path(os.path.abspath("src")), parts=[], + parametrization=None, module_name=None, ) @@ -268,6 +286,7 @@ def test_absolute_paths_are_resolved_correctly(self, invocation_path: Path) -> N ) == CollectionArgument( path=Path(os.path.abspath("src")), parts=[], + parametrization=None, module_name=None, ) From 6764439f3fb53c64f897742865f99c7b803f20ad Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 6 Sep 2025 20:25:21 +0300 Subject: [PATCH 2/2] main: add explicit handling of overlapping collection arguments Consider a pytest invocation like `pytest tests/ tests/test_it.py`. What should happen? Currently what happens is that only `tests/test_it.py` is run, which is obviously wrong. This regressed in the big package collection rework (PR #11646). The reason it regressed is the way pytest collection works. See #12083 for (some) details. I have made an attempt to fix the problem directly in the collection loop, but failed. The main challenge is the node caching, i.e. when should a collector node be reused when it is needed for several collection arguments. I believe it is possible to make it work, but it's hard. In order to not leave this embarrassing bug lingering for any longer, this instead takes an easier approach, which is to massage the collection argument list itself such that issues with overlapping nodes don't come up during collection at all. This *adds* complexity instead of simplifying things, but I hope it should be good enough in practice for now, and maybe we can revisit in the future. This change introduces behavioral changes, mainly: - `pytest a/b a/` is equivalent to `pytest a`; if there is an `a/a` then `a/b` will *not* be ordered before `a/a`. So the ability to order a subset before a superset is lost. - `pytest x.py x.py` does *not* run the file twice; previously we took an explicit request like this to mean that it should. The `--keep-duplicates` option remains as a sort of "expert mode" that retains its current behavior; though it is still subtly broken in that *collector nodes* are also duplicated (not just the items). A fix for that requires the harder change. Fix #12083. --- changelog/12083.breaking.rst | 9 + doc/en/example/pythoncollection.rst | 14 +- src/_pytest/main.py | 77 +++- testing/test_collection.py | 670 ++++++++++++++++++++++++++++ testing/test_junitxml.py | 2 +- testing/test_main.py | 49 +- testing/test_mark.py | 2 +- 7 files changed, 783 insertions(+), 40 deletions(-) create mode 100644 changelog/12083.breaking.rst diff --git a/changelog/12083.breaking.rst b/changelog/12083.breaking.rst new file mode 100644 index 00000000000..53a8393dfb4 --- /dev/null +++ b/changelog/12083.breaking.rst @@ -0,0 +1,9 @@ +Fixed a bug where an invocation such as `pytest a/ a/b` would cause only tests from `a/b` to run, and not other tests under `a/`. + +The fix entails a few breaking changes to how such overlapping arguments and duplicates are handled: + +1. `pytest a/b a/` or `pytest a/ a/b` are equivalent to `pytest a`; if an argument overlaps another arguments, only the prefix remains. + +2. `pytest x.py x.py` is equivalent to `pytest x.py`; previously such an invocation was taken as an explicit request to run the tests from the file twice. + +If you rely on these behaviors, consider using :ref:`--keep-duplicates `, which retains its existing behavior (including the bug). diff --git a/doc/en/example/pythoncollection.rst b/doc/en/example/pythoncollection.rst index 2487e7b9d19..9aada00345a 100644 --- a/doc/en/example/pythoncollection.rst +++ b/doc/en/example/pythoncollection.rst @@ -55,6 +55,8 @@ You can run all of the tests within ``tests/`` *except* for ``tests/foobar/test_ by invoking ``pytest`` with ``--deselect tests/foobar/test_foobar_01.py::test_a``. ``pytest`` allows multiple ``--deselect`` options. +.. _duplicate-paths: + Keeping duplicate paths specified from command line ---------------------------------------------------- @@ -82,18 +84,6 @@ Example: collected 2 items ... -As the collector just works on directories, if you specify twice a single test file, ``pytest`` will -still collect it twice, no matter if the ``--keep-duplicates`` is not specified. -Example: - -.. code-block:: pytest - - pytest test_a.py test_a.py - - ... - collected 2 items - ... - Changing directory recursion ----------------------------------------------------- diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 0276313b365..893dee90e84 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -781,14 +781,25 @@ def perform_collect( try: initialpaths: list[Path] = [] initialpaths_with_parents: list[Path] = [] - for arg in args: - collection_argument = resolve_collection_argument( + + collection_args = [ + resolve_collection_argument( self.config.invocation_params.dir, arg, + i, as_pypath=self.config.option.pyargs, consider_namespace_packages=consider_namespace_packages, ) - self._initial_parts.append(collection_argument) + for i, arg in enumerate(args) + ] + + if not self.config.getoption("keepduplicates"): + # Normalize the collection arguments -- remove duplicates and overlaps. + self._initial_parts = normalize_collection_arguments(collection_args) + else: + self._initial_parts = collection_args + + for collection_argument in self._initial_parts: initialpaths.append(collection_argument.path) initialpaths_with_parents.append(collection_argument.path) initialpaths_with_parents.extend(collection_argument.path.parents) @@ -976,12 +987,9 @@ def genitems(self, node: nodes.Item | nodes.Collector) -> Iterator[nodes.Item]: yield node else: assert isinstance(node, nodes.Collector) - keepduplicates = self.config.getoption("keepduplicates") # For backward compat, dedup only applies to files. - handle_dupes = not (keepduplicates and isinstance(node, nodes.File)) + handle_dupes = not isinstance(node, nodes.File) rep, duplicate = self._collect_one_node(node, handle_dupes) - if duplicate and not keepduplicates: - return if rep.passed: for subnode in rep.result: yield from self.genitems(subnode) @@ -1033,11 +1041,13 @@ class CollectionArgument: parts: Sequence[str] parametrization: str | None module_name: str | None + original_index: int def resolve_collection_argument( invocation_path: Path, arg: str, + arg_index: int, *, as_pypath: bool = False, consider_namespace_packages: bool = False, @@ -1109,4 +1119,57 @@ def resolve_collection_argument( parts=parts, parametrization=parametrization, module_name=module_name, + original_index=arg_index, + ) + + +def is_collection_argument_subsumed_by( + arg: CollectionArgument, by: CollectionArgument +) -> bool: + """Check if `arg` is subsumed (contained) by `by`.""" + # First check path subsumption. + if by.path != arg.path: + # `by` subsumes `arg` if `by` is a parent directory of `arg` and has no + # parts (collects everything in that directory). + if not by.parts: + return arg.path.is_relative_to(by.path) + return False + # Paths are equal, check parts. + # For example: ("TestClass",) is a prefix of ("TestClass", "test_method"). + if len(by.parts) > len(arg.parts) or arg.parts[: len(by.parts)] != by.parts: + return False + # Paths and parts are equal, check parametrization. + # A `by` without parametrization (None) matches everything, e.g. + # `pytest x.py::test_it` matches `x.py::test_it[0]`. Otherwise must be + # exactly equal. + if by.parametrization is not None and by.parametrization != arg.parametrization: + return False + return True + + +def normalize_collection_arguments( + collection_args: Sequence[CollectionArgument], +) -> list[CollectionArgument]: + """Normalize collection arguments to eliminate overlapping paths and parts. + + Detects when collection arguments overlap in either paths or parts and only + keeps the shorter prefix, or the earliest argument if duplicate, preserving + order. The result is prefix-free. + """ + # A quadratic algorithm is not acceptable since large inputs are possible. + # So this uses an O(n*log(n)) algorithm which takes advantage of the + # property that after sorting, a collection argument will immediately + # precede collection arguments it subsumes. An O(n) algorithm is not worth + # it. + collection_args_sorted = sorted( + collection_args, + key=lambda arg: (arg.path, arg.parts, arg.parametrization or ""), ) + normalized: list[CollectionArgument] = [] + last_kept = None + for arg in collection_args_sorted: + if last_kept is None or not is_collection_argument_subsumed_by(arg, last_kept): + normalized.append(arg) + last_kept = arg + normalized.sort(key=lambda arg: arg.original_index) + return normalized diff --git a/testing/test_collection.py b/testing/test_collection.py index 2214c130a05..5d2aa6cb981 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -2031,3 +2031,673 @@ def test_namespace_packages(pytester: Pytester, import_mode: str): " ", ] ) + + +class TestOverlappingCollectionArguments: + """Test that overlapping collection arguments (e.g. `pytest a/b a + a/c::TestIt) are handled correctly (#12083).""" + + @pytest.mark.parametrize("args", [("a", "a/b"), ("a/b", "a")]) + def test_parent_child(self, pytester: Pytester, args: tuple[str, ...]) -> None: + """Test that 'pytest a a/b' and `pytest a/b a` collects all tests from 'a'.""" + pytester.makepyfile( + **{ + "a/test_a.py": """ + def test_a1(): pass + def test_a2(): pass + """, + "a/b/test_b.py": """ + def test_b1(): pass + def test_b2(): pass + """, + } + ) + + result = pytester.runpytest("--collect-only", *args) + + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + def test_multiple_nested_paths(self, pytester: Pytester) -> None: + """Test that 'pytest a/b a a/b/c' collects all tests from 'a'.""" + pytester.makepyfile( + **{ + "a/test_a.py": """ + def test_a(): pass + """, + "a/b/test_b.py": """ + def test_b(): pass + """, + "a/b/c/test_c.py": """ + def test_c(): pass + """, + } + ) + + result = pytester.runpytest("--collect-only", "a/b", "a", "a/b/c") + + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + def test_same_path_twice(self, pytester: Pytester) -> None: + """Test that 'pytest a a' doesn't duplicate tests.""" + pytester.makepyfile( + **{ + "a/test_a.py": """ + def test_a(): pass + """, + } + ) + + result = pytester.runpytest("--collect-only", "a", "a") + + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + def test_keep_duplicates_flag(self, pytester: Pytester) -> None: + """Test that --keep-duplicates allows duplication.""" + pytester.makepyfile( + **{ + "a/test_a.py": """ + def test_a(): pass + """, + "a/b/test_b.py": """ + def test_b(): pass + """, + } + ) + + result = pytester.runpytest("--collect-only", "--keep-duplicates", "a", "a/b") + + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + def test_specific_file_then_parent_dir(self, pytester: Pytester) -> None: + """Test that 'pytest a/test_a.py a' collects all tests from 'a'.""" + pytester.makepyfile( + **{ + "a/test_a.py": """ + def test_a(): pass + """, + "a/test_other.py": """ + def test_other(): pass + """, + } + ) + + result = pytester.runpytest("--collect-only", "a/test_a.py", "a") + + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + def test_package_scope_fixture_with_overlapping_paths( + self, pytester: Pytester + ) -> None: + """Test that package-scoped fixtures work correctly with overlapping paths.""" + pytester.makepyfile( + **{ + "pkg/__init__.py": "", + "pkg/test_pkg.py": """ + import pytest + + counter = {"value": 0} + + @pytest.fixture(scope="package") + def pkg_fixture(): + counter["value"] += 1 + return counter["value"] + + def test_pkg1(pkg_fixture): + assert pkg_fixture == 1 + + def test_pkg2(pkg_fixture): + assert pkg_fixture == 1 + """, + "pkg/sub/__init__.py": "", + "pkg/sub/test_sub.py": """ + def test_sub(): pass + """, + } + ) + + # Package fixture should run only once even with overlapping paths. + result = pytester.runpytest("pkg", "pkg/sub", "pkg", "-v") + result.assert_outcomes(passed=3) + + def test_execution_order_preserved(self, pytester: Pytester) -> None: + """Test that test execution order follows argument order.""" + pytester.makepyfile( + **{ + "a/test_a.py": """ + def test_a(): pass + """, + "b/test_b.py": """ + def test_b(): pass + """, + } + ) + + result = pytester.runpytest("--collect-only", "b", "a", "b/test_b.py::test_b") + + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + def test_overlapping_node_ids_class_and_method(self, pytester: Pytester) -> None: + """Test that overlapping node IDs are handled correctly.""" + pytester.makepyfile( + test_nodeids=""" + class TestClass: + def test_method1(self): pass + def test_method2(self): pass + def test_method3(self): pass + + def test_function(): pass + """ + ) + + # Class then specific method. + result = pytester.runpytest( + "--collect-only", + "test_nodeids.py::TestClass", + "test_nodeids.py::TestClass::test_method2", + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + # Specific method then class. + result = pytester.runpytest( + "--collect-only", + "test_nodeids.py::TestClass::test_method3", + "test_nodeids.py::TestClass", + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + def test_overlapping_node_ids_file_and_class(self, pytester: Pytester) -> None: + """Test that file-level and class-level selections work correctly.""" + pytester.makepyfile( + test_file=""" + class TestClass: + def test_method(self): pass + + class TestOther: + def test_other(self): pass + + def test_function(): pass + """ + ) + + # File then class. + result = pytester.runpytest( + "--collect-only", "test_file.py", "test_file.py::TestClass" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + # Class then file. + result = pytester.runpytest( + "--collect-only", "test_file.py::TestClass", "test_file.py" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + def test_same_node_id_twice(self, pytester: Pytester) -> None: + """Test that the same node ID specified twice is collected only once.""" + pytester.makepyfile( + test_dup=""" + def test_one(): pass + def test_two(): pass + """ + ) + + result = pytester.runpytest( + "--collect-only", + "test_dup.py::test_one", + "test_dup.py::test_one", + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + "", + ], + consecutive=True, + ) + + def test_overlapping_with_parametrization(self, pytester: Pytester) -> None: + """Test overlapping with parametrized tests.""" + pytester.makepyfile( + test_param=""" + import pytest + + @pytest.mark.parametrize("n", [1, 2]) + def test_param(n): pass + + class TestClass: + @pytest.mark.parametrize("x", ["a", "b"]) + def test_method(self, x): pass + """ + ) + + result = pytester.runpytest( + "--collect-only", + "test_param.py::test_param[2]", + "test_param.py::TestClass::test_method[a]", + "test_param.py", + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + result = pytester.runpytest( + "--collect-only", + "test_param.py::test_param[2]", + "test_param.py::test_param", + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + @pytest.mark.parametrize("order", [(".", "a"), ("a", ".")]) + def test_root_and_subdir(self, pytester: Pytester, order: tuple[str, ...]) -> None: + """Test that '. a' and 'a .' both collect all tests.""" + pytester.makepyfile( + test_root=""" + def test_root(): pass + """, + **{ + "a/test_a.py": """ + def test_a(): pass + """, + }, + ) + + result = pytester.runpytest("--collect-only", *order) + + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + def test_complex_combined_handling(self, pytester: Pytester) -> None: + """Test some scenarios in a complex hierarchy.""" + pytester.makepyfile( + **{ + "top1/__init__.py": "", + "top1/test_1.py": ( + """ + def test_1(): pass + + class TestIt: + def test_2(): pass + + def test_3(): pass + """ + ), + "top1/test_2.py": ( + """ + def test_1(): pass + """ + ), + "top2/__init__.py": "", + "top2/test_1.py": ( + """ + def test_1(): pass + """ + ), + }, + ) + + result = pytester.runpytest_inprocess("--collect-only", ".") + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + result = pytester.runpytest_inprocess("--collect-only", "top2", "top1") + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + result = pytester.runpytest_inprocess( + "--collect-only", "top1", "top1/test_2.py" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + # NOTE: Also sensible arguably even without --keep-duplicates. + # " ", + # " ", + "", + ], + consecutive=True, + ) + + result = pytester.runpytest_inprocess( + "--collect-only", "top1/test_2.py", "top1" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + # NOTE: Ideally test_2 would come before test_1 here. + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + result = pytester.runpytest_inprocess( + "--collect-only", "--keep-duplicates", "top1/test_2.py", "top1" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + result = pytester.runpytest_inprocess( + "--collect-only", "top1/test_2.py", "top1/test_2.py" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + # NOTE: Also sensible arguably even without --keep-duplicates. + # " ", + # " ", + "", + ], + consecutive=True, + ) + + result = pytester.runpytest_inprocess("--collect-only", "top2/", "top2/") + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + # NOTE: Also sensible arguably even without --keep-duplicates. + # " ", + # " ", + # " ", + "", + ], + consecutive=True, + ) + + result = pytester.runpytest_inprocess( + "--collect-only", "top2/", "top2/", "top2/test_1.py" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + # NOTE: Also sensible arguably even without --keep-duplicates. + # " ", + # " ", + # " ", + # " ", + # " ", + "", + ], + consecutive=True, + ) + + result = pytester.runpytest_inprocess( + "--collect-only", "top1/test_1.py", "top1/test_1.py::test_3" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + " ", + " ", + " ", + " ", + # NOTE: Also sensible arguably even without --keep-duplicates. + # " ", + "", + ], + consecutive=True, + ) + + result = pytester.runpytest_inprocess( + "--collect-only", "top1/test_1.py::test_3", "top1/test_1.py" + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + " ", + # NOTE: Ideally test_3 would come before the others here. + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) + + result = pytester.runpytest_inprocess( + "--collect-only", + "--keep-duplicates", + "top1/test_1.py::test_3", + "top1/test_1.py", + ) + result.stdout.fnmatch_lines( + [ + "", + " ", + # NOTE: That is duplicated here is not great. + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + consecutive=True, + ) diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 9f18a90d100..6f8e2c81426 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -1468,7 +1468,7 @@ def test_pass(): """ ) - result, dom = run_and_parse(f, f) + result, dom = run_and_parse("--keep-duplicates", f, f) result.stdout.no_fnmatch_line("*INTERNALERROR*") first, second = (x["classname"] for x in dom.find_by_tag("testcase")) assert first == second diff --git a/testing/test_main.py b/testing/test_main.py index 37b887b0c66..4f1426f1278 100644 --- a/testing/test_main.py +++ b/testing/test_main.py @@ -121,66 +121,72 @@ def invocation_path(self, pytester: Pytester) -> Path: def test_file(self, invocation_path: Path) -> None: """File and parts.""" assert resolve_collection_argument( - invocation_path, "src/pkg/test.py" + invocation_path, "src/pkg/test.py", 0 ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=[], parametrization=None, module_name=None, + original_index=0, ) assert resolve_collection_argument( - invocation_path, "src/pkg/test.py::" + invocation_path, "src/pkg/test.py::", 10 ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=[""], parametrization=None, module_name=None, + original_index=10, ) assert resolve_collection_argument( - invocation_path, "src/pkg/test.py::foo::bar" + invocation_path, "src/pkg/test.py::foo::bar", 20 ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=["foo", "bar"], parametrization=None, module_name=None, + original_index=20, ) assert resolve_collection_argument( - invocation_path, "src/pkg/test.py::foo::bar::" + invocation_path, "src/pkg/test.py::foo::bar::", 30 ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=["foo", "bar", ""], parametrization=None, module_name=None, + original_index=30, ) assert resolve_collection_argument( - invocation_path, "src/pkg/test.py::foo::bar[a,b,c]" + invocation_path, "src/pkg/test.py::foo::bar[a,b,c]", 40 ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=["foo", "bar"], parametrization="[a,b,c]", module_name=None, + original_index=40, ) def test_dir(self, invocation_path: Path) -> None: """Directory and parts.""" assert resolve_collection_argument( - invocation_path, "src/pkg" + invocation_path, "src/pkg", 0 ) == CollectionArgument( path=invocation_path / "src/pkg", parts=[], parametrization=None, module_name=None, + original_index=0, ) with pytest.raises( UsageError, match=r"directory argument cannot contain :: selection parts" ): - resolve_collection_argument(invocation_path, "src/pkg::") + resolve_collection_argument(invocation_path, "src/pkg::", 0) with pytest.raises( UsageError, match=r"directory argument cannot contain :: selection parts" ): - resolve_collection_argument(invocation_path, "src/pkg::foo::bar") + resolve_collection_argument(invocation_path, "src/pkg::foo::bar", 0) @pytest.mark.parametrize("namespace_package", [False, True]) def test_pypath(self, namespace_package: bool, invocation_path: Path) -> None: @@ -190,24 +196,27 @@ def test_pypath(self, namespace_package: bool, invocation_path: Path) -> None: (invocation_path / "src/pkg/__init__.py").unlink() assert resolve_collection_argument( - invocation_path, "pkg.test", as_pypath=True + invocation_path, "pkg.test", 0, as_pypath=True ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=[], parametrization=None, module_name="pkg.test", + original_index=0, ) assert resolve_collection_argument( - invocation_path, "pkg.test::foo::bar", as_pypath=True + invocation_path, "pkg.test::foo::bar", 0, as_pypath=True ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=["foo", "bar"], parametrization=None, module_name="pkg.test", + original_index=0, ) assert resolve_collection_argument( invocation_path, "pkg", + 0, as_pypath=True, consider_namespace_packages=namespace_package, ) == CollectionArgument( @@ -215,6 +224,7 @@ def test_pypath(self, namespace_package: bool, invocation_path: Path) -> None: parts=[], parametrization=None, module_name="pkg", + original_index=0, ) with pytest.raises( @@ -223,18 +233,20 @@ def test_pypath(self, namespace_package: bool, invocation_path: Path) -> None: resolve_collection_argument( invocation_path, "pkg::foo::bar", + 0, as_pypath=True, consider_namespace_packages=namespace_package, ) def test_parametrized_name_with_colons(self, invocation_path: Path) -> None: assert resolve_collection_argument( - invocation_path, "src/pkg/test.py::test[a::b]" + invocation_path, "src/pkg/test.py::test[a::b]", 0 ) == CollectionArgument( path=invocation_path / "src/pkg/test.py", parts=["test"], parametrization="[a::b]", module_name=None, + original_index=0, ) @pytest.mark.parametrize( @@ -246,17 +258,14 @@ def test_path_parametrization_not_allowed( with pytest.raises( UsageError, match=r"path cannot contain \[\] parametrization" ): - resolve_collection_argument( - invocation_path, - arg, - ) + resolve_collection_argument(invocation_path, arg, 0) def test_does_not_exist(self, invocation_path: Path) -> None: """Given a file/module that does not exist raises UsageError.""" with pytest.raises( UsageError, match=re.escape("file or directory not found: foobar") ): - resolve_collection_argument(invocation_path, "foobar") + resolve_collection_argument(invocation_path, "foobar", 0) with pytest.raises( UsageError, @@ -264,30 +273,32 @@ def test_does_not_exist(self, invocation_path: Path) -> None: "module or package not found: foobar (missing __init__.py?)" ), ): - resolve_collection_argument(invocation_path, "foobar", as_pypath=True) + resolve_collection_argument(invocation_path, "foobar", 0, as_pypath=True) def test_absolute_paths_are_resolved_correctly(self, invocation_path: Path) -> None: """Absolute paths resolve back to absolute paths.""" full_path = str(invocation_path / "src") assert resolve_collection_argument( - invocation_path, full_path + invocation_path, full_path, 0 ) == CollectionArgument( path=Path(os.path.abspath("src")), parts=[], parametrization=None, module_name=None, + original_index=0, ) # ensure full paths given in the command-line without the drive letter resolve # to the full path correctly (#7628) drive, full_path_without_drive = os.path.splitdrive(full_path) assert resolve_collection_argument( - invocation_path, full_path_without_drive + invocation_path, full_path_without_drive, 0 ) == CollectionArgument( path=Path(os.path.abspath("src")), parts=[], parametrization=None, module_name=None, + original_index=0, ) diff --git a/testing/test_mark.py b/testing/test_mark.py index 1e51f9db18f..e05aebc0730 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -59,7 +59,7 @@ def test_1(self, abc): """ ) file_name = os.path.basename(py_file) - rec = pytester.inline_run(file_name, file_name) + rec = pytester.inline_run("--keep-duplicates", file_name, file_name) rec.assertoutcome(passed=6)