From 9f4bb0f19d37ac0649a6d6a99784fdf242bd5d65 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 28 Oct 2025 08:07:14 +0200 Subject: [PATCH 01/13] config: inline `_initini` It doesn't help understanding much and doesn't deal with only ini (config). So inline it into `_preparse`. --- src/_pytest/config/__init__.py | 59 +++++++++++++++++----------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 3cad7a7d5eb..92a8fae9193 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1246,35 +1246,6 @@ def pytest_load_initial_conftests(self, early_config: Config) -> None: ), ) - def _initini(self, args: Sequence[str]) -> None: - ns, unknown_args = self._parser.parse_known_and_unknown_args( - args, namespace=copy.copy(self.option) - ) - rootpath, inipath, inicfg, ignored_config_files = determine_setup( - inifile=ns.inifilename, - override_ini=ns.override_ini, - args=ns.file_or_dir + unknown_args, - rootdir_cmd_arg=ns.rootdir or None, - invocation_dir=self.invocation_params.dir, - ) - self._rootpath = rootpath - self._inipath = inipath - self._ignored_config_files = ignored_config_files - self.inicfg = inicfg - self._parser.extra_info["rootdir"] = str(self.rootpath) - self._parser.extra_info["inifile"] = str(self.inipath) - self._parser.addini("addopts", "Extra command line options", "args") - self._parser.addini("minversion", "Minimally required pytest version") - self._parser.addini( - "pythonpath", type="paths", help="Add paths to sys.path", default=[] - ) - self._parser.addini( - "required_plugins", - "Plugins that must be present for pytest to run", - type="args", - default=[], - ) - def _consider_importhook(self, args: Sequence[str]) -> None: """Install the PEP 302 import hook if using assertion rewriting. @@ -1399,7 +1370,35 @@ def _preparse(self, args: list[str], addopts: bool = True) -> None: self._validate_args(shlex.split(env_addopts), "via PYTEST_ADDOPTS") + args ) - self._initini(args) + + ns, unknown_args = self._parser.parse_known_and_unknown_args( + args, namespace=copy.copy(self.option) + ) + rootpath, inipath, inicfg, ignored_config_files = determine_setup( + inifile=ns.inifilename, + override_ini=ns.override_ini, + args=ns.file_or_dir + unknown_args, + rootdir_cmd_arg=ns.rootdir or None, + invocation_dir=self.invocation_params.dir, + ) + self._rootpath = rootpath + self._inipath = inipath + self._ignored_config_files = ignored_config_files + self.inicfg = inicfg + self._parser.extra_info["rootdir"] = str(self.rootpath) + self._parser.extra_info["inifile"] = str(self.inipath) + self._parser.addini("addopts", "Extra command line options", "args") + self._parser.addini("minversion", "Minimally required pytest version") + self._parser.addini( + "pythonpath", type="paths", help="Add paths to sys.path", default=[] + ) + self._parser.addini( + "required_plugins", + "Plugins that must be present for pytest to run", + type="args", + default=[], + ) + if addopts: args[:] = ( self._validate_args(self.getini("addopts"), "via addopts config") + args From a32a0120ffb88057735574475acea95d1d8789b7 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 28 Oct 2025 08:19:22 +0200 Subject: [PATCH 02/13] config: better typing for `get_config` and `Config.fromdictargs` --- src/_pytest/config/__init__.py | 21 ++++++++++----------- testing/test_config.py | 2 +- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 92a8fae9193..1e91b60788e 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -10,6 +10,7 @@ from collections.abc import Generator from collections.abc import Iterable from collections.abc import Iterator +from collections.abc import Mapping from collections.abc import Sequence import contextlib import copy @@ -290,23 +291,21 @@ def directory_arg(path: str, optname: str) -> str: def get_config( - args: list[str] | None = None, + args: Iterable[str] | None = None, plugins: Sequence[str | _PluggyPlugin] | None = None, ) -> Config: # Subsequent calls to main will create a fresh instance. pluginmanager = PytestPluginManager() - config = Config( - pluginmanager, - invocation_params=Config.InvocationParams( - args=args or (), - plugins=plugins, - dir=pathlib.Path.cwd(), - ), + invocation_params = Config.InvocationParams( + args=args or (), + plugins=plugins, + dir=pathlib.Path.cwd(), ) + config = Config(pluginmanager, invocation_params=invocation_params) - if args is not None: + if invocation_params.args: # Handle any "-p no:plugin" args. - pluginmanager.consider_preparse(args, exclude_only=True) + pluginmanager.consider_preparse(invocation_params.args, exclude_only=True) for spec in default_plugins: pluginmanager.import_plugin(spec) @@ -1202,7 +1201,7 @@ def cwd_relative_nodeid(self, nodeid: str) -> str: return nodeid @classmethod - def fromdictargs(cls, option_dict, args) -> Config: + def fromdictargs(cls, option_dict: Mapping[str, Any], args: list[str]) -> Config: """Constructor usable for subprocesses.""" config = get_config(args) config.option.__dict__.update(option_dict) diff --git a/testing/test_config.py b/testing/test_config.py index 9df00d7a219..9cbb50ab2ed 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1425,7 +1425,7 @@ def test_inifilename(self, tmp_path: Path) -> None: ) with MonkeyPatch.context() as mp: mp.chdir(cwd) - config = Config.fromdictargs(option_dict, ()) + config = Config.fromdictargs(option_dict, []) inipath = absolutepath(inifilename) assert config.args == [str(cwd)] From 349bfd9661e91e191608aa0558990e4442b84b34 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 28 Oct 2025 08:39:35 +0200 Subject: [PATCH 03/13] config: make it clear only `parse_setoption` can raise `PrintHelp` --- src/_pytest/config/__init__.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 1e91b60788e..5857d7f536c 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1541,16 +1541,16 @@ def parse(self, args: list[str], addopts: bool = True) -> None: args = self._parser.parse_setoption( args, self.option, namespace=self.option ) - self.args, self.args_source = self._decide_args( - args=args, - pyargs=self.known_args_namespace.pyargs, - testpaths=self.getini("testpaths"), - invocation_dir=self.invocation_params.dir, - rootpath=self.rootpath, - warn=True, - ) except PrintHelp: - pass + return + self.args, self.args_source = self._decide_args( + args=args, + pyargs=self.known_args_namespace.pyargs, + testpaths=self.getini("testpaths"), + invocation_dir=self.invocation_params.dir, + rootpath=self.rootpath, + warn=True, + ) def issue_config_time_warning(self, warning: Warning, stacklevel: int) -> None: """Issue and handle a warning during the "configure" stage. From ab2859b1b01202c8cec1f468363e72ff18dfe2ec Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 28 Oct 2025 09:00:32 +0200 Subject: [PATCH 04/13] helpconfig: improve typing in `HelpAction` --- src/_pytest/helpconfig.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 760f8269dd1..8a4e5beba91 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -3,20 +3,23 @@ from __future__ import annotations -from argparse import Action +import argparse from collections.abc import Generator +from collections.abc import Sequence import os import sys +from typing import Any from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import PrintHelp +from _pytest.config.argparsing import MyOptionParser from _pytest.config.argparsing import Parser from _pytest.terminal import TerminalReporter import pytest -class HelpAction(Action): +class HelpAction(argparse.Action): """An argparse Action that will raise an exception in order to skip the rest of the argument parsing when --help is passed. @@ -26,20 +29,29 @@ class HelpAction(Action): implemented by raising SystemExit. """ - def __init__(self, option_strings, dest=None, default=False, help=None): + def __init__( + self, option_strings: Sequence[str], dest: str, *, help: str | None = None + ) -> None: super().__init__( option_strings=option_strings, dest=dest, - const=True, - default=default, nargs=0, + const=True, + default=False, help=help, ) - def __call__(self, parser, namespace, values, option_string=None): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, + ) -> None: setattr(namespace, self.dest, self.const) # We should only skip the rest of the parsing after preparse is done. + assert isinstance(parser, MyOptionParser) if getattr(parser._parser, "after_preparse", False): raise PrintHelp From b7e9612df013f5e694f8e9f8361b886fc8f18c68 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 28 Oct 2025 09:24:01 +0200 Subject: [PATCH 05/13] config: make `_config_source_hint` type safe Set to `None` instead of using `hasattr`. --- src/_pytest/config/__init__.py | 4 ++-- src/_pytest/config/argparsing.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 5857d7f536c..6e94328c1ba 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1306,13 +1306,13 @@ def _unconfigure_python_path(self) -> None: def _validate_args(self, args: list[str], via: str) -> list[str]: """Validate known args.""" - self._parser._config_source_hint = via # type: ignore + self._parser._config_source_hint = via try: self._parser.parse_known_and_unknown_args( args, namespace=copy.copy(self.option) ) finally: - del self._parser._config_source_hint # type: ignore + self._parser._config_source_hint = None return args diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 99835884848..d53769eb042 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -54,6 +54,7 @@ def __init__( # Maps alias -> canonical name. self._ini_aliases: dict[str, str] = {} self.extra_info: dict[str, Any] = {} + self._config_source_hint: str | None = None def processoption(self, option: Argument) -> None: if self._processopt: @@ -460,7 +461,7 @@ def error(self, message: str) -> NoReturn: """Transform argparse error message into UsageError.""" msg = f"{self.prog}: error: {message}" - if hasattr(self._parser, "_config_source_hint"): + if self._parser._config_source_hint is not None: msg = f"{msg} ({self._parser._config_source_hint})" raise UsageError(self.format_usage() + msg) From 95e0cf22fa1f9fa01a372216572f97a8e614205c Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 28 Oct 2025 09:32:58 +0200 Subject: [PATCH 06/13] config: rename `MyOptionParser` -> `PytestArgumentParser` Initially wanted to rename it to `MyArgumentParser` to make it clearer it's an `argparse.ArgumentParser`. But "My" looks funny, so change to `Pytest` similar to `PytestPluginManager`. --- src/_pytest/config/argparsing.py | 6 +++--- src/_pytest/helpconfig.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index d53769eb042..99a530a54db 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -115,10 +115,10 @@ def parse( strargs = [os.fspath(x) for x in args] return self.optparser.parse_args(strargs, namespace=namespace) - def _getparser(self) -> MyOptionParser: + def _getparser(self) -> PytestArgumentParser: from _pytest._argcomplete import filescompleter - optparser = MyOptionParser(self, self.extra_info, prog=self.prog) + optparser = PytestArgumentParser(self, self.extra_info, prog=self.prog) groups = [*self._groups, self._anonymous] for group in groups: if group.options: @@ -437,7 +437,7 @@ def _addoption_instance(self, option: Argument, shortupper: bool = False) -> Non self.options.append(option) -class MyOptionParser(argparse.ArgumentParser): +class PytestArgumentParser(argparse.ArgumentParser): def __init__( self, parser: Parser, diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 8a4e5beba91..151164d62f9 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -13,8 +13,8 @@ from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import PrintHelp -from _pytest.config.argparsing import MyOptionParser from _pytest.config.argparsing import Parser +from _pytest.config.argparsing import PytestArgumentParser from _pytest.terminal import TerminalReporter import pytest @@ -51,7 +51,7 @@ def __call__( setattr(namespace, self.dest, self.const) # We should only skip the rest of the parsing after preparse is done. - assert isinstance(parser, MyOptionParser) + assert isinstance(parser, PytestArgumentParser) if getattr(parser._parser, "after_preparse", False): raise PrintHelp From 4f07180276575856ebe3d35d02c8b415316ba56d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 29 Oct 2025 10:28:29 +0200 Subject: [PATCH 07/13] config: inline `parse_setoption` It only makes the code harder to understand in my opinion. Also remove the manual `setattr(option, name, value)` which are unneeded because `parse` already updates the `Namespace`. --- src/_pytest/config/__init__.py | 7 +++---- src/_pytest/config/argparsing.py | 12 ------------ testing/test_parseopt.py | 9 +++------ 3 files changed, 6 insertions(+), 22 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 6e94328c1ba..43a4cc3dfaf 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -56,6 +56,7 @@ from _pytest._io import TerminalWriter from _pytest.compat import assert_never from _pytest.config.argparsing import Argument +from _pytest.config.argparsing import FILE_OR_DIR from _pytest.config.argparsing import Parser import _pytest.deprecated import _pytest.hookspec @@ -1538,13 +1539,11 @@ def parse(self, args: list[str], addopts: bool = True) -> None: self._preparse(args, addopts=addopts) self._parser.after_preparse = True # type: ignore try: - args = self._parser.parse_setoption( - args, self.option, namespace=self.option - ) + parsed = self._parser.parse(args, namespace=self.option) except PrintHelp: return self.args, self.args_source = self._decide_args( - args=args, + args=getattr(parsed, FILE_OR_DIR), pyargs=self.known_args_namespace.pyargs, testpaths=self.getini("testpaths"), invocation_dir=self.invocation_params.dir, diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 99a530a54db..eab942997c9 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -7,7 +7,6 @@ from collections.abc import Sequence import os from typing import Any -from typing import cast from typing import final from typing import Literal from typing import NoReturn @@ -134,17 +133,6 @@ def _getparser(self) -> PytestArgumentParser: file_or_dir_arg.completer = filescompleter # type: ignore return optparser - def parse_setoption( - self, - args: Sequence[str | os.PathLike[str]], - option: argparse.Namespace, - namespace: argparse.Namespace | None = None, - ) -> list[str]: - parsedoption = self.parse(args, namespace=namespace) - for name, value in parsedoption.__dict__.items(): - setattr(option, name, value) - return cast(list[str], getattr(parsedoption, FILE_OR_DIR)) - def parse_known_args( self, args: Sequence[str | os.PathLike[str]], diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index 36db7b13989..d3c39d55820 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -158,19 +158,16 @@ def test_parse_will_set_default(self, parser: parseopt.Parser) -> None: parser.addoption("--hello", dest="hello", default="x", action="store") option = parser.parse([]) assert option.hello == "x" - del option.hello - parser.parse_setoption([], option) - assert option.hello == "x" - def test_parse_setoption(self, parser: parseopt.Parser) -> None: + def test_parse_set_options(self, parser: parseopt.Parser) -> None: parser.addoption("--hello", dest="hello", action="store") parser.addoption("--world", dest="world", default=42) option = argparse.Namespace() - args = parser.parse_setoption(["--hello", "world"], option) + parser.parse(["--hello", "world"], option) assert option.hello == "world" assert option.world == 42 - assert not args + assert getattr(option, parseopt.FILE_OR_DIR) == [] def test_parse_special_destination(self, parser: parseopt.Parser) -> None: parser.addoption("--ultimate-answer", type=int) From f56f7c5af9c1409f379c8445792b7b9522c01f39 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 29 Oct 2025 11:18:19 +0200 Subject: [PATCH 08/13] config: tiny style improvement --- src/_pytest/config/argparsing.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index eab942997c9..26b226bd0b1 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -320,9 +320,7 @@ def names(self) -> list[str]: def attrs(self) -> Mapping[str, Any]: # Update any attributes set by processopt. - attrs = "default dest help".split() - attrs.append(self.dest) - for attr in attrs: + for attr in ("default", "dest", "help", self.dest): try: self._attrs[attr] = getattr(self, attr) except AttributeError: From e86d689cb935bcb61604f3397a98b8a23592a08d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 29 Oct 2025 11:46:31 +0200 Subject: [PATCH 09/13] config: small code improvement --- src/_pytest/config/argparsing.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 26b226bd0b1..ae0bc80e74e 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -460,16 +460,13 @@ def parse_args( # type: ignore ) -> argparse.Namespace: """Allow splitting of positional arguments.""" parsed, unrecognized = self.parse_known_args(args, namespace) - if unrecognized: - for arg in unrecognized: - if arg and arg[0] == "-": - lines = [ - "unrecognized arguments: {}".format(" ".join(unrecognized)) - ] - for k, v in sorted(self.extra_info.items()): - lines.append(f" {k}: {v}") - self.error("\n".join(lines)) - getattr(parsed, FILE_OR_DIR).extend(unrecognized) + for arg in unrecognized: + if arg.startswith("-"): + lines = ["unrecognized arguments: " + " ".join(unrecognized)] + for k, v in sorted(self.extra_info.items()): + lines.append(f" {k}: {v}") + self.error("\n".join(lines)) + getattr(parsed, FILE_OR_DIR).extend(unrecognized) return parsed From 6ff0670622061035b05d0a328cea6d795f48f3dd Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 29 Oct 2025 12:19:16 +0200 Subject: [PATCH 10/13] config: move `extra_info` handling from `parse_args` to `error` This way it works for any error, which seems like better separation of concerns. --- src/_pytest/config/argparsing.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index ae0bc80e74e..43d0118bd05 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -446,10 +446,12 @@ def __init__( def error(self, message: str) -> NoReturn: """Transform argparse error message into UsageError.""" msg = f"{self.prog}: error: {message}" - if self._parser._config_source_hint is not None: msg = f"{msg} ({self._parser._config_source_hint})" - + if self.extra_info: + msg += "\n" + "\n".join( + f" {k}: {v}" for k, v in sorted(self.extra_info.items()) + ) raise UsageError(self.format_usage() + msg) # Type ignored because typeshed has a very complex type in the superclass. @@ -462,10 +464,7 @@ def parse_args( # type: ignore parsed, unrecognized = self.parse_known_args(args, namespace) for arg in unrecognized: if arg.startswith("-"): - lines = ["unrecognized arguments: " + " ".join(unrecognized)] - for k, v in sorted(self.extra_info.items()): - lines.append(f" {k}: {v}") - self.error("\n".join(lines)) + self.error("unrecognized arguments: " + " ".join(unrecognized)) getattr(parsed, FILE_OR_DIR).extend(unrecognized) return parsed From 8c6e01d6907465ca6c8101fe852f933b71bd0683 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 29 Oct 2025 12:23:33 +0200 Subject: [PATCH 11/13] config: remove `_config_source_hint`, use `extra_info` instead Let's use a single mechanism. The error is now shown in its own line but that's fine. --- src/_pytest/config/__init__.py | 4 ++-- src/_pytest/config/argparsing.py | 3 --- testing/test_config.py | 8 +++++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 43a4cc3dfaf..36fe4171582 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1307,13 +1307,13 @@ def _unconfigure_python_path(self) -> None: def _validate_args(self, args: list[str], via: str) -> list[str]: """Validate known args.""" - self._parser._config_source_hint = via + self._parser.extra_info["config source"] = via try: self._parser.parse_known_and_unknown_args( args, namespace=copy.copy(self.option) ) finally: - self._parser._config_source_hint = None + self._parser.extra_info.pop("config source", None) return args diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 43d0118bd05..8e50be753b3 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -53,7 +53,6 @@ def __init__( # Maps alias -> canonical name. self._ini_aliases: dict[str, str] = {} self.extra_info: dict[str, Any] = {} - self._config_source_hint: str | None = None def processoption(self, option: Argument) -> None: if self._processopt: @@ -446,8 +445,6 @@ def __init__( def error(self, message: str) -> NoReturn: """Transform argparse error message into UsageError.""" msg = f"{self.prog}: error: {message}" - if self._parser._config_source_hint is not None: - msg = f"{msg} ({self._parser._config_source_hint})" if self.extra_info: msg += "\n" + "\n".join( f" {k}: {v}" for k, v in sorted(self.extra_info.items()) diff --git a/testing/test_config.py b/testing/test_config.py index 9cbb50ab2ed..f1221cea9d0 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -2290,9 +2290,10 @@ def test_addopts_from_env_not_concatenated( with pytest.raises(UsageError) as excinfo: config._preparse(["cache_dir=ignored"], addopts=True) assert ( - "error: argument -o/--override-ini: expected one argument (via PYTEST_ADDOPTS)" + "error: argument -o/--override-ini: expected one argument" in excinfo.value.args[0] ) + assert "via PYTEST_ADDOPTS" in excinfo.value.args[0] def test_addopts_from_ini_not_concatenated(self, pytester: Pytester) -> None: """`addopts` from configuration should not take values from normal args (#4265).""" @@ -2303,10 +2304,11 @@ def test_addopts_from_ini_not_concatenated(self, pytester: Pytester) -> None: """ ) result = pytester.runpytest("cache_dir=ignored") + config = pytester._request.config result.stderr.fnmatch_lines( [ - f"{pytester._request.config._parser.optparser.prog}: error: " - f"argument -o/--override-ini: expected one argument (via addopts config)" + f"{config._parser.optparser.prog}: error: argument -o/--override-ini: expected one argument", + " config source: via addopts config", ] ) assert result.ret == _pytest.config.ExitCode.USAGE_ERROR From fc2d649280a335fb23d58bbd08a2e2b5f5bee50f Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 29 Oct 2025 12:56:09 +0200 Subject: [PATCH 12/13] config: use argparse intermixed args parsing instead of doing it ourselves See https://docs.python.org/3/library/argparse.html#intermixed-parsing. pytest uses the intermixed style, but implemented it manually, probably because it as only added to argparse in Python 3.7. --- src/_pytest/config/argparsing.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 8e50be753b3..fd907409c3f 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -111,7 +111,7 @@ def parse( self.optparser = self._getparser() try_argcomplete(self.optparser) strargs = [os.fspath(x) for x in args] - return self.optparser.parse_args(strargs, namespace=namespace) + return self.optparser.parse_intermixed_args(strargs, namespace=namespace) def _getparser(self) -> PytestArgumentParser: from _pytest._argcomplete import filescompleter @@ -451,20 +451,6 @@ def error(self, message: str) -> NoReturn: ) raise UsageError(self.format_usage() + msg) - # Type ignored because typeshed has a very complex type in the superclass. - def parse_args( # type: ignore - self, - args: Sequence[str] | None = None, - namespace: argparse.Namespace | None = None, - ) -> argparse.Namespace: - """Allow splitting of positional arguments.""" - parsed, unrecognized = self.parse_known_args(args, namespace) - for arg in unrecognized: - if arg.startswith("-"): - self.error("unrecognized arguments: " + " ".join(unrecognized)) - getattr(parsed, FILE_OR_DIR).extend(unrecognized) - return parsed - class DropShorterLongHelpFormatter(argparse.HelpFormatter): """Shorten help for long options that differ only in extra hyphens. From 0c275c45958a817669149c3c4124c6474077ca1d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 29 Oct 2025 18:16:11 +0200 Subject: [PATCH 13/13] helpconfig: remove unused `conftest_options` global --- src/_pytest/helpconfig.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 151164d62f9..932e35ceacc 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -257,9 +257,6 @@ def showhelp(config: Config) -> None: tw.line("warning : " + warningreport.message, red=True) -conftest_options = [("pytest_plugins", "list of plugin names to load")] - - def getpluginversioninfo(config: Config) -> list[str]: lines = [] plugininfo = config.pluginmanager.list_plugin_distinfo()