From 98061d26ddb21b3bd7d6f96dd281a2eafdfb07dd Mon Sep 17 00:00:00 2001 From: w-Jessamine <148705640+w-Jessamine@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:27:54 +0800 Subject: [PATCH 1/3] Reject implicit option dest conflicts --- changelog/12824.bugfix.rst | 3 +++ src/_pytest/config/argparsing.py | 17 +++++++++++++++++ testing/test_parseopt.py | 22 ++++++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 changelog/12824.bugfix.rst diff --git a/changelog/12824.bugfix.rst b/changelog/12824.bugfix.rst new file mode 100644 index 00000000000..0bf95533816 --- /dev/null +++ b/changelog/12824.bugfix.rst @@ -0,0 +1,3 @@ +Custom command-line options now fail with a clear error when their implicit +destination conflicts with an existing option, avoiding silent overrides of +built-in options such as ``-k``. diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index f70e27614ef..4b442891624 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -21,6 +21,12 @@ FILE_OR_DIR = "file_or_dir" +def _get_argparse_dest(opts: Sequence[str]) -> str: + long_opts = [opt for opt in opts if opt.startswith("--")] + opt = long_opts[0] if long_opts else opts[0] + return opt.lstrip("-").replace("-", "_") + + @final class Parser: """Parser for command line arguments and config-file values. @@ -355,6 +361,17 @@ def addoption(self, *opts: str, **attrs: Any) -> None: ) if conflict: raise ValueError(f"option names {conflict} already added") + + if self.parser and "dest" not in attrs: + dest = _get_argparse_dest(opts) + for group in self.parser._groups: + for option in group.options: + if option.dest == dest: + raise ValueError( + f"option dest {dest!r} already used by " + f"{option.names()!r}; pass dest=... explicitly " + "to share the same destination" + ) self._addoption_inner(opts, attrs, allow_reserved=False) def _addoption(self, *opts: str, **attrs: Any) -> None: diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index 6da04b7d7cf..871d441cda0 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -123,6 +123,28 @@ def test_parser_addoption(self, parser: parseopt.Parser) -> None: group.addoption("--option1", action="store_true") assert len(group.options) == 1 + def test_group_addoption_rejects_implicit_dest_conflict( + self, parser: parseopt.Parser + ) -> None: + group = parser.getgroup("hello") + group._addoption("-k", dest="keyword", action="store") + + with pytest.raises(ValueError) as err: + group.addoption("--keyword", action="store") + + assert "option dest 'keyword' already used by ['-k']" in str(err.value) + + def test_group_addoption_allows_explicit_dest_conflict( + self, parser: parseopt.Parser + ) -> None: + group = parser.getgroup("hello") + group.addoption("--capture", action="store", default="fd") + group._addoption("-s", dest="capture", action="store_const", const="no") + + args = parser.parse(["-s"]) + + assert args.capture == "no" + def test_parse(self, parser: parseopt.Parser) -> None: parser.addoption("--hello", dest="hello", action="store") args = parser.parse(["--hello", "world"]) From d3d0bbd1bb50a2bdba3a33b030f5ad7b9304e1bb Mon Sep 17 00:00:00 2001 From: w-Jessamine <148705640+w-Jessamine@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:34:48 +0800 Subject: [PATCH 2/3] Cover option destination derivation --- testing/test_parseopt.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index 871d441cda0..35ce0646729 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -74,6 +74,11 @@ def test_argument_type(self) -> None: argument = parseopt.Argument(action) assert argument.type is str + def test_get_argparse_dest(self) -> None: + assert parseopt._get_argparse_dest(("--keyword",)) == "keyword" + assert parseopt._get_argparse_dest(("-x",)) == "x" + assert parseopt._get_argparse_dest(("-x", "--exit-first")) == "exit_first" + def test_group_add_and_get(self, parser: parseopt.Parser) -> None: group = parser.getgroup("hello") assert group.name == "hello" From 877995d394599b4dafe14ff5fbdfe36de0bb7c8b Mon Sep 17 00:00:00 2001 From: w-Jessamine Date: Tue, 30 Jun 2026 01:20:04 +0800 Subject: [PATCH 3/3] Clarify implicit option dest conflict error --- src/_pytest/config/argparsing.py | 5 +++-- testing/test_parseopt.py | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 4b442891624..d6a97c8928e 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -369,8 +369,9 @@ def addoption(self, *opts: str, **attrs: Any) -> None: if option.dest == dest: raise ValueError( f"option dest {dest!r} already used by " - f"{option.names()!r}; pass dest=... explicitly " - "to share the same destination" + f"{option.names()!r} (this is the option that maps to " + f"dest {dest!r}); pass dest={dest!r} explicitly " + "to share the destination" ) self._addoption_inner(opts, attrs, allow_reserved=False) diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index 35ce0646729..f58754aae27 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -137,7 +137,11 @@ def test_group_addoption_rejects_implicit_dest_conflict( with pytest.raises(ValueError) as err: group.addoption("--keyword", action="store") - assert "option dest 'keyword' already used by ['-k']" in str(err.value) + assert str(err.value) == ( + "option dest 'keyword' already used by ['-k'] " + "(this is the option that maps to dest 'keyword'); " + "pass dest='keyword' explicitly to share the destination" + ) def test_group_addoption_allows_explicit_dest_conflict( self, parser: parseopt.Parser