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