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