## ## altopt.py ## ## Alternative command line options parsing library. ## ## by Michael J. Fromberger ## Copyright (C) 2003 Michael J. Fromberger ## ## Permission is hereby granted, free of charge, to any person ## obtaining a copy of this software and associated documentation ## files (the "Software"), to deal in the Software without ## restriction, including without limitation the rights to use, copy, ## modify, merge, publish, distribute, sublicense, and/or sell copies ## of the Software, and to permit persons to whom the Software is ## furnished to do so, subject to the following conditions: ## ## The above copyright notice and this permission notice shall be ## included in all copies or substantial portions of the Software. ## ## THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, ## EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF ## MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. ## IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY ## CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, ## TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE ## SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ## ## $Id: altopt.py,v 1.10 2003/08/27 19:53:44 sting Exp $ ## ## The option parsing strategy used here is based on the format used ## by the AFS (Andrew File System) tools, although I have not tried to ## make them identical by any means. ## ## There are two types of options: ## -single : an option which is selected by being present, and ## consumes no additional command line arguments ## ## -multiple : an option which consumes one or more additional ## arguments. ## ## ---- Basic usage: ## ## 1. Create an Options object ## ## 2. Add options to the object using its methods: ## .add_option(), .add_single(), .add_multiple() ## [optionally] Set help text using .set_help() ## [optionally] Set a free-argument map using .set_free_map() ## [optionally] Set min/max numbers of free args using .set_min_free() ## and .set_max_free() ## ## 3. Parse a command line using the .parse() method. ## ## ---- Alternate usage: ## ## 1. Call create_from_strings() with a list of option specs. ## This returns a new Options object. ## ## 2. Parse a command line using the .parse() method. ## ## ---- Other Things to Know: ## ## Each Options object has a string that signals the end of option ## processing when it is encountered. By default, it is `--', but you ## can set it using the .set_end() method. The terminating string ## MUST begin with a dash (-); one will be added if it doesn't. ## ## Each Options object has a string that "escapes" the following ## argument when it is encountered. By default, it is `-esc', but you ## can set it using the .set_escape() method. The escape string tells ## the parser to take the following argument "as is" even if it ## otherwise looks like an option selector. ## ## Any unique prefix of an option name suffices to match that option ## when parsing. So, for instance, if there are options named "aaron" ## and "aardvark", -aard would be a valid match for "aardvark" (as ## would -aardv, -aardva, and -aardvar) while -aaro would be a valid ## match for "aaron". But -a, -aa, and -aar are ambiguous. ## ## The string `-' is special, and is not treated as an option (i.e., ## when encountered, it is treated as a plain argument). ## ## In case of errors, an OptError is thrown. ## import sre as _sre __version__ = "$Revision: 1.10 $".split(' ')[1] class AltOptError ( Exception ): """The superclass for this library's exceptions.""" class OptError ( AltOptError ): """This class represents errors in option processing.""" class CommandError ( AltOptError ): """This class represents errors in command processing.""" class Options: """This class represents a collection of command line options. After creating an instance of the object, use the .add_single(), .add_multiple(), and .add_option() methods to add options to the collection. See also the create_from_strings() function. If the vald argument is true, then only valid (recognized) options will signal the end of an argument list for a multiple option. If vald is false, anything that "looks like" an option (i.e., begins with a dash and is not the escape- or option-ending string) will signal the end of the options.""" def __init__(self, vald = False): """Create a new empty collection of options.""" self.__opts = {} # Primary option information self.__nmap = None # Prefix mapping self.__eopt = '--' # Marker for end of options self.__esc = '-esc' # Escapes the next argument self.__vald = vald # Validate option-like strings? self.__fmap = None # Maps free arguments to multiple options # Other option information... self.__info = { 'min_free': 0, 'max_free': -1 } def __str__(self): """The string form of an Options object can be used as part of a help message, as it gives a summary of each of the options currently known, along with their help messages (if any).""" if not self.__opts: return '' max_len = max([ len(name) for name in self.__opts.keys() ]) + 1 singles = filter(lambda n: self.__opts[n]['type'] == 'single', self.__opts.keys()) others = filter(lambda n: self.__opts[n]['type'] <> 'single', self.__opts.keys()) singles.sort() others.sort() return str.join('\n', [ self.__fmt_opt(max_len, name) for name in singles ] + [ self.__fmt_opt(max_len, name) for name in others ]) def __fmt_opt(self, width, name): info = self.__opts[name] single_fmt = " %%-%ds : %%s" % width multiple_fmt = " %%-%ds\t%%s\n%s : %%s" % \ (width, ' ' * (width + 2)) if info.has_key('help') and info['help']: help_str = info['help'] else: help_str = '' if info['type'] == 'single': return single_fmt % ('-' + info['name'], help_str) if info.has_key('argkeys'): arg_keys = info['argkeys'] else: arg_keys = [ 'arg' ] args = [] arg_num = 0 while arg_num < info['min_args']: if arg_num >= len(arg_keys): key_string = arg_keys[-1] + \ str(arg_num + 2 - len(arg_keys)) else: key_string = arg_keys[arg_num] arg_num += 1 args.append('<' + key_string + '>') opt_args = [] if info['max_args'] >= 0: while arg_num < info['max_args']: if arg_num >= len(arg_keys): key_string = arg_keys[-1] + \ str(arg_num + 2 - len(arg_keys)) else: key_string = arg_keys[arg_num] arg_num += 1 opt_args.append('<' + key_string + '>') else: if info['min_args'] == 0: args.append('[%s]' % arg_keys[0]) args.append('...') base = str.join(' ', args) if opt_args: for arg in opt_args: base += ' [' + arg base += ']' * len(opt_args) if info['required']: help_str = '[required] ' + help_str return multiple_fmt % ('-' + info['name'], base, help_str) def __build_map(self): self.__nmap = _make_prefix_tab(self.__opts.keys()) def set_escape(self, esc): """Set the option escape string to esc. Default is `-esc'.""" self.__esc = esc def set_end(self, end): """Set the option terminating string to end. Default is `--'.""" if end[0] <> '-': self.__eopt = '-' + end else: self.__eopt = end def set_min_free(self, min): """Set the minimum number of required free arguments (0 means no free arguments are required, and is the default).""" self.__info['min_free'] = min def set_max_free(self, max): """Set the maximum number of allowable free arguments (-1 means no upper bound, and is the default).""" self.__info['max_free'] = max def set_free_map(self, map): """Maps free arguments to option names. The 'map' argument must be a list of strings, corresponding to the names of multiple-type options (which need not exist a priori). If the last element of map begins with a '-', all remaining unconsumed free arguments will be assigned to that option. When the free map is set, options corresponding to the free arguments will be appended to the parsed options returned by .parse(), and those free arguments will NOT appear in the free arguments list. The free map is processed BEFORE minimum and maximum numbers of free arguments are checked.""" assert(type(map) == list and len(map) > 0) self.__fmap = map[:] def clear_free_map(self): """Get rid of the free map.""" self.__fmap = None def add_single(self, name, help = None, max_count = 0): """Add a zero-argument option with the given name. If help is not None, the help text is set. If max_count > 0, the argument may be repeated multiple times, up to the given maximum; if max_count is zero (the default) it may be repeated as many times as desired.""" name = name.lower() self.__opts[name] = { 'name': name, 'type': 'single', 'max_count': max_count } if help: self.set_help(name, help) self.__build_map() def add_multiple(self, name, min_args = 1, max_args = 1, help = None, arg_keys = None, required = False, max_count = 0): """Add a multiple-argument option with the given name. At least min_args arguments are required, and at most max_args arguments will be consumed. If max_args < 0, then as many arguments as are available will be consumed (up to another valid option). If required is True, then this option MUST be present at least one time. If max_count > 0, it will only be accepted at most that number of times (by default, max_count = 0, and the option may be repeated any number of times).""" name = name.lower() self.__opts[name] = { 'name': name, 'type': 'multiple', 'min_args': min_args, 'max_args': max_args, 'required': required, 'max_count': max_count } if help: self.set_help(name, help) if arg_keys: self.set_arg_keys(name, arg_keys) self.__build_map() def set_help(self, name, help): """Set the help string for the given option name.""" if not self.__opts.has_key(name): raise OptError("No such option: %s" % name) self.__opts[name]['help'] = help def set_arg_keys(self, name, keys): """Set the argument key(s) for the given option name.""" if not self.__opts.has_key(name): raise OptError("No such option: %s" % name) elif self.__opts[name]['type'] <> 'multiple': raise OptError("Cannot set argument keys of single option: %s" % name) if type(keys) == list: self.__opts[name]['argkeys'] = keys else: self.__opts[name]['argkeys'] = [ keys ] def add_option(self, info): """Add an option by specifing a dictionary of the relevant information. At minimum, you must supply the 'name' and 'type' keys; if the type is 'multiple', you must also provide 'min_args', 'max_args', 'required', and 'max_count'. You may also give help text ('help') and other data (e.g., 'argkeys') as you see fit.""" if type(info) <> dict: raise TypeError("Incorrect type for 'info' parameter " "(wanted dict)") if not (info.has_key('name') and info.has_key('type')): raise OptError("Incorrect format for option specification") if info['type'] == 'multiple' and \ not (info.has_key('min_args') and info.has_key('max_args') and \ info.has_key('required') and info.has_key('max_count')): raise OptError("Incorrect format for multiple option") self.__opts[info['name'].lower()] = info self.__build_map() def test_option(self, key): """Test whether the given string uniquely identifies some option. If so, the option info for the matching option is returned (as a dictionary). If the string matches more than one option, a list of the matching option names is returned. Otherwise, False is returned.""" if not key or not self.__nmap: return False name = key if name[0] == '-': name = name[1:] ambig = {} for (pfx, opt) in self.__nmap.items(): if name.find(pfx) == 0 and \ opt.find(name) == 0: return self.__opts[opt] elif pfx.find(name) == 0 and \ opt.find(name) == 0: ambig[opt] = True if ambig: return ambig.keys() else: return False def parse(self, args): """Given a list of strings, attempt to parse command line options. Returns a tuple of (parsed_opts, free_args). The parsed_opts is a list of options and their arguments; free_args is a list of the strings that did not belong to any other option, in the same order they occured in the input list. Each element of parsed_opts is a pair of (name, args). For single options, args is None; for multiple options, args is a list of the arguments associated with that option. The list may be empty.""" free_args = [] parsed_opts = [] esc = False while args: if esc or args[0] == '-' or args[0][0] <> '-': free_args.append(args.pop(0)) esc = False continue # If this is the escape string, get rid of it and set the escape # flag for the next argument... if args[0] == self.__esc: esc = True args.pop(0) continue # If this is the end-of-options string, stop processing here if args[0] == self.__eopt: args.pop(0) break match = self.test_option(args[0]) if not match: raise OptError("Option '%s' not understood" % args[0]) elif type(match) == list: raise OptError("Ambiguous option '%s' (%s)" % (args[0], str.join(', ', match))) if match['type'] == 'single': parsed_opts.append((match['name'], None)) args.pop(0) elif match['type'] == 'multiple': args.pop(0) min_args = match['min_args']; max_args = match['max_args'] opt_args = [] while args and \ (max_args < 0 or len(opt_args) < max_args): if esc or args[0] == '-' or args[0][0] <> '-': opt_args.append(args.pop(0)) esc = False elif args[0] == self.__esc: esc = True args.pop(0) else: # If the next thing is a valid option, then # stop gathering arguments; otherwise, grab # it and put it into the collection if args[0] == self.__eopt or \ type(self.test_option(args[0])) == dict or \ not self.__vald: break else: opt_args.append(args.pop(0)) if min_args >= 0 and len(opt_args) < min_args: raise OptError("Insufficient arguments for '%s' " "(need at least %d)" % (match['name'], min_args)) parsed_opts.append((match['name'], opt_args)) else: raise OptError("Option type '%s' not understood for %s" % (match['type'], match['name'])) if args: free_args.extend(args) # If there is a free map, do the right thing if self.__fmap is not None: (parsed_opts, free_args) = self.map_free(parsed_opts, free_args) # This function does not return in case of errors. self.check_results(parsed_opts, free_args) return (parsed_opts, free_args) def map_free(self, parsed_opts, free_args): map = zip(self.__fmap, free_args) if len(map) == 0: return (parsed_opts, free_args) free_args = free_args[len(map):] # Append the regular items to the parsed options... for (key, arg) in map[:-1]: parsed_opts.append((key, [ arg ] )) # If the last element of the free map has a leading dash, # that option consumes all the remaining free arguments, if any if(map[-1][0][0] == '-'): map[-1] = (map[-1][0][1:], [ map[-1][1] ] + free_args) free_args = [] parsed_opts.append(map[-1]) else: parsed_opts.append((map[-1][0], [ map[-1][1] ])) return (parsed_opts, free_args) def check_results(self, parsed_opts, free_args): # If there's a minimum number of free arguments, enforce it. if self.__info['min_free'] > 0 and \ len(free_args) < self.__info['min_free']: raise OptError("Insufficient free arguments " "(need at least %d, got only %d)" % (self.__info['min_free'], len(free_args))) # If there's a maximum number of free arguments, enforce it. if self.__info['max_free'] >= 0 and\ len(free_args) > self.__info['max_free']: raise OptError("Too many free arguments " "(want at most %d, got %d)" % (self.__info['max_free'], len(free_args))) # Check each option to make sure it's okay (present if # required, not repeated too many times, etc.) for info in self.__opts.values(): found = find_all_option(info['name'], parsed_opts, merge = False) if info['type'] == 'multiple': if info['required'] and not found: raise OptError("Required option '%s' is missing" % info['name']) if found and info['max_count'] > 0 and \ len(found) > info['max_count']: if info['max_count'] == 1: plur = '' else: plur = 's' raise OptError("Option '%s' may occur at most %d " "time%s (not %d)" % (info['name'], info['max_count'], plur, len(found))) # Falling off the end of this function is sufficient to assure # that the checks passed... class Commands: """This class represents a set of subcommands, each of which has its own set of distinct options. Create an instance of this class and add commands to it using .add_command(), use the .get_options() method to fetch the Options object associated with a particular command. Use the .parse() method to parse a sequence of strings.""" def __init__(self, *cmds): self.__cmds = {} if cmds: for cmd in [ c.lower() for c in cmds ]: self.__cmds[cmd] = { 'name': cmd, 'options': Options() } self.__build_map() def __str__(self): names = self.__cmds.keys() names.sort() max_len = max([ len(c) for c in names ]) + 1 return str.join('\n', [ self.__fmt_cmd(max_len, c) for c in [ self.__cmds[n] for n in names ] ]) def __fmt_cmd(self, width, cmd): out_fmt = " %%-%ds : %%s" % width if cmd.has_key('help'): help_str = cmd['help'] else: help_str = "" return out_fmt % (cmd['name'], help_str) def __build_map(self): self.__nmap = _make_prefix_tab(self.__cmds.keys()) def add_command(self, cmd, opts = None, help = None): cmd = cmd.lower() if not self.__cmds.has_key(cmd): self.__cmds[cmd] = { 'name': cmd } if not opts: self.__cmds[cmd]['options'] = Options() else: self.set_options(cmd, opts) if help: self.set_help(cmd, help) self.__build_map() def get_options(self, cmd): return self.__cmds[cmd]['options'] def set_options(self, cmd, opts): if isinstance(opts, Options): self.__cmds[cmd]['options'] = opts elif type(opts) == list: self.__cmds[cmd]['options'] = create_from_strings(opts) else: raise TypeError("invalid type for opts argument") def set_help(self, cmd, help): self.__cmds[cmd]['help'] = help def get_help_line(self, cmd): """Return a descriptive help line for the named command.""" match = self.test_command(cmd) if type(match) == list: raise CommandError("ambiguous command '%s' " "(matches %s)" % (cmd, str.join(', ', match))) elif not match: raise CommandError("no such command: %s" % cmd) max_width = max([ len(c) for c in self.__cmds.keys() ]) return self.__fmt_cmd(max_width, match) def test_command(self, cmd): """Test whether the given string uniquely identifies a command. If so, its record is returned. If the command is ambiguous, a list of matching command names are returned; otherwise, False.""" ambig = {} for (pfx, name) in self.__nmap.items(): if cmd.find(pfx) == 0 and \ name.find(cmd) == 0: return self.__cmds[name] elif pfx.find(cmd) == 0 and \ name.find(cmd) == 0: ambig[name] = 1 if ambig: return ambig.keys() else: return False def get_command_names(self): return self.__cmds.keys() def parse(self, args): if not args: cmd = '' else: cmd = args.pop(0) match = self.test_command(cmd) if not match: raise CommandError("no such command: %s" % cmd) elif type(match) == list: raise CommandError("ambiguous command '%s' (matches %s)" % (cmd, str.join(', ', match))) (parsed_opts, other_args) = match['options'].parse(args) return {'command': match['name'], 'options': parsed_opts, 'other': other_args } def find_option(key, opts): """Find the given option in a parsed list of options; returns either the first instance found as a tuple of (name, value), or False if the option was not found. The name is a string, the value is either None (in the case of single options) or a list of values (in the case of multiple options).""" for opt in opts: if key == opt[0]: return opt return False def find_all_option(key, opts, merge = False): """Like find_option(), but returns a list of tuples containing all the occurrences of the given option. If merge is true, then the arguments for repeated instances of multiple options will all be folded into a single list, and returned as (name, list); while repeated occurrences of single options will be counted up and returned as a single tuple of the form (name, count). In the event the option is not found, if merge is True, this function returns None; otherwise the empty list is returned.""" out = [] for opt in opts: if key == opt[0]: out.append(opt) if merge and out: single = (out[0][1] == None) if single: out = (out[0][0], len(out)) else: accum = [] for opt in out: accum.extend(opt[1]) out = (out[0][0], accum) elif merge and not out: return None return out def _make_prefix_tab(names): # Given a list of names, return a dictionary in which each key is # a unique prefix of exactly one string from the original set of # names. Returns None if not all the keys are unique. Only the # shortest and matching prefixes are stored in the dictionary; so # for instance, if the strings were ['allen', 'alien'], the keys # of the result would be ['all', 'allen', 'ali', 'alien']. if not names: return None def any_prefix(name, whole): for key in names: if key <> whole and key.find(name) == 0: return True return False out = {} for name in names: if out.has_key(name): return None # Key conflict out[name] = name last = None pfx = name[:-1] while pfx and not any_prefix(pfx, name): last = pfx pfx = pfx[:-1] if last: out[last] = name return out # Try not to stare at these too hard, you might go blind _spec_s = _sre.compile('-(\w+)(?:\*(\d+))?(?:\?(.+))?') # Single options _spec_m = _sre.compile('([+!])(\w+)(?:\*(\d+))?' # +name*n '=(\d+)(?:\.(\d+))?' # =min.max '(?:\?([^@]+))?' # ?help text [opt] '(?:\@(\w+(?:,\w+)*))?' # @arg1,arg2 [opt] '$') def parse_option_spec(spec): match = _spec_s.match(spec) if match: opt = { 'type': 'single' } opt['name'] = match.group(1) if match.group(2): opt['max_count'] = int(match.group(2)) else: opt['max_count'] = 0 if match.group(3): opt['help'] = match.group(3) return opt match = _spec_m.match(spec) if not match: raise OptError("incorrect format for option specifier: %s" % spec) opt = { 'type': 'multiple' } opt['required'] = (match.group(1) == '!') opt['name'] = match.group(2) if match.group(3): opt['max_count'] = int(match.group(3)) else: opt['max_count'] = 0 opt['min_args'] = int(match.group(4)) opt['max_args'] = -1 if match.group(5): opt['max_args'] = int(match.group(5)) if match.group(6): opt['help'] = match.group(6) if match.group(7): opt['argkeys'] = match.group(7).split(',') return opt def create_from_strings(opts): """Create an options object from a list of strings specifying the options to be included. The formats for these strings may be: -name -- a single option, no arguments, any number of times -name*n -- a single option, no arguments, at most n repeats. +name=x +name=x.y -- a multiple option with at least x arguments and at most y arguments (no limit if y is omitted). To specify that the option is REQUIRED, replace the leading '+' with a '!'. To specify that it may be repeated at most n times, write *n after the name and before the '=' sign. To either of these forms you may append help text by writing a question mark (?) followed by the text, e.g.: -name?Description of the option +name=1?Description of the option For multiple options, you can specify the argument names to be displayed in the help listing by appending an at-sign (@) followed by a comma-separated list of names, e.g.: +set=2.2?Set a parameter value.@name,value The return value is an Options object.""" out = Options() for opt in opts: out.add_option(parse_option_spec(opt)) return out # Here there be dragons