Skip to content

Parsers

Subparser utilities and helper functions.

duho.parsers

Subparser utilities and helper functions.

__all__ = ['pop_action', 'insert_action', 'add_help_argument', 'disable_subparser_check', 'enable_subparser_check', 'prerun_parse', 'find_subparsers', 'strip_subparsers', 'unique_subcommands', 'command_name'] module-attribute

add_help_argument(parser)

Add a help argument to a parser.

Source code in src/duho/parsers.py
79
80
81
82
83
84
85
86
87
def add_help_argument(parser: _argparse.ArgumentParser) -> _argparse.Action:
    """Add a help argument to a parser."""
    return parser.add_argument(
        "-h",
        "--help",
        action="help",
        default=_argparse.SUPPRESS,
        help=("show this help message and exit"),
    )

command_name(command)

Re-export of duho.args._command_name, the one canonical subcommand-naming rule (a class's OWN _parsername_ if it declares one -- checked through the class's own __dict__, never inherited -- else its class name).

Exposed here so a parser-tree consumer that only needs naming/subparser utilities (this module) does not also have to import duho.args directly. Forwards lazily, function-local (rather than a top-level import), because duho.args itself imports this module for :func:prerun_parse -- a top-level import the other way would cycle.

Source code in src/duho/parsers.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def command_name(command: object) -> str:
    """Re-export of ``duho.args._command_name``, the one canonical
    subcommand-naming rule (a class's OWN ``_parsername_`` if it declares one
    -- checked through the class's own ``__dict__``, never inherited -- else
    its class name).

    Exposed here so a parser-tree consumer that only needs naming/subparser
    utilities (this module) does not also have to import ``duho.args``
    directly. Forwards lazily, function-local (rather than a top-level
    import), because ``duho.args`` itself imports this module for
    :func:`prerun_parse` -- a top-level import the other way would cycle.
    """
    from .args._naming import _command_name

    return _command_name(command)

disable_subparser_check(action)

Relax name validation on THIS _SubParsersAction instance.

Per-instance surgery: swaps only this action's class to :class:_RelaxedSubParsersAction and nulls its choices (so argparse's name check is skipped). Reentrant via a depth counter: a NESTED disable (this action is already relaxed) only bumps the depth and leaves the saved original state alone, so the matching, non-outermost :func:enable_subparser_check calls only decrement it -- only the OUTERMOST pair actually saves/restores. Before this, a nested disable saved the already-relaxed state, so the inner enable restored THAT (still relaxed) and the outer enable found nothing left to restore, leaving the action permanently relaxed. No argparse class attribute is ever mutated; safe for distinct parser instances used concurrently, but this action itself is ordinary, unsynchronized instance state -- two threads racing the SAME action need their own external lock.

Source code in src/duho/parsers.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def disable_subparser_check(action: _argparse._SubParsersAction) -> None:
    """Relax name validation on THIS ``_SubParsersAction`` instance.

    Per-instance surgery: swaps only this action's class to
    :class:`_RelaxedSubParsersAction` and nulls its ``choices`` (so argparse's
    name check is skipped). Reentrant via a depth counter: a NESTED
    disable (this action is already relaxed) only bumps the depth and leaves
    the saved original state alone, so the matching, non-outermost
    :func:`enable_subparser_check` calls only decrement it -- only the
    OUTERMOST pair actually saves/restores. Before this, a nested disable
    saved the already-relaxed state, so the inner ``enable`` restored THAT
    (still relaxed) and the outer ``enable`` found nothing left to restore,
    leaving the action permanently relaxed. No ``argparse`` class attribute is
    ever mutated; safe for distinct parser instances used concurrently,
    but this action itself is ordinary, unsynchronized instance state -- two
    threads racing the SAME action need their own external lock.
    """
    depth = getattr(action, "_duho_disable_depth_", 0)
    if depth == 0:
        action._duho_saved_ = (action.__class__, action.choices)  # type: ignore[attr-defined]
        action.choices = None  # type: ignore
        action._duho_action_called_ = False  # type: ignore[attr-defined]
        action.__class__ = _RelaxedSubParsersAction
    action._duho_disable_depth_ = depth + 1  # type: ignore[attr-defined]

enable_subparser_check(action)

Restore the class + choices saved by :func:disable_subparser_check.

Reentrant: only the call that brings the depth counter back to 0 (the OUTERMOST enable matching the OUTERMOST disable) actually restores anything; an inner call just decrements.

Source code in src/duho/parsers.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def enable_subparser_check(action: _argparse._SubParsersAction) -> None:
    """Restore the class + choices saved by :func:`disable_subparser_check`.

    Reentrant: only the call that brings the depth counter back to
    ``0`` (the OUTERMOST ``enable`` matching the OUTERMOST ``disable``)
    actually restores anything; an inner call just decrements.
    """
    depth = getattr(action, "_duho_disable_depth_", 0)
    if depth > 1:
        action._duho_disable_depth_ = depth - 1  # type: ignore[attr-defined]
        return
    saved = getattr(action, "_duho_saved_", None)
    if saved is not None:
        action.__class__, action.choices = saved
        del action._duho_saved_
    if hasattr(action, "_duho_action_called_"):
        del action._duho_action_called_
    if hasattr(action, "_duho_disable_depth_"):
        del action._duho_disable_depth_

find_subparsers(parser)

parser's subparsers action, if it has one.

The one lookup every parser-tree walker needs (a parser can carry at most one, since argparse itself raises on a second add_subparsers() call); shared here instead of a hand-written for action in parser._actions: isinstance(...) loop repeated at each call site.

Source code in src/duho/parsers.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def find_subparsers(
    parser: _argparse.ArgumentParser,
) -> _ty.Optional[_argparse._SubParsersAction]:
    """``parser``'s subparsers action, if it has one.

    The one lookup every parser-tree walker needs (a parser can carry at most
    one, since argparse itself raises on a second ``add_subparsers()`` call);
    shared here instead of a hand-written ``for action in parser._actions:
    isinstance(...)`` loop repeated at each call site.
    """
    for action in parser._actions:
        if isinstance(action, _argparse._SubParsersAction):
            return action
    return None

insert_action(parser, action, index=None)

Insert an action into a parser (optionally at a given index).

index=None (the default) APPENDS. The previous default, -1, used list.insert(-1, x) semantics -- which puts x BEFORE the last action, not at the end, almost certainly not what a caller reordering a flag wants. Also re-adds the action to an argument group's _group_actions -- without this, format_help (which renders from the GROUP lists, never parser._actions directly) never shows the reinserted flag -- the defect pop_action fixes, now fixed in the opposite direction too.

The target group is the action's OWN pre-removal one (action. container, which pop_action removes the action FROM but never clears) when it still has one -- so an action that belonged to a titled add_argument_group(...) goes back to that SAME group, not the parser's default "options"/"positional arguments" section. Only an action with no container of its own yet (never added through a group at all) falls back to the parser's own _optionals/ _positionals (chosen by whether the action has option strings -- the same split add_argument itself uses).

Source code in src/duho/parsers.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def insert_action(
    parser: _argparse.ArgumentParser,
    action: _argparse.Action,
    index: _ty.Optional[int] = None,
) -> None:
    """Insert an action into a parser (optionally at a given index).

    ``index=None`` (the default) APPENDS. The previous default, ``-1``, used
    ``list.insert(-1, x)`` semantics -- which puts ``x`` BEFORE the last
    action, not at the end, almost certainly not what a caller reordering a
    flag wants. Also re-adds the action to an argument group's
    ``_group_actions`` -- without this, ``format_help`` (which renders from
    the GROUP lists, never ``parser._actions`` directly) never shows the
    reinserted flag -- the defect ``pop_action`` fixes, now fixed in the
    opposite direction too.

    The target group is the action's OWN pre-removal one (``action.
    container``, which ``pop_action`` removes the action FROM but never
    clears) when it still has one -- so an action that belonged to a titled
    ``add_argument_group(...)`` goes back to that SAME group, not the
    parser's default "options"/"positional arguments" section. Only an
    action with no ``container`` of its own yet (never added through a
    group at all) falls back to the parser's own ``_optionals``/
    ``_positionals`` (chosen by whether the action has option strings -- the
    same split ``add_argument`` itself uses).
    """
    if index is None:
        parser._actions.append(action)
    else:
        parser._actions.insert(index, action)
    for k in action.option_strings:
        parser._option_string_actions[k] = action
    group = getattr(action, "container", None)
    if group is None:
        group = parser._optionals if action.option_strings else parser._positionals
    action.container = group
    if action not in group._group_actions:
        group._group_actions.append(action)

pop_action(parser, name)

Remove an action from a parser by destination name.

Removes the action from the parser's action list, its option-string map, the owning argument group's _group_actions -- format_help renders from the group lists, so missing the last one left a "removed" flag still visible in help output -- AND every _mutually_exclusive_groups entry it belonged to, so a required mutex group's error does not name a removed flag.

Source code in src/duho/parsers.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def pop_action(parser: _argparse.ArgumentParser, name: str) -> _argparse.Action:
    """Remove an action from a parser by destination name.

    Removes the action from the parser's action list, its option-string map,
    the owning argument group's ``_group_actions`` -- ``format_help`` renders
    from the group lists, so missing the last one left a "removed" flag still
    visible in help output -- AND every ``_mutually_exclusive_groups``
    entry it belonged to, so a required mutex group's error does not name a
    removed flag.
    """
    index = None
    for idx, action in enumerate(parser._actions):
        if action.dest == name:
            index = idx
            break
    if index is not None:
        action = parser._actions.pop(index)
        for k in action.option_strings:
            parser._option_string_actions.pop(k)
        container = getattr(action, "container", None)
        group_actions = getattr(container, "_group_actions", None)
        if group_actions is not None and action in group_actions:
            group_actions.remove(action)
        for mutex_group in getattr(parser, "_mutually_exclusive_groups", []):
            if action in mutex_group._group_actions:
                mutex_group._group_actions.remove(action)
        return action
    raise KeyError(name)

prerun_parse(parser, argv=None, *, quiet=False)

Parse arguments without a subcommand descent or any print-and-exit side effect.

An advisory, throwaway pre-parse of parser's ROOT-level options only:

  • Any subparsers action is DETACHED for the duration of the call (see :func:strip_subparsers) and restored exactly afterward. A subcommand name -- or anything after it -- becomes an ordinary unrecognized trailing token instead of re-entering the parser's own (possibly duho-patched) parse_known_args, whatever it is. This is what makes the exported duho.parsers.prerun_parse safe to call directly on any duho root that has _subcommands_, and what lets duho.app's advisory register prepass run on a root that already has built-in subcommands without a KeyError('#cls') from a double-popped selection marker.
  • Every TERMINAL action (:func:_is_terminal_action: -h/--help, --version, --print-completion, --help-agents) is swapped to a no-op for the duration of the call, also restored afterward -- widened from the original, help-only version: before, only --help was silenced, so --version printed twice (once here, once on the real parse afterward) and --print-completion wrote two concatenated scripts.

Since there is never a reachable subparser action during this call, only parser's OWN root-level actions can ever be terminal here -- no subparser subtree to walk.

When quiet is true, parser.error() (a missing required option, a bad type= conversion) is ALSO silenced: it raises SystemExit(2) without writing usage/error text, for a caller (duho.app's advisory prepass) that re-parses for real right after and reports any error exactly once, itself. quiet=False (the default -- duho.parse_globals's case, which never re-parses) leaves parser.error() writing normally, so a genuinely missing global is still reported, once, here.

All surgery is per-instance, restored in finally: no argparse/duho CLASS is ever mutated, and parser itself is back to its original shape (subparsers action included) before this returns or raises -- safe for repeated and nested calls, since nothing here is shared state.

Source code in src/duho/parsers.py
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
def prerun_parse(
    parser: _argparse.ArgumentParser,
    argv: _ty.Optional[_ty.Sequence[str]] = None,
    *,
    quiet: bool = False,
) -> _argparse.Namespace:
    """Parse arguments without a subcommand descent or any print-and-exit
    side effect.

    An advisory, throwaway pre-parse of ``parser``'s ROOT-level options only:

    * Any subparsers action is DETACHED for the duration of the call (see
      :func:`strip_subparsers`) and restored exactly afterward. A subcommand
      name -- or anything after it -- becomes an ordinary unrecognized
      trailing token instead of re-entering the parser's own (possibly
      duho-patched) ``parse_known_args``, whatever it is. This is what makes
      the exported ``duho.parsers.prerun_parse`` safe to call directly on any
      duho root that has ``_subcommands_``, and what lets
      ``duho.app``'s advisory ``register`` prepass run on a root that already
      has built-in subcommands without a ``KeyError('#cls')`` from a
      double-popped selection marker.
    * Every TERMINAL action (:func:`_is_terminal_action`: ``-h``/``--help``,
      ``--version``, ``--print-completion``, ``--help-agents``) is swapped to
      a no-op for the duration of the call, also restored afterward -- widened
      from the original, help-only version: before, only
      ``--help`` was silenced, so ``--version`` printed twice (once here, once
      on the real parse afterward) and ``--print-completion`` wrote two
      concatenated scripts.

    Since there is never a reachable subparser action during this call, only
    ``parser``'s OWN root-level actions can ever be terminal here -- no
    subparser subtree to walk.

    When ``quiet`` is true, ``parser.error()`` (a missing required option, a
    bad ``type=`` conversion) is ALSO silenced: it raises ``SystemExit(2)``
    without writing usage/error text, for a caller (``duho.app``'s advisory
    prepass) that re-parses for real right after and reports any error
    exactly once, itself. ``quiet=False`` (the default --
    ``duho.parse_globals``'s case, which never re-parses) leaves
    ``parser.error()`` writing normally, so a genuinely missing global is
    still reported, once, here.

    All surgery is per-instance, restored in ``finally``: no ``argparse``/duho
    CLASS is ever mutated, and ``parser`` itself is back to its original shape
    (subparsers action included) before this returns or raises -- safe for
    repeated and nested calls, since nothing here is shared state.
    """
    saved_subparsers = strip_subparsers(parser)

    terminal_actions = [a for a in parser._actions if _is_terminal_action(a)]
    saved_classes = [(a, a.__class__) for a in terminal_actions]
    for a in terminal_actions:
        a.__class__ = _NoOpAction

    if quiet:

        def _silent_error(message=None):  # noqa: ARG001 - argparse's own signature
            raise SystemExit(2)

        parser.error = _silent_error  # type: ignore[method-assign]

    try:
        args, _unused = parser.parse_known_args(argv)
    finally:
        if quiet:
            del parser.error
        for a, cls in saved_classes:
            a.__class__ = cls
        _restore_subparsers(parser, saved_subparsers)
    return args

strip_subparsers(parser)

Detach parser's subparsers action (if any) from BOTH _actions and whichever argument group it belongs to, returning what :func:_restore_subparsers needs to put it back in the exact same place.

Removing only from _actions (a past, buggy shortcut this replaces) leaves the action listed in its owning group's _group_actions, which format_help/usage formatting renders from -- so a caller that stripped it for good (e.g. a subcommand's parents= donor parser, which never wants the whole app's command tree inherited downward) still showed it in help. Safe for a PERMANENT removal too: just discard the returned handle instead of passing it to :func:_restore_subparsers.

Returns None when parser has no subparsers action.

Source code in src/duho/parsers.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
def strip_subparsers(
    parser: _argparse.ArgumentParser,
) -> _ty.Optional[_ty.Tuple[_argparse.Action, int, object, _ty.Optional[int]]]:
    """Detach ``parser``'s subparsers action (if any) from BOTH ``_actions``
    and whichever argument group it belongs to, returning what
    :func:`_restore_subparsers` needs to put it back in the exact same place.

    Removing only from ``_actions`` (a past, buggy shortcut this replaces)
    leaves the action listed in its owning group's ``_group_actions``,
    which ``format_help``/usage formatting renders from -- so a caller that
    stripped it for good (e.g. a subcommand's ``parents=`` donor parser, which
    never wants the whole app's command tree inherited downward) still showed
    it in help. Safe for a PERMANENT removal too: just discard the returned
    handle instead of passing it to :func:`_restore_subparsers`.

    Returns ``None`` when ``parser`` has no subparsers action.
    """
    action = find_subparsers(parser)
    if action is None:
        return None
    actions_index = parser._actions.index(action)
    parser._actions.pop(actions_index)
    group = None
    group_index = None
    for candidate in parser._action_groups:
        group_actions = candidate._group_actions
        if action in group_actions:
            group = candidate
            group_index = group_actions.index(action)
            group_actions.pop(group_index)
            break
    return (action, actions_index, group, group_index)

unique_subcommands(parser, seen=None)

Yield (canonical_name, aliases, subparser) once per DISTINCT subcommand of parser.

argparse registers every alias as an extra choices key pointing at the SAME subparser object; entries are grouped by id(subparser) and each is yielded once, under its canonical name -- the subcommand class's own :func:command_name if it is one of the registered names, else the first-seen key. aliases is every OTHER registered name for that same subparser (empty when there are none).

seen is the caller's running set of already-yielded subparser ids, threaded through a whole-tree walk (pass the same set into a recursive call) so a subparser reached under multiple paths is never yielded twice; omit it (the default) for a single-level call. duho.agenthelp and duho.mcp share this one grouping and canonical-name rule.

Source code in src/duho/parsers.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
def unique_subcommands(
    parser: _argparse.ArgumentParser, seen: _ty.Optional[set] = None
) -> _ty.Iterator[tuple]:
    """Yield ``(canonical_name, aliases, subparser)`` once per DISTINCT
    subcommand of ``parser``.

    argparse registers every alias as an extra ``choices`` key pointing at
    the SAME subparser object; entries are grouped by ``id(subparser)`` and
    each is yielded once, under its canonical name -- the subcommand class's
    own :func:`command_name` if it is one of the registered names, else the
    first-seen key. ``aliases`` is every OTHER registered name for that same
    subparser (empty when there are none).

    ``seen`` is the caller's running set of already-yielded subparser ids,
    threaded through a whole-tree walk (pass the same set into a recursive
    call) so a subparser reached under multiple paths is never yielded twice;
    omit it (the default) for a single-level call. ``duho.agenthelp`` and
    ``duho.mcp`` share this one grouping and canonical-name rule.
    """
    if seen is None:
        seen = set()
    subparsers_action = find_subparsers(parser)
    if subparsers_action is None:
        return

    grouped: dict = {}
    order: list = []
    for choice_name, subparser in (subparsers_action.choices or {}).items():
        key = id(subparser)
        if key not in grouped:
            grouped[key] = {"parser": subparser, "names": []}
            order.append(key)
        grouped[key]["names"].append(choice_name)

    for key in order:
        if key in seen:
            continue
        seen.add(key)
        entry = grouped[key]
        subparser = entry["parser"]
        names = entry["names"]
        sub_cls = getattr(subparser, "_duho_cls_", None)
        canonical = command_name(sub_cls) if sub_cls else None
        if canonical not in names:
            canonical = names[0]
        aliases = tuple(n for n in names if n != canonical)
        yield canonical, aliases, subparser