Package svnmailer :: Module util

[hide private]

# Source Code for Module svnmailer.util

      1  # -*- coding: utf-8 -*-
      2  # pylint: disable-msg = W0613, W0622, W0704
      3  #
      4  # Copyright 2004-2006 André Malo or his licensors, as applicable
      5  #
      6  # Licensed under the Apache License, Version 2.0 (the "License");
      7  # you may not use this file except in compliance with the License.
      8  # You may obtain a copy of the License at
      9  #
     10  #     http://www.apache.org/licenses/LICENSE-2.0
     11  #
     12  # Unless required by applicable law or agreed to in writing, software
     13  # distributed under the License is distributed on an "AS IS" BASIS,
     14  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     15  # See the License for the specific language governing permissions and
     16  # limitations under the License.
     17  """
     18  Utilities
     19  =========
     20
     21  This module contains some utility functions and classes used in several
     22  places of the svnmailer. These functions have a quite general character
     23  and can be used easily outside of the svnmailer as well.
     24  """
     25  __author__    = "André Malo"
     26  __docformat__ = "epytext en"
     27  __all__       = [
     28      'TempFile',
     29      'getPipe4',
     30      'getSuitableCommandLine',
     31      'splitCommand',
     32      'filename',
     33      'extractX509User',
     34      'substitute',
     35      'filterForXml',
     36      'getParentDirList',
     37      'getGlobValue',
     38      'parseQuery',
     39      'modifyQuery',
     40      'inherit',
     41      'commonPaths',
     42      'ReadOnlyDict',
     43      'SafeDict',
     44  ]
     45
     46  # global imports
     47  import locale, os, sys
     48
     49
     50 -class TempFile(object):
     51      """ Tempfile container class
     52
     53          The class contains a destructor that removes the created
     54          file. This differs from the stuff in tempfile, which removes
     55          the file, when it's closed.
     56
     57          The mode is fixed to C{w+}; a C{b} is added if the C{text}
     58          argument is false (see C{__init__})
     59
     60          @cvar name: C{None}
     61          @ivar name: The full name of the file
     62          @type name: C{str}
     63
     64          @cvar fp: C{None}
     65          @ivar fp: The file descriptor
     66          @type fp: file like object
     67
     68          @cvar _unlink: C{None}
     69          @ivar _unlink: C{os.unlink}
     70          @type _unlink: callable
     71      """
     72      name = None
     73      fp = None
     74      _unlink = None
     75
     76 -    def __init__(self, tempdir = None, text = False):
     77          """ Initialization
     78
     79              @param tempdir: The temporary directory
     80              @type tempdir: C{str}
     81
     82              @param text: want to write text?
     83              @type text: C{bool}
     84          """
     85          import tempfile
     86
     87          self._unlink = os.unlink # make sure, unlink is available in __del__
     88
     89          fd, self.name = tempfile.mkstemp(dir = tempdir, text = text)
     90          self.fp = os.fdopen(fd, "w+%s" % ["b", ""][bool(text)])
     91
     92
     93 -    def __del__(self):
     94          """ Unlink the file name """
     95          if self.fp:
     96              try:
     97                  self.fp.close()
     98              except ValueError:
     99                  # ok
    100                  pass
    101
    102          if self.name and self._unlink:
    103              try:
    104                  self._unlink(self.name)
    105              except OSError:
    106                  # don't even ignore
    107                  pass
    108
    109
    110 -    def close(self):
    111          """ Close the file (but don't delete it)
    112
    113              @exception ValueError: The file was already closed
    114          """
    115          if self.fp:
    116              self.fp.close()
    117
    118
    119 -def getPipe2(command):
    120      """ Returns a pipe object (C{Popen3} or C{_DummyPopen3} on win32)
    121
    122          @param command: The command list (the first item is the command
    123              itself, the rest represents the arguments)
    124          @type command: C{list}
    125
    126          @return: The pipe object
    127          @rtype: C{popen2.Popen3} or C{_DummyPopen3}
    128      """
    129      import popen2
    130
    131      try:
    132          cls = popen2.Popen3
    133      except AttributeError:
    134          cls = _DummyPopen3
    135
    136      return cls(getSuitableCommandLine(command))
    137
    138
    139 -def getPipe4(command):
    140      """ Returns a pipe object (C{Popen4} or C{_DummyPopen4} on win32)
    141
    142          @param command: The command list (the first item is the command
    143              itself, the rest represents the arguments)
    144          @type command: C{list}
    145
    146          @return: The pipe object
    147          @rtype: C{popen2.Popen4} or C{_DummyPopen4}
    148      """
    149      import popen2
    150
    151      try:
    152          cls = popen2.Popen4
    153      except AttributeError:
    154          cls = _DummyPopen4
    155
    156      return cls(getSuitableCommandLine(command))
    157
    158
    159 -class _DummyPopen4(object):
    160      """ Dummy Popen4 class for platforms which don't provide one in popen2 """
    161
    162 -    def __init__(self, cmd, bufsize = -1):
    163          """ Initialization """
    164          bufsize = -1 # otherwise error on win32
    165          self.tochild, self.fromchild = os.popen4(cmd, 'b', bufsize)
    166
    167
    168 -    def wait(self):
    169          """ Dummy wait """
    170          return 0
    171
    172
    173 -class _DummyPopen3(object):
    174      """ Dummy Popen3 class for platforms which don't provide one in popen2 """
    175
    176 -    def __init__(self, cmd, capturestderr = False, bufsize = -1):
    177          """ Initialization """
    178          bufsize = -1 # otherwise error on win32
    179          capturestderr = False # we don't do this on win32
    180          self.tochild, self.fromchild = os.popen2(cmd, 'b', bufsize)
    181          self.childerr = None
    182
    183
    184 -    def wait(self):
    185          """ Dummy wait """
    186          return 0
    187
    188
    189 -def getSuitableCommandLine(command, _platform = None):
    190      """ Return the revised command suitable for being exec'd
    191
    192          Currently this means, it's escaped and converted to a string
    193          only for Win32, because on this system the shell is called.
    194          For other systems the list is just returned.
    195
    196          @note: This is more or less the same as the stuff in
    197              svn.fs._escape_msvcrt_shell_command/arg. But it
    198              belongs somewhere else - e.g. into a util module...
    199
    200              Perhaps once a day the whole package goes directly
    201              into the subversion distribution and then it's all
    202              cool.
    203
    204          @param command: The command to escape
    205          @type command: C{list}
    206
    207          @param _platform: A platform string (for testing purposes only)
    208          @type _platform: C{str}
    209
    210          @return: The escaped command string or the original list
    211          @rtype: C{str} or C{list}
    212      """
    213      platform = _platform or sys.platform
    214      if platform != "win32":
    215          return command
    216
    217      try:
    218          slashre = getSuitableCommandLine._slashre
    219      except AttributeError:
    220          import re
    221          slashre = getSuitableCommandLine._slashre = re.compile(r'(\\+)("|$)')
    222
    223      # What we do here is:
    224      # (1) double up slashes, but only before quotes or the string end
    225      #     (since we surround it by quotes afterwards)
    226      # (2) Escape " as "^""
    227      #     This means "string end", "Escaped quote", "string begin" in that
    228      #     order
    229      #     (See also http://www.microsoft.com/technet/archive/winntas
    230      #               /deploy/prodspecs/shellscr.mspx)
    231
    232      # Original comments from the svn.fs functions:
    233      # ============================================
    234      # According cmd's usage notes (cmd /?), it parses the command line by
    235      # "seeing if the first character is a quote character and if so, stripping
    236      # the leading character and removing the last quote character."
    237      # So to prevent the argument string from being changed we add an extra set
    238      # of quotes around it here.
    239
    240      # The (very strange) parsing rules used by the C runtime library are
    241      # described at:
    242      # http://msdn.microsoft.com/library/en-us/vclang/html
    243      # /_pluslang_Parsing_C.2b2b_.Command.2d.Line_Arguments.asp
    244
    245      return '"%s"' % " ".join([
    246          '"%s"' % slashre.sub(r'\1\1\2', arg).replace('"', '"^""')
    247          for arg in command
    248      ])
    249
    250
    251 -def splitCommand(command):
    252      r"""Split a command string with respect to quotes and such
    253
    254          The command string consists of several tokens:
    255              - whitespace: Those are separators except inside quoted items
    256              - unquoted items: every token that doesn't start with
    257                  a double quote (")
    258              - quoted items: every token that starts with a double quote (").
    259                  Those items must be closed with a double quote and may contain
    260                  whitespaces. The enclosing quotes are stripped. To put a double
    261                  quote character inside such a token, it has to be escaped with
    262                  a backslash (\). Therefore - backslashes themselves have to be
    263                  escaped as well. The escapes are also stripped from the result.
    264
    265          Here's an example: C{r'foo bar "baz" "zo\"" "\\nk"'} resolves
    266          to C{['foo', 'bar', 'baz', 'zo"', r'\nk']}
    267
    268          @param command: The command string
    269          @type command: C{str}
    270
    271          @return: The splitted command
    272          @rtype: C{list}
    273
    274          @exception ValueError: The command string is not valid
    275              (unclosed quote or the like)
    276      """
    277      try:
    278          argre, checkre, subre = splitCommand._regexps
    279      except AttributeError:
    280          import re
    281          argre   = r'[^"\s]\S*|"[^\\"]*(?:\\[\\"][^\\"]*)*"'
    282          checkre = r'\s*(?:%(arg)s)(?:\s+(?:%(arg)s))*\s*$' % {'arg': argre}
    283          subre   = r'\\([\\"])'
    284
    285          argre, checkre, subre = splitCommand._regexps = (
    286              re.compile(argre), re.compile(checkre), re.compile(subre)
    287          )
    288
    289      if not checkre.match(command or ''):
    290          raise ValueError("Command string %r is not valid" % command)
    291
    292      return [
    293          (arg.startswith('"') and [subre.sub(r'\1', arg[1:-1])] or [arg])[0]
    294          for arg in argre.findall(command or '')
    295      ]
    296
    297
    298 -class _LocaleFile(object):
    299      """ Transform filenames according to locale """
    300 -    def __init__(self, _locale = locale, _os = os, _sys = sys):
    301          """ Initialization """
    302          self.unicode_system = _os.path.supports_unicode_filenames
    303          self.from_enc = _locale.getpreferredencoding(False) or "us-ascii"
    304          self.to_enc = _sys.getfilesystemencoding() or "us-ascii"
    305
    306
    307 -    def toLocale(self, name, name_enc = None, locale_enc = None):
    308          """ Transforms a file name to the locale representation
    309
    310              @param name: The name to consider
    311              @type name: C{str} / C{unicode}
    312
    313              @param name_enc: The source encoding of C{name}, if it's
    314                  not unicode already
    315              @type name_enc: C{str}
    316
    317              @param locale_enc: The file system encoding (used only
    318                  if it's not a unicode supporting OS)
    319              @type locale_enc: C{str}
    320
    321              @return: The name in locale representation
    322              @rtype: C{str}/C{unicode}
    323
    324              @exception UnicodeError: An error happened while recoding
    325          """
    326          if locale_enc is None:
    327              locale_enc = self.to_enc
    328          if name_enc is None:
    329              name_enc = self.from_enc
    330
    331          if self.unicode_system:
    332              if isinstance(name, unicode):
    333                  return name
    334              else:
    335                  return name.decode(name_enc, "strict")
    336
    337          if locale_enc.lower() == "none":
    338              if isinstance(name, unicode):
    339                  raise RuntimeError("Illegal call")
    340              else:
    341                  return name
    342
    343          if not isinstance(name, unicode):
    344              name = name.decode(name_enc, "strict")
    345
    346          return name.encode(locale_enc, "strict")
    347
    348
    349 -    def fromLocale(self, name, locale_enc = None):
    350          """ Transform a file name from locale repr to unicode (hopefully)
    351
    352              @param name: The name to decode
    353              @type name: C{str}/C{unicode}
    354
    355              @param locale_enc: The locale encoding
    356              @type locale_enc: C{str}
    357
    358              @return: The decoded name
    359              @rtype: C{unicode}/C{str}
    360
    361              @exception UnicodeError: An error happend while recoding
    362          """
    363          if isinstance(name, unicode):
    364              return name
    365
    366          if locale_enc is None:
    367              locale_enc = self.from_enc
    368
    369          if locale_enc.lower() == "none":
    370              return name # no unicode.
    371
    372          return name.decode(locale_enc, "strict")
    373
    374  filename = _LocaleFile()
    375
    376
    377 -def extractX509User(author):
    378      """ Returns user data extracted from x509 subject string
    379
    380          @param author: The author string
    381          @type author: C{str}
    382
    383          @return: user name, mail address (user name maybe C{None})
    384          @rtype: C{tuple} or C{None}
    385      """
    386      if author:
    387          try:
    388              cnre, eare = extractX509User._regexps
    389          except AttributeError:
    390              import re
    391              cnre = re.compile(ur'/CN=([^/]+)', re.I)
    392              eare = re.compile(ur'/emailAddress=([^/]+)', re.I)
    393              extractX509User._regexps = (cnre, eare)
    394
    395          author = author.decode('utf-8', 'replace')
    396          ea_match = eare.search(author)
    397          if ea_match:
    398              cn_match = cnre.search(author)
    399              return (cn_match and cn_match.group(1), ea_match.group(1))
    400
    401      return None
    402
    403
    404 -def substitute(template, subst):
    405      """ Returns a filled template
    406
    407          If the L{template} is C{None}, this function returns C{None}
    408          as well.
    409
    410          @param template: The temlate to fill
    411          @type template: C{unicode}
    412
    413          @param subst: The substitution parameters
    414          @type subst: C{dict}
    415
    416          @return: The filled template (The return type depends on the
    417              template and the parameters)
    418          @rtype: C{str} or C{unicode}
    419      """
    420      if template is None:
    421          return None
    422
    423      return template % SafeDict(subst.items())
    424
    425
    426 -def filterForXml(value):
    427      """ Replaces control characters with replace characters
    428
    429          @param value: The value to filter
    430          @type value: C{unicode}
    431
    432          @return: The filtered value
    433          @rtype: C{unicode}
    434      """
    435      try:
    436          regex = filterForXml._regex
    437      except AttributeError:
    438          import re
    439          chars = u''.join([chr(num) for num in range(32)
    440              if num not in (9, 10, 13) # XML 1.0
    441          ])
    442          regex = filterForXml._regex = re.compile("[%s]" % chars)
    443
    444      return regex.sub(u'\ufffd', value)
    445
    446
    447 -def getParentDirList(path):
    448      """ Returns the directories up to a (posix) path
    449
    450          @param path: The path to process
    451          @type path: C{str}
    452
    453          @return: The directory list
    454          @rtype: C{list}
    455      """
    456      import posixpath
    457
    458      path = posixpath.normpath("/%s" % path)
    459      if path[:2] == '//':
    460          path = path[1:]
    461
    462      dirs = []
    463      path = posixpath.dirname(path)
    464      while path != '/':
    465          dirs.append(path)
    466          path = posixpath.dirname(path)
    467      dirs.append('/')
    468
    469      return dirs
    470
    471
    472 -def getGlobValue(globs, path):
    473      """ Returns the value of the glob, where path matches
    474
    475          @param globs: The glob list (C{[(glob, associated value)]})
    476          @type globs: C{list} of C{tuple}
    477
    478          @param path: The path to match
    479          @type path: C{str}
    480
    481          @return: The matched value or C{None}
    482          @rtype: any
    483      """
    484      import fnmatch
    485
    486      result = None
    487      for glob in globs:
    488          if fnmatch.fnmatchcase(path, glob[0]):
    489              result = glob[1]
    490              break
    491
    492      return result
    493
    494
    495 -def modifyQuery(query, rem = None, add = None, set = None, delim = '&'):
    496      """ Returns a modified query string
    497
    498          @note: set is a convenience parameter, it's actually a combination of
    499              C{rem} and C{add}. The order of processing is:
    500                  1. append the set parameters to C{rem} and C{add}
    501                  2. apply C{rem}
    502                  3. apply C{add}
    503
    504          @warning: query parameters containing no C{=} character are silently
    505              dropped.
    506
    507          @param query: The query string to modify
    508          @type query: C{str} or C{dict}
    509
    510          @param rem: parameters to remove (if present)
    511          @type rem: C{list} of C{str}
    512
    513          @param add: parameters to add
    514          @type add: C{list} of C{tuple}
    515
    516          @param set: parameters to override
    517          @type set: C{list} of C{tuple}
    518
    519          @param delim: Delimiter to use when rebuilding the query string
    520          @type delim: C{str}
    521      """
    522      rem = list(rem or [])
    523      add = list(add or [])
    524      set = list(set or [])
    525
    526      # parse query string
    527      query_dict = (isinstance(query, dict) and
    528          [query.copy()] or [parseQuery(query)]
    529      )[0]
    530
    531      # append set list to rem and add
    532      rem.extend([tup[0] for tup in set])
    533      add.extend(set)
    534
    535      # apply rem
    536      for key in rem:
    537          try:
    538              del query_dict[key]
    539          except KeyError:
    540              # don't even ignore
    541              pass
    542
    543      # apply add
    544      for key, val in add:
    545          query_dict.setdefault(key, []).append(val)
    546
    547      # rebuild query and return
    548      return delim.join([
    549          delim.join(["%s=%s" % (key, str(val)) for val in vals])
    550          for key, vals in query_dict.items()
    551      ])
    552
    553
    554 -def parseQuery(query):
    555      """ Parses a query string
    556
    557          @warning: query parameters containing no C{=} character are silently
    558              dropped.
    559
    560          @param query: The query string to parse
    561          @type query: C{str}
    562
    563          @return: The parsed query (C{{key: [values]}})
    564          @rtype: C{dict}
    565      """
    566      try:
    567          queryre = parseQuery._regex
    568      except AttributeError:
    569          import re
    570          parseQuery._regex = queryre = re.compile(r'[&;]')
    571
    572      query_dict = {}
    573      for key, val in [pair.split('=', 1)
    574              for pair in queryre.split(query) if '=' in pair]:
    575          query_dict.setdefault(key, []).append(val)
    576
    577      return query_dict
    578
    579
    580 -def commonPaths(paths):
    581      """ Returns the common component and the stripped paths
    582
    583          It expects that directories do always end with a trailing slash and
    584          paths never begin with a slash (except root).
    585
    586          @param paths: The list of paths (C{[str, str, ...]})
    587          @type paths: C{list}
    588
    589          @return: The common component (always a directory) and the stripped
    590              paths (C{(str, [str, str, ...])})
    591          @rtype: C{tuple}
    592      """
    593      import posixpath
    594
    595      common = ''
    596      if len(paths) > 1 and "/" not in paths:
    597          common = posixpath.commonprefix(paths)
    598          if common[-1:] != "/":
    599              common = common[:common.rfind("/") + 1]
    600
    601          idx = len(common)
    602          if idx > 0:
    603              paths = [path[idx:] or "./" for path in paths]
    604              common = common[:-1] # chop the trailing slash
    605
    606      return (common, paths)
    607
    608
    609 -def inherit(cls, *bases):
    610      """ Inherits class cls from *bases
    611
    612          @note: cls needs a __dict__, so __slots__ is tabu
    613
    614          @param cls: The class to inherit from *bases
    615          @type cls: C{class}
    616
    617          @param bases: The base class(es)
    618          @type bases: C{list}
    619      """
    620      newdict = dict([(key, value)
    621          for key, value in cls.__dict__.items()
    622          if key != '__module__'
    623      ])
    624      cls = type(cls.__name__, tuple(bases), newdict)
    625      setattr(cls, "_%s__decorator_class" % cls.__name__, cls)
    626
    627      return cls
    628
    629
    630 -def parseContentType(value):
    631      """ Parses a content type
    632
    633          (the email module unfortunately doesn't provide a public
    634          interface for this)
    635
    636          @warning: comments are not recognized yet
    637
    638          @param value: The value to parse - must be ascii compatible
    639          @type value: C{basestring}
    640
    641          @return: The parsed header (C{(value, {key, [value, value, ...]})})
    642              or C{None}
    643          @rtype: C{tuple}
    644      """
    645      try:
    646          if isinstance(value, unicode):
    647              value.encode('us-ascii')
    648          else:
    649              value.decode('us-ascii')
    650      except (AttributeError, UnicodeError):
    651          return None
    652
    653      try:
    654          typere, pairre, stripre = parseContentType._regexps
    655      except AttributeError:
    656          import re
    657          # a bit more lenient than RFC 2045
    658          tokenres = r'[^\000-\040()<>@,;:\\"/[\]?=]+'
    659          qcontent = r'[^\000\\"]'
    660          qsres    = r'"%(qc)s*(?:\\"%(qc)s*)*"' % {'qc': qcontent}
    661          valueres = r'(?:%(token)s|%(quoted-string)s)' % {
    662              'token': tokenres, 'quoted-string': qsres,
    663          }
    664
    665          typere = re.compile(
    666              r'\s*([^;/\s]+/[^;/\s]+)((?:\s*;\s*%(key)s\s*=\s*%(val)s)*)\s*$' %
    667              {'key': tokenres, 'val': valueres,}
    668          )
    669          pairre = re.compile(r'\s*;\s*(%(key)s)\s*=\s*(%(val)s)' % {
    670              'key': tokenres, 'val': valueres
    671          })
    672          stripre = re.compile(r'\r?\n')
    673          parseContentType._regexps = (typere, pairre, stripre)
    674
    675      match = typere.match(value)
    676      if not match:
    677          return None
    678
    679      parsed = (match.group(1).lower(), {})
    680      match = match.group(2)
    681      if match:
    682          for key, val in pairre.findall(match):
    683              if val[:1] == '"':
    684                  val = stripre.sub(r'', val[1:-1]).replace(r'\"', '"')
    685              parsed[1].setdefault(key.lower(), []).append(val)
    686
    687      return parsed
    688
    689
    690 -class ReadOnlyDict(dict):
    691      """ Read only dictionary """
    692      __msg = "The dictionary is read-only"
    693
    694 -    def __setitem__(self, key, value):
    695          """ modifiying is not allowed """
    696          raise TypeError(self.__msg)
    697
    698
    699 -    def __delitem__(self, key):
    700          """ deleting is not allowed """
    701          raise TypeError(self.__msg)
    702
    703
    704 -    def clear(self):
    705          """ clearing is not allowed """
    706          raise TypeError(self.__msg)
    707
    708
    709 -    def fromkeys(cls, seq, value = None):
    710          """ Chokes by default, so work around it """
    711          return cls(dict.fromkeys(seq, value))
    712      fromkeys = classmethod(fromkeys)
    713
    714
    715 -    def pop(self, key, default = None):
    716          """ popping is not allowed """
    717          raise TypeError(self.__msg)
    718
    719
    720 -    def popitem(self):
    721          """ popping is not allowed """
    722          raise TypeError(self.__msg)
    723
    724
    725 -    def setdefault(self, default = None):
    726          """ modifying is not allowed """
    727          raise TypeError(self.__msg)
    728
    729
    730 -    def update(self, newdict):
    731          """ updating is not allowed """
    732          raise TypeError(self.__msg)
    733
    734
    735 -class SafeDict(dict):
    736      """ A dict, which returns '' on unknown keys or false values """
    737
    738 -    def __getitem__(self, key):
    739          """ Returns an empty string on false values or unknown keys """
    740          return dict.get(self, key) or ''
    741