Skip to content

Commit cf47df5

Browse files
committed
Merge branch 'main' into 1627-silence-settable
2 parents 70480ab + 79c984a commit cf47df5

17 files changed

Lines changed: 499 additions & 293 deletions

CHANGELOG.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ prompt is displayed.
8181
- `cmd2` no longer sets a default title for a subparsers group. If you desire a title, you will
8282
need to pass one in like this `parser.add_subparsers(title="subcommands")`. This is standard
8383
`argparse` behavior.
84-
- `TextGroup` is now a standalone Rich renderable.
84+
- Added `HelpFormatterRenderable` protocol and `HelpContent` type alias to support context-aware
85+
help content in `argparse`.
86+
- `TextGroup` now implements `HelpFormatterRenderable`.
8587
- Removed `formatter_creator` parameter from `TextGroup.__init__()`.
8688
- Removed `Cmd2ArgumentParser.create_text_group()` method.
8789
- `argparse` and `Rich` integration refactoring:
@@ -101,6 +103,12 @@ prompt is displayed.
101103
greater flexibility in passing keyword arguments to `console.print()` calls.
102104
- Removed `always_show_hint` settable as it provided a poor user experience with
103105
`prompt-toolkit`
106+
- `cmd2` redirection only captures output directed to `self.stdout` (e.g., via
107+
`self.poutput()`). Standard `print()` calls write directly to `sys.stdout` and are not
108+
captured. However, `print()` calls within `pyscripts` and the interactive Python shell are
109+
treated as command output and sent to `self.stdout`, allowing them to be captured.
110+
- Verbose help table descriptions are no longer generated from help function output. The system
111+
now relies exclusively on command function docstrings.
104112
- Enhancements
105113
- New `cmd2.Cmd` parameters
106114
- **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These
@@ -136,7 +144,12 @@ prompt is displayed.
136144
specific `cmd2.Cmd` subclass (e.g.,`class MyCommandSet(CommandSet[MyApp]):`). This provides
137145
full type hints and IDE autocompletion for `self._cmd` without needing to override and cast
138146
the property.
139-
- Added `traceback_kwargs` attribute to allow customization of Rich-based tracebacks
147+
- Added `traceback_kwargs` attribute to allow customization of Rich-based tracebacks.
148+
- The `print()` function available in a `pyscript` writes to `self.stdout` and respects the
149+
`allow_style` setting. It also supports printing `Rich` objects.
150+
- Added `Cmd2ArgumentParser.output_to()` context manager to temporarily set the output stream
151+
during `argparse` operations. This is helpful for directing output for functions like
152+
`parse_args()`, which default to `sys.stdout` and lack a `file` argument.
140153
- Updated `set` command to consolidate its confirmation output into a single, colorized line.
141154
The confirmation now uses `pfeedback()`, allowing it to be silenced when the `quiet` settable
142155
is enabled.

cmd2/argparse_completer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -692,9 +692,9 @@ def print_help(self, tokens: Sequence[str], file: IO[str] | None = None) -> None
692692
if parser is not None:
693693
completer_type = self._cmd2_app._determine_ap_completer_type(parser)
694694
completer = completer_type(parser, self._cmd2_app)
695-
completer.print_help(tokens[1:], file=file)
695+
completer.print_help(tokens[1:], file)
696696
return
697-
self._parser.print_help(file=file)
697+
self._parser.print_help(file)
698698

699699
def _choices_to_items(self, arg_state: _ArgumentState) -> list[CompletionItem]:
700700
"""Convert choices from action to list of CompletionItems."""

cmd2/argparse_utils.py

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -225,27 +225,35 @@ def get_choices(self) -> Choices:
225225
"""
226226

227227
import argparse
228+
import contextlib
228229
import re
229230
import sys
231+
import threading
230232
from argparse import ArgumentError
231233
from collections.abc import (
232234
Callable,
233235
Iterable,
236+
Iterator,
234237
Sequence,
235238
)
239+
from dataclasses import dataclass
236240
from typing import (
241+
IO,
237242
TYPE_CHECKING,
238243
Any,
244+
ClassVar,
239245
NoReturn,
240246
cast,
241247
)
242248

243-
from rich.console import RenderableType
244249
from rich.table import Column
245250

246251
from . import constants
247252
from .completion import CompletionItem
248-
from .rich_utils import Cmd2HelpFormatter
253+
from .rich_utils import (
254+
Cmd2HelpFormatter,
255+
HelpContent,
256+
)
249257
from .styles import Cmd2Style
250258
from .types import (
251259
CmdOrSetT,
@@ -541,15 +549,46 @@ def _SubParsersAction_remove_parser( # noqa: N802
541549
argparse._SubParsersAction.remove_parser = _SubParsersAction_remove_parser # type: ignore[attr-defined]
542550

543551

552+
@dataclass
553+
class _ParserThreadLocals(threading.local):
554+
"""Thread-local storage used by Cmd2ArgumentParser to manage execution context."""
555+
556+
# The active output stream for help, usage, and errors. Since argparse does not
557+
# pass the destination stream to the formatter factory, this transient value
558+
# provides the context needed to synchronize Rich's rendering with the specific
559+
# capabilities of the destination file descriptor. It is managed via the
560+
# output_to() context manager.
561+
current_output_file: IO[str] | None = None
562+
563+
544564
class Cmd2ArgumentParser(argparse.ArgumentParser):
545565
"""Custom ArgumentParser class that improves error and help output."""
546566

567+
# Thread-local storage shared by all parser instances (including subparsers)
568+
_thread_locals: ClassVar[_ParserThreadLocals] = _ParserThreadLocals()
569+
570+
@contextlib.contextmanager
571+
def output_to(self, file: IO[str] | None) -> Iterator[None]:
572+
"""Context manager to temporarily set the output stream during argparse operations.
573+
574+
This is helpful for directing output for functions like `parse_args()`, which
575+
default to `sys.stdout` and lack a `file` argument.
576+
577+
:param file: the file stream to use for output
578+
"""
579+
previous = self._thread_locals.current_output_file
580+
self._thread_locals.current_output_file = file
581+
try:
582+
yield
583+
finally:
584+
self._thread_locals.current_output_file = previous
585+
547586
def __init__(
548587
self,
549588
prog: str | None = None,
550589
usage: str | None = None,
551-
description: RenderableType | None = None,
552-
epilog: RenderableType | None = None,
590+
description: HelpContent | None = None,
591+
epilog: HelpContent | None = None,
553592
parents: Sequence[argparse.ArgumentParser] = (),
554593
formatter_class: type[Cmd2HelpFormatter] = Cmd2HelpFormatter,
555594
prefix_chars: str = "-",
@@ -599,8 +638,24 @@ def __init__(
599638

600639
# To assist type checkers, recast these to reflect our usage of rich-argparse.
601640
self.formatter_class: type[Cmd2HelpFormatter]
602-
self.description: RenderableType | None # type: ignore[assignment]
603-
self.epilog: RenderableType | None # type: ignore[assignment]
641+
self.description: HelpContent | None # type: ignore[assignment]
642+
self.epilog: HelpContent | None # type: ignore[assignment]
643+
644+
def print_usage(self, file: IO[str] | None = None) -> None: # type:ignore[override]
645+
"""Override to ensure the formatter is aware of the target file."""
646+
if file is None:
647+
file = self._thread_locals.current_output_file
648+
649+
with self.output_to(file):
650+
super().print_usage(file)
651+
652+
def print_help(self, file: IO[str] | None = None) -> None: # type:ignore[override]
653+
"""Override to ensure the formatter is aware of the target file."""
654+
if file is None:
655+
file = self._thread_locals.current_output_file
656+
657+
with self.output_to(file):
658+
super().print_help(file)
604659

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

807-
self.print_usage(sys.stderr)
862+
with self.output_to(sys.stderr):
863+
self.print_usage(sys.stderr)
808864

809-
# Use console to add style since it will respect ALLOW_STYLE's value
810-
console = self._get_formatter().console
811-
with console.capture() as capture:
812-
console.print(formatted_message, style=Cmd2Style.ERROR)
813-
formatted_message = f"{capture.get()}"
865+
# Use console to add style since it will respect ALLOW_STYLE's value.
866+
# Now _get_formatter() will return a formatter bound to stderr.
867+
console = self._get_formatter().console
868+
with console.capture() as capture:
869+
console.print(formatted_message, style=Cmd2Style.ERROR)
870+
formatted_message = f"{capture.get()}"
814871

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

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

821878
def format_help(self) -> str:
822879
"""Override to add a newline."""

cmd2/cmd2.py

Lines changed: 45 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
- Redirection to file or paste buffer (clipboard) with > or >>
1616
- Bash-style ``select`` available
1717
18-
Note, if self.stdout is different than sys.stdout, then redirection with > and |
19-
will only work if `self.poutput()` is used in place of `print`.
18+
Note: cmd2 redirection only captures output directed to self.stdout (e.g., via self.poutput()).
19+
Standard print() calls write directly to sys.stdout and are not captured. However, print() calls
20+
within pyscripts and the interactive Python shell are treated as command output and sent to
21+
self.stdout, allowing them to be captured.
2022
2123
GitHub: https://github.com/python-cmd2/cmd2
2224
Documentation: https://cmd2.readthedocs.io/
@@ -318,12 +320,12 @@ class AsyncAlert:
318320
timestamp: float = field(default_factory=time.monotonic, init=False)
319321

320322

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

324-
def __init__(self) -> None:
325-
self.stdout: Cmd2BaseConsole | None = None
326-
self.stderr: Cmd2BaseConsole | None = None
327+
stdout: Cmd2BaseConsole | None = None
328+
stderr: Cmd2BaseConsole | None = None
327329

328330

329331
class Cmd:
@@ -3191,13 +3193,8 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
31913193
"""
31923194
import subprocess
31933195

3194-
# Only redirect sys.stdout if it's the same as self.stdout
3195-
stdouts_match = self.stdout == sys.stdout
3196-
31973196
# Initialize the redirection saved state
3198-
redir_saved_state = utils.RedirectionSavedState(
3199-
self.stdout, stdouts_match, self._cur_pipe_proc_reader, self._redirecting
3200-
)
3197+
redir_saved_state = utils.RedirectionSavedState(self.stdout, self._cur_pipe_proc_reader, self._redirecting)
32013198

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

32563253
self.stdout = new_stdout
3257-
if stdouts_match:
3258-
sys.stdout = self.stdout
32593254

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

32733268
self.stdout = new_stdout
3274-
if stdouts_match:
3275-
sys.stdout = self.stdout
32763269

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

32943287
self.stdout = new_stdout
3295-
if stdouts_match:
3296-
sys.stdout = self.stdout
32973288

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

3327-
# Restore the stdout values
3318+
# Restore self.stdout
33283319
self.stdout = cast(TextIO, saved_redir_state.saved_self_stdout)
3329-
if saved_redir_state.stdouts_match:
3330-
sys.stdout = self.stdout
33313320

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

43964385
def _print_documented_command_topics(self, header: str, commands: Sequence[str], verbose: bool) -> None:
43974386
"""Print topics which are documented commands, switching between verbose or traditional output."""
4398-
import io
4399-
44004387
if not commands:
44014388
return
44024389

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

44124399
# Try to get the documentation string for each command
4413-
topics = self.get_help_topics()
44144400
for command in commands:
44154401
if (command_func := self.get_command_func(command)) is None:
44164402
continue
44174403

4418-
doc: str | None
4419-
4420-
# Non-argparse commands can have help_functions for their documentation
4421-
if command in topics:
4422-
help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
4423-
result = io.StringIO()
4424-
4425-
# try to redirect system stdout
4426-
with contextlib.redirect_stdout(result):
4427-
# save our internal stdout
4428-
stdout_orig = self.stdout
4429-
try:
4430-
# redirect our internal stdout
4431-
self.stdout = cast(TextIO, result)
4432-
help_func()
4433-
finally:
4434-
with self.sigint_protection:
4435-
# restore internal stdout
4436-
self.stdout = stdout_orig
4437-
doc = result.getvalue()
4438-
4439-
else:
4440-
doc = command_func.__doc__
4404+
doc = command_func.__doc__
44414405

44424406
# Attempt to locate the first documentation block
44434407
cmd_desc = strip_doc_annotations(doc) if doc else ""
@@ -4914,8 +4878,38 @@ def _run_python(self, *, pyscript: str | None = None) -> bool | None:
49144878
"""
49154879
self.last_result = False
49164880

4881+
# Replace print() in the embedded Python environment. Standard print() writes to
4882+
# sys.stdout, which bypasses cmd2 redirection (e.g., run_pyscript script.py > out.txt).
4883+
# Using self.print_to(self.stdout) ensures output is capturable and respects 'allow_style'
4884+
# without requiring the user to have access to 'self'.
4885+
def py_print(
4886+
*objects: Any,
4887+
sep: str = " ",
4888+
end: str = "\n",
4889+
file: IO[str] | None = None,
4890+
flush: bool = False, # noqa: ARG001
4891+
) -> None:
4892+
"""Print objects to a stream, defaulting to self.stdout.
4893+
4894+
This is used as the print() function within interactive Python shells and pyscripts.
4895+
It wraps cmd2's print_to() method to honor output redirection and style settings.
4896+
4897+
:param objects: objects to print (including Rich objects)
4898+
:param sep: string to write between printed text. Defaults to " ".
4899+
:param end: string to write at end of printed text. Defaults to a newline.
4900+
:param file: file stream being written to. Defaults to self.stdout.
4901+
:param flush: ignored as Rich-based output is flushed automatically. Defaults to False.
4902+
"""
4903+
if file is None:
4904+
file = self.stdout
4905+
4906+
self.print_to(file, *objects, sep=sep, end=end)
4907+
4908+
# Replace quit/exit in the embedded Python environment. Standard sys.exit()
4909+
# would kill the entire application process; raising EmbeddedConsoleExit
4910+
# allows the interpreter to return gracefully to the cmd2 prompt.
49174911
def py_quit() -> None:
4918-
"""Exit an interactive Python environment, callable from the interactive Python console."""
4912+
"""Exit an interactive Python shell or pyscript."""
49194913
raise EmbeddedConsoleExit
49204914

49214915
from .py_bridge import PyBridge
@@ -4938,6 +4932,7 @@ def py_quit() -> None:
49384932
# it's OK for py_locals to contain objects which are editable in a pyscript.
49394933
local_vars = self.py_locals.copy()
49404934
local_vars[self.py_bridge_name] = py_bridge
4935+
local_vars["print"] = py_print
49414936
local_vars["quit"] = py_quit
49424937
local_vars["exit"] = py_quit
49434938

@@ -5097,19 +5092,13 @@ def do_ipy(self, _: argparse.Namespace) -> bool | None: # pragma: no cover
50975092
except NameError:
50985093
from IPython import start_ipython
50995094

5100-
from IPython.terminal.interactiveshell import (
5101-
TerminalInteractiveShell,
5102-
)
5103-
from IPython.terminal.ipapp import (
5104-
TerminalIPythonApp,
5105-
)
5095+
from IPython.terminal.interactiveshell import TerminalInteractiveShell
5096+
from IPython.terminal.ipapp import TerminalIPythonApp
51065097
except ImportError:
51075098
self.perror("IPython package is not installed")
51085099
return None
51095100

5110-
from .py_bridge import (
5111-
PyBridge,
5112-
)
5101+
from .py_bridge import PyBridge
51135102

51145103
if self.in_pyscript():
51155104
self.perror("Recursively entering interactive Python shells is not allowed")

0 commit comments

Comments
 (0)