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.

116 changes: 75 additions & 41 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 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,
*,
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Comment thread
neoniobium marked this conversation as resolved.

# 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:
Expand All @@ -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.
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
Loading
Loading