diff --git a/CHANGELOG.md b/CHANGELOG.md index e640542ad..f82b0a206 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: @@ -101,6 +103,12 @@ prompt is displayed. greater flexibility in passing keyword arguments to `console.print()` calls. - Removed `always_show_hint` settable as it provided a poor user experience with `prompt-toolkit` + - `cmd2` redirection only captures output directed to `self.stdout` (e.g., via + `self.poutput()`). Standard `print()` calls write directly to `sys.stdout` and are not + captured. However, `print()` calls within `pyscripts` and the interactive Python shell are + treated as command output and sent to `self.stdout`, allowing them to be captured. + - Verbose help table descriptions are no longer generated from help function output. The system + now relies exclusively on command function docstrings. - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These @@ -137,6 +145,11 @@ prompt is displayed. full type hints and IDE autocompletion for `self._cmd` without needing to override and cast the property. - Added `traceback_kwargs` attribute to allow customization of Rich-based tracebacks. + - The `print()` function available in a `pyscript` writes to `self.stdout` and respects the + `allow_style` setting. It also supports printing `Rich` objects. + - Added `Cmd2ArgumentParser.output_to()` context manager to temporarily set the output stream + during `argparse` operations. This is helpful for directing output for functions like + `parse_args()`, which default to `sys.stdout` and lack a `file` argument. ## 3.5.1 (April 24, 2026) 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..cf06825e0 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -225,27 +225,35 @@ def get_choices(self) -> Choices: """ import argparse +import contextlib import re import sys +import threading from argparse import ArgumentError from collections.abc import ( Callable, Iterable, + Iterator, Sequence, ) +from dataclasses import dataclass from typing import ( + IO, TYPE_CHECKING, Any, + ClassVar, NoReturn, cast, ) -from rich.console import RenderableType from rich.table import Column from . import constants from .completion import CompletionItem -from .rich_utils import Cmd2HelpFormatter +from .rich_utils import ( + Cmd2HelpFormatter, + HelpContent, +) from .styles import Cmd2Style from .types import ( CmdOrSetT, @@ -541,15 +549,46 @@ def _SubParsersAction_remove_parser( # noqa: N802 argparse._SubParsersAction.remove_parser = _SubParsersAction_remove_parser # type: ignore[attr-defined] +@dataclass +class _ParserThreadLocals(threading.local): + """Thread-local storage used by Cmd2ArgumentParser to manage execution context.""" + + # The active output stream for help, usage, and errors. Since argparse does not + # pass the destination stream to the formatter factory, this transient value + # provides the context needed to synchronize Rich's rendering with the specific + # capabilities of the destination file descriptor. It is managed via the + # output_to() context manager. + current_output_file: IO[str] | None = None + + class Cmd2ArgumentParser(argparse.ArgumentParser): """Custom ArgumentParser class that improves error and help output.""" + # Thread-local storage shared by all parser instances (including subparsers) + _thread_locals: ClassVar[_ParserThreadLocals] = _ParserThreadLocals() + + @contextlib.contextmanager + def output_to(self, file: IO[str] | None) -> Iterator[None]: + """Context manager to temporarily set the output stream during argparse operations. + + This is helpful for directing output for functions like `parse_args()`, which + default to `sys.stdout` and lack a `file` argument. + + :param file: the file stream to use for output + """ + previous = self._thread_locals.current_output_file + self._thread_locals.current_output_file = file + try: + yield + finally: + self._thread_locals.current_output_file = previous + def __init__( self, prog: str | None = None, usage: str | None = None, - description: RenderableType | None = None, - epilog: RenderableType | None = None, + description: HelpContent | None = None, + epilog: HelpContent | None = None, parents: Sequence[argparse.ArgumentParser] = (), formatter_class: type[Cmd2HelpFormatter] = Cmd2HelpFormatter, prefix_chars: str = "-", @@ -599,8 +638,24 @@ def __init__( # To assist type checkers, recast these to reflect our usage of rich-argparse. self.formatter_class: type[Cmd2HelpFormatter] - self.description: RenderableType | None # type: ignore[assignment] - self.epilog: RenderableType | None # type: ignore[assignment] + self.description: HelpContent | None # type: ignore[assignment] + self.epilog: HelpContent | None # type: ignore[assignment] + + def print_usage(self, file: IO[str] | None = None) -> None: # type:ignore[override] + """Override to ensure the formatter is aware of the target file.""" + if file is None: + file = self._thread_locals.current_output_file + + with self.output_to(file): + super().print_usage(file) + + def print_help(self, file: IO[str] | None = None) -> None: # type:ignore[override] + """Override to ensure the formatter is aware of the target file.""" + if file is None: + file = self._thread_locals.current_output_file + + with self.output_to(file): + super().print_help(file) def get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentParser]": """Get the _SubParsersAction for this parser if it exists. @@ -804,19 +859,21 @@ def error(self, message: str) -> NoReturn: else: formatted_message += "\n " + line - self.print_usage(sys.stderr) + with self.output_to(sys.stderr): + self.print_usage(sys.stderr) - # Use console to add style since it will respect ALLOW_STYLE's value - console = self._get_formatter().console - with console.capture() as capture: - console.print(formatted_message, style=Cmd2Style.ERROR) - formatted_message = f"{capture.get()}" + # Use console to add style since it will respect ALLOW_STYLE's value. + # Now _get_formatter() will return a formatter bound to stderr. + console = self._get_formatter().console + with console.capture() as capture: + console.print(formatted_message, style=Cmd2Style.ERROR) + formatted_message = f"{capture.get()}" self.exit(2, f"{formatted_message}\n") - def _get_formatter(self, **kwargs: Any) -> Cmd2HelpFormatter: + def _get_formatter(self, **_kwargs: Any) -> Cmd2HelpFormatter: """Override with customizations for Cmd2HelpFormatter.""" - return cast(Cmd2HelpFormatter, super()._get_formatter(**kwargs)) + return self.formatter_class(prog=self.prog, file=self._thread_locals.current_output_file) def format_help(self) -> str: """Override to add a newline.""" diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e9cbb8f48..066885c90 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -15,8 +15,10 @@ - Redirection to file or paste buffer (clipboard) with > or >> - Bash-style ``select`` available -Note, if self.stdout is different than sys.stdout, then redirection with > and | -will only work if `self.poutput()` is used in place of `print`. +Note: cmd2 redirection only captures output directed to self.stdout (e.g., via self.poutput()). +Standard print() calls write directly to sys.stdout and are not captured. However, print() calls +within pyscripts and the interactive Python shell are treated as command output and sent to +self.stdout, allowing them to be captured. GitHub: https://github.com/python-cmd2/cmd2 Documentation: https://cmd2.readthedocs.io/ @@ -318,12 +320,12 @@ class AsyncAlert: timestamp: float = field(default_factory=time.monotonic, init=False) +@dataclass class _ConsoleCache(threading.local): """Thread-local storage for cached Rich consoles used by core print methods.""" - def __init__(self) -> None: - self.stdout: Cmd2BaseConsole | None = None - self.stderr: Cmd2BaseConsole | None = None + stdout: Cmd2BaseConsole | None = None + stderr: Cmd2BaseConsole | None = None class Cmd: @@ -3191,13 +3193,8 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: """ import subprocess - # Only redirect sys.stdout if it's the same as self.stdout - stdouts_match = self.stdout == sys.stdout - # Initialize the redirection saved state - redir_saved_state = utils.RedirectionSavedState( - self.stdout, stdouts_match, self._cur_pipe_proc_reader, self._redirecting - ) + redir_saved_state = utils.RedirectionSavedState(self.stdout, self._cur_pipe_proc_reader, self._redirecting) # The ProcReader for this command cmd_pipe_proc_reader: utils.ProcReader | None = None @@ -3254,8 +3251,6 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: cmd_pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr) self.stdout = new_stdout - if stdouts_match: - sys.stdout = self.stdout elif statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND): if statement.redirect_to: @@ -3271,8 +3266,6 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: redir_saved_state.redirecting = True self.stdout = new_stdout - if stdouts_match: - sys.stdout = self.stdout else: # Redirecting to a paste buffer @@ -3292,8 +3285,6 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: redir_saved_state.redirecting = True self.stdout = new_stdout - if stdouts_match: - sys.stdout = self.stdout if statement.redirector == constants.REDIRECTION_APPEND: self.stdout.write(current_paste_buffer) @@ -3324,10 +3315,8 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec # Close the file or pipe that stdout was redirected to self.stdout.close() - # Restore the stdout values + # Restore self.stdout self.stdout = cast(TextIO, saved_redir_state.saved_self_stdout) - if saved_redir_state.stdouts_match: - sys.stdout = self.stdout # Check if we need to wait for the process being piped to if self._cur_pipe_proc_reader is not None: @@ -4395,8 +4384,6 @@ def print_topics(self, header: str, cmds: Sequence[str] | None, cmdlen: int, max def _print_documented_command_topics(self, header: str, commands: Sequence[str], verbose: bool) -> None: """Print topics which are documented commands, switching between verbose or traditional output.""" - import io - if not commands: return @@ -4410,34 +4397,11 @@ def _print_documented_command_topics(self, header: str, commands: Sequence[str], ) # Try to get the documentation string for each command - topics = self.get_help_topics() for command in commands: if (command_func := self.get_command_func(command)) is None: continue - doc: str | None - - # Non-argparse commands can have help_functions for their documentation - if command in topics: - help_func = getattr(self, constants.HELP_FUNC_PREFIX + command) - result = io.StringIO() - - # try to redirect system stdout - with contextlib.redirect_stdout(result): - # save our internal stdout - stdout_orig = self.stdout - try: - # redirect our internal stdout - self.stdout = cast(TextIO, result) - help_func() - finally: - with self.sigint_protection: - # restore internal stdout - self.stdout = stdout_orig - doc = result.getvalue() - - else: - doc = command_func.__doc__ + doc = command_func.__doc__ # Attempt to locate the first documentation block cmd_desc = strip_doc_annotations(doc) if doc else "" @@ -4903,8 +4867,38 @@ def _run_python(self, *, pyscript: str | None = None) -> bool | None: """ self.last_result = False + # Replace print() in the embedded Python environment. Standard print() writes to + # sys.stdout, which bypasses cmd2 redirection (e.g., run_pyscript script.py > out.txt). + # Using self.print_to(self.stdout) ensures output is capturable and respects 'allow_style' + # without requiring the user to have access to 'self'. + def py_print( + *objects: Any, + sep: str = " ", + end: str = "\n", + file: IO[str] | None = None, + flush: bool = False, # noqa: ARG001 + ) -> None: + """Print objects to a stream, defaulting to self.stdout. + + This is used as the print() function within interactive Python shells and pyscripts. + It wraps cmd2's print_to() method to honor output redirection and style settings. + + :param objects: objects to print (including Rich objects) + :param sep: string to write between printed text. Defaults to " ". + :param end: string to write at end of printed text. Defaults to a newline. + :param file: file stream being written to. Defaults to self.stdout. + :param flush: ignored as Rich-based output is flushed automatically. Defaults to False. + """ + if file is None: + file = self.stdout + + self.print_to(file, *objects, sep=sep, end=end) + + # Replace quit/exit in the embedded Python environment. Standard sys.exit() + # would kill the entire application process; raising EmbeddedConsoleExit + # allows the interpreter to return gracefully to the cmd2 prompt. def py_quit() -> None: - """Exit an interactive Python environment, callable from the interactive Python console.""" + """Exit an interactive Python shell or pyscript.""" raise EmbeddedConsoleExit from .py_bridge import PyBridge @@ -4927,6 +4921,7 @@ def py_quit() -> None: # it's OK for py_locals to contain objects which are editable in a pyscript. local_vars = self.py_locals.copy() local_vars[self.py_bridge_name] = py_bridge + local_vars["print"] = py_print local_vars["quit"] = py_quit local_vars["exit"] = py_quit @@ -5086,19 +5081,13 @@ def do_ipy(self, _: argparse.Namespace) -> bool | None: # pragma: no cover except NameError: from IPython import start_ipython - from IPython.terminal.interactiveshell import ( - TerminalInteractiveShell, - ) - from IPython.terminal.ipapp import ( - TerminalIPythonApp, - ) + from IPython.terminal.interactiveshell import TerminalInteractiveShell + from IPython.terminal.ipapp import TerminalIPythonApp except ImportError: self.perror("IPython package is not installed") return None - from .py_bridge import ( - PyBridge, - ) + from .py_bridge import PyBridge if self.in_pyscript(): self.perror("Recursively entering interactive Python shells is not allowed") diff --git a/cmd2/decorators.py b/cmd2/decorators.py index d743b7b0b..be42cc230 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -303,10 +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(command_arg_list, initial_namespace) - else: - parsing_results = (arg_parser.parse_args(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/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/cmd2/rich_utils.py b/cmd2/rich_utils.py index 704e603aa..106901162 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``.""" @@ -104,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 @@ -117,27 +145,20 @@ 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 __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 +289,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 +303,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 +584,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 +599,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/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/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/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/pyscript/test_print.py b/tests/pyscript/test_print.py new file mode 100644 index 000000000..26b950d4a --- /dev/null +++ b/tests/pyscript/test_print.py @@ -0,0 +1,29 @@ +""" +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 + +from rich.text import Text + +# 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 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) + +# Test file=sys.stderr +print("this goes to sys.stderr", file=sys.stderr) diff --git a/tests/test_argparse_utils.py b/tests/test_argparse_utils.py index 3be2263f4..a2315773a 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,133 @@ 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_output_to_context_manager() -> None: + """Test that output_to() 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.output_to(buf1): + assert parser._thread_locals.current_output_file is buf1 + 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 + + assert parser._thread_locals.current_output_file is None # type: ignore[unreachable] + + +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 + + 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_output_to(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_implicit_output_to(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.output_to(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_cmd2.py b/tests/test_cmd2.py index d0a52c964..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" @@ -1419,7 +1394,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]) diff --git a/tests/test_rich_utils.py b/tests/test_rich_utils.py index 1401c860c..20229630f 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,35 +272,49 @@ 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.""" +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()) - 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") +def test_formatter_lazy_console_with_file() -> None: + """Test that Cmd2HelpFormatter lazily creates a console bound to the provided file.""" + import io - # Render help - parser.print_help() - out, _ = capsys.readouterr() + buf = io.StringIO() + formatter = ru.Cmd2HelpFormatter(prog="test", file=buf) - assert "Notes:" in out - assert " Some text" in out + # 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() -> None: - # self._console = console (inside console.setter) +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", diff --git a/tests/test_run_pyscript.py b/tests/test_run_pyscript.py index e19f77c59..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, ) @@ -122,29 +124,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_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 -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 + class App(cmd2.Cmd): + def do_test_capture(self, _): + print("process_stdout") + self.poutput("app_stdout") - 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}") + app = App() + bridge = PyBridge(app) + result = bridge("test_capture") - assert "print" not in out - assert out[0] == "poutput" + # 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: @@ -213,3 +209,56 @@ 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]" + + +@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__) + 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) == 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 + 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 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 + 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