Repository: python-cmd2/cmd2
Pull request #643 (2019-03-10 04:53:35)

Disable commands
Added way to disable/enable individual commands and entire categories of commands.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7a985b7..043962ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,14 @@
     * Added ``matches_sort_key`` to override the default way tab completion matches are sorted
     * Added ``StdSim.pause_storage`` member which when True will cause ``StdSim`` to not save the output sent to it.
       See documentation for ``CommandResult`` in ``pyscript_bridge.py`` for reasons pausing the storage can be useful.
+    * Added ability to disable/enable individual commands and entire categories of commands. When a command
+      is disabled, it will not show up in the help menu or tab complete. If a user tries to run the command
+      or call help on it, a command-specific message supplied by the developer will be printed. The following
+      commands were added to support this feature.
+        * ``enable_command()``
+        * ``enable_category()``
+        * ``disable_command()``
+        * ``disable_category()``
 * Potentially breaking changes
     * Made ``cmd2_app`` a positional and required argument of ``AutoCompleter`` since certain functionality now
     requires that it can't be ``None``.
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 0d1d0b51..1767cc67 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -37,6 +37,7 @@ import os
 import re
 import sys
 import threading
+from collections import namedtuple
 from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union, IO
 
 import colorama
@@ -279,6 +280,10 @@ class EmptyStatement(Exception):
     pass
 
 
+# Contains data about a disabled command which is used to restore its original functions when the command is enabled
+DisabledCommand = namedtuple('DisabledCommand', ['command_function', 'help_function'])
+
+
 class Cmd(cmd.Cmd):
     """An easy but powerful framework for writing line-oriented command interpreters.
 
@@ -521,6 +526,11 @@ class Cmd(cmd.Cmd):
         # being printed by a command.
         self.terminal_lock = threading.RLock()
 
+        # Commands that have been disabled from use. This is to support commands that are only available
+        # during specific states of the application. This dictionary's keys are the command names and its
+        # values are DisabledCommand objects.
+        self.disabled_commands = dict()
+
     # -----  Methods related to presenting output to the user -----
 
     @property
@@ -1562,7 +1572,7 @@ class Cmd(cmd.Cmd):
                 if name.startswith(COMMAND_FUNC_PREFIX) and callable(getattr(self, name))]
 
     def get_visible_commands(self) -> List[str]:
-        """Returns a list of commands that have not been hidden."""
+        """Returns a list of commands that have not been hidden or disabled."""
         commands = self.get_all_commands()
 
         # Remove the hidden commands
@@ -1570,6 +1580,11 @@ class Cmd(cmd.Cmd):
             if name in commands:
                 commands.remove(name)
 
+        # Remove the disabled commands
+        for name in self.disabled_commands:
+            if name in commands:
+                commands.remove(name)
+
         return commands
 
     def get_alias_names(self) -> List[str]:
@@ -1953,7 +1968,7 @@ class Cmd(cmd.Cmd):
     def onecmd(self, statement: Union[Statement, str]) -> bool:
         """ This executes the actual do_* method for a command.
 
-        If the command provided doesn't exist, then it executes _default() instead.
+        If the command provided doesn't exist, then it executes default() instead.
 
         :param statement: intended to be a Statement instance parsed command from the input stream, alternative
                           acceptance of a str is present only for backward compatibility with cmd
@@ -1969,8 +1984,9 @@ class Cmd(cmd.Cmd):
         else:
             func = self.cmd_func(statement.command)
             if func:
-                # Since we have a valid command store it in the history
-                if statement.command not in self.exclude_from_history:
+                # Check to see if this command should be stored in history
+                if statement.command not in self.exclude_from_history \
+                        and statement.command not in self.disabled_commands:
                     self.history.append(statement)
 
                 stop = func(statement)
@@ -3186,13 +3202,15 @@ class Cmd(cmd.Cmd):
 
         # -v must be used alone with no other options
         if args.verbose:
-            if args.clear or args.edit or args.output_file or args.run or args.transcript or args.expanded or args.script:
+            if args.clear or args.edit or args.output_file or args.run or args.transcript \
+                    or args.expanded or args.script:
                 self.poutput("-v can not be used with any other options")
                 self.poutput(self.history_parser.format_usage())
                 return
 
         # -s and -x can only be used if none of these options are present: [-c -r -e -o -t]
-        if (args.script or args.expanded) and (args.clear or args.edit or args.output_file or args.run or args.transcript):
+        if (args.script or args.expanded) \
+                and (args.clear or args.edit or args.output_file or args.run or args.transcript):
             self.poutput("-s and -x can not be used with -c, -r, -e, -o, or -t")
             self.poutput(self.history_parser.format_usage())
             return
@@ -3598,6 +3616,95 @@ class Cmd(cmd.Cmd):
         else:
             raise RuntimeError("another thread holds terminal_lock")
 
+    def enable_command(self, command: str) -> None:
+        """
+        Enable a command by restoring its functions
+        :param command: the command being enabled
+        """
+        # If the commands is already enabled, then return
+        if command not in self.disabled_commands:
+            return
+
+        help_func_name = HELP_FUNC_PREFIX + command
+
+        # Restore the command and help functions to their original values
+        dc = self.disabled_commands[command]
+        setattr(self, self.cmd_func_name(command), dc.command_function)
+
+        if dc.help_function is None:
+            delattr(self, help_func_name)
+        else:
+            setattr(self, help_func_name, dc.help_function)
+
+        # Remove the disabled command entry
+        del self.disabled_commands[command]
+
+    def enable_category(self, category: str) -> None:
+        """
+        Enable an entire category of commands
+        :param category: the category to enable
+        """
+        for cmd_name in list(self.disabled_commands):
+            dc = self.disabled_commands[cmd_name]
+            cmd_category = getattr(dc.command_function, HELP_CATEGORY, None)
+            if cmd_category is not None and cmd_category == category:
+                self.enable_command(cmd_name)
+
+    def disable_command(self, command: str, message_to_print: str) -> None:
+        """
+        Disable a command and overwrite its functions
+        :param command: the command being disabled
+        :param message_to_print: what to print when this command is run or help is called on it while disabled
+        """
+        import functools
+
+        # If the commands is already disabled, then return
+        if command in self.disabled_commands:
+            return
+
+        # Make sure this is an actual command
+        command_function = self.cmd_func(command)
+        if command_function is None:
+            raise AttributeError("{} does not refer to a command".format(command))
+
+        help_func_name = HELP_FUNC_PREFIX + command
+
+        # Add the disabled command record
+        self.disabled_commands[command] = DisabledCommand(command_function=command_function,
+                                                          help_function=getattr(self, help_func_name, None))
+
+        # Overwrite the command and help functions to print the message
+        new_func = functools.partial(self._report_disabled_command_usage, message_to_print=message_to_print)
+        setattr(self, self.cmd_func_name(command), new_func)
+        setattr(self, help_func_name, new_func)
+
+    def disable_category(self, category: str, message_to_print: str) -> None:
+        """
+        Disable an entire category of commands
+        :param category: the category to disable
+        :param message_to_print: what to print when anything in this category is run or help is called on it
+                                 while disabled
+        """
+        all_commands = self.get_all_commands()
+
+        for cmd_name in all_commands:
+            func = self.cmd_func(cmd_name)
+            cmd_category = getattr(func, HELP_CATEGORY, None)
+
+            # If this command is in the category, then disable it
+            if cmd_category is not None and cmd_category == category:
+                self.disable_command(cmd_name, message_to_print)
+
+    # noinspection PyUnusedLocal
+    def _report_disabled_command_usage(self, *args, message_to_print: str, **kwargs) -> None:
+        """
+        Report when a disabled command has been run or had help called on it
+        :param args: not used
+        :param message_to_print: the message reporting that the command is disabled
+        :param kwargs: not used
+        """
+        self.poutput(message_to_print)
+
     def cmdloop(self, intro: Optional[str] = None) -> None:
         """This is an outer wrapper around _cmdloop() which deals with extra features provided by cmd2.
 
diff --git a/docs/argument_processing.rst b/docs/argument_processing.rst
index 4c77fa80..bad683bf 100644
--- a/docs/argument_processing.rst
+++ b/docs/argument_processing.rst
@@ -195,138 +195,6 @@ Which yields:
 .. _argparse: https://docs.python.org/3/library/argparse.html
 
 
-Grouping Commands
-=================
-
-By default, the ``help`` command displays::
-
-  Documented commands (type help <topic>):
-  ========================================
-  alias    findleakers  pyscript    sessions             status       vminfo
-  config   help         quit        set                  stop         which
-  connect  history      redeploy    shell                thread_dump
-  deploy   list         resources   shortcuts            unalias
-  edit     load         restart     sslconnectorciphers  undeploy
-  expire   py           serverinfo  start                version
-
-If you have a large number of commands, you can optionally group your commands into categories.
-Here's the output from the example ``help_categories.py``::
-
-  Documented commands (type help <topic>):
-
-  Application Management
-  ======================
-  deploy  findleakers  redeploy  sessions  stop
-  expire  list         restart   start     undeploy
-
-  Connecting
-  ==========
-  connect  which
-
-  Server Information
-  ==================
-  resources  serverinfo  sslconnectorciphers  status  thread_dump  vminfo
-
-  Other
-  =====
-  alias   edit  history  py        quit  shell      unalias
-  config  help  load     pyscript  set   shortcuts  version
-
-
-There are 2 methods of specifying command categories, using the ``@with_category`` decorator or with the
-``categorize()`` function. Once a single command category is detected, the help output switches to a categorized
-mode of display. All commands with an explicit category defined default to the category `Other`.
-
-Using the ``@with_category`` decorator::
-
-  @with_category(CMD_CAT_CONNECTING)
-  def do_which(self, _):
-      """Which command"""
-      self.poutput('Which')
-
-Using the ``categorize()`` function:
-
-    You can call with a single function::
-
-        def do_connect(self, _):
-            """Connect command"""
-            self.poutput('Connect')
-
-        # Tag the above command functions under the category Connecting
-        categorize(do_connect, CMD_CAT_CONNECTING)
-
-    Or with an Iterable container of functions::
-
-        def do_undeploy(self, _):
-            """Undeploy command"""
-            self.poutput('Undeploy')
-
-        def do_stop(self, _):
-            """Stop command"""
-            self.poutput('Stop')
-
-        def do_findleakers(self, _):
-            """Find Leakers command"""
-            self.poutput('Find Leakers')
-
-        # Tag the above command functions under the category Application Management
-        categorize((do_undeploy,
-                    do_stop,
-                    do_findleakers), CMD_CAT_APP_MGMT)
-
-The ``help`` command also has a verbose option (``help -v`` or ``help --verbose``) that combines
-the help categories with per-command Help Messages::
-
-    Documented commands (type help <topic>):
-
-    Application Management
-    ================================================================================
-    deploy              Deploy command
-    expire              Expire command
-    findleakers         Find Leakers command
-    list                List command
-    redeploy            Redeploy command
-    restart             usage: restart [-h] {now,later,sometime,whenever}
-    sessions            Sessions command
-    start               Start command
-    stop                Stop command
-    undeploy            Undeploy command
-
-    Connecting
-    ================================================================================
-    connect             Connect command
-    which               Which command
-
-    Server Information
-    ================================================================================
-    resources              Resources command
-    serverinfo             Server Info command
-    sslconnectorciphers    SSL Connector Ciphers command is an example of a command that contains
-                           multiple lines of help information for the user. Each line of help in a
-                           contiguous set of lines will be printed and aligned in the verbose output
-                           provided with 'help --verbose'
-    status                 Status command
-    thread_dump            Thread Dump command
-    vminfo                 VM Info command
-
-    Other
-    ================================================================================
-    alias               Define or display aliases
-    config              Config command
-    edit                Edit a file in a text editor
-    help                List available commands with "help" or detailed help with "help cmd"
-    history             usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT] [arg]
-    load                Runs commands in script file that is encoded as either ASCII or UTF-8 text
-    py                  Invoke python command, shell, or script
-    pyscript            Runs a python script file inside the console
-    quit                Exits this application
-    set                 usage: set [-h] [-a] [-l] [settable [settable ...]]
-    shell               Execute a command as if at the OS prompt
-    shortcuts           Lists shortcuts available
-    unalias             Unsets aliases
-    version             Version command
-
-
 Receiving an argument list
 ==========================
 
diff --git a/docs/unfreefeatures.rst b/docs/unfreefeatures.rst
index 2cdc17ba..97953215 100644
--- a/docs/unfreefeatures.rst
+++ b/docs/unfreefeatures.rst
@@ -237,3 +237,165 @@ set_window_title()
 The easiest way to understand these functions is to see the AsyncPrinting_ example for a demonstration.
 
 .. _AsyncPrinting: https://github.com/python-cmd2/cmd2/blob/master/examples/async_printing.py
+
+
+Grouping Commands
+=================
+
+By default, the ``help`` command displays::
+
+  Documented commands (type help <topic>):
+  ========================================
+  alias    findleakers  pyscript    sessions             status       vminfo
+  config   help         quit        set                  stop         which
+  connect  history      redeploy    shell                thread_dump
+  deploy   list         resources   shortcuts            unalias
+  edit     load         restart     sslconnectorciphers  undeploy
+  expire   py           serverinfo  start                version
+
+If you have a large number of commands, you can optionally group your commands into categories.
+Here's the output from the example ``help_categories.py``::
+
+  Documented commands (type help <topic>):
+
+  Application Management
+  ======================
+  deploy  findleakers  redeploy  sessions  stop
+  expire  list         restart   start     undeploy
+
+  Connecting
+  ==========
+  connect  which
+
+  Server Information
+  ==================
+  resources  serverinfo  sslconnectorciphers  status  thread_dump  vminfo
+
+  Other
+  =====
+  alias   edit  history  py        quit  shell      unalias
+  config  help  load     pyscript  set   shortcuts  version
+
+
+There are 2 methods of specifying command categories, using the ``@with_category`` decorator or with the
+``categorize()`` function. Once a single command category is detected, the help output switches to a categorized
+mode of display. All commands with an explicit category defined default to the category `Other`.
+
+Using the ``@with_category`` decorator::
+
+  @with_category(CMD_CAT_CONNECTING)
+  def do_which(self, _):
+      """Which command"""
+      self.poutput('Which')
+
+Using the ``categorize()`` function:
+
+    You can call with a single function::
+
+        def do_connect(self, _):
+            """Connect command"""
+            self.poutput('Connect')
+
+        # Tag the above command functions under the category Connecting
+        categorize(do_connect, CMD_CAT_CONNECTING)
+
+    Or with an Iterable container of functions::
+
+        def do_undeploy(self, _):
+            """Undeploy command"""
+            self.poutput('Undeploy')
+
+        def do_stop(self, _):
+            """Stop command"""
+            self.poutput('Stop')
+
+        def do_findleakers(self, _):
+            """Find Leakers command"""
+            self.poutput('Find Leakers')
+
+        # Tag the above command functions under the category Application Management
+        categorize((do_undeploy,
+                    do_stop,
+                    do_findleakers), CMD_CAT_APP_MGMT)
+
+The ``help`` command also has a verbose option (``help -v`` or ``help --verbose``) that combines
+the help categories with per-command Help Messages::
+
+    Documented commands (type help <topic>):
+
+    Application Management
+    ================================================================================
+    deploy              Deploy command
+    expire              Expire command
+    findleakers         Find Leakers command
+    list                List command
+    redeploy            Redeploy command
+    restart             usage: restart [-h] {now,later,sometime,whenever}
+    sessions            Sessions command
+    start               Start command
+    stop                Stop command
+    undeploy            Undeploy command
+
+    Connecting
+    ================================================================================
+    connect             Connect command
+    which               Which command
+
+    Server Information
+    ================================================================================
+    resources              Resources command
+    serverinfo             Server Info command
+    sslconnectorciphers    SSL Connector Ciphers command is an example of a command that contains
+                           multiple lines of help information for the user. Each line of help in a
+                           contiguous set of lines will be printed and aligned in the verbose output
+                           provided with 'help --verbose'
+    status                 Status command
+    thread_dump            Thread Dump command
+    vminfo                 VM Info command
+
+    Other
+    ================================================================================
+    alias               Define or display aliases
+    config              Config command
+    edit                Edit a file in a text editor
+    help                List available commands with "help" or detailed help with "help cmd"
+    history             usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT] [arg]
+    load                Runs commands in script file that is encoded as either ASCII or UTF-8 text
+    py                  Invoke python command, shell, or script
+    pyscript            Runs a python script file inside the console
+    quit                Exits this application
+    set                 usage: set [-h] [-a] [-l] [settable [settable ...]]
+    shell               Execute a command as if at the OS prompt
+    shortcuts           Lists shortcuts available
+    unalias             Unsets aliases
+    version             Version command
+
+
+Disabling Commands
+==================
+
+``cmd2`` supports disabling commands during runtime. This is useful if certain commands should only be available
+when the application is in a specific state. When a command is disabled, it will not show up in the help menu or
+tab complete. If a user tries to run the command, a command-specific message supplied by the developer will be
+printed. The following functions support this feature.
+
+enable_command()
+    Enable an individual command
+
+enable_category()
+    Enable an entire category of commands
+
+disable_command()
+    Disable an individual command and set the message that will print when this command is run or help is called
+    on it while disabled
+
+disable_category()
+    Disable an entire category of commands and set the message that will print when anything in this category is
+    run or help is called on it while disabled
+
+See the definitions of these functions for descriptions of their arguments.
+
+See the ``do_enable_commands()`` and ``do_disable_commands()`` functions in the HelpCategories_ example for
+a demonstration.
+
+.. _HelpCategories: https://github.com/python-cmd2/cmd2/blob/master/examples/help_categories.py
diff --git a/examples/help_categories.py b/examples/help_categories.py
index 50b2c17d..e58bac02 100755
--- a/examples/help_categories.py
+++ b/examples/help_categories.py
@@ -39,7 +39,7 @@ class HelpCategories(cmd2.Cmd):
 
     def do_deploy(self, _):
         """Deploy command"""
-        self.poutput('Which')
+        self.poutput('Deploy')
 
     def do_start(self, _):
         """Start command"""
@@ -140,6 +140,18 @@ class HelpCategories(cmd2.Cmd):
         """Version command"""
         self.poutput(cmd2.__version__)
 
+    @cmd2.with_category("Command Management")
+    def do_disable_commands(self, _):
+        """Disable the Application Management commands"""
+        self.disable_category(self.CMD_CAT_APP_MGMT, "Application Management is currently disabled")
+        self.poutput("The Application Management commands have been disabled")
+
+    @cmd2.with_category("Command Management")
+    def do_enable_commands(self, _):
+        """Enable the Application Management commands"""
+        self.enable_category(self.CMD_CAT_APP_MGMT)
+        self.poutput("The Application Management commands have been enabled")
+
 
 if __name__ == '__main__':
     c = HelpCategories()


Test patch:
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index d43f7786..b3942203 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -2170,3 +2170,108 @@ def test_colors_never_notty(mocker, capsys):
     app.onecmd_plus_hooks('echo oopsie')
     out, err = capsys.readouterr()
     assert out == err == 'oopsie\n'
+
+
+class DisableCommandsApp(cmd2.Cmd):
+    """Class for disabling commands"""
+    category_name = "Test Category"
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    @cmd2.with_category(category_name)
+    def do_has_help_func(self, arg):
+        self.poutput("The real has_help_func")
+
+    def help_has_help_func(self):
+        self.poutput('Help for has_help_func')
+
+    @cmd2.with_category(category_name)
+    def do_has_no_help_func(self, arg):
+        """Help for has_no_help_func"""
+        self.poutput("The real has_no_help_func")
+
+
+@pytest.fixture
+def disable_commands_app():
+    app = DisableCommandsApp()
+    app.stdout = utils.StdSim(app.stdout)
+    return app
+
+
+def test_disable_and_enable_category(disable_commands_app):
+    # Disable the category
+    message_to_print = 'These commands are currently disabled'
+    disable_commands_app.disable_category(disable_commands_app.category_name, message_to_print)
+
+    # Make sure all the commands and help on those commands displays the message
+    out = run_cmd(disable_commands_app, 'has_help_func')
+    assert out == [message_to_print]
+
+    out = run_cmd(disable_commands_app, 'help has_help_func')
+    assert out == [message_to_print]
+
+    out = run_cmd(disable_commands_app, 'has_no_help_func')
+    assert out == [message_to_print]
+
+    out = run_cmd(disable_commands_app, 'help has_no_help_func')
+    assert out == [message_to_print]
+
+    visible_commands = disable_commands_app.get_visible_commands()
+    assert 'has_help_func' not in visible_commands
+    assert 'has_no_help_func' not in visible_commands
+
+    # Enable the category
+    disable_commands_app.enable_category(disable_commands_app.category_name)
+
+    # Make sure all the commands and help on those commands are restored
+    out = run_cmd(disable_commands_app, 'has_help_func')
+    assert out == ["The real has_help_func"]
+
+    out = run_cmd(disable_commands_app, 'help has_help_func')
+    assert out == ["Help for has_help_func"]
+
+    out = run_cmd(disable_commands_app, 'has_no_help_func')
+    assert out == ["The real has_no_help_func"]
+
+    out = run_cmd(disable_commands_app, 'help has_no_help_func')
+    assert out == ["Help for has_no_help_func"]
+
+    visible_commands = disable_commands_app.get_visible_commands()
+    assert 'has_help_func' in visible_commands
+    assert 'has_no_help_func' in visible_commands
+
+def test_enable_enabled_command(disable_commands_app):
+    # Test enabling a command that is not disabled
+    saved_len = len(disable_commands_app.disabled_commands)
+    disable_commands_app.enable_command('has_help_func')
+
+    # The number of disabled_commands should not have changed
+    assert saved_len == len(disable_commands_app.disabled_commands)
+
+def test_disable_fake_command(disable_commands_app):
+    with pytest.raises(AttributeError):
+        disable_commands_app.disable_command('fake', 'fake message')
+
+def test_disable_command_twice(disable_commands_app):
+    saved_len = len(disable_commands_app.disabled_commands)
+    message_to_print = 'These commands are currently disabled'
+    disable_commands_app.disable_command('has_help_func', message_to_print)
+
+    # The length of disabled_commands should have increased one
+    new_len = len(disable_commands_app.disabled_commands)
+    assert saved_len == new_len - 1
+    saved_len = new_len
+
+    # Disable again and the length should not change
+    disable_commands_app.disable_command('has_help_func', message_to_print)
+    new_len = len(disable_commands_app.disabled_commands)
+    assert saved_len == new_len
+
+def test_disabled_command_not_in_history(disable_commands_app):
+    message_to_print = 'These commands are currently disabled'
+    disable_commands_app.disable_command('has_help_func', message_to_print)
+
+    saved_len = len(disable_commands_app.history)
+    run_cmd(disable_commands_app, 'has_help_func')
+    assert saved_len == len(disable_commands_app.history)