From 9b976eee73300d54baddfc11ecbdcb2d4360d606 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 1 Nov 2025 23:44:57 +0200 Subject: [PATCH 01/11] config: don't lazy import textwrap Already imported from a bunch of places. --- src/_pytest/config/argparsing.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 995408800a8..b0057d0cfc3 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -7,6 +7,7 @@ from collections.abc import Sequence import os import sys +import textwrap from typing import Any from typing import final from typing import Literal @@ -533,8 +534,6 @@ def _split_lines(self, text, width): This allows to have explicit line breaks in the help text. """ - import textwrap - lines = [] for line in text.splitlines(): lines.extend(textwrap.wrap(line.strip(), width)) From 88ec0d1e1c530675485a314109ad16c708e06e04 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 1 Nov 2025 22:15:50 +0200 Subject: [PATCH 02/11] config,mark,monkeypatch: use common `NOTSET` from `compat` --- src/_pytest/config/__init__.py | 13 +++-------- src/_pytest/config/argparsing.py | 13 +++-------- src/_pytest/mark/__init__.py | 4 ++-- src/_pytest/monkeypatch.py | 38 ++++++++++++++------------------ 4 files changed, 24 insertions(+), 44 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 9b2afe3e8b4..b0a37e4a197 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -55,6 +55,7 @@ from _pytest._code.code import TracebackStyle from _pytest._io import TerminalWriter from _pytest.compat import assert_never +from _pytest.compat import NOTSET from _pytest.config.argparsing import Argument from _pytest.config.argparsing import FILE_OR_DIR from _pytest.config.argparsing import Parser @@ -907,14 +908,6 @@ def _get_plugin_specs_as_list( ) -class Notset: - def __repr__(self): - return "" - - -notset = Notset() - - def _iter_rewritable_modules(package_files: Iterable[str]) -> Iterator[str]: """Given an iterable of file names in a source distribution, return the "names" that should be marked for assertion rewrite. @@ -1840,7 +1833,7 @@ def _getconftest_pathlist( values.append(relroot) return values - def getoption(self, name: str, default: Any = notset, skip: bool = False): + def getoption(self, name: str, default: Any = NOTSET, skip: bool = False): """Return command line option value. :param name: Name of the option. You may also specify @@ -1857,7 +1850,7 @@ def getoption(self, name: str, default: Any = notset, skip: bool = False): raise AttributeError(name) return val except AttributeError as e: - if default is not notset: + if default is not NOTSET: return default if skip: import pytest diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index b0057d0cfc3..4ff772fe682 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -15,20 +15,13 @@ from .exceptions import UsageError import _pytest._io +from _pytest.compat import NOTSET from _pytest.deprecated import check_ispytest FILE_OR_DIR = "file_or_dir" -class NotSet: - def __repr__(self) -> str: - return "" - - -NOT_SET = NotSet() - - @final class Parser: """Parser for command line arguments and config-file values. @@ -191,7 +184,7 @@ def addini( "string", "paths", "pathlist", "args", "linelist", "bool", "int", "float" ] | None = None, - default: Any = NOT_SET, + default: Any = NOTSET, *, aliases: Sequence[str] = (), ) -> None: @@ -251,7 +244,7 @@ def addini( ) if type is None: type = "string" - if default is NOT_SET: + if default is NOTSET: default = get_ini_default_for_type(type) self._inidict[name] = (help, type, default) diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 841d7811fdd..56c407ab371 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -19,11 +19,11 @@ from .structures import MarkDecorator from .structures import MarkGenerator from .structures import ParameterSet +from _pytest.compat import NOTSET from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config import UsageError -from _pytest.config.argparsing import NOT_SET from _pytest.config.argparsing import Parser from _pytest.stash import StashKey @@ -247,7 +247,7 @@ def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool: return False for mark in matches: # pylint: disable=consider-using-any-or-all - if all(mark.kwargs.get(k, NOT_SET) == v for k, v in kwargs.items()): + if all(mark.kwargs.get(k, NOTSET) == v for k, v in kwargs.items()): return True return False diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 07cc3fc4b0f..0cdfaec2dde 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -17,6 +17,8 @@ from typing import TypeVar import warnings +from _pytest.compat import NOTSET +from _pytest.compat import NotSetType from _pytest.deprecated import MONKEYPATCH_LEGACY_NAMESPACE_PACKAGES from _pytest.fixtures import fixture from _pytest.warning_types import PytestWarning @@ -107,14 +109,6 @@ def derive_importpath(import_path: str, raising: bool) -> tuple[str, object]: return attr, target -class Notset: - def __repr__(self) -> str: - return "" - - -notset = Notset() - - @final class MonkeyPatch: """Helper to conveniently monkeypatch attributes/items/environment @@ -167,7 +161,7 @@ def setattr( self, target: str, name: object, - value: Notset = ..., + value: NotSetType = ..., raising: bool = ..., ) -> None: ... @@ -184,7 +178,7 @@ def setattr( self, target: str | object, name: object | str, - value: object = notset, + value: object = NOTSET, raising: bool = True, ) -> None: """ @@ -225,7 +219,7 @@ def setattr( __tracebackhide__ = True import inspect - if isinstance(value, Notset): + if value is NOTSET: if not isinstance(target, str): raise TypeError( "use setattr(target, name, value) or " @@ -242,20 +236,20 @@ def setattr( "import string" ) - oldval = getattr(target, name, notset) - if raising and oldval is notset: + oldval = getattr(target, name, NOTSET) + if raising and oldval is NOTSET: raise AttributeError(f"{target!r} has no attribute {name!r}") # avoid class descriptors like staticmethod/classmethod if inspect.isclass(target): - oldval = target.__dict__.get(name, notset) + oldval = target.__dict__.get(name, NOTSET) self._setattr.append((target, name, oldval)) setattr(target, name, value) def delattr( self, target: object | str, - name: str | Notset = notset, + name: str | NotSetType = NOTSET, raising: bool = True, ) -> None: """Delete attribute ``name`` from ``target``. @@ -270,7 +264,7 @@ def delattr( __tracebackhide__ = True import inspect - if isinstance(name, Notset): + if name is NOTSET: if not isinstance(target, str): raise TypeError( "use delattr(target, name) or " @@ -283,16 +277,16 @@ def delattr( if raising: raise AttributeError(name) else: - oldval = getattr(target, name, notset) + oldval = getattr(target, name, NOTSET) # Avoid class descriptors like staticmethod/classmethod. if inspect.isclass(target): - oldval = target.__dict__.get(name, notset) + oldval = target.__dict__.get(name, NOTSET) self._setattr.append((target, name, oldval)) delattr(target, name) def setitem(self, dic: Mapping[K, V], name: K, value: V) -> None: """Set dictionary entry ``name`` to value.""" - self._setitem.append((dic, name, dic.get(name, notset))) + self._setitem.append((dic, name, dic.get(name, NOTSET))) # Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict dic[name] = value # type: ignore[index] @@ -306,7 +300,7 @@ def delitem(self, dic: Mapping[K, V], name: K, raising: bool = True) -> None: if raising: raise KeyError(name) else: - self._setitem.append((dic, name, dic.get(name, notset))) + self._setitem.append((dic, name, dic.get(name, NOTSET))) # Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict del dic[name] # type: ignore[attr-defined] @@ -410,13 +404,13 @@ def undo(self) -> None: Prefer to use :meth:`context() ` instead. """ for obj, name, value in reversed(self._setattr): - if value is not notset: + if value is not NOTSET: setattr(obj, name, value) else: delattr(obj, name) self._setattr[:] = [] for dictionary, key, value in reversed(self._setitem): - if value is notset: + if value is NOTSET: try: # Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict del dictionary[key] # type: ignore[attr-defined] From 2de44c484580afa8be25d0b03c0bd4551d540fae Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 1 Nov 2025 22:11:59 +0200 Subject: [PATCH 03/11] config: remove unnecessary code from `Argument` The `processopt` callback currently doesn't update any fields, so this code is not needed. And since we definitely do not want to extend the use of `processopt`, it's fine to remove. --- src/_pytest/config/argparsing.py | 6 ------ testing/test_parseopt.py | 26 -------------------------- 2 files changed, 32 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 4ff772fe682..1e1a6728011 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -335,12 +335,6 @@ def names(self) -> list[str]: return self._short_opts + self._long_opts def attrs(self) -> Mapping[str, Any]: - # Update any attributes set by processopt. - for attr in ("default", "dest", "help", self.dest): - try: - self._attrs[attr] = getattr(self, attr) - except AttributeError: - pass return self._attrs def _set_opt_strings(self, opts: Sequence[str]) -> None: diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index 30370d3d673..7b31abdf934 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -63,14 +63,6 @@ def test_argument_type(self) -> None: ) assert argument.type is str - def test_argument_processopt(self) -> None: - argument = parseopt.Argument("-t", type=int) - argument.default = 42 - argument.dest = "abc" - res = argument.attrs() - assert res["default"] == 42 - assert res["dest"] == "abc" - def test_group_add_and_get(self, parser: parseopt.Parser) -> None: group = parser.getgroup("hello") assert group.name == "hello" @@ -191,24 +183,6 @@ def test_parse_split_positional_arguments(self, parser: parseopt.Parser) -> None assert args.R is True assert args.S is False - def test_parse_defaultgetter(self) -> None: - def defaultget(option): - if not hasattr(option, "type"): - return - if option.type is int: - option.default = 42 - elif option.type is str: - option.default = "world" - - parser = parseopt.Parser(processopt=defaultget, _ispytest=True) - parser.addoption("--this", dest="this", type=int, action="store") - parser.addoption("--hello", dest="hello", type=str, action="store") - parser.addoption("--no", dest="no", action="store_true") - option = parser.parse([]) - assert option.hello == "world" - assert option.this == 42 - assert option.no is False - def test_drop_short_helper(self) -> None: parser = argparse.ArgumentParser( formatter_class=parseopt.DropShorterLongHelpFormatter, allow_abbrev=False From 38e8676a3ac350480e63fff47a40b3cdc9482378 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 1 Nov 2025 22:42:10 +0200 Subject: [PATCH 04/11] config: make `Argument` a wrapper around `argparse.Action` Same as in `OptionGroup`, this continues making our Parser code just a simple wrapper around argparse. --- src/_pytest/config/__init__.py | 7 +- src/_pytest/config/argparsing.py | 139 ++++++++----------------------- src/_pytest/helpconfig.py | 2 +- testing/test_parseopt.py | 50 ++++++----- 4 files changed, 71 insertions(+), 127 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index b0a37e4a197..2ec3488327e 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1208,12 +1208,11 @@ def fromdictargs(cls, option_dict: Mapping[str, Any], args: list[str]) -> Config return config def _processopt(self, opt: Argument) -> None: - for name in opt._short_opts + opt._long_opts: + for name in opt.names(): self._opt2dest[name] = opt.dest - if hasattr(opt, "default"): - if not hasattr(self.option, opt.dest): - setattr(self.option, opt.dest, opt.default) + if not hasattr(self.option, opt.dest): + setattr(self.option, opt.dest, opt.default) @hookimpl(trylast=True) def pytest_load_initial_conftests(self, early_config: Config) -> None: diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 1e1a6728011..d88d43816e9 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -3,7 +3,6 @@ import argparse from collections.abc import Callable -from collections.abc import Mapping from collections.abc import Sequence import os import sys @@ -280,103 +279,37 @@ def get_ini_default_for_type( return "" -class ArgumentError(Exception): - """Raised if an Argument instance is created with invalid or - inconsistent arguments.""" - - def __init__(self, msg: str, option: Argument | str) -> None: - self.msg = msg - self.option_id = str(option) - - def __str__(self) -> str: - if self.option_id: - return f"option {self.option_id}: {self.msg}" - else: - return self.msg - - class Argument: - """Class that mimics the necessary behaviour of optparse.Option. + """An option defined in an OptionGroup.""" - It's currently a least effort implementation and ignoring choices - and integer prefixes. + def __init__(self, action: argparse.Action) -> None: + self._action = action - https://docs.python.org/3/library/optparse.html#optparse-standard-option-types - """ + def attrs(self) -> dict[str, Any]: + return self._action.__dict__ - def __init__(self, *names: str, **attrs: Any) -> None: - """Store params in private vars for use in add_argument.""" - self._attrs = attrs - self._short_opts: list[str] = [] - self._long_opts: list[str] = [] - try: - self.type = attrs["type"] - except KeyError: - pass - try: - # Attribute existence is tested in Config._processopt. - self.default = attrs["default"] - except KeyError: - pass - self._set_opt_strings(names) - dest: str | None = attrs.get("dest") - if dest: - self.dest = dest - elif self._long_opts: - self.dest = self._long_opts[0][2:].replace("-", "_") - else: - try: - self.dest = self._short_opts[0][1:] - except IndexError as e: - self.dest = "???" # Needed for the error repr. - raise ArgumentError("need a long or short option", self) from e + def names(self) -> Sequence[str]: + return self._action.option_strings - def names(self) -> list[str]: - return self._short_opts + self._long_opts - - def attrs(self) -> Mapping[str, Any]: - return self._attrs + @property + def dest(self) -> str: + return self._action.dest - def _set_opt_strings(self, opts: Sequence[str]) -> None: - """Directly from optparse. + @property + def default(self) -> Any: + return self._action.default - Might not be necessary as this is passed to argparse later on. - """ - for opt in opts: - if len(opt) < 2: - raise ArgumentError( - f"invalid option string {opt!r}: " - "must be at least two characters long", - self, - ) - elif len(opt) == 2: - if not (opt[0] == "-" and opt[1] != "-"): - raise ArgumentError( - f"invalid short option string {opt!r}: " - "must be of the form -x, (x any non-dash char)", - self, - ) - self._short_opts.append(opt) - else: - if not (opt[0:2] == "--" and opt[2] != "-"): - raise ArgumentError( - f"invalid long option string {opt!r}: " - "must start with --, followed by non-dash", - self, - ) - self._long_opts.append(opt) + @property + def type(self) -> Any | None: + return self._action.type def __repr__(self) -> str: args: list[str] = [] - if self._short_opts: - args += ["_short_opts: " + repr(self._short_opts)] - if self._long_opts: - args += ["_long_opts: " + repr(self._long_opts)] + args += ["opts: " + repr(self.names())] args += ["dest: " + repr(self.dest)] - if hasattr(self, "type"): + if self._action.type: args += ["type: " + repr(self.type)] - if hasattr(self, "default"): - args += ["default: " + repr(self.default)] + args += ["default: " + repr(self.default)] return "Argument({})".format(", ".join(args)) @@ -406,6 +339,7 @@ def addoption(self, *opts: str, **attrs: Any) -> None: :param opts: Option names, can be short or long options. + Note that lower-case short options (e.g. `-x`) are reserved. :param attrs: Same attributes as the argparse library's :meth:`add_argument() ` function accepts. @@ -415,25 +349,27 @@ def addoption(self, *opts: str, **attrs: Any) -> None: ) if conflict: raise ValueError(f"option names {conflict} already added") - option = Argument(*opts, **attrs) - self._addoption_instance(option, shortupper=False) + self._addoption_inner(opts, attrs, allow_reserved=False) def _addoption(self, *opts: str, **attrs: Any) -> None: - option = Argument(*opts, **attrs) - self._addoption_instance(option, shortupper=True) + """Like addoption(), but also allows registering short lower case options (e.g. -x), + which are reserved for pytest core.""" + self._addoption_inner(opts, attrs, allow_reserved=True) - def _addoption_instance(self, option: Argument, shortupper: bool = False) -> None: - if not shortupper: - for opt in option._short_opts: - if opt[0] == "-" and opt[1].islower(): - raise ValueError("lowercase shortoptions reserved") + def _addoption_inner( + self, opts: tuple[str, ...], attrs: dict[str, Any], allow_reserved: bool + ) -> None: + if not allow_reserved: + for opt in opts: + if len(opt) >= 2 and opt[0] == "-" and opt[1].islower(): + raise ValueError("lowercase short options are reserved") + action = self._arggroup.add_argument(*opts, **attrs) + option = Argument(action) + self.options.append(option) if self.parser: self.parser.processoption(option) - self._arggroup.add_argument(*option.names(), **option.attrs()) - self.options.append(option) - class PytestArgumentParser(argparse.ArgumentParser): def __init__( @@ -495,10 +431,9 @@ def _format_action_invocation(self, action: argparse.Action) -> str: for option in options: if len(option) == 2 or option[2] == " ": continue - if not option.startswith("--"): - raise ArgumentError( - f'long optional argument without "--": [{option}]', option - ) + assert option.startswith("--"), ( + f'long optional argument without "--": [{option}]' + ) xxoption = option[2:] shortened = xxoption.replace("-", "") if shortened not in short_long or len(short_long[shortened]) < len( diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 6a22c9f58ac..fdba02b35f4 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -59,8 +59,8 @@ def __call__( def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("debugconfig") group.addoption( - "--version", "-V", + "--version", action="count", default=0, dest="version", diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index 7b31abdf934..4b721cb96f6 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -34,33 +34,43 @@ def test_custom_prog(self, parser: parseopt.Parser) -> None: assert parser.optparser.prog == "custom-prog" def test_argument(self) -> None: - with pytest.raises(parseopt.ArgumentError): - # need a short or long option - argument = parseopt.Argument() - argument = parseopt.Argument("-t") - assert argument._short_opts == ["-t"] - assert argument._long_opts == [] - assert argument.dest == "t" - argument = parseopt.Argument("-t", "--test") - assert argument._short_opts == ["-t"] - assert argument._long_opts == ["--test"] - assert argument.dest == "test" - argument = parseopt.Argument("-t", "--test", dest="abc") + parser = argparse.ArgumentParser() + + action = parser.add_argument("-a") + argument = parseopt.Argument(action) + assert argument.names() == ["-a"] + assert argument.dest == "a" + + action = parser.add_argument("-b", "--boop") + argument = parseopt.Argument(action) + assert argument.names() == ["-b", "--boop"] + assert argument.dest == "boop" + + action = parser.add_argument("-c", "--coop", dest="abc") + argument = parseopt.Argument(action) assert argument.dest == "abc" - assert str(argument) == ( - "Argument(_short_opts: ['-t'], _long_opts: ['--test'], dest: 'abc')" + assert ( + str(argument) + == "Argument(opts: ['-c', '--coop'], dest: 'abc', default: None)" ) def test_argument_type(self) -> None: - argument = parseopt.Argument("-t", dest="abc", type=int) + parser = argparse.ArgumentParser() + + action = parser.add_argument("-a", dest="aa", type=int) + argument = parseopt.Argument(action) assert argument.type is int - argument = parseopt.Argument("-t", dest="abc", type=str) + + action = parser.add_argument("-b", dest="bb", type=str) + argument = parseopt.Argument(action) assert argument.type is str - argument = parseopt.Argument("-t", dest="abc", type=float) + + action = parser.add_argument("-c", dest="cc", type=float) + argument = parseopt.Argument(action) assert argument.type is float - argument = parseopt.Argument( - "-t", dest="abc", type=str, choices=["red", "blue"] - ) + + action = parser.add_argument("-d", dest="dd", type=str, choices=["red", "blue"]) + argument = parseopt.Argument(action) assert argument.type is str def test_group_add_and_get(self, parser: parseopt.Parser) -> None: From 0e05bfe796d4bc7864df5cb9e0b0aa07da6f66c2 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 1 Nov 2025 23:30:10 +0200 Subject: [PATCH 05/11] config: move `_opt2dest` maintenance to `Parser` It's a low-level concern so low level should handle it. And one less thing in the `_processopt` callback which hopefully we can eventually remove. --- src/_pytest/config/__init__.py | 6 +----- src/_pytest/config/argparsing.py | 4 ++++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 2ec3488327e..ac52d992d51 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1083,7 +1083,6 @@ def __init__( self.trace = self.pluginmanager.trace.root.get("config") self.hook: pluggy.HookRelay = PathAwareHookProxy(self.pluginmanager.hook) # type: ignore[assignment] self._inicache: dict[str, Any] = {} - self._opt2dest: dict[str, str] = {} self._cleanup_stack = contextlib.ExitStack() self.pluginmanager.register(self, "pytestconfig") self._configured = False @@ -1208,9 +1207,6 @@ def fromdictargs(cls, option_dict: Mapping[str, Any], args: list[str]) -> Config return config def _processopt(self, opt: Argument) -> None: - for name in opt.names(): - self._opt2dest[name] = opt.dest - if not hasattr(self.option, opt.dest): setattr(self.option, opt.dest, opt.default) @@ -1842,7 +1838,7 @@ def getoption(self, name: str, default: Any = NOTSET, skip: bool = False): :param skip: If ``True``, raise :func:`pytest.skip` if option is undeclared or has a ``None`` value. Note that even if ``True``, if a default was specified it will be returned instead of a skip. """ - name = self._opt2dest.get(name, name) + name = self._parser._opt2dest.get(name, name) try: val = getattr(self.option, name) if val is None and skip: diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index d88d43816e9..f66b62d3147 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -48,6 +48,8 @@ def __init__( anonymous_arggroup, "_anonymous", self, _ispytest=True ) self._groups = [self._anonymous] + # Maps option strings -> dest, e.g. "-V" and "--version" to "version". + self._opt2dest: dict[str, str] = {} file_or_dir_arg = self.optparser.add_argument(FILE_OR_DIR, nargs="*") file_or_dir_arg.completer = filescompleter # type: ignore @@ -368,6 +370,8 @@ def _addoption_inner( option = Argument(action) self.options.append(option) if self.parser: + for name in option.names(): + self.parser._opt2dest[name] = option.dest self.parser.processoption(option) From 2d4b85455ed26450ffcf94382edebe681f0c3017 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 2 Nov 2025 09:30:09 +0200 Subject: [PATCH 06/11] Use `importlib.import_module` instead of `__import__` As recommended by Python's documentation for `__import__`. --- src/_pytest/config/__init__.py | 6 +++--- src/_pytest/debugging.py | 4 ++-- src/_pytest/monkeypatch.py | 5 +++-- src/_pytest/outcomes.py | 3 ++- testing/acceptance_test.py | 4 ++-- testing/test_config.py | 3 +-- testing/test_pathlib.py | 2 +- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index ac52d992d51..122e9900cbd 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -18,6 +18,7 @@ import enum from functools import lru_cache import glob +import importlib import importlib.metadata import inspect import os @@ -874,7 +875,7 @@ def import_plugin(self, modname: str, consider_entry_points: bool = False) -> No return try: - __import__(importspec) + mod = importlib.import_module(importspec) except ImportError as e: raise ImportError( f'Error importing plugin "{modname}": {e.args[0]}' @@ -883,7 +884,6 @@ def import_plugin(self, modname: str, consider_entry_points: bool = False) -> No except Skipped as e: self.skipped_plugins.append((modname, e.msg or "")) else: - mod = sys.modules[importspec] self.register(mod, modname) @@ -2122,7 +2122,7 @@ def _resolve_warning_category(category: str) -> type[Warning]: klass = category else: module, _, klass = category.rpartition(".") - m = __import__(module, None, None, [klass]) + m = importlib.import_module(module) cat = getattr(m, klass) if not issubclass(cat, Warning): raise UsageError(f"{cat} is not a Warning subclass") diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index de1b2688f76..cf9d0fdd247 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -8,6 +8,7 @@ from collections.abc import Callable from collections.abc import Generator import functools +import importlib import sys import types from typing import Any @@ -123,8 +124,7 @@ def _import_pdb_cls(cls, capman: CaptureManager | None): modname, classname = usepdb_cls try: - __import__(modname) - mod = sys.modules[modname] + mod = importlib.import_module(modname) # Handle --pdbcls=pdb:pdb.Pdb (useful e.g. with pdbpp). parts = classname.split(".") diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 0cdfaec2dde..6c033f36fda 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -7,6 +7,7 @@ from collections.abc import Mapping from collections.abc import MutableMapping from contextlib import contextmanager +import importlib import os from pathlib import Path import re @@ -66,7 +67,7 @@ def resolve(name: str) -> object: parts = name.split(".") used = parts.pop(0) - found: object = __import__(used) + found: object = importlib.import_module(used) for part in parts: used += "." + part try: @@ -78,7 +79,7 @@ def resolve(name: str) -> object: # We use explicit un-nesting of the handling block in order # to avoid nested exceptions. try: - __import__(used) + importlib.import_module(used) except ImportError as ex: expected = str(ex).split()[-1] if expected == used: diff --git a/src/_pytest/outcomes.py b/src/_pytest/outcomes.py index 766be95c0f7..09d0a6eccea 100644 --- a/src/_pytest/outcomes.py +++ b/src/_pytest/outcomes.py @@ -3,6 +3,7 @@ from __future__ import annotations +import importlib import sys from typing import Any from typing import ClassVar @@ -268,7 +269,7 @@ def importorskip( warnings.simplefilter("ignore") try: - __import__(modname) + importlib.import_module(modname) except exc_type as exc: # Do not raise or issue warnings inside the catch_warnings() block. if reason is None: diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index b9384008483..11cc8a7217f 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -129,9 +129,9 @@ class DummyEntryPoint: group: str = "pytest11" def load(self): - __import__(self.module) + mod = importlib.import_module(self.module) loaded.append(self.name) - return sys.modules[self.module] + return mod entry_points = [ DummyEntryPoint("myplugin1", "mytestplugin1_module"), diff --git a/testing/test_config.py b/testing/test_config.py index 98555e04452..65bf94ae19b 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -600,8 +600,7 @@ class DummyEntryPoint: group: str = "pytest11" def load(self): - __import__(self.module) - return sys.modules[self.module] + return importlib.import_module(self.module) entry_points = [ DummyEntryPoint("myplugin1", "myplugin1_module"), diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index 0880c355557..1dec3c6ec78 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -964,7 +964,7 @@ def test_demo(): ) # unit test - __import__(name) # import standard library + importlib.import_module(name) # import standard library import_path( # import user files file_path, From 483cf8a3c6120a9f1dae705369c54180d934e3f2 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 2 Nov 2025 11:34:23 +0200 Subject: [PATCH 07/11] config: remove passing `parser` on `PytestArgumentParser` No longer needed, forgot to remove it. --- 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 f66b62d3147..71a09a7200e 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -42,7 +42,7 @@ def __init__( self._processopt = processopt self.extra_info: dict[str, Any] = {} - self.optparser = PytestArgumentParser(self, usage, self.extra_info) + self.optparser = PytestArgumentParser(usage, self.extra_info) anonymous_arggroup = self.optparser.add_argument_group("Custom options") self._anonymous = OptionGroup( anonymous_arggroup, "_anonymous", self, _ispytest=True @@ -378,11 +378,9 @@ def _addoption_inner( class PytestArgumentParser(argparse.ArgumentParser): def __init__( self, - parser: Parser, usage: str | None, extra_info: dict[str, str], ) -> None: - self._parser = parser super().__init__( usage=usage, add_help=False, From 1020d47215595faf2401b9246918570acd77519b Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 5 Nov 2025 16:51:18 +0200 Subject: [PATCH 08/11] config: add comment about future possibility --- src/_pytest/config/argparsing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 71a09a7200e..fa841420e58 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -394,6 +394,8 @@ def __init__( def error(self, message: str) -> NoReturn: """Transform argparse error message into UsageError.""" + # TODO(py313): Replace with `exit_on_error=False`. Note that while it + # was added in Python 3.9, it was broken until 3.13 (cpython#121018). msg = f"{self.prog}: error: {message}" if self.extra_info: msg += "\n" + "\n".join( From f0d61a6ab1b68e409dfb2ff1986d6b0d210c480f Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 2 Nov 2025 14:17:09 +0200 Subject: [PATCH 09/11] config: drop caching from `DropShorterLongHelpFormatter` Unlike what the comment says, it is not called multiple times when `--help` is used (it is not used at all when not). --- src/_pytest/config/argparsing.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index fa841420e58..4976bd246a7 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -409,7 +409,6 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter): - Collapse **long** options that are the same except for extra hyphens. - Shortcut if there are only two options and one of them is a short one. - - Cache result on the action object as this is called at least 2 times. """ def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -422,13 +421,9 @@ def _format_action_invocation(self, action: argparse.Action) -> str: orgstr = super()._format_action_invocation(action) if orgstr and orgstr[0] != "-": # only optional arguments return orgstr - res: str | None = getattr(action, "_formatted_action_invocation", None) - if res: - return res options = orgstr.split(", ") if len(options) == 2 and (len(options[0]) == 2 or len(options[1]) == 2): # a shortcut for '-h, --help' or '--abc', '-a' - action._formatted_action_invocation = orgstr # type: ignore return orgstr return_list = [] short_long: dict[str, str] = {} @@ -451,9 +446,7 @@ def _format_action_invocation(self, action: argparse.Action) -> str: return_list.append(option) if option[2:] == short_long.get(option.replace("-", "")): return_list.append(option.replace(" ", "=", 1)) - formatted_action_invocation = ", ".join(return_list) - action._formatted_action_invocation = formatted_action_invocation # type: ignore - return formatted_action_invocation + return ", ".join(return_list) def _split_lines(self, text, width): """Wrap lines after splitting on original newlines. From 6e8d2058ac4d5688f667e2e8dc32c7b477fc739d Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 2 Nov 2025 14:26:12 +0200 Subject: [PATCH 10/11] config: add a type annotation --- src/_pytest/config/argparsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 4976bd246a7..0901941a6dc 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -448,7 +448,7 @@ def _format_action_invocation(self, action: argparse.Action) -> str: return_list.append(option.replace(" ", "=", 1)) return ", ".join(return_list) - def _split_lines(self, text, width): + def _split_lines(self, text: str, width: int) -> list[str]: """Wrap lines after splitting on original newlines. This allows to have explicit line breaks in the help text. From b6d9eb6a11efeb29f3be20e82a77dcee3565a2da Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 2 Nov 2025 14:37:09 +0200 Subject: [PATCH 11/11] config: avoid direct access to `inicfg` No apparent need for it. --- src/_pytest/config/__init__.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 122e9900cbd..1511fc97223 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1364,17 +1364,11 @@ def pytest_collection(self) -> Generator[None, object, object]: def _checkversion(self) -> None: import pytest - minver_ini_value = self.inicfg.get("minversion", None) - minver = minver_ini_value.value if minver_ini_value is not None else None + minver = self.getini("minversion") if minver: # Imported lazily to improve start-up time. from packaging.version import Version - if not isinstance(minver, str): - raise pytest.UsageError( - f"{self.inipath}: 'minversion' must be a single value" - ) - if Version(minver) > Version(pytest.__version__): raise pytest.UsageError( f"{self.inipath}: 'minversion' requires pytest-{minver}, actual pytest-{pytest.__version__}'"