Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
266 changes: 27 additions & 239 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
# Recast to assist type checkers
self._console: Cmd2RichArgparseConsole | None

def _metavar_formatter(
self,
action: argparse.Action,
default_metavar: str,
) -> Callable[[int], tuple[str, ...]]:
metavar = self._determine_metavar(action, default_metavar)

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,15 +1331,15 @@ 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]

def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: # type: ignore[type-arg]
"""Add a subcommand parser.

Set a default title if one was not given.f
Set a default title if one was not given.

:param kwargs: additional keyword arguments
:return: argparse Subparser Action
Expand All @@ -1509,10 +1350,7 @@ def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: # type:
return super().add_subparsers(**kwargs)

def error(self, message: str) -> NoReturn:
"""Print a usage message, including the message, to sys.stderr and terminates the program with a status code of 2.

Custom override that applies custom formatting to the error message.
"""
"""Override that applies custom formatting to the error message."""
lines = message.split('\n')
formatted_message = ''
for linum, line in enumerate(lines):
Expand All @@ -1532,62 +1370,12 @@ def error(self, message: str) -> NoReturn:
self.exit(2, f'{formatted_message}\n')

def _get_formatter(self) -> Cmd2HelpFormatter:
"""Override _get_formatter with customizations for Cmd2HelpFormatter."""
"""Override with customizations for Cmd2HelpFormatter."""
return cast(Cmd2HelpFormatter, super()._get_formatter())

def format_help(self) -> str:
"""Return a string containing a help message, including the program usage and information about the arguments.

Copy of format_help() from argparse.ArgumentParser with tweaks to separately display required parameters.
"""
formatter = self._get_formatter()

# usage
formatter.add_usage(self.usage, self._actions, self._mutually_exclusive_groups)

# description
formatter.add_text(self.description)

# Begin cmd2 customization (separate required and optional arguments)

# positionals, optionals and user-defined groups
for action_group in self._action_groups:
default_options_group = action_group.title == 'options'

if default_options_group:
# check if the arguments are required, group accordingly
req_args = []
opt_args = []
for action in action_group._group_actions:
if action.required:
req_args.append(action)
else:
opt_args.append(action)

# separately display required arguments
formatter.start_section('required arguments')
formatter.add_text(action_group.description)
formatter.add_arguments(req_args)
formatter.end_section()

# now display truly optional arguments
formatter.start_section('optional arguments')
formatter.add_text(action_group.description)
formatter.add_arguments(opt_args)
formatter.end_section()
else:
formatter.start_section(action_group.title)
formatter.add_text(action_group.description)
formatter.add_arguments(action_group._group_actions)
formatter.end_section()

# End cmd2 customization

# epilog
formatter.add_text(self.epilog)

# determine help from format above
return formatter.format_help() + '\n'
"""Override to add a newline."""
return super().format_help() + '\n'

def create_text_group(self, title: str, text: RenderableType) -> TextGroup:
"""Create a TextGroup using this parser's formatter creator."""
Expand Down
Loading
Loading