diff --git a/Doc/library/argparse.rst b/Doc/library/argparse.rst index db5fae2006678a..97aa359cfbb157 100644 --- a/Doc/library/argparse.rst +++ b/Doc/library/argparse.rst @@ -1716,8 +1716,9 @@ Subcommands :meth:`!add_subparsers` method. The :meth:`!add_subparsers` method is normally called with no arguments and returns a special action object. This object has a single method, :meth:`~_SubParsersAction.add_parser`, which takes a - command name and any :class:`!ArgumentParser` constructor arguments, and - returns an :class:`!ArgumentParser` object that can be modified as usual. + command name, optional deprecated_ and subnamespace_ flags, any + :class:`!ArgumentParser` constructor arguments, and returns an + :class:`!ArgumentParser` object that can be modified as usual. Description of parameters: @@ -1775,7 +1776,9 @@ Subcommands command line (and not any other subparsers). So in the example above, when the ``a`` command is specified, only the ``foo`` and ``bar`` attributes are present, and when the ``b`` command is specified, only the ``foo`` and - ``baz`` attributes are present. + ``baz`` attributes are present. If one wishes to store the the subparser's + attributes separate from the main parser's attributes, see the subnamespace_ + option of :meth:`~_SubParsersAction.add_parser`. Similarly, when a help message is requested from a subparser, only the help for that particular parser will be printed. The help message will not @@ -1896,7 +1899,8 @@ Subcommands .. method:: _SubParsersAction.add_parser(name, *, help=None, aliases=None, \ - deprecated=False, **kwargs) + deprecated=False, subnamespace=False, \ + **kwargs) Create and return a new :class:`ArgumentParser` object for the subcommand *name*. @@ -1925,12 +1929,73 @@ Subcommands chicken.py: warning: command 'fly' is deprecated Namespace() + .. _subnamespace: + + The *subnamespace* flag, if ``True``, tells the parent parser to + store the subparser's parsed arguments contained in + their own :class:`Namespace`, nested within the parent's :class:`!Namespace`. + The attribute name in the parent's namespace at which the + subparser's subnamespace is stored is the subparser's *name*, + but with underscores ``_`` replacing hyphens ``-`` + similar to dest_ in :meth:`ArgumentParser.add_argument`. + + This is useful for receiving parsed arguments hierarchically, mirroring the + hierarchical relation between a parser and its subparsers. For example:: + + >>> inet = argparse.ArgumentParser(add_help=False) + >>> inet.add_argument("address") + >>> inet.add_argument("port", type=int) + >>> + >>> unix = argparse.ArgumentParser(add_help=False) + >>> unix.add_argument("path") + >>> + >>> parser = argparse.ArgumentParser(prog='my-socat') + >>> action = parser.add_subparsers(required=True, dest="action") + >>> + >>> parser_bind = action.add_parser("bind", subnamespace=True) + >>> parser_bind.add_argument("--fork", action="store_true") + >>> bind_family = parser_bind.add_subparsers(required=True, dest="family") + >>> + >>> parser_bind_inet = bind_family.add_parser("inet", subnamespace=True, parents=[inet]) + >>> parser_bind_unix = bind_family.add_parser("unix", subnamespace=True, parents=[unix]) + >>> + >>> parser_connect = action.add_parser("connect", subnamespace=True) + >>> connect_family = parser_connect.add_subparsers(required=True, dest="family") + >>> + >>> parser_connect_inet = connect_family.add_parser("inet", subnamespace=True, parents=[inet]) + >>> parser_connect_unix = connect_family.add_parser("unix", subnamespace=True, parents=[unix]) + >>> + >>> args = parser.parse_args(["bind", "unix", "/foo/bar/socket"]) + >>> args + Namespace(action='bind', bind=Namespace(fork=False, family='unix', unix=Namespace(path='/foo/bar/socket'))) + + This is also very useful when one has arguments in subparsers whose + ``dest`` conflict with those of the parent parser's arguments, and one + wishes to faithfully distinguish between the two. For example:: + + >>> parser = argparse.ArgumentParser(prog='restaurant.py') + >>> parser.add_argument('-f', help='fast-tracked order', action='store_true') + >>> meals = parser.add_subparsers(dest='meal') + >>> + >>> parser_nuggets = meals.add_parser('chicken-nuggets', subnamespace=True) + >>> parser_nuggets.add_argument('-f', help='with fries', action='store_true') + >>> + >>> parser_salad = meals.add_parser('caesar-salad', subnamespace=True) + >>> parser_salad.add_argument('-f', help='fresh', action='store_true') + >>> + >>> args = parser.parse_args(['chicken-nuggets', '-f']) + >>> args + Namespace(f=False, meal='chicken-nuggets', chicken_nuggets=Namespace(f=True)) + All other keyword arguments are passed directly to the :class:`!ArgumentParser` constructor. .. versionadded:: 3.13 Added the *deprecated* parameter. + .. versionadded:: next + Added the *subnamespace* parameter. + FileType objects ^^^^^^^^^^^^^^^^ diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 2ca28378e6ef73..8539bb64ce126b 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -835,6 +835,12 @@ argparse double backticks (RST inline-literal style). (Contributed by Hugo van Kemenade in :gh:`149375`.) +* Added ``subnamespace`` keyword-only flag to + :meth:`argparse._SubParsersAction.add_parser` to allow nested + :class:`argparse.Namespace`\ s, which correspond with the hierarchical + nature of subparsers. By default ``subnamespace`` is ``False`` for + backwards compatibility. + array ----- diff --git a/Lib/argparse.py b/Lib/argparse.py index 9bc3ea64431e52..cc39a7c5b077fc 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -1317,7 +1317,7 @@ def __init__(self, help=help, metavar=metavar) - def add_parser(self, name, *, deprecated=False, **kwargs): + def add_parser(self, name, *, deprecated=False, subnamespace=False, **kwargs): # set prog from the existing prefix if kwargs.get('prog') is None: kwargs['prog'] = '%s %s' % (self._prog_prefix, name) @@ -1348,6 +1348,11 @@ def add_parser(self, name, *, deprecated=False, **kwargs): parser._check_help(choice_action) self._name_parser_map[name] = parser + # set the subnamespace attribute on the parser to determine + # whether parsed arguments should be stored in their own + # nested namespace or added to the parent parser's namespace + setattr(parser, 'subnamespace', subnamespace) + # make parser available under aliases also for alias in aliases: self._name_parser_map[alias] = parser @@ -1390,8 +1395,23 @@ def __call__(self, parser, namespace, values, option_string=None): # in a new namespace object and then update the original # namespace for the relevant parts. subnamespace, arg_strings = subparser.parse_known_args(arg_strings, None) - for key, value in vars(subnamespace).items(): - setattr(namespace, key, value) + + # If the subparser's 'subnamespace' attribute is ``True`` + # then store the subparser's parsed arguments contained in + # their own namespace, nested within the parent namespace. + # The attribute name in the parent namespace at which the + # subparser's subnamespace is stored is the subparser's name, + # specified when using '.add_parser()', but with '-' replaced with '_' + # similar to how options are stored. + # + # Otherwise if 'subnamespace' is ``False`` then update + # the parent namespace with the values from the subnamespace. + if subparser.subnamespace: + subnamespace_name = parser_name.replace('-', '_') + setattr(namespace, subnamespace_name, subnamespace) + else: + for key, value in vars(subnamespace).items(): + setattr(namespace, key, value) if arg_strings: if not hasattr(namespace, _UNRECOGNIZED_ARGS_ATTR): diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 88c1a21aa28551..2307de8aa66c78 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -6164,6 +6164,211 @@ def test_equality_returns_notimplemented(self): self.assertIs(ns.__ne__(None), NotImplemented) +# ================== +# Subnamespace tests +# ================== + +class TestSubnamespace(TestCase): + + def test_single_subnamespace(self): + parser = argparse.ArgumentParser() + action = parser.add_subparsers(required=False, dest="action") + + parser_add = action.add_parser("add", subnamespace=True) + parser_add.add_argument("--to") + + parser_remove = action.add_parser("remove", subnamespace=True) + parser_remove.add_argument("--from") + + # a root parser should not have 'subnamespace' attribute, + # as that attribute should only be set when using + # `_SubParsersAction.add_parser()` + self.assertNotHasAttr(parser, "subnamespace") + + # check subparser has 'subnamespace' attribute, + # that was set when calling `action.add_parser()` + self.assertHasAttr(parser_add, "subnamespace") + + # 'subnamespace' attribute is a bool + self.assertIsInstance(parser_add.subnamespace, bool) + + # check nesting of Namspaces works + args = parser.parse_args(["add"]) + self.assertEqual(args, argparse.Namespace( + action="add", add=argparse.Namespace( + to=None + ))) + + # test accessing of subnamespaces and args via `x.y` works + self.assertEqual(args.add.to, None) + + # check 'required=False' allows no subnamespaces to be created + args = parser.parse_args([]) + self.assertEqual(args, argparse.Namespace(action=None)) + + def test_double_subnamespace(self): + inet = argparse.ArgumentParser(add_help=False) + inet.add_argument("address") + inet.add_argument("port", type=int) + inet.add_argument("--use-proxy", action="store_true") + + unix = argparse.ArgumentParser(add_help=False) + unix.add_argument("path") + + parser = argparse.ArgumentParser(prog="my-socat") + parser.add_argument("--key-file") + action = parser.add_subparsers(required=True, dest="action") + + parser_bind = action.add_parser("bind", subnamespace=True) + parser_bind.add_argument("--fork", action="store_true") + bind_family = parser_bind.add_subparsers(required=True, dest="family") + + parser_bind_inet = bind_family.add_parser("inet", subnamespace=True, parents=[inet]) + parser_bind_unix = bind_family.add_parser("unix", subnamespace=True, parents=[unix]) + + parser_connect = action.add_parser("connect", subnamespace=True) + connect_family = parser_connect.add_subparsers(required=True, dest="family") + + parser_connect_inet = connect_family.add_parser("inet", subnamespace=True, parents=[inet]) + parser_connect_unix = connect_family.add_parser("unix", subnamespace=True, parents=[unix]) + + # check doubly-nested Namespaces work + # we assume if this test passes that we don't need to write + # redundant triply-nested etc Namespaces tests + args = parser.parse_args(["bind", "unix", "/foo/bar/socket"]) + self.assertEqual(args, argparse.Namespace( + key_file=None, + action="bind", bind=argparse.Namespace( + fork=False, + family="unix", unix=argparse.Namespace( + path="/foo/bar/socket" + )))) + + # we test with nested args with '-' in their name + # '--key-file' -> 'key_file' and '--use-proxy' -> 'use_proxy' + args = parser.parse_args(["--key-file", "/etc/key", "connect", + "inet", "127.0.0.1", "8000"]) + self.assertEqual(args, argparse.Namespace( + key_file="/etc/key", + action="connect", connect=argparse.Namespace( + family="inet", inet=argparse.Namespace( + address="127.0.0.1", port=8000, use_proxy=False + )))) + + # test accessing of nested args works when they have '_' in them + self.assertEqual(args.connect.inet.use_proxy, False) + + def test_mixed_some_subnamespace_some_not(self): + parser = argparse.ArgumentParser() + action = parser.add_subparsers(required=True, dest="action") + + parser_add = action.add_parser("add", subnamespace=True) + spec = parser_add.add_subparsers(required=True, dest="spec") + + parser_add_country = spec.add_parser("country", subnamespace=False) + parser_add_country.add_argument("country_name") + + parser_add_color = spec.add_parser("color", subnamespace=True) + parser_add_color.add_argument("name") + + # test that non-subnamespace parser arguments get parented to + # their parent Namespace, not the root Namespace + # ie make sure 'country_name' gets put under the 'add' Namespace + # not the root Namespace + args = parser.parse_args(["add", "country", "france"]) + self.assertEqual(args, argparse.Namespace( + action="add", add=argparse.Namespace( + spec="country", country_name="france" + ))) + + # test accessing of double subnamespaces works + # with non-subnamespace args + self.assertEqual(args.add.country_name, "france") + + # contrast above example with this one, where 'name' is + # parented under the 'add.color' Namespace + args = parser.parse_args(["add", "color", "blue"]) + self.assertEqual(args, argparse.Namespace( + action="add", add=argparse.Namespace( + spec="color", color=argparse.Namespace( + name="blue" + )))) + + # test accessing of double subnamespaces works, + # in particular check that we're not just getting a proxy + # or some descriptor chicanery; args genuinely are stored + # hierarchically + self.assertIsInstance(args.add.color, argparse.Namespace) + self.assertEqual(args.add.color.name, "blue") + + def test_exotic_subnamespace_names(self): + parser = argparse.ArgumentParser() + parser.add_argument("-f", help="fast-tracked order", action="store_true") + + choice = parser.add_subparsers(required=True, dest="choice") + + parser_0 = choice.add_parser("0", subnamespace=True, + help="number 0 menu item") + + parser_1 = choice.add_parser("1", subnamespace=True, + help="number 1 menu item") + + parser_True = choice.add_parser("True", subnamespace=True, + help="limited edition 'True' meal") + parser_True.add_argument("--deluxe", "-d", action="store_true") + + parser_double_cheeseburger = choice.add_parser("double-cheeseburger", + subnamespace=True) + + parser_chicken_nuggets = choice.add_parser("chicken-nuggets", + subnamespace=True) + parser_chicken_nuggets.add_argument("-f", help="with fries", + action="store_true") + + # test to observe '-' being replaced with '_' in + # subnamespace attribute name + args = parser.parse_args(["double-cheeseburger"]) + self.assertNotHasAttr(args, "double-cheeseburger") + self.assertHasAttr(args, "double_cheeseburger") + self.assertEqual(args, argparse.Namespace( + f=False, + choice="double-cheeseburger", double_cheeseburger=argparse.Namespace() + )) + + # test to observe two different `f` flags with different values + # something which is not possible without subnamespaces, + # and a key reason of why we're interested in subnamespaces + args = parser.parse_args(["chicken-nuggets", "-f"]) + self.assertEqual(args, argparse.Namespace( + f=False, + choice="chicken-nuggets", chicken_nuggets=argparse.Namespace( + f=True, + ))) + + # test to check that the order in which the `-f` flags appear + # doesn't lead to a last-write-wins situation; the `f`s are + # genuinely being parsed individually and are not overwriting + # each other in the `_SubParsersAction.__call__` stage + args = parser.parse_args(["-f", "chicken-nuggets"]) + self.assertEqual(args, argparse.Namespace( + f=True, + choice="chicken-nuggets", chicken_nuggets=argparse.Namespace( + f=False, + ))) + + # check that subnamespaces that aren't accessible by `x.y` notation + # are still accessible via getattr + args = parser.parse_args(["0"]) + self.assertHasAttr(args, "0") + self.assertEqual(getattr(args, "0"), argparse.Namespace()) + + # check that subparsers whose name are a Python keyword + # are acceptable and their subnamespaces are correctly stored + args = parser.parse_args(["True"]) + self.assertHasAttr(args, "True") + self.assertEqual(getattr(args, "True"), argparse.Namespace(deluxe=False)) + + # =================== # File encoding tests # =================== diff --git a/Misc/NEWS.d/next/Library/2026-05-02-11-22-02.gh-issue-103639.l9hA7f.rst b/Misc/NEWS.d/next/Library/2026-05-02-11-22-02.gh-issue-103639.l9hA7f.rst new file mode 100644 index 00000000000000..0eb0f779332035 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-02-11-22-02.gh-issue-103639.l9hA7f.rst @@ -0,0 +1,5 @@ +Add ``subnamespace`` keyword-only flag to +:meth:`argparse._SubParsersAction.add_parser` to allow nested +:class:`argparse.Namespace`\ s, which correspond with the hierarchical +nature of subparsers. By default ``subnamespace`` is ``False`` for +backwards compatibility.