From 0184f82455aea793d1948ad9cabe33724dc2cb5c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 27 Apr 2026 19:42:15 -0400 Subject: [PATCH 01/18] Added methods to Cmd2ArgumentParser to support redirection of help and usage output during the parsing phase. --- cmd2/argparse_completer.py | 4 +-- cmd2/argparse_utils.py | 74 ++++++++++++++++++++++++++++++++++++++ cmd2/cmd2.py | 6 ++-- cmd2/decorators.py | 14 ++++++-- 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index cdf017038..755bc48c2 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -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.""" diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index 6a0f621df..859b54bdd 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -227,15 +227,19 @@ def get_choices(self) -> Choices: import argparse import re import sys +import threading from argparse import ArgumentError from collections.abc import ( Callable, Iterable, Sequence, ) +from dataclasses import dataclass from typing import ( + IO, TYPE_CHECKING, Any, + ClassVar, NoReturn, cast, ) @@ -541,9 +545,21 @@ 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.""" + + # If set, this stream will be used by print_help() and print_usage() + # instead of defaulting to sys.stdout. + custom_stdout: 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() + def __init__( self, prog: str | None = None, @@ -602,6 +618,64 @@ def __init__( self.description: RenderableType | None # type: ignore[assignment] self.epilog: RenderableType | None # type: ignore[assignment] + def parse_args_custom_stdout( + self, + stdout: IO[str], + args: list[str] | None = None, + namespace: argparse.Namespace | None = None, + ) -> argparse.Namespace: + """Parse arguments while directing help and usage output to a custom stdout stream. + + This method is particularly useful when you need to capture help output without + globally redirecting sys.stdout. + + :param stdout: the stream to use for help and usage output + :param args: optional list of arguments to parse. If None, uses sys.argv[1:]. + :param namespace: optional namespace to populate. If None, a new Namespace is created. + :return: the parsed namespace + """ + previous = self._thread_locals.custom_stdout + try: + self._thread_locals.custom_stdout = stdout + return self.parse_args(args, namespace) + finally: + self._thread_locals.custom_stdout = previous + + def parse_known_args_custom_stdout( + self, + stdout: IO[str], + args: list[str] | None = None, + namespace: argparse.Namespace | None = None, + ) -> tuple[argparse.Namespace, list[str]]: + """Parse known arguments while directing help and usage output to a custom stdout stream. + + This method is particularly useful when you need to capture help output without + globally redirecting sys.stdout. + + :param stdout: the stream to use for help and usage output + :param args: optional list of arguments to parse. If None, uses sys.argv[1:]. + :param namespace: optional namespace to populate. If None, a new Namespace is created. + :return: a tuple containing the parsed namespace and a list of unknown arguments + """ + previous = self._thread_locals.custom_stdout + try: + self._thread_locals.custom_stdout = stdout + return self.parse_known_args(args, namespace) + finally: + self._thread_locals.custom_stdout = previous + + def print_usage(self, file: IO[str] | None = None) -> None: # type:ignore[override] + """Override to support writing to a custom stream.""" + if file is None: + file = self._thread_locals.custom_stdout + super().print_usage(file) + + def print_help(self, file: IO[str] | None = None) -> None: # type:ignore[override] + """Override to support writing to a custom stream.""" + if file is None: + file = self._thread_locals.custom_stdout + super().print_help(file) + def get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentParser]": """Get the _SubParsersAction for this parser if it exists. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e9cbb8f48..3daf327bb 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -318,12 +318,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: diff --git a/cmd2/decorators.py b/cmd2/decorators.py index d743b7b0b..aa9e239ce 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -304,9 +304,19 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: try: parsing_results: tuple[argparse.Namespace] | tuple[argparse.Namespace, list[str]] if with_unknown_args: - parsing_results = arg_parser.parse_known_args(command_arg_list, initial_namespace) + parsing_results = arg_parser.parse_known_args_custom_stdout( + cmd2_app.stdout, + command_arg_list, + initial_namespace, + ) else: - parsing_results = (arg_parser.parse_args(command_arg_list, initial_namespace),) + parsing_results = ( + arg_parser.parse_args_custom_stdout( + cmd2_app.stdout, + command_arg_list, + initial_namespace, + ), + ) except SystemExit as exc: raise Cmd2ArgparseError from exc From a45a98d182f81cb83dd2af7a4dccdfa3ee7c5cab Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 27 Apr 2026 20:35:27 -0400 Subject: [PATCH 02/18] Made docstrings the single source of truth for verbose help descriptions. --- cmd2/cmd2.py | 27 +-------------------------- tests/test_cmd2.py | 2 +- 2 files changed, 2 insertions(+), 27 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 3daf327bb..c4e4bfef5 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4395,8 +4395,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 @@ -4410,34 +4408,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 "" diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index d0a52c964..db54d6da0 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1419,7 +1419,7 @@ def test_miscellaneous_help_topic(help_app) -> None: assert help_app.last_result is True -def test_help_verbose_uses_parser_description(help_app: HelpApp) -> None: +def test_help_verbose(help_app: HelpApp) -> None: out, _err = run_cmd(help_app, "help --verbose") expected_verbose = utils.strip_doc_annotations(help_app.do_parser_cmd.__doc__) verify_help_text(help_app, out, verbose_strings=[expected_verbose]) From 03925e3ed04469d8371f5c783ae2f4ff5e2ed85a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 27 Apr 2026 21:40:35 -0400 Subject: [PATCH 03/18] Only capture command output written to self.stdout during pyscript execution. --- cmd2/py_bridge.py | 19 ++++----------- tests/pyscript/stdout_capture.py | 4 ---- tests/test_run_pyscript.py | 40 ++++++++++++++------------------ 3 files changed, 22 insertions(+), 41 deletions(-) delete mode 100644 tests/pyscript/stdout_capture.py diff --git a/cmd2/py_bridge.py b/cmd2/py_bridge.py index 3eca37a73..15caa7fb2 100644 --- a/cmd2/py_bridge.py +++ b/cmd2/py_bridge.py @@ -99,17 +99,14 @@ def __call__(self, command: str, *, echo: bool | None = None) -> CommandResult: ex: app('help') :param command: command line being run - :param echo: If provided, this temporarily overrides the value of self.cmd_echo while the - command runs. If True, output will be echoed to stdout/stderr. (Defaults to None) - + :param echo: If provided, this temporarily overrides the value of self.cmd_echo + while the command runs. If True, output will be echoed to _cmd2_app.stdout + and sys.stderr. (Defaults to None) """ if echo is None: echo = self.cmd_echo - # Only capture sys.stdout if it's the same stream as self.stdout - stdouts_match = self._cmd2_app.stdout == sys.stdout - - # This will be used to capture _cmd2_app.stdout and sys.stdout + # This will be used to capture _cmd2_app.stdout copy_cmd_stdout = StdSim(cast(TextIO | StdSim, self._cmd2_app.stdout), echo=echo) # Pause the storing of stdout until onecmd_plus_hooks enables it @@ -122,10 +119,7 @@ def __call__(self, command: str, *, echo: bool | None = None) -> CommandResult: stop = False try: - with self._cmd2_app.sigint_protection: - self._cmd2_app.stdout = cast(TextIO, copy_cmd_stdout) - if stdouts_match: - sys.stdout = self._cmd2_app.stdout + self._cmd2_app.stdout = cast(TextIO, copy_cmd_stdout) with redirect_stderr(cast(IO[str], copy_stderr)): stop = self._cmd2_app.onecmd_plus_hooks( @@ -136,9 +130,6 @@ def __call__(self, command: str, *, echo: bool | None = None) -> CommandResult: finally: with self._cmd2_app.sigint_protection: self._cmd2_app.stdout = cast(TextIO, copy_cmd_stdout.inner_stream) - if stdouts_match: - sys.stdout = self._cmd2_app.stdout - self.stop = stop or self.stop # Save the result diff --git a/tests/pyscript/stdout_capture.py b/tests/pyscript/stdout_capture.py deleted file mode 100644 index 7cc6641c6..000000000 --- a/tests/pyscript/stdout_capture.py +++ /dev/null @@ -1,4 +0,0 @@ -# This script demonstrates that cmd2 can capture sys.stdout and self.stdout when both point to the same stream. -# Set base_app.self_in_py to True before running this script. -print("print") -self.poutput("poutput") diff --git a/tests/test_run_pyscript.py b/tests/test_run_pyscript.py index e19f77c59..45aaa2ed4 100644 --- a/tests/test_run_pyscript.py +++ b/tests/test_run_pyscript.py @@ -122,29 +122,23 @@ def test_run_pyscript_dir(base_app, request) -> None: assert out[0] == "['cmd_echo']" -def test_run_pyscript_capture(base_app, request) -> None: - base_app.self_in_py = True - test_dir = os.path.dirname(request.module.__file__) - python_script = os.path.join(test_dir, "pyscript", "stdout_capture.py") - out, _err = run_cmd(base_app, f"run_pyscript {python_script}") - - assert out[0] == "print" - assert out[1] == "poutput" - - -def test_run_pyscript_capture_custom_stdout(base_app, request) -> None: - """sys.stdout will not be captured if it's different than self.stdout.""" - import io - - base_app.stdout = io.StringIO() - - base_app.self_in_py = True - test_dir = os.path.dirname(request.module.__file__) - python_script = os.path.join(test_dir, "pyscript", "stdout_capture.py") - out, _err = run_cmd(base_app, f"run_pyscript {python_script}") - - assert "print" not in out - assert out[0] == "poutput" +def test_py_bridge_capture_isolation() -> None: + """Verify that PyBridge captures poutput but not raw print from within a command.""" + import cmd2 + from cmd2.py_bridge import PyBridge + + class App(cmd2.Cmd): + def do_test_capture(self, _): + print("process_stdout") + self.poutput("app_stdout") + + app = App() + bridge = PyBridge(app) + result = bridge("test_capture") + + # Verify isolation: only the application stream should be in the result + assert result.stdout == "app_stdout\n" + assert "process_stdout" not in result.stdout def test_run_pyscript_stop(base_app, request) -> None: From cc9b14f27c287c39c7e09403892fded71a5b38ae Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 27 Apr 2026 23:55:29 -0400 Subject: [PATCH 04/18] No longer capturing sys.stdout when redirecting output. --- cmd2/cmd2.py | 28 +++++------ cmd2/utils.py | 9 ++-- docs/features/settings.md | 2 +- tests/conftest.py | 11 +---- tests/test_cmd2.py | 101 ++++++++++++++------------------------ 5 files changed, 55 insertions(+), 96 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index c4e4bfef5..17a613121 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -15,8 +15,8 @@ - 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()). +print() calls write directly to sys.stdout and are not intercepted. GitHub: https://github.com/python-cmd2/cmd2 Documentation: https://cmd2.readthedocs.io/ @@ -3191,13 +3191,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 @@ -3254,8 +3249,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: @@ -3271,8 +3264,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 @@ -3292,8 +3283,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) @@ -3324,10 +3313,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: @@ -4878,6 +4865,12 @@ def _run_python(self, *, pyscript: str | None = None) -> bool | None: """ self.last_result = False + def py_print(*args: Any, file: IO[str] | None = None, **kwargs: Any) -> None: + """Print objects to a stream, defaulting to self.stdout.""" + if file is None: + file = self.stdout + self.print_to(file, *args, **kwargs) + def py_quit() -> None: """Exit an interactive Python environment, callable from the interactive Python console.""" raise EmbeddedConsoleExit @@ -4902,6 +4895,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 diff --git a/cmd2/utils.py b/cmd2/utils.py index 41df14db7..a5057ff79 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -652,24 +652,21 @@ class RedirectionSavedState: def __init__( self, - self_stdout: StdSim | TextIO, - stdouts_match: bool, - pipe_proc_reader: ProcReader | None, + self_stdout: "StdSim | TextIO", + pipe_proc_reader: "ProcReader | None", saved_redirecting: bool, ) -> None: """RedirectionSavedState initializer. :param self_stdout: saved value of Cmd.stdout - :param stdouts_match: True if Cmd.stdout is equal to sys.stdout :param pipe_proc_reader: saved value of Cmd._cur_pipe_proc_reader :param saved_redirecting: saved value of Cmd._redirecting. """ # Tells if command is redirecting self.redirecting = False - # Used to restore stdout values after redirection ends + # Used to restore Cmd.stdout after redirection ends self.saved_self_stdout = self_stdout - self.stdouts_match = stdouts_match # Used to restore values after command ends regardless of whether the command redirected self.saved_pipe_proc_reader = pipe_proc_reader diff --git a/docs/features/settings.md b/docs/features/settings.md index 84cc01616..e0eefe25a 100644 --- a/docs/features/settings.md +++ b/docs/features/settings.md @@ -56,7 +56,7 @@ be run by the [edit](./builtin_commands.md#edit) command. ### feedback_to_output -Controls whether feedback generated with the `cmd2.Cmd.pfeedback` method is sent to `sys.stdout` or +Controls whether feedback generated with the `cmd2.Cmd.pfeedback` method is sent to `self.stdout` or `sys.stderr`. If `False` the output will be sent to `sys.stderr` If `True` the output is sent to `stdout` (which is often the screen but may be diff --git a/tests/conftest.py b/tests/conftest.py index 8cbce3036..816d00696 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,12 +60,9 @@ def normalize(block: str) -> list[str]: def run_cmd(app: cmd2.Cmd, cmd: str) -> tuple[list[str], list[str]]: - """Clear out and err StdSim buffers, run the command, and return out and err""" + """Run the command in app and return what it writes to app.stdout and sys.stderr.""" - # Only capture sys.stdout if it's the same stream as self.stdout - stdouts_match = app.stdout == sys.stdout - - # This will be used to capture app.stdout and sys.stdout + # This will be used to capture app.stdout copy_cmd_stdout = StdSim(cast(TextIO, app.stdout)) # This will be used to capture sys.stderr @@ -73,14 +70,10 @@ def run_cmd(app: cmd2.Cmd, cmd: str) -> tuple[list[str], list[str]]: try: app.stdout = cast(TextIO, copy_cmd_stdout) - if stdouts_match: - sys.stdout = app.stdout with redirect_stderr(cast(TextIO, copy_stderr)): app.onecmd_plus_hooks(cmd) finally: app.stdout = cast(TextIO, copy_cmd_stdout.inner_stream) - if stdouts_match: - sys.stdout = app.stdout out = copy_cmd_stdout.getvalue() err = copy_stderr.getvalue() diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index db54d6da0..c5b1f1545 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -641,7 +641,10 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) def do_print_output(self, _: str) -> None: - """Print output to sys.stdout and self.stdout..""" + """Print output to sys.stdout and self.stdout.""" + + # Only data written to self.stdout is redirected. + # Therefore the print() call will not be. print("print") self.poutput("poutput") @@ -651,53 +654,40 @@ def do_print_feedback(self, _: str) -> None: @pytest.fixture -def redirection_app(): +def redirection_app() -> RedirectionApp: return RedirectionApp() -def test_output_redirection(redirection_app) -> None: +def test_output_redirection(redirection_app: RedirectionApp, capsys: pytest.CaptureFixture[str]) -> None: + """Verify that redirection captures data written to self.stdout and not sys.stdout.""" fd, filename = tempfile.mkstemp(prefix="cmd2_test", suffix=".txt") os.close(fd) try: # Verify that writing to a file works run_cmd(redirection_app, f"print_output > {filename}") - with open(filename) as f: - lines = f.read().splitlines() - assert lines[0] == "print" - assert lines[1] == "poutput" - # Verify that appending to a file also works - run_cmd(redirection_app, f"print_output >> {filename}") - with open(filename) as f: - lines = f.read().splitlines() - assert lines[0] == "print" - assert lines[1] == "poutput" - assert lines[2] == "print" - assert lines[3] == "poutput" - finally: - os.remove(filename) - - -def test_output_redirection_custom_stdout(redirection_app) -> None: - """sys.stdout should not redirect if it's different than self.stdout.""" - fd, filename = tempfile.mkstemp(prefix="cmd2_test", suffix=".txt") - os.close(fd) + # Verify print() went to sys.stdout + out, _err = capsys.readouterr() + assert out == "print\n" - redirection_app.stdout = io.StringIO() - try: - # Verify that we only see output written to self.stdout - run_cmd(redirection_app, f"print_output > {filename}") with open(filename) as f: lines = f.read().splitlines() - assert "print" not in lines + + # Only data written to self.stdout should be in the file + assert len(lines) == 1 assert lines[0] == "poutput" # Verify that appending to a file also works run_cmd(redirection_app, f"print_output >> {filename}") + + out, _err = capsys.readouterr() + assert out == "print\n" + with open(filename) as f: lines = f.read().splitlines() - assert "print" not in lines + + assert len(lines) == 2 assert lines[0] == "poutput" assert lines[1] == "poutput" finally: @@ -760,7 +750,7 @@ def test_feedback_to_output_false(redirection_app) -> None: os.remove(filename) -def test_disallow_redirection(redirection_app) -> None: +def test_disallow_redirection(redirection_app: RedirectionApp, capsys: pytest.CaptureFixture[str]) -> None: # Set allow_redirection to False redirection_app.allow_redirection = False @@ -768,24 +758,20 @@ def test_disallow_redirection(redirection_app) -> None: # Verify output wasn't redirected out, _err = run_cmd(redirection_app, f"print_output > {filename}") - assert "print" in out assert "poutput" in out - # Verify that no file got created + # Verify that no file was created assert not os.path.exists(filename) -def test_pipe_to_shell(redirection_app) -> None: +def test_pipe_to_shell(redirection_app: RedirectionApp, capsys: pytest.CaptureFixture[str]) -> None: out, err = run_cmd(redirection_app, "print_output | sort") - assert "print" in out - assert "poutput" in out - assert not err + # Verify print() went to sys.stdout + captured = capsys.readouterr() + assert captured.out == "print\n" -def test_pipe_to_shell_custom_stdout(redirection_app) -> None: - """sys.stdout should not redirect if it's different than self.stdout.""" - redirection_app.stdout = io.StringIO() - out, err = run_cmd(redirection_app, "print_output | sort") + # Verify only data written to self.stdout was piped assert "print" not in out assert "poutput" in out assert not err @@ -822,37 +808,26 @@ def test_pipe_to_shell_error(redirection_app) -> None: @pytest.mark.skipif(not can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") -def test_send_to_paste_buffer(redirection_app) -> None: +def test_send_to_paste_buffer(redirection_app: RedirectionApp, capsys: pytest.CaptureFixture[str]) -> None: # Test writing to the PasteBuffer/Clipboard run_cmd(redirection_app, "print_output >") - lines = cmd2.cmd2.get_paste_buffer().splitlines() - assert lines[0] == "print" - assert lines[1] == "poutput" - - # Test appending to the PasteBuffer/Clipboard - run_cmd(redirection_app, "print_output >>") - lines = cmd2.cmd2.get_paste_buffer().splitlines() - assert lines[0] == "print" - assert lines[1] == "poutput" - assert lines[2] == "print" - assert lines[3] == "poutput" - -@pytest.mark.skipif(not can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") -def test_send_to_paste_buffer_custom_stdout(redirection_app) -> None: - """sys.stdout should not redirect if it's different than self.stdout.""" - redirection_app.stdout = io.StringIO() + # Verify print() went to sys.stdout + out, _err = capsys.readouterr() + assert out == "print\n" - # Verify that we only see output written to self.stdout - run_cmd(redirection_app, "print_output >") - lines = cmd2.cmd2.get_paste_buffer().splitlines() - assert "print" not in lines + lines = cmd2.clipboard.get_paste_buffer().splitlines() + assert len(lines) == 1 assert lines[0] == "poutput" # Test appending to the PasteBuffer/Clipboard run_cmd(redirection_app, "print_output >>") - lines = cmd2.cmd2.get_paste_buffer().splitlines() - assert "print" not in lines + + out, _err = capsys.readouterr() + assert out == "print\n" + + lines = cmd2.clipboard.get_paste_buffer().splitlines() + assert len(lines) == 2 assert lines[0] == "poutput" assert lines[1] == "poutput" From 07117624ed292359e5c27bac28584da424ad29a0 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 01:09:23 -0400 Subject: [PATCH 05/18] Made py_print's signature match print's --- cmd2/cmd2.py | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 17a613121..8d3cfbd46 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4865,14 +4865,34 @@ def _run_python(self, *, pyscript: str | None = None) -> bool | None: """ self.last_result = False - def py_print(*args: Any, file: IO[str] | None = None, **kwargs: Any) -> None: - """Print objects to a stream, defaulting to self.stdout.""" + 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 + :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, *args, **kwargs) + + self.print_to(file, *objects, sep=sep, end=end) def py_quit() -> None: - """Exit an interactive Python environment, callable from the interactive Python console.""" + """Exit an interactive Python shell or pyscript. + + This is used as the quit() and exit() functions within the Python environment. + """ raise EmbeddedConsoleExit from .py_bridge import PyBridge @@ -5055,19 +5075,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") From 8f78030f86530831625ef3b9dce2926800bcb1aa Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 10:01:54 -0400 Subject: [PATCH 06/18] Updated comments. --- cmd2/cmd2.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 8d3cfbd46..2003b3fe3 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -16,7 +16,9 @@ - Bash-style ``select`` available Note: cmd2 redirection only captures output directed to self.stdout (e.g., via self.poutput()). -print() calls write directly to sys.stdout and are not intercepted. +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. GitHub: https://github.com/python-cmd2/cmd2 Documentation: https://cmd2.readthedocs.io/ @@ -4865,6 +4867,10 @@ def _run_python(self, *, pyscript: str | None = None) -> bool | None: """ self.last_result = False + # Replace print() in the 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 = " ", @@ -4888,11 +4894,11 @@ def py_print( 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 shell or pyscript. - - This is used as the quit() and exit() functions within the Python environment. - """ + """Exit an interactive Python shell or pyscript.""" raise EmbeddedConsoleExit from .py_bridge import PyBridge From 3af35991c7301f89535b10a7e034e526b5329cb8 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 10:41:56 -0400 Subject: [PATCH 07/18] Added unit tests. --- tests/pyscript/environment.py | 2 -- tests/pyscript/test_print.py | 24 +++++++++++++++++ tests/test_run_pyscript.py | 49 +++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 tests/pyscript/test_print.py diff --git a/tests/pyscript/environment.py b/tests/pyscript/environment.py index adced4ae0..092349187 100644 --- a/tests/pyscript/environment.py +++ b/tests/pyscript/environment.py @@ -2,8 +2,6 @@ import os import sys -app.cmd_echo = True - if __name__ != "__main__": print(f"Error: __name__ is: {__name__}") quit() diff --git a/tests/pyscript/test_print.py b/tests/pyscript/test_print.py new file mode 100644 index 000000000..108cab81a --- /dev/null +++ b/tests/pyscript/test_print.py @@ -0,0 +1,24 @@ +""" +This script is used to test the py_print() wrapper that cmd2 provides as a replacement +for the built-in print() function in the embedded Python environment. +""" + +import sys + +# Test multiple objects and sep +print("hello", "world", sep="-") + +# Test end +print("no newline", end=" ") +print("here") + +# Test multiple objects with custom sep and end +print(1, 2, 3, sep=":", end=".") +print() # to get a newline + + +# Test file=sys.stdout +print("this goes to sys.stdout", file=sys.stdout) + +# Test file=sys.stderr +print("this goes to sys.stderr", file=sys.stderr) diff --git a/tests/test_run_pyscript.py b/tests/test_run_pyscript.py index 45aaa2ed4..a2072f3f5 100644 --- a/tests/test_run_pyscript.py +++ b/tests/test_run_pyscript.py @@ -207,3 +207,52 @@ def test_run_pyscript_app_echo(base_app, request) -> None: # Only the edit help text should have been echoed to pytest's stdout assert out[0] == "Usage: edit [-h] [file_path]" + + +def test_run_pyscript_print(base_app, request, capsys) -> None: + """Verify that py_print() (the print() replacement in pyscripts) works correctly.""" + test_dir = os.path.dirname(request.module.__file__) + python_script = os.path.join(test_dir, "pyscript", "test_print.py") + out, err = run_cmd(base_app, f"run_pyscript {python_script}") + + # Verify contents of self.stdout + assert len(out) == 3 + assert out[0] == "hello-world" + assert out[1] == "no newline here" + assert out[2] == "1:2:3." + + # Verify contents of sys.stderr + assert len(err) == 1 + assert err[0] == "this goes to sys.stderr" + + # Verify contents of sys.stdout + stdout, _ = capsys.readouterr() + assert "this goes to sys.stdout" in stdout + + +def test_run_pyscript_print_redirection(base_app, request, tmp_path, capsys) -> None: + """Verify that py_print() (the print() replacement in pyscripts) respects cmd2 redirection.""" + test_dir = os.path.dirname(request.module.__file__) + python_script = os.path.join(test_dir, "pyscript", "test_print.py") + out_file = tmp_path / "out.txt" + + # Run the pyscript with redirection + base_app.onecmd_plus_hooks(f"run_pyscript {python_script} > {out_file}") + out, err = capsys.readouterr() + + # Verify the output file contains what we expect from print() + with open(out_file) as f: + content = f.read() + + # Look for everything written to self.stdout + assert "hello-world\n" in content + assert "no newline here\n" in content + assert "1:2:3.\n" in content + + # Nothing else should have been redirected + assert "this goes to sys.stdout" not in content + assert "this goes to sys.stderr" not in content + + # Verify the remaining output when to the correct stream + assert "this goes to sys.stdout" in out + assert "this goes to sys.stderr" in err From 1f22dee9bc609e8b177784e512d605abb7291fba Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 10:53:56 -0400 Subject: [PATCH 08/18] Updated comment. --- cmd2/cmd2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 2003b3fe3..73b4c2600 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4883,7 +4883,7 @@ def py_print( 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 + :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. From 4f54ad6802febee4d1819fd21f898e4705ef83a3 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 16:22:01 -0400 Subject: [PATCH 09/18] Added HelpFormatterRenderable to simplify how we pass help formatter when rendering argparse help. --- CHANGELOG.md | 4 +- cmd2/argparse_utils.py | 21 +++++----- cmd2/cmd2.py | 2 +- cmd2/rich_utils.py | 84 +++++++++++++++++----------------------- tests/test_rich_utils.py | 51 +----------------------- 5 files changed, 53 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e640542ad..cd85dbfc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index 859b54bdd..e1154356e 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -244,12 +244,14 @@ def get_choices(self) -> Choices: 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, @@ -549,8 +551,7 @@ def _SubParsersAction_remove_parser( # noqa: N802 class _ParserThreadLocals(threading.local): """Thread-local storage used by Cmd2ArgumentParser to manage execution context.""" - # If set, this stream will be used by print_help() and print_usage() - # instead of defaulting to sys.stdout. + # If set, print_help() and print_usage() will default to use this instead of sys.stdout. custom_stdout: IO[str] | None = None @@ -564,8 +565,8 @@ 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 = "-", @@ -615,8 +616,8 @@ 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 parse_args_custom_stdout( self, @@ -888,9 +889,9 @@ def error(self, message: str) -> NoReturn: self.exit(2, f"{formatted_message}\n") - def _get_formatter(self, **kwargs: Any) -> Cmd2HelpFormatter: + def _get_formatter(self) -> Cmd2HelpFormatter: """Override with customizations for Cmd2HelpFormatter.""" - return cast(Cmd2HelpFormatter, super()._get_formatter(**kwargs)) + return cast(Cmd2HelpFormatter, super()._get_formatter()) def format_help(self) -> str: """Override to add a newline.""" diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 73b4c2600..066885c90 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -4867,7 +4867,7 @@ def _run_python(self, *, pyscript: str | None = None) -> bool | None: """ self.last_result = False - # Replace print() in the the embedded Python environment. Standard print() writes to + # 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'. diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 704e603aa..55c128c64 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -3,7 +3,6 @@ import argparse import re import sys -import threading from collections.abc import ( Iterator, Mapping, @@ -13,18 +12,19 @@ IO, Any, ClassVar, + Protocol, + TypeAlias, + runtime_checkable, ) from rich.box import SIMPLE_HEAD from rich.console import ( Console, - ConsoleOptions, ConsoleRenderable, Group, JustifyMethod, OverflowMethod, RenderableType, - RenderResult, ) from rich.padding import Padding from rich.pretty import is_expandable @@ -58,6 +58,29 @@ ANSI_STYLE_SEQUENCE_RE = re.compile(r"\x1b\[[0-9;]*m") +@runtime_checkable +class HelpFormatterRenderable(Protocol): + """Protocol for objects that require a Cmd2HelpFormatter to render.""" + + def __cmd2_argparse_help__(self, formatter: "Cmd2HelpFormatter") -> RenderableType | None: + """Provide a representation of this object for a Cmd2HelpFormatter. + + Return a Rich renderable for this object. + + This method is called by Cmd2HelpFormatter during the argparse help + generation process. + + :param formatter: the active Cmd2HelpFormatter instance + :return: a Rich renderable or None to suppress output + """ + ... + + +# Union of types supported by Cmd2HelpFormatter, including custom cmd2 +# protocols and standard Rich types supported by rich-argparse. +HelpContent: TypeAlias = RenderableType | HelpFormatterRenderable + + class AllowStyle(Enum): """Values for ``cmd2.rich_utils.ALLOW_STYLE``.""" @@ -125,19 +148,11 @@ def console(self, console: "Cmd2RichArgparseConsole") -> None: """Set our console instance.""" self._console = console - def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: - """Provide this help formatter to renderables via the console.""" - if isinstance(console, Cmd2RichArgparseConsole): - old_formatter = console.help_formatter - console.help_formatter = self - try: - yield from super().__rich_console__(console, options) - finally: - console.help_formatter = old_formatter - else: - # Handle rendering on a console type other than Cmd2RichArgparseConsole. - # In this case, we don't set the help_formatter on the console. - yield from super().__rich_console__(console, options) + def add_text(self, text: Any) -> None: + """Override to support HelpFormatterRenderable objects.""" + if isinstance(text, HelpFormatterRenderable): + text = text.__cmd2_argparse_help__(self) + super().add_text(text) def _set_color(self, color: bool, **kwargs: Any) -> None: """Set the color for the help output. @@ -268,25 +283,12 @@ def __init__( self.title = title self.text = text - def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: - """Return a renderable Rich Group object for the class instance. + def __cmd2_argparse_help__(self, formatter: Cmd2HelpFormatter) -> Group: + """Provide a representation of this object for a Cmd2HelpFormatter. - This method formats the title and indents the text to match argparse - group styling, making the object displayable by a Rich console. + :param formatter: the active Cmd2HelpFormatter instance + :return: a Rich Group containing the formatted title and indented text """ - formatter: Cmd2HelpFormatter | None = None - if isinstance(console, Cmd2RichArgparseConsole): - formatter = console.help_formatter - - # This occurs if the console is not a Cmd2RichArgparseConsole or if the - # TextGroup is printed directly instead of as part of an argparse help message. - if formatter is None: - # If console is the wrong type, then have Cmd2HelpFormatter create its own. - formatter = Cmd2HelpFormatter( - prog="", - console=console if isinstance(console, Cmd2RichArgparseConsole) else None, - ) - styled_title = Text( type(formatter).group_name_formatter(f"{self.title}:"), style=formatter.styles["argparse.groups"], @@ -295,7 +297,7 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR # Indent text like an argparse argument group does indented_text = indent(self.text, formatter._indent_increment) - yield Group(styled_title, indented_text) + return Group(styled_title, indented_text) # The application-wide theme. Use get_theme() and set_theme() to access it. @@ -576,9 +578,6 @@ class Cmd2RichArgparseConsole(Cmd2BaseConsole): and highlighting. Because rich-argparse does markup and highlighting without involving the console, disabling these settings does not affect the library's internal functionality. - - Additionally, this console serves as a context carrier for the active help formatter, - allowing renderables to access formatting settings during help generation. """ def __init__(self, *, file: IO[str] | None = None) -> None: @@ -594,17 +593,6 @@ def __init__(self, *, file: IO[str] | None = None) -> None: emoji=False, highlight=False, ) - self._thread_local = threading.local() - - @property - def help_formatter(self) -> "Cmd2HelpFormatter | None": - """Return the active help formatter for this thread.""" - return getattr(self._thread_local, "help_formatter", None) - - @help_formatter.setter - def help_formatter(self, value: "Cmd2HelpFormatter | None") -> None: - """Set the active help formatter for this thread.""" - self._thread_local.help_formatter = value class Cmd2ExceptionConsole(Cmd2BaseConsole): diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index 1401c860c..a3c5b09f6 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -259,34 +259,8 @@ def test_cmd2_base_console_init_never() -> None: assert kwargs["force_interactive"] is None -def test_text_group_direct_cmd2() -> None: - """Print a TextGroup directly using a Cmd2RichArgparseConsole.""" - title = "Notes" - content = "Some text" - text_group = ru.TextGroup(title, content) - console = ru.Cmd2RichArgparseConsole() - with console.capture() as capture: - console.print(text_group) - output = capture.get() - assert "Notes:" in output - assert " Some text" in output - - -def test_text_group_direct_plain() -> None: - """Print a TextGroup directly not using a Cmd2RichArgparseConsole.""" - title = "Notes" - content = "Some text" - text_group = ru.TextGroup(title, content) - console = Console() - with console.capture() as capture: - console.print(text_group) - output = capture.get() - assert "Notes:" in output - assert " Some text" in output - - -def test_text_group_in_parser_cmd2(capsys: pytest.CaptureFixture[str]) -> None: - """Print a TextGroup with argparse using a Cmd2RichArgparseConsole.""" +def test_text_group_in_parser(capsys: pytest.CaptureFixture[str]) -> None: + """Print a TextGroup with argparse.""" parser = Cmd2ArgumentParser(prog="test") parser.epilog = ru.TextGroup("Notes", "Some text") @@ -298,27 +272,6 @@ def test_text_group_in_parser_cmd2(capsys: pytest.CaptureFixture[str]) -> None: assert " Some text" in out -def test_text_group_in_parser_plain(capsys: pytest.CaptureFixture[str]) -> None: - """Print a TextGroup with argparse not using a Cmd2RichArgparseConsole.""" - - class CustomParser(Cmd2ArgumentParser): - def _get_formatter(self, **kwargs: Any) -> ru.Cmd2HelpFormatter: - """Overwrite the formatter's console with a plain one.""" - formatter = super()._get_formatter(**kwargs) - formatter.console = Console() # type: ignore[assignment] - return formatter - - parser = CustomParser(prog="test") - parser.epilog = ru.TextGroup("Notes", "Some text") - - # Render help - parser.print_help() - out, _ = capsys.readouterr() - - assert "Notes:" in out - assert " Some text" in out - - def test_formatter_console() -> None: # self._console = console (inside console.setter) formatter = ru.Cmd2HelpFormatter(prog="test") From bd546074ee715c874a55c77d7a5b6c3ea6b53877 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 19:05:32 -0400 Subject: [PATCH 10/18] Added ability to set the file stream for all argparse output. --- cmd2/argparse_utils.py | 64 +++++++++++++++++++++++++----------------- cmd2/rich_utils.py | 8 +++++- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index e1154356e..29f8f21fa 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -225,6 +225,7 @@ def get_choices(self) -> Choices: """ import argparse +import contextlib import re import sys import threading @@ -232,6 +233,7 @@ def get_choices(self) -> Choices: from collections.abc import ( Callable, Iterable, + Iterator, Sequence, ) from dataclasses import dataclass @@ -551,8 +553,12 @@ def _SubParsersAction_remove_parser( # noqa: N802 class _ParserThreadLocals(threading.local): """Thread-local storage used by Cmd2ArgumentParser to manage execution context.""" - # If set, print_help() and print_usage() will default to use this instead of sys.stdout. - custom_stdout: IO[str] | None = None + # 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 + # _set_output_file() context manager. + current_output_file: IO[str] | None = None class Cmd2ArgumentParser(argparse.ArgumentParser): @@ -561,6 +567,16 @@ class Cmd2ArgumentParser(argparse.ArgumentParser): # Thread-local storage shared by all parser instances (including subparsers) _thread_locals: ClassVar[_ParserThreadLocals] = _ParserThreadLocals() + @contextlib.contextmanager + def _set_output_file(self, file: IO[str] | None) -> Iterator[None]: + """Context manager to temporarily set the current output file.""" + 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, @@ -635,12 +651,8 @@ def parse_args_custom_stdout( :param namespace: optional namespace to populate. If None, a new Namespace is created. :return: the parsed namespace """ - previous = self._thread_locals.custom_stdout - try: - self._thread_locals.custom_stdout = stdout + with self._set_output_file(stdout): return self.parse_args(args, namespace) - finally: - self._thread_locals.custom_stdout = previous def parse_known_args_custom_stdout( self, @@ -658,24 +670,24 @@ def parse_known_args_custom_stdout( :param namespace: optional namespace to populate. If None, a new Namespace is created. :return: a tuple containing the parsed namespace and a list of unknown arguments """ - previous = self._thread_locals.custom_stdout - try: - self._thread_locals.custom_stdout = stdout + with self._set_output_file(stdout): return self.parse_known_args(args, namespace) - finally: - self._thread_locals.custom_stdout = previous def print_usage(self, file: IO[str] | None = None) -> None: # type:ignore[override] - """Override to support writing to a custom stream.""" + """Override to ensure the formatter is aware of the target file.""" if file is None: - file = self._thread_locals.custom_stdout - super().print_usage(file) + file = self._thread_locals.current_output_file + + with self._set_output_file(file): + super().print_usage(file) def print_help(self, file: IO[str] | None = None) -> None: # type:ignore[override] - """Override to support writing to a custom stream.""" + """Override to ensure the formatter is aware of the target file.""" if file is None: - file = self._thread_locals.custom_stdout - super().print_help(file) + file = self._thread_locals.current_output_file + + with self._set_output_file(file): + super().print_help(file) def get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentParser]": """Get the _SubParsersAction for this parser if it exists. @@ -879,19 +891,21 @@ def error(self, message: str) -> NoReturn: else: formatted_message += "\n " + line - self.print_usage(sys.stderr) + with self._set_output_file(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) -> Cmd2HelpFormatter: """Override with customizations for Cmd2HelpFormatter.""" - return cast(Cmd2HelpFormatter, super()._get_formatter()) + return self.formatter_class(prog=self.prog, file=self._thread_locals.current_output_file) def format_help(self) -> str: """Override to add a newline.""" diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 55c128c64..106901162 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -127,10 +127,15 @@ def __init__( max_help_position: int = 24, width: int | None = None, *, + file: IO[str] | None = None, console: "Cmd2RichArgparseConsole | None" = None, **kwargs: Any, ) -> None: """Initialize Cmd2HelpFormatter.""" + if file is not None and console is not None: + raise TypeError("cannot provide both 'file' and 'console' arguments") + + self._file = file super().__init__(prog, indent_increment, max_help_position, width, console=console, **kwargs) # Recast to assist type checkers @@ -140,12 +145,13 @@ def __init__( def console(self) -> "Cmd2RichArgparseConsole": """Return our console instance.""" if self._console is None: - self._console = Cmd2RichArgparseConsole() + self._console = Cmd2RichArgparseConsole(file=self._file) return self._console @console.setter def console(self, console: "Cmd2RichArgparseConsole") -> None: """Set our console instance.""" + self._file = None self._console = console def add_text(self, text: Any) -> None: From a61085c65b1246daf822db50badcde95e1a039f0 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 20:10:39 -0400 Subject: [PATCH 11/18] Added unit tests. --- tests/test_argparse_utils.py | 158 ++++++++++++++++++++++++++++++++++- tests/test_rich_utils.py | 39 ++++++++- 2 files changed, 194 insertions(+), 3 deletions(-) diff --git a/tests/test_argparse_utils.py b/tests/test_argparse_utils.py index 3be2263f4..f418f91df 100644 --- a/tests/test_argparse_utils.py +++ b/tests/test_argparse_utils.py @@ -4,6 +4,7 @@ import sys import pytest +from pytest_mock import MockerFixture import cmd2 from cmd2 import ( @@ -12,12 +13,17 @@ argparse_utils, constants, ) +from cmd2 import rich_utils as ru +from cmd2 import string_utils as su from cmd2.argparse_utils import ( build_range_error, register_argparse_argument_parameter, ) -from .conftest import run_cmd +from .conftest import ( + run_cmd, + with_ansi_style, +) class ApCustomTestApp(cmd2.Cmd): @@ -612,3 +618,153 @@ def test_update_prog() -> None: if isinstance(action, argparse._SubParsersAction): assert action.choices["s1"].prog == sub1.prog assert action.choices["alias1"].prog == sub1.prog + + +def test_parser_set_output_file_context_manager() -> None: + """Test that _set_output_file correctly shadows and restores current_output_file.""" + import io + + parser = Cmd2ArgumentParser() + buf1 = io.StringIO() + buf2 = io.StringIO() + + assert parser._thread_locals.current_output_file is None + + with parser._set_output_file(buf1): + assert parser._thread_locals.current_output_file is buf1 + with parser._set_output_file(buf2): # type: ignore[unreachable] + assert parser._thread_locals.current_output_file is buf2 + assert parser._thread_locals.current_output_file is buf1 + + assert parser._thread_locals.current_output_file is None # type: ignore[unreachable] + + +def test_parser_print_help_redirection(mocker: MockerFixture) -> None: + """Test that print_help(file=my_file) correctly sets the context for the formatter.""" + import io + + parser = Cmd2ArgumentParser(prog="test") + buf = io.StringIO() + + # We want to verify that when print_help(buf) is called, + # _get_formatter() is called and its result is initialized with buf. + # We can mock Cmd2HelpFormatter to see what it's initialized with. + mock_formatter_class = mocker.patch("cmd2.argparse_utils.Cmd2HelpFormatter", autospec=True) + parser.formatter_class = mock_formatter_class + + # argparse print_help() calls format_help() which calls formatter.format_help() + # It expects a string return value. + mock_formatter_class.return_value.format_help.return_value = "Help Text" + + parser.print_help(buf) + + # Verify Cmd2HelpFormatter was instantiated with file=buf + mock_formatter_class.assert_called_with(prog="test", file=buf) + + +def test_parser_error_redirection(mocker: MockerFixture) -> None: + """Test that error() shadows to sys.stderr and uses it for styling.""" + from cmd2 import rich_utils + + parser = Cmd2ArgumentParser(prog="test") + + # Mock exit to prevent actual exit + mocker.patch.object(parser, "exit") + + # Mock print_usage to prevent actual printing + mocker.patch.object(parser, "print_usage") + + # Mock _get_formatter to return a formatter with a mocked console + mock_formatter = mocker.Mock(spec=rich_utils.Cmd2HelpFormatter) + mock_console = mocker.MagicMock(spec=rich_utils.Cmd2RichArgparseConsole) + mock_formatter.console = mock_console + + mocker.patch.object(parser, "_get_formatter", return_value=mock_formatter) + + # Mock capture context manager + mock_capture = mocker.MagicMock() + mock_console.capture.return_value.__enter__.return_value = mock_capture + mock_capture.get.return_value = "Styled Error" + + parser.error("some message") + + # Verify that during error processing, current_output_file was shadowed to sys.stderr + # Check that print_usage was called with sys.stderr + parser.print_usage.assert_called_once_with(sys.stderr) # type: ignore[unreachable] + + # Check that formatter's console was used to print the error + mock_console.print.assert_called_once() + args, kwargs = mock_console.print.call_args + assert "Error: some message" in args[0] + assert kwargs["style"] == argparse_utils.Cmd2Style.ERROR + + +def test_parser_custom_stdout_methods(mocker: MockerFixture) -> None: + """Test parse_args_custom_stdout() and parse_known_args_custom_stdout().""" + import io + + parser = Cmd2ArgumentParser() + buf = io.StringIO() + + # Mock parse_args and parse_known_args + mock_parse = mocker.patch.object(parser, "parse_args") + mock_parse_known = mocker.patch.object(parser, "parse_known_args") + + parser.parse_args_custom_stdout(buf, ["arg"]) + assert parser._thread_locals.current_output_file is None + mock_parse.assert_called_once_with(["arg"], None) + + parser.parse_known_args_custom_stdout(buf, ["arg"]) + assert parser._thread_locals.current_output_file is None + mock_parse_known.assert_called_once_with(["arg"], None) + + +def test_parser_implicit_output_redirection(mocker: MockerFixture) -> None: + """Test that print_help() and print_usage() use thread-local context when no file is provided.""" + import io + + parser = Cmd2ArgumentParser(prog="test") + buf = io.StringIO() + + mock_formatter_class = mocker.patch("cmd2.argparse_utils.Cmd2HelpFormatter", autospec=True) + parser.formatter_class = mock_formatter_class + mock_formatter_class.return_value.format_help.return_value = "Help/Usage Text" + + # Shadow the output file + with parser._set_output_file(buf): + # Call print_help without a file argument + parser.print_help() + # Verify Cmd2HelpFormatter was instantiated with file=buf (from thread-local) + mock_formatter_class.assert_called_with(prog="test", file=buf) + + mock_formatter_class.reset_mock() + + # Call print_usage without a file argument + parser.print_usage() + # Verify Cmd2HelpFormatter was instantiated with file=buf (from thread-local) + mock_formatter_class.assert_called_with(prog="test", file=buf) + + +@with_ansi_style(ru.AllowStyle.NEVER) +def test_argparse_output_capture(base_app: cmd2.Cmd) -> None: + """Test that both help code paths capture the same output.""" + + # First generate unstyled output + unstyled_help_out, help_err = run_cmd(base_app, "help alias") + unstyled_flag_out, flag_err = run_cmd(base_app, "alias -h") + assert unstyled_help_out == unstyled_flag_out + assert not help_err + assert not flag_err + + # Now generate styled output + ru.ALLOW_STYLE = ru.AllowStyle.ALWAYS + + styled_help_out, help_err = run_cmd(base_app, "help alias") + styled_flag_out, flag_err = run_cmd(base_app, "alias -h") + assert styled_help_out == styled_flag_out + assert not help_err + assert not flag_err + + # Prove that the console style settings were used + assert styled_help_out != unstyled_help_out + assert su.strip_style("\n".join(styled_help_out)) == "\n".join(unstyled_help_out) diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index a3c5b09f6..20229630f 100644 --- a/tests/test_rich_utils.py +++ b/tests/test_rich_utils.py @@ -272,14 +272,49 @@ def test_text_group_in_parser(capsys: pytest.CaptureFixture[str]) -> None: assert " Some text" in out -def test_formatter_console() -> None: - # self._console = console (inside console.setter) +def test_formatter_init_mutually_exclusive() -> None: + """Test that providing both file and console to Cmd2HelpFormatter raises TypeError.""" + with pytest.raises(TypeError, match="cannot provide both 'file' and 'console' arguments"): + ru.Cmd2HelpFormatter(prog="test", file=sys.stdout, console=ru.Cmd2RichArgparseConsole()) + + +def test_formatter_lazy_console_with_file() -> None: + """Test that Cmd2HelpFormatter lazily creates a console bound to the provided file.""" + import io + + buf = io.StringIO() + formatter = ru.Cmd2HelpFormatter(prog="test", file=buf) + + # Console should not be created yet + assert formatter._console is None + + # Accessing console should create it bound to buf + console = formatter.console + assert isinstance(console, ru.Cmd2RichArgparseConsole) + assert console.file is buf + + +def test_formatter_console_setter() -> None: formatter = ru.Cmd2HelpFormatter(prog="test") new_console = ru.Cmd2RichArgparseConsole() formatter.console = new_console assert formatter._console is new_console +def test_formatter_console_setter_clears_file() -> None: + """Test that setting console explicitly clears the internal file stream.""" + import io + + buf = io.StringIO() + formatter = ru.Cmd2HelpFormatter(prog="test", file=buf) + + new_console = ru.Cmd2RichArgparseConsole() + formatter.console = new_console + + assert formatter._console is new_console + assert formatter._file is None + + @pytest.mark.skipif( sys.version_info < (3, 14), reason="Argparse didn't support color until Python 3.14", From b33c80da28227d3d02fa0eea0d22fe1dbb2fe291 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 20:16:16 -0400 Subject: [PATCH 12/18] Updated change log. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd85dbfc7..fdb5a16bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,10 @@ 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. - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These @@ -139,6 +143,8 @@ 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. ## 3.5.1 (April 24, 2026) From 15d75d7e4a7ec27c6930efd8d1cc9174fc045915 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 20:25:01 -0400 Subject: [PATCH 13/18] Added test. --- tests/pyscript/test_print.py | 5 +++++ tests/test_run_pyscript.py | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/pyscript/test_print.py b/tests/pyscript/test_print.py index 108cab81a..26b950d4a 100644 --- a/tests/pyscript/test_print.py +++ b/tests/pyscript/test_print.py @@ -5,6 +5,8 @@ import sys +from rich.text import Text + # Test multiple objects and sep print("hello", "world", sep="-") @@ -16,6 +18,9 @@ print(1, 2, 3, sep=":", end=".") print() # to get a newline +# Test printing a Rich object +text = Text("I am Rich Text", style="blue") +print(text) # Test file=sys.stdout print("this goes to sys.stdout", file=sys.stdout) diff --git a/tests/test_run_pyscript.py b/tests/test_run_pyscript.py index a2072f3f5..12ada4398 100644 --- a/tests/test_run_pyscript.py +++ b/tests/test_run_pyscript.py @@ -7,11 +7,13 @@ import pytest +from cmd2 import rich_utils as ru from cmd2.string_utils import quote from .conftest import ( odd_file_names, run_cmd, + with_ansi_style, ) @@ -209,6 +211,7 @@ def test_run_pyscript_app_echo(base_app, request) -> None: assert out[0] == "Usage: edit [-h] [file_path]" +@with_ansi_style(ru.AllowStyle.ALWAYS) def test_run_pyscript_print(base_app, request, capsys) -> None: """Verify that py_print() (the print() replacement in pyscripts) works correctly.""" test_dir = os.path.dirname(request.module.__file__) @@ -216,10 +219,11 @@ def test_run_pyscript_print(base_app, request, capsys) -> None: out, err = run_cmd(base_app, f"run_pyscript {python_script}") # Verify contents of self.stdout - assert len(out) == 3 + assert len(out) == 4 assert out[0] == "hello-world" assert out[1] == "no newline here" assert out[2] == "1:2:3." + assert out[3] == "\x1b[34mI am Rich Text\x1b[0m" # Verify contents of sys.stderr assert len(err) == 1 @@ -245,9 +249,11 @@ def test_run_pyscript_print_redirection(base_app, request, tmp_path, capsys) -> content = f.read() # Look for everything written to self.stdout + assert len(content.splitlines()) == 4 assert "hello-world\n" in content assert "no newline here\n" in content assert "1:2:3.\n" in content + assert "I am Rich Text\n" in content # Nothing else should have been redirected assert "this goes to sys.stdout" not in content From 6c3accd0b25d1039fca33718d07a51a4fda2209a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 20:55:27 -0400 Subject: [PATCH 14/18] Fixed test on Python 3.15. --- cmd2/argparse_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index 29f8f21fa..d6218e54e 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -903,7 +903,7 @@ def error(self, message: str) -> NoReturn: self.exit(2, f"{formatted_message}\n") - def _get_formatter(self) -> Cmd2HelpFormatter: + def _get_formatter(self, **_kwargs: Any) -> Cmd2HelpFormatter: """Override with customizations for Cmd2HelpFormatter.""" return self.formatter_class(prog=self.prog, file=self._thread_locals.current_output_file) From 697a335e11f4f7eab0d9689fdd0f99efa666dd5e Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 21:37:57 -0400 Subject: [PATCH 15/18] Removed parse_args wrappers and made context manager public. --- CHANGELOG.md | 3 ++ cmd2/argparse_utils.py | 56 ++++++++---------------------------- cmd2/decorators.py | 19 ++++-------- tests/test_argparse_utils.py | 30 ++++--------------- 4 files changed, 25 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb5a16bf..3b345df4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,6 +145,9 @@ prompt is displayed. - 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. + This is helpful for redirecting output from functions like `parse_args()`, which default to + `sys.stdout` and lack a `file` argument. ## 3.5.1 (April 24, 2026) diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index d6218e54e..f97448dcc 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -557,7 +557,7 @@ class _ParserThreadLocals(threading.local): # 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 - # _set_output_file() context manager. + # output_to() context manager. current_output_file: IO[str] | None = None @@ -568,8 +568,14 @@ class Cmd2ArgumentParser(argparse.ArgumentParser): _thread_locals: ClassVar[_ParserThreadLocals] = _ParserThreadLocals() @contextlib.contextmanager - def _set_output_file(self, file: IO[str] | None) -> Iterator[None]: - """Context manager to temporarily set the current output file.""" + 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 redirecting output from 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: @@ -635,50 +641,12 @@ def __init__( self.description: HelpContent | None # type: ignore[assignment] self.epilog: HelpContent | None # type: ignore[assignment] - def parse_args_custom_stdout( - self, - stdout: IO[str], - args: list[str] | None = None, - namespace: argparse.Namespace | None = None, - ) -> argparse.Namespace: - """Parse arguments while directing help and usage output to a custom stdout stream. - - This method is particularly useful when you need to capture help output without - globally redirecting sys.stdout. - - :param stdout: the stream to use for help and usage output - :param args: optional list of arguments to parse. If None, uses sys.argv[1:]. - :param namespace: optional namespace to populate. If None, a new Namespace is created. - :return: the parsed namespace - """ - with self._set_output_file(stdout): - return self.parse_args(args, namespace) - - def parse_known_args_custom_stdout( - self, - stdout: IO[str], - args: list[str] | None = None, - namespace: argparse.Namespace | None = None, - ) -> tuple[argparse.Namespace, list[str]]: - """Parse known arguments while directing help and usage output to a custom stdout stream. - - This method is particularly useful when you need to capture help output without - globally redirecting sys.stdout. - - :param stdout: the stream to use for help and usage output - :param args: optional list of arguments to parse. If None, uses sys.argv[1:]. - :param namespace: optional namespace to populate. If None, a new Namespace is created. - :return: a tuple containing the parsed namespace and a list of unknown arguments - """ - with self._set_output_file(stdout): - return self.parse_known_args(args, namespace) - 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._set_output_file(file): + with self.output_to(file): super().print_usage(file) def print_help(self, file: IO[str] | None = None) -> None: # type:ignore[override] @@ -686,7 +654,7 @@ def print_help(self, file: IO[str] | None = None) -> None: # type:ignore[overri if file is None: file = self._thread_locals.current_output_file - with self._set_output_file(file): + with self.output_to(file): super().print_help(file) def get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentParser]": @@ -891,7 +859,7 @@ def error(self, message: str) -> NoReturn: else: formatted_message += "\n " + line - with self._set_output_file(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. diff --git a/cmd2/decorators.py b/cmd2/decorators.py index aa9e239ce..be42cc230 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -303,20 +303,11 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: try: parsing_results: tuple[argparse.Namespace] | tuple[argparse.Namespace, list[str]] - if with_unknown_args: - parsing_results = arg_parser.parse_known_args_custom_stdout( - cmd2_app.stdout, - command_arg_list, - initial_namespace, - ) - else: - parsing_results = ( - arg_parser.parse_args_custom_stdout( - cmd2_app.stdout, - command_arg_list, - initial_namespace, - ), - ) + with arg_parser.output_to(cmd2_app.stdout): + if with_unknown_args: + parsing_results = arg_parser.parse_known_args(command_arg_list, initial_namespace) + else: + parsing_results = (arg_parser.parse_args(command_arg_list, initial_namespace),) except SystemExit as exc: raise Cmd2ArgparseError from exc diff --git a/tests/test_argparse_utils.py b/tests/test_argparse_utils.py index f418f91df..61c2381e4 100644 --- a/tests/test_argparse_utils.py +++ b/tests/test_argparse_utils.py @@ -620,8 +620,8 @@ def test_update_prog() -> None: assert action.choices["alias1"].prog == sub1.prog -def test_parser_set_output_file_context_manager() -> None: - """Test that _set_output_file correctly shadows and restores current_output_file.""" +def test_parser_output_to_context_manager() -> None: + """Test that output_to() correctly shadows and restores current_output_file.""" import io parser = Cmd2ArgumentParser() @@ -630,9 +630,9 @@ def test_parser_set_output_file_context_manager() -> None: assert parser._thread_locals.current_output_file is None - with parser._set_output_file(buf1): + with parser.output_to(buf1): assert parser._thread_locals.current_output_file is buf1 - with parser._set_output_file(buf2): # type: ignore[unreachable] + with parser.output_to(buf2): # type: ignore[unreachable] assert parser._thread_locals.current_output_file is buf2 assert parser._thread_locals.current_output_file is buf1 @@ -699,26 +699,6 @@ def test_parser_error_redirection(mocker: MockerFixture) -> None: assert kwargs["style"] == argparse_utils.Cmd2Style.ERROR -def test_parser_custom_stdout_methods(mocker: MockerFixture) -> None: - """Test parse_args_custom_stdout() and parse_known_args_custom_stdout().""" - import io - - parser = Cmd2ArgumentParser() - buf = io.StringIO() - - # Mock parse_args and parse_known_args - mock_parse = mocker.patch.object(parser, "parse_args") - mock_parse_known = mocker.patch.object(parser, "parse_known_args") - - parser.parse_args_custom_stdout(buf, ["arg"]) - assert parser._thread_locals.current_output_file is None - mock_parse.assert_called_once_with(["arg"], None) - - parser.parse_known_args_custom_stdout(buf, ["arg"]) - assert parser._thread_locals.current_output_file is None - mock_parse_known.assert_called_once_with(["arg"], None) - - def test_parser_implicit_output_redirection(mocker: MockerFixture) -> None: """Test that print_help() and print_usage() use thread-local context when no file is provided.""" import io @@ -731,7 +711,7 @@ def test_parser_implicit_output_redirection(mocker: MockerFixture) -> None: mock_formatter_class.return_value.format_help.return_value = "Help/Usage Text" # Shadow the output file - with parser._set_output_file(buf): + with parser.output_to(buf): # Call print_help without a file argument parser.print_help() # Verify Cmd2HelpFormatter was instantiated with file=buf (from thread-local) From a144feb3266c722699289e1956cd8fa29b58d250 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 21:50:20 -0400 Subject: [PATCH 16/18] Updated comments. --- CHANGELOG.md | 2 +- cmd2/argparse_utils.py | 2 +- tests/test_argparse_utils.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b345df4c..d7632f91c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,7 +146,7 @@ prompt is displayed. - 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. - This is helpful for redirecting output from functions like `parse_args()`, which default to + 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) diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index f97448dcc..cf06825e0 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -571,7 +571,7 @@ class Cmd2ArgumentParser(argparse.ArgumentParser): 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 redirecting output from functions like `parse_args()`, which + 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 diff --git a/tests/test_argparse_utils.py b/tests/test_argparse_utils.py index 61c2381e4..a2315773a 100644 --- a/tests/test_argparse_utils.py +++ b/tests/test_argparse_utils.py @@ -639,7 +639,7 @@ def test_parser_output_to_context_manager() -> None: assert parser._thread_locals.current_output_file is None # type: ignore[unreachable] -def test_parser_print_help_redirection(mocker: MockerFixture) -> None: +def test_parser_print_help_output_to(mocker: MockerFixture) -> None: """Test that print_help(file=my_file) correctly sets the context for the formatter.""" import io @@ -662,7 +662,7 @@ def test_parser_print_help_redirection(mocker: MockerFixture) -> None: mock_formatter_class.assert_called_with(prog="test", file=buf) -def test_parser_error_redirection(mocker: MockerFixture) -> None: +def test_parser_error_output_to(mocker: MockerFixture) -> None: """Test that error() shadows to sys.stderr and uses it for styling.""" from cmd2 import rich_utils @@ -699,7 +699,7 @@ def test_parser_error_redirection(mocker: MockerFixture) -> None: assert kwargs["style"] == argparse_utils.Cmd2Style.ERROR -def test_parser_implicit_output_redirection(mocker: MockerFixture) -> None: +def test_parser_implicit_output_to(mocker: MockerFixture) -> None: """Test that print_help() and print_usage() use thread-local context when no file is provided.""" import io From 8870324a9609a0cd7ef18fd60f3bd68e4e839fbf Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 21:52:22 -0400 Subject: [PATCH 17/18] Updated change log. --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7632f91c..5df1c6393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,9 +145,9 @@ prompt is displayed. - 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. - This is helpful for directing output for functions like `parse_args()`, which default to - `sys.stdout` and lack a `file` argument. + - 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) From 770b819e7b364cdf83a5836c2d907de3299ba6c4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 28 Apr 2026 22:04:15 -0400 Subject: [PATCH 18/18] Updated change log. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5df1c6393..f82b0a206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,8 @@ prompt is displayed. `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