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
36
37 pass
38
40
41
43
44
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
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
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
128
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
232
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
239
240 finally:
241 the_file.close()
242 self.wrap = oldwrap
243 self.parser = oldparser
244 self.prefix = oldprefix
245
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
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
323 """Initialize with character class mappings."""
324 self.patterns = dict(self._patterns)
325 if patterns is not None:
326 self.patterns.update(patterns)
327
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
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
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
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
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
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
409 """Dispatch based on list of rules."""
410
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
426 """Compose middleware based on list of rules."""
427
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
455 """Set obj._exposed = True and return obj."""
456 obj._exposed = True
457 return obj
458
459
461 """Naked object style dispatch base class."""
462
463 _not_found = staticmethod(not_found)
464 _expose_all = True
465 _exposed = True
466
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
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
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
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