Skip to content

Commit fbabd9b

Browse files
authored
Merge pull request #1258 from python-cmd2/clipboard
allow_clipboard and other clipboard improvements
2 parents ee7599f + 97b0468 commit fbabd9b

File tree

7 files changed

+106
-66
lines changed

7 files changed

+106
-66
lines changed

CHANGELOG.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
* `cmd2` 2.5 supports Python 3.7+ (removed support for Python 3.6)
44
* Enhancements
55
* Removed dependency on `attrs` and replaced with [dataclasses](https://docs.python.org/3/library/dataclasses.html)
6+
* add `allow_clipboard` initialization parameter and attribute to disable ability to
7+
add output to the operating system clipboard
8+
69

710
## 2.4.3 (January 27, 2023)
811
* Bug Fixes
@@ -90,7 +93,7 @@
9093
* Added `ap_completer_type` keyword arg to `Cmd2ArgumentParser.__init__()` which saves a call
9194
to `set_ap_completer_type()`. This keyword will also work with `add_parser()` when creating subcommands
9295
if the base command's parser is a `Cmd2ArgumentParser`.
93-
* New function `register_argparse_argument_parameter()` allows developers to specify custom
96+
* New function `register_argparse_argument_parameter()` allows developers to specify custom
9497
parameters to be passed to the argparse parser's `add_argument()` method. These parameters will
9598
become accessible in the resulting argparse Action object when modifying `ArgparseCompleter` behavior.
9699
* Using `SimpleTable` in the output for the following commands to improve appearance.
@@ -263,12 +266,12 @@
263266

264267
## 1.3.7 (August 27, 2020)
265268
* Bug Fixes
266-
* Fixes an issue introduced in 1.3.0 with processing command strings containing terminator/separator
269+
* Fixes an issue introduced in 1.3.0 with processing command strings containing terminator/separator
267270
character(s) that are manually passed to a command that uses argparse.
268271

269272
## 1.3.6 (August 27, 2020)
270273
* Breaking changes
271-
* The functions cmd2 adds to Namespaces (`get_statement()` and `get_handler()`) are now
274+
* The functions cmd2 adds to Namespaces (`get_statement()` and `get_handler()`) are now
272275
`Cmd2AttributeWrapper` objects named `cmd2_statement` and `cmd2_handler`. This makes it
273276
easy to filter out which attributes in an `argparse.Namespace` were added by `cmd2`.
274277
* Deprecations
@@ -294,7 +297,7 @@
294297
* Breaking changes
295298
* CommandSet command functions (do_, complete_, help_) will no longer have the cmd2 app
296299
passed in as the first parameter after `self` since this is already a class member.
297-
* Renamed `install_command_set()` and `uninstall_command_set()` to `register_command_set()` and
300+
* Renamed `install_command_set()` and `uninstall_command_set()` to `register_command_set()` and
298301
`unregister_command_set()` for better name consistency.
299302
* Bug Fixes
300303
* Fixed help formatting bug in `Cmd2ArgumentParser` when `metavar` is a tuple
@@ -304,8 +307,8 @@
304307
* Removed explicit type hints that fail due to a bug in 3.5.2 favoring comment-based hints instead
305308
* When passing a ns_provider to an argparse command, will now attempt to resolve the correct
306309
CommandSet instance for self. If not, it'll fall back and pass in the cmd2 app
307-
* Other
308-
* Added missing doc-string for new cmd2.Cmd __init__ parameters
310+
* Other
311+
* Added missing doc-string for new cmd2.Cmd __init__ parameters
309312
introduced by CommandSet enhancement
310313

311314
## 1.3.2 (August 10, 2020)
@@ -320,8 +323,8 @@
320323
## 1.3.1 (August 6, 2020)
321324
* Bug Fixes
322325
* Fixed issue determining whether an argparse completer function required a reference to a containing
323-
CommandSet. Also resolves issues determining the correct CommandSet instance when calling the argparse
324-
argument completer function. Manifested as a TypeError when using `cmd2.Cmd.path_complete` as a completer
326+
CommandSet. Also resolves issues determining the correct CommandSet instance when calling the argparse
327+
argument completer function. Manifested as a TypeError when using `cmd2.Cmd.path_complete` as a completer
325328
for an argparse-based command defined in a CommandSet
326329

327330
## 1.3.0 (August 4, 2020)
@@ -330,15 +333,15 @@
330333
with your cmd2 application.
331334
* Other
332335
* Marked with_argparser_and_unknown_args pending deprecation and consolidated implementation into
333-
with_argparser
336+
with_argparser
334337

335338
## 1.2.1 (July 14, 2020)
336339
* Bug Fixes
337340
* Relax minimum version of `importlib-metadata` to >= 1.6.0 when using Python < 3.8
338341

339342
## 1.2.0 (July 13, 2020)
340343
* Bug Fixes
341-
* Fixed `typing` module compatibility issue with Python 3.5 prior to 3.5.4
344+
* Fixed `typing` module compatibility issue with Python 3.5 prior to 3.5.4
342345
* Enhancements
343346
* Switched to getting version using `importlib.metadata` instead of using `pkg_resources`
344347
* Improves `cmd2` application launch time on systems that have a lot of Python packages on `sys.path`
@@ -347,7 +350,7 @@
347350
## 1.1.0 (June 6, 2020)
348351
* Bug Fixes
349352
* Fixed issue where subcommand usage text could contain a subcommand alias instead of the actual name
350-
* Fixed bug in `ArgparseCompleter` where `fill_width` could become negative if `token_width` was large
353+
* Fixed bug in `ArgparseCompleter` where `fill_width` could become negative if `token_width` was large
351354
relative to the terminal width.
352355
* Enhancements
353356
* Made `ipy` consistent with `py` in the following ways
@@ -371,7 +374,7 @@
371374
after parsing fails, just return instead of raising an exception.
372375
* Added explicit handling of `SystemExit`. If a command raises this exception, the command loop will be
373376
gracefully stopped.
374-
377+
375378
## 1.0.2 (April 06, 2020)
376379
* Bug Fixes
377380
* Ctrl-C now stops a running text script instead of just the current `run_script` command
@@ -394,9 +397,9 @@
394397
* Bug Fixes
395398
* Corrected issue where the actual new value was not always being printed in do_set. This occurred in cases where
396399
the typed value differed from what the setter had converted it to.
397-
* Fixed bug where ANSI style sequences were not correctly handled in `utils.truncate_line()`.
400+
* Fixed bug where ANSI style sequences were not correctly handled in `utils.truncate_line()`.
398401
* Fixed bug where pyscripts could edit `cmd2.Cmd.py_locals` dictionary.
399-
* Fixed bug where cmd2 set `sys.path[0]` for a pyscript to cmd2's working directory instead of the
402+
* Fixed bug where cmd2 set `sys.path[0]` for a pyscript to cmd2's working directory instead of the
400403
script file's directory.
401404
* Fixed bug where `sys.path` was not being restored after a pyscript ran.
402405
* Enhancements

cmd2/clipboard.py

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,17 @@
22
"""
33
This module provides basic ability to copy from and paste to the clipboard/pastebuffer.
44
"""
5-
from typing import (
6-
cast,
7-
)
5+
import typing
86

97
import pyperclip # type: ignore[import]
108

11-
# noinspection PyProtectedMember
12-
13-
# Can we access the clipboard? Should always be true on Windows and Mac, but only sometimes on Linux
14-
# noinspection PyBroadException
15-
try:
16-
# Try getting the contents of the clipboard
17-
_ = pyperclip.paste()
18-
19-
# pyperclip raises at least the following types of exceptions. To be safe, just catch all Exceptions.
20-
# FileNotFoundError on Windows Subsystem for Linux (WSL) when Windows paths are removed from $PATH
21-
# ValueError for headless Linux systems without Gtk installed
22-
# AssertionError can be raised by paste_klipper().
23-
# PyperclipException for pyperclip-specific exceptions
24-
except Exception:
25-
can_clip = False
26-
else:
27-
can_clip = True
28-
299

3010
def get_paste_buffer() -> str:
3111
"""Get the contents of the clipboard / paste buffer.
3212
3313
:return: contents of the clipboard
3414
"""
35-
pb_str = cast(str, pyperclip.paste())
15+
pb_str = typing.cast(str, pyperclip.paste())
3616
return pb_str
3717

3818

cmd2/cmd2.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import pydoc
3838
import re
3939
import sys
40+
import tempfile
4041
import threading
4142
from code import (
4243
InteractiveConsole,
@@ -83,7 +84,6 @@
8384
CompletionItem,
8485
)
8586
from .clipboard import (
86-
can_clip,
8787
get_paste_buffer,
8888
write_to_paste_buffer,
8989
)
@@ -234,6 +234,7 @@ def __init__(
234234
shortcuts: Optional[Dict[str, str]] = None,
235235
command_sets: Optional[Iterable[CommandSet]] = None,
236236
auto_load_commands: bool = True,
237+
allow_clipboard: bool = True,
237238
) -> None:
238239
"""An easy but powerful framework for writing line-oriented command
239240
interpreters. Extends Python's cmd package.
@@ -281,6 +282,7 @@ def __init__(
281282
that are currently loaded by Python and automatically
282283
instantiate and register all commands. If False, CommandSets
283284
must be manually installed with `register_command_set`.
285+
:param allow_clipboard: If False, cmd2 will disable clipboard interactions
284286
"""
285287
# Check if py or ipy need to be disabled in this instance
286288
if not include_py:
@@ -434,8 +436,8 @@ def __init__(
434436
self.pager = 'less -RXF'
435437
self.pager_chop = 'less -SRXF'
436438

437-
# This boolean flag determines whether or not the cmd2 application can interact with the clipboard
438-
self._can_clip = can_clip
439+
# This boolean flag stores whether cmd2 will allow clipboard related features
440+
self.allow_clipboard = allow_clipboard
439441

440442
# This determines the value returned by cmdloop() when exiting the application
441443
self.exit_code = 0
@@ -2722,13 +2724,8 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
27222724
sys.stdout = self.stdout = new_stdout
27232725

27242726
elif statement.output:
2725-
import tempfile
2726-
2727-
if (not statement.output_to) and (not self._can_clip):
2728-
raise RedirectionError("Cannot redirect to paste buffer; missing 'pyperclip' and/or pyperclip dependencies")
2729-
2730-
# Redirecting to a file
2731-
elif statement.output_to:
2727+
if statement.output_to:
2728+
# redirecting to a file
27322729
# statement.output can only contain REDIRECTION_APPEND or REDIRECTION_OUTPUT
27332730
mode = 'a' if statement.output == constants.REDIRECTION_APPEND else 'w'
27342731
try:
@@ -2740,14 +2737,26 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
27402737
redir_saved_state.redirecting = True
27412738
sys.stdout = self.stdout = new_stdout
27422739

2743-
# Redirecting to a paste buffer
27442740
else:
2741+
# Redirecting to a paste buffer
2742+
# we are going to direct output to a temporary file, then read it back in and
2743+
# put it in the paste buffer later
2744+
if not self.allow_clipboard:
2745+
raise RedirectionError("Clipboard access not allowed")
2746+
2747+
# attempt to get the paste buffer, this forces pyperclip to go figure
2748+
# out if it can actually interact with the paste buffer, and will throw exceptions
2749+
# if it's not gonna work. That way we throw the exception before we go
2750+
# run the command and queue up all the output. if this is going to fail,
2751+
# no point opening up the temporary file
2752+
current_paste_buffer = get_paste_buffer()
2753+
# create a temporary file to store output
27452754
new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+"))
27462755
redir_saved_state.redirecting = True
27472756
sys.stdout = self.stdout = new_stdout
27482757

27492758
if statement.output == constants.REDIRECTION_APPEND:
2750-
self.stdout.write(get_paste_buffer())
2759+
self.stdout.write(current_paste_buffer)
27512760
self.stdout.flush()
27522761

27532762
# These are updated regardless of whether the command redirected
@@ -4537,8 +4546,6 @@ def do_history(self, args: argparse.Namespace) -> Optional[bool]:
45374546
self.last_result = True
45384547
return stop
45394548
elif args.edit:
4540-
import tempfile
4541-
45424549
fd, fname = tempfile.mkstemp(suffix='.txt', text=True)
45434550
fobj: TextIO
45444551
with os.fdopen(fd, 'w') as fobj:

docs/api/cmd.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,10 @@ cmd2.Cmd
6565
The symbol name which :ref:`features/scripting:Python Scripts` run
6666
using the :ref:`features/builtin_commands:run_pyscript` command can use
6767
to reference the parent ``cmd2`` application.
68+
69+
.. attribute:: allow_clipboard
70+
71+
If ``True``, ``cmd2`` will allow output to be written to or appended to
72+
the operating system pasteboard. If ``False``, this capability will not
73+
be allowed. See :ref:`features/clipboard:Clipboard Integration` for more
74+
information.

docs/features/clipboard.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ contents of the clipboard by ending the command with two greater than symbols:
2525
Developers
2626
----------
2727

28+
You can control whether the above user features of adding output to the
29+
operating system clipboard are allowed for the user by setting the
30+
:attr:`~cmd2.Cmd.allow_clipboard` attribute. The default value is ``True``.
31+
Set it to ``False`` and the above functionality will generate an error
32+
message instead of adding the output to the clipboard.
33+
:attr:`~cmd2.Cmd.allow_clipboard` can be set upon initialization, and you can
34+
change it at any time from within your code.
35+
2836
If you would like your ``cmd2`` based application to be able to use the
2937
clipboard in additional or alternative ways, you can use the following methods
3038
(which work uniformly on Windows, macOS, and Linux).

setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
'codecov',
6767
'doc8',
6868
'flake8',
69+
'black',
70+
'isort',
6971
'invoke',
7072
'mypy',
7173
'nox',

tests/test_cmd2.py

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,9 @@ def test_set(base_app):
171171
out, err = run_cmd(base_app, 'set quiet')
172172
expected = normalize(
173173
"""
174-
Name Value Description
174+
Name Value Description
175175
===================================================================================================
176-
quiet True Don't print nonessential feedback
176+
quiet True Don't print nonessential feedback
177177
"""
178178
)
179179
assert out == expected
@@ -730,7 +730,21 @@ def test_pipe_to_shell_error(base_app):
730730
assert "Pipe process exited with code" in err[0]
731731

732732

733-
@pytest.mark.skipif(not clipboard.can_clip, reason="Pyperclip could not find a copy/paste mechanism for your system")
733+
try:
734+
# try getting the contents of the clipboard
735+
_ = clipboard.get_paste_buffer()
736+
# pyperclip raises at least the following types of exceptions
737+
# FileNotFoundError on Windows Subsystem for Linux (WSL) when Windows paths are removed from $PATH
738+
# ValueError for headless Linux systems without Gtk installed
739+
# AssertionError can be raised by paste_klipper().
740+
# PyperclipException for pyperclip-specific exceptions
741+
except Exception:
742+
can_paste = False
743+
else:
744+
can_paste = True
745+
746+
747+
@pytest.mark.skipif(not can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system")
734748
def test_send_to_paste_buffer(base_app):
735749
# Test writing to the PasteBuffer/Clipboard
736750
run_cmd(base_app, 'help >')
@@ -744,6 +758,38 @@ def test_send_to_paste_buffer(base_app):
744758
assert len(appended_contents) > len(paste_contents)
745759

746760

761+
def test_get_paste_buffer_exception(base_app, mocker, capsys):
762+
# Force get_paste_buffer to throw an exception
763+
pastemock = mocker.patch('pyperclip.paste')
764+
pastemock.side_effect = ValueError('foo')
765+
766+
# Redirect command output to the clipboard
767+
base_app.onecmd_plus_hooks('help > ')
768+
769+
# Make sure we got the exception output
770+
out, err = capsys.readouterr()
771+
assert out == ''
772+
# this just checks that cmd2 is surfacing whatever error gets raised by pyperclip.paste
773+
assert 'ValueError' in err and 'foo' in err
774+
775+
776+
def test_allow_clipboard_initializer(base_app):
777+
assert base_app.allow_clipboard == True
778+
noclipcmd = cmd2.Cmd(allow_clipboard=False)
779+
assert noclipcmd.allow_clipboard == False
780+
781+
782+
# if clipboard access is not allowed, cmd2 should check that first
783+
# before it tries to do anything with pyperclip, that's why we can
784+
# safely run this test without skipping it if pyperclip doesn't
785+
# work in the test environment, like we do for test_send_to_paste_buffer()
786+
def test_allow_clipboard(base_app):
787+
base_app.allow_clipboard = False
788+
out, err = run_cmd(base_app, 'help >')
789+
assert not out
790+
assert "Clipboard access not allowed" in err
791+
792+
747793
def test_base_timing(base_app):
748794
base_app.feedback_to_output = False
749795
out, err = run_cmd(base_app, 'set timing True')
@@ -1566,19 +1612,6 @@ def test_multiline_input_line_to_statement(multiline_app):
15661612
assert statement.multiline_command == 'orate'
15671613

15681614

1569-
def test_clipboard_failure(base_app, capsys):
1570-
# Force cmd2 clipboard to be disabled
1571-
base_app._can_clip = False
1572-
1573-
# Redirect command output to the clipboard when a clipboard isn't present
1574-
base_app.onecmd_plus_hooks('help > ')
1575-
1576-
# Make sure we got the error output
1577-
out, err = capsys.readouterr()
1578-
assert out == ''
1579-
assert 'Cannot redirect to paste buffer;' in err and 'pyperclip' in err
1580-
1581-
15821615
class CommandResultApp(cmd2.Cmd):
15831616
def __init__(self, *args, **kwargs):
15841617
super().__init__(*args, **kwargs)

0 commit comments

Comments
 (0)