Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 3.1.4 (TBD)
Comment thread
tleonhardt marked this conversation as resolved.
Outdated

- Bug Fixes
- Fixed incompatibilities with Python 3.14.3.

## 3.1.3 (February 3, 2026)

- Bug Fixes
Expand Down
203 changes: 22 additions & 181 deletions cmd2/argparse_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,11 +261,9 @@ def get_items(self) -> list[CompletionItems]:
from argparse import ArgumentError
from collections.abc import (
Callable,
Iterable,
Iterator,
Sequence,
)
from gettext import gettext
from typing import (
TYPE_CHECKING,
Any,
Expand Down Expand Up @@ -1126,177 +1124,22 @@ def __init__(
**kwargs: Any,
) -> None:
"""Initialize Cmd2HelpFormatter."""
if console is None:
console = Cmd2RichArgparseConsole()

super().__init__(prog, indent_increment, max_help_position, width, console=console, **kwargs)

def _format_usage(
self,
usage: str | None,
actions: Iterable[argparse.Action],
groups: Iterable[argparse._ArgumentGroup],
prefix: str | None = None,
) -> str:
if prefix is None:
prefix = gettext('Usage: ')

# if usage is specified, use that
if usage is not None:
usage %= {"prog": self._prog}

# if no optionals or positionals are available, usage is just prog
elif not actions:
usage = f'{self._prog}'

# if optionals and positionals are available, calculate usage
else:
prog = f'{self._prog}'

# split optionals from positionals
optionals = []
positionals = []
# Begin cmd2 customization (separates required and optional, applies to all changes in this function)
required_options = []
for action in actions:
if action.option_strings:
if action.required:
required_options.append(action)
else:
optionals.append(action)
else:
positionals.append(action)
# End cmd2 customization

# build full usage string
format_actions = self._format_actions_usage
action_usage = format_actions(required_options + optionals + positionals, groups) # type: ignore[arg-type]
usage = ' '.join([s for s in [prog, action_usage] if s])

# wrap the usage parts if it's too long
text_width = self._width - self._current_indent
if len(prefix) + len(usage) > text_width:
# Begin cmd2 customization

# break usage into wrappable parts
part_regexp = r'\(.*?\)+|\[.*?\]+|\S+'
req_usage = format_actions(required_options, groups) # type: ignore[arg-type]
opt_usage = format_actions(optionals, groups) # type: ignore[arg-type]
pos_usage = format_actions(positionals, groups) # type: ignore[arg-type]
req_parts = re.findall(part_regexp, req_usage)
opt_parts = re.findall(part_regexp, opt_usage)
pos_parts = re.findall(part_regexp, pos_usage)

# End cmd2 customization

# helper for wrapping lines
def get_lines(parts: list[str], indent: str, prefix: str | None = None) -> list[str]:
lines: list[str] = []
line: list[str] = []
line_len = len(prefix) - 1 if prefix is not None else len(indent) - 1
for part in parts:
if line_len + 1 + len(part) > text_width and line:
lines.append(indent + ' '.join(line))
line = []
line_len = len(indent) - 1
line.append(part)
line_len += len(part) + 1
if line:
lines.append(indent + ' '.join(line))
if prefix is not None:
lines[0] = lines[0][len(indent) :]
return lines

# if prog is short, follow it with optionals or positionals
if len(prefix) + len(prog) <= 0.75 * text_width:
indent = ' ' * (len(prefix) + len(prog) + 1)
# Begin cmd2 customization
if req_parts:
lines = get_lines([prog, *req_parts], indent, prefix)
lines.extend(get_lines(opt_parts, indent))
lines.extend(get_lines(pos_parts, indent))
elif opt_parts:
lines = get_lines([prog, *opt_parts], indent, prefix)
lines.extend(get_lines(pos_parts, indent))
elif pos_parts:
lines = get_lines([prog, *pos_parts], indent, prefix)
else:
lines = [prog]
# End cmd2 customization

# if prog is long, put it on its own line
else:
indent = ' ' * len(prefix)
# Begin cmd2 customization
parts = req_parts + opt_parts + pos_parts
lines = get_lines(parts, indent)
if len(lines) > 1:
lines = []
lines.extend(get_lines(req_parts, indent))
lines.extend(get_lines(opt_parts, indent))
lines.extend(get_lines(pos_parts, indent))
# End cmd2 customization
lines = [prog, *lines]

# join lines into usage
usage = '\n'.join(lines)

# prefix with 'Usage:'
return f'{prefix}{usage}\n\n'

def _format_action_invocation(self, action: argparse.Action) -> str:
if not action.option_strings:
default = self._get_default_metavar_for_positional(action)
(metavar,) = self._metavar_formatter(action, default)(1)
return metavar

parts: list[str] = []

# if the Optional doesn't take a value, format is:
# -s, --long
if action.nargs == 0:
parts.extend(action.option_strings)
return ', '.join(parts)

# Begin cmd2 customization (less verbose)
# if the Optional takes a value, format is:
# -s, --long ARGS
default = self._get_default_metavar_for_optional(action)
args_string = self._format_args(action, default)

return ', '.join(action.option_strings) + ' ' + args_string
# End cmd2 customization

def _determine_metavar(
self,
action: argparse.Action,
default_metavar: str,
) -> str | tuple[str, ...]:
"""Determine what to use as the metavar value of an action."""
if action.metavar is not None:
result = action.metavar
elif action.choices is not None:
choice_strs = [str(choice) for choice in action.choices]
# Begin cmd2 customization (added space after comma)
result = f'{", ".join(choice_strs)}'
# End cmd2 customization
else:
result = default_metavar
return result

def _metavar_formatter(
self,
action: argparse.Action,
default_metavar: str,
) -> Callable[[int], tuple[str, ...]]:
metavar = self._determine_metavar(action, default_metavar)
# Recast to assist type checkers
self._console: Cmd2RichArgparseConsole | None

def format_tuple(tuple_size: int) -> tuple[str, ...]:
if isinstance(metavar, tuple):
return metavar
return (metavar,) * tuple_size
@property # type: ignore[override]
def console(self) -> Cmd2RichArgparseConsole:
"""Return our console instance."""
if self._console is None:
self._console = Cmd2RichArgparseConsole()
return self._console

return format_tuple
@console.setter
def console(self, console: Cmd2RichArgparseConsole) -> None:
"""Set our console instance."""
self._console = console

def _build_nargs_range_str(self, nargs_range: tuple[int, int | float]) -> str:
"""Generate nargs range string for help text."""
Expand All @@ -1314,13 +1157,12 @@ def _format_args(self, action: argparse.Action, default_metavar: str) -> str:

All formats in this function need to be handled by _rich_metavar_parts().
"""
metavar = self._determine_metavar(action, default_metavar)
metavar_formatter = self._metavar_formatter(action, default_metavar)
get_metavar = self._metavar_formatter(action, default_metavar)

# Handle nargs specified as a range
nargs_range = action.get_nargs_range() # type: ignore[attr-defined]
if nargs_range is not None:
arg_str = '%s' % metavar_formatter(1) # noqa: UP031
arg_str = '%s' % get_metavar(1) # noqa: UP031
range_str = self._build_nargs_range_str(nargs_range)
return f"{arg_str}{range_str}"

Expand All @@ -1329,8 +1171,8 @@ def _format_args(self, action: argparse.Action, default_metavar: str) -> str:
# To make this less verbose, format it like: 'command arg{5}'.
# Do not customize the output when metavar is a tuple of strings. Allow argparse's
# formatter to handle that instead.
if isinstance(metavar, str) and isinstance(action.nargs, int) and action.nargs > 1:
arg_str = '%s' % metavar_formatter(1) # noqa: UP031
if not isinstance(action.metavar, tuple) and isinstance(action.nargs, int) and action.nargs > 1:
arg_str = '%s' % get_metavar(1) # noqa: UP031
return f"{arg_str}{{{action.nargs}}}"

# Fallback to parent for all other cases
Expand All @@ -1342,19 +1184,18 @@ def _rich_metavar_parts(
default_metavar: str,
) -> Iterator[tuple[str, bool]]:
"""Override to handle all cmd2-specific formatting in _format_args()."""
metavar = self._determine_metavar(action, default_metavar)
metavar_formatter = self._metavar_formatter(action, default_metavar)
get_metavar = self._metavar_formatter(action, default_metavar)

# Handle nargs specified as a range
nargs_range = action.get_nargs_range() # type: ignore[attr-defined]
if nargs_range is not None:
yield "%s" % metavar_formatter(1), True # noqa: UP031
yield "%s" % get_metavar(1), True # noqa: UP031
yield self._build_nargs_range_str(nargs_range), False
return

# Handle specific integer nargs (e.g., nargs=5 -> arg{5})
if isinstance(metavar, str) and isinstance(action.nargs, int) and action.nargs > 1:
yield "%s" % metavar_formatter(1), True # noqa: UP031
if not isinstance(action.metavar, tuple) and isinstance(action.nargs, int) and action.nargs > 1:
yield "%s" % get_metavar(1), True # noqa: UP031
yield f"{{{action.nargs}}}", False
return

Expand Down Expand Up @@ -1490,8 +1331,8 @@ def __init__(
)

# Recast to assist type checkers since these can be Rich renderables in a Cmd2HelpFormatter.
self.description: RenderableType | None = self.description # type: ignore[assignment]
self.epilog: RenderableType | None = self.epilog # type: ignore[assignment]
self.description: RenderableType | None # type: ignore[assignment]
self.epilog: RenderableType | None # type: ignore[assignment]

self.set_ap_completer_type(ap_completer_type) # type: ignore[attr-defined]

Expand Down
45 changes: 26 additions & 19 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -950,11 +950,12 @@ def unregister_command_set(self, cmdset: CommandSet) -> None:

def _check_uninstallable(self, cmdset: CommandSet) -> None:
def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None:
cmdset_id = id(cmdset)
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
for subparser in action.choices.values():
attached_cmdset = getattr(subparser, constants.PARSER_ATTR_COMMANDSET, None)
if attached_cmdset is not None and attached_cmdset is not cmdset:
attached_cmdset_id = getattr(subparser, constants.PARSER_ATTR_COMMANDSET_ID, None)
if attached_cmdset_id is not None and attached_cmdset_id != cmdset_id:
raise CommandSetRegistrationError(
'Cannot uninstall CommandSet when another CommandSet depends on it'
)
Expand Down Expand Up @@ -1049,7 +1050,7 @@ def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) ->
subcmd_parser.set_defaults(**defaults)

# Set what instance the handler is bound to
setattr(subcmd_parser, constants.PARSER_ATTR_COMMANDSET, cmdset)
setattr(subcmd_parser, constants.PARSER_ATTR_COMMANDSET_ID, id(cmdset))

# Find the argparse action that handles subcommands
for action in target_parser._actions:
Expand Down Expand Up @@ -4059,25 +4060,31 @@ def _macro_delete(self, args: argparse.Namespace) -> None:
self.perror(f"Macro '{cur_name}' does not exist")

# macro -> list
macro_list_help = "list macros"
macro_list_description = Text.assemble(
"List specified macros in a reusable form that can be saved to a startup script to preserve macros across sessions.",
"\n\n",
"Without arguments, all macros will be listed.",
)
@classmethod
def _build_macro_list_parser(cls) -> Cmd2ArgumentParser:
macro_list_description = Text.assemble(
(
"List specified macros in a reusable form that can be saved to a startup script "
"to preserve macros across sessions."
),
"\n\n",
"Without arguments, all macros will be listed.",
)

macro_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_list_description)
macro_list_parser.add_argument(
'names',
nargs=argparse.ZERO_OR_MORE,
help='macro(s) to list',
choices_provider=_get_macro_completion_items,
descriptive_headers=["Value"],
)
macro_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_list_description)
macro_list_parser.add_argument(
'names',
nargs=argparse.ZERO_OR_MORE,
help='macro(s) to list',
choices_provider=cls._get_macro_completion_items,
descriptive_headers=["Value"],
)

return macro_list_parser

@as_subcommand_to('macro', 'list', macro_list_parser, help=macro_list_help)
@as_subcommand_to('macro', 'list', _build_macro_list_parser, help="list macros")
def _macro_list(self, args: argparse.Namespace) -> None:
"""List some or all macros as 'macro create' commands."""
"""List macros."""
self.last_result = {} # dict[macro_name, macro_value]

tokens_to_quote = constants.REDIRECTION_TOKENS
Expand Down
4 changes: 2 additions & 2 deletions cmd2/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@
SUBCMD_ATTR_NAME = 'subcommand_name'
SUBCMD_ATTR_ADD_PARSER_KWARGS = 'subcommand_add_parser_kwargs'

# arpparse attribute linking to command set instance
PARSER_ATTR_COMMANDSET = 'command_set'
# arpparse attribute uniquely identifying the command set instance
PARSER_ATTR_COMMANDSET_ID = 'command_set_id'

# custom attributes added to argparse Namespaces
NS_ATTR_SUBCMD_HANDLER = '__subcmd_handler__'
8 changes: 4 additions & 4 deletions tests/transcripts/from_cmdloop.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ Usage: speak [-h] [-p] [-s] [-r REPEAT]/ */
Repeats what you tell me to./ */

Optional Arguments:/ */
-h, --help show this help message and exit/ */
-p, --piglatin atinLay/ */
-s, --shout N00B EMULATION MODE/ */
-r, --repeat REPEAT output [n] times/ */
-h, --help/ */show this help message and exit/ */
-p, --piglatin/ */atinLay/ */
-s, --shout/ */N00B EMULATION MODE/ */
-r, --repeat REPEAT/ */output [n] times/ */

(Cmd) say goodnight, Gracie
goodnight, Gracie
Expand Down
Loading