Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Doc/library/argparse.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2041,6 +2041,22 @@ Argument groups
Passing prefix_chars_ to :meth:`add_argument_group`
is now deprecated.

.. class:: ArgumentGroup

A :class:`typing.Protocol` describing the object returned by
:meth:`~ArgumentParser.add_argument_group`. Use it to annotate code that
receives or stores an argument group instead of referring to the private
implementation class::

def add_common_options(group: argparse.ArgumentGroup) -> None:
group.add_argument('--verbose', action='store_true')

The protocol only guarantees the :meth:`~ArgumentParser.add_argument`
method. It is intended for type annotations; the concrete class remains an
implementation detail and should not be instantiated or subclassed directly.

.. versionadded:: 3.16


Mutual exclusion
^^^^^^^^^^^^^^^^
Expand Down Expand Up @@ -2104,6 +2120,23 @@ Mutual exclusion
never supported, often failed to work correctly, and was unintentionally
exposed through inheritance.

.. class:: MutuallyExclusiveGroup

A :class:`typing.Protocol` describing the object returned by
:meth:`~ArgumentParser.add_mutually_exclusive_group`. Use it to annotate
code that receives or stores a mutually exclusive group instead of referring
to the private implementation class::

def add_format_options(group: argparse.MutuallyExclusiveGroup) -> None:
group.add_argument('--json', action='store_true')
group.add_argument('--xml', action='store_true')

The protocol only guarantees the :meth:`~ArgumentParser.add_argument`
method. It is intended for type annotations; the concrete class remains an
implementation detail and should not be instantiated or subclassed directly.

.. versionadded:: 3.16


Parser defaults
^^^^^^^^^^^^^^^
Expand Down
12 changes: 12 additions & 0 deletions Doc/whatsnew/3.16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ New modules
Improved modules
================

argparse
--------

* Added :class:`argparse.ArgumentGroup` and
:class:`argparse.MutuallyExclusiveGroup`, public typing protocols describing
the objects returned by
:meth:`~argparse.ArgumentParser.add_argument_group` and
:meth:`~argparse.ArgumentParser.add_mutually_exclusive_group`. They allow
annotating code that passes these objects around without referring to private
names.
(Contributed by Bernát Gábor in :gh:`144812`.)

gzip
----

Expand Down
39 changes: 39 additions & 0 deletions Lib/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
'MetavarTypeHelpFormatter',
'Namespace',
'Action',
'ArgumentGroup',
'MutuallyExclusiveGroup',
'ONE_OR_MORE',
'OPTIONAL',
'PARSER',
Expand Down Expand Up @@ -1927,6 +1929,39 @@ def _remove_action(self, action):
def add_mutually_exclusive_group(self, **kwargs):
raise ValueError('mutually exclusive groups cannot be nested')


def _build_group_protocols():
# Public typing protocols describing the objects returned by
# ArgumentParser.add_argument_group() and add_mutually_exclusive_group().
# The concrete classes (_ArgumentGroup and _MutuallyExclusiveGroup) stay
# private so their implementation is free to change; only the structural
# contract is exposed. They are built lazily so that importing argparse
# does not import the (comparatively expensive) typing module.
from typing import Protocol

class ArgumentGroup(Protocol):
"""Structural type of :meth:`ArgumentParser.add_argument_group` results.

Use this in annotations in place of the private implementation class.
"""

def add_argument(self, *args, **kwargs) -> Action: ...

class MutuallyExclusiveGroup(Protocol):
"""Structural type of :meth:`ArgumentParser.add_mutually_exclusive_group` results.

Use this in annotations in place of the private implementation class.
"""

def add_argument(self, *args, **kwargs) -> Action: ...

for protocol in (ArgumentGroup, MutuallyExclusiveGroup):
protocol.__module__ = __name__
protocol.__qualname__ = protocol.__name__
return {'ArgumentGroup': ArgumentGroup,
'MutuallyExclusiveGroup': MutuallyExclusiveGroup}


def _prog_name(prog=None):
if prog is not None:
return prog
Expand Down Expand Up @@ -2935,6 +2970,10 @@ def _warning(self, message):
self._print_message(fmt % args, _sys.stderr)

def __getattr__(name):
if name in ("ArgumentGroup", "MutuallyExclusiveGroup"):
protocols = _build_group_protocols()
globals().update(protocols)
return protocols[name]
if name == "__version__":
from warnings import _deprecated

Expand Down
60 changes: 60 additions & 0 deletions Lib/test/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -7094,6 +7094,10 @@ def test(self):
self.assertHasAttr(argparse, name)

def test_all_exports_everything_but_modules(self):
# Materialize lazily-created public names (the typing protocols) so
# they appear in the module namespace regardless of test ordering.
for name in argparse.__all__:
getattr(argparse, name)
items = [
name
for name, value in vars(argparse).items()
Expand All @@ -7103,6 +7107,62 @@ def test_all_exports_everything_but_modules(self):
self.assertEqual(sorted(items), sorted(argparse.__all__))


class TestGroupProtocols(TestCase):
# gh-144812: public, structural typing protocols for the group objects
# returned by add_argument_group() and add_mutually_exclusive_group().

def test_protocols_are_accessible(self):
self.assertHasAttr(argparse, 'ArgumentGroup')
self.assertHasAttr(argparse, 'MutuallyExclusiveGroup')

def test_protocols_are_exported(self):
self.assertIn('ArgumentGroup', argparse.__all__)
self.assertIn('MutuallyExclusiveGroup', argparse.__all__)

def test_protocols_are_protocols(self):
import typing
self.assertTrue(typing.is_protocol(argparse.ArgumentGroup))
self.assertTrue(typing.is_protocol(argparse.MutuallyExclusiveGroup))

def test_protocol_identity_is_stable(self):
self.assertIs(argparse.ArgumentGroup, argparse.ArgumentGroup)
self.assertIs(argparse.MutuallyExclusiveGroup,
argparse.MutuallyExclusiveGroup)

def test_protocol_names(self):
self.assertEqual(argparse.ArgumentGroup.__module__, 'argparse')
self.assertEqual(argparse.ArgumentGroup.__qualname__, 'ArgumentGroup')
self.assertEqual(argparse.MutuallyExclusiveGroup.__module__, 'argparse')
self.assertEqual(argparse.MutuallyExclusiveGroup.__qualname__,
'MutuallyExclusiveGroup')

def test_concrete_groups_provide_protocol_members(self):
parser = argparse.ArgumentParser()
group = parser.add_argument_group('g')
mutex = parser.add_mutually_exclusive_group()
self.assertHasAttr(group, 'add_argument')
self.assertHasAttr(mutex, 'add_argument')

def test_import_does_not_import_typing(self):
# argparse is on the start-up path of most CLIs, so importing it must
# not pull in the comparatively expensive typing module.
script_helper.assert_python_ok(
'-S', '-c',
'import sys, argparse; '
'assert "typing" not in sys.modules, '
'"argparse imported typing eagerly"',
)

def test_protocol_access_imports_typing_lazily(self):
script_helper.assert_python_ok(
'-S', '-c',
'import sys, argparse; '
'assert "typing" not in sys.modules; '
'argparse.MutuallyExclusiveGroup; '
'assert "typing" in sys.modules',
)


class TestWrappingMetavar(TestCase):

def setUp(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Add :class:`argparse.ArgumentGroup` and
:class:`argparse.MutuallyExclusiveGroup`, public typing protocols describing the
objects returned by :meth:`~argparse.ArgumentParser.add_argument_group` and
:meth:`~argparse.ArgumentParser.add_mutually_exclusive_group`. They are built
lazily so importing :mod:`argparse` does not import :mod:`typing`.
Loading