Skip to content

Commit cf631fa

Browse files
committed
[Clipboard] Check clipboard on init a provide property. Also add unit tests and Changelog entry.
1 parent 0c9d215 commit cf631fa

3 files changed

Lines changed: 85 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
- `@with_annotated` argument groups can now contain an `ArgumentBlock`'s arguments. A `Group`
55
member names a command-line argument, and a block expands into one argument per field, so its
66
fields are named: `Group("host", "port")`.
7+
- `Cmd` now uses the `pyperclip` clipboard integration from `prompt_toolkit` as the default
8+
clipboard if available and provides it as a property.
79
- Breaking Changes
810
- A `Group` member now names an argument rather than a parameter. The two differ only for an
911
`ArgumentBlock` parameter, which is expanded away and has no argument of its own:

cmd2/cmd2.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
)
7474
from prompt_toolkit.application import create_app_session, get_app
7575
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
76+
from prompt_toolkit.clipboard import Clipboard
7677
from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
7778
from prompt_toolkit.completion import Completer, DummyCompleter
7879
from prompt_toolkit.formatted_text import ANSI, AnyFormattedText
@@ -386,31 +387,32 @@ def __init__(
386387
) -> None:
387388
"""Easy but powerful framework for writing line-oriented command interpreters, extends Python's cmd package.
388389
389-
:param completekey: name of a completion key, default to 'tab'. (If None or an empty string, 'tab' is used)
390+
:param completekey: name of a completion key, default to 'tab'. (If ``None`` or an empty string, 'tab' is used)
390391
:param stdin: alternate input file object, if not specified, sys.stdin is used
391392
:param stdout: alternate output file object, if not specified, sys.stdout is used
392393
:param allow_cli_args: if ``True``, then [cmd2.Cmd.__init__][] will process command
393394
line arguments as either commands to be run. This should be
394395
set to ``False`` if your application parses its own command line
395396
arguments.
396-
:param allow_clipboard: If False, cmd2 will disable clipboard interactions
397+
:param allow_clipboard: If ``False``, cmd2 will disable clipboard interactions
397398
:param allow_redirection: If ``False``, prevent output redirection and piping to shell
398399
commands. This parameter prevents redirection and piping, but
399400
does not alter parsing behavior. A user can still type
400401
redirection and piping tokens, and they will be parsed as such
401402
but they won't do anything.
402-
:param auto_load_commands: If True, cmd2 will check for all subclasses of `CommandSet`
403+
:param auto_load_commands: If ``True``, cmd2 will check for all subclasses of `CommandSet`
403404
that are currently loaded by Python and automatically
404-
instantiate and register all commands. If False, CommandSets
405+
instantiate and register all commands. If ``False``, CommandSets
405406
must be manually installed with `register_command_set`.
406-
:param auto_suggest: If True, cmd2 will provide fish shell style auto-suggestions
407+
:param auto_suggest: If ``True``, cmd2 will provide fish shell style auto-suggestions
407408
based on history. User can press right-arrow key to accept the
408409
provided suggestion.
409410
:param complete_in_thread: if ``True``, then completion will run in a separate thread.
410411
:param command_sets: Provide CommandSet instances to load during cmd2 initialization.
411412
This allows CommandSets with custom constructor parameters to be
412413
loaded. This also allows the a set of CommandSets to be provided
413-
when `auto_load_commands` is set to False
414+
when `auto_load_commands` is set to ``False``
415+
414416
:param enable_bottom_toolbar: if ``True``, enables a bottom toolbar while at the main prompt.
415417
Override ``get_bottom_toolbar()`` to define its content.
416418
:param enable_rprompt: if ``True``, enables a right prompt while at the main prompt.
@@ -429,7 +431,7 @@ def __init__(
429431
updates in the bottom toolbar).
430432
:param shortcuts: Mapping containing shortcuts for commands. If not supplied,
431433
then defaults to constants.DEFAULT_SHORTCUTS. If you do not want
432-
any shortcuts, pass None and an empty dictionary will be created.
434+
any shortcuts, pass ``None`` and an empty dictionary will be created.
433435
:param silence_startup_script: if ``True``, then the startup script's output will be
434436
suppressed. Anything written to stderr will still display.
435437
:param startup_script: file path to a script to execute at startup
@@ -440,7 +442,7 @@ def __init__(
440442
terminate single-line commands. If not supplied, the default
441443
is a semicolon. If your app only contains single-line commands
442444
and you want terminators to be treated as literals by the parser,
443-
then set this to None.
445+
then set this to ``None``.
444446
"""
445447
# Check if py or ipy need to be disabled in this instance
446448
if not include_py:
@@ -790,9 +792,20 @@ def _(event: Any) -> None: # pragma: no cover
790792
"refresh_interval": refresh_interval,
791793
"rprompt": self.get_rprompt if enable_rprompt else None,
792794
"style": DynamicStyle(get_pt_theme),
793-
"clipboard": PyperclipClipboard(),
794795
}
795796

797+
# Only enable PyperclipClipboard if the system clipboard is accessible to Pyperclip.
798+
try:
799+
cb = PyperclipClipboard()
800+
cb.get_data() # Check if the system clipboard is accessible to Pyperclip
801+
except Exception: # noqa: BLE001, S110
802+
# Prevent prompt_toolkit from crashing in headless environments and fallback
803+
# on prompt toolkit's default clipboard (InMemoryClipboard) by not providing
804+
# any argument for 'clipboard' in kwargs
805+
pass
806+
else:
807+
kwargs["clipboard"] = cb
808+
796809
if self.stdin.isatty() and self.stdout.isatty():
797810
try:
798811
if self.stdin != sys.stdin:
@@ -1487,6 +1500,17 @@ def visible_prompt(self) -> str:
14871500
"""
14881501
return su.strip_style(self.prompt)
14891502

1503+
@property
1504+
def clipboard(self) -> Clipboard:
1505+
"""The application clipboard.
1506+
1507+
The clipboard the be either a ``PyperclipClipboard`` or an ``InMemoryClipboard``
1508+
depending on weather the system clipboard is accessible to ``pyperclip``.
1509+
1510+
:return: the clipboard of the application's main session
1511+
"""
1512+
return self.main_session.clipboard
1513+
14901514
def _get_core_print_console(
14911515
self,
14921516
*,
@@ -3325,12 +3349,9 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
33253349
if not self.allow_clipboard:
33263350
raise RedirectionError("Clipboard access not allowed")
33273351

3328-
# attempt to get the paste buffer, this forces pyperclip to go figure
3329-
# out if it can actually interact with the paste buffer, and will throw exceptions
3330-
# if it's not gonna work. That way we throw the exception before we go
3331-
# run the command and queue up all the output. if this is going to fail,
3332-
# no point opening up the temporary file
3333-
current_paste_buffer = self.main_session.clipboard.get_data().text
3352+
# Get the current paste buffer from either the system clipboard if available
3353+
# or the in-memory clipboard only available to the main session
3354+
current_paste_buffer = self.clipboard.get_data().text
33343355
# create a temporary file to store output
33353356
new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+")) # noqa: SIM115
33363357
redir_saved_state.redirecting = True
@@ -3360,7 +3381,10 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec
33603381
and not statement.redirect_to
33613382
):
33623383
self.stdout.seek(0)
3363-
self.main_session.clipboard.set_text(self.stdout.read())
3384+
# Read stdout into the clipboard. Uses the system clipboard if available
3385+
# otherwise fall back to the in-memory clipboard only available to the main
3386+
# session
3387+
self.clipboard.set_text(self.stdout.read())
33643388

33653389
with contextlib.suppress(BrokenPipeError):
33663390
# Close the file or pipe that stdout was redirected to

tests/test_cmd2.py

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import pyperclip # type: ignore[import-untyped]
1616
import pytest
1717
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
18+
from prompt_toolkit.clipboard.in_memory import InMemoryClipboard
19+
from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
1820
from prompt_toolkit.completion import DummyCompleter
1921
from prompt_toolkit.input import DummyInput, create_pipe_input
2022
from prompt_toolkit.output import DummyOutput
@@ -50,15 +52,6 @@
5052
)
5153

5254

53-
def get_paste_buffer() -> str:
54-
"""
55-
Get the contents of the clipboard / paste buffer. This is just wrapper around
56-
pyperclip paste() that provides the correct type annotation.
57-
58-
"""
59-
return cast(str, pyperclip.paste())
60-
61-
6255
def create_outsim_app():
6356
c = cmd2.Cmd()
6457
c.stdout = utils.StdSim(c.stdout)
@@ -882,28 +875,31 @@ def test_pipe_to_shell_error(redirection_app) -> None:
882875

883876
try:
884877
# try getting the contents of the clipboard
885-
_ = get_paste_buffer()
878+
_ = pyperclip.paste()
886879
# pyperclip raises at least the following types of exceptions
887880
# FileNotFoundError on Windows Subsystem for Linux (WSL) when Windows paths are removed from $PATH
888881
# ValueError for headless Linux systems without Gtk installed
889882
# AssertionError can be raised by paste_klipper().
890883
# PyperclipException for pyperclip-specific exceptions
891884
except Exception: # noqa: BLE001
892-
can_paste = False
885+
pyperclip_can_paste = False
893886
else:
894-
can_paste = True
887+
pyperclip_can_paste = True
895888

896889

897-
@pytest.mark.skipif(not can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system")
890+
@pytest.mark.skipif(not pyperclip_can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system")
898891
def test_send_to_paste_buffer(redirection_app: RedirectionApp, capsys: pytest.CaptureFixture[str]) -> None:
899892
# Test writing to the PasteBuffer/Clipboard
900893
run_cmd(redirection_app, "print_output >")
901894

895+
# check if the clipboard is a PyperclipClipboard
896+
assert isinstance(redirection_app.clipboard, PyperclipClipboard)
897+
902898
# Verify print() went to sys.stdout
903899
out, _err = capsys.readouterr()
904900
assert out == "print\n"
905901

906-
lines = get_paste_buffer().splitlines()
902+
lines = redirection_app.clipboard.get_data().text.splitlines()
907903
assert len(lines) == 1
908904
assert lines[0] == "poutput"
909905

@@ -913,14 +909,45 @@ def test_send_to_paste_buffer(redirection_app: RedirectionApp, capsys: pytest.Ca
913909
out, _err = capsys.readouterr()
914910
assert out == "print\n"
915911

916-
lines = get_paste_buffer().splitlines()
912+
lines = redirection_app.clipboard.get_data().text.splitlines()
917913
assert len(lines) == 2
918914
assert lines[0] == "poutput"
919915
assert lines[1] == "poutput"
920916

921917

918+
def test_init_with_no_clipboard_allowed() -> None:
919+
app = cmd2.Cmd(allow_clipboard=False)
920+
921+
# Check for the clipboard type
922+
if pyperclip_can_paste:
923+
assert isinstance(app.clipboard, PyperclipClipboard)
924+
else:
925+
assert isinstance(app.clipboard, InMemoryClipboard)
926+
927+
928+
def test_init_with_clipboard_allowed() -> None:
929+
app = cmd2.Cmd(allow_clipboard=True)
930+
931+
# Check for the clipboard type
932+
if pyperclip_can_paste:
933+
assert isinstance(app.clipboard, PyperclipClipboard)
934+
else:
935+
assert isinstance(app.clipboard, InMemoryClipboard)
936+
937+
938+
def test_pyperclip_exception_on_init(mocker) -> None:
939+
# Force pyperclip.paste to throw an exception
940+
pastemock = mocker.patch("pyperclip.paste")
941+
pastemock.side_effect = ValueError("foo")
942+
app = cmd2.Cmd(allow_clipboard=True)
943+
944+
# Check if if the clipboard is an InMemoryClipboard when pyperclip cannot access
945+
# the system clipboard
946+
assert isinstance(app.clipboard, InMemoryClipboard)
947+
948+
922949
def test_get_paste_buffer_exception(redirection_app, mocker, capsys) -> None:
923-
# Force get_paste_buffer to throw an exception
950+
# Force pyperclip.paste to throw an exception which will be
924951
pastemock = mocker.patch("pyperclip.paste")
925952
pastemock.side_effect = ValueError("foo")
926953

0 commit comments

Comments
 (0)