Package translate :: Package misc :: Module selector |
[hide private]
[frames] | no frames]

# Source Code for Module translate.misc.selector

      1  # -*- coding: latin-1 -*-
      2  """selector - WSGI delegation based on URL path and method.
      3
      4  (See the docstring of selector.Selector.)
      5
      6  Copyright (C) 2006 Luke Arno - http://lukearno.com/
      7
      8  This library is free software; you can redistribute it and/or
      9  modify it under the terms of the GNU Lesser General Public
     10  License as published by the Free Software Foundation; either
     11  version 2.1 of the License, or (at your option) any later version.
     12
     13  This library is distributed in the hope that it will be useful,
     14  but WITHOUT ANY WARRANTY; without even the implied warranty of
     15  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
     16  Lesser General Public License for more details.
     17
     18  You should have received a copy of the GNU Lesser General Public
     19  License along with this library; if not, write to
     20  the Free Software Foundation, Inc., 51 Franklin Street,
     21  Fifth Floor, Boston, MA  02110-1301  USA
     22
     23  Luke Arno can be found at http://lukearno.com/
     24
     25  """
     26
     27  import re
     28  from itertools import starmap
     29  from wsgiref.util import shift_path_info
     30
     31
     32  try:
     33      from resolver import resolve
     34  except ImportError:
     35      # resolver not essential for basic featurs
     36      #FIXME: this library is overkill, simplify
     37      pass
     38
     39 -class MappingFileError(Exception): pass
     40
     41
     42 -class PathExpressionParserError(Exception): pass
     43
     44
     45 -def method_not_allowed(environ, start_response):
     46      """Respond with a 405 and appropriate Allow header."""
     47      start_response("405 Method Not Allowed",
     48                     [('Allow', ', '.join(environ['selector.methods'])),
     49                      ('Content-Type', 'text/plain')])
     50      return ["405 Method Not Allowed\n\n"
     51              "The method specified in the Request-Line is not allowed "
     52              "for the resource identified by the Request-URI."]
     53
     54
     55 -def not_found(environ, start_response):
     56      """Respond with a 404."""
     57      start_response("404 Not Found", [('Content-Type', 'text/plain')])
     58      return ["404 Not Found\n\n"
     59              "The server has not found anything matching the Request-URI."]
     60
     61
     62 -class Selector(object):
     63      """WSGI middleware for URL paths and HTTP method based delegation.
     64
     65      see http://lukearno.com/projects/selector/
     66
     67      mappings are given are an iterable that returns tuples like this:
     68
     69      (path_expression, http_methods_dict, optional_prefix)
     70      """
     71
     72      status405 = staticmethod(method_not_allowed)
     73      status404 = staticmethod(not_found)
     74
     75 -    def __init__(self,
     76                   mappings=None,
     77                   prefix="",
     78                   parser=None,
     79                   wrap=None,
     80                   mapfile=None,
     81                   consume_path=True):
     82          """Initialize selector."""
     83          self.mappings = []
     84          self.prefix = prefix
     85          if parser is None:
     86              self.parser = SimpleParser()
     87          else:
     88              self.parser = parser
     89          self.wrap = wrap
     90          if mapfile is not None:
     91              self.slurp_file(mapfile)
     92          if mappings is not None:
     93              self.slurp(mappings)
     94          self.consume_path = consume_path
     95
     96 -    def slurp(self, mappings, prefix=None, parser=None, wrap=None):
     97          """Slurp in a whole list (or iterable) of mappings.
     98
     99          Prefix and parser args will override self.parser and self.args
    100          for the given mappings.
    101          """
    102          if prefix is not None:
    103              oldprefix = self.prefix
    104              self.prefix = prefix
    105          if parser is not None:
    106              oldparser = self.parser
    107              self.parser = parser
    108          if wrap is not None:
    109              oldwrap = self.wrap
    110              self.wrap = wrap
    111          list(starmap(self.add, mappings))
    112          if wrap is not None:
    113              self.wrap = oldwrap
    114          if parser is not None:
    115              self.parser = oldparser
    116          if prefix is not None:
    117              self.prefix = oldprefix
    118
    119 -    def add(self, path, method_dict=None, prefix=None, **http_methods):
    120          """Add a mapping.
    121
    122          HTTP methods can be specified in a dict or using kwargs,
    123          but kwargs will override if both are given.
    124
    125          Prefix will override self.prefix for this mapping.
    126          """
    127          # Thanks to Sébastien Pierre
    128          # for suggesting that this accept keyword args.
    129          if method_dict is None:
    130              method_dict = {}
    131          if prefix is None:
    132              prefix = self.prefix
    133          method_dict = dict(method_dict)
    134          method_dict.update(http_methods)
    135          if self.wrap is not None:
    136              for meth, cbl in method_dict.items():
    137                  method_dict[meth] = self.wrap(cbl)
    138          regex = self.parser(self.prefix + path)
    139          compiled_regex = re.compile(regex, re.DOTALL | re.MULTILINE)
    140          self.mappings.append((compiled_regex, method_dict))
    141
    142 -    def __call__(self, environ, start_response):
    143          """Delegate request to the appropriate WSGI app."""
    144          app, svars, methods, matched = \
    145              self.select(environ['PATH_INFO'], environ['REQUEST_METHOD'])
    146          unnamed, named = [], {}
    147          for k, v in svars.iteritems():
    148              if k.startswith('__pos'):
    149                  k = k[5:]
    150              named[k] = v
    151          environ['selector.vars'] = dict(named)
    152          for k in named.keys():
    153              if k.isdigit():
    154                  unnamed.append((k, named.pop(k)))
    155          unnamed.sort(); unnamed = [v for k, v in unnamed]
    156          cur_unnamed, cur_named = environ.get('wsgiorg.routing_args', ([], {}))
    157          unnamed = cur_unnamed + unnamed
    158          named.update(cur_named)
    159          environ['wsgiorg.routing_args'] = unnamed, named
    160          environ['selector.methods'] = methods
    161          environ.setdefault('selector.matches', []).append(matched)
    162          if self.consume_path:
    163              environ['SCRIPT_NAME'] = environ.get('SCRIPT_NAME', '') + matched
    164              environ['PATH_INFO'] = environ['PATH_INFO'][len(matched):]
    165          return app(environ, start_response)
    166
    167 -    def select(self, path, method):
    168          """Figure out which app to delegate to or send 404 or 405."""
    169          for regex, method_dict in self.mappings:
    170              match = regex.search(path)
    171              if match:
    172                  methods = method_dict.keys()
    173                  if method_dict.has_key(method):
    174                      return (method_dict[method],
    175                              match.groupdict(),
    176                              methods,
    177                              match.group(0))
    178                  elif method_dict.has_key('_ANY_'):
    179                      return (method_dict['_ANY_'],
    180                              match.groupdict(),
    181                              methods,
    182                              match.group(0))
    183                  else:
    184                      return self.status405, {}, methods, ''
    185          return self.status404, {}, [], ''
    186
    187 -    def slurp_file(self, the_file, prefix=None, parser=None, wrap=None):
    188          """Read mappings from a simple text file.
    189
    190          == Format looks like this: ==
    191
    192          {{{
    193
    194          # Comments if first non-whitespace char on line is '#'
    195          # Blank lines are ignored
    196
    197          /foo/{id}[/]
    198              GET somemodule:some_wsgi_app
    199              POST pak.subpak.mod:other_wsgi_app
    200
    201          @prefix /myapp
    202          /path[/]
    203              GET module:app
    204              POST package.module:get_app('foo')
    205              PUT package.module:FooApp('hello', resolve('module.setting'))
    206
    207          @parser :lambda x: x
    208          @prefix
    209          ^/spam/eggs[/]$
    210              GET mod:regex_mapped_app
    211
    212          }}}
    213
    214          @prefix and @parser directives take effect
    215          until the end of the file or until changed
    216          """
    217          if isinstance(the_file, str):
    218              the_file = open(the_file)
    219          oldprefix = self.prefix
    220          if prefix is not None:
    221              self.prefix = prefix
    222          oldparser = self.parser
    223          if parser is not None:
    224              self.parser = parser
    225          oldwrap = self.wrap
    226          if parser is not None:
    227              self.wrap = wrap
    228          path = methods = None
    229          lineno = 0
    230          try:
    231              #try:
    232                  # accumulate methods (notice add in 2 places)
    233                  for line in the_file:
    234                      lineno += 1
    235                      path, methods = self._parse_line(line, path, methods)
    236                  if path and methods:
    237                      self.add(path, methods)
    238              #except Exception, e:
    239              #    raise MappingFileError("Mapping line %s: %s" % (lineno, e))
    240          finally:
    241              the_file.close()
    242              self.wrap = oldwrap
    243              self.parser = oldparser
    244              self.prefix = oldprefix
    245
    246 -    def _parse_line(self, line, path, methods):
    247          """Parse one line of a mapping file.
    248
    249          This method is for the use of selector.slurp_file.
    250          """
    251          if not line.strip() or line.strip()[0] == '#':
    252              pass
    253          elif not line.strip() or line.strip()[0] == '@':
    254              #
    255              if path and methods:
    256                  self.add(path, methods)
    257              path = line.strip()
    258              methods = {}
    259              #
    260              parts = line.strip()[1:].split(' ', 1)
    261              if len(parts) == 2:
    262                  directive, rest = parts
    263              else:
    264                  directive = parts[0]
    265                  rest = ''
    266              if directive == 'prefix':
    267                  self.prefix = rest.strip()
    268              if directive == 'parser':
    269                  self.parser = resolve(rest.strip())
    270              if directive == 'wrap':
    271                  self.wrap = resolve(rest.strip())
    272          elif line and line[0] not in ' \t':
    273              if path and methods:
    274                  self.add(path, methods)
    275              path = line.strip()
    276              methods = {}
    277          else:
    278              meth, app = line.strip().split(' ', 1)
    279              methods[meth.strip()] = resolve(app)
    280          return path, methods
    281
    282
    283 -class SimpleParser(object):
    284      """Callable to turn path expressions into regexes with named groups.
    285
    286      For instance "/hello/{name}" becomes r"^\/hello\/(?P<name>[^\^.]+)$"
    287
    288      For /hello/{name:pattern}
    289      you get whatever is in self.patterns['pattern'] instead of "[^\^.]+"
    290
    291      Optional portions of path expression can be expressed [like this]
    292
    293      /hello/{name}[/] (can have trailing slash or not)
    294
    295      Example:
    296
    297      /blog/archive/{year:digits}/{month:digits}[/[{article}[/]]]
    298
    299      This would catch any of these:
    300
    301      /blog/archive/2005/09
    302      /blog/archive/2005/09/
    303      /blog/archive/2005/09/1
    304      /blog/archive/2005/09/1/
    305
    306      (I am not suggesting that this example is a best practice.
    307      I would probably have a separate mapping for listing the month
    308      and retrieving an individual entry. It depends, though.)
    309      """
    310
    311      start, end = '{}'
    312      ostart, oend = '[]'
    313      _patterns = {'word': r'\w+',
    314                   'alpha': r'[a-zA-Z]+',
    315                   'digits': r'\d+',
    316                   'number': r'\d*.?\d+',
    317                   'chunk': r'[^/^.]+',
    318                   'segment': r'[^/]+',
    319                   'any': r'.+'}
    320      default_pattern = 'chunk'
    321
    322 -    def __init__(self, patterns=None):
    323          """Initialize with character class mappings."""
    324          self.patterns = dict(self._patterns)
    325          if patterns is not None:
    326              self.patterns.update(patterns)
    327
    328 -    def lookup(self, name):
    329          """Return the replacement for the name found."""
    330          if ':' in name:
    331              name, pattern = name.split(':')
    332              pattern = self.patterns[pattern]
    333          else:
    334              pattern = self.patterns[self.default_pattern]
    335          if name == '':
    336              name = '__pos%s' % self._pos
    337              self._pos += 1
    338          return '(?P<%s>%s)' % (name, pattern)
    339
    340 -    def lastly(self, regex):
    341          """Process the result of __call__ right before it returns.
    342
    343          Adds the ^ and the $ to the beginning and the end, respectively.
    344          """
    345          return "^%s$" % regex
    346
    347 -    def openended(self, regex):
    348          """Process the result of __call__ right before it returns.
    349
    350          Adds the ^ to the beginning but no $ to the end.
    351          Called as a special alternative to lastly.
    352          """
    353          return "^%s" % regex
    354
    355 -    def outermost_optionals_split(self, text):
    356          """Split out optional portions by outermost matching delims."""
    357          parts = []
    358          buffer = ""
    359          starts = ends = 0
    360          for c in text:
    361              if c == self.ostart:
    362                  if starts == 0:
    363                      parts.append(buffer)
    364                      buffer = ""
    365                  else:
    366                      buffer += c
    367                  starts +=1
    368              elif c == self.oend:
    369                  ends +=1
    370                  if starts == ends:
    371                      parts.append(buffer)
    372                      buffer = ""
    373                      starts = ends = 0
    374                  else:
    375                      buffer += c
    376              else:
    377                  buffer += c
    378          if not starts == ends == 0:
    379              raise PathExpressionParserError(
    380                  "Mismatch of optional portion delimiters."
    381              )
    382          parts.append(buffer)
    383          return parts
    384
    385 -    def parse(self, text):
    386          """Turn a path expression into regex."""
    387          if self.ostart in text:
    388              parts = self.outermost_optionals_split(text)
    389              parts = map(self.parse, parts)
    390              parts[1::2] = ["(%s)?" % p for p in parts[1::2]]
    391          else:
    392              parts = [part.split(self.end)
    393                       for part in text.split(self.start)]
    394              parts = [y for x in parts for y in x]
    395              parts[::2] = map(re.escape, parts[::2])
    396              parts[1::2] = map(self.lookup, parts[1::2])
    397          return ''.join(parts)
    398
    399 -    def __call__(self, url_pattern):
    400          """Turn a path expression into regex via parse and lastly."""
    401          self._pos = 0
    402          if url_pattern.endswith('|'):
    403              return self.openended(self.parse(url_pattern[:-1]))
    404          else:
    405              return self.lastly(self.parse(url_pattern))
    406
    407
    408 -class EnvironDispatcher(object):
    409      """Dispatch based on list of rules."""
    410
    411 -    def __init__(self, rules):
    412          """Instantiate with a list of (predicate, wsgiapp) rules."""
    413          self.rules = rules
    414
    415 -    def __call__(self, environ, start_response):
    416          """Call the first app whose predicate is true.
    417
    418          Each predicate is passes the environ to evaluate.
    419          """
    420          for predicate, app in self.rules:
    421              if predicate(environ):
    422                  return app(environ, start_response)
    423
    424
    425 -class MiddlewareComposer(object):
    426      """Compose middleware based on list of rules."""
    427
    428 -    def __init__(self, app, rules):
    429          """Instantiate with an app and a list of rules."""
    430          self.app = app
    431          self.rules = rules
    432
    433 -    def __call__(self, environ, start_response):
    434          """Apply each middleware whose predicate is true.
    435
    436          Each predicate is passes the environ to evaluate.
    437
    438          Given this set of rules:
    439
    440          t = lambda x: True; f = lambda x: False
    441          [(t, a), (f, b), (t, c), (f, d), (t, e)]
    442
    443          The app composed would be equivalent to this:
    444
    445          a(c(e(app)))
    446          """
    447          app = self.app
    448          for predicate, middleware in reversed(self.rules):
    449              if predicate(environ):
    450                  app = middleware(app)
    451          return app(environ, start_response)
    452
    453
    454 -def expose(obj):
    455      """Set obj._exposed = True and return obj."""
    456      obj._exposed = True
    457      return obj
    458
    459
    460 -class Naked(object):
    461      """Naked object style dispatch base class."""
    462
    463      _not_found = staticmethod(not_found)
    464      _expose_all = True
    465      _exposed = True
    466
    467 -    def _is_exposed(self, obj):
    468          """Determine if obj should be exposed.
    469
    470          If self._expose_all is True, always return True.
    471          Otherwise, look at obj._exposed.
    472          """
    473          return self._expose_all or getattr(obj, '_exposed', False)
    474
    475 -    def __call__(self, environ, start_response):
    476          """Dispatch to the method named by the next bit of PATH_INFO."""
    477          name = shift_path_info(dict(SCRIPT_NAME=environ['SCRIPT_NAME'],
    478                                      PATH_INFO=environ['PATH_INFO']))
    479          callable = getattr(self, name or 'index', None)
    480          if callable is not None and self._is_exposed(callable):
    481              shift_path_info(environ)
    482              return callable(environ, start_response)
    483          else:
    484              return self._not_found(environ, start_response)
    485
    486
    487 -class ByMethod(object):
    488      """Base class for dispatching to method named by REQUEST_METHOD."""
    489
    490      _method_not_allowed = staticmethod(method_not_allowed)
    491
    492 -    def __call__(self, environ, start_response):
    493          """Dispatch based on REQUEST_METHOD."""
    494          environ['selector.methods'] = \
    495              [m for m in dir(self) if not m.startswith('_')]
    496          return getattr(self,
    497                         environ['REQUEST_METHOD'],
    498                         self._method_not_allowed)(environ, start_response)
    499
    500
    501 -def pliant(func):
    502      """Decorate an unbound wsgi callable taking args from wsgiorg.routing_args.
    503
    504      @pliant
    505      def app(environ, start_response, arg1, arg2, foo='bar'):
    506          ...
    507      """
    508      def wsgi_func(environ, start_response):
    509          args, kwargs = environ.get('wsgiorg.routing_args', ([], {}))
    510          args = list(args)
    511          args.insert(0, start_response)
    512          args.insert(0, environ)
    513          return apply(func, args, dict(kwargs))
    514      return wsgi_func
    515
    516
    517 -def opliant(meth):
    518      """Decorate a bound wsgi callable taking args from wsgiorg.routing_args.
    519
    520      class App(object):
    521          @opliant
    522          def __call__(self, environ, start_response, arg1, arg2, foo='bar'):
    523              ...
    524      """
    525      def wsgi_meth(self, environ, start_response):
    526          args, kwargs = environ.get('wsgiorg.routing_args', ([], {}))
    527          args = list(args)
    528          args.insert(0, start_response)
    529          args.insert(0, environ)
    530          args.insert(0, self)
    531          return apply(meth, args, dict(kwargs))
    532      return wsgi_meth
    533