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