Skip to content

Commit bcbaf18

Browse files
tleonhardtKelvinChung2000
authored andcommitted
feat: add support for subcommand and groups
2 parents 7f4cd61 + ca8495e commit bcbaf18

File tree

14 files changed

+2405
-895
lines changed

14 files changed

+2405
-895
lines changed

cmd2/annotated.py

Lines changed: 614 additions & 210 deletions
Large diffs are not rendered by default.

cmd2/argparse_completer.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -743,12 +743,11 @@ def _get_raw_choices(self, arg_state: _ArgumentState) -> list[CompletionItem] |
743743

744744
return ChoicesCallable(is_completer=True, to_call=Cmd.path_complete)
745745

746-
if isinstance(action_type, type) and issubclass(action_type, enum.Enum):
747-
return [CompletionItem(str(m.value), display_meta=m.name) for m in action_type]
748-
749-
enum_from_converter = getattr(action_type, '_cmd2_enum_class', None)
750-
if isinstance(enum_from_converter, type) and issubclass(enum_from_converter, enum.Enum):
751-
return [CompletionItem(str(m.value), display_meta=m.name) for m in enum_from_converter]
746+
enum_class = action_type if isinstance(action_type, type) and issubclass(action_type, enum.Enum) else None
747+
if enum_class is None:
748+
enum_class = getattr(action_type, '_cmd2_enum_class', None)
749+
if isinstance(enum_class, type) and issubclass(enum_class, enum.Enum):
750+
return [CompletionItem(str(m.value), display_meta=m.name) for m in enum_class]
752751

753752
return None
754753

cmd2/cmd2.py

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,14 @@ class AsyncAlert:
308308
timestamp: float = field(default_factory=time.monotonic, init=False)
309309

310310

311+
class _ConsoleCache(threading.local):
312+
"""Thread-local storage for cached Rich consoles used by core print methods."""
313+
314+
def __init__(self) -> None:
315+
self.stdout: Cmd2BaseConsole | None = None
316+
self.stderr: Cmd2BaseConsole | None = None
317+
318+
311319
class Cmd:
312320
"""An easy but powerful framework for writing line-oriented command interpreters.
313321
@@ -441,6 +449,9 @@ def __init__(
441449
self.scripts_add_to_history = True # Scripts and pyscripts add commands to history
442450
self.timing = False # Prints elapsed time for each command
443451

452+
# Cached Rich consoles used by core print methods.
453+
self._console_cache = _ConsoleCache()
454+
444455
# The maximum number of items to display in a completion table. If the number of completion
445456
# suggestions exceeds this number, then no table will appear.
446457
self.max_completion_table_items: int = 50
@@ -1324,28 +1335,57 @@ def visible_prompt(self) -> str:
13241335
"""
13251336
return su.strip_style(self.prompt)
13261337

1327-
def _create_base_printing_console(
1338+
def _get_core_print_console(
13281339
self,
13291340
*,
13301341
file: IO[str],
13311342
emoji: bool,
13321343
markup: bool,
13331344
highlight: bool,
13341345
) -> Cmd2BaseConsole:
1335-
"""Create a Cmd2BaseConsole with formatting overrides.
1346+
"""Get a console configured for the specified stream and formatting settings.
1347+
1348+
This method is intended for internal use by cmd2's core print methods.
1349+
To avoid the overhead of repeated initialization, it manages a thread-local
1350+
cache for consoles targeting ``self.stdout`` or ``sys.stderr``. It returns a cached
1351+
instance if its configuration matches the request. For all other streams, or if
1352+
the configuration has changed, a new console is created.
1353+
1354+
Note: This implementation works around a bug in Rich where passing formatting settings
1355+
(emoji, markup, and highlight) directly to console.print() or console.log() does not
1356+
always work when printing certain Renderables. Passing them to the constructor instead
1357+
ensures they are correctly propagated. Once this bug is fixed, these parameters can
1358+
be removed from this method. For more details, see:
1359+
https://github.com/Textualize/rich/issues/4028
1360+
"""
1361+
# Dictionary of settings to check against cached consoles
1362+
kwargs = {
1363+
"emoji": emoji,
1364+
"markup": markup,
1365+
"highlight": highlight,
1366+
}
13361367

1337-
This works around a bug in Rich where passing these formatting settings directly to
1338-
console.print() or console.log() does not always work when printing certain Renderables.
1339-
Passing them to the constructor instead ensures they are correctly propagated.
1368+
# Check if we should use or update a cached console
1369+
if file is self.stdout:
1370+
cached = self._console_cache.stdout
1371+
if cached is not None and cached.matches_config(file=file, **kwargs):
1372+
return cached
13401373

1341-
See: https://github.com/Textualize/rich/issues/4028
1342-
"""
1343-
return Cmd2BaseConsole(
1344-
file=file,
1345-
emoji=emoji,
1346-
markup=markup,
1347-
highlight=highlight,
1348-
)
1374+
# Create new console and update cache
1375+
self._console_cache.stdout = Cmd2BaseConsole(file=file, **kwargs)
1376+
return self._console_cache.stdout
1377+
1378+
if file is sys.stderr:
1379+
cached = self._console_cache.stderr
1380+
if cached is not None and cached.matches_config(file=file, **kwargs):
1381+
return cached
1382+
1383+
# Create new console and update cache
1384+
self._console_cache.stderr = Cmd2BaseConsole(file=file, **kwargs)
1385+
return self._console_cache.stderr
1386+
1387+
# For any other file, just create a new console
1388+
return Cmd2BaseConsole(file=file, **kwargs)
13491389

13501390
def print_to(
13511391
self,
@@ -1398,7 +1438,7 @@ def print_to(
13981438
See the Rich documentation for more details on emoji codes, markup tags, and highlighting.
13991439
"""
14001440
try:
1401-
self._create_base_printing_console(
1441+
self._get_core_print_console(
14021442
file=file,
14031443
emoji=emoji,
14041444
markup=markup,
@@ -1714,7 +1754,7 @@ def ppaged(
17141754
soft_wrap = True
17151755

17161756
# Generate the bytes to send to the pager
1717-
console = self._create_base_printing_console(
1757+
console = self._get_core_print_console(
17181758
file=self.stdout,
17191759
emoji=emoji,
17201760
markup=markup,

cmd2/decorators.py

Lines changed: 122 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ def arg_decorator(func: ArgparseCommandFunc[CmdOrSet]) -> RawCommandFuncOptional
280280
"""
281281

282282
@functools.wraps(func)
283-
def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None:
283+
def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
284284
"""Command function wrapper which translates command line into argparse Namespace and call actual command function.
285285
286286
:param args: All positional arguments to this function. We're expecting there to be:
@@ -349,24 +349,73 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None:
349349
def with_annotated(
350350
func: Callable[..., Any] | None = None,
351351
*,
352+
ns_provider: Callable[..., argparse.Namespace] | None = None,
352353
preserve_quotes: bool = False,
353354
with_unknown_args: bool = False,
355+
base_command: bool = False,
356+
subcommand_to: str | None = None,
357+
help: str | None = None, # noqa: A002
358+
aliases: Sequence[str] | None = None,
359+
groups: tuple[tuple[str, ...], ...] | None = None,
360+
mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None,
354361
) -> Any:
355362
"""Decorate a ``do_*`` method to build its argparse parser from type annotations.
356363
357-
Can be used bare or with keyword arguments::
364+
.. warning:: Experimental -- behaviour may change in future releases.
358365
366+
:param func: the command function (when used without parentheses)
367+
:param ns_provider: optional callable returning a prepopulated argparse.Namespace.
368+
Not supported with ``subcommand_to``.
369+
:param preserve_quotes: if True, preserve quotes in arguments.
370+
Not supported with ``subcommand_to``.
371+
:param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``).
372+
Not supported with ``subcommand_to``.
373+
:param base_command: if True, this command has subcommands (adds ``add_subparsers()``).
374+
Requires a ``cmd2_handler`` parameter and no positional arguments.
375+
:param subcommand_to: parent command name (e.g. ``'team'`` or ``'team member'``).
376+
Function must be named ``{parent_underscored}_{subcommand}``.
377+
:param help: help text for the subcommand (only valid with ``subcommand_to``)
378+
:param aliases: alternative names for the subcommand (only valid with ``subcommand_to``)
379+
:param groups: tuples of parameter names to place in argument groups (for help display)
380+
:param mutually_exclusive_groups: tuples of parameter names that are mutually exclusive
381+
382+
Example:
383+
```py
384+
class MyApp(cmd2.Cmd):
359385
@with_annotated
360386
def do_greet(self, name: str, count: int = 1): ...
361387
362-
@with_annotated(preserve_quotes=True)
363-
def do_raw(self, text: str): ...
388+
@with_annotated(base_command=True)
389+
def do_team(self, *, cmd2_handler): ...
390+
391+
@with_annotated(subcommand_to='team', help='create a team')
392+
def team_create(self, name: str): ...
393+
```
364394
365-
:param func: the command function (when used without parentheses)
366-
:param preserve_quotes: if True, preserve quotes in arguments
367-
:param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``)
368395
"""
369-
from .annotated import build_parser_from_function
396+
from .annotated import (
397+
_filtered_namespace_kwargs,
398+
_validate_base_command_params,
399+
build_parser_from_function,
400+
build_subcommand_handler,
401+
)
402+
403+
if (help is not None or aliases is not None) and subcommand_to is None:
404+
raise TypeError("'help' and 'aliases' are only valid with subcommand_to")
405+
if subcommand_to is not None:
406+
unsupported: list[str] = []
407+
if ns_provider is not None:
408+
unsupported.append('ns_provider')
409+
if preserve_quotes:
410+
unsupported.append('preserve_quotes')
411+
if with_unknown_args:
412+
unsupported.append('with_unknown_args')
413+
if unsupported:
414+
names = ', '.join(unsupported)
415+
raise TypeError(
416+
f"{names} {'is' if len(unsupported) == 1 else 'are'} not supported with subcommand_to. "
417+
"Configure these behaviors on the base command instead."
418+
)
370419

371420
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
372421
if with_unknown_args:
@@ -376,45 +425,96 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
376425
if unknown_param.kind is inspect.Parameter.POSITIONAL_ONLY:
377426
raise TypeError('Parameter _unknown must be keyword-compatible when with_unknown_args=True')
378427

428+
if subcommand_to is not None:
429+
handler, subcmd_name, parser_builder = build_subcommand_handler(
430+
fn,
431+
subcommand_to,
432+
base_command=base_command,
433+
groups=groups,
434+
mutually_exclusive_groups=mutually_exclusive_groups,
435+
)
436+
setattr(handler, constants.SUBCMD_ATTR_COMMAND, subcommand_to)
437+
setattr(handler, constants.SUBCMD_ATTR_NAME, subcmd_name)
438+
setattr(handler, constants.CMD_ATTR_ARGPARSER, parser_builder)
439+
add_parser_kwargs: dict[str, Any] = {}
440+
if help is not None:
441+
add_parser_kwargs['help'] = help
442+
if aliases:
443+
add_parser_kwargs['aliases'] = aliases[:]
444+
setattr(handler, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, add_parser_kwargs)
445+
return handler
446+
447+
if base_command:
448+
_validate_base_command_params(fn, include_unknown=with_unknown_args)
449+
450+
# ---- Normal do_* command ----
379451
command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
380452

453+
# Cache signature introspection at decoration time, not per-invocation
454+
_accepted_params = set(inspect.signature(fn).parameters.keys()) - {'self'}
455+
381456
@functools.wraps(fn)
382-
def cmd_wrapper(*args: Any, **_kwargs: Any) -> bool | None:
457+
def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
383458
cmd2_app, statement_arg = _parse_positionals(args)
384459
owner = args[0] # Cmd or CommandSet instance
385-
_statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(
460+
statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(
386461
command_name, statement_arg, preserve_quotes
387462
)
388463

389464
arg_parser = cmd2_app._command_parsers.get(cmd_wrapper)
390465
if arg_parser is None:
391466
raise ValueError(f'No argument parser found for {command_name}')
392467

468+
# Resolve namespace provider (same logic as with_argparser)
469+
if ns_provider is None:
470+
namespace = None
471+
else:
472+
provider_self = cmd2_app._resolve_func_self(ns_provider, args[0])
473+
namespace = ns_provider(provider_self if provider_self is not None else cmd2_app)
474+
393475
try:
394476
if with_unknown_args:
395-
ns, unknown = arg_parser.parse_known_args(parsed_arglist)
477+
ns, unknown = arg_parser.parse_known_args(parsed_arglist, namespace)
396478
else:
397-
ns = arg_parser.parse_args(parsed_arglist)
479+
ns = arg_parser.parse_args(parsed_arglist, namespace)
398480
unknown = None
399481
except SystemExit as exc:
400482
raise Cmd2ArgparseError from exc
401483

402-
# Unpack Namespace into function kwargs
403-
func_kwargs: dict[str, Any] = {}
404-
for key, value in vars(ns).items():
405-
if key.startswith('cmd2_') or key == constants.NS_ATTR_SUBCMD_HANDLER:
406-
continue
407-
func_kwargs[key] = value
484+
# Extract user-defined kwargs from a parsed Namespace, excluding cmd2 internals.
485+
func_kwargs = _filtered_namespace_kwargs(ns, exclude_subcommand=True)
486+
487+
# Inject cmd2-specific kwargs that commands can opt into
488+
func_kwargs['cmd2_statement'] = Cmd2AttributeWrapper(statement)
489+
raw_handler = getattr(ns, constants.NS_ATTR_SUBCMD_HANDLER, None)
490+
bound_handler = (lambda _h=raw_handler, _ns=ns: _h(_ns)) if raw_handler is not None else None
491+
func_kwargs['cmd2_handler'] = Cmd2AttributeWrapper(bound_handler)
408492

409493
if with_unknown_args:
410494
func_kwargs['_unknown'] = unknown
411495

412-
result: bool | None = fn(owner, **func_kwargs)
496+
filtered_kwargs = {k: v for k, v in func_kwargs.items() if k in _accepted_params}
497+
498+
# Direct wrapper kwargs (e.g. do_greet("x", keyword_arg="y")) override parsed values
499+
for key in kwargs:
500+
filtered_kwargs.pop(key, None)
501+
502+
result: bool | None = fn(owner, **filtered_kwargs, **kwargs)
413503
return result
414504

415-
# Store a parser-builder callable — _CommandParsers._build_parser()
416-
# already handles callables by calling them with no arguments.
417-
setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, lambda: build_parser_from_function(fn))
505+
# Store a parser-builder callable
506+
def _build_cmd_parser() -> argparse.ArgumentParser:
507+
parser = build_parser_from_function(
508+
fn,
509+
include_unknown=with_unknown_args,
510+
groups=groups,
511+
mutually_exclusive_groups=mutually_exclusive_groups,
512+
)
513+
if base_command:
514+
parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True)
515+
return parser
516+
517+
setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, _build_cmd_parser)
418518
setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes)
419519

420520
return cmd_wrapper

cmd2/rich_utils.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ def __init__(
160160
"Passing 'theme' is not allowed. Its behavior is controlled by the global APP_THEME and set_theme()."
161161
)
162162

163+
# Store the configuration used to create this console for caching purposes.
164+
self._config_key = self._generate_config_key(file=file, **kwargs)
165+
163166
force_terminal: bool | None = None
164167
force_interactive: bool | None = None
165168

@@ -180,6 +183,41 @@ def __init__(
180183
**kwargs,
181184
)
182185

186+
@staticmethod
187+
def _generate_config_key(
188+
*,
189+
file: IO[str] | None,
190+
**kwargs: Any,
191+
) -> tuple[Any, ...]:
192+
"""Generate a key representing the settings used to initialize a console.
193+
194+
This key includes the file identity, global settings (ALLOW_STYLE, APP_THEME),
195+
and any other settings passed in via kwargs.
196+
197+
:param file: file stream being checked
198+
:param kwargs: other console settings
199+
"""
200+
return (
201+
id(file),
202+
ALLOW_STYLE,
203+
id(APP_THEME),
204+
tuple(sorted(kwargs.items())),
205+
)
206+
207+
def matches_config(
208+
self,
209+
*,
210+
file: IO[str] | None,
211+
**kwargs: Any,
212+
) -> bool:
213+
"""Check if this console instance was initialized with the specified settings.
214+
215+
:param file: file stream being checked
216+
:param kwargs: other console settings being checked
217+
:return: True if the settings match this console's configuration
218+
"""
219+
return self._config_key == self._generate_config_key(file=file, **kwargs)
220+
183221
def on_broken_pipe(self) -> None:
184222
"""Override which raises BrokenPipeError instead of SystemExit."""
185223
self.quiet = True

docs/api/annotated.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# cmd2.annotated
2+
3+
::: cmd2.annotated

docs/api/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ incremented according to the [Semantic Version Specification](https://semver.org
1313

1414
- [cmd2.Cmd](./cmd.md) - functions and attributes of the main class in this library
1515
- [cmd2.argparse_completer](./argparse_completer.md) - classes for `argparse`-based tab completion
16+
- [cmd2.annotated](./annotated.md) - experimental helpers for building parsers from type annotations
1617
- [cmd2.argparse_custom](./argparse_custom.md) - classes and functions for extending `argparse`
1718
- [cmd2.clipboard](./clipboard.md) - functions to copy from and paste to the clipboard/pastebuffer
1819
- [cmd2.colors](./colors.md) - StrEnum of all color names supported by the Rich library

0 commit comments

Comments
 (0)