summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTodd Leonhardt <todd.leonhardt@gmail.com>2018-09-28 21:26:24 -0400
committerGitHub <noreply@github.com>2018-09-28 21:26:24 -0400
commit61d5703cd3586b3460669a6260cf903c9863b240 (patch)
tree5b7f4893f7edfa60435946f1027b07933fe1b3cf
parent87fdda149ade5c82d853334c87db0a2d11445594 (diff)
parent46bd94acbd10eced821827555a0ffd49a2f9cd92 (diff)
downloadcmd2-git-61d5703cd3586b3460669a6260cf903c9863b240.tar.gz
Merge pull request #553 from python-cmd2/argparse_conversion
Argparse conversion
-rw-r--r--CHANGELOG.md2
-rw-r--r--cmd2/cmd2.py389
-rw-r--r--docs/freefeatures.rst10
-rw-r--r--tests/conftest.py18
-rw-r--r--tests/test_argparse.py2
-rw-r--r--tests/test_cmd2.py61
-rw-r--r--tests/test_completion.py92
-rw-r--r--tests/test_pyscript.py10
8 files changed, 324 insertions, 260 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bad61734..d2d0adec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,7 +22,7 @@
* Always - output methods **never** strip ANSI escape sequences, regardless of the output destination
* Never - output methods strip all ANSI escape sequences
* Added ``macro`` command to create macros, which are similar to aliases, but can take arguments when called
- * ``alias`` is now an argparse command with subcommands to create, list, and delete aliases
+ * All cmd2 command functions have been converted to use argparse.
* Deprecations
* Deprecated the built-in ``cmd2`` support for colors including ``Cmd.colorize()`` and ``Cmd._colorcodes``
* Deletions (potentially breaking changes)
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index d029aa6c..dec0a04d 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -122,6 +122,10 @@ except ImportError: # pragma: no cover
HELP_CATEGORY = 'help_category'
HELP_SUMMARY = 'help_summary'
+INTERNAL_COMMAND_EPILOG = ("Notes:\n"
+ " This command is for internal use and is not intended to be called from the\n"
+ " command line.")
+
# All command functions start with this
COMMAND_PREFIX = 'do_'
@@ -386,6 +390,10 @@ class Cmd(cmd.Cmd):
# Call super class constructor
super().__init__(completekey=completekey, stdin=stdin, stdout=stdout)
+ # Get rid of cmd's complete_help() functions so AutoCompleter will complete our help command
+ if getattr(cmd.Cmd, 'complete_help', None) is not None:
+ delattr(cmd.Cmd, 'complete_help')
+
# Commands to exclude from the help menu and tab completion
self.hidden_commands = ['eof', 'eos', '_relative_load']
@@ -1632,46 +1640,6 @@ class Cmd(cmd.Cmd):
return [name[5:] for name in self.get_names()
if name.startswith('help_') and callable(getattr(self, name))]
- def complete_help(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
- """
- Override of parent class method to handle tab completing subcommands and not showing hidden commands
- Returns a list of possible tab completions
- """
-
- # The command is the token at index 1 in the command line
- cmd_index = 1
-
- # The subcommand is the token at index 2 in the command line
- subcmd_index = 2
-
- # Get all tokens through the one being completed
- tokens, _ = self.tokens_for_completion(line, begidx, endidx)
- if not tokens:
- return []
-
- matches = []
-
- # Get the index of the token being completed
- index = len(tokens) - 1
-
- # Check if we are completing a command or help topic
- if index == cmd_index:
-
- # Complete token against topics and visible commands
- topics = set(self.get_help_topics())
- visible_commands = set(self.get_visible_commands())
- strs_to_match = list(topics | visible_commands)
- matches = self.basic_complete(text, line, begidx, endidx, strs_to_match)
-
- # check if the command uses argparser
- elif index >= subcmd_index:
- func = self.cmd_func(tokens[cmd_index])
- if func and hasattr(func, 'argparser'):
- completer = AutoCompleter(getattr(func, 'argparser'), cmd2_app=self)
- matches = completer.complete_command_help(tokens[1:], text, line, begidx, endidx)
-
- return matches
-
# noinspection PyUnusedLocal
def sigint_handler(self, signum: int, frame) -> None:
"""Signal handler for SIGINTs which typically come from Ctrl-C events.
@@ -2275,7 +2243,7 @@ class Cmd(cmd.Cmd):
self.aliases.clear()
self.poutput("All aliases deleted")
elif not args.name:
- self.do_help(['alias', 'delete'])
+ self.do_help('alias delete')
else:
# Get rid of duplicates and strip quotes since the argparse decorator for do_alias() preserves them
aliases_to_delete = [utils.strip_quotes(cur_name) for cur_name in utils.remove_duplicates(args.name)]
@@ -2337,7 +2305,7 @@ class Cmd(cmd.Cmd):
setattr(alias_create_parser.add_argument('command', help='what the alias resolves to'),
ACTION_ARG_CHOICES, get_commands_aliases_and_macros_for_completion)
setattr(alias_create_parser.add_argument('command_args', nargs=argparse.REMAINDER,
- help='arguments being passed to command'),
+ help='arguments to pass to command'),
ACTION_ARG_CHOICES, ('path_complete',))
alias_create_parser.set_defaults(func=alias_create)
@@ -2374,7 +2342,7 @@ class Cmd(cmd.Cmd):
func(self, args)
else:
# No subcommand was provided, so call help
- self.do_help(['alias'])
+ self.do_help('alias')
# ----- Macro subcommand functions -----
@@ -2463,7 +2431,7 @@ class Cmd(cmd.Cmd):
self.macros.clear()
self.poutput("All macros deleted")
elif not args.name:
- self.do_help(['macro', 'delete'])
+ self.do_help('macro delete')
else:
# Get rid of duplicates and strip quotes since the argparse decorator for do_macro() preserves them
macros_to_delete = [utils.strip_quotes(cur_name) for cur_name in utils.remove_duplicates(args.name)]
@@ -2549,7 +2517,7 @@ class Cmd(cmd.Cmd):
setattr(macro_create_parser.add_argument('command', help='what the macro resolves to'),
ACTION_ARG_CHOICES, get_commands_aliases_and_macros_for_completion)
setattr(macro_create_parser.add_argument('command_args', nargs=argparse.REMAINDER,
- help='arguments being passed to command'),
+ help='arguments to pass to command'),
ACTION_ARG_CHOICES, ('path_complete',))
macro_create_parser.set_defaults(func=macro_create)
@@ -2585,23 +2553,77 @@ class Cmd(cmd.Cmd):
func(self, args)
else:
# No subcommand was provided, so call help
- self.do_help(['macro'])
-
- @with_argument_list
- def do_help(self, arglist: List[str]) -> None:
- """ List available commands with "help" or detailed help with "help cmd" """
- if not arglist or (len(arglist) == 1 and arglist[0] in ('--verbose', '-v')):
- verbose = len(arglist) == 1 and arglist[0] in ('--verbose', '-v')
- self._help_menu(verbose)
+ self.do_help('macro')
+
+ def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ """Completes the command argument of help"""
+
+ # Complete token against topics and visible commands
+ topics = set(self.get_help_topics())
+ visible_commands = set(self.get_visible_commands())
+ strs_to_match = list(topics | visible_commands)
+ return self.basic_complete(text, line, begidx, endidx, strs_to_match)
+
+ def complete_help_subcommand(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
+ """Completes the subcommand argument of help"""
+
+ # Get all tokens through the one being completed
+ tokens, _ = self.tokens_for_completion(line, begidx, endidx)
+
+ if not tokens:
+ return []
+
+ # Must have at least 3 args for 'help command subcommand'
+ if len(tokens) < 3:
+ return []
+
+ # Find where the command is by skipping past any flags
+ cmd_index = 1
+ for cur_token in tokens[cmd_index:]:
+ if not cur_token.startswith('-'):
+ break
+ cmd_index += 1
+
+ if cmd_index >= len(tokens):
+ return []
+
+ command = tokens[cmd_index]
+ matches = []
+
+ # Check if this is a command with an argparse function
+ func = self.cmd_func(command)
+ if func and hasattr(func, 'argparser'):
+ completer = AutoCompleter(getattr(func, 'argparser'), cmd2_app=self)
+ matches = completer.complete_command_help(tokens[cmd_index:], text, line, begidx, endidx)
+
+ return matches
+
+ help_parser = ACArgumentParser()
+
+ setattr(help_parser.add_argument('command', help="command to retrieve help for", nargs="?"),
+ ACTION_ARG_CHOICES, ('complete_help_command',))
+ setattr(help_parser.add_argument('subcommand', help="subcommand to retrieve help for",
+ nargs=argparse.REMAINDER),
+ ACTION_ARG_CHOICES, ('complete_help_subcommand',))
+ help_parser.add_argument('-v', '--verbose', action='store_true',
+ help="print a list of all commands with descriptions of each")
+
+ @with_argparser(help_parser)
+ def do_help(self, args: argparse.Namespace) -> None:
+ """List available commands or provide detailed help for a specific command"""
+ if not args.command or args.verbose:
+ self._help_menu(args.verbose)
+
else:
# Getting help for a specific command
- func = self.cmd_func(arglist[0])
+ func = self.cmd_func(args.command)
if func and hasattr(func, 'argparser'):
completer = AutoCompleter(getattr(func, 'argparser'), cmd2_app=self)
- self.poutput(completer.format_help(arglist))
+ tokens = [args.command] + args.subcommand
+ self.poutput(completer.format_help(tokens))
else:
# No special behavior needed, delegate to cmd base class do_help()
- super().do_help(arglist[0])
+ super().do_help(args.command)
def _help_menu(self, verbose: bool=False) -> None:
"""Show a list of commands which help can be displayed for.
@@ -2721,18 +2743,21 @@ class Cmd(cmd.Cmd):
command = ''
self.stdout.write("\n")
- def do_shortcuts(self, _: str) -> None:
- """Lists shortcuts available"""
+ @with_argparser(ACArgumentParser())
+ def do_shortcuts(self, _: argparse.Namespace) -> None:
+ """List available shortcuts"""
result = "\n".join('%s: %s' % (sc[0], sc[1]) for sc in sorted(self.shortcuts))
self.poutput("Shortcuts for other commands:\n{}\n".format(result))
- def do_eof(self, _: str) -> bool:
+ @with_argparser(ACArgumentParser(epilog=INTERNAL_COMMAND_EPILOG))
+ def do_eof(self, _: argparse.Namespace) -> bool:
"""Called when <Ctrl>-D is pressed"""
# End of script should not exit app, but <Ctrl>-D should.
return self._STOP_AND_EXIT
- def do_quit(self, _: str) -> bool:
- """Exits this application"""
+ @with_argparser(ACArgumentParser())
+ def do_quit(self, _: argparse.Namespace) -> bool:
+ """Exit this application"""
self._should_quit = True
return self._STOP_AND_EXIT
@@ -2818,7 +2843,7 @@ class Cmd(cmd.Cmd):
else:
raise LookupError("Parameter '{}' not supported (type 'set' for list of parameters).".format(param))
- set_description = ("Sets a settable parameter or shows current settings of parameters.\n"
+ set_description = ("Set a settable parameter or show current settings of parameters\n"
"\n"
"Accepts abbreviated parameter names so long as there is no ambiguity.\n"
"Call without arguments for a list of settable parameters with their values.")
@@ -2832,7 +2857,7 @@ class Cmd(cmd.Cmd):
@with_argparser(set_parser)
def do_set(self, args: argparse.Namespace) -> None:
- """Sets a settable parameter or shows current settings of parameters"""
+ """Set a settable parameter or show current settings of parameters"""
# Check if param was passed in
if not args.param:
@@ -2865,14 +2890,20 @@ class Cmd(cmd.Cmd):
if onchange_hook is not None:
onchange_hook(old=current_value, new=value)
- def do_shell(self, statement: Statement) -> None:
- """Execute a command as if at the OS prompt
+ shell_parser = ACArgumentParser()
+ setattr(shell_parser.add_argument('command', help='the command to run'),
+ ACTION_ARG_CHOICES, ('shell_cmd_complete',))
+ setattr(shell_parser.add_argument('command_args', nargs=argparse.REMAINDER,
+ help='arguments to pass to command'),
+ ACTION_ARG_CHOICES, ('path_complete',))
- Usage: shell <command> [arguments]"""
+ @with_argparser(shell_parser, preserve_quotes=True)
+ def do_shell(self, args: argparse.Namespace) -> None:
+ """Execute a command as if at the OS prompt"""
import subprocess
- # Get list of arguments to shell with quotes preserved
- tokens = statement.arg_list
+ # Create a list of arguments to shell
+ tokens = [args.command] + args.command_args
# Support expanding ~ in quoted paths
for index, _ in enumerate(tokens):
@@ -2893,18 +2924,6 @@ class Cmd(cmd.Cmd):
proc = subprocess.Popen(expanded_command, stdout=self.stdout, shell=True)
proc.communicate()
- def complete_shell(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
- """Handles tab completion of executable commands and local file system paths for the shell command
-
- :param text: the string prefix we are attempting to match (all returned matches must begin with it)
- :param line: the current input line with leading whitespace removed
- :param begidx: the beginning index of the prefix text
- :param endidx: the ending index of the prefix text
- :return: a list of possible tab completions
- """
- index_dict = {1: self.shell_cmd_complete}
- return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete)
-
@staticmethod
def _reset_py_display() -> None:
"""
@@ -2929,16 +2948,13 @@ class Cmd(cmd.Cmd):
sys.displayhook = sys.__displayhook__
sys.excepthook = sys.__excepthook__
- def do_py(self, arg: str) -> bool:
- """
- Invoke python command, shell, or script
+ py_parser = ACArgumentParser()
+ py_parser.add_argument('command', help="command to run", nargs='?')
+ py_parser.add_argument('remainder', help="remainder of command", nargs=argparse.REMAINDER)
- py <command>: Executes a Python command.
- py: Enters interactive Python mode.
- End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``.
- Non-python commands can be issued with ``pyscript_name("your command")``.
- Run python code from external script files with ``run("script.py")``
- """
+ @with_argparser(py_parser)
+ def do_py(self, args: argparse.Namespace) -> bool:
+ """Invoke Python command or shell"""
from .pyscript_bridge import PyscriptBridge, CommandResult
if self._in_py:
err = "Recursively entering interactive Python consoles is not allowed."
@@ -2949,19 +2965,18 @@ class Cmd(cmd.Cmd):
# noinspection PyBroadException
try:
- arg = arg.strip()
-
# Support the run command even if called prior to invoking an interactive interpreter
def run(filename: str):
"""Run a Python script file in the interactive console.
:param filename: filename of *.py script file to run
"""
+ expanded_filename = os.path.expanduser(filename)
try:
- with open(filename) as f:
+ with open(expanded_filename) as f:
interp.runcode(f.read())
except OSError as ex:
- error_msg = "Error opening script file '{}': {}".format(filename, ex)
+ error_msg = "Error opening script file '{}': {}".format(expanded_filename, ex)
self.perror(error_msg, traceback_war=False)
bridge = PyscriptBridge(self)
@@ -2976,8 +2991,12 @@ class Cmd(cmd.Cmd):
interp = InteractiveConsole(locals=localvars)
interp.runcode('import sys, os;sys.path.insert(0, os.getcwd())')
- if arg:
- interp.runcode(arg)
+ if args.command:
+ full_command = utils.quote_string_if_needed(args.command)
+ for cur_token in args.remainder:
+ full_command += ' ' + utils.quote_string_if_needed(cur_token)
+
+ interp.runcode(full_command)
# If there are no args, then we will open an interactive Python console
else:
@@ -3042,11 +3061,14 @@ class Cmd(cmd.Cmd):
sys.stdin = self.stdin
cprt = 'Type "help", "copyright", "credits" or "license" for more information.'
- docstr = self.do_py.__doc__.replace('pyscript_name', self.pyscript_name)
+ instructions = ('End with `Ctrl-D` (Unix) / `Ctrl-Z` (Windows), `quit()`, `exit()`.\n'
+ 'Non-Python commands can be issued with: {}("your command")\n'
+ 'Run Python code from external script files with: run("script.py")'
+ .format(self.pyscript_name))
try:
- interp.interact(banner="Python {} on {}\n{}\n({})\n{}".
- format(sys.version, sys.platform, cprt, self.__class__.__name__, docstr))
+ interp.interact(banner="Python {} on {}\n{}\n\n{}\n".
+ format(sys.version, sys.platform, cprt, instructions))
except EmbeddedConsoleExit:
pass
@@ -3087,30 +3109,22 @@ class Cmd(cmd.Cmd):
self._in_py = False
return self._should_quit
- @with_argument_list
- def do_pyscript(self, arglist: List[str]) -> None:
- """\nRuns a python script file inside the console
-
- Usage: pyscript <script_path> [script_arguments]
-
-Console commands can be executed inside this script with cmd("your command")
-However, you cannot run nested "py" or "pyscript" commands from within this script
-Paths or arguments that contain spaces must be enclosed in quotes
-"""
- if not arglist:
- self.perror("pyscript command requires at least 1 argument ...", traceback_war=False)
- self.do_help(['pyscript'])
- return
+ pyscript_parser = ACArgumentParser()
+ setattr(pyscript_parser.add_argument('script_path', help='path to the script file'),
+ ACTION_ARG_CHOICES, ('path_complete',))
+ pyscript_parser.add_argument('script_arguments', nargs=argparse.REMAINDER,
+ help='arguments to pass to script')
- # Get the absolute path of the script
- script_path = os.path.expanduser(arglist[0])
+ @with_argparser(pyscript_parser)
+ def do_pyscript(self, args: argparse.Namespace) -> None:
+ """Run a Python script file inside the console"""
+ script_path = os.path.expanduser(args.script_path)
# Save current command line arguments
orig_args = sys.argv
# Overwrite sys.argv to allow the script to take command line arguments
- sys.argv = [script_path]
- sys.argv.extend(arglist[1:])
+ sys.argv = [script_path] + args.script_arguments
# Run the script - use repr formatting to escape things which need to be escaped to prevent issues on Windows
self.do_py("run({!r})".format(script_path))
@@ -3118,33 +3132,24 @@ Paths or arguments that contain spaces must be enclosed in quotes
# Restore command line arguments to original state
sys.argv = orig_args
- def complete_pyscript(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
- """Enable tab-completion for pyscript command."""
- index_dict = {1: self.path_complete}
- return self.index_based_complete(text, line, begidx, endidx, index_dict)
-
# Only include the do_ipy() method if IPython is available on the system
- if ipython_available:
- # noinspection PyMethodMayBeStatic,PyUnusedLocal
- def do_ipy(self, arg: str) -> None:
- """Enters an interactive IPython shell.
-
- Run python code from external files with ``run filename.py``
- End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``.
- """
+ if ipython_available: # pragma: no cover
+ @with_argparser(ACArgumentParser())
+ def do_ipy(self, _: argparse.Namespace) -> None:
+ """Enter an interactive IPython shell"""
from .pyscript_bridge import PyscriptBridge
bridge = PyscriptBridge(self)
+ banner = ('Entering an embedded IPython shell. Type quit or <Ctrl>-d to exit.\n'
+ 'Run Python code from external files with: run filename.py\n')
+ exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0])
+
if self.locals_in_py:
def load_ipy(self, app):
- banner = 'Entering an embedded IPython shell. Type quit() or <Ctrl>-d to exit ...'
- exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0])
embed(banner1=banner, exit_msg=exit_msg)
load_ipy(self, bridge)
else:
def load_ipy(app):
- banner = 'Entering an embedded IPython shell. Type quit() or <Ctrl>-d to exit ...'
- exit_msg = 'Leaving IPython, back to {}'.format(sys.argv[0])
embed(banner1=banner, exit_msg=exit_msg)
load_ipy(bridge)
@@ -3153,10 +3158,10 @@ Paths or arguments that contain spaces must be enclosed in quotes
history_parser_group.add_argument('-r', '--run', action='store_true', help='run selected history items')
history_parser_group.add_argument('-e', '--edit', action='store_true',
help='edit and then run selected history items')
- history_parser_group.add_argument('-s', '--script', action='store_true', help='script format; no separation lines')
+ history_parser_group.add_argument('-s', '--script', action='store_true', help='output commands in script format')
history_parser_group.add_argument('-o', '--output-file', metavar='FILE', help='output commands to a script file')
history_parser_group.add_argument('-t', '--transcript', help='output commands and results to a transcript file')
- history_parser_group.add_argument('-c', '--clear', action="store_true", help='clears all history')
+ history_parser_group.add_argument('-c', '--clear', action="store_true", help='clear all history')
_history_arg_help = """empty all history items
a one history item by number
a..b, a:b, a:, ..b items by indices (inclusive)
@@ -3308,29 +3313,28 @@ a..b, a:b, a:, ..b items by indices (inclusive)
msg = '{} {} saved to transcript file {!r}'
self.pfeedback(msg.format(len(history), plural, transcript_file))
- @with_argument_list
- def do_edit(self, arglist: List[str]) -> None:
- """Edit a file in a text editor
+ edit_description = ("Edit a file in a text editor\n"
+ "\n"
+ "The editor used is determined by a settable parameter. To set it:\n"
+ "\n"
+ " set editor (program-name)")
-Usage: edit [file_path]
- Where:
- * file_path - path to a file to open in editor
+ edit_parser = ACArgumentParser(description=edit_description)
+ setattr(edit_parser.add_argument('file_path', help="path to a file to open in editor", nargs="?"),
+ ACTION_ARG_CHOICES, ('path_complete',))
-The editor used is determined by the ``editor`` settable parameter.
-"set editor (program-name)" to change or set the EDITOR environment variable.
-"""
+ @with_argparser(edit_parser)
+ def do_edit(self, args: argparse.Namespace) -> None:
+ """Edit a file in a text editor"""
if not self.editor:
raise EnvironmentError("Please use 'set editor' to specify your text editing program of choice.")
- filename = arglist[0] if arglist else ''
- if filename:
- os.system('"{}" "{}"'.format(self.editor, filename))
- else:
- os.system('"{}"'.format(self.editor))
- def complete_edit(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
- """Enable tab-completion for edit command."""
- index_dict = {1: self.path_complete}
- return self.index_based_complete(text, line, begidx, endidx, index_dict)
+ editor = utils.quote_string_if_needed(self.editor)
+ if args.file_path:
+ expanded_path = utils.quote_string_if_needed(os.path.expanduser(args.file_path))
+ os.system('{} {}'.format(editor, expanded_path))
+ else:
+ os.system('{}'.format(editor))
@property
def _current_script_dir(self) -> Optional[str]:
@@ -3340,54 +3344,25 @@ The editor used is determined by the ``editor`` settable parameter.
else:
return None
- @with_argument_list
- def do__relative_load(self, arglist: List[str]) -> None:
- """Runs commands in script file that is encoded as either ASCII or UTF-8 text
-
- Usage: _relative_load <file_path>
-
- optional argument:
- file_path a file path pointing to a script
-
-Script should contain one command per line, just like command would be typed in console.
-
-If this is called from within an already-running script, the filename will be interpreted
-relative to the already-running script's directory.
-
-NOTE: This command is intended to only be used within text file scripts.
- """
- # If arg is None or arg is an empty string this is an error
- if not arglist:
- self.perror('_relative_load command requires a file path:', traceback_war=False)
- return
-
- file_path = arglist[0].strip()
- # NOTE: Relative path is an absolute path, it is just relative to the current script directory
- relative_path = os.path.join(self._current_script_dir or '', file_path)
- self.do_load([relative_path])
-
- def do_eos(self, _: str) -> None:
- """Handles cleanup when a script has finished executing"""
+ @with_argparser(ACArgumentParser(epilog=INTERNAL_COMMAND_EPILOG))
+ def do_eos(self, _: argparse.Namespace) -> None:
+ """Handle cleanup when a script has finished executing"""
if self._script_dir:
self._script_dir.pop()
- @with_argument_list
- def do_load(self, arglist: List[str]) -> None:
- """Runs commands in script file that is encoded as either ASCII or UTF-8 text
-
- Usage: load <file_path>
+ load_description = ("Run commands in script file that is encoded as either ASCII or UTF-8 text\n"
+ "\n"
+ "Script should contain one command per line, just like the command would be\n"
+ "typed in the console.")
- * file_path - a file path pointing to a script
-
-Script should contain one command per line, just like command would be typed in console.
- """
- # If arg is None or arg is an empty string this is an error
- if not arglist:
- self.perror('load command requires a file path', traceback_war=False)
- return
+ load_parser = ACArgumentParser(description=load_description)
+ setattr(load_parser.add_argument('script_path', help="path to the script file"),
+ ACTION_ARG_CHOICES, ('path_complete',))
- file_path = arglist[0].strip()
- expanded_path = os.path.abspath(os.path.expanduser(file_path))
+ @with_argparser(load_parser)
+ def do_load(self, args: argparse.Namespace) -> None:
+ """Run commands in script file that is encoded as either ASCII or UTF-8 text"""
+ expanded_path = os.path.abspath(os.path.expanduser(args.script_path))
# Make sure the path exists and we can access it
if not os.path.exists(expanded_path):
@@ -3421,10 +3396,24 @@ Script should contain one command per line, just like command would be typed in
self._script_dir.append(os.path.dirname(expanded_path))
- def complete_load(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
- """Enable tab-completion for load command."""
- index_dict = {1: self.path_complete}
- return self.index_based_complete(text, line, begidx, endidx, index_dict)
+ relative_load_description = load_description
+ relative_load_description += ("\n\n"
+ "If this is called from within an already-running script, the filename will be\n"
+ "interpreted relative to the already-running script's directory.")
+
+ relative_load_epilog = ("Notes:\n"
+ " This command is intended to only be used within text file scripts.")
+
+ relative_load_parser = ACArgumentParser(description=relative_load_description, epilog=relative_load_epilog)
+ relative_load_parser.add_argument('file_path', help='a file path pointing to a script')
+
+ @with_argparser(relative_load_parser)
+ def do__relative_load(self, args: argparse.Namespace) -> None:
+ """"""
+ file_path = args.file_path
+ # NOTE: Relative path is an absolute path, it is just relative to the current script directory
+ relative_path = os.path.join(self._current_script_dir or '', file_path)
+ self.do_load(relative_path)
def run_transcript_tests(self, callargs: List[str]) -> None:
"""Runs transcript tests for provided file(s).
diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst
index a03a1d08..0a95a829 100644
--- a/docs/freefeatures.rst
+++ b/docs/freefeatures.rst
@@ -174,13 +174,9 @@ More Python examples:
Type "help", "copyright", "credits" or "license" for more information.
(CmdLineApp)
- Invoke python command, shell, or script
-
- py <command>: Executes a Python command.
- py: Enters interactive Python mode.
- End with ``Ctrl-D`` (Unix) / ``Ctrl-Z`` (Windows), ``quit()``, '`exit()``.
- Non-python commands can be issued with ``app("your command")``.
- Run python code from external script files with ``run("script.py")``
+ End with `Ctrl-D` (Unix) / `Ctrl-Z` (Windows), `quit()`, `exit()`.
+ Non-python commands can be issued with: app("your command")
+ Run python code from external script files with: run("script.py")
>>> import os
>>> os.uname()
diff --git a/tests/conftest.py b/tests/conftest.py
index 2c7a2600..da7e8b08 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -38,16 +38,16 @@ Documented commands (type help <topic>):
================================================================================
alias Manage aliases
edit Edit a file in a text editor
-help List available commands with "help" or detailed help with "help cmd"
+help List available commands or provide detailed help for a specific command
history View, run, edit, save, or clear previously entered commands
-load Runs commands in script file that is encoded as either ASCII or UTF-8 text
+load Run commands in script file that is encoded as either ASCII or UTF-8 text
macro Manage macros
-py Invoke python command, shell, or script
-pyscript Runs a python script file inside the console
-quit Exits this application
-set Sets a settable parameter or shows current settings of parameters
+py Invoke Python command or shell
+pyscript Run a Python script file inside the console
+quit Exit this application
+set Set a settable parameter or show current settings of parameters
shell Execute a command as if at the OS prompt
-shortcuts Lists shortcuts available
+shortcuts List available shortcuts
"""
# Help text for the history command
@@ -66,12 +66,12 @@ optional arguments:
-h, --help show this help message and exit
-r, --run run selected history items
-e, --edit edit and then run selected history items
- -s, --script script format; no separation lines
+ -s, --script output commands in script format
-o, --output-file FILE
output commands to a script file
-t, --transcript TRANSCRIPT
output commands and results to a transcript file
- -c, --clear clears all history
+ -c, --clear clear all history
"""
# Output from the shortcuts command with default built-in shortcuts
diff --git a/tests/test_argparse.py b/tests/test_argparse.py
index 0939859a..fdd16bcc 100644
--- a/tests/test_argparse.py
+++ b/tests/test_argparse.py
@@ -217,7 +217,7 @@ class SubcommandApp(cmd2.Cmd):
func(self, args)
else:
# No subcommand was provided, so call help
- self.do_help(['base'])
+ self.do_help('base')
@pytest.fixture
def subcommand_app():
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index daca3087..3ce7a11d 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -70,7 +70,7 @@ def test_base_argparse_help(base_app, capsys):
assert out1 == out2
assert out1[0].startswith('Usage: set')
assert out1[1] == ''
- assert out1[2].startswith('Sets a settable parameter')
+ assert out1[2].startswith('Set a settable parameter')
def test_base_invalid_option(base_app, capsys):
run_cmd(base_app, 'set -z')
@@ -184,6 +184,30 @@ now: True
assert out == ['quiet: True']
+class OnChangeHookApp(cmd2.Cmd):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ def _onchange_quiet(self, old, new) -> None:
+ """Runs when quiet is changed via set command"""
+ self.poutput("You changed quiet")
+
+@pytest.fixture
+def onchange_app():
+ app = OnChangeHookApp()
+ app.stdout = utils.StdSim(app.stdout)
+ return app
+
+def test_set_onchange_hook(onchange_app):
+ out = run_cmd(onchange_app, 'set quiet True')
+ expected = normalize("""
+quiet - was: False
+now: True
+You changed quiet
+""")
+ assert out == expected
+
+
def test_base_shell(base_app, monkeypatch):
m = mock.Mock()
monkeypatch.setattr("{}.Popen".format('subprocess'), m)
@@ -247,7 +271,7 @@ def test_pyscript_with_exception(base_app, capsys, request):
def test_pyscript_requires_an_argument(base_app, capsys):
run_cmd(base_app, "pyscript")
out, err = capsys.readouterr()
- assert err.startswith('ERROR: pyscript command requires at least 1 argument ...')
+ assert "the following arguments are required: script_path" in err
def test_base_error(base_app):
@@ -477,7 +501,7 @@ def test_load_with_empty_args(base_app, capsys):
out, err = capsys.readouterr()
# The load command requires a file path argument, so we should get an error message
- assert "load command requires a file path" in str(err)
+ assert "the following arguments are required" in str(err)
assert base_app.cmdqueue == []
@@ -614,8 +638,7 @@ def test_base_relative_load(base_app, request):
def test_relative_load_requires_an_argument(base_app, capsys):
run_cmd(base_app, '_relative_load')
out, err = capsys.readouterr()
- assert out == ''
- assert err.startswith('ERROR: _relative_load command requires a file path:\n')
+ assert 'Error: the following arguments' in err
assert base_app.cmdqueue == []
@@ -858,7 +881,8 @@ def test_edit_file(base_app, request, monkeypatch):
run_cmd(base_app, 'edit {}'.format(filename))
# We think we have an editor, so should expect a system call
- m.assert_called_once_with('"{}" "{}"'.format(base_app.editor, filename))
+ m.assert_called_once_with('{} {}'.format(utils.quote_string_if_needed(base_app.editor),
+ utils.quote_string_if_needed(filename)))
def test_edit_file_with_spaces(base_app, request, monkeypatch):
# Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock
@@ -874,7 +898,8 @@ def test_edit_file_with_spaces(base_app, request, monkeypatch):
run_cmd(base_app, 'edit "{}"'.format(filename))
# We think we have an editor, so should expect a system call
- m.assert_called_once_with('"{}" "{}"'.format(base_app.editor, filename))
+ m.assert_called_once_with('{} {}'.format(utils.quote_string_if_needed(base_app.editor),
+ utils.quote_string_if_needed(filename)))
def test_edit_blank(base_app, monkeypatch):
# Set a fake editor just to make sure we have one. We aren't really going to call it due to the mock
@@ -1250,16 +1275,16 @@ diddly This command does diddly
Other
================================================================================
alias Manage aliases
-help List available commands with "help" or detailed help with "help cmd"
+help List available commands or provide detailed help for a specific command
history View, run, edit, save, or clear previously entered commands
-load Runs commands in script file that is encoded as either ASCII or UTF-8 text
+load Run commands in script file that is encoded as either ASCII or UTF-8 text
macro Manage macros
-py Invoke python command, shell, or script
-pyscript Runs a python script file inside the console
-quit Exits this application
-set Sets a settable parameter or shows current settings of parameters
+py Invoke Python command or shell
+pyscript Run a Python script file inside the console
+quit Exit this application
+set Set a settable parameter or show current settings of parameters
shell Execute a command as if at the OS prompt
-shortcuts Lists shortcuts available
+shortcuts List available shortcuts
Undocumented commands:
======================
@@ -1556,7 +1581,7 @@ def test_is_text_file_bad_input(base_app):
def test_eof(base_app):
# Only thing to verify is that it returns True
- assert base_app.do_eof('dont care')
+ assert base_app.do_eof('')
def test_eos(base_app):
sdir = 'dummy_dir'
@@ -1564,7 +1589,7 @@ def test_eos(base_app):
assert len(base_app._script_dir) == 1
# Assert that it does NOT return true
- assert not base_app.do_eos('dont care')
+ assert not base_app.do_eos('')
# And make sure it reduced the length of the script dir list
assert len(base_app._script_dir) == 0
@@ -1803,7 +1828,7 @@ def test_alias_create(base_app, capsys):
# Use the alias
run_cmd(base_app, 'fake')
out, err = capsys.readouterr()
- assert "pyscript command requires at least 1 argument" in err
+ assert "the following arguments are required: script_path" in err
# See a list of aliases
out = run_cmd(base_app, 'alias list')
@@ -1905,7 +1930,7 @@ def test_macro_create(base_app, capsys):
# Use the macro
run_cmd(base_app, 'fake')
out, err = capsys.readouterr()
- assert "pyscript command requires at least 1 argument" in err
+ assert "the following arguments are required: script_path" in err
# See a list of macros
out = run_cmd(base_app, 'macro list')
diff --git a/tests/test_completion.py b/tests/test_completion.py
index f02989bf..1b7b65d2 100644
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -98,7 +98,7 @@ def test_complete_empty_arg(cmd2_app):
endidx = len(line)
begidx = endidx - len(text)
- expected = sorted(cmd2_app.complete_help(text, line, begidx, endidx))
+ expected = sorted(cmd2_app.get_visible_commands())
first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
assert first_match is not None and cmd2_app.completion_matches == expected
@@ -151,7 +151,11 @@ def test_cmd2_help_completion_single(cmd2_app):
line = 'help {}'.format(text)
endidx = len(line)
begidx = endidx - len(text)
- assert cmd2_app.complete_help(text, line, begidx, endidx) == ['help']
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+
+ # It is at end of line, so extra space is present
+ assert first_match is not None and cmd2_app.completion_matches == ['help ']
def test_cmd2_help_completion_multiple(cmd2_app):
text = 'h'
@@ -159,15 +163,18 @@ def test_cmd2_help_completion_multiple(cmd2_app):
endidx = len(line)
begidx = endidx - len(text)
- matches = sorted(cmd2_app.complete_help(text, line, begidx, endidx))
- assert matches == ['help', 'history']
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and cmd2_app.completion_matches == ['help', 'history']
+
def test_cmd2_help_completion_nomatch(cmd2_app):
text = 'fakecommand'
line = 'help {}'.format(text)
endidx = len(line)
begidx = endidx - len(text)
- assert cmd2_app.complete_help(text, line, begidx, endidx) == []
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is None
def test_shell_command_completion_shortcut(cmd2_app):
@@ -201,7 +208,9 @@ def test_shell_command_completion_doesnt_match_wildcards(cmd2_app):
line = 'shell {}'.format(text)
endidx = len(line)
begidx = endidx - len(text)
- assert cmd2_app.complete_shell(text, line, begidx, endidx) == []
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is None
def test_shell_command_completion_multiple(cmd2_app):
if sys.platform == "win32":
@@ -214,21 +223,27 @@ def test_shell_command_completion_multiple(cmd2_app):
line = 'shell {}'.format(text)
endidx = len(line)
begidx = endidx - len(text)
- assert expected in cmd2_app.complete_shell(text, line, begidx, endidx)
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and expected in cmd2_app.completion_matches
def test_shell_command_completion_nomatch(cmd2_app):
text = 'zzzz'
line = 'shell {}'.format(text)
endidx = len(line)
begidx = endidx - len(text)
- assert cmd2_app.complete_shell(text, line, begidx, endidx) == []
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is None
def test_shell_command_completion_doesnt_complete_when_just_shell(cmd2_app):
text = ''
line = 'shell {}'.format(text)
endidx = len(line)
begidx = endidx - len(text)
- assert cmd2_app.complete_shell(text, line, begidx, endidx) == []
+
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is None
def test_shell_command_completion_does_path_completion_when_after_command(cmd2_app, request):
test_dir = os.path.dirname(request.module.__file__)
@@ -239,7 +254,8 @@ def test_shell_command_completion_does_path_completion_when_after_command(cmd2_a
endidx = len(line)
begidx = endidx - len(text)
- assert cmd2_app.complete_shell(text, line, begidx, endidx) == [text + '.py']
+ first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
+ assert first_match is not None and cmd2_app.completion_matches == [text + '.py ']
def test_path_completion_single_end(cmd2_app, request):
@@ -742,7 +758,11 @@ def test_cmd2_help_subcommand_completion_single(sc_app):
line = 'help {}'.format(text)
endidx = len(line)
begidx = endidx - len(text)
- assert sc_app.complete_help(text, line, begidx, endidx) == ['base']
+
+ first_match = complete_tester(text, line, begidx, endidx, sc_app)
+
+ # It is at end of line, so extra space is present
+ assert first_match is not None and sc_app.completion_matches == ['base ']
def test_cmd2_help_subcommand_completion_multiple(sc_app):
text = ''
@@ -750,8 +770,8 @@ def test_cmd2_help_subcommand_completion_multiple(sc_app):
endidx = len(line)
begidx = endidx - len(text)
- matches = sorted(sc_app.complete_help(text, line, begidx, endidx))
- assert matches == ['bar', 'foo', 'sport']
+ first_match = complete_tester(text, line, begidx, endidx, sc_app)
+ assert first_match is not None and sc_app.completion_matches == ['bar', 'foo', 'sport']
def test_cmd2_help_subcommand_completion_nomatch(sc_app):
@@ -759,7 +779,9 @@ def test_cmd2_help_subcommand_completion_nomatch(sc_app):
line = 'help base {}'.format(text)
endidx = len(line)
begidx = endidx - len(text)
- assert sc_app.complete_help(text, line, begidx, endidx) == []
+
+ first_match = complete_tester(text, line, begidx, endidx, sc_app)
+ assert first_match is None
def test_subcommand_tab_completion(sc_app):
# This makes sure the correct completer for the sport subcommand is called
@@ -852,7 +874,7 @@ class SubcommandsWithUnknownExample(cmd2.Cmd):
func(self, args)
else:
# No subcommand was provided, so call help
- self.do_help(['base'])
+ self.do_help('base')
@pytest.fixture
@@ -901,7 +923,11 @@ def test_cmd2_help_subcommand_completion_single_scu(scu_app):
line = 'help {}'.format(text)
endidx = len(line)
begidx = endidx - len(text)
- assert scu_app.complete_help(text, line, begidx, endidx) == ['base']
+
+ first_match = complete_tester(text, line, begidx, endidx, scu_app)
+
+ # It is at end of line, so extra space is present
+ assert first_match is not None and scu_app.completion_matches == ['base ']
def test_cmd2_help_subcommand_completion_multiple_scu(scu_app):
@@ -910,8 +936,34 @@ def test_cmd2_help_subcommand_completion_multiple_scu(scu_app):
endidx = len(line)
begidx = endidx - len(text)
- matches = sorted(scu_app.complete_help(text, line, begidx, endidx))
- assert matches == ['bar', 'foo', 'sport']
+ first_match = complete_tester(text, line, begidx, endidx, scu_app)
+ assert first_match is not None and scu_app.completion_matches == ['bar', 'foo', 'sport']
+
+def test_cmd2_help_subcommand_completion_with_flags_before_command(scu_app):
+ text = ''
+ line = 'help -h -v base {}'.format(text)
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ first_match = complete_tester(text, line, begidx, endidx, scu_app)
+ assert first_match is not None and scu_app.completion_matches == ['bar', 'foo', 'sport']
+
+def test_complete_help_subcommand_with_no_command(scu_app):
+ # No command because not enough tokens
+ text = ''
+ line = 'help '
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ assert not scu_app.complete_help_subcommand(text, line, begidx, endidx)
+
+ # No command because everything is a flag
+ text = '-v'
+ line = 'help -f -v'
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ assert not scu_app.complete_help_subcommand(text, line, begidx, endidx)
def test_cmd2_help_subcommand_completion_nomatch_scu(scu_app):
@@ -919,7 +971,9 @@ def test_cmd2_help_subcommand_completion_nomatch_scu(scu_app):
line = 'help base {}'.format(text)
endidx = len(line)
begidx = endidx - len(text)
- assert scu_app.complete_help(text, line, begidx, endidx) == []
+
+ first_match = complete_tester(text, line, begidx, endidx, scu_app)
+ assert first_match == None
def test_subcommand_tab_completion_scu(scu_app):
diff --git a/tests/test_pyscript.py b/tests/test_pyscript.py
index 84abc965..36e48598 100644
--- a/tests/test_pyscript.py
+++ b/tests/test_pyscript.py
@@ -18,16 +18,16 @@ class PyscriptExample(Cmd):
def _do_media_movies(self, args) -> None:
if not args.command:
- self.do_help(['media movies'])
+ self.do_help('media movies')
else:
self.poutput('media movies ' + str(args.__dict__))
def _do_media_shows(self, args) -> None:
if not args.command:
- self.do_help(['media shows'])
+ self.do_help('media shows')
if not args.command:
- self.do_help(['media shows'])
+ self.do_help('media shows')
else:
self.poutput('media shows ' + str(args.__dict__))
@@ -72,7 +72,7 @@ class PyscriptExample(Cmd):
func(self, args)
else:
# No subcommand was provided, so call help
- self.do_help(['media'])
+ self.do_help('media')
foo_parser = argparse_completer.ACArgumentParser(prog='foo')
foo_parser.add_argument('-c', dest='counter', action='count')
@@ -84,7 +84,7 @@ class PyscriptExample(Cmd):
@with_argparser(foo_parser)
def do_foo(self, args):
- self.poutput('foo ' + str(args.__dict__))
+ self.poutput('foo ' + str(sorted(args.__dict__)))
if self._in_py:
FooResult = namedtuple_with_defaults('FooResult',
['counter', 'trueval', 'constval',