Skip to content

Commit 22dfcb6

Browse files
committed
✨ feat(argparse): add public group typing protocols
ArgumentParser.add_argument_group() and add_mutually_exclusive_group() return objects whose only public name was the private _ArgumentGroup and _MutuallyExclusiveGroup. Code that stores or passes these objects had no public type to annotate against, forcing callers to reference the underscore-prefixed implementation classes. Expose them as structural protocols rather than aliasing the concrete classes, so the implementation stays private and free to evolve while callers get a stable contract to annotate against. The protocols are built on first attribute access through the module __getattr__, keeping typing off the import path: argparse sits on the start-up path of most CLIs and does not otherwise import typing.
1 parent 9242700 commit 22dfcb6

5 files changed

Lines changed: 149 additions & 0 deletions

File tree

Doc/library/argparse.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2041,6 +2041,22 @@ Argument groups
20412041
Passing prefix_chars_ to :meth:`add_argument_group`
20422042
is now deprecated.
20432043

2044+
.. class:: ArgumentGroup
2045+
2046+
A :class:`typing.Protocol` describing the object returned by
2047+
:meth:`~ArgumentParser.add_argument_group`. Use it to annotate code that
2048+
receives or stores an argument group instead of referring to the private
2049+
implementation class::
2050+
2051+
def add_common_options(group: argparse.ArgumentGroup) -> None:
2052+
group.add_argument('--verbose', action='store_true')
2053+
2054+
The protocol only guarantees the :meth:`~ArgumentParser.add_argument`
2055+
method. It is intended for type annotations; the concrete class remains an
2056+
implementation detail and should not be instantiated or subclassed directly.
2057+
2058+
.. versionadded:: 3.16
2059+
20442060

20452061
Mutual exclusion
20462062
^^^^^^^^^^^^^^^^
@@ -2104,6 +2120,23 @@ Mutual exclusion
21042120
never supported, often failed to work correctly, and was unintentionally
21052121
exposed through inheritance.
21062122

2123+
.. class:: MutuallyExclusiveGroup
2124+
2125+
A :class:`typing.Protocol` describing the object returned by
2126+
:meth:`~ArgumentParser.add_mutually_exclusive_group`. Use it to annotate
2127+
code that receives or stores a mutually exclusive group instead of referring
2128+
to the private implementation class::
2129+
2130+
def add_format_options(group: argparse.MutuallyExclusiveGroup) -> None:
2131+
group.add_argument('--json', action='store_true')
2132+
group.add_argument('--xml', action='store_true')
2133+
2134+
The protocol only guarantees the :meth:`~ArgumentParser.add_argument`
2135+
method. It is intended for type annotations; the concrete class remains an
2136+
implementation detail and should not be instantiated or subclassed directly.
2137+
2138+
.. versionadded:: 3.16
2139+
21072140

21082141
Parser defaults
21092142
^^^^^^^^^^^^^^^

Doc/whatsnew/3.16.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@ New modules
8686
Improved modules
8787
================
8888

89+
argparse
90+
--------
91+
92+
* Added :class:`argparse.ArgumentGroup` and
93+
:class:`argparse.MutuallyExclusiveGroup`, public typing protocols describing
94+
the objects returned by
95+
:meth:`~argparse.ArgumentParser.add_argument_group` and
96+
:meth:`~argparse.ArgumentParser.add_mutually_exclusive_group`. They allow
97+
annotating code that passes these objects around without referring to private
98+
names.
99+
(Contributed by Bernát Gábor in :gh:`144812`.)
100+
89101
gzip
90102
----
91103

Lib/argparse.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@
7777
'MetavarTypeHelpFormatter',
7878
'Namespace',
7979
'Action',
80+
'ArgumentGroup',
81+
'MutuallyExclusiveGroup',
8082
'ONE_OR_MORE',
8183
'OPTIONAL',
8284
'PARSER',
@@ -1927,6 +1929,39 @@ def _remove_action(self, action):
19271929
def add_mutually_exclusive_group(self, **kwargs):
19281930
raise ValueError('mutually exclusive groups cannot be nested')
19291931

1932+
1933+
def _build_group_protocols():
1934+
# Public typing protocols describing the objects returned by
1935+
# ArgumentParser.add_argument_group() and add_mutually_exclusive_group().
1936+
# The concrete classes (_ArgumentGroup and _MutuallyExclusiveGroup) stay
1937+
# private so their implementation is free to change; only the structural
1938+
# contract is exposed. They are built lazily so that importing argparse
1939+
# does not import the (comparatively expensive) typing module.
1940+
from typing import Protocol
1941+
1942+
class ArgumentGroup(Protocol):
1943+
"""Structural type of :meth:`ArgumentParser.add_argument_group` results.
1944+
1945+
Use this in annotations in place of the private implementation class.
1946+
"""
1947+
1948+
def add_argument(self, *args, **kwargs) -> Action: ...
1949+
1950+
class MutuallyExclusiveGroup(Protocol):
1951+
"""Structural type of :meth:`ArgumentParser.add_mutually_exclusive_group` results.
1952+
1953+
Use this in annotations in place of the private implementation class.
1954+
"""
1955+
1956+
def add_argument(self, *args, **kwargs) -> Action: ...
1957+
1958+
for protocol in (ArgumentGroup, MutuallyExclusiveGroup):
1959+
protocol.__module__ = __name__
1960+
protocol.__qualname__ = protocol.__name__
1961+
return {'ArgumentGroup': ArgumentGroup,
1962+
'MutuallyExclusiveGroup': MutuallyExclusiveGroup}
1963+
1964+
19301965
def _prog_name(prog=None):
19311966
if prog is not None:
19321967
return prog
@@ -2935,6 +2970,10 @@ def _warning(self, message):
29352970
self._print_message(fmt % args, _sys.stderr)
29362971

29372972
def __getattr__(name):
2973+
if name in ("ArgumentGroup", "MutuallyExclusiveGroup"):
2974+
protocols = _build_group_protocols()
2975+
globals().update(protocols)
2976+
return protocols[name]
29382977
if name == "__version__":
29392978
from warnings import _deprecated
29402979

Lib/test/test_argparse.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7094,6 +7094,10 @@ def test(self):
70947094
self.assertHasAttr(argparse, name)
70957095

70967096
def test_all_exports_everything_but_modules(self):
7097+
# Materialize lazily-created public names (the typing protocols) so
7098+
# they appear in the module namespace regardless of test ordering.
7099+
for name in argparse.__all__:
7100+
getattr(argparse, name)
70977101
items = [
70987102
name
70997103
for name, value in vars(argparse).items()
@@ -7103,6 +7107,62 @@ def test_all_exports_everything_but_modules(self):
71037107
self.assertEqual(sorted(items), sorted(argparse.__all__))
71047108

71057109

7110+
class TestGroupProtocols(TestCase):
7111+
# gh-144812: public, structural typing protocols for the group objects
7112+
# returned by add_argument_group() and add_mutually_exclusive_group().
7113+
7114+
def test_protocols_are_accessible(self):
7115+
self.assertHasAttr(argparse, 'ArgumentGroup')
7116+
self.assertHasAttr(argparse, 'MutuallyExclusiveGroup')
7117+
7118+
def test_protocols_are_exported(self):
7119+
self.assertIn('ArgumentGroup', argparse.__all__)
7120+
self.assertIn('MutuallyExclusiveGroup', argparse.__all__)
7121+
7122+
def test_protocols_are_protocols(self):
7123+
import typing
7124+
self.assertTrue(typing.is_protocol(argparse.ArgumentGroup))
7125+
self.assertTrue(typing.is_protocol(argparse.MutuallyExclusiveGroup))
7126+
7127+
def test_protocol_identity_is_stable(self):
7128+
self.assertIs(argparse.ArgumentGroup, argparse.ArgumentGroup)
7129+
self.assertIs(argparse.MutuallyExclusiveGroup,
7130+
argparse.MutuallyExclusiveGroup)
7131+
7132+
def test_protocol_names(self):
7133+
self.assertEqual(argparse.ArgumentGroup.__module__, 'argparse')
7134+
self.assertEqual(argparse.ArgumentGroup.__qualname__, 'ArgumentGroup')
7135+
self.assertEqual(argparse.MutuallyExclusiveGroup.__module__, 'argparse')
7136+
self.assertEqual(argparse.MutuallyExclusiveGroup.__qualname__,
7137+
'MutuallyExclusiveGroup')
7138+
7139+
def test_concrete_groups_provide_protocol_members(self):
7140+
parser = argparse.ArgumentParser()
7141+
group = parser.add_argument_group('g')
7142+
mutex = parser.add_mutually_exclusive_group()
7143+
self.assertHasAttr(group, 'add_argument')
7144+
self.assertHasAttr(mutex, 'add_argument')
7145+
7146+
def test_import_does_not_import_typing(self):
7147+
# argparse is on the start-up path of most CLIs, so importing it must
7148+
# not pull in the comparatively expensive typing module.
7149+
script_helper.assert_python_ok(
7150+
'-S', '-c',
7151+
'import sys, argparse; '
7152+
'assert "typing" not in sys.modules, '
7153+
'"argparse imported typing eagerly"',
7154+
)
7155+
7156+
def test_protocol_access_imports_typing_lazily(self):
7157+
script_helper.assert_python_ok(
7158+
'-S', '-c',
7159+
'import sys, argparse; '
7160+
'assert "typing" not in sys.modules; '
7161+
'argparse.MutuallyExclusiveGroup; '
7162+
'assert "typing" in sys.modules',
7163+
)
7164+
7165+
71067166
class TestWrappingMetavar(TestCase):
71077167

71087168
def setUp(self):
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Add :class:`argparse.ArgumentGroup` and
2+
:class:`argparse.MutuallyExclusiveGroup`, public typing protocols describing the
3+
objects returned by :meth:`~argparse.ArgumentParser.add_argument_group` and
4+
:meth:`~argparse.ArgumentParser.add_mutually_exclusive_group`. They are built
5+
lazily so importing :mod:`argparse` does not import :mod:`typing`.

0 commit comments

Comments
 (0)