lib.App: Add a root command slot
All checks were successful
CI / Packaging - Kali Linux (pull_request) Successful in 4m23s
CI / Packaging - OpenSUSE Tumbleweed (pull_request) Successful in 4m28s
CI / Packaging test (pull_request) Successful in 0s
CI / Packaging - Kali Linux (push) Successful in 3m58s
CI / Packaging - OpenSUSE Tumbleweed (push) Successful in 4m16s
CI / Packaging test (push) Successful in 0s

The application's top-level behavior is defined by overriding
App._add_arguments() and App._run(). The lightweight run-and-options
unit, Cmd, can already be mounted at any node of the command tree, but
the root is reserved for the application itself. An application that
wants to host a plain command at the top level therefore has to
subclass App and carry its full lifecycle implementation.

Add a root parameter to App.__init__(). When it is given a command
class, App instantiates it and uses it as the top level: the command's
options are registered on the top-level parser, it becomes the parent
of the top-level subcommands, and App._run() delegates the run to it.
The command's children are wired as the top-level subcommands, so the
same Cmd can now occupy the root node. When root is not given, the
previous auto-discovery behavior is preserved unchanged.

Keep the top-level subcommand heading as plain "Available subcommands"
whether it is hosted by the application or by a root command, while
nested command levels continue to qualify the heading with the parent
name. Add a unit test that mounts a root command hosting a child and
checks option registration, dispatch, setup and teardown, and
resolution of the application through the parent chain.

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-16 17:34:38 +02:00
commit 6b4bcdfaf8
Signed by: Jan Lindemann
GPG key ID: 3750640C9E25DD61
3 changed files with 171 additions and 11 deletions

View file

@ -112,6 +112,7 @@ class App: # export
name_filter: str = '^Cmd.*',
modules: list[str] | None = None,
eloop: asyncio.AbstractEventLoop | None = None,
root: type[AbstractCmd] | None = None,
) -> None:
self.__args: Namespace | None = None
@ -143,12 +144,22 @@ class App: # export
self.__eloop = eloop
self.__own_eloop = False
cmd_classes: LoadTypes[AbstractCmd] = LoadTypes(
modules if modules else ['__main__'],
type_name_filter = name_filter,
type_filter = [AbstractCmd],
)
self.__cmds: list[AbstractCmd] = [cmd_class(self) for cmd_class in cmd_classes]
if root is not None:
# -- The application's top-level behavior is delegated to a root
# command. The root hosts the top-level options and its
# subcommands become the top-level subcommands. It is the same
# kind of command that can be mounted at any other node in the
# tree.
self.__root: AbstractCmd | None = root(self)
self.__cmds: Collection[AbstractCmd] = list(self.__root.children)
else:
self.__root = None
cmd_classes: LoadTypes[AbstractCmd] = LoadTypes(
modules if modules else ['__main__'],
type_name_filter = name_filter,
type_filter = [AbstractCmd],
)
self.__cmds = [cmd_class(self) for cmd_class in cmd_classes]
self._build_parser()
def _build_parser(self, argv: list[str] | None = None) -> None:
@ -172,13 +183,17 @@ class App: # export
parent: AbstractCmd | App,
parser: ArgumentParser,
cmds: Collection[AbstractCmd],
all: bool = False
all: bool,
top_level: bool,
) -> None:
if not cmds:
return
title = 'Available subcommands'
if isinstance(parent, AbstractCmd):
# -- Only nested command levels qualify the title with the parent
# name; the top level (hosted by the application itself or by a
# root command) keeps the plain title.
if not top_level and isinstance(parent, AbstractCmd):
title += ' of ' + parent.name
subparsers = parser.add_subparsers(
title = title, metavar = '', dest = 'command'
@ -203,7 +218,11 @@ class App: # export
if id(sc) not in seen:
seen.add(id(sc))
add_cmds_to_parser(
sc.cmd, sc.parser, sc.cmd.children, all = all
sc.cmd,
sc.parser,
sc.cmd.children,
all = all,
top_level = False,
)
return
# -- Re-parse the command line to find the invoked subcommand.
@ -214,7 +233,13 @@ class App: # export
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)
add_cmds_to_parser(
sc.cmd,
sc.parser,
sc.cmd.children,
all = all,
top_level = False,
)
cmdline = sys.argv if argv is None else argv
if argv is None:
@ -229,6 +254,12 @@ class App: # export
add_help = False,
)
self._add_arguments(self.__parser)
if self.__root is not None:
# -- The root command hosts the top-level behavior: register its
# options on the top-level parser and make it the parent of the
# top-level subcommands.
self.__root.set_parser(self.__parser)
self.__root.add_arguments(self.__parser)
if not add_all_parsers:
# Parse known args and configure logging, but only if we're not on
@ -241,7 +272,22 @@ class App: # export
set_log_level(args.log_level)
log(DEBUG, f'-------------- Running: >{pretty_cmd(cmdline)}<')
add_cmds_to_parser(self, self.__parser, self.__cmds, all = add_all_parsers)
if self.__root is None:
add_cmds_to_parser(
self,
self.__parser,
self.__cmds,
all = add_all_parsers,
top_level = False,
)
else:
add_cmds_to_parser(
self.__root,
self.__parser,
self.__root.children,
all = add_all_parsers,
top_level = True,
)
# -- Add help only now, wouldn't want to have parse_known_args() exit
# on --help with subcommands missing
@ -343,6 +389,10 @@ class App: # export
# want to do something else, for instance if you don't have sub-commands,
# or if want to do anything before and / or after the subcommands.
async def _run(self, args: Namespace) -> None | int:
if self.__root is not None:
# -- Delegate the top-level behavior (options, setup, dispatch,
# teardown) to the root command.
return cast('None | int', await self.__root.run(args))
if not hasattr(args, 'func'):
self.__parser.print_help()
return None