lib.Cmd: Build the command tree lazily

App() construction builds the entire command tree: every
load_subcommands() call in a command's __init__() constructs its whole
subtree eagerly, so running a single leaf command pays to instantiate
every unrelated command object as well (jw-pkg builds about 50 command
objects for any invocation).

Defer the construction instead. load_subcommands() now records only the
module search path and name filter, and the subcommands are materialized
on first access to the children or child_classes property. The parser
reads children only down the invoked branch on the non-help path, so a
simple run instantiates just that path (jw-pkg builds 5-7 objects),
while the help and completion path expands every node and leaves
rendered help unchanged.

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-17 19:25:41 +02:00
commit 9f7e1b6cb2

View file

@ -25,6 +25,12 @@ class AbstractCmd(abc.ABC):
self.__children: list[Cmd] = []
self.__child_classes: list[type[Cmd]] = []
self.__parser: ArgumentParser | None = None
# -- Subcommands registered via load_subcommands() are not built
# immediately; the module search path and name filter are stored here
# and the subcommands are materialized on first access to `children`
# (see __materialize_pending_subcommands()). This keeps a simple run
# from instantiating the whole command tree.
self.__pending_subcommands: tuple[list[str], str] | None = None
def set_parent(self, parent: Any | Cmd) -> None:
self.__parent = parent
@ -61,12 +67,25 @@ class AbstractCmd(abc.ABC):
parent = parent.__parent
return self.__app
def __materialize_pending_subcommands(self) -> None:
# -- Build the subcommands that load_subcommands() registered
# lazily, if any. Called on first access to `children` (and
# `child_classes`) so that only the parts of the tree a run actually
# descends into are ever instantiated.
if self.__pending_subcommands is None:
return
modules, name_filter = self.__pending_subcommands
self.__pending_subcommands = None
self.add_subcommands(LoadTypes(modules, type_name_filter = name_filter))
@property
def children(self) -> tuple[Cmd, ...]:
self.__materialize_pending_subcommands()
return tuple(self.__children)
@property
def child_classes(self) -> tuple[type[Cmd], ...]:
self.__materialize_pending_subcommands()
return tuple(self.__child_classes)
@property
@ -127,7 +146,11 @@ class AbstractCmd(abc.ABC):
modules = [type(self).__module__.replace('Cmd', '').lower()]
elif isinstance(modules, str):
modules = [modules]
self.add_subcommands(LoadTypes(modules, type_name_filter = name_filter))
# -- Defer the actual subcommand construction: store the search path
# and filter, and materialize on first access to `children`. Building
# them here (in __init__) would instantiate the entire tree up front,
# even for a run that only descends into a single branch.
self.__pending_subcommands = (modules, name_filter)
# -- Interface to derived classes