Nested commands
Advanced Features and Best Practices
1. Command Inheritance
You can create a base command class to share functionality:
class BaseCommand(Parser):
def common_method(self):
pass
class SpecificCommand(BaseCommand):
def cli_run(self):
self.common_method()
2. Argument Inheritance
Ancestor flags are copied onto descendant parsers by default
(propagate=True). Opt out per flag with propagate=False. cli_run on a
leaf still receives parent dests, and the flags may appear before or after
the subcommand name. See
Ancestor flags on descendants.
class AppMain(Parser):
verbose = Argument("--verbose", action="store_true")
class SubCommand(Parser):
def cli_run(self, verbose=False, **_):
if verbose:
print("Verbose mode enabled")
For structured -v / -vv logging tiers, prefer LoggingOptMixin
(see Logging) instead of a hand-rolled boolean flag.
A mixin is optional when the flag already sits on a parent parser.
3. Named help groups and exclusive groups
Clak-only kwargs on Argument (stripped before add_argument):
| Key | Role |
|---|---|
option_group="Title" |
Help section (typical for options) |
argument_group="Title" |
Help section (typical for positionals) |
exclusive_group="key" |
At most one member may be set (argparse mutual exclusion) |
propagate=False |
Do not copy this flag onto descendant parsers |
option_group and argument_group are the same feature under two names: the same
title string reuses one add_argument_group. Do not set both on one Argument.
exclusive_group enforces XOR at parse time (required=False); it does not
create a titled help section by itself, but may nest under a help group when
both are set.
from clak import Argument, Parser
class App(Parser):
catalog = Argument("--catalog", help="Pick a catalog")
format = Argument(
"--format",
choices=["view", "json"],
option_group="Output options",
help="Output format",
)
columns = Argument(
"--columns",
option_group="Output options",
help="Columns to show",
)
json = Argument("--json", action="store_true", exclusive_group="fmt")
yaml = Argument("--yaml", action="store_true", exclusive_group="fmt")
def cli_run(self, **_):
return None
App(parse=False, add_help=True).parser.format_help() shows --catalog under
the default options section and --format / --columns under
Output options. Passing both --json and --yaml is a parse error.
Subcommand grouping is formatter metadata (not a second add_subparsers).
Meta.command_groups is ordered (key, title) pairs on that Parser only.
Command(..., command_group="key") is stripped before add_parser. Keys
without members are omitted. Commands with no command_group stay under
leftover subcommands:. Ungrouped CLIs keep a single subcommands: list.
--help lists nested children by default (Meta.help_subcommands = "all").
Set "top" on the root (inherited; a child may override) for immediate
children only. Nested names hide the parent path by default
(Meta.help_hide_parent = True); set False for flattened paths such as
tool leaf. Grouping and listing depth are independent.
from clak import Command, Parser
class ToolGroup(Parser):
def cli_run(self, **_):
return None
class RenderCmd(Parser):
def cli_run(self, **_):
return None
class OrphanCmd(Parser):
def cli_run(self, **_):
return None
class App(Parser):
class Meta:
command_groups = (
("base", "subcommands (base):"),
("dynamic", "subcommands (dynamic):"),
)
tool = Command(ToolGroup, command_group="base")
render = Command(RenderCmd, command_group="dynamic")
orphan = Command(OrphanCmd)
Intermixed flags and positionals
The command path is ordered (app grep). Once a command is selected, that
command's own flags and positionals may mix. This is on by default.
Set Meta.parse_intermixed = False to get argparse leftover errors after a
flag.
The difference shows up with nargs="*" or nargs="+" (and a fixed
nargs=N greater than 1). A single required positional is unchanged:
cmd file --force already works either way. Unknown flags (--nope) still
error either way. Extra tokens for a single positional (NAME then a spare
word) still error either way.
from clak import Arg, Command, Opt, Parser
class Grep(Parser):
pattern = Arg("pattern")
files = Arg("files", nargs="*")
ignore_case = Opt("-i", "--ignore-case", action="store_true")
def cli_run(self, pattern, files, ignore_case=False, **_):
return None
class App(Parser):
grep = Command(Grep, help="Search files")
CLI: app grep foo a.txt -i b.txt
parse_intermixed |
Result |
|---|---|
True (default) |
pattern=foo, ignore_case=True, files=['a.txt', 'b.txt'] |
False |
error: unrecognized arguments: b.txt |
These are equivalent when the default is on:
Opt out on a node (inherited; a child may set True again):
Parent flags are copied onto descendants by default (see
Ancestor flags on descendants). Nodes with
subcommands keep standard parse for themselves (argparse cannot intermix
add_subparsers). nargs="..." remainder is incompatible and falls back to
standard parse. Do not mix a flag and a positional in one exclusive_group
when using intermixed parse.
Ancestor flags on descendants
Ancestor flags (not positionals) are copied onto descendant parsers at build time. Default on. Both placements work without redeclaring the option on the leaf:
verbose = Opt("--verbose", action="store_true") # propagates
quiet = Opt("--quiet", action="store_true", propagate=False) # opt out
Opt out with propagate=False. Argparse's own -h / --help is not a
Clak Argument, so it is not copied.
Copies use default=argparse.SUPPRESS so a value set on the parent is not
reset when the leaf does not see the flag. Leaf --help lists them in a
dedicated parent options: section (rename with
Meta.propagate_options_group). Closer ancestor wins when the same dest or
option strings appear on more than one node. The same command class under
two roots only inherits flags from that instance's parent chain.
Disable all copying on a node (inherited; a child may set True again):
Required parent flags still must appear before the subcommand (argparse validates them on the parent first). Exclusive groups do not span parse stages. Mixin/class inheritance still shares flags across unrelated commands; this feature is the command tree.
4. Custom Help Messages
Override the default help behavior:
def cli_run(self, **_):
print("Custom usage information:")
self.show_usage()
print("\nDetailed help:")
self.show_help()
For colored --help, see Colored help. Opt out with
Meta.help_formatter = RecursiveHelpFormatter.
5. Command Organization Best Practices
-
Logical Grouping:
- Group related commands under common parents
- Use meaningful command names
- Keep the hierarchy shallow (3-4 levels max)
-
Argument Design:
- Put shared options in parent commands
- Use consistent naming across commands
- Provide sensible defaults
-
Documentation:
- Write clear help messages
- Document command relationships
- Include examples in docstrings
-
Code Structure:
- One class per command
- Use inheritance for shared behavior
- Keep command implementations focused
Error Handling and Validation
For production CLI apps, prefer Clak's built-in exception pipeline instead of
manual print + return 1. See Error handling for patterns
used in Paasify (domain translation, result.error guards, Meta.known_exceptions).
from clak.exception import ClakUserError
def cli_run(self, name=None, **_):
if not name:
raise ClakUserError("NAME is required")
Testing Nested Commands
- Test Command Structure:
def test_command_structure():
app = AppMain()
assert hasattr(app, 'command1')
assert hasattr(app.command1, 'sub1')
- Test Command Execution: