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