fancy_getopt.py 17 KB

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