diff options
author | kotfu <kotfu@kotfu.net> | 2023-02-27 14:15:41 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-02-27 14:15:41 -0700 |
commit | fbabd9bff4c66766f74d50e3c05eaf58e5a6f6de (patch) | |
tree | 009d3f7f540411cc9a8f7bce9e294e6f034a9874 | |
parent | ee7599f9ac0dbb6ce3793f6b665ba1200d3ef9a3 (diff) | |
parent | 97b04689722be872e853017465adaaf1eb7cc591 (diff) | |
download | cmd2-git-fbabd9bff4c66766f74d50e3c05eaf58e5a6f6de.tar.gz |
Merge pull request #1258 from python-cmd2/clipboard
allow_clipboard and other clipboard improvements
-rw-r--r-- | CHANGELOG.md | 31 | ||||
-rw-r--r-- | cmd2/clipboard.py | 24 | ||||
-rw-r--r-- | cmd2/cmd2.py | 35 | ||||
-rw-r--r-- | docs/api/cmd.rst | 7 | ||||
-rw-r--r-- | docs/features/clipboard.rst | 8 | ||||
-rwxr-xr-x | setup.py | 2 | ||||
-rwxr-xr-x | tests/test_cmd2.py | 65 |
7 files changed, 106 insertions, 66 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e04405..e65c6b40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ * `cmd2` 2.5 supports Python 3.7+ (removed support for Python 3.6) * Enhancements * Removed dependency on `attrs` and replaced with [dataclasses](https://docs.python.org/3/library/dataclasses.html) + * add `allow_clipboard` initialization parameter and attribute to disable ability to + add output to the operating system clipboard + ## 2.4.3 (January 27, 2023) * Bug Fixes @@ -90,7 +93,7 @@ * Added `ap_completer_type` keyword arg to `Cmd2ArgumentParser.__init__()` which saves a call to `set_ap_completer_type()`. This keyword will also work with `add_parser()` when creating subcommands if the base command's parser is a `Cmd2ArgumentParser`. - * New function `register_argparse_argument_parameter()` allows developers to specify custom + * New function `register_argparse_argument_parameter()` allows developers to specify custom parameters to be passed to the argparse parser's `add_argument()` method. These parameters will become accessible in the resulting argparse Action object when modifying `ArgparseCompleter` behavior. * Using `SimpleTable` in the output for the following commands to improve appearance. @@ -263,12 +266,12 @@ ## 1.3.7 (August 27, 2020) * Bug Fixes - * Fixes an issue introduced in 1.3.0 with processing command strings containing terminator/separator + * Fixes an issue introduced in 1.3.0 with processing command strings containing terminator/separator character(s) that are manually passed to a command that uses argparse. ## 1.3.6 (August 27, 2020) * Breaking changes - * The functions cmd2 adds to Namespaces (`get_statement()` and `get_handler()`) are now + * The functions cmd2 adds to Namespaces (`get_statement()` and `get_handler()`) are now `Cmd2AttributeWrapper` objects named `cmd2_statement` and `cmd2_handler`. This makes it easy to filter out which attributes in an `argparse.Namespace` were added by `cmd2`. * Deprecations @@ -294,7 +297,7 @@ * Breaking changes * CommandSet command functions (do_, complete_, help_) will no longer have the cmd2 app passed in as the first parameter after `self` since this is already a class member. - * Renamed `install_command_set()` and `uninstall_command_set()` to `register_command_set()` and + * Renamed `install_command_set()` and `uninstall_command_set()` to `register_command_set()` and `unregister_command_set()` for better name consistency. * Bug Fixes * Fixed help formatting bug in `Cmd2ArgumentParser` when `metavar` is a tuple @@ -304,8 +307,8 @@ * Removed explicit type hints that fail due to a bug in 3.5.2 favoring comment-based hints instead * When passing a ns_provider to an argparse command, will now attempt to resolve the correct CommandSet instance for self. If not, it'll fall back and pass in the cmd2 app -* Other - * Added missing doc-string for new cmd2.Cmd __init__ parameters +* Other + * Added missing doc-string for new cmd2.Cmd __init__ parameters introduced by CommandSet enhancement ## 1.3.2 (August 10, 2020) @@ -320,8 +323,8 @@ ## 1.3.1 (August 6, 2020) * Bug Fixes * Fixed issue determining whether an argparse completer function required a reference to a containing - CommandSet. Also resolves issues determining the correct CommandSet instance when calling the argparse - argument completer function. Manifested as a TypeError when using `cmd2.Cmd.path_complete` as a completer + CommandSet. Also resolves issues determining the correct CommandSet instance when calling the argparse + argument completer function. Manifested as a TypeError when using `cmd2.Cmd.path_complete` as a completer for an argparse-based command defined in a CommandSet ## 1.3.0 (August 4, 2020) @@ -330,7 +333,7 @@ with your cmd2 application. * Other * Marked with_argparser_and_unknown_args pending deprecation and consolidated implementation into - with_argparser + with_argparser ## 1.2.1 (July 14, 2020) * Bug Fixes @@ -338,7 +341,7 @@ ## 1.2.0 (July 13, 2020) * Bug Fixes - * Fixed `typing` module compatibility issue with Python 3.5 prior to 3.5.4 + * Fixed `typing` module compatibility issue with Python 3.5 prior to 3.5.4 * Enhancements * Switched to getting version using `importlib.metadata` instead of using `pkg_resources` * Improves `cmd2` application launch time on systems that have a lot of Python packages on `sys.path` @@ -347,7 +350,7 @@ ## 1.1.0 (June 6, 2020) * Bug Fixes * Fixed issue where subcommand usage text could contain a subcommand alias instead of the actual name - * Fixed bug in `ArgparseCompleter` where `fill_width` could become negative if `token_width` was large + * Fixed bug in `ArgparseCompleter` where `fill_width` could become negative if `token_width` was large relative to the terminal width. * Enhancements * Made `ipy` consistent with `py` in the following ways @@ -371,7 +374,7 @@ after parsing fails, just return instead of raising an exception. * Added explicit handling of `SystemExit`. If a command raises this exception, the command loop will be gracefully stopped. - + ## 1.0.2 (April 06, 2020) * Bug Fixes * Ctrl-C now stops a running text script instead of just the current `run_script` command @@ -394,9 +397,9 @@ * Bug Fixes * Corrected issue where the actual new value was not always being printed in do_set. This occurred in cases where the typed value differed from what the setter had converted it to. - * Fixed bug where ANSI style sequences were not correctly handled in `utils.truncate_line()`. + * Fixed bug where ANSI style sequences were not correctly handled in `utils.truncate_line()`. * Fixed bug where pyscripts could edit `cmd2.Cmd.py_locals` dictionary. - * Fixed bug where cmd2 set `sys.path[0]` for a pyscript to cmd2's working directory instead of the + * Fixed bug where cmd2 set `sys.path[0]` for a pyscript to cmd2's working directory instead of the script file's directory. * Fixed bug where `sys.path` was not being restored after a pyscript ran. * Enhancements diff --git a/cmd2/clipboard.py b/cmd2/clipboard.py index fa1c23a7..eda92bf6 100644 --- a/cmd2/clipboard.py +++ b/cmd2/clipboard.py @@ -2,37 +2,17 @@ """ This module provides basic ability to copy from and paste to the clipboard/pastebuffer. """ -from typing import ( - cast, -) +import typing import pyperclip # type: ignore[import] -# noinspection PyProtectedMember - -# Can we access the clipboard? Should always be true on Windows and Mac, but only sometimes on Linux -# noinspection PyBroadException -try: - # Try getting the contents of the clipboard - _ = pyperclip.paste() - -# pyperclip raises at least the following types of exceptions. To be safe, just catch all 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: - can_clip = False -else: - can_clip = True - def get_paste_buffer() -> str: """Get the contents of the clipboard / paste buffer. :return: contents of the clipboard """ - pb_str = cast(str, pyperclip.paste()) + pb_str = typing.cast(str, pyperclip.paste()) return pb_str diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 97cfb77d..bdabee9c 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -37,6 +37,7 @@ import os import pydoc import re import sys +import tempfile import threading from code import ( InteractiveConsole, @@ -83,7 +84,6 @@ from .argparse_custom import ( CompletionItem, ) from .clipboard import ( - can_clip, get_paste_buffer, write_to_paste_buffer, ) @@ -234,6 +234,7 @@ class Cmd(cmd.Cmd): shortcuts: Optional[Dict[str, str]] = None, command_sets: Optional[Iterable[CommandSet]] = None, auto_load_commands: bool = True, + allow_clipboard: bool = True, ) -> None: """An easy but powerful framework for writing line-oriented command interpreters. Extends Python's cmd package. @@ -281,6 +282,7 @@ class Cmd(cmd.Cmd): that are currently loaded by Python and automatically instantiate and register all commands. If False, CommandSets must be manually installed with `register_command_set`. + :param allow_clipboard: If False, cmd2 will disable clipboard interactions """ # Check if py or ipy need to be disabled in this instance if not include_py: @@ -434,8 +436,8 @@ class Cmd(cmd.Cmd): self.pager = 'less -RXF' self.pager_chop = 'less -SRXF' - # This boolean flag determines whether or not the cmd2 application can interact with the clipboard - self._can_clip = can_clip + # This boolean flag stores whether cmd2 will allow clipboard related features + self.allow_clipboard = allow_clipboard # This determines the value returned by cmdloop() when exiting the application self.exit_code = 0 @@ -2722,13 +2724,8 @@ class Cmd(cmd.Cmd): sys.stdout = self.stdout = new_stdout elif statement.output: - import tempfile - - if (not statement.output_to) and (not self._can_clip): - raise RedirectionError("Cannot redirect to paste buffer; missing 'pyperclip' and/or pyperclip dependencies") - - # Redirecting to a file - elif statement.output_to: + if statement.output_to: + # redirecting to a file # statement.output can only contain REDIRECTION_APPEND or REDIRECTION_OUTPUT mode = 'a' if statement.output == constants.REDIRECTION_APPEND else 'w' try: @@ -2740,14 +2737,26 @@ class Cmd(cmd.Cmd): redir_saved_state.redirecting = True sys.stdout = self.stdout = new_stdout - # Redirecting to a paste buffer else: + # Redirecting to a paste buffer + # we are going to direct output to a temporary file, then read it back in and + # put it in the paste buffer later + 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() + # create a temporary file to store output new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+")) redir_saved_state.redirecting = True sys.stdout = self.stdout = new_stdout if statement.output == constants.REDIRECTION_APPEND: - self.stdout.write(get_paste_buffer()) + self.stdout.write(current_paste_buffer) self.stdout.flush() # These are updated regardless of whether the command redirected @@ -4537,8 +4546,6 @@ class Cmd(cmd.Cmd): self.last_result = True return stop elif args.edit: - import tempfile - fd, fname = tempfile.mkstemp(suffix='.txt', text=True) fobj: TextIO with os.fdopen(fd, 'w') as fobj: diff --git a/docs/api/cmd.rst b/docs/api/cmd.rst index 6fdfbf27..f9bd07c6 100644 --- a/docs/api/cmd.rst +++ b/docs/api/cmd.rst @@ -65,3 +65,10 @@ cmd2.Cmd The symbol name which :ref:`features/scripting:Python Scripts` run using the :ref:`features/builtin_commands:run_pyscript` command can use to reference the parent ``cmd2`` application. + + .. attribute:: allow_clipboard + + If ``True``, ``cmd2`` will allow output to be written to or appended to + the operating system pasteboard. If ``False``, this capability will not + be allowed. See :ref:`features/clipboard:Clipboard Integration` for more + information. diff --git a/docs/features/clipboard.rst b/docs/features/clipboard.rst index 73e206c2..a4b9cdf2 100644 --- a/docs/features/clipboard.rst +++ b/docs/features/clipboard.rst @@ -25,6 +25,14 @@ contents of the clipboard by ending the command with two greater than symbols: Developers ---------- +You can control whether the above user features of adding output to the +operating system clipboard are allowed for the user by setting the +:attr:`~cmd2.Cmd.allow_clipboard` attribute. The default value is ``True``. +Set it to ``False`` and the above functionality will generate an error +message instead of adding the output to the clipboard. +:attr:`~cmd2.Cmd.allow_clipboard` can be set upon initialization, and 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). @@ -66,6 +66,8 @@ EXTRAS_REQUIRE = { 'codecov', 'doc8', 'flake8', + 'black', + 'isort', 'invoke', 'mypy', 'nox', diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 5bcceb34..2270a907 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -171,9 +171,9 @@ now: True out, err = run_cmd(base_app, 'set quiet') expected = normalize( """ -Name Value Description +Name Value Description =================================================================================================== -quiet True Don't print nonessential feedback +quiet True Don't print nonessential feedback """ ) assert out == expected @@ -730,7 +730,21 @@ def test_pipe_to_shell_error(base_app): assert "Pipe process exited with code" in err[0] -@pytest.mark.skipif(not clipboard.can_clip, reason="Pyperclip could not find a copy/paste mechanism for your system") +try: + # try getting the contents of the clipboard + _ = clipboard.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 + # AssertionError can be raised by paste_klipper(). + # PyperclipException for pyperclip-specific exceptions +except Exception: + can_paste = False +else: + can_paste = True + + +@pytest.mark.skipif(not can_paste, reason="Pyperclip could not find a copy/paste mechanism for your system") def test_send_to_paste_buffer(base_app): # Test writing to the PasteBuffer/Clipboard run_cmd(base_app, 'help >') @@ -744,6 +758,38 @@ def test_send_to_paste_buffer(base_app): assert len(appended_contents) > len(paste_contents) +def test_get_paste_buffer_exception(base_app, mocker, capsys): + # Force get_paste_buffer to throw an exception + pastemock = mocker.patch('pyperclip.paste') + pastemock.side_effect = ValueError('foo') + + # Redirect command output to the clipboard + base_app.onecmd_plus_hooks('help > ') + + # 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 and 'foo' in err + + +def test_allow_clipboard_initializer(base_app): + assert base_app.allow_clipboard == True + noclipcmd = cmd2.Cmd(allow_clipboard=False) + assert noclipcmd.allow_clipboard == False + + +# if clipboard access is not allowed, cmd2 should check that first +# before it tries to do anything with pyperclip, that's why we can +# safely run this test without skipping it if pyperclip doesn't +# work in the test environment, like we do for test_send_to_paste_buffer() +def test_allow_clipboard(base_app): + base_app.allow_clipboard = False + out, err = run_cmd(base_app, 'help >') + assert not out + assert "Clipboard access not allowed" in err + + def test_base_timing(base_app): base_app.feedback_to_output = False out, err = run_cmd(base_app, 'set timing True') @@ -1566,19 +1612,6 @@ def test_multiline_input_line_to_statement(multiline_app): assert statement.multiline_command == 'orate' -def test_clipboard_failure(base_app, capsys): - # Force cmd2 clipboard to be disabled - base_app._can_clip = False - - # Redirect command output to the clipboard when a clipboard isn't present - base_app.onecmd_plus_hooks('help > ') - - # Make sure we got the error output - out, err = capsys.readouterr() - assert out == '' - assert 'Cannot redirect to paste buffer;' in err and 'pyperclip' in err - - class CommandResultApp(cmd2.Cmd): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) |