From 3f84119f5fabe788ae35b6450f69fb3933f93bb8 Mon Sep 17 00:00:00 2001 From: neoniobium Date: Fri, 17 Jul 2026 09:32:05 +0200 Subject: [PATCH 01/10] [Cmd] use prompt-toolkit native clipboard integration --- cmd2/clipboard.py | 21 --------------------- cmd2/cmd2.py | 10 ++++------ docs/api/clipboard.md | 3 --- docs/api/index.md | 1 - docs/features/clipboard.md | 9 ++++----- tests/test_cmd2.py | 17 +++++++++++++---- 6 files changed, 21 insertions(+), 40 deletions(-) delete mode 100644 cmd2/clipboard.py delete mode 100644 docs/api/clipboard.md 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..3e96f40ae 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -73,6 +73,7 @@ ) from prompt_toolkit.application import create_app_session, get_app from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +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 +118,6 @@ SubcommandRecord, SubcommandSpec, ) -from .clipboard import ( - get_paste_buffer, - write_to_paste_buffer, -) from .command_set import CommandSet from .completion import ( Choices, @@ -793,6 +790,7 @@ def _(event: Any) -> None: # pragma: no cover "refresh_interval": refresh_interval, "rprompt": self.get_rprompt if enable_rprompt else None, "style": DynamicStyle(get_pt_theme), + "clipboard": PyperclipClipboard(), } if self.stdin.isatty() and self.stdout.isatty(): @@ -3332,7 +3330,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: # 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 = self.main_session.clipboard.get_data().text # create a temporary file to store output new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+")) # noqa: SIM115 redir_saved_state.redirecting = True @@ -3362,7 +3360,7 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec and not statement.redirect_to ): self.stdout.seek(0) - write_to_paste_buffer(self.stdout.read()) + self.main_session.clipboard.set_text(self.stdout.read()) with contextlib.suppress(BrokenPipeError): # Close the file or pipe that stdout was redirected to 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..088948e76 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -12,6 +12,7 @@ ) from unittest import mock +import pyperclip # type: ignore[import-untyped] import pytest from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.completion import DummyCompleter @@ -29,7 +30,6 @@ CommandSet, Completions, SubcommandRecord, - clipboard, constants, exceptions, plugin, @@ -50,6 +50,15 @@ ) +def get_paste_buffer() -> str: + """ + Get the contents of the clipboard / paste buffer. This is just wrapper around + pyperclip paste() that provides the correct type annotation. + + """ + return cast(str, pyperclip.paste()) + + def create_outsim_app(): c = cmd2.Cmd() c.stdout = utils.StdSim(c.stdout) @@ -873,7 +882,7 @@ def test_pipe_to_shell_error(redirection_app) -> None: try: # try getting the contents of the clipboard - _ = clipboard.get_paste_buffer() + _ = get_paste_buffer() # 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 @@ -894,7 +903,7 @@ 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 = get_paste_buffer().splitlines() assert len(lines) == 1 assert lines[0] == "poutput" @@ -904,7 +913,7 @@ 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 = get_paste_buffer().splitlines() assert len(lines) == 2 assert lines[0] == "poutput" assert lines[1] == "poutput" From cf631fa9162a38840fe126b337e0b0642b540606 Mon Sep 17 00:00:00 2001 From: neoniobium Date: Sun, 19 Jul 2026 15:15:28 +0200 Subject: [PATCH 02/10] [Clipboard] Check clipboard on init a provide property. Also add unit tests and Changelog entry. --- CHANGELOG.md | 2 ++ cmd2/cmd2.py | 56 ++++++++++++++++++++++++++++++------------- tests/test_cmd2.py | 59 +++++++++++++++++++++++++++++++++------------- 3 files changed, 85 insertions(+), 32 deletions(-) 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/cmd2.py b/cmd2/cmd2.py index 3e96f40ae..9be66ef17 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -73,6 +73,7 @@ ) 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 @@ -386,31 +387,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. @@ -429,7 +431,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 @@ -440,7 +442,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: @@ -790,9 +792,20 @@ def _(event: Any) -> None: # pragma: no cover "refresh_interval": refresh_interval, "rprompt": self.get_rprompt if enable_rprompt else None, "style": DynamicStyle(get_pt_theme), - "clipboard": PyperclipClipboard(), } + # 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: @@ -1487,6 +1500,17 @@ def visible_prompt(self) -> str: """ return su.strip_style(self.prompt) + @property + def clipboard(self) -> Clipboard: + """The application clipboard. + + The clipboard the be either a ``PyperclipClipboard`` or an ``InMemoryClipboard`` + depending on weather 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, *, @@ -3325,12 +3349,9 @@ 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 = self.main_session.clipboard.get_data().text + # Get the current paste buffer from either the system clipboard if available + # or the in-memory clipboard only available to the main session + current_paste_buffer = self.clipboard.get_data().text # create a temporary file to store output new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+")) # noqa: SIM115 redir_saved_state.redirecting = True @@ -3360,7 +3381,10 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec and not statement.redirect_to ): self.stdout.seek(0) - self.main_session.clipboard.set_text(self.stdout.read()) + # 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 + self.clipboard.set_text(self.stdout.read()) with contextlib.suppress(BrokenPipeError): # Close the file or pipe that stdout was redirected to diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 088948e76..9da546ce1 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -15,6 +15,8 @@ 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 @@ -50,15 +52,6 @@ ) -def get_paste_buffer() -> str: - """ - Get the contents of the clipboard / paste buffer. This is just wrapper around - pyperclip paste() that provides the correct type annotation. - - """ - return cast(str, pyperclip.paste()) - - def create_outsim_app(): c = cmd2.Cmd() c.stdout = utils.StdSim(c.stdout) @@ -882,28 +875,31 @@ def test_pipe_to_shell_error(redirection_app) -> None: try: # try getting the contents of the 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 = get_paste_buffer().splitlines() + lines = redirection_app.clipboard.get_data().text.splitlines() assert len(lines) == 1 assert lines[0] == "poutput" @@ -913,14 +909,45 @@ def test_send_to_paste_buffer(redirection_app: RedirectionApp, capsys: pytest.Ca out, _err = capsys.readouterr() assert out == "print\n" - lines = 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_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 + pastemock = mocker.patch("pyperclip.paste") + pastemock.side_effect = ValueError("foo") + app = cmd2.Cmd(allow_clipboard=True) + + # Check if if the clipboard is an InMemoryClipboard when pyperclip cannot access + # the system clipboard + assert isinstance(app.clipboard, InMemoryClipboard) + + def test_get_paste_buffer_exception(redirection_app, mocker, capsys) -> None: - # Force get_paste_buffer to throw an exception + # Force pyperclip.paste to throw an exception which will be pastemock = mocker.patch("pyperclip.paste") pastemock.side_effect = ValueError("foo") From c95b19d81b527bb3040226d8d0cf4a284027e758 Mon Sep 17 00:00:00 2001 From: neoniobium Date: Sun, 19 Jul 2026 17:07:36 +0200 Subject: [PATCH 03/10] [Clipboard] unit test and custom exception for clipboard errors --- cmd2/cmd2.py | 13 ++++++++++--- cmd2/exceptions.py | 4 ++++ tests/test_cmd2.py | 22 ++++++++++++++++++++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 9be66ef17..cbe46ff2d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -135,6 +135,7 @@ with_argparser, ) from .exceptions import ( + ClipboardError, Cmd2ShlexError, CommandSetRegistrationError, CompletionError, @@ -797,7 +798,7 @@ def _(event: Any) -> None: # pragma: no cover # 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 + _ = 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 @@ -3351,7 +3352,10 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: # Get the current paste buffer from either the system clipboard if available # or the in-memory clipboard only available to the main session - current_paste_buffer = self.clipboard.get_data().text + 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 @@ -3384,7 +3388,10 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec # 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 - self.clipboard.set_text(self.stdout.read()) + try: + self.clipboard.set_text(self.stdout.read()) + except Exception as ex: + raise ClipboardError(f"Failed to set clipboard data: {ex}") from ex with contextlib.suppress(BrokenPipeError): # Close the file or pipe that stdout was redirected to 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/tests/test_cmd2.py b/tests/test_cmd2.py index 9da546ce1..6f1e31cef 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -946,8 +946,25 @@ def test_pyperclip_exception_on_init(mocker) -> None: assert isinstance(app.clipboard, InMemoryClipboard) +def test_get_paste_copy_exception(redirection_app, mocker, capsys) -> None: + # Force pyperclip.paste to throw an exception + pastemock = mocker.patch("pyperclip.copy") + pastemock.side_effect = ValueError("foo") + + # 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 == "print\n" + # this just checks that cmd2 is surfacing whatever error gets raised by pyperclip.paste + assert "ClipboardError" in err + assert "Failed to set clipboard data" in err + assert "foo" in err + + def test_get_paste_buffer_exception(redirection_app, mocker, capsys) -> None: - # Force pyperclip.paste to throw an exception which will be + # Force pyperclip.paste to throw an exception pastemock = mocker.patch("pyperclip.paste") pastemock.side_effect = ValueError("foo") @@ -958,7 +975,8 @@ def test_get_paste_buffer_exception(redirection_app, mocker, capsys) -> None: out, err = capsys.readouterr() assert out == "" # this just checks that cmd2 is surfacing whatever error gets raised by pyperclip.paste - assert "ValueError" in err + assert "ClipboardError" in err + assert "Failed to access clipboard data" in err assert "foo" in err From 158c2155f2aee2107068e93589f2fee70d0e5218 Mon Sep 17 00:00:00 2001 From: neoniobium Date: Sun, 19 Jul 2026 17:12:10 +0200 Subject: [PATCH 04/10] mypy --- cmd2/cmd2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index cbe46ff2d..1d4fa066f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -798,7 +798,7 @@ def _(event: Any) -> None: # pragma: no cover # 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 + 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 From dfe1bbd5e39237a91c4c94fcbd9051ace7c8bf25 Mon Sep 17 00:00:00 2001 From: neoniobium Date: Sun, 19 Jul 2026 17:25:23 +0200 Subject: [PATCH 05/10] text --- tests/test_cmd2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 6f1e31cef..54d1b5f3b 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -957,7 +957,7 @@ def test_get_paste_copy_exception(redirection_app, mocker, capsys) -> None: # Make sure we got the exception output out, err = capsys.readouterr() assert out == "print\n" - # this just checks that cmd2 is surfacing whatever error gets raised by pyperclip.paste + # 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 "foo" in err From c8c4a334223b9669b92363dcaf6b564109b48f05 Mon Sep 17 00:00:00 2001 From: neoniobium Date: Sun, 19 Jul 2026 17:36:16 +0200 Subject: [PATCH 06/10] [Clipboard] unit tests --- tests/test_cmd2.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 54d1b5f3b..eb1d2abd0 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -947,37 +947,43 @@ def test_pyperclip_exception_on_init(mocker) -> None: def test_get_paste_copy_exception(redirection_app, mocker, capsys) -> None: - # Force pyperclip.paste to throw an exception - pastemock = mocker.patch("pyperclip.copy") - pastemock.side_effect = ValueError("foo") + # Force pyperclip.copy to throw an exception + copymock = mocker.patch("pyperclip.copy") + copymock.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 == "print\n" + # 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 "foo" in err + assert "copy fail" in err + + # check stdout + assert out == "print\n" def test_get_paste_buffer_exception(redirection_app, mocker, capsys) -> None: # Force pyperclip.paste to throw an exception pastemock = mocker.patch("pyperclip.paste") - pastemock.side_effect = ValueError("foo") + pastemock.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() - assert out == "" + # this just checks that cmd2 is surfacing whatever error gets raised by pyperclip.paste assert "ClipboardError" in err assert "Failed to access clipboard data" in err - assert "foo" in err + assert "paste fail" in err + + # check stdout + assert out == "" def test_allow_clipboard_initializer(redirection_app) -> None: From 2bb5e683c9c129b9a43d9823da47383350382bbf Mon Sep 17 00:00:00 2001 From: neoniobium Date: Sun, 19 Jul 2026 18:12:46 +0200 Subject: [PATCH 07/10] [Clipboard] unit tests --- tests/test_cmd2.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index eb1d2abd0..f6c397e93 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -937,8 +937,7 @@ def test_init_with_clipboard_allowed() -> None: def test_pyperclip_exception_on_init(mocker) -> None: # Force pyperclip.paste to throw an exception - pastemock = mocker.patch("pyperclip.paste") - pastemock.side_effect = ValueError("foo") + _ = mocker.patch("pyperclip.paste", side_effect=ValueError("paste fail on init")) app = cmd2.Cmd(allow_clipboard=True) # Check if if the clipboard is an InMemoryClipboard when pyperclip cannot access @@ -946,10 +945,13 @@ def test_pyperclip_exception_on_init(mocker) -> None: 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(redirection_app, mocker, capsys) -> None: + # check clipboard type + assert isinstance(redirection_app.clipboard, PyperclipClipboard) + # Force pyperclip.copy to throw an exception - copymock = mocker.patch("pyperclip.copy") - copymock.side_effect = ValueError("copy fail") + _ = mocker.patch("pyperclip.copy", side_effect=ValueError("copy fail")) # Redirect command output to the clipboard redirection_app.onecmd_plus_hooks("print_output > ") @@ -966,10 +968,13 @@ def test_get_paste_copy_exception(redirection_app, mocker, capsys) -> None: 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(redirection_app, mocker, capsys) -> None: + # check clipboard type + assert isinstance(redirection_app.clipboard, PyperclipClipboard) + # Force pyperclip.paste to throw an exception - pastemock = mocker.patch("pyperclip.paste") - pastemock.side_effect = ValueError("paste fail") + _ = mocker.patch("pyperclip.paste", side_effect=ValueError("paste fail")) # Redirect command output to the clipboard redirection_app.onecmd_plus_hooks("print_output > ") From 861193a8e2d8dc3f36db891611b274e39499c698 Mon Sep 17 00:00:00 2001 From: neoniobium Date: Sun, 19 Jul 2026 21:04:03 +0200 Subject: [PATCH 08/10] [Clipboard] fixes --- cmd2/cmd2.py | 77 ++++++++++++++++++++++++---------------------- tests/test_cmd2.py | 50 ++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 40 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 1d4fa066f..e803678bf 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1505,8 +1505,8 @@ def visible_prompt(self) -> str: def clipboard(self) -> Clipboard: """The application clipboard. - The clipboard the be either a ``PyperclipClipboard`` or an ``InMemoryClipboard`` - depending on weather the system clipboard is accessible to ``pyperclip``. + 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 """ @@ -3329,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 @@ -3339,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: @@ -3350,19 +3350,20 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: if not self.allow_clipboard: raise RedirectionError("Clipboard access not allowed") - # 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 + # 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 + self.stdout.write(current_paste_buffer) self.stdout.flush() @@ -3379,34 +3380,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) - # 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 - - 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/tests/test_cmd2.py b/tests/test_cmd2.py index f6c397e93..40cc07e5f 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -940,13 +940,13 @@ def test_pyperclip_exception_on_init(mocker) -> None: _ = mocker.patch("pyperclip.paste", side_effect=ValueError("paste fail on init")) app = cmd2.Cmd(allow_clipboard=True) - # Check if if the clipboard is an InMemoryClipboard when pyperclip cannot access + # 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(redirection_app, mocker, capsys) -> None: +def test_get_paste_copy_exception_overwrite(redirection_app, mocker, capsys) -> None: # check clipboard type assert isinstance(redirection_app.clipboard, PyperclipClipboard) @@ -968,8 +968,30 @@ def test_get_paste_copy_exception(redirection_app, mocker, capsys) -> None: assert out == "print\n" +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(redirection_app, mocker, capsys) -> None: +def test_get_paste_buffer_exception_overwrite(redirection_app, mocker, capsys) -> None: # check clipboard type assert isinstance(redirection_app.clipboard, PyperclipClipboard) @@ -979,6 +1001,28 @@ def test_get_paste_buffer_exception(redirection_app, mocker, capsys) -> None: # 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() From c149c0bc749adff836c096199f155447fc24bd31 Mon Sep 17 00:00:00 2001 From: neoniobium Date: Sun, 19 Jul 2026 21:12:09 +0200 Subject: [PATCH 09/10] [Clipboard] unit tests --- tests/test_cmd2.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 40cc07e5f..454f987d0 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -968,6 +968,7 @@ def test_get_paste_copy_exception_overwrite(redirection_app, mocker, capsys) -> 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) From b734d160bd6de4a6b1a10694b4855b7a94bffaae Mon Sep 17 00:00:00 2001 From: neoniobium Date: Mon, 20 Jul 2026 18:23:59 +0200 Subject: [PATCH 10/10] [Clipboard] fix --- cmd2/cmd2.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e803678bf..328c4738e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3350,10 +3350,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: if not self.allow_clipboard: raise RedirectionError("Clipboard access not allowed") - # create a temporary file to store output - new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+")) # noqa: SIM115 - self.stdout = new_stdout - + 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: @@ -3364,6 +3361,11 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: 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 + self.stdout = new_stdout + + if statement.redirector == constants.REDIRECTION_APPEND: self.stdout.write(current_paste_buffer) self.stdout.flush()