Skip to content

Commit e94518d

Browse files
committed
config: use one ArgumentParser instead of creating new one for each parse
Previously, `Parser` would create a new `ArgumentParser` for each parsing call (of which there are several during pytest initialization). Besides being a bit wasteful, it makes things hard to follow, especially since one `ArgumentParser` was saved on the `Parser` (in `optparser`). It makes a few refactorings I am planning difficult to pull off. So now, `Parser` instantiates one `ArgumentParser` in `__init__` and just adds groups/arguments to it immediately. There's more synchronization needed, but since arguments can only be added, never removed, it's not bad. One gotcha, in order to control `--help` group orders we must access argparse internals since it doesn't provide a way to do it. I checked and the relevant code hasn't changed since argparse was introduced 15 years ago. So hopefully it will continue to be fine.
1 parent bda322a commit e94518d

File tree

4 files changed

+53
-52
lines changed

4 files changed

+53
-52
lines changed

src/_pytest/config/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1157,7 +1157,7 @@ def pytest_cmdline_parse(
11571157
elif (
11581158
getattr(self.option, "help", False) or "--help" in args or "-h" in args
11591159
):
1160-
self._parser._getparser().print_help()
1160+
self._parser.optparser.print_help()
11611161
sys.stdout.write(
11621162
"\nNOTE: displaying only minimal help due to UsageError.\n\n"
11631163
)

src/_pytest/config/argparsing.py

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ class Parser:
3636
there's an error processing the command line arguments.
3737
"""
3838

39-
prog: str | None = None
40-
4139
def __init__(
4240
self,
4341
usage: str | None = None,
@@ -46,14 +44,31 @@ def __init__(
4644
_ispytest: bool = False,
4745
) -> None:
4846
check_ispytest(_ispytest)
49-
self._anonymous = OptionGroup("Custom options", parser=self, _ispytest=True)
50-
self._groups: list[OptionGroup] = []
47+
48+
from _pytest._argcomplete import filescompleter
49+
5150
self._processopt = processopt
52-
self._usage = usage
51+
self.extra_info: dict[str, Any] = {}
52+
self.optparser = PytestArgumentParser(self, usage, self.extra_info)
53+
anonymous_arggroup = self.optparser.add_argument_group("Custom options")
54+
self._anonymous = OptionGroup(
55+
anonymous_arggroup, "_anonymous", self, _ispytest=True
56+
)
57+
self._groups = [self._anonymous]
58+
file_or_dir_arg = self.optparser.add_argument(FILE_OR_DIR, nargs="*")
59+
file_or_dir_arg.completer = filescompleter # type: ignore
60+
5361
self._inidict: dict[str, tuple[str, str, Any]] = {}
5462
# Maps alias -> canonical name.
5563
self._ini_aliases: dict[str, str] = {}
56-
self.extra_info: dict[str, Any] = {}
64+
65+
@property
66+
def prog(self) -> str:
67+
return self.optparser.prog
68+
69+
@prog.setter
70+
def prog(self, value: str) -> None:
71+
self.optparser.prog = value
5772

5873
def processoption(self, option: Argument) -> None:
5974
if self._processopt:
@@ -78,12 +93,17 @@ def getgroup(
7893
for group in self._groups:
7994
if group.name == name:
8095
return group
81-
group = OptionGroup(name, description, parser=self, _ispytest=True)
96+
97+
arggroup = self.optparser.add_argument_group(description or name)
98+
group = OptionGroup(arggroup, name, self, _ispytest=True)
8299
i = 0
83100
for i, grp in enumerate(self._groups):
84101
if grp.name == after:
85102
break
86103
self._groups.insert(i + 1, group)
104+
# argparse doesn't provide a way to control `--help` order, so must
105+
# access its internals ☹.
106+
self.optparser._action_groups.insert(i + 1, self.optparser._action_groups.pop())
87107
return group
88108

89109
def addoption(self, *opts: str, **attrs: Any) -> None:
@@ -102,25 +122,6 @@ def addoption(self, *opts: str, **attrs: Any) -> None:
102122
"""
103123
self._anonymous.addoption(*opts, **attrs)
104124

105-
def _getparser(self) -> PytestArgumentParser:
106-
from _pytest._argcomplete import filescompleter
107-
108-
optparser = PytestArgumentParser(self, self.extra_info, prog=self.prog)
109-
groups = [*self._groups, self._anonymous]
110-
for group in groups:
111-
if group.options:
112-
desc = group.description or group.name
113-
arggroup = optparser.add_argument_group(desc)
114-
for option in group.options:
115-
n = option.names()
116-
a = option.attrs()
117-
arggroup.add_argument(*n, **a)
118-
file_or_dir_arg = optparser.add_argument(FILE_OR_DIR, nargs="*")
119-
# bash like autocompletion for dirs (appending '/')
120-
# Type ignored because typeshed doesn't know about argcomplete.
121-
file_or_dir_arg.completer = filescompleter # type: ignore
122-
return optparser
123-
124125
def parse(
125126
self,
126127
args: Sequence[str | os.PathLike[str]],
@@ -135,7 +136,6 @@ def parse(
135136
"""
136137
from _pytest._argcomplete import try_argcomplete
137138

138-
self.optparser = self._getparser()
139139
try_argcomplete(self.optparser)
140140
strargs = [os.fspath(x) for x in args]
141141
return self.optparser.parse_intermixed_args(strargs, namespace=namespace)
@@ -163,7 +163,6 @@ def parse_known_and_unknown_args(
163163
A tuple containing an argparse namespace object for the known
164164
arguments, and a list of unknown flag arguments.
165165
"""
166-
optparser = self._getparser()
167166
strargs = [os.fspath(x) for x in args]
168167
if sys.version_info < (3, 12):
169168
# Older argparse have a bugged parse_known_intermixed_args.
@@ -175,7 +174,7 @@ def parse_known_and_unknown_args(
175174
(unknown_flags if arg.startswith("-") else file_or_dir).append(arg)
176175
return namespace, unknown_flags
177176
else:
178-
return optparser.parse_known_intermixed_args(strargs, namespace)
177+
return self.optparser.parse_known_intermixed_args(strargs, namespace)
179178

180179
def addini(
181180
self,
@@ -392,15 +391,14 @@ class OptionGroup:
392391

393392
def __init__(
394393
self,
394+
arggroup: argparse._ArgumentGroup,
395395
name: str,
396-
description: str = "",
397-
parser: Parser | None = None,
398-
*,
396+
parser: Parser | None,
399397
_ispytest: bool = False,
400398
) -> None:
401399
check_ispytest(_ispytest)
400+
self._arggroup = arggroup
402401
self.name = name
403-
self.description = description
404402
self.options: list[Argument] = []
405403
self.parser = parser
406404

@@ -435,30 +433,32 @@ def _addoption_instance(self, option: Argument, shortupper: bool = False) -> Non
435433
for opt in option._short_opts:
436434
if opt[0] == "-" and opt[1].islower():
437435
raise ValueError("lowercase shortoptions reserved")
436+
438437
if self.parser:
439438
self.parser.processoption(option)
439+
440+
self._arggroup.add_argument(*option.names(), **option.attrs())
440441
self.options.append(option)
441442

442443

443444
class PytestArgumentParser(argparse.ArgumentParser):
444445
def __init__(
445446
self,
446447
parser: Parser,
447-
extra_info: dict[str, Any] | None = None,
448-
prog: str | None = None,
448+
usage: str | None,
449+
extra_info: dict[str, str],
449450
) -> None:
450451
self._parser = parser
451452
super().__init__(
452-
prog=prog,
453-
usage=parser._usage,
453+
usage=usage,
454454
add_help=False,
455455
formatter_class=DropShorterLongHelpFormatter,
456456
allow_abbrev=False,
457457
fromfile_prefix_chars="@",
458458
)
459459
# extra_info is a dict of (param -> value) to display if there's
460460
# an usage error to provide more contextual information to the user.
461-
self.extra_info = extra_info if extra_info else {}
461+
self.extra_info = extra_info
462462

463463
def error(self, message: str) -> NoReturn:
464464
"""Transform argparse error message into UsageError."""

testing/test_config.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2304,10 +2304,9 @@ def test_addopts_from_ini_not_concatenated(self, pytester: Pytester) -> None:
23042304
"""
23052305
)
23062306
result = pytester.runpytest("cache_dir=ignored")
2307-
config = pytester._request.config
23082307
result.stderr.fnmatch_lines(
23092308
[
2310-
f"{config._parser.optparser.prog}: error: argument -o/--override-ini: expected one argument",
2309+
"*: error: argument -o/--override-ini: expected one argument",
23112310
" config source: via addopts config",
23122311
]
23132312
)
@@ -2410,8 +2409,7 @@ def pytest_addoption(parser):
24102409
result.stderr.fnmatch_lines(
24112410
[
24122411
"ERROR: usage: *",
2413-
f"{pytester._request.config._parser.optparser.prog}: error: "
2414-
f"argument --invalid-option-should-allow-for-help: expected one argument",
2412+
"*: error: argument --invalid-option-should-allow-for-help: expected one argument",
24152413
]
24162414
)
24172415
# Does not display full/default help.

testing/test_parseopt.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ def test_no_help_by_default(self) -> None:
2828

2929
def test_custom_prog(self, parser: parseopt.Parser) -> None:
3030
"""Custom prog can be set for `argparse.ArgumentParser`."""
31-
assert parser._getparser().prog == argparse.ArgumentParser().prog
31+
assert parser.optparser.prog == argparse.ArgumentParser().prog
3232
parser.prog = "custom-prog"
33-
assert parser._getparser().prog == "custom-prog"
33+
assert parser.prog == "custom-prog"
34+
assert parser.optparser.prog == "custom-prog"
3435

3536
def test_argument(self) -> None:
3637
with pytest.raises(parseopt.ArgumentError):
@@ -71,14 +72,12 @@ def test_argument_processopt(self) -> None:
7172
assert res["dest"] == "abc"
7273

7374
def test_group_add_and_get(self, parser: parseopt.Parser) -> None:
74-
group = parser.getgroup("hello", description="desc")
75+
group = parser.getgroup("hello")
7576
assert group.name == "hello"
76-
assert group.description == "desc"
7777

7878
def test_getgroup_simple(self, parser: parseopt.Parser) -> None:
79-
group = parser.getgroup("hello", description="desc")
79+
group = parser.getgroup("hello")
8080
assert group.name == "hello"
81-
assert group.description == "desc"
8281
group2 = parser.getgroup("hello")
8382
assert group2 is group
8483

@@ -88,16 +87,20 @@ def test_group_ordering(self, parser: parseopt.Parser) -> None:
8887
parser.getgroup("3", after="1")
8988
groups = parser._groups
9089
groups_names = [x.name for x in groups]
91-
assert groups_names == list("132")
90+
assert groups_names == ["_anonymous", "1", "3", "2"]
9291

9392
def test_group_addoption(self) -> None:
94-
group = parseopt.OptionGroup("hello", _ispytest=True)
93+
optparser = argparse.ArgumentParser()
94+
arggroup = optparser.add_argument_group("hello")
95+
group = parseopt.OptionGroup(arggroup, "hello", None, _ispytest=True)
9596
group.addoption("--option1", action="store_true")
9697
assert len(group.options) == 1
9798
assert isinstance(group.options[0], parseopt.Argument)
9899

99100
def test_group_addoption_conflict(self) -> None:
100-
group = parseopt.OptionGroup("hello again", _ispytest=True)
101+
optparser = argparse.ArgumentParser()
102+
arggroup = optparser.add_argument_group("hello again")
103+
group = parseopt.OptionGroup(arggroup, "hello again", None, _ispytest=True)
101104
group.addoption("--option1", "--option-1", action="store_true")
102105
with pytest.raises(ValueError) as err:
103106
group.addoption("--option1", "--option-one", action="store_true")

0 commit comments

Comments
 (0)