lib.App: Tidy up subcommand registration

The subcommand registration in _build_parser() defines a SubCommand
helper class inside the add_cmds_to_parser() closure, so a fresh class
object is created on every call. It also stores command names and
aliases in a dictionary without checking for duplicates, so a colliding
name or alias is silently overwritten, and it relies on every subparser
level sharing the dest = 'command' attribute to descend one level per
re-parse, an invariant that is not documented anywhere.

Hoist the helper to a module-level _SubCommand NamedTuple, log a
warning when a subcommand name or alias collides with an earlier one at
the same level, and document the dest = 'command' invariant next to the
re-parse.

Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2
Signed-off-by: Jan Lindemann <jan@janware.com>
This commit is contained in:
Jan Lindemann 2026-08-14 23:41:34 +02:00
commit 3ac649cdf1
Signed by: Jan Lindemann
GPG key ID: 3750640C9E25DD61

View file

@ -7,13 +7,15 @@ import sys
import warnings
from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace
from typing import TYPE_CHECKING, Any, cast, override
from typing import TYPE_CHECKING, Any, NamedTuple, cast, override
from .AsyncRunner import AsyncRunner
from .Cmd import AbstractCmd
from .log import (
DEBUG,
ERR,
NOTICE,
WARNING,
LogFlag,
log,
log_m,
@ -43,6 +45,11 @@ def _get_current_event_loop() -> asyncio.AbstractEventLoop | None:
except (RuntimeError, DeprecationWarning):
return None
class _SubCommand(NamedTuple):
cmd: AbstractCmd
parser: ArgumentParser
class App: # export
def _add_arguments(self, parser: ArgumentParser) -> None:
@ -105,8 +112,6 @@ class App: # export
eloop: asyncio.AbstractEventLoop | None = None,
) -> None:
from .Cmd import AbstractCmd
self.__args: Namespace | None = None
self.__cmdline: str | None = None
self.__description = description
@ -171,23 +176,25 @@ class App: # export
if not cmds:
return
class SubCommand:
def __init__(self, cmd: AbstractCmd, parser: Any):
self.cmd = cmd
self.parser = parser
title = 'Available subcommands'
if isinstance(parent, AbstractCmd):
title += ' of ' + parent.name
subparsers = parser.add_subparsers(
title = title, metavar = '', dest = 'command'
)
scs: dict[str, SubCommand] = {}
scs: dict[str, _SubCommand] = {}
for cmd in cmds:
cmd.set_parent(parent)
scs[cmd.name] = SubCommand(cmd, add_cmd_to_parser(cmd, subparsers))
if cmd.name in scs:
log(WARNING, f'Duplicate subcommand name: {cmd.name}')
scs[cmd.name] = _SubCommand(cmd, add_cmd_to_parser(cmd, subparsers))
for alias in cmd.aliases:
if alias != cmd.name and alias in scs:
log(
WARNING,
f'Subcommand alias "{alias}" of "{cmd.name}" '
'collides with an earlier subcommand',
)
scs[alias] = scs[cmd.name]
if all:
seen: set[int] = set()
@ -198,14 +205,16 @@ class App: # export
sc.cmd, sc.parser, sc.cmd.children, all = all
)
return
# -- Re-parse the command line to find the invoked subcommand.
# This works because every level below uses dest = 'command',
# so each pass descends one level further into the command
# tree.
args, _ = self.__parser.parse_known_args(argv)
cmd_name = getattr(args, 'command', None)
if cmd_name in scs:
sc = scs[cmd_name]
add_cmds_to_parser(sc.cmd, sc.parser, sc.cmd.children, all = all)
from .Cmd import AbstractCmd
cmdline = sys.argv if argv is None else argv
if argv is None:
argv = sys.argv[1:]