Home | History | Annotate | Download | only in distutils
      1 """distutils.fancy_getopt
      2 
      3 Wrapper around the standard getopt module that provides the following
      4 additional features:
      5   * short and long options are tied together
      6   * options have help strings, so fancy_getopt could potentially
      7     create a complete usage summary
      8   * options set attributes of a passed-in object
      9 """
     10 
     11 __revision__ = "$Id$"
     12 
     13 import sys
     14 import string
     15 import re
     16 import getopt
     17 from distutils.errors import DistutilsGetoptError, DistutilsArgError
     18 
     19 # Much like command_re in distutils.core, this is close to but not quite
     20 # the same as a Python NAME -- except, in the spirit of most GNU
     21 # utilities, we use '-' in place of '_'.  (The spirit of LISP lives on!)
     22 # The similarities to NAME are again not a coincidence...
     23 longopt_pat = r'[a-zA-Z](?:[a-zA-Z0-9-]*)'
     24 longopt_re = re.compile(r'^%s$' % longopt_pat)
     25 
     26 # For recognizing "negative alias" options, eg. "quiet=!verbose"
     27 neg_alias_re = re.compile("^(%s)=!(%s)$" % (longopt_pat, longopt_pat))
     28 
     29 # This is used to translate long options to legitimate Python identifiers
     30 # (for use as attributes of some object).
     31 longopt_xlate = string.maketrans('-', '_')
     32 
     33 class FancyGetopt:
     34     """Wrapper around the standard 'getopt()' module that provides some
     35     handy extra functionality:
     36       * short and long options are tied together
     37       * options have help strings, and help text can be assembled
     38         from them
     39       * options set attributes of a passed-in object
     40       * boolean options can have "negative aliases" -- eg. if
     41         --quiet is the "negative alias" of --verbose, then "--quiet"
     42         on the command line sets 'verbose' to false
     43     """
     44 
     45     def __init__ (self, option_table=None):
     46 
     47         # The option table is (currently) a list of tuples.  The
     48         # tuples may have 3 or four values:
     49         #   (long_option, short_option, help_string [, repeatable])
     50         # if an option takes an argument, its long_option should have '='
     51         # appended; short_option should just be a single character, no ':'
     52         # in any case.  If a long_option doesn't have a corresponding
     53         # short_option, short_option should be None.  All option tuples
     54         # must have long options.
     55         self.option_table = option_table
     56 
     57         # 'option_index' maps long option names to entries in the option
     58         # table (ie. those 3-tuples).
     59         self.option_index = {}
     60         if self.option_table:
     61             self._build_index()
     62 
     63         # 'alias' records (duh) alias options; {'foo': 'bar'} means
     64         # --foo is an alias for --bar
     65         self.alias = {}
     66 
     67         # 'negative_alias' keeps track of options that are the boolean
     68         # opposite of some other option
     69         self.negative_alias = {}
     70 
     71         # These keep track of the information in the option table.  We
     72         # don't actually populate these structures until we're ready to
     73         # parse the command-line, since the 'option_table' passed in here
     74         # isn't necessarily the final word.
     75         self.short_opts = []
     76         self.long_opts = []
     77         self.short2long = {}
     78         self.attr_name = {}
     79         self.takes_arg = {}
     80 
     81         # And 'option_order' is filled up in 'getopt()'; it records the
     82         # original order of options (and their values) on the command-line,
     83         # but expands short options, converts aliases, etc.
     84         self.option_order = []
     85 
     86     # __init__ ()
     87 
     88 
     89     def _build_index (self):
     90         self.option_index.clear()
     91         for option in self.option_table:
     92             self.option_index[option[0]] = option
     93 
     94     def set_option_table (self, option_table):
     95         self.option_table = option_table
     96         self._build_index()
     97 
     98     def add_option (self, long_option, short_option=None, help_string=None):
     99         if long_option in self.option_index:
    100             raise DistutilsGetoptError, \
    101                   "option conflict: already an option '%s'" % long_option
    102         else:
    103             option = (long_option, short_option, help_string)
    104             self.option_table.append(option)
    105             self.option_index[long_option] = option
    106 
    107 
    108     def has_option (self, long_option):
    109         """Return true if the option table for this parser has an
    110         option with long name 'long_option'."""
    111         return long_option in self.option_index
    112 
    113     def get_attr_name (self, long_option):
    114         """Translate long option name 'long_option' to the form it
    115         has as an attribute of some object: ie., translate hyphens
    116         to underscores."""
    117         return string.translate(long_option, longopt_xlate)
    118 
    119 
    120     def _check_alias_dict (self, aliases, what):
    121         assert isinstance(aliases, dict)
    122         for (alias, opt) in aliases.items():
    123             if alias not in self.option_index:
    124                 raise DistutilsGetoptError, \
    125                       ("invalid %s '%s': "
    126                        "option '%s' not defined") % (what, alias, alias)
    127             if opt not in self.option_index:
    128                 raise DistutilsGetoptError, \
    129                       ("invalid %s '%s': "
    130                        "aliased option '%s' not defined") % (what, alias, opt)
    131 
    132     def set_aliases (self, alias):
    133         """Set the aliases for this option parser."""
    134         self._check_alias_dict(alias, "alias")
    135         self.alias = alias
    136 
    137     def set_negative_aliases (self, negative_alias):
    138         """Set the negative aliases for this option parser.
    139         'negative_alias' should be a dictionary mapping option names to
    140         option names, both the key and value must already be defined
    141         in the option table."""
    142         self._check_alias_dict(negative_alias, "negative alias")
    143         self.negative_alias = negative_alias
    144 
    145 
    146     def _grok_option_table (self):
    147         """Populate the various data structures that keep tabs on the
    148         option table.  Called by 'getopt()' before it can do anything
    149         worthwhile.
    150         """
    151         self.long_opts = []
    152         self.short_opts = []
    153         self.short2long.clear()
    154         self.repeat = {}
    155 
    156         for option in self.option_table:
    157             if len(option) == 3:
    158                 long, short, help = option
    159                 repeat = 0
    160             elif len(option) == 4:
    161                 long, short, help, repeat = option
    162             else:
    163                 # the option table is part of the code, so simply
    164                 # assert that it is correct
    165                 raise ValueError, "invalid option tuple: %r" % (option,)
    166 
    167             # Type- and value-check the option names
    168             if not isinstance(long, str) or len(long) < 2:
    169                 raise DistutilsGetoptError, \
    170                       ("invalid long option '%s': "
    171                        "must be a string of length >= 2") % long
    172 
    173             if (not ((short is None) or
    174                      (isinstance(short, str) and len(short) == 1))):
    175                 raise DistutilsGetoptError, \
    176                       ("invalid short option '%s': "
    177                        "must a single character or None") % short
    178 
    179             self.repeat[long] = repeat
    180             self.long_opts.append(long)
    181 
    182             if long[-1] == '=':             # option takes an argument?
    183                 if short: short = short + ':'
    184                 long = long[0:-1]
    185                 self.takes_arg[long] = 1
    186             else:
    187 
    188                 # Is option is a "negative alias" for some other option (eg.
    189                 # "quiet" == "!verbose")?
    190                 alias_to = self.negative_alias.get(long)
    191                 if alias_to is not None:
    192                     if self.takes_arg[alias_to]:
    193                         raise DistutilsGetoptError, \
    194                               ("invalid negative alias '%s': "
    195                                "aliased option '%s' takes a value") % \
    196                                (long, alias_to)
    197 
    198                     self.long_opts[-1] = long # XXX redundant?!
    199                     self.takes_arg[long] = 0
    200 
    201                 else:
    202                     self.takes_arg[long] = 0
    203 
    204             # If this is an alias option, make sure its "takes arg" flag is
    205             # the same as the option it's aliased to.
    206             alias_to = self.alias.get(long)
    207             if alias_to is not None:
    208                 if self.takes_arg[long] != self.takes_arg[alias_to]:
    209                     raise DistutilsGetoptError, \
    210                           ("invalid alias '%s': inconsistent with "
    211                            "aliased option '%s' (one of them takes a value, "
    212                            "the other doesn't") % (long, alias_to)
    213 
    214 
    215             # Now enforce some bondage on the long option name, so we can
    216             # later translate it to an attribute name on some object.  Have
    217             # to do this a bit late to make sure we've removed any trailing
    218             # '='.
    219             if not longopt_re.match(long):
    220                 raise DistutilsGetoptError, \
    221                       ("invalid long option name '%s' " +
    222                        "(must be letters, numbers, hyphens only") % long
    223 
    224             self.attr_name[long] = self.get_attr_name(long)
    225             if short:
    226                 self.short_opts.append(short)
    227                 self.short2long[short[0]] = long
    228 
    229         # for option_table
    230 
    231     # _grok_option_table()
    232 
    233 
    234     def getopt (self, args=None, object=None):
    235         """Parse command-line options in args. Store as attributes on object.
    236 
    237         If 'args' is None or not supplied, uses 'sys.argv[1:]'.  If
    238         'object' is None or not supplied, creates a new OptionDummy
    239         object, stores option values there, and returns a tuple (args,
    240         object).  If 'object' is supplied, it is modified in place and
    241         'getopt()' just returns 'args'; in both cases, the returned
    242         'args' is a modified copy of the passed-in 'args' list, which
    243         is left untouched.
    244         """
    245         if args is None:
    246             args = sys.argv[1:]
    247         if object is None:
    248             object = OptionDummy()
    249             created_object = 1
    250         else:
    251             created_object = 0
    252 
    253         self._grok_option_table()
    254 
    255         short_opts = string.join(self.short_opts)
    256         try:
    257             opts, args = getopt.getopt(args, short_opts, self.long_opts)
    258         except getopt.error, msg:
    259             raise DistutilsArgError, msg
    260 
    261         for opt, val in opts:
    262             if len(opt) == 2 and opt[0] == '-': # it's a short option
    263                 opt = self.short2long[opt[1]]
    264             else:
    265                 assert len(opt) > 2 and opt[:2] == '--'
    266                 opt = opt[2:]
    267 
    268             alias = self.alias.get(opt)
    269             if alias:
    270                 opt = alias
    271 
    272             if not self.takes_arg[opt]:     # boolean option?
    273                 assert val == '', "boolean option can't have value"
    274                 alias = self.negative_alias.get(opt)
    275                 if alias:
    276                     opt = alias
    277                     val = 0
    278                 else:
    279                     val = 1
    280 
    281             attr = self.attr_name[opt]
    282             # The only repeating option at the moment is 'verbose'.
    283             # It has a negative option -q quiet, which should set verbose = 0.
    284             if val and self.repeat.get(attr) is not None:
    285                 val = getattr(object, attr, 0) + 1
    286             setattr(object, attr, val)
    287             self.option_order.append((opt, val))
    288 
    289         # for opts
    290         if created_object:
    291             return args, object
    292         else:
    293             return args
    294 
    295     # getopt()
    296 
    297 
    298     def get_option_order (self):
    299         """Returns the list of (option, value) tuples processed by the
    300         previous run of 'getopt()'.  Raises RuntimeError if
    301         'getopt()' hasn't been called yet.
    302         """
    303         if self.option_order is None:
    304             raise RuntimeError, "'getopt()' hasn't been called yet"
    305         else:
    306             return self.option_order
    307 
    308 
    309     def generate_help (self, header=None):
    310         """Generate help text (a list of strings, one per suggested line of
    311         output) from the option table for this FancyGetopt object.
    312         """
    313         # Blithely assume the option table is good: probably wouldn't call
    314         # 'generate_help()' unless you've already called 'getopt()'.
    315 
    316         # First pass: determine maximum length of long option names
    317         max_opt = 0
    318         for option in self.option_table:
    319             long = option[0]
    320             short = option[1]
    321             l = len(long)
    322             if long[-1] == '=':
    323                 l = l - 1
    324             if short is not None:
    325                 l = l + 5                   # " (-x)" where short == 'x'
    326             if l > max_opt:
    327                 max_opt = l
    328 
    329         opt_width = max_opt + 2 + 2 + 2     # room for indent + dashes + gutter
    330 
    331         # Typical help block looks like this:
    332         #   --foo       controls foonabulation
    333         # Help block for longest option looks like this:
    334         #   --flimflam  set the flim-flam level
    335         # and with wrapped text:
    336         #   --flimflam  set the flim-flam level (must be between
    337         #               0 and 100, except on Tuesdays)
    338         # Options with short names will have the short name shown (but
    339         # it doesn't contribute to max_opt):
    340         #   --foo (-f)  controls foonabulation
    341         # If adding the short option would make the left column too wide,
    342         # we push the explanation off to the next line
    343         #   --flimflam (-l)
    344         #               set the flim-flam level
    345         # Important parameters:
    346         #   - 2 spaces before option block start lines
    347         #   - 2 dashes for each long option name
    348         #   - min. 2 spaces between option and explanation (gutter)
    349         #   - 5 characters (incl. space) for short option name
    350 
    351         # Now generate lines of help text.  (If 80 columns were good enough
    352         # for Jesus, then 78 columns are good enough for me!)
    353         line_width = 78
    354         text_width = line_width - opt_width
    355         big_indent = ' ' * opt_width
    356         if header:
    357             lines = [header]
    358         else:
    359             lines = ['Option summary:']
    360 
    361         for option in self.option_table:
    362             long, short, help = option[:3]
    363             text = wrap_text(help, text_width)
    364             if long[-1] == '=':
    365                 long = long[0:-1]
    366 
    367             # Case 1: no short option at all (makes life easy)
    368             if short is None:
    369                 if text:
    370                     lines.append("  --%-*s  %s" % (max_opt, long, text[0]))
    371                 else:
    372                     lines.append("  --%-*s  " % (max_opt, long))
    373 
    374             # Case 2: we have a short option, so we have to include it
    375             # just after the long option
    376             else:
    377                 opt_names = "%s (-%s)" % (long, short)
    378                 if text:
    379                     lines.append("  --%-*s  %s" %
    380                                  (max_opt, opt_names, text[0]))
    381                 else:
    382                     lines.append("  --%-*s" % opt_names)
    383 
    384             for l in text[1:]:
    385                 lines.append(big_indent + l)
    386 
    387         # for self.option_table
    388 
    389         return lines
    390 
    391     # generate_help ()
    392 
    393     def print_help (self, header=None, file=None):
    394         if file is None:
    395             file = sys.stdout
    396         for line in self.generate_help(header):
    397             file.write(line + "\n")
    398 
    399 # class FancyGetopt
    400 
    401 
    402 def fancy_getopt (options, negative_opt, object, args):
    403     parser = FancyGetopt(options)
    404     parser.set_negative_aliases(negative_opt)
    405     return parser.getopt(args, object)
    406 
    407 
    408 WS_TRANS = string.maketrans(string.whitespace, ' ' * len(string.whitespace))
    409 
    410 def wrap_text (text, width):
    411     """wrap_text(text : string, width : int) -> [string]
    412 
    413     Split 'text' into multiple lines of no more than 'width' characters
    414     each, and return the list of strings that results.
    415     """
    416 
    417     if text is None:
    418         return []
    419     if len(text) <= width:
    420         return [text]
    421 
    422     text = string.expandtabs(text)
    423     text = string.translate(text, WS_TRANS)
    424     chunks = re.split(r'( +|-+)', text)
    425     chunks = filter(None, chunks)      # ' - ' results in empty strings
    426     lines = []
    427 
    428     while chunks:
    429 
    430         cur_line = []                   # list of chunks (to-be-joined)
    431         cur_len = 0                     # length of current line
    432 
    433         while chunks:
    434             l = len(chunks[0])
    435             if cur_len + l <= width:    # can squeeze (at least) this chunk in
    436                 cur_line.append(chunks[0])
    437                 del chunks[0]
    438                 cur_len = cur_len + l
    439             else:                       # this line is full
    440                 # drop last chunk if all space
    441                 if cur_line and cur_line[-1][0] == ' ':
    442                     del cur_line[-1]
    443                 break
    444 
    445         if chunks:                      # any chunks left to process?
    446 
    447             # if the current line is still empty, then we had a single
    448             # chunk that's too big too fit on a line -- so we break
    449             # down and break it up at the line width
    450             if cur_len == 0:
    451                 cur_line.append(chunks[0][0:width])
    452                 chunks[0] = chunks[0][width:]
    453 
    454             # all-whitespace chunks at the end of a line can be discarded
    455             # (and we know from the re.split above that if a chunk has
    456             # *any* whitespace, it is *all* whitespace)
    457             if chunks[0][0] == ' ':
    458                 del chunks[0]
    459 
    460         # and store this line in the list-of-all-lines -- as a single
    461         # string, of course!
    462         lines.append(string.join(cur_line, ''))
    463 
    464     # while chunks
    465 
    466     return lines
    467 
    468 
    469 def translate_longopt(opt):
    470     """Convert a long option name to a valid Python identifier by
    471     changing "-" to "_".
    472     """
    473     return string.translate(opt, longopt_xlate)
    474 
    475 
    476 class OptionDummy:
    477     """Dummy class just used as a place to hold command-line option
    478     values as instance attributes."""
    479 
    480     def __init__ (self, options=[]):
    481         """Create a new OptionDummy instance.  The attributes listed in
    482         'options' will be initialized to None."""
    483         for opt in options:
    484             setattr(self, opt, None)
    485