• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""distutils.fancy_getopt
2
3Wrapper around the standard getopt module that provides the following
4additional 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
13import sys
14import string
15import re
16import getopt
17from 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...
23longopt_pat = r'[a-zA-Z](?:[a-zA-Z0-9-]*)'
24longopt_re = re.compile(r'^%s$' % longopt_pat)
25
26# For recognizing "negative alias" options, eg. "quiet=!verbose"
27neg_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).
31longopt_xlate = string.maketrans('-', '_')
32
33class 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
402def 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
408WS_TRANS = string.maketrans(string.whitespace, ' ' * len(string.whitespace))
409
410def 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
469def 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
476class 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