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