diff options
-rw-r--r-- | .pytest_cache/v/cache/lastfailed | 1 | ||||
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rwxr-xr-x | README.md | 1 | ||||
-rwxr-xr-x | cmd2.py | 152 | ||||
-rw-r--r-- | docs/freefeatures.rst | 6 | ||||
-rw-r--r-- | docs/settingchanges.rst | 26 | ||||
-rw-r--r-- | tests/conftest.py | 3 | ||||
-rw-r--r-- | tests/test_cmd2.py | 46 | ||||
-rw-r--r-- | tests/test_completion.py | 51 | ||||
-rw-r--r-- | tests/test_parsing.py | 6 | ||||
-rw-r--r-- | tests/test_transcript.py | 4 | ||||
-rw-r--r-- | tests/transcripts/from_cmdloop.txt | 4 |
12 files changed, 262 insertions, 39 deletions
diff --git a/.pytest_cache/v/cache/lastfailed b/.pytest_cache/v/cache/lastfailed deleted file mode 100644 index 9e26dfee..00000000 --- a/.pytest_cache/v/cache/lastfailed +++ /dev/null @@ -1 +0,0 @@ -{}
\ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d82d191d..3c590bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Added [quit_on_sigint](http://cmd2.readthedocs.io/en/latest/settingchanges.html#quit-on-sigint) attribute to enable canceling current line instead of quitting when Ctrl+C is typed * Added possibility of having readline history preservation in a SubMenu * Added [table_display.py](https://github.com/python-cmd2/cmd2/blob/master/examples/table_display.py) example to demonstrate how to display tabular data + * Added command aliasing with ``alias`` command ## 0.8.1 (March 9, 2018) @@ -29,6 +29,7 @@ Main Features - Option to display long output using a pager with ``cmd2.Cmd.ppaged()`` - Multi-line commands - Special-character command shortcuts (beyond cmd's `@` and `!`) +- Command aliasing - Settable environment parameters - Parsing commands with arguments using `argparse`, including support for sub-commands - Sub-menu support via the ``AddSubmenu`` decorator @@ -164,6 +164,27 @@ def set_use_arg_list(val): USE_ARG_LIST = val +# noinspection PyUnusedLocal +def basic_complete(text, line, begidx, endidx, match_against): + """ + Performs tab completion against a list + :param text: str - the string prefix we are attempting to match (all returned matches must begin with it) + :param line: str - the current input line with leading whitespace removed + :param begidx: int - the beginning index of the prefix text + :param endidx: int - the ending index of the prefix text + :param match_against: iterable - the list being matched against + :return: List[str] - a list of possible tab completions + """ + completions = [cur_str for cur_str in match_against if cur_str.startswith(text)] + + # If there is only 1 match and it's at the end of the line, then add a space + if len(completions) == 1 and endidx == len(line): + completions[0] += ' ' + + completions.sort() + return completions + + def flag_based_complete(text, line, begidx, endidx, flag_dict, all_else=None): """ Tab completes based on a particular flag preceding the token being completed @@ -500,7 +521,7 @@ def with_argparser_and_unknown_args(argparser): argparser.prog = func.__name__[3:] # If the description has not been set, then use the method docstring if one exists - if not argparser.description and func.__doc__: + if argparser.description is None and func.__doc__: argparser.description = func.__doc__ cmd_wrapper.__doc__ = argparser.format_help() @@ -539,7 +560,7 @@ def with_argparser(argparser): argparser.prog = func.__name__[3:] # If the description has not been set, then use the method docstring if one exists - if not argparser.description and func.__doc__: + if argparser.description is None and func.__doc__: argparser.description = func.__doc__ cmd_wrapper.__doc__ = argparser.format_help() @@ -1024,6 +1045,7 @@ class Cmd(cmd.Cmd): prefixParser = pyparsing.Empty() redirector = '>' # for sending output to file shortcuts = {'?': 'help', '!': 'shell', '@': 'load', '@@': '_relative_load'} + aliases = dict() terminators = [';'] # make sure your terminators are not in legalChars! # Attributes which are NOT dynamically settable at runtime @@ -1113,7 +1135,8 @@ class Cmd(cmd.Cmd): legalChars=self.legalChars, commentGrammars=self.commentGrammars, commentInProgress=self.commentInProgress, blankLinesAllowed=self.blankLinesAllowed, prefixParser=self.prefixParser, - preparse=self.preparse, postparse=self.postparse, shortcuts=self.shortcuts) + preparse=self.preparse, postparse=self.postparse, aliases=self.aliases, + shortcuts=self.shortcuts) self._transcript_files = transcript_files # Used to enable the ability for a Python script to quit the application @@ -1391,20 +1414,11 @@ class Cmd(cmd.Cmd): self.completion_matches = compfunc(text, line, begidx, endidx) else: - # Complete the command against command names and shortcuts. By design, shortcuts that start with - # symbols not in self.identchars won't be tab completed since they are handled in the above if - # statement. This includes shortcuts like: ?, !, @, @@ - strs_to_match = [] - - # If a command has been started, then match against shortcuts. This keeps shortcuts out of the - # full list of commands that show up when tab completion is done on an empty line. - if len(line) > 0: - for (shortcut, expansion) in self.shortcuts: - strs_to_match.append(shortcut) + # Complete the command against aliases and command names + strs_to_match = list(self.aliases.keys()) - # Get command names - do_text = 'do_' + text - strs_to_match.extend([cur_name[3:] for cur_name in self.get_names() if cur_name.startswith(do_text)]) + # Add command names + strs_to_match.extend(self.get_command_names()) # Perform matching completions = [cur_str for cur_str in strs_to_match if cur_str.startswith(text)] @@ -1420,6 +1434,10 @@ class Cmd(cmd.Cmd): except IndexError: return None + def get_command_names(self): + """ Returns a list of commands """ + return [cur_name[3:] for cur_name in self.get_names() if cur_name.startswith('do_')] + def complete_help(self, text, line, begidx, endidx): """ Override of parent class method to handle tab completing subcommands @@ -1538,6 +1556,12 @@ class Cmd(cmd.Cmd): # Deal with empty line or all whitespace line return None, None, line + # Handle aliases + for cur_alias in self.aliases: + if line == cur_alias or line.startswith(cur_alias + ' '): + line = line.replace(cur_alias, self.aliases[cur_alias], 1) + break + # Expand command shortcuts to the full command name for (shortcut, expansion) in self.shortcuts: if line.startswith(shortcut): @@ -1923,6 +1947,92 @@ class Cmd(cmd.Cmd): return stop @with_argument_list + def do_alias(self, arglist): + """Define or display aliases + +Usage: Usage: alias [<name> <value>] + Where: + name - name of the alias being added or edited + value - what the alias will be resolved to + this can contain spaces and does not need to be quoted + + Without arguments, `alias' prints a list of all aliases in a resuable form + + Example: alias ls !ls -lF +""" + # If no args were given, then print a list of current aliases + if len(arglist) == 0: + for cur_alias in self.aliases: + self.poutput("alias {} {}".format(cur_alias, self.aliases[cur_alias])) + + # The user is creating an alias + elif len(arglist) >= 2: + name = arglist[0] + value = ' '.join(arglist[1:]) + + # Make sure the alias does not match an existing command + cmd_func = self._func_named(name) + if cmd_func is not None: + self.perror("Alias names cannot match an existing command: {!r}".format(name), traceback_war=False) + return + + # Check for a valid name + for cur_char in name: + if cur_char not in self.identchars: + self.perror("Alias names can only contain the following characters: {}".format(self.identchars), + traceback_war=False) + return + + # Set the alias + self.aliases[name] = value + self.poutput("Alias created") + + else: + self.do_help('alias') + + def complete_alias(self, text, line, begidx, endidx): + """ Tab completion for alias """ + index_dict = \ + { + 1: self.aliases, + 2: self.get_command_names() + } + return index_based_complete(text, line, begidx, endidx, index_dict, path_complete) + + @with_argument_list + def do_unalias(self, arglist): + """Unsets aliases + +Usage: Usage: unalias [-a] name [name ...] + Where: + name - name of the alias being unset + + Options: + -a remove all alias definitions +""" + if len(arglist) == 0: + self.do_help('unalias') + + if '-a' in arglist: + self.aliases.clear() + self.poutput("All aliases cleared") + + else: + # Get rid of duplicates + arglist = list(set(arglist)) + + for cur_arg in arglist: + if cur_arg in self.aliases: + del self.aliases[cur_arg] + self.poutput("Alias {!r} cleared".format(cur_arg)) + else: + self.perror("Alias {!r} does not exist".format(cur_arg), traceback_war=False) + + def complete_unalias(self, text, line, begidx, endidx): + """ Tab completion for unalias """ + return basic_complete(text, line, begidx, endidx, self.aliases) + + @with_argument_list def do_help(self, arglist): """List available commands with "help" or detailed help with "help cmd".""" if arglist: @@ -2747,12 +2857,13 @@ class ParserManager: Class which encapsulates all of the pyparsing parser functionality for cmd2 in a single location. """ def __init__(self, redirector, terminators, multilineCommands, legalChars, commentGrammars, commentInProgress, - blankLinesAllowed, prefixParser, preparse, postparse, shortcuts): + blankLinesAllowed, prefixParser, preparse, postparse, aliases, shortcuts): """Creates and uses parsers for user input according to app's parameters.""" self.commentGrammars = commentGrammars self.preparse = preparse self.postparse = postparse + self.aliases = aliases self.shortcuts = shortcuts self.main_parser = self._build_main_parser(redirector=redirector, terminators=terminators, @@ -2854,6 +2965,13 @@ class ParserManager: s = self.preparse(raw) s = self.input_source_parser.transformString(s.lstrip()) s = self.commentGrammars.transformString(s) + + # Handle aliases + for cur_alias in self.aliases: + if s == cur_alias or s.startswith(cur_alias + ' '): + s = s.replace(cur_alias, self.aliases[cur_alias], 1) + break + for (shortcut, expansion) in self.shortcuts: if s.startswith(shortcut): s = s.replace(shortcut, expansion + ' ', 1) diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index 6cebbaf3..dff82de4 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -320,8 +320,8 @@ Additionally, it is trivial to add identical file system path completion to your have defined a custom command ``foo`` by implementing the ``do_foo`` method. To enable path completion for the ``foo`` command, then add a line of code similar to the following to your class which inherits from ``cmd2.Cmd``:: - # Assuming you have an "import cmd2" somewhere at the top - complete_foo = cmd2.Cmd.path_complete + # Make sure you have an "import functools" somewhere at the top + complete_foo = functools.partial(path_complete) This will effectively define the ``complete_foo`` readline completer method in your class and make it utilize the same path completion logic as the built-in commands. @@ -332,4 +332,4 @@ path completion of directories only for this command by adding a line of code si which inherits from ``cmd2.Cmd``:: # Make sure you have an "import functools" somewhere at the top - complete_bar = functools.partialmethod(cmd2.Cmd.path_complete, dir_only=True) + complete_bar = functools.partial(path_complete, dir_only=True) diff --git a/docs/settingchanges.rst b/docs/settingchanges.rst index 67292a48..f5ba16d4 100644 --- a/docs/settingchanges.rst +++ b/docs/settingchanges.rst @@ -9,12 +9,12 @@ its name is included in the dictionary ``app.settable``. (To define your own user-settable parameters, see :ref:`parameters`) -Shortcuts (command aliases) +Shortcuts =========================== -Command aliases for long command names such as special-character shortcuts for common commands can make life more -convenient for your users. Shortcuts are used without a space separating them from their arguments, -like ``!ls``. By default, the following shortcuts are defined: +Command shortcuts for long command names and common commands can make life more convenient for your users. +Shortcuts are used without a space separating them from their arguments, like ``!ls``. By default, the +following shortcuts are defined: ``?`` help @@ -41,12 +41,28 @@ To define more shortcuts, update the dict ``App.shortcuts`` with the .. warning:: - Command aliases needed to be created by updating the ``shortcuts`` dictionary attribute prior to calling the + Shortcuts need to be created by updating the ``shortcuts`` dictionary attribute prior to calling the ``cmd2.Cmd`` super class ``__init__()`` method. Moreover, that super class init method needs to be called after updating the ``shortcuts`` attribute This warning applies in general to many other attributes which are not settable at runtime such as ``commentGrammars``, ``multilineCommands``, etc. +Aliases +================ + +In addition to shortcuts, ``cmd2`` provides a full alias feature via the ``alias`` command which is similar to the +``alias`` command in Bash. + +The syntax to create an alias is ``alias <name> <value>``. ``value`` can contain spaces and does not need +to be quoted. Ex: ``alias ls !ls -lF`` + +If ``alias`` is run without arguments, then a list of all aliases will be printed to stdout and are in the proper +``alias`` command syntax, meaning they can easily be reused. + +The ``unalias`` is used to clear aliases. Using the ``-a`` flag will clear all aliases. Otherwise provide a list of +aliases to clear. Ex: ``unalias ls cd pwd`` will clear the aliases called ls, cd, and pwd. + + Default to shell ================ diff --git a/tests/conftest.py b/tests/conftest.py index 5ee5dd58..58ec8ee0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,8 @@ import cmd2 # Help text for base cmd2.Cmd application BASE_HELP = """Documented commands (type help <topic>): ======================================== -edit help history load py pyscript quit set shell shortcuts +alias help load pyscript set shortcuts +edit history py quit shell unalias """ # Help text for the history command diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index fd37e25e..d69bf343 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1044,7 +1044,8 @@ def test_custom_help_menu(help_app): expected = normalize(""" Documented commands (type help <topic>): ======================================== -edit help history load py pyscript quit set shell shortcuts squat +alias help load pyscript set shortcuts unalias +edit history py quit shell squat Undocumented commands: ====================== @@ -1541,3 +1542,46 @@ def test_poutput_none(base_app): out = base_app.stdout.buffer expected = '' assert out == expected + + +def test_alias(base_app, capsys): + # Create the alias + out = run_cmd(base_app, 'alias fake pyscript') + assert out == normalize("Alias created") + + # Use the alias + run_cmd(base_app, 'fake') + out, err = capsys.readouterr() + assert "pyscript command requires at least 1 argument" in err + + # See a list of aliases + out = run_cmd(base_app, 'alias') + assert out == normalize('alias fake pyscript') + +def test_alias_with_cmd_name(base_app, capsys): + run_cmd(base_app, 'alias help eos') + out, err = capsys.readouterr() + assert "cannot match an existing command" in err + +def test_alias_with_invalid_name(base_app, capsys): + run_cmd(base_app, 'alias @ help') + out, err = capsys.readouterr() + assert "can only contain the following characters" in err + + +def test_unalias(base_app): + # Create an alias + run_cmd(base_app, 'alias fake pyscript') + + # Remove the alias + out = run_cmd(base_app, 'unalias fake') + assert out == normalize("Alias 'fake' cleared") + +def test_unalias_all(base_app): + out = run_cmd(base_app, 'unalias -a') + assert out == normalize("All aliases cleared") + +def test_unalias_non_existing(base_app, capsys): + run_cmd(base_app, 'unalias fake') + out, err = capsys.readouterr() + assert "does not exist" in err diff --git a/tests/test_completion.py b/tests/test_completion.py index c1111ab9..a9f75dce 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -17,7 +17,7 @@ import cmd2 import mock import pytest -from cmd2 import path_complete, flag_based_complete, index_based_complete +from cmd2 import path_complete, basic_complete, flag_based_complete, index_based_complete @pytest.fixture def cmd2_app(): @@ -399,7 +399,7 @@ def test_path_completion_no_tokens(): assert path_complete(text, line, begidx, endidx) == [] -# List of strings used with flag and index based completion functions +# List of strings used with basic, flag, and index based completion functions food_item_strs = ['Pizza', 'Hamburger', 'Ham', 'Potato'] sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football'] @@ -414,6 +414,39 @@ flag_dict = \ '--other': path_complete, # Tab-complete using path_complete function after --other flag in command line } +def test_basic_completion_single_end(): + text = 'Pi' + line = 'list_food -f Pi' + endidx = len(line) + begidx = endidx - len(text) + + assert basic_complete(text, line, begidx, endidx, food_item_strs) == ['Pizza '] + +def test_basic_completion_single_mid(): + text = 'Pi' + line = 'list_food -f Pi' + begidx = len(line) - len(text) + endidx = begidx + 1 + + assert basic_complete(text, line, begidx, endidx, food_item_strs) == ['Pizza'] + +def test_basic_completion_multiple(): + text = '' + line = 'list_food -f ' + endidx = len(line) + begidx = endidx - len(text) + + assert basic_complete(text, line, begidx, endidx, food_item_strs) == sorted(food_item_strs) + +def test_basic_completion_nomatch(): + text = 'q' + line = 'list_food -f q' + endidx = len(line) + begidx = endidx - len(text) + + assert basic_complete(text, line, begidx, endidx, food_item_strs) == [] + + def test_flag_based_completion_single_end(): text = 'Pi' line = 'list_food -f Pi' @@ -435,6 +468,7 @@ def test_flag_based_completion_multiple(): line = 'list_food -f ' endidx = len(line) begidx = endidx - len(text) + assert flag_based_complete(text, line, begidx, endidx, flag_dict) == sorted(food_item_strs) def test_flag_based_completion_nomatch(): @@ -442,6 +476,7 @@ def test_flag_based_completion_nomatch(): line = 'list_food -f q' endidx = len(line) begidx = endidx - len(text) + assert flag_based_complete(text, line, begidx, endidx, flag_dict) == [] def test_flag_based_default_completer(request): @@ -900,6 +935,7 @@ def test_cmd2_submenu_completion_multiple(sb_app): assert first_match is not None and sb_app.completion_matches == [ '_relative_load', + 'alias', 'edit', 'eof', 'eos', @@ -912,7 +948,8 @@ def test_cmd2_submenu_completion_multiple(sb_app): 'quit', 'set', 'shell', - 'shortcuts' + 'shortcuts', + 'unalias' ] @@ -1006,6 +1043,7 @@ def test_cmd2_help_submenu_completion_multiple(sb_app): begidx = endidx - len(text) assert sb_app.complete_help(text, line, begidx, endidx) == [ '_relative_load', + 'alias', 'edit', 'eof', 'eos', @@ -1018,7 +1056,8 @@ def test_cmd2_help_submenu_completion_multiple(sb_app): 'quit', 'set', 'shell', - 'shortcuts' + 'shortcuts', + 'unalias' ] @@ -1037,6 +1076,7 @@ def test_cmd2_help_submenu_completion_subcommands(sb_app): begidx = endidx - len(text) assert sb_app.complete_help(text, line, begidx, endidx) == [ '_relative_load', + 'alias', 'edit', 'eof', 'eos', @@ -1049,5 +1089,6 @@ def test_cmd2_help_submenu_completion_subcommands(sb_app): 'quit', 'set', 'shell', - 'shortcuts' + 'shortcuts', + 'unalias' ] diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 8ef9c5c0..12b50eda 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -29,7 +29,8 @@ def parser(): multilineCommands=c.multilineCommands, legalChars=c.legalChars, commentGrammars=c.commentGrammars, commentInProgress=c.commentInProgress, blankLinesAllowed=c.blankLinesAllowed, prefixParser=c.prefixParser, - preparse=c.preparse, postparse=c.postparse, shortcuts=c.shortcuts) + preparse=c.preparse, postparse=c.postparse, aliases=c.aliases, + shortcuts=c.shortcuts) return c.parser_manager.main_parser # Case-sensitive ParserManager @@ -41,7 +42,8 @@ def cs_pm(): multilineCommands=c.multilineCommands, legalChars=c.legalChars, commentGrammars=c.commentGrammars, commentInProgress=c.commentInProgress, blankLinesAllowed=c.blankLinesAllowed, prefixParser=c.prefixParser, - preparse=c.preparse, postparse=c.postparse, shortcuts=c.shortcuts) + preparse=c.preparse, postparse=c.postparse, aliases=c.aliases, + shortcuts=c.shortcuts) return c.parser_manager diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 43b7a72c..8c2af29d 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -129,8 +129,8 @@ def test_base_with_transcript(_cmdline_app): Documented commands (type help <topic>): ======================================== -edit history mumble py quit set shortcuts -help load orate pyscript say shell speak +alias help load orate pyscript say shell speak +edit history mumble py quit set shortcuts unalias (Cmd) help say Repeats what you tell me to. diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt index 07bdc30d..ebbf3c91 100644 --- a/tests/transcripts/from_cmdloop.txt +++ b/tests/transcripts/from_cmdloop.txt @@ -5,8 +5,8 @@ Documented commands (type help <topic>): ======================================== -edit history mumble py quit set shortcuts/ */ -help load orate pyscript say shell speak/ */ +alias help load orate pyscript say shell speak/ */ +edit history mumble py quit set shortcuts unalias/ */ (Cmd) help say Repeats what you tell me to. |