fancy_getopt.py 17 KB

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