diff --git a/CHANGELOG.md b/CHANGELOG.md index 09f27ec98..4e97ffaa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,12 @@ - Added `Cmd.macro_arg_complete()` which tab completes arguments to a macro. Its default behavior is to perform path completion, but it can be overridden as needed. - - Bug Fixes - - No longer redirecting `sys.stdout` if it's a different stream than `self.stdout`. This - fixes issue where we overwrote an application's `sys.stdout` while redirecting. + - All print methods (`poutput()`, `perror()`, `ppaged()`, etc.) have the ability to print Rich + objects. + +- Bug Fixes + - No longer redirecting `sys.stdout` if it's a different stream than `self.stdout`. This fixes + issue where we overwrote an application's `sys.stdout` while redirecting. ## 2.7.0 (June 30, 2025) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 5337f1449..da308ef44 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -24,10 +24,7 @@ # This module has many imports, quite a few of which are only # infrequently utilized. To reduce the initial overhead of # import this module, many of these imports are lazy-loaded -# i.e. we only import the module when we use it -# For example, we don't import the 'traceback' module -# until the pexcept() function is called and the debug -# setting is True +# i.e. we only import the module when we use it. import argparse import cmd import contextlib @@ -36,7 +33,6 @@ import glob import inspect import os -import pprint import pydoc import re import sys @@ -69,6 +65,7 @@ ) from rich.console import Group +from rich.style import StyleType from rich.text import Text from . import ( @@ -127,6 +124,7 @@ StatementParser, shlex_split, ) +from .rich_utils import Cmd2Console, RichPrintKwargs # NOTE: When using gnureadline with Python 3.13, start_ipython needs to be imported before any readline-related stuff with contextlib.suppress(ImportError): @@ -158,7 +156,7 @@ # Set up readline if rl_type == RlType.NONE: # pragma: no cover - sys.stderr.write(ansi.style_warning(rl_warning)) + Cmd2Console(sys.stderr).print(Text(rl_warning, style="cmd2.warning")) else: from .rl_utils import ( # type: ignore[attr-defined] readline, @@ -416,7 +414,7 @@ def __init__( # Use as prompt for multiline commands on the 2nd+ line of input self.continuation_prompt: str = '> ' - # Allow access to your application in embedded Python shells and scripts py via self + # Allow access to your application in embedded Python shells and pyscripts via self self.self_in_py = False # Commands to exclude from the help menu and tab completion @@ -513,7 +511,7 @@ def __init__( elif transcript_files: self._transcript_files = transcript_files - # Set the pager(s) for use with the ppaged() method for displaying output using a pager + # Set the pager(s) for use when displaying output using a pager if sys.platform.startswith('win'): self.pager = self.pager_chop = 'more' else: @@ -937,7 +935,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True) if not subcommand_valid: - raise CommandSetRegistrationError(f'Subcommand {subcommand_name!s} is not valid: {errmsg}') + raise CommandSetRegistrationError(f'Subcommand {subcommand_name} is not valid: {errmsg}') command_tokens = full_command_name.split() command_name = command_tokens[0] @@ -950,11 +948,11 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: command_func = self.cmd_func(command_name) if command_func is None: - raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method!s}") + raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}") command_parser = self._command_parsers.get(command_func) if command_parser is None: raise CommandSetRegistrationError( - f"Could not find argparser for command '{command_name}' needed by subcommand: {method!s}" + f"Could not find argparser for command '{command_name}' needed by subcommand: {method}" ) def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> argparse.ArgumentParser: @@ -1044,13 +1042,13 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: if command_func is None: # pragma: no cover # This really shouldn't be possible since _register_subcommands would prevent this from happening # but keeping in case it does for some strange reason - raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method!s}") + raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}") command_parser = self._command_parsers.get(command_func) if command_parser is None: # pragma: no cover # This really shouldn't be possible since _register_subcommands would prevent this from happening # but keeping in case it does for some strange reason raise CommandSetRegistrationError( - f"Could not find argparser for command '{command_name}' needed by subcommand: {method!s}" + f"Could not find argparser for command '{command_name}' needed by subcommand: {method}" ) for action in command_parser._actions: @@ -1129,11 +1127,11 @@ def allow_style_type(value: str) -> rich_utils.AllowStyle: """Convert a string value into an rich_utils.AllowStyle.""" try: return rich_utils.AllowStyle[value.upper()] - except KeyError as esc: + except KeyError as ex: raise ValueError( f"must be {rich_utils.AllowStyle.ALWAYS}, {rich_utils.AllowStyle.NEVER}, or " f"{rich_utils.AllowStyle.TERMINAL} (case-insensitive)" - ) from esc + ) from ex self.add_settable( Settable( @@ -1189,170 +1187,364 @@ def visible_prompt(self) -> str: def print_to( self, - dest: IO[str], - msg: Any, - *, - end: str = '\n', - style: Optional[Callable[[str], str]] = None, + file: IO[str], + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[StyleType] = None, + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 ) -> None: - """Print message to a given file object. - - :param dest: the file object being written to - :param msg: object to print - :param end: string appended after the end of the message, default a newline - :param style: optional style function to format msg with (e.g. ansi.style_success) + """Print objects to a given file stream. + + :param file: file stream being written to + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param style: optional style to apply to output + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - final_msg = style(msg) if style is not None else msg + prepared_objects = rich_utils.prepare_objects_for_rich_print(*objects) + try: - ansi.style_aware_write(dest, f'{final_msg}{end}') + Cmd2Console(file).print( + *prepared_objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + **(rich_print_kwargs if rich_print_kwargs is not None else {}), + ) except BrokenPipeError: # This occurs if a command's output is being piped to another - # process and that process closes before the command is - # finished. If you would like your application to print a + # process which closes the pipe before the command is finished + # writing. If you would like your application to print a # warning message, then set the broken_pipe_warning attribute # to the message you want printed. - if self.broken_pipe_warning: - sys.stderr.write(self.broken_pipe_warning) + if self.broken_pipe_warning and file != sys.stderr: + Cmd2Console(sys.stderr).print(self.broken_pipe_warning) - def poutput(self, msg: Any = '', *, end: str = '\n') -> None: - """Print message to self.stdout and appends a newline by default. - - :param msg: object to print - :param end: string appended after the end of the message, default a newline + def poutput( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[StyleType] = None, + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Print objects to self.stdout. + + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param style: optional style to apply to output + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - self.print_to(self.stdout, msg, end=end) - - def perror(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None: - """Print message to sys.stderr. + self.print_to( + self.stdout, + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) - :param msg: object to print - :param end: string appended after the end of the message, default a newline - :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases - where the message text already has the desired style. Defaults to True. + def perror( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[StyleType] = "cmd2.error", + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Print objects to sys.stderr. + + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param style: optional style to apply to output. Defaults to cmd2.error. + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - self.print_to(sys.stderr, msg, end=end, style=ansi.style_error if apply_style else None) - - def psuccess(self, msg: Any = '', *, end: str = '\n') -> None: - """Wrap poutput, but applies ansi.style_success by default. + self.print_to( + sys.stderr, + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) - :param msg: object to print - :param end: string appended after the end of the message, default a newline + def psuccess( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Wrap poutput, but apply cmd2.success style. + + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - msg = ansi.style_success(msg) - self.poutput(msg, end=end) - - def pwarning(self, msg: Any = '', *, end: str = '\n') -> None: - """Wrap perror, but applies ansi.style_warning by default. + self.poutput( + *objects, + sep=sep, + end=end, + style="cmd2.success", + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) - :param msg: object to print - :param end: string appended after the end of the message, default a newline + def pwarning( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Wrap perror, but apply cmd2.warning style. + + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - msg = ansi.style_warning(msg) - self.perror(msg, end=end, apply_style=False) - - def pexcept(self, msg: Any, *, end: str = '\n', apply_style: bool = True) -> None: - """Print Exception message to sys.stderr. If debug is true, print exception traceback if one exists. + self.perror( + *objects, + sep=sep, + end=end, + style="cmd2.warning", + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) - :param msg: message or Exception to print - :param end: string appended after the end of the message, default a newline - :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases - where the message text already has the desired style. Defaults to True. + def pexcept( + self, + exception: BaseException, + end: str = "\n", + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """Print exception to sys.stderr. If debug is true, print exception traceback if one exists. + + :param exception: the exception to print. + :param end: string to write at end of print data. Defaults to a newline. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - if self.debug and sys.exc_info() != (None, None, None): - import traceback - - traceback.print_exc() + final_msg = Text() - if isinstance(msg, Exception): - final_msg = f"EXCEPTION of type '{type(msg).__name__}' occurred with message: {msg}" + if self.debug and sys.exc_info() != (None, None, None): + console = Cmd2Console(sys.stderr) + console.print_exception(word_wrap=True) else: - final_msg = str(msg) - - if apply_style: - final_msg = ansi.style_error(final_msg) + final_msg += f"EXCEPTION of type '{type(exception).__name__}' occurred with message: {exception}" if not self.debug and 'debug' in self.settables: warning = "\nTo enable full traceback, run the following command: 'set debug true'" - final_msg += ansi.style_warning(warning) + final_msg.append(warning, style="cmd2.warning") - self.perror(final_msg, end=end, apply_style=False) + if final_msg: + self.perror( + final_msg, + end=end, + rich_print_kwargs=rich_print_kwargs, + ) - def pfeedback(self, msg: Any, *, end: str = '\n') -> None: - """Print nonessential feedback. Can be silenced with `quiet`. + def pfeedback( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[StyleType] = None, + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: + """For printing nonessential feedback. Can be silenced with `quiet`. Inclusion in redirected output is controlled by `feedback_to_output`. - :param msg: object to print - :param end: string appended after the end of the message, default a newline + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param style: optional style to apply to output + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ if not self.quiet: if self.feedback_to_output: - self.poutput(msg, end=end) + self.poutput( + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) else: - self.perror(msg, end=end, apply_style=False) + self.perror( + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) - def ppaged(self, msg: Any, *, end: str = '\n', chop: bool = False) -> None: + def ppaged( + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[StyleType] = None, + chop: bool = False, + soft_wrap: Optional[bool] = None, + rich_print_kwargs: Optional[RichPrintKwargs] = None, + **kwargs: Any, # noqa: ARG002 + ) -> None: """Print output using a pager if it would go off screen and stdout isn't currently being redirected. Never uses a pager inside a script (Python or text) or when output is being redirected or piped or when stdout or stdin are not a fully functional terminal. - :param msg: object to print - :param end: string appended after the end of the message, default a newline + :param objects: objects to print + :param sep: string to write between print data. Defaults to " ". + :param end: string to write at end of print data. Defaults to a newline. + :param style: optional style to apply to output :param chop: True -> causes lines longer than the screen width to be chopped (truncated) rather than wrapped - truncated text is still accessible by scrolling with the right & left arrow keys - chopping is ideal for displaying wide tabular data as is done in utilities like pgcli False -> causes lines longer than the screen width to wrap to the next line - wrapping is ideal when you want to keep users from having to use horizontal scrolling - - WARNING: On Windows, the text always wraps regardless of what the chop argument is set to + WARNING: On Windows, the text always wraps regardless of what the chop argument is set to + :param soft_wrap: Enable soft wrap mode. If True, text lines will not be automatically word-wrapped to fit the + terminal width; instead, any text that doesn't fit will run onto the following line(s), + similar to the built-in print() function. Set to False to enable automatic word-wrapping. + If None (the default for this parameter), the output will default to no word-wrapping, as + configured by the Cmd2Console. + Note: If chop is True and a pager is used, soft_wrap is automatically set to True. + :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print(). + :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this + method and still call `super()` without encountering unexpected keyword argument errors. + These arguments are not passed to Rich's Console.print(). """ - # Attempt to detect if we are not running within a fully functional terminal. + # Detect if we are running within a fully functional terminal. # Don't try to use the pager when being run by a continuous integration system like Jenkins + pexpect. - functional_terminal = False + functional_terminal = ( + self.stdin.isatty() + and self.stdout.isatty() + and (sys.platform.startswith('win') or os.environ.get('TERM') is not None) + ) - if self.stdin.isatty() and self.stdout.isatty(): # noqa: SIM102 - if sys.platform.startswith('win') or os.environ.get('TERM') is not None: - functional_terminal = True + # A pager application blocks, so only run one if not redirecting or running a script (either text or Python). + can_block = not (self._redirecting or self.in_pyscript() or self.in_script()) - # Don't attempt to use a pager that can block if redirecting or running a script (either text or Python). - # Also only attempt to use a pager if actually running in a real fully functional terminal. - if functional_terminal and not self._redirecting and not self.in_pyscript() and not self.in_script(): - final_msg = f"{msg}{end}" - if rich_utils.allow_style == rich_utils.AllowStyle.NEVER: - final_msg = ansi.strip_style(final_msg) + # Check if we are outputting to a pager. + if functional_terminal and can_block: + prepared_objects = rich_utils.prepare_objects_for_rich_print(*objects) - pager = self.pager + # Chopping overrides soft_wrap if chop: - pager = self.pager_chop + soft_wrap = True + + # Generate the bytes to send to the pager + console = Cmd2Console(self.stdout) + with console.capture() as capture: + console.print( + *prepared_objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + **(rich_print_kwargs if rich_print_kwargs is not None else {}), + ) + output_bytes = capture.get().encode('utf-8', 'replace') - try: - # Prevent KeyboardInterrupts while in the pager. The pager application will - # still receive the SIGINT since it is in the same process group as us. - with self.sigint_protection: - import subprocess - - pipe_proc = subprocess.Popen(pager, shell=True, stdin=subprocess.PIPE, stdout=self.stdout) # noqa: S602 - pipe_proc.communicate(final_msg.encode('utf-8', 'replace')) - except BrokenPipeError: - # This occurs if a command's output is being piped to another process and that process closes before the - # command is finished. If you would like your application to print a warning message, then set the - # broken_pipe_warning attribute to the message you want printed.` - if self.broken_pipe_warning: - sys.stderr.write(self.broken_pipe_warning) - else: - self.poutput(msg, end=end) + # Prevent KeyboardInterrupts while in the pager. The pager application will + # still receive the SIGINT since it is in the same process group as us. + with self.sigint_protection: + import subprocess - def ppretty(self, data: Any, *, indent: int = 2, width: int = 80, depth: Optional[int] = None, end: str = '\n') -> None: - """Pretty print arbitrary Python data structures to self.stdout and appends a newline by default. + pipe_proc = subprocess.Popen( # noqa: S602 + self.pager_chop if chop else self.pager, + shell=True, + stdin=subprocess.PIPE, + stdout=self.stdout, + ) + pipe_proc.communicate(output_bytes) - :param data: object to print - :param indent: the amount of indentation added for each nesting level - :param width: the desired maximum number of characters per line in the output, a best effort will be made for long data - :param depth: the number of nesting levels which may be printed, if data is too deep, the next level replaced by ... - :param end: string appended after the end of the message, default a newline - """ - self.print_to(self.stdout, pprint.pformat(data, indent, width, depth), end=end) + else: + self.poutput( + *objects, + sep=sep, + end=end, + style=style, + soft_wrap=soft_wrap, + rich_print_kwargs=rich_print_kwargs, + ) # ----- Methods related to tab completion ----- @@ -2278,9 +2470,13 @@ def complete( # type: ignore[override] # Don't print error and redraw the prompt unless the error has length err_str = str(ex) if err_str: - if ex.apply_style: - err_str = ansi.style_error(err_str) - ansi.style_aware_write(sys.stdout, '\n' + err_str + '\n') + self.print_to( + sys.stdout, + Text.assemble( + "\n", + (err_str, "cmd2.error" if ex.apply_style else ""), + ), + ) rl_force_redisplay() return None except Exception as ex: # noqa: BLE001 @@ -2380,13 +2576,17 @@ def get_help_topics(self) -> list[str]: # Filter out hidden and disabled commands return [topic for topic in all_topics if topic not in self.hidden_commands and topic not in self.disabled_commands] - def sigint_handler(self, signum: int, _: Optional[FrameType]) -> None: # noqa: ARG002 + def sigint_handler( + self, + signum: int, # noqa: ARG002, + frame: Optional[FrameType], # noqa: ARG002, + ) -> None: """Signal handler for SIGINTs which typically come from Ctrl-C events. If you need custom SIGINT behavior, then override this method. :param signum: signal number - :param _: the current stack frame or None + :param frame: the current stack frame or None """ if self._cur_pipe_proc_reader is not None: # Pass the SIGINT to the current pipe process @@ -3062,7 +3262,7 @@ def default(self, statement: Statement) -> Optional[bool]: # type: ignore[overr err_msg += f"\n{self.default_suggestion_message.format(suggested_command)}" # Set apply_style to False so styles for default_error and default_suggestion_message are not overridden - self.perror(err_msg, apply_style=False) + self.perror(err_msg, style=None) return None def _suggest_similar_command(self, command: str) -> Optional[str]: @@ -3902,7 +4102,7 @@ def do_help(self, args: argparse.Namespace) -> None: err_msg = self.help_error.format(args.command) # Set apply_style to False so help_error's style is not overridden - self.perror(err_msg, apply_style=False) + self.perror(err_msg, style=None) self.last_result = False def print_topics(self, header: str, cmds: Optional[list[str]], cmdlen: int, maxcol: int) -> None: # noqa: ARG002 @@ -5356,7 +5556,7 @@ class TestMyAppCase(Cmd2TestCase): test_results = runner.run(testcase) execution_time = time.time() - start_time if test_results.wasSuccessful(): - ansi.style_aware_write(sys.stderr, stream.read()) + self.perror(stream.read(), end="", style=None) finish_msg = f' {num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds ' finish_msg = utils.align_center(finish_msg, fill_char='=') self.psuccess(finish_msg) @@ -5613,8 +5813,7 @@ def _report_disabled_command_usage(self, *_args: Any, message_to_print: str, **_ :param message_to_print: the message reporting that the command is disabled :param _kwargs: not used """ - # Set apply_style to False so message_to_print's style is not overridden - self.perror(message_to_print, apply_style=False) + self.perror(message_to_print, style=None) def cmdloop(self, intro: Optional[str] = None) -> int: # type: ignore[override] """Deal with extra features provided by cmd2, this is an outer wrapper around _cmdloop(). diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index eb9b39236..623dc9353 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -6,13 +6,22 @@ IO, Any, Optional, + TypedDict, ) -from rich.console import Console +from rich.console import ( + Console, + ConsoleRenderable, + JustifyMethod, + OverflowMethod, + RenderableType, + RichCast, +) from rich.style import ( Style, StyleType, ) +from rich.text import Text from rich.theme import Theme from rich_argparse import RichHelpFormatter @@ -88,6 +97,28 @@ def set_theme(new_theme: Cmd2Theme) -> None: RichHelpFormatter.styles[name] = THEME.styles[name] +class RichPrintKwargs(TypedDict, total=False): + """Keyword arguments that can be passed to rich.console.Console.print() via cmd2's print methods. + + See Rich's Console.print() documentation for full details on these parameters. + https://rich.readthedocs.io/en/stable/reference/console.html#rich.console.Console.print + + Note: All fields are optional (total=False). If a key is not present in the + dictionary, Rich's default behavior for that argument will apply. + """ + + justify: Optional[JustifyMethod] + overflow: Optional[OverflowMethod] + no_wrap: Optional[bool] + markup: Optional[bool] + emoji: Optional[bool] + highlight: Optional[bool] + width: Optional[int] + height: Optional[int] + crop: bool + new_line_start: bool + + class Cmd2Console(Console): """Rich console with characteristics appropriate for cmd2 applications.""" @@ -106,11 +137,16 @@ def __init__(self, file: IO[str]) -> None: elif allow_style == AllowStyle.NEVER: kwargs["force_terminal"] = False - # Turn off automatic markup, emoji, and highlight rendering at the console level. - # You can still enable these in Console.print() calls. + # Configure console defaults to treat output as plain, unstructured text. + # This involves enabling soft wrapping (no automatic word-wrap) and disabling + # Rich's automatic markup, emoji, and highlight processing. + # While these automatic features are off by default, the console fully supports + # rendering explicitly created Rich objects (e.g., Panel, Table). + # Any of these default settings or other print behaviors can be overridden + # in individual Console.print() calls or via cmd2's print methods. super().__init__( file=file, - tab_size=4, + soft_wrap=True, markup=False, emoji=False, highlight=False, @@ -120,9 +156,60 @@ def __init__(self, file: IO[str]) -> None: def on_broken_pipe(self) -> None: """Override which raises BrokenPipeError instead of SystemExit.""" - import contextlib + self.quiet = True + raise BrokenPipeError - with contextlib.suppress(SystemExit): - super().on_broken_pipe() - raise BrokenPipeError +def from_ansi(text: str) -> Text: + r"""Patched version of rich.Text.from_ansi() that handles a discarded newline issue. + + Text.from_ansi() currently removes the ending line break from string. + e.g. "Hello\n" becomes "Hello" + + There is currently a pull request to fix this. + https://github.com/Textualize/rich/pull/3793 + + :param text: a string to convert to a Text object. + :return: the converted string + """ + result = Text.from_ansi(text) + + # If 'text' ends with a line break character, restore the missing newline to 'result'. + # Note: '\r\n' is handled as its last character is '\n'. + # Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines + line_break_chars = { + "\n", # Line Feed + "\r", # Carriage Return + "\v", # Vertical Tab + "\f", # Form Feed + "\x1c", # File Separator + "\x1d", # Group Separator + "\x1e", # Record Separator + "\x85", # Next Line (NEL) + "\u2028", # Line Separator + "\u2029", # Paragraph Separator + } + if text and text[-1] in line_break_chars: + # We use "\n" because Text.from_ansi() converts all line breaks chars into newlines. + result.append("\n") + + return result + + +def prepare_objects_for_rich_print(*objects: Any) -> tuple[RenderableType, ...]: + """Prepare a tuple of objects for printing by Rich's Console.print(). + + Converts any non-Rich objects (i.e., not ConsoleRenderable or RichCast) + into rich.Text objects by stringifying them and processing them with + from_ansi(). This ensures Rich correctly interprets any embedded ANSI + escape sequences. + + :param objects: objects to prepare + :return: a tuple containing the processed objects, where non-Rich objects are + converted to rich.Text. + """ + object_list = list(objects) + for i, obj in enumerate(object_list): + if not isinstance(obj, (ConsoleRenderable, RichCast)): + object_list[i] = from_ansi(str(obj)) + return tuple(object_list) diff --git a/cmd2/rl_utils.py b/cmd2/rl_utils.py index a07479c7b..137d447e2 100644 --- a/cmd2/rl_utils.py +++ b/cmd2/rl_utils.py @@ -133,7 +133,7 @@ def pyreadline_remove_history_item(pos: int) -> None: readline_lib = ctypes.CDLL(readline.__file__) except (AttributeError, OSError): # pragma: no cover _rl_warn_reason = ( - "this application is running in a non-standard Python environment in\n" + "this application is running in a non-standard Python environment in " "which GNU readline is not loaded dynamically from a shared library file." ) else: @@ -144,10 +144,10 @@ def pyreadline_remove_history_item(pos: int) -> None: if rl_type == RlType.NONE: # pragma: no cover if not _rl_warn_reason: _rl_warn_reason = ( - "no supported version of readline was found. To resolve this, install\n" + "no supported version of readline was found. To resolve this, install " "pyreadline3 on Windows or gnureadline on Linux/Mac." ) - rl_warning = "Readline features including tab completion have been disabled because\n" + _rl_warn_reason + '\n\n' + rl_warning = f"Readline features including tab completion have been disabled because {_rl_warn_reason}\n\n" else: rl_warning = '' diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 751797231..ec6ec91d0 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -15,6 +15,7 @@ ) import pytest +from rich.text import Text import cmd2 from cmd2 import ( @@ -933,7 +934,7 @@ def test_base_debug(base_app) -> None: # Verify that we now see the exception traceback out, err = run_cmd(base_app, 'edit') - assert err[0].startswith('Traceback (most recent call last):') + assert 'Traceback (most recent call last)' in err[0] def test_debug_not_settable(base_app) -> None: @@ -2026,46 +2027,50 @@ def test_poutput_none(outsim_app) -> None: assert out == expected -def test_ppretty_dict(outsim_app) -> None: - data = { - "name": "John Doe", - "age": 30, - "address": {"street": "123 Main St", "city": "Anytown", "state": "CA"}, - "hobbies": ["reading", "hiking", "coding"], - } - outsim_app.ppretty(data) - out = outsim_app.stdout.getvalue() - expected = """ -{ 'address': {'city': 'Anytown', 'state': 'CA', 'street': '123 Main St'}, - 'age': 30, - 'hobbies': ['reading', 'hiking', 'coding'], - 'name': 'John Doe'} -""" - assert out == expected.lstrip() - - @with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_poutput_ansi_always(outsim_app) -> None: msg = 'Hello World' - colored_msg = ansi.style(msg, fg=ansi.Fg.CYAN) + colored_msg = Text(msg, style="cyan") outsim_app.poutput(colored_msg) out = outsim_app.stdout.getvalue() - expected = colored_msg + '\n' - assert colored_msg != msg - assert out == expected + assert out == "\x1b[36mHello World\x1b[0m\n" @with_ansi_style(rich_utils.AllowStyle.NEVER) def test_poutput_ansi_never(outsim_app) -> None: msg = 'Hello World' - colored_msg = ansi.style(msg, fg=ansi.Fg.CYAN) + colored_msg = Text(msg, style="cyan") outsim_app.poutput(colored_msg) out = outsim_app.stdout.getvalue() expected = msg + '\n' - assert colored_msg != msg assert out == expected +@with_ansi_style(rich_utils.AllowStyle.TERMINAL) +def test_poutput_ansi_terminal(outsim_app) -> None: + """Test that AllowStyle.TERMINAL strips style when redirecting.""" + msg = 'testing...' + colored_msg = Text(msg, style="cyan") + outsim_app._redirecting = True + outsim_app.poutput(colored_msg) + out = outsim_app.stdout.getvalue() + expected = msg + '\n' + assert out == expected + + +def test_broken_pipe_error(outsim_app, monkeypatch, capsys): + write_mock = mock.MagicMock() + write_mock.side_effect = BrokenPipeError + monkeypatch.setattr("cmd2.utils.StdSim.write", write_mock) + + outsim_app.broken_pipe_warning = "The pipe broke" + outsim_app.poutput("My test string") + + out, err = capsys.readouterr() + assert not out + assert outsim_app.broken_pipe_warning in err + + # These are invalid names for aliases and macros invalid_command_name = [ '""', # Blank name @@ -2485,17 +2490,16 @@ def test_nonexistent_macro(base_app) -> None: @with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_perror_style(base_app, capsys) -> None: msg = 'testing...' - end = '\n' base_app.perror(msg) out, err = capsys.readouterr() - assert err == ansi.style_error(msg) + end + assert err == "\x1b[91mtesting...\x1b[0m\x1b[91m\n\x1b[0m" @with_ansi_style(rich_utils.AllowStyle.ALWAYS) def test_perror_no_style(base_app, capsys) -> None: msg = 'testing...' end = '\n' - base_app.perror(msg, apply_style=False) + base_app.perror(msg, style=None) out, err = capsys.readouterr() assert err == msg + end @@ -2506,14 +2510,14 @@ def test_pexcept_style(base_app, capsys) -> None: base_app.pexcept(msg) out, err = capsys.readouterr() - assert err.startswith(ansi.style_error("EXCEPTION of type 'Exception' occurred with message: testing...")) + assert err.startswith("\x1b[91mEXCEPTION of type 'Exception' occurred with message: testing") -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) +@with_ansi_style(rich_utils.AllowStyle.NEVER) def test_pexcept_no_style(base_app, capsys) -> None: msg = Exception('testing...') - base_app.pexcept(msg, apply_style=False) + base_app.pexcept(msg) out, err = capsys.readouterr() assert err.startswith("EXCEPTION of type 'Exception' occurred with message: testing...") @@ -2525,36 +2529,43 @@ def test_pexcept_not_exception(base_app, capsys) -> None: base_app.pexcept(msg) out, err = capsys.readouterr() - assert err.startswith(ansi.style_error(msg)) + assert err.startswith("\x1b[91mEXCEPTION of type 'bool' occurred with message: False") -def test_ppaged(outsim_app) -> None: - msg = 'testing...' - end = '\n' - outsim_app.ppaged(msg) - out = outsim_app.stdout.getvalue() - assert out == msg + end +@pytest.mark.parametrize('chop', [True, False]) +def test_ppaged_with_pager(outsim_app, monkeypatch, chop) -> None: + """Force ppaged() to run the pager by mocking an actual terminal state.""" + # Make it look like we're in a terminal + stdin_mock = mock.MagicMock() + stdin_mock.isatty.return_value = True + monkeypatch.setattr(outsim_app, "stdin", stdin_mock) -@with_ansi_style(rich_utils.AllowStyle.TERMINAL) -def test_ppaged_strips_ansi_when_redirecting(outsim_app) -> None: - msg = 'testing...' - end = '\n' - outsim_app._redirecting = True - outsim_app.ppaged(ansi.style(msg, fg=ansi.Fg.RED)) - out = outsim_app.stdout.getvalue() - assert out == msg + end + stdout_mock = mock.MagicMock() + stdout_mock.isatty.return_value = True + monkeypatch.setattr(outsim_app, "stdout", stdout_mock) + if not sys.platform.startswith('win') and os.environ.get("TERM") is None: + monkeypatch.setenv('TERM', 'simulated') -@with_ansi_style(rich_utils.AllowStyle.ALWAYS) -def test_ppaged_strips_ansi_when_redirecting_if_always(outsim_app) -> None: + # This will force ppaged to call Popen to run a pager + popen_mock = mock.MagicMock(name='Popen') + monkeypatch.setattr("subprocess.Popen", popen_mock) + outsim_app.ppaged("Test", chop=chop) + + # Verify the correct pager was run + expected_cmd = outsim_app.pager_chop if chop else outsim_app.pager + assert len(popen_mock.call_args_list) == 1 + assert expected_cmd == popen_mock.call_args_list[0].args[0] + + +def test_ppaged_no_pager(outsim_app) -> None: + """Since we're not in a fully-functional terminal, ppaged() will just call poutput().""" msg = 'testing...' end = '\n' - outsim_app._redirecting = True - colored_msg = ansi.style(msg, fg=ansi.Fg.RED) - outsim_app.ppaged(colored_msg) + outsim_app.ppaged(msg) out = outsim_app.stdout.getvalue() - assert out == colored_msg + end + assert out == msg + end # we override cmd.parseline() so we always get consistent diff --git a/tests/transcripts/regex_set.txt b/tests/transcripts/regex_set.txt index 4d40ce0f6..8344af818 100644 --- a/tests/transcripts/regex_set.txt +++ b/tests/transcripts/regex_set.txt @@ -1,5 +1,5 @@ # Run this transcript with "python example.py -t transcript_regex.txt" -# The regex for colors shows all possible settings for colors +# The regex for allow_style will match any setting for the previous value. # The regex for editor will match whatever program you use. # Regexes on prompts just make the trailing space obvious @@ -10,19 +10,19 @@ now: 'Terminal' editor - was: '/.*/' now: 'vim' (Cmd) set -Name Value Description/ +/ +Name Value Description/ */ ==================================================================================================================== -allow_style Terminal Allow ANSI text style sequences in output (valid values:/ +/ - Always, Never, Terminal)/ +/ +allow_style Terminal Allow ANSI text style sequences in output (valid values:/ */ + Always, Never, Terminal)/ */ always_show_hint False Display tab completion hint even when completion suggestions - print/ +/ -debug False Show full traceback on exception/ +/ -echo False Echo command issued into output/ +/ -editor vim Program used by 'edit'/ +/ -feedback_to_output False Include nonessentials in '|', '>' results/ +/ -max_completion_items 50 Maximum number of CompletionItems to display during tab/ +/ - completion/ +/ -maxrepeats 3 Max number of `--repeat`s allowed/ +/ -quiet False Don't print nonessential feedback/ +/ -scripts_add_to_history True Scripts and pyscripts add commands to history/ +/ -timing False Report execution times/ +/ + print/ */ +debug False Show full traceback on exception/ */ +echo False Echo command issued into output/ */ +editor vim Program used by 'edit'/ */ +feedback_to_output False Include nonessentials in '|', '>' results/ */ +max_completion_items 50 Maximum number of CompletionItems to display during tab/ */ + completion/ */ +maxrepeats 3 Max number of `--repeat`s allowed/ */ +quiet False Don't print nonessential feedback/ */ +scripts_add_to_history True Scripts and pyscripts add commands to history/ */ +timing False Report execution times/ */