From 6ab90150f3208e166590b60c7325f927421e806f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 29 May 2026 19:04:06 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(argparse):=20add=20public=20gr?= =?UTF-8?q?oup=20typing=20protocols?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Doc/library/argparse.rst | 33 ++++++++++ Doc/whatsnew/3.16.rst | 12 ++++ Lib/argparse.py | 39 ++++++++++++ Lib/test/test_argparse.py | 60 +++++++++++++++++++ ...-05-29-17-52-02.gh-issue-144812.Qk3pZr.rst | 5 ++ 5 files changed, 149 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-05-29-17-52-02.gh-issue-144812.Qk3pZr.rst diff --git a/Doc/library/argparse.rst b/Doc/library/argparse.rst index db5fae2006678a2..4e6de417cd5b513 100644 --- a/Doc/library/argparse.rst +++ b/Doc/library/argparse.rst @@ -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 ^^^^^^^^^^^^^^^^ @@ -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 ^^^^^^^^^^^^^^^ diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index a6911b68c2eb756..8abee5f45f7afb7 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -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 ---- diff --git a/Lib/argparse.py b/Lib/argparse.py index 29e6ebb9634261a..61aaf9ea6b1ea1c 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -77,6 +77,8 @@ 'MetavarTypeHelpFormatter', 'Namespace', 'Action', + 'ArgumentGroup', + 'MutuallyExclusiveGroup', 'ONE_OR_MORE', 'OPTIONAL', 'PARSER', @@ -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 @@ -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 diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 1dc3f538f4ad8ba..5eb251269f52f56 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -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() @@ -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): diff --git a/Misc/NEWS.d/next/Library/2026-05-29-17-52-02.gh-issue-144812.Qk3pZr.rst b/Misc/NEWS.d/next/Library/2026-05-29-17-52-02.gh-issue-144812.Qk3pZr.rst new file mode 100644 index 000000000000000..3a6411df2ff1cbf --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-29-17-52-02.gh-issue-144812.Qk3pZr.rst @@ -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`.