Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ shell, and the option for a persistent bottom bar that can display realtime stat
- **max_column_completion_results**: (int) the maximum number of completion results to
display in a single column

## 3.2.0 (February 5, 2026)

- Bug Fixes
- Fixed incompatibilities with Python 3.14.3.

- Potentially Breaking Changes
- To avoid future incompatibilities with argparse, we removed most of our overridden help
functions. This should not break an application, but it could affect unit tests which parse
help text.

## 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