Source code for commandeer

# Copyright (C) 2012 Johannes Spielmann
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""Use this module to add a simple way to add nice command line interaction to your program.

Usage: in your main python script, arrange for all functions that should be reachable from
the command line to have a name ending in ``_command``. All arguments to your function are
parsed as arguments in a smart way.
At the end of your script, simply add
a call to ``commandeer.cli()`` and you're good to go. A simple example script could look like
this::

  "This script echo's your input."
  def echo_command(input, timestamp=False, indent=0):
	  "Outputs the input, with an optional timestamp and some indenting."
	  echo_str = input
	  if timestamp:
		  echo_str = "[date]" + echo_str
	  echo_str = " " * indent + echo_str
	  print(echo_str)
	  
  if __name__ == '__main__':
	  commandeer.cli()

Calling this script without any arguments (or with only "help") will show a short help screen::

  $ python script.py help
  This script echo's your input.
  Usage: script.py command [options]
  
  Command can be one of the following:
    echo   Outputs the input, with an optional timestamp and some indenting.

Calling the script with "help commandname" will print more information from the docstring as
well as the available arguments.

For a more detailed explanation refer to the documentation.
"""

import inspect
import sys

#exported functions

[docs]def cli(with_help=True): """Create a really nice command line from the callers local space, then parse the program arguments and act accordingly. If you set the ``with_help`` parameter to ``True``, commandeer will add the help command by itself and also execute that command when no other commands are passed to the program. If you set it to ``False``, no help will be added and only your commands will be available. """ # we need to go back one frame because we need to parse the caller's local namespace # this line is here because we don't want to inflate the stack frame backtracing too much, it's only one level we have to go back from here caller_locals = inspect.currentframe().f_back.f_locals # build the command map (i.e. short commands) commands = _build_commands_from_locals(caller_locals) commandmap = _condense(commands) maindoc = caller_locals['__doc__'] # parse command line arguments into components calledfile, command, switches, args = _argparse() # add help to the mix if it is enabled if with_help: def help(*helpforcommands): """Show this help screen.""" if helpforcommands: for commandname in helpforcommands: return _help_for_command(calledfile, commandname, commandmap[commandname]) return _help(calledfile, maindoc, commands, command) commands['help'] = help commandmap['help'] = commandmap['hlp'] = commandmap['?'] = help if command == '': command = 'help' # before calling a command, find the right command if command not in commandmap: return _error_command_not_found(calledfile, maindoc, commands, command, switches, args) command_func = commandmap[command] return _call_command_with_args(command, commandmap[command], switches, args) ############################################################################################################################### # constants # help for the whole program
HELP_FORMAT = """ {doc}Usage: {name} command [options] Here are the commands you can try: {commands_formatted} If you need help on any of them, try "{name} help command" for any of them.""" # we build the actual format string from this format_format string. Oh, fun! COMMAND_SHORTDOC_FORMAT_FORMAT = """ {{name:<{spacerlength}}}{{docline}}\n """ # help format for a single command HELP_COMMAND_FORMAT = """Usage: {name} {command}{required}{aliases} {shortexp} {explanation}""" ARG_DOC_FORMAT_FORMAT = """ {{name:<{spacerlength}}} {{doc}} """ ARG_ADD_FORMAT = """ Required arguments: {args_formatted}""" SWITCH_DOC_FORMAT_FORMAT = """ --{{name:<{spacerlength}}} {{doc}} {spacer}default value: {{default}} """ SWITCH_ADD_FORMAT = """ Command line options: {switches_formatted}""" # allowed values for boolean switch values TRUE_VALUES = "true", "yes", "on", "1", FALSE_VALUES = "false", "no", "off", "0", "-1" # but, honestly, just use --switchname or --noswitchname ############################################################################################################################### # functions directly called from the exported functions def _build_commands_from_locals(caller_locals): """Extract all callable commands from the 'locals' dictionary of the caller.""" commands = dict() for l in caller_locals: if l.endswith('_command'): commands[l[:-8]] = caller_locals[l] return commands def _condense(command_dict): """Create a list of short commands. Creates a dictionary of strings that can be used to start commands. These short commands are built from all available commands in such a way that the shortest (and all longer) mnemonics that uniquely idenfity one command. It's O(n^2), but that really shouldn't matter because we don't expect there to be many commands.""" res = dict() candidates = set() for command_name, command in command_dict.items(): res[command_name] = command if 'aliases' in command.__dict__: for alias in command.aliases: # note that we might clobber legitimate functions if the aliases are duplicate (or the other way around) res[alias] = command # now that the commands and aliases have been added for command_name, command in command_dict.items(): for length in range(1, len(command_name)): unique = True short_command_name = command_name[:length] for othercommand_name, other_command in res.items(): if othercommand_name.startswith(short_command_name) and command_name != othercommand_name: unique = False break if unique: res[short_command_name] = command return res def _argparse(argv=None): """Parse arguments into an easily digestible dictionary. Returns the name of the called file, the command, all switches and their values and all arguments. Arguments are such values in the array that do not start with a dash, switches are the rest. The first argument is special because it is the command. If the first entry in the array (afte the file name) is a switch, the command will be ''. Expects an array in the same form as sys.argv.""" if argv is None: argv = sys.argv start_args_index = 2 callfile = argv[0] if len(argv) > 1: if argv[1].startswith('-'): start_args_index = 1 command = '' else: command = argv[1] else: command = '' args = list() switches = dict() last = None for arg_pos in argv[start_args_index:]: if arg_pos.startswith('-'): last = None switch = _clean_switch(arg_pos) if switch.find('=') != -1: switchname, switchval = switch.split('=', 1) switches[switchname] = switchval else: switches[switch] = '' last = switch # last is our 'carry bit', because we might need to add something to this command at the next position else: if last is not None: switches[last] = arg_pos last = None else: args.append(arg_pos) return callfile, command, switches, args def _call_command_with_args(command, command_func, switches, args): """Call the given command_func (with name 'command') using the specified switches and args.""" fn_accepts_args, fn_args, fn_defaults, fn_kwargs, fn_all_args = _funcspec(command_func) pass_args = list() pass_kwargs = dict() #check number of arguments if len(args) < len(fn_args): return _error_too_few_input_arguments(command, command_func, args, fn_args) else: if len(args) == len(fn_args): pass_args = args else: #len(args) > len(fn_args) if fn_accepts_args: pass_args = args[:len(fn_args)] # we'll have to fill up the default arguments before we can go on, unfortunately for def_arg in fn_all_args[len(fn_args):len(fn_defaults)+len(fn_args)]: pass_args.append(fn_defaults[def_arg]) pass_args.extend(args[len(fn_args):]) else: return _error_too_many_input_arguments(command, command_func, args, fn_args) # fill up arguments array with defaults for argname in fn_all_args: if argname in fn_defaults: pass_args.append(fn_defaults[argname]) #clean up the switch values (and check for errors) error_switches = dict() for switch in switches: # we clean once because we want to get rid of the 'no'-beginnings switch_val = switches[switch] switch_val, switch = _clean_value(switch_val, '', switch) if switch not in fn_defaults and switch not in fn_kwargs: error_switches[switch] = switch_val continue if switch in fn_defaults: switch_default = fn_defaults.get(switch) clean_value, clean_switch = _clean_value(switch_val, switch_default, switch) pass_args[fn_all_args.index(clean_switch)] = clean_value else: # switch in fn_kwargs switch_default = fn_kwargs.get(switch) clean_value, clean_switch = _clean_value(switch_val, switch_default, switch) pass_kwargs[clean_switch] = clean_value if error_switches: return _error_too_many_switches(command, command_func, error_switches) return command_func(*pass_args, **pass_kwargs) ############################################################################################################################### # help functionality def _help(name, maindoc, commands, command): """Show a helpful help message for available commands.""" maxlength = max(map(len, commands)) # produce shortdoc-format by inserting the max-length + 4 into the shortdoc-format-format COMMAND_SHORTDOC_FORMAT = COMMAND_SHORTDOC_FORMAT_FORMAT.format(spacerlength=maxlength+6) commands_formatted = "" for command in sorted(commands): commands_formatted += COMMAND_SHORTDOC_FORMAT.format(name=command, docline=_firstline(commands[command].__doc__)) if maindoc: maindoc += "\n " else: maindoc = '' helpstr = HELP_FORMAT.format(name=name, doc=maindoc, commands_formatted=commands_formatted) print(helpstr) def _help_for_command(name, command, command_func): """Show the help for a specific command.""" shortdoc, longdoc, params = _parse_docstring(command_func.__doc__) fn_accepts_args, fn_args, fn_defaults, fn_kwargs, fn_all_args = _funcspec(command_func) # prepare doc for required arguments args_maxlength = max(map(len, fn_args + [''])) ARG_DOC_FORMAT = ARG_DOC_FORMAT_FORMAT.format(spacerlength=args_maxlength) args_formatted = "" for arg in fn_args: args_formatted += ARG_DOC_FORMAT.format(name=arg, doc=params.get(arg, '')) # prepare doc for each of the optional values fn_defaults.update(fn_kwargs) switches = list(fn_defaults) + list(fn_kwargs) switches_maxlength = max(map(len, switches + [''])) SWITCH_DOC_FORMAT = SWITCH_DOC_FORMAT_FORMAT.format(spacerlength=switches_maxlength, spacer=' ' * switches_maxlength) switches_formatted = "" for switch in fn_defaults: default = str(fn_defaults[switch]) switches_formatted += SWITCH_DOC_FORMAT.format(name=switch, default=default, doc=params.get(switch, '')) # assembling final string required, aliases = "", "" if fn_args: required = " " + ' '.join(fn_args) if fn_accepts_args: required += " [optional arguments]" if fn_defaults: required += " [optional switches]" if 'aliases' in command_func.__dict__: aliases = """ Aliases: {} """.format(', '.join(command_func.aliases)) helpstr = HELP_COMMAND_FORMAT.format(name=name, command=command, required=required, aliases=aliases, shortexp=shortdoc, explanation=longdoc).strip() if fn_args: helpstr += ARG_ADD_FORMAT.format(args_formatted=args_formatted[:-1]) # -1 because that's removing the extra newline at the end if switches: helpstr += SWITCH_ADD_FORMAT.format(switches_formatted=switches_formatted) print(helpstr) ############################################################################################################################### # error handlers def _error_too_few_input_arguments(command, command_func, args, fnargs): """Call when the error occurs: too few input arguments.""" print("Error: missing required arguments. Here is a list of arguments this command requires and the values you gave for them:") shortdoc, longdoc, params = _parse_docstring(command_func.__doc__) args_maxlength = max(map(len, fnargs + [''])) ARG_DOC_FORMAT = ARG_DOC_FORMAT_FORMAT.format(spacerlength=args_maxlength) args_formatted = "" for index, arg in enumerate(fnargs): if index < len(args): args_formatted += ARG_DOC_FORMAT.format(name=arg, doc="'{}'".format(args[index])) else: args_formatted += ARG_DOC_FORMAT.format(name=arg, doc=params[arg]) print(args_formatted[:-1]) # removing the extra newline at the end print("Try 'help {}' for a full documentation of this command.".format(command)) def _error_too_many_input_arguments(command, command_func, args, fnargs): """Call when the error occurred: Too many input arguments.""" print("Error: too many arguments. Here is a list of arguments this command takes and the values you gave for them:") shortdoc, longdoc, params = _parse_docstring(command_func.__doc__) args_maxlength = max(map(len, fnargs + [''])) ARG_DOC_FORMAT = ARG_DOC_FORMAT_FORMAT.format(spacerlength=args_maxlength) args_formatted = "" for index, arg in enumerate(fnargs): args_formatted += ARG_DOC_FORMAT.format(name=arg, doc="'{}'".format(args[index])) print(args_formatted[:-1]) # removing the extra newline at the end print("You supplied the additional arguments: " + ' '.join(args[len(fnargs):])) print("Try 'help {}' for a full documentation of this command.".format(command)) def _error_too_many_switches(command, command_func, error_switches): """Call when there are non-recognized switches.""" shortdoc, longdoc, params = _parse_docstring(command_func.__doc__) fn_accepts_args, fn_args, fn_defaults, fn_kwargs, fn_all_args = _funcspec(command_func) # prepare doc for each of the optional values fn_defaults.update(fn_kwargs) switches = list(fn_defaults) + list(fn_kwargs) switches_maxlength = max(map(len, switches + list(error_switches))) SWITCH_DOC_FORMAT = SWITCH_DOC_FORMAT_FORMAT.format(spacerlength=switches_maxlength, spacer=' ' * switches_maxlength) switches_formatted = "" for switch in fn_defaults: default = str(fn_defaults[switch]) switches_formatted += SWITCH_DOC_FORMAT.format(name=switch, default=default, doc=params.get(switch, '')) ESWITCH_DOC_FORMAT = ARG_DOC_FORMAT_FORMAT.format(spacerlength=switches_maxlength+2, spacer=' ' * (switches_maxlength+2)) # +2 to account for missing -- eswitches_formatted = "" for switch in error_switches: eswitches_formatted += ESWITCH_DOC_FORMAT.format(name="--"+switch, doc=repr(error_switches[switch])) print("Error: unrecognized options: Here is a list of available options") print(switches_formatted) print("You supplied the following extra options:") print(eswitches_formatted) def _error_command_not_found(name, maindoc, commands, command, switches, args): """Call when the error occurred: The command was not found.""" # todo: must be a lot better here! _help(name, maindoc, commands, command) print("Error: Command '{}' not found".format(command)) # print("available commands are:", ", ".join(commands)) # print("use '{} help' to get information about each of them".format(name)) ############################################################################################################################### # helper functions def _clean_switch(switchinput): """Remove dashes from the beginning of the string""" while switchinput.startswith('-'): switchinput = switchinput[1:] return switchinput def _firstline(s): """Return the first line of the string.""" if s is None: return '' return s.split('\n', 1)[0] def _parse_docstring(s): """Parse out the short and long parts of a docstring and extract the param descriptions from them.""" if s is None: return '', '', {} res = s.split('\n', 1) if len(res) == 1: return s, '', {} shortdoc = res[0] # find and extract the parameter lines params = {} lines = res[1].split('\n') doclines = list() for line in lines: # TODO: multiline param descriptions line = line.strip() if line.startswith('\param') or line.startswith('@param'): spl = line.split(' ', 2) spl.extend(('', '', '')) commandname = spl[1] doc = spl[2].strip() params[commandname] = doc else: if not line.startswith('\\') and not line.startswith('@'): doclines.append(line) longdoc = ' '.join(line for line in doclines).strip() #why the second strip? because we get spaces in the beginning otherwise return shortdoc, longdoc, params def _firstline_separated(s): if s is None: return '' res = s.split('\n', 1) if len(res) == 1: return res[0], '' return res def _funcspec(fn): """Parse out the function specification from the given function. Returns a boolean that indicates whether the function accepts a variable number of arguments, a list of arguments without a default value, a dictionary of arguments with a default value and a list of all arguments the function accepts. Note that this is not a "full" functional specification, as we are only interested in certain parts. """ fnargs, fnvarargname, fnkwname, fndefaults, fnkwonlyargs, fnkwonlydefaults, fnannotations = inspect.getfullargspec(fn) # normalize to empty types instead of None fndefaults = fndefaults or [] fnkwonlydefaults = fnkwonlydefaults or dict() defaults = dict() for i in range(len(fndefaults)): defaults[fnargs[-(i+1)]] = fndefaults[-(i+1)] if len(fndefaults) > 0: args = fnargs[:-len(fndefaults)] else: args = fnargs[:] all_args = fnargs + fnkwonlyargs accepts_args = fnvarargname is not None return accepts_args, args, defaults, fnkwonlydefaults, all_args def _clean_value(value, default, name): """Clean up the switch value for use in calling a function. This includes checking the type, determining some boolean values as well as accepting switches that are in the form 'noYYY' or 'YYY' to indicate boolean values. """ if value == '': # values of empty string are those that don't have arguments if name.startswith('no'): return False, name[2:] else: return True, name if type(default) == type(0) or type(default) == type(0.0): try: return int(value), name except: try: return float(value), name except: return value, name if type(default) == type(True): value = str(value).lower() if value not in TRUE_VALUES and value not in FALSE_VALUES: return None, name return value in TRUE_VALUES, name return value, name