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 b1eb22f1f61..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) @@ -859,6 +870,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 +955,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 @@ -969,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) @@ -1024,12 +1039,15 @@ class CollectionArgument: path: Path 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, @@ -1052,7 +1070,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 +1078,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 +1087,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 +1117,59 @@ def resolve_collection_argument( return CollectionArgument( path=fspath, 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 3f173ec4e9f..4f1426f1278 100644 --- a/testing/test_main.py +++ b/testing/test_main.py @@ -121,53 +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]", 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: @@ -177,28 +196,35 @@ 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( path=invocation_path / "src/pkg", parts=[], + parametrization=None, module_name="pkg", + original_index=0, ) with pytest.raises( @@ -207,17 +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[a::b]"], + parts=["test"], + parametrization="[a::b]", module_name=None, + original_index=0, ) @pytest.mark.parametrize( @@ -229,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, @@ -247,28 +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)