summaryrefslogtreecommitdiff
path: root/pecan/routing.py
blob: aed8ad5b313deb384ee3030b105d54e0aab54659 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
import re
import warnings
from inspect import getmembers, ismethod

from webob import exc
import six

from .secure import handle_security, cross_boundary
from .util import iscontroller, getargspec, _cfg

__all__ = ['lookup_controller', 'find_object', 'route']
__observed_controllers__ = set()
__custom_routes__ = {}


def route(*args):
    """
    This function is used to define an explicit route for a path segment.

    You generally only want to use this in situations where your desired path
    segment is not a valid Python variable/function name.

    For example, if you wanted to be able to route to:

    /path/with-dashes/

    ...the following is invalid Python syntax::

        class Controller(object):

            with-dashes = SubController()

    ...so you would instead define the route explicitly::

        class Controller(object):
            pass

        pecan.route(Controller, 'with-dashes', SubController())
    """

    def _validate_route(route):
        if not isinstance(route, six.string_types):
            raise TypeError('%s must be a string' % route)

        if route in ('.', '..') or not re.match(
            '^[0-9a-zA-Z-_$\(\)\.~!,;:*+@=]+$', route
        ):
            raise ValueError(
                '%s must be a valid path segment.  Keep in mind '
                'that path segments should not contain path separators '
                '(e.g., /) ' % route
            )

    if len(args) == 2:
        # The handler in this situation is a @pecan.expose'd callable,
        # and is generally only used by the @expose() decorator itself.
        #
        # This sets a special attribute, `custom_route` on the callable, which
        # pecan's routing logic knows how to make use of (as a special case)
        route, handler = args
        if ismethod(handler):
            handler = handler.__func__
        if not iscontroller(handler):
            raise TypeError(
                '%s must be a callable decorated with @pecan.expose' % handler
            )
        obj, attr, value = handler, 'custom_route', route

        if handler.__name__ in ('_lookup', '_default', '_route'):
            raise ValueError(
                '%s is a special method in pecan and cannot be used in '
                'combination with custom path segments.' % handler.__name__
            )
    elif len(args) == 3:
        # This is really just a setattr on the parent controller (with some
        # additional validation for the path segment itself)
        _, route, handler = args
        obj, attr, value = args

        if hasattr(obj, attr):
            raise RuntimeError(
                (
                    "%(module)s.%(class)s already has an "
                    "existing attribute named \"%(route)s\"." % {
                        'module': obj.__module__,
                        'class': obj.__name__,
                        'route': attr
                    }
                ),
            )
    else:
        raise TypeError(
            'pecan.route should be called in the format '
            'route(ParentController, "path-segment", SubController())'
        )

    _validate_route(route)
    setattr(obj, attr, value)


class PecanNotFound(Exception):
    pass


class NonCanonicalPath(Exception):
    '''
    Exception Raised when a non-canonical path is encountered when 'walking'
    the URI.  This is typically a ``POST`` request which requires a trailing
    slash.
    '''
    def __init__(self, controller, remainder):
        self.controller = controller
        self.remainder = remainder


def lookup_controller(obj, remainder, request=None):
    '''
    Traverses the requested url path and returns the appropriate controller
    object, including default routes.

    Handles common errors gracefully.
    '''
    if request is None:
        warnings.warn(
            (
                "The function signature for %s.lookup_controller is changing "
                "in the next version of pecan.\nPlease update to: "
                "`lookup_controller(self, obj, remainder, request)`." % (
                    __name__,
                )
            ),
            DeprecationWarning
        )

    notfound_handlers = []
    while True:
        try:
            obj, remainder = find_object(obj, remainder, notfound_handlers,
                                         request)
            handle_security(obj)
            return obj, remainder
        except (exc.HTTPNotFound, exc.HTTPMethodNotAllowed,
                PecanNotFound) as e:
            if isinstance(e, PecanNotFound):
                e = exc.HTTPNotFound()
            while notfound_handlers:
                name, obj, remainder = notfound_handlers.pop()
                if name == '_default':
                    # Notfound handler is, in fact, a controller, so stop
                    #   traversal
                    return obj, remainder
                else:
                    # Notfound handler is an internal redirect, so continue
                    #   traversal
                    result = handle_lookup_traversal(obj, remainder)
                    if result:
                        # If no arguments are passed to the _lookup, yet the
                        # argspec requires at least one, raise a 404
                        if (
                            remainder == [''] and
                            len(obj._pecan['argspec'].args) > 1
                        ):
                            raise e
                        obj_, remainder_ = result
                        return lookup_controller(obj_, remainder_, request)
            else:
                raise e


def handle_lookup_traversal(obj, args):
    try:
        result = obj(*args)
        if result:
            prev_obj = obj
            obj, remainder = result
            # crossing controller boundary
            cross_boundary(prev_obj, obj)
            return result
    except TypeError as te:
        msg = 'Got exception calling lookup(): %s (%s)'
        warnings.warn(
            msg % (te, te.args),
            RuntimeWarning
        )


def find_object(obj, remainder, notfound_handlers, request):
    '''
    'Walks' the url path in search of an action for which a controller is
    implemented and returns that controller object along with what's left
    of the remainder.
    '''
    prev_obj = None
    while True:
        if obj is None:
            raise PecanNotFound
        if iscontroller(obj):
            if getattr(obj, 'custom_route', None) is None:
                return obj, remainder

        _detect_custom_path_segments(obj)

        if remainder:
            custom_route = __custom_routes__.get((obj.__class__, remainder[0]))
            if custom_route:
                return getattr(obj, custom_route), remainder[1:]

        # are we traversing to another controller
        cross_boundary(prev_obj, obj)
        try:
            next_obj, rest = remainder[0], remainder[1:]
            if next_obj == '':
                index = getattr(obj, 'index', None)
                if iscontroller(index):
                    return index, rest
        except IndexError:
            # the URL has hit an index method without a trailing slash
            index = getattr(obj, 'index', None)
            if iscontroller(index):
                raise NonCanonicalPath(index, [])

        default = getattr(obj, '_default', None)
        if iscontroller(default):
            notfound_handlers.append(('_default', default, remainder))

        lookup = getattr(obj, '_lookup', None)
        if iscontroller(lookup):
            notfound_handlers.append(('_lookup', lookup, remainder))

        route = getattr(obj, '_route', None)
        if iscontroller(route):
            if len(getargspec(route).args) == 2:
                warnings.warn(
                    (
                        "The function signature for %s.%s._route is changing "
                        "in the next version of pecan.\nPlease update to: "
                        "`def _route(self, args, request)`." % (
                            obj.__class__.__module__,
                            obj.__class__.__name__
                        )
                    ),
                    DeprecationWarning
                )
                next_obj, next_remainder = route(remainder)
            else:
                next_obj, next_remainder = route(remainder, request)
            cross_boundary(route, next_obj)
            return next_obj, next_remainder

        if not remainder:
            raise PecanNotFound

        prev_remainder = remainder
        prev_obj = obj
        remainder = rest
        try:
            obj = getattr(obj, next_obj, None)
        except UnicodeEncodeError:
            obj = None

        # Last-ditch effort: if there's not a matching subcontroller, no
        # `_default`, no `_lookup`, and no `_route`, look to see if there's
        # an `index` that has a generic method defined for the current request
        # method.
        if not obj and not notfound_handlers and hasattr(prev_obj, 'index'):
            if request.method in _cfg(prev_obj.index).get('generic_handlers',
                                                          {}):
                return prev_obj.index, prev_remainder


def _detect_custom_path_segments(obj):
    # Detect custom controller routes (on the initial traversal)
    if obj.__class__.__module__ == '__builtin__':
        return

    attrs = set(dir(obj))

    if obj.__class__ not in __observed_controllers__:
        for key, val in getmembers(obj):
            if iscontroller(val) and isinstance(
                getattr(val, 'custom_route', None),
                six.string_types
            ):
                route = val.custom_route

                # Detect class attribute name conflicts
                for conflict in attrs.intersection(set((route,))):
                    raise RuntimeError(
                        (
                            "%(module)s.%(class)s.%(function)s has "
                            "a custom path segment, \"%(route)s\", "
                            "but %(module)s.%(class)s already has an "
                            "existing attribute named \"%(route)s\"." % {
                                'module': obj.__class__.__module__,
                                'class': obj.__class__.__name__,
                                'function': val.__name__,
                                'route': conflict
                            }
                        ),
                    )

                existing = __custom_routes__.get(
                    (obj.__class__, route)
                )
                if existing:
                    # Detect custom path conflicts between functions
                    raise RuntimeError(
                        (
                            "%(module)s.%(class)s.%(function)s and "
                            "%(module)s.%(class)s.%(other)s have a "
                            "conflicting custom path segment, "
                            "\"%(route)s\"." % {
                                'module': obj.__class__.__module__,
                                'class': obj.__class__.__name__,
                                'function': val.__name__,
                                'other': existing,
                                'route': route
                            }
                        ),
                    )

                __custom_routes__[
                    (obj.__class__, route)
                ] = key
        __observed_controllers__.add(obj.__class__)