Skip to content
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 0 additions & 21 deletions cmd2/clipboard.py

This file was deleted.

67 changes: 48 additions & 19 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -137,6 +135,7 @@
with_argparser,
)
from .exceptions import (
ClipboardError,
Cmd2ShlexError,
CommandSetRegistrationError,
CompletionError,
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyperclipClipboard is instantiated even if allow_clipboard is False.

The docstring states that setting allow_clipboard to False disables clipboard interactions. However, PyperclipClipboard is currently added to the PromptSession regardless of this flag (if the system clipboard is accessible). This natively enables system clipboard pasting inside the prompt, which contradicts the intention of disabling all clipboard interactions. If allow_clipboard is meant to disable all system clipboard access, you should conditionally check self.allow_clipboard. (Note: If you apply this fix, you will also need to update test_init_with_no_clipboard_allowed).

Recommend wrapping code ath initializes cb inside a check:

if self.allow_clipboard:
    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:
Expand Down Expand Up @@ -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 the be either a ``PyperclipClipboard`` or an ``InMemoryClipboard``
Comment thread
neoniobium marked this conversation as resolved.
Outdated
depending on weather the system clipboard is accessible to ``pyperclip``.
Comment thread
neoniobium marked this conversation as resolved.
Outdated

:return: the clipboard of the application's main session
"""
return self.main_session.clipboard

def _get_core_print_console(
self,
*,
Expand Down Expand Up @@ -3327,12 +3350,12 @@ 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()
# 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
Comment thread
neoniobium marked this conversation as resolved.
Outdated
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
Expand Down Expand Up @@ -3362,7 +3385,13 @@ 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())
# 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
Comment thread
neoniobium marked this conversation as resolved.
Outdated

with contextlib.suppress(BrokenPipeError):
# Close the file or pipe that stdout was redirected to
Expand Down
4 changes: 4 additions & 0 deletions cmd2/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,7 @@ class MacroError(Exception):

class RedirectionError(Exception):
"""Custom exception class for when redirecting or piping output fails."""


class ClipboardError(Exception):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClipboardError will cause a generic stack trace since it is not caught in onecmd_plus_hooks.

ClipboardError inherits directly from Exception. Because there is no explicit except ClipboardError: block in onecmd_plus_hooks (where commands are executed), raising this error during output redirection will trigger the generic except Exception: handler, which prints a full stack trace via self.pexcept(ex).

You should either catch ClipboardError gracefully in onecmd_plus_hooks alongside RedirectionError, or simply have this class inherit from RedirectionError so it gets handled cleanly by the existing logic, i.e.:

class ClipboardError(RedirectionError):
    """Custom exception class for when clipboard operations fail."""

"""Custom exception class for when clipboard operations fail."""
3 changes: 0 additions & 3 deletions docs/api/clipboard.md

This file was deleted.

1 change: 0 additions & 1 deletion docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 4 additions & 5 deletions docs/features/clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 >
Expand Down Expand Up @@ -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
91 changes: 78 additions & 13 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,7 +32,6 @@
CommandSet,
Completions,
SubcommandRecord,
clipboard,
constants,
exceptions,
plugin,
Expand Down Expand Up @@ -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"

Expand All @@ -904,26 +909,86 @@ 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_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 if the clipboard is an InMemoryClipboard when pyperclip cannot access
Comment thread
neoniobium marked this conversation as resolved.
Outdated
# 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:
# 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:
# Force get_paste_buffer to throw an exception
pastemock = mocker.patch("pyperclip.paste")
pastemock.side_effect = ValueError("foo")
# 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()
assert out == ""

# 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:
Expand Down
Loading