diff --git a/CHANGELOG.md b/CHANGELOG.md index 27cdb5b19..2d19a1299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ `Cmd._build_parser()`. This code had previously been restored to support backward compatibility in `cmd2` 2.0 family. + - In an effort be consistent with the purpose of `self.stdout` and our own documentation, the + following changes were made. + - No longer redirecting `sys.stdout`. + - No longer capturing pyscript output written to `sys.stdout`. + - To assist with this change, calling `print()` within a pyscript now writes to + `self.stdout`. Calling `self.poutput()` within a pyscript is still preferred, but that + may not always be possible. + - Enhancements - Simplified the process to set a custom parser for `cmd2's` built-in commands. See diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d9bc4abc6..6f05941d8 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2856,7 +2856,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: # Initialize the redirection saved state redir_saved_state = utils.RedirectionSavedState( - cast(TextIO, self.stdout), sys.stdout, self._cur_pipe_proc_reader, self._redirecting + cast(TextIO, self.stdout), self._cur_pipe_proc_reader, self._redirecting ) # The ProcReader for this command @@ -2912,7 +2912,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: raise RedirectionError(f'Pipe process exited with code {proc.returncode} before command could run') redir_saved_state.redirecting = True # type: ignore[unreachable] cmd_pipe_proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr) - sys.stdout = self.stdout = new_stdout + self.stdout = new_stdout elif statement.output: if statement.output_to: @@ -2926,7 +2926,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: raise RedirectionError('Failed to redirect output') from ex redir_saved_state.redirecting = True - sys.stdout = self.stdout = new_stdout + self.stdout = new_stdout else: # Redirecting to a paste buffer @@ -2944,7 +2944,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: # create a temporary file to store output new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+")) # noqa: SIM115 redir_saved_state.redirecting = True - sys.stdout = self.stdout = new_stdout + self.stdout = new_stdout if statement.output == constants.REDIRECTION_APPEND: self.stdout.write(current_paste_buffer) @@ -2972,9 +2972,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) - sys.stdout = cast(TextIO, saved_redir_state.saved_sys_stdout) # Check if we need to wait for the process being piped to if self._cur_pipe_proc_reader is not None: @@ -4449,12 +4448,6 @@ def _set_up_py_shell_env(self, interp: InteractiveConsole) -> _SavedCmd2Env: # Set up sys module for the Python console self._reset_py_display() - cmd2_env.sys_stdout = sys.stdout - sys.stdout = self.stdout # type: ignore[assignment] - - cmd2_env.sys_stdin = sys.stdin - sys.stdin = self.stdin # type: ignore[assignment] - return cmd2_env def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None: @@ -4462,9 +4455,6 @@ def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None: :param cmd2_env: the environment settings to restore """ - sys.stdout = cmd2_env.sys_stdout # type: ignore[assignment] - sys.stdin = cmd2_env.sys_stdin # type: ignore[assignment] - # Set up readline for cmd2 if rl_type != RlType.NONE: # Save py's history @@ -4539,6 +4529,11 @@ def py_quit() -> None: if self.self_in_py: local_vars['self'] = self + # Since poutput() may not be available in a pyscript, like in the case when self_in_py is False, + # provide a version of print() which writes to self.stdout. This way, print's output can be + # captured and redirected. + local_vars['print'] = functools.partial(print, file=self.stdout) + # Handle case where we were called by do_run_pyscript() if pyscript is not None: # Read the script file diff --git a/cmd2/py_bridge.py b/cmd2/py_bridge.py index 2a147583c..75672875f 100644 --- a/cmd2/py_bridge.py +++ b/cmd2/py_bridge.py @@ -4,10 +4,7 @@ """ import sys -from contextlib import ( - redirect_stderr, - redirect_stdout, -) +from contextlib import redirect_stderr from typing import ( IO, TYPE_CHECKING, @@ -19,9 +16,7 @@ cast, ) -from .utils import ( # namedtuple_with_defaults, - StdSim, -) +from .utils import StdSim if TYPE_CHECKING: # pragma: no cover import cmd2 @@ -113,7 +108,7 @@ def __call__(self, command: str, *, echo: Optional[bool] = None) -> CommandResul if echo is None: echo = self.cmd_echo - # 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(Union[TextIO, StdSim], self._cmd2_app.stdout), echo=echo) # Pause the storing of stdout until onecmd_plus_hooks enables it @@ -127,7 +122,7 @@ def __call__(self, command: str, *, echo: Optional[bool] = None) -> CommandResul stop = False try: self._cmd2_app.stdout = cast(TextIO, copy_cmd_stdout) - with redirect_stdout(cast(IO[str], copy_cmd_stdout)), redirect_stderr(cast(IO[str], copy_stderr)): + with redirect_stderr(cast(IO[str], copy_stderr)): stop = self._cmd2_app.onecmd_plus_hooks( command, add_to_history=self._add_to_history, diff --git a/cmd2/utils.py b/cmd2/utils.py index 1c3506e6b..d29ef788d 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -13,10 +13,22 @@ import sys import threading import unicodedata -from collections.abc import Callable, Iterable +from collections.abc import ( + Callable, + Iterable, +) from difflib import SequenceMatcher from enum import Enum -from typing import TYPE_CHECKING, Any, Optional, TextIO, TypeVar, Union, cast, get_type_hints +from typing import ( + TYPE_CHECKING, + Any, + Optional, + TextIO, + TypeVar, + Union, + cast, + get_type_hints, +) from . import constants from .argparse_custom import ChoicesProviderFunc, CompleterFunc @@ -683,23 +695,20 @@ class RedirectionSavedState: def __init__( self, self_stdout: Union[StdSim, TextIO], - sys_stdout: Union[StdSim, TextIO], pipe_proc_reader: Optional[ProcReader], saved_redirecting: bool, ) -> None: """RedirectionSavedState initializer. :param self_stdout: saved value of Cmd.stdout - :param sys_stdout: saved value of 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 values after redirection ends + # Used to restore self.stdout after redirection ends self.saved_self_stdout = self_stdout - self.saved_sys_stdout = sys_stdout # 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 a315231e3..e499cb67c 100644 --- a/docs/features/settings.md +++ b/docs/features/settings.md @@ -61,7 +61,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/test_run_pyscript.py b/tests/test_run_pyscript.py index a64f77ba9..013c44bca 100644 --- a/tests/test_run_pyscript.py +++ b/tests/test_run_pyscript.py @@ -9,6 +9,7 @@ import pytest from cmd2 import ( + cmd2, plugin, utils, ) @@ -18,14 +19,6 @@ run_cmd, ) -HOOK_OUTPUT = "TEST_OUTPUT" - - -def cmdfinalization_hook(data: plugin.CommandFinalizationData) -> plugin.CommandFinalizationData: - """A cmdfinalization_hook hook which requests application exit""" - print(HOOK_OUTPUT) - return data - def test_run_pyscript(base_app, request) -> None: test_dir = os.path.dirname(request.module.__file__) @@ -133,11 +126,23 @@ def test_run_pyscript_dir(base_app, request) -> None: assert out[0] == "['cmd_echo']" -def test_run_pyscript_stdout_capture(base_app, request) -> None: - base_app.register_cmdfinalization_hook(cmdfinalization_hook) +def test_run_pyscript_stdout_capture(request) -> None: + class HookApp(cmd2.Cmd): + HOOK_OUTPUT = "TEST_OUTPUT" + + def __init__(self) -> None: + super().__init__() + self.register_cmdfinalization_hook(self.cmdfinalization_hook) + + def cmdfinalization_hook(self, data: plugin.CommandFinalizationData) -> plugin.CommandFinalizationData: + """A cmdfinalization_hook prints output.""" + self.poutput(self.HOOK_OUTPUT) + return data + + hook_app = HookApp() 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} {HOOK_OUTPUT}') + out, err = run_cmd(hook_app, f'run_pyscript {python_script} {hook_app.HOOK_OUTPUT}') assert out[0] == "PASSED" assert out[1] == "PASSED"