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>
103 lines
3.5 KiB
Python
103 lines
3.5 KiB
Python
import asyncio
|
|
|
|
import sys
|
|
|
|
from jw.pkg.lib.App import App
|
|
from jw.pkg.lib.Cmd import Cmd
|
|
|
|
# -- A minimal command tree: a root command hosting one child command. The
|
|
# root mimics the "run + opts" unit the App can now mount at the top level:
|
|
# it adds options, sets up, dispatches to the selected child (or runs its own
|
|
# default), and tears down.
|
|
|
|
class ChildCmd(Cmd):
|
|
|
|
def __init__(self, parent):
|
|
super().__init__(parent, 'child', 'A child command')
|
|
self.ran = False
|
|
self.got_opt = None
|
|
|
|
def add_arguments(self, parser):
|
|
super().add_arguments(parser)
|
|
parser.add_argument('--child-opt', default = 'child-default')
|
|
|
|
async def _run(self, args):
|
|
self.ran = True
|
|
self.got_opt = args.child_opt
|
|
|
|
class RootCmd(Cmd):
|
|
|
|
made = []
|
|
|
|
def __init__(self, parent):
|
|
super().__init__(parent, 'root', 'Root command')
|
|
self.child = ChildCmd(self)
|
|
self.add_subcommands(self.child)
|
|
self.setup = False
|
|
self.teardown = False
|
|
self.default = False
|
|
RootCmd.made.append(self)
|
|
|
|
def add_arguments(self, parser):
|
|
super().add_arguments(parser)
|
|
parser.add_argument('--root-opt', default = 'root-default')
|
|
|
|
async def _run(self, args):
|
|
self.setup = True
|
|
try:
|
|
if hasattr(args, 'func'):
|
|
await args.func(args)
|
|
else:
|
|
self.default = True
|
|
finally:
|
|
self.teardown = True
|
|
|
|
# -- App.__init__ builds the parser from sys.argv, so point it at a clean
|
|
# command line while constructing the app.
|
|
|
|
saved_argv = sys.argv
|
|
sys.argv = ['jw-pkg-test']
|
|
try:
|
|
app = App(description = 'Root slot test', root = RootCmd)
|
|
finally:
|
|
sys.argv = saved_argv
|
|
|
|
root = RootCmd.made[0]
|
|
child = root.child
|
|
|
|
# -- Parent chain: the child is parented to the root, the root to the app,
|
|
# and both resolve their application through the chain.
|
|
assert child.parent is root, 'child.parent should be the root'
|
|
assert root.parent is app, 'root.parent should be the app'
|
|
assert child.app is app, 'child.app should resolve to the app'
|
|
assert root.app is app, 'root.app should resolve to the app'
|
|
|
|
# -- The root's options are registered on the top-level parser, and the child
|
|
# is wired as a top-level subcommand.
|
|
args = app.parser.parse_args(['--root-opt', 'RV', 'child', '--child-opt', 'CV'])
|
|
assert args.root_opt == 'RV', args
|
|
assert args.child_opt == 'CV', args
|
|
assert hasattr(args, 'func'), 'selecting a subcommand must set the dispatch target'
|
|
|
|
# -- Dispatching with a selected child runs the child, wrapped by the root's
|
|
# setup and teardown.
|
|
asyncio.run(app._run(args))
|
|
assert child.ran is True, 'the selected child must run'
|
|
assert child.got_opt == 'CV', child.got_opt
|
|
assert root.setup is True, 'root setup must run'
|
|
assert root.teardown is True, 'root teardown must run'
|
|
assert root.default is False, 'default must not run when a child is selected'
|
|
|
|
# -- Dispatching with no subcommand runs the root's default, still wrapped by
|
|
# setup and teardown, and does not run the child.
|
|
root.setup = root.teardown = root.default = False
|
|
child.ran = False
|
|
args = app.parser.parse_args(['--root-opt', 'RV2'])
|
|
assert not hasattr(args, 'func'), 'no subcommand must not set a dispatch target'
|
|
asyncio.run(app._run(args))
|
|
assert root.setup is True, 'root setup must run'
|
|
assert root.teardown is True, 'root teardown must run'
|
|
assert root.default is True, 'root default must run when no child is selected'
|
|
assert child.ran is False, 'the child must not run when no child is selected'
|
|
|
|
print('All App root-slot tests passed')
|