diff --git a/CHANGELOG.md b/CHANGELOG.md index 1001a91ce..bdb5fb3ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - `@with_annotated` argument groups can now contain an `ArgumentBlock`'s arguments. A `Group` member names a command-line argument, and a block expands into one argument per field, so its fields are named: `Group("host", "port")`. + - `Cmd` now uses the `pyperclip` clipboard integration from `prompt_toolkit` as the default + clipboard if available and provides it as a property. - Breaking Changes - A `Group` member now names an argument rather than a parameter. The two differ only for an `ArgumentBlock` parameter, which is expanded away and has no argument of its own: diff --git a/cmd2/clipboard.py b/cmd2/clipboard.py deleted file mode 100644 index 4f78925cf..000000000 --- a/cmd2/clipboard.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Module provides basic ability to copy from and paste to the clipboard/pastebuffer.""" - -import typing - -import pyperclip # type: ignore[import-untyped] - - -def get_paste_buffer() -> str: - """Get the contents of the clipboard / paste buffer. - - :return: contents of the clipboard - """ - return typing.cast(str, pyperclip.paste()) - - -def write_to_paste_buffer(txt: str) -> None: - """Copy text to the clipboard / paste buffer. - - :param txt: text to copy to the clipboard - """ - pyperclip.copy(txt) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 3bfb4574a..328c4738e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -73,6 +73,8 @@ ) from prompt_toolkit.application import create_app_session, get_app from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from prompt_toolkit.clipboard import Clipboard +from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard from prompt_toolkit.completion import Completer, DummyCompleter from prompt_toolkit.formatted_text import ANSI, AnyFormattedText from prompt_toolkit.history import InMemoryHistory @@ -117,10 +119,6 @@ SubcommandRecord, SubcommandSpec, ) -from .clipboard import ( - get_paste_buffer, - write_to_paste_buffer, -) from .command_set import CommandSet from .completion import ( Choices, @@ -137,6 +135,7 @@ with_argparser, ) from .exceptions import ( + ClipboardError, Cmd2ShlexError, CommandSetRegistrationError, CompletionError, @@ -389,31 +388,32 @@ def __init__( ) -> None: """Easy but powerful framework for writing line-oriented command interpreters, extends Python's cmd package. - :param completekey: name of a completion key, default to 'tab'. (If None or an empty string, 'tab' is used) + :param completekey: name of a completion key, default to 'tab'. (If ``None`` or an empty string, 'tab' is used) :param stdin: alternate input file object, if not specified, sys.stdin is used :param stdout: alternate output file object, if not specified, sys.stdout is used :param allow_cli_args: if ``True``, then [cmd2.Cmd.__init__][] will process command line arguments as either commands to be run. This should be set to ``False`` if your application parses its own command line arguments. - :param allow_clipboard: If False, cmd2 will disable clipboard interactions + :param allow_clipboard: If ``False``, cmd2 will disable clipboard interactions :param allow_redirection: If ``False``, prevent output redirection and piping to shell commands. This parameter prevents redirection and piping, but does not alter parsing behavior. A user can still type redirection and piping tokens, and they will be parsed as such but they won't do anything. - :param auto_load_commands: If True, cmd2 will check for all subclasses of `CommandSet` + :param auto_load_commands: If ``True``, cmd2 will check for all subclasses of `CommandSet` that are currently loaded by Python and automatically - instantiate and register all commands. If False, CommandSets + instantiate and register all commands. If ``False``, CommandSets must be manually installed with `register_command_set`. - :param auto_suggest: If True, cmd2 will provide fish shell style auto-suggestions + :param auto_suggest: If ``True``, cmd2 will provide fish shell style auto-suggestions based on history. User can press right-arrow key to accept the provided suggestion. :param complete_in_thread: if ``True``, then completion will run in a separate thread. :param command_sets: Provide CommandSet instances to load during cmd2 initialization. This allows CommandSets with custom constructor parameters to be loaded. This also allows the a set of CommandSets to be provided - when `auto_load_commands` is set to False + when `auto_load_commands` is set to ``False`` + :param enable_bottom_toolbar: if ``True``, enables a bottom toolbar while at the main prompt. Override ``get_bottom_toolbar()`` to define its content. :param enable_rprompt: if ``True``, enables a right prompt while at the main prompt. @@ -432,7 +432,7 @@ def __init__( updates in the bottom toolbar). :param shortcuts: Mapping containing shortcuts for commands. If not supplied, then defaults to constants.DEFAULT_SHORTCUTS. If you do not want - any shortcuts, pass None and an empty dictionary will be created. + any shortcuts, pass ``None`` and an empty dictionary will be created. :param silence_startup_script: if ``True``, then the startup script's output will be suppressed. Anything written to stderr will still display. :param startup_script: file path to a script to execute at startup @@ -443,7 +443,7 @@ def __init__( terminate single-line commands. If not supplied, the default is a semicolon. If your app only contains single-line commands and you want terminators to be treated as literals by the parser, - then set this to None. + then set this to ``None``. """ # Check if py or ipy need to be disabled in this instance if not include_py: @@ -795,6 +795,18 @@ def _(event: Any) -> None: # pragma: no cover "style": DynamicStyle(get_pt_theme), } + # Only enable PyperclipClipboard if the system clipboard is accessible to Pyperclip. + try: + cb = PyperclipClipboard() + cb.get_data() # Check if the system clipboard is accessible to Pyperclip + except Exception: # noqa: BLE001, S110 + # Prevent prompt_toolkit from crashing in headless environments and fallback + # on prompt toolkit's default clipboard (InMemoryClipboard) by not providing + # any argument for 'clipboard' in kwargs + pass + else: + kwargs["clipboard"] = cb + if self.stdin.isatty() and self.stdout.isatty(): try: if self.stdin != sys.stdin: @@ -1489,6 +1501,17 @@ def visible_prompt(self) -> str: """ return su.strip_style(self.prompt) + @property + def clipboard(self) -> Clipboard: + """The application clipboard. + + The clipboard can be either ``PyperclipClipboard`` or ``InMemoryClipboard`` + depending on whether the system clipboard is accessible to ``pyperclip``. + + :return: the clipboard of the application's main session + """ + return self.main_session.clipboard + def _get_core_print_console( self, *, @@ -3306,6 +3329,8 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: self.stdout = new_stdout elif statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND): + redir_saved_state.redirecting = True + if statement.redirect_to: # redirecting to a file # statement.output can only contain REDIRECTION_APPEND or REDIRECTION_OUTPUT @@ -3316,8 +3341,6 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: except OSError as ex: raise RedirectionError("Failed to redirect output") from ex - redir_saved_state.redirecting = True - self.stdout = new_stdout else: @@ -3327,16 +3350,19 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: if not self.allow_clipboard: raise RedirectionError("Clipboard access not allowed") - # attempt to get the paste buffer, this forces pyperclip to go figure - # out if it can actually interact with the paste buffer, and will throw exceptions - # if it's not gonna work. That way we throw the exception before we go - # run the command and queue up all the output. if this is going to fail, - # no point opening up the temporary file - current_paste_buffer = get_paste_buffer() + current_paste_buffer = "" + # The current clipboard contents only need to be accessed for appending + # to the clipboard. Hence skip reading the clipboard content for overwriting. + if statement.redirector == constants.REDIRECTION_APPEND: + # Get the current paste buffer from either the system clipboard if available + # or the in-memory clipboard only available to the main session + try: + current_paste_buffer = self.clipboard.get_data().text + except Exception as ex: + raise ClipboardError(f"Failed to access clipboard data: {ex}") from ex + # create a temporary file to store output new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+")) # noqa: SIM115 - redir_saved_state.redirecting = True - self.stdout = new_stdout if statement.redirector == constants.REDIRECTION_APPEND: @@ -3356,28 +3382,36 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec :param saved_redir_state: contains information needed to restore state data """ if saved_redir_state.redirecting: - # If we redirected output to the clipboard - if ( - statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND) - and not statement.redirect_to - ): - self.stdout.seek(0) - write_to_paste_buffer(self.stdout.read()) - - with contextlib.suppress(BrokenPipeError): - # Close the file or pipe that stdout was redirected to - self.stdout.close() + try: + # If we redirected output to the clipboard + if ( + statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND) + and not statement.redirect_to + ): + self.stdout.seek(0) + # Read stdout into the clipboard. Uses the system clipboard if available otherwise + # fall back to the in-memory clipboard only available to the main session + try: + self.clipboard.set_text(self.stdout.read()) + except Exception as ex: + raise ClipboardError(f"Failed to set clipboard data: {ex}") from ex + finally: + with contextlib.suppress(BrokenPipeError): + # Close the file or pipe that stdout was redirected to + self.stdout.close() - # Restore self.stdout - self.stdout = cast(TextIO, saved_redir_state.saved_self_stdout) + # Restore self.stdout + self.stdout = cast(TextIO, saved_redir_state.saved_self_stdout) - # Check if we need to wait for the process being piped to - if self._cur_pipe_proc_reader is not None: - self._cur_pipe_proc_reader.wait() + # Check if we need to wait for the process being piped to + if self._cur_pipe_proc_reader is not None: + self._cur_pipe_proc_reader.wait() - # These are restored regardless of whether the command redirected - self._cur_pipe_proc_reader = saved_redir_state.saved_pipe_proc_reader - self._redirecting = saved_redir_state.saved_redirecting + self._cur_pipe_proc_reader = saved_redir_state.saved_pipe_proc_reader + self._redirecting = saved_redir_state.saved_redirecting + else: + self._cur_pipe_proc_reader = saved_redir_state.saved_pipe_proc_reader + self._redirecting = saved_redir_state.saved_redirecting def get_command_func(self, command: str) -> BoundCommandFunc | None: """Get the bound command function for a command. diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index a113a02df..f05352bbd 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -87,3 +87,7 @@ class MacroError(Exception): class RedirectionError(Exception): """Custom exception class for when redirecting or piping output fails.""" + + +class ClipboardError(Exception): + """Custom exception class for when clipboard operations fail.""" diff --git a/docs/api/clipboard.md b/docs/api/clipboard.md deleted file mode 100644 index b3f9a2bf9..000000000 --- a/docs/api/clipboard.md +++ /dev/null @@ -1,3 +0,0 @@ -# cmd2.clipboard - -::: cmd2.clipboard diff --git a/docs/api/index.md b/docs/api/index.md index d412daaf6..e33190bc2 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -16,7 +16,6 @@ incremented according to the [Semantic Version Specification](https://semver.org signatures - [cmd2.argparse_completer](./argparse_completer.md) - classes for `argparse`-based tab completion - [cmd2.argparse_utils](./argparse_utils.md) - classes and functions for extending `argparse` -- [cmd2.clipboard](./clipboard.md) - functions to copy from and paste to the clipboard/pastebuffer - [cmd2.colors](./colors.md) - StrEnum of all color names supported by the Rich library - [cmd2.command_set](./command_set.md) - supports the definition of commands in separate classes to be composed into cmd2.Cmd diff --git a/docs/features/clipboard.md b/docs/features/clipboard.md index 28bae5b50..0c95a223b 100644 --- a/docs/features/clipboard.md +++ b/docs/features/clipboard.md @@ -4,9 +4,10 @@ Nearly every operating system has some notion of a short-term storage area which any program. Usually this is called the :clipboard: clipboard, but sometimes people refer to it as the paste buffer. -`cmd2` integrates with the operating system clipboard using the -[pyperclip](https://github.com/asweigart/pyperclip) module. Command output can be sent to the -clipboard by ending the command with a greater than symbol: +`cmd2` integrates with the operating system clipboard using the clipboard integration of +[prompt_toolkit](https://github.com/jonathanslenders/prompt_toolkit) in conjunction with the +[pyperclip](https://github.com/prompt-toolkit/python-prompt-toolkit) module. Command output can be +sent to the clipboard by ending the command with a greater than symbol: ```text mycommand args > @@ -36,5 +37,3 @@ you can change it at any time from within your code. If you would like your `cmd2` based application to be able to use the clipboard in additional or alternative ways, you can use the following methods (which work uniformly on Windows, macOS, and Linux). - -::: cmd2.clipboard diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index a1c1eb568..454f987d0 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -12,8 +12,11 @@ ) from unittest import mock +import pyperclip # type: ignore[import-untyped] import pytest from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from prompt_toolkit.clipboard.in_memory import InMemoryClipboard +from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard from prompt_toolkit.completion import DummyCompleter from prompt_toolkit.input import DummyInput, create_pipe_input from prompt_toolkit.output import DummyOutput @@ -29,7 +32,6 @@ CommandSet, Completions, SubcommandRecord, - clipboard, constants, exceptions, plugin, @@ -873,28 +875,31 @@ def test_pipe_to_shell_error(redirection_app) -> None: try: # try getting the contents of the clipboard - _ = clipboard.get_paste_buffer() + _ = pyperclip.paste() # pyperclip raises at least the following types of exceptions # FileNotFoundError on Windows Subsystem for Linux (WSL) when Windows paths are removed from $PATH # ValueError for headless Linux systems without Gtk installed # AssertionError can be raised by paste_klipper(). # PyperclipException for pyperclip-specific exceptions except Exception: # noqa: BLE001 - can_paste = False + pyperclip_can_paste = False else: - can_paste = True + pyperclip_can_paste = True -@pytest.mark.skipif(not can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") +@pytest.mark.skipif(not pyperclip_can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") 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 >") + # check if the clipboard is a PyperclipClipboard + assert isinstance(redirection_app.clipboard, PyperclipClipboard) + # Verify print() went to sys.stdout out, _err = capsys.readouterr() assert out == "print\n" - lines = cmd2.clipboard.get_paste_buffer().splitlines() + lines = redirection_app.clipboard.get_data().text.splitlines() assert len(lines) == 1 assert lines[0] == "poutput" @@ -904,26 +909,131 @@ def test_send_to_paste_buffer(redirection_app: RedirectionApp, capsys: pytest.Ca out, _err = capsys.readouterr() assert out == "print\n" - lines = cmd2.clipboard.get_paste_buffer().splitlines() + lines = redirection_app.clipboard.get_data().text.splitlines() assert len(lines) == 2 assert lines[0] == "poutput" assert lines[1] == "poutput" -def test_get_paste_buffer_exception(redirection_app, mocker, capsys) -> None: - # Force get_paste_buffer to throw an exception - pastemock = mocker.patch("pyperclip.paste") - pastemock.side_effect = ValueError("foo") +def test_init_with_no_clipboard_allowed() -> None: + app = cmd2.Cmd(allow_clipboard=False) + + # Check for the clipboard type + if pyperclip_can_paste: + assert isinstance(app.clipboard, PyperclipClipboard) + else: + assert isinstance(app.clipboard, InMemoryClipboard) + + +def test_init_with_clipboard_allowed() -> None: + app = cmd2.Cmd(allow_clipboard=True) + + # Check for the clipboard type + if pyperclip_can_paste: + assert isinstance(app.clipboard, PyperclipClipboard) + else: + assert isinstance(app.clipboard, InMemoryClipboard) + + +def test_pyperclip_exception_on_init(mocker) -> None: + # Force pyperclip.paste to throw an exception + _ = mocker.patch("pyperclip.paste", side_effect=ValueError("paste fail on init")) + app = cmd2.Cmd(allow_clipboard=True) + + # Check if the clipboard is a InMemoryClipboard when pyperclip cannot access + # the system clipboard + assert isinstance(app.clipboard, InMemoryClipboard) + + +@pytest.mark.skipif(not pyperclip_can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") +def test_get_paste_copy_exception_overwrite(redirection_app, mocker, capsys) -> None: + # check clipboard type + assert isinstance(redirection_app.clipboard, PyperclipClipboard) + + # Force pyperclip.copy to throw an exception + _ = mocker.patch("pyperclip.copy", side_effect=ValueError("copy fail")) # Redirect command output to the clipboard redirection_app.onecmd_plus_hooks("print_output > ") # Make sure we got the exception output out, err = capsys.readouterr() - assert out == "" + + # this just checks that cmd2 is surfacing whatever error gets raised by pyperclip.copy + assert "ClipboardError" in err + assert "Failed to set clipboard data" in err + assert "copy fail" in err + + # check stdout + assert out == "print\n" + + +@pytest.mark.skipif(not pyperclip_can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") +def test_get_paste_copy_exception_append(redirection_app, mocker, capsys) -> None: + # check clipboard type + assert isinstance(redirection_app.clipboard, PyperclipClipboard) + + # Force pyperclip.copy to throw an exception + _ = mocker.patch("pyperclip.copy", side_effect=ValueError("copy fail")) + + # Redirect command output to the clipboard + redirection_app.onecmd_plus_hooks("print_output >> ") + + # Make sure we got the exception output + out, err = capsys.readouterr() + + # this just checks that cmd2 is surfacing whatever error gets raised by pyperclip.copy + assert "ClipboardError" in err + assert "Failed to set clipboard data" in err + assert "copy fail" in err + + # check stdout + assert out == "print\n" + + +@pytest.mark.skipif(not pyperclip_can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") +def test_get_paste_buffer_exception_overwrite(redirection_app, mocker, capsys) -> None: + # check clipboard type + assert isinstance(redirection_app.clipboard, PyperclipClipboard) + + # Force pyperclip.paste to throw an exception + _ = mocker.patch("pyperclip.paste", side_effect=ValueError("paste fail")) + + # Redirect command output to the clipboard + redirection_app.onecmd_plus_hooks("print_output > ") + + # Make sure we got the output + out, err = capsys.readouterr() + + # Since pyperclip.paste is only called for appending, we don't expect an error + # at this point for overwriting the clipboard + assert err == "" + + # check stdout + assert out == "print\n" + + +@pytest.mark.skipif(not pyperclip_can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") +def test_get_paste_buffer_exception_append(redirection_app, mocker, capsys) -> None: + # check clipboard type + assert isinstance(redirection_app.clipboard, PyperclipClipboard) + + # Force pyperclip.paste to throw an exception + _ = mocker.patch("pyperclip.paste", side_effect=ValueError("paste fail")) + + # Redirect command output to the clipboard + redirection_app.onecmd_plus_hooks("print_output >> ") + + # Make sure we got the exception output + out, err = capsys.readouterr() + # this just checks that cmd2 is surfacing whatever error gets raised by pyperclip.paste - assert "ValueError" in err - assert "foo" in err + assert "ClipboardError" in err + assert "Failed to access clipboard data" in err + assert "paste fail" in err + + # check stdout + assert out == "" def test_allow_clipboard_initializer(redirection_app) -> None: