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