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
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ prompt is displayed.
- `cmd2` no longer sets a default title for a subparsers group. If you desire a title, you will
need to pass one in like this `parser.add_subparsers(title="subcommands")`. This is standard
`argparse` behavior.
- `TextGroup` is now a standalone Rich renderable.
- Added `HelpFormatterRenderable` protocol and `HelpContent` type alias to support context-aware
help content in `argparse`.
- `TextGroup` now implements `HelpFormatterRenderable`.
- Removed `formatter_creator` parameter from `TextGroup.__init__()`.
- Removed `Cmd2ArgumentParser.create_text_group()` method.
- `argparse` and `Rich` integration refactoring:
Expand All @@ -101,6 +103,12 @@ prompt is displayed.
greater flexibility in passing keyword arguments to `console.print()` calls.
- Removed `always_show_hint` settable as it provided a poor user experience with
`prompt-toolkit`
- `cmd2` redirection only captures output directed to `self.stdout` (e.g., via
`self.poutput()`). Standard `print()` calls write directly to `sys.stdout` and are not
captured. However, `print()` calls within `pyscripts` and the interactive Python shell are
treated as command output and sent to `self.stdout`, allowing them to be captured.
- Verbose help table descriptions are no longer generated from help function output. The system
now relies exclusively on command function docstrings.
- Enhancements
- New `cmd2.Cmd` parameters
- **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These
Expand Down Expand Up @@ -137,6 +145,11 @@ prompt is displayed.
full type hints and IDE autocompletion for `self._cmd` without needing to override and cast
the property.
- Added `traceback_kwargs` attribute to allow customization of Rich-based tracebacks.
- The `print()` function available in a `pyscript` writes to `self.stdout` and respects the
`allow_style` setting. It also supports printing `Rich` objects.
- Added `Cmd2ArgumentParser.output_to()` context manager to temporarily set the output stream
during `argparse` operations. This is helpful for directing output for functions like
`parse_args()`, which default to `sys.stdout` and lack a `file` argument.

## 3.5.1 (April 24, 2026)

Expand Down
4 changes: 2 additions & 2 deletions cmd2/argparse_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,9 +692,9 @@ def print_help(self, tokens: Sequence[str], file: IO[str] | None = None) -> None
if parser is not None:
completer_type = self._cmd2_app._determine_ap_completer_type(parser)
completer = completer_type(parser, self._cmd2_app)
completer.print_help(tokens[1:], file=file)
completer.print_help(tokens[1:], file)
return
self._parser.print_help(file=file)
self._parser.print_help(file)

def _choices_to_items(self, arg_state: _ArgumentState) -> list[CompletionItem]:
"""Convert choices from action to list of CompletionItems."""
Expand Down
85 changes: 71 additions & 14 deletions cmd2/argparse_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,27 +225,35 @@ def get_choices(self) -> Choices:
"""

import argparse
import contextlib
import re
import sys
import threading
from argparse import ArgumentError
from collections.abc import (
Callable,
Iterable,
Iterator,
Sequence,
)
from dataclasses import dataclass
from typing import (
IO,
TYPE_CHECKING,
Any,
ClassVar,
NoReturn,
cast,
)

from rich.console import RenderableType
from rich.table import Column

from . import constants
from .completion import CompletionItem
from .rich_utils import Cmd2HelpFormatter
from .rich_utils import (
Cmd2HelpFormatter,
HelpContent,
)
from .styles import Cmd2Style
from .types import (
CmdOrSetT,
Expand Down Expand Up @@ -541,15 +549,46 @@ def _SubParsersAction_remove_parser( # noqa: N802
argparse._SubParsersAction.remove_parser = _SubParsersAction_remove_parser # type: ignore[attr-defined]


@dataclass
class _ParserThreadLocals(threading.local):
"""Thread-local storage used by Cmd2ArgumentParser to manage execution context."""

# The active output stream for help, usage, and errors. Since argparse does not
# pass the destination stream to the formatter factory, this transient value
# provides the context needed to synchronize Rich's rendering with the specific
# capabilities of the destination file descriptor. It is managed via the
# output_to() context manager.
current_output_file: IO[str] | None = None


class Cmd2ArgumentParser(argparse.ArgumentParser):
"""Custom ArgumentParser class that improves error and help output."""

# Thread-local storage shared by all parser instances (including subparsers)
_thread_locals: ClassVar[_ParserThreadLocals] = _ParserThreadLocals()

@contextlib.contextmanager
def output_to(self, file: IO[str] | None) -> Iterator[None]:
"""Context manager to temporarily set the output stream during argparse operations.

This is helpful for directing output for functions like `parse_args()`, which
default to `sys.stdout` and lack a `file` argument.

:param file: the file stream to use for output
"""
previous = self._thread_locals.current_output_file
self._thread_locals.current_output_file = file
try:
yield
finally:
self._thread_locals.current_output_file = previous

def __init__(
self,
prog: str | None = None,
usage: str | None = None,
description: RenderableType | None = None,
epilog: RenderableType | None = None,
description: HelpContent | None = None,
epilog: HelpContent | None = None,
parents: Sequence[argparse.ArgumentParser] = (),
formatter_class: type[Cmd2HelpFormatter] = Cmd2HelpFormatter,
prefix_chars: str = "-",
Expand Down Expand Up @@ -599,8 +638,24 @@ def __init__(

# To assist type checkers, recast these to reflect our usage of rich-argparse.
self.formatter_class: type[Cmd2HelpFormatter]
self.description: RenderableType | None # type: ignore[assignment]
self.epilog: RenderableType | None # type: ignore[assignment]
self.description: HelpContent | None # type: ignore[assignment]
self.epilog: HelpContent | None # type: ignore[assignment]

def print_usage(self, file: IO[str] | None = None) -> None: # type:ignore[override]
"""Override to ensure the formatter is aware of the target file."""
if file is None:
file = self._thread_locals.current_output_file

with self.output_to(file):
super().print_usage(file)

def print_help(self, file: IO[str] | None = None) -> None: # type:ignore[override]
"""Override to ensure the formatter is aware of the target file."""
if file is None:
file = self._thread_locals.current_output_file

with self.output_to(file):
super().print_help(file)

def get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentParser]":
"""Get the _SubParsersAction for this parser if it exists.
Expand Down Expand Up @@ -804,19 +859,21 @@ def error(self, message: str) -> NoReturn:
else:
formatted_message += "\n " + line

self.print_usage(sys.stderr)
with self.output_to(sys.stderr):
self.print_usage(sys.stderr)

# Use console to add style since it will respect ALLOW_STYLE's value
console = self._get_formatter().console
with console.capture() as capture:
console.print(formatted_message, style=Cmd2Style.ERROR)
formatted_message = f"{capture.get()}"
# Use console to add style since it will respect ALLOW_STYLE's value.
# Now _get_formatter() will return a formatter bound to stderr.
console = self._get_formatter().console
with console.capture() as capture:
console.print(formatted_message, style=Cmd2Style.ERROR)
formatted_message = f"{capture.get()}"

self.exit(2, f"{formatted_message}\n")

def _get_formatter(self, **kwargs: Any) -> Cmd2HelpFormatter:
def _get_formatter(self, **_kwargs: Any) -> Cmd2HelpFormatter:
"""Override with customizations for Cmd2HelpFormatter."""
return cast(Cmd2HelpFormatter, super()._get_formatter(**kwargs))
return self.formatter_class(prog=self.prog, file=self._thread_locals.current_output_file)

def format_help(self) -> str:
"""Override to add a newline."""
Expand Down
101 changes: 45 additions & 56 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
- Redirection to file or paste buffer (clipboard) with > or >>
- Bash-style ``select`` available

Note, if self.stdout is different than sys.stdout, then redirection with > and |
will only work if `self.poutput()` is used in place of `print`.
Note: cmd2 redirection only captures output directed to self.stdout (e.g., via self.poutput()).
Standard print() calls write directly to sys.stdout and are not captured. However, print() calls
within pyscripts and the interactive Python shell are treated as command output and sent to
self.stdout, allowing them to be captured.
Comment thread
tleonhardt marked this conversation as resolved.

GitHub: https://github.com/python-cmd2/cmd2
Documentation: https://cmd2.readthedocs.io/
Expand Down Expand Up @@ -318,12 +320,12 @@ class AsyncAlert:
timestamp: float = field(default_factory=time.monotonic, init=False)


@dataclass
class _ConsoleCache(threading.local):
"""Thread-local storage for cached Rich consoles used by core print methods."""

def __init__(self) -> None:
self.stdout: Cmd2BaseConsole | None = None
self.stderr: Cmd2BaseConsole | None = None
stdout: Cmd2BaseConsole | None = None
stderr: Cmd2BaseConsole | None = None


class Cmd:
Expand Down Expand Up @@ -3191,13 +3193,8 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
"""
import subprocess

# Only redirect sys.stdout if it's the same as self.stdout
stdouts_match = self.stdout == sys.stdout

# Initialize the redirection saved state
redir_saved_state = utils.RedirectionSavedState(
self.stdout, stdouts_match, self._cur_pipe_proc_reader, self._redirecting
)
redir_saved_state = utils.RedirectionSavedState(self.stdout, self._cur_pipe_proc_reader, self._redirecting)

# The ProcReader for this command
cmd_pipe_proc_reader: utils.ProcReader | None = None
Expand Down Expand Up @@ -3254,8 +3251,6 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
cmd_pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)

self.stdout = new_stdout
if stdouts_match:
sys.stdout = self.stdout

elif statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND):
if statement.redirect_to:
Expand All @@ -3271,8 +3266,6 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
redir_saved_state.redirecting = True

self.stdout = new_stdout
if stdouts_match:
sys.stdout = self.stdout

else:
# Redirecting to a paste buffer
Expand All @@ -3292,8 +3285,6 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
redir_saved_state.redirecting = True

self.stdout = new_stdout
if stdouts_match:
sys.stdout = self.stdout

if statement.redirector == constants.REDIRECTION_APPEND:
self.stdout.write(current_paste_buffer)
Expand Down Expand Up @@ -3324,10 +3315,8 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec
# Close the file or pipe that stdout was redirected to
self.stdout.close()

# Restore the stdout values
# Restore self.stdout
self.stdout = cast(TextIO, saved_redir_state.saved_self_stdout)
if saved_redir_state.stdouts_match:
sys.stdout = self.stdout

# Check if we need to wait for the process being piped to
if self._cur_pipe_proc_reader is not None:
Expand Down Expand Up @@ -4395,8 +4384,6 @@ def print_topics(self, header: str, cmds: Sequence[str] | None, cmdlen: int, max

def _print_documented_command_topics(self, header: str, commands: Sequence[str], verbose: bool) -> None:
"""Print topics which are documented commands, switching between verbose or traditional output."""
import io

if not commands:
return

Expand All @@ -4410,34 +4397,11 @@ def _print_documented_command_topics(self, header: str, commands: Sequence[str],
)

# Try to get the documentation string for each command
topics = self.get_help_topics()
for command in commands:
if (command_func := self.get_command_func(command)) is None:
continue

doc: str | None

# Non-argparse commands can have help_functions for their documentation
if command in topics:
help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
result = io.StringIO()

# try to redirect system stdout
with contextlib.redirect_stdout(result):
# save our internal stdout
stdout_orig = self.stdout
try:
# redirect our internal stdout
self.stdout = cast(TextIO, result)
help_func()
finally:
with self.sigint_protection:
# restore internal stdout
self.stdout = stdout_orig
doc = result.getvalue()

else:
doc = command_func.__doc__
doc = command_func.__doc__

# Attempt to locate the first documentation block
cmd_desc = strip_doc_annotations(doc) if doc else ""
Expand Down Expand Up @@ -4903,8 +4867,38 @@ def _run_python(self, *, pyscript: str | None = None) -> bool | None:
"""
self.last_result = False

# Replace print() in the embedded Python environment. Standard print() writes to
# sys.stdout, which bypasses cmd2 redirection (e.g., run_pyscript script.py > out.txt).
# Using self.print_to(self.stdout) ensures output is capturable and respects 'allow_style'
# without requiring the user to have access to 'self'.
def py_print(
*objects: Any,
sep: str = " ",
end: str = "\n",
file: IO[str] | None = None,
flush: bool = False, # noqa: ARG001
) -> None:
"""Print objects to a stream, defaulting to self.stdout.

This is used as the print() function within interactive Python shells and pyscripts.
It wraps cmd2's print_to() method to honor output redirection and style settings.

:param objects: objects to print (including Rich objects)
:param sep: string to write between printed text. Defaults to " ".
:param end: string to write at end of printed text. Defaults to a newline.
:param file: file stream being written to. Defaults to self.stdout.
:param flush: ignored as Rich-based output is flushed automatically. Defaults to False.
"""
if file is None:
file = self.stdout

self.print_to(file, *objects, sep=sep, end=end)

# Replace quit/exit in the embedded Python environment. Standard sys.exit()
# would kill the entire application process; raising EmbeddedConsoleExit
# allows the interpreter to return gracefully to the cmd2 prompt.
def py_quit() -> None:
"""Exit an interactive Python environment, callable from the interactive Python console."""
"""Exit an interactive Python shell or pyscript."""
raise EmbeddedConsoleExit

from .py_bridge import PyBridge
Expand All @@ -4927,6 +4921,7 @@ def py_quit() -> None:
# it's OK for py_locals to contain objects which are editable in a pyscript.
local_vars = self.py_locals.copy()
local_vars[self.py_bridge_name] = py_bridge
local_vars["print"] = py_print
local_vars["quit"] = py_quit
local_vars["exit"] = py_quit

Expand Down Expand Up @@ -5086,19 +5081,13 @@ def do_ipy(self, _: argparse.Namespace) -> bool | None: # pragma: no cover
except NameError:
from IPython import start_ipython

from IPython.terminal.interactiveshell import (
TerminalInteractiveShell,
)
from IPython.terminal.ipapp import (
TerminalIPythonApp,
)
from IPython.terminal.interactiveshell import TerminalInteractiveShell
from IPython.terminal.ipapp import TerminalIPythonApp
except ImportError:
self.perror("IPython package is not installed")
return None

from .py_bridge import (
PyBridge,
)
from .py_bridge import PyBridge

if self.in_pyscript():
self.perror("Recursively entering interactive Python shells is not allowed")
Expand Down
Loading
Loading