Skip to content

Commit 77fea2f

Browse files
committed
Merge branch 'main' into pt-colors
2 parents 3bc0070 + 79c984a commit 77fea2f

17 files changed

Lines changed: 498 additions & 292 deletions

CHANGELOG.md

Lines changed: 14 additions & 1 deletion
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
@@ -137,6 +145,11 @@ prompt is displayed.
137145
full type hints and IDE autocompletion for `self._cmd` without needing to override and cast
138146
the property.
139147
- 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
- Added ability to customize `prompt-toolkit` completion menu colors by overriding
141154
`Cmd2Style.COMPLETION_MENU_ITEM` and `Cmd2Style.COMPLETION_MENU_META` in the `cmd2` theme.
142155

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/
@@ -321,12 +323,12 @@ class AsyncAlert:
321323
timestamp: float = field(default_factory=time.monotonic, init=False)
322324

323325

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

327-
def __init__(self) -> None:
328-
self.stdout: Cmd2BaseConsole | None = None
329-
self.stderr: Cmd2BaseConsole | None = None
330+
stdout: Cmd2BaseConsole | None = None
331+
stderr: Cmd2BaseConsole | None = None
330332

331333

332334
class Cmd:
@@ -3223,13 +3225,8 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
32233225
"""
32243226
import subprocess
32253227

3226-
# Only redirect sys.stdout if it's the same as self.stdout
3227-
stdouts_match = self.stdout == sys.stdout
3228-
32293228
# Initialize the redirection saved state
3230-
redir_saved_state = utils.RedirectionSavedState(
3231-
self.stdout, stdouts_match, self._cur_pipe_proc_reader, self._redirecting
3232-
)
3229+
redir_saved_state = utils.RedirectionSavedState(self.stdout, self._cur_pipe_proc_reader, self._redirecting)
32333230

32343231
# The ProcReader for this command
32353232
cmd_pipe_proc_reader: utils.ProcReader | None = None
@@ -3286,8 +3283,6 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
32863283
cmd_pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)
32873284

32883285
self.stdout = new_stdout
3289-
if stdouts_match:
3290-
sys.stdout = self.stdout
32913286

32923287
elif statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND):
32933288
if statement.redirect_to:
@@ -3303,8 +3298,6 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
33033298
redir_saved_state.redirecting = True
33043299

33053300
self.stdout = new_stdout
3306-
if stdouts_match:
3307-
sys.stdout = self.stdout
33083301

33093302
else:
33103303
# Redirecting to a paste buffer
@@ -3324,8 +3317,6 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
33243317
redir_saved_state.redirecting = True
33253318

33263319
self.stdout = new_stdout
3327-
if stdouts_match:
3328-
sys.stdout = self.stdout
33293320

33303321
if statement.redirector == constants.REDIRECTION_APPEND:
33313322
self.stdout.write(current_paste_buffer)
@@ -3356,10 +3347,8 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec
33563347
# Close the file or pipe that stdout was redirected to
33573348
self.stdout.close()
33583349

3359-
# Restore the stdout values
3350+
# Restore self.stdout
33603351
self.stdout = cast(TextIO, saved_redir_state.saved_self_stdout)
3361-
if saved_redir_state.stdouts_match:
3362-
sys.stdout = self.stdout
33633352

33643353
# Check if we need to wait for the process being piped to
33653354
if self._cur_pipe_proc_reader is not None:
@@ -4429,8 +4418,6 @@ def print_topics(self, header: str, cmds: Sequence[str] | None, cmdlen: int, max
44294418

44304419
def _print_documented_command_topics(self, header: str, commands: Sequence[str], verbose: bool) -> None:
44314420
"""Print topics which are documented commands, switching between verbose or traditional output."""
4432-
import io
4433-
44344421
if not commands:
44354422
return
44364423

@@ -4444,34 +4431,11 @@ def _print_documented_command_topics(self, header: str, commands: Sequence[str],
44444431
)
44454432

44464433
# Try to get the documentation string for each command
4447-
topics = self.get_help_topics()
44484434
for command in commands:
44494435
if (command_func := self.get_command_func(command)) is None:
44504436
continue
44514437

4452-
doc: str | None
4453-
4454-
# Non-argparse commands can have help_functions for their documentation
4455-
if command in topics:
4456-
help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
4457-
result = io.StringIO()
4458-
4459-
# try to redirect system stdout
4460-
with contextlib.redirect_stdout(result):
4461-
# save our internal stdout
4462-
stdout_orig = self.stdout
4463-
try:
4464-
# redirect our internal stdout
4465-
self.stdout = cast(TextIO, result)
4466-
help_func()
4467-
finally:
4468-
with self.sigint_protection:
4469-
# restore internal stdout
4470-
self.stdout = stdout_orig
4471-
doc = result.getvalue()
4472-
4473-
else:
4474-
doc = command_func.__doc__
4438+
doc = command_func.__doc__
44754439

44764440
# Attempt to locate the first documentation block
44774441
cmd_desc = strip_doc_annotations(doc) if doc else ""
@@ -4937,8 +4901,38 @@ def _run_python(self, *, pyscript: str | None = None) -> bool | None:
49374901
"""
49384902
self.last_result = False
49394903

4904+
# Replace print() in the embedded Python environment. Standard print() writes to
4905+
# sys.stdout, which bypasses cmd2 redirection (e.g., run_pyscript script.py > out.txt).
4906+
# Using self.print_to(self.stdout) ensures output is capturable and respects 'allow_style'
4907+
# without requiring the user to have access to 'self'.
4908+
def py_print(
4909+
*objects: Any,
4910+
sep: str = " ",
4911+
end: str = "\n",
4912+
file: IO[str] | None = None,
4913+
flush: bool = False, # noqa: ARG001
4914+
) -> None:
4915+
"""Print objects to a stream, defaulting to self.stdout.
4916+
4917+
This is used as the print() function within interactive Python shells and pyscripts.
4918+
It wraps cmd2's print_to() method to honor output redirection and style settings.
4919+
4920+
:param objects: objects to print (including Rich objects)
4921+
:param sep: string to write between printed text. Defaults to " ".
4922+
:param end: string to write at end of printed text. Defaults to a newline.
4923+
:param file: file stream being written to. Defaults to self.stdout.
4924+
:param flush: ignored as Rich-based output is flushed automatically. Defaults to False.
4925+
"""
4926+
if file is None:
4927+
file = self.stdout
4928+
4929+
self.print_to(file, *objects, sep=sep, end=end)
4930+
4931+
# Replace quit/exit in the embedded Python environment. Standard sys.exit()
4932+
# would kill the entire application process; raising EmbeddedConsoleExit
4933+
# allows the interpreter to return gracefully to the cmd2 prompt.
49404934
def py_quit() -> None:
4941-
"""Exit an interactive Python environment, callable from the interactive Python console."""
4935+
"""Exit an interactive Python shell or pyscript."""
49424936
raise EmbeddedConsoleExit
49434937

49444938
from .py_bridge import PyBridge
@@ -4961,6 +4955,7 @@ def py_quit() -> None:
49614955
# it's OK for py_locals to contain objects which are editable in a pyscript.
49624956
local_vars = self.py_locals.copy()
49634957
local_vars[self.py_bridge_name] = py_bridge
4958+
local_vars["print"] = py_print
49644959
local_vars["quit"] = py_quit
49654960
local_vars["exit"] = py_quit
49664961

@@ -5120,19 +5115,13 @@ def do_ipy(self, _: argparse.Namespace) -> bool | None: # pragma: no cover
51205115
except NameError:
51215116
from IPython import start_ipython
51225117

5123-
from IPython.terminal.interactiveshell import (
5124-
TerminalInteractiveShell,
5125-
)
5126-
from IPython.terminal.ipapp import (
5127-
TerminalIPythonApp,
5128-
)
5118+
from IPython.terminal.interactiveshell import TerminalInteractiveShell
5119+
from IPython.terminal.ipapp import TerminalIPythonApp
51295120
except ImportError:
51305121
self.perror("IPython package is not installed")
51315122
return None
51325123

5133-
from .py_bridge import (
5134-
PyBridge,
5135-
)
5124+
from .py_bridge import PyBridge
51365125

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

0 commit comments

Comments
 (0)