Repository: python-cmd2/cmd2
Pull request #1258 (2023-01-31 01:33:13)

allow_clipboard and other clipboard improvements
PR to address #1225 


Patch:
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).
diff --git a/setup.py b/setup.py
index b5724dd4..dae53695 100755
--- a/setup.py
+++ b/setup.py
@@ -66,6 +66,8 @@ EXTRAS_REQUIRE = {
         'codecov',
         'doc8',
         'flake8',
+        'black',
+        'isort',
         'invoke',
         'mypy',
         'nox',


Test patch:
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)