Michael Merickel
2018-10-18 e4c0570d5c67ddf0ad9502169b59475ba0784d82
commit | author | age
05c023 1 import re
3b7334 2 from zope.interface import implementer
74409d 3
0c29cf 4 from pyramid.interfaces import IRoutesMapper, IRoute
8392d7 5
0c1c39 6 from pyramid.compat import (
bc37a5 7     PY2,
0c1c39 8     native_,
a511b1 9     text_,
0c1c39 10     text_type,
CM 11     string_types,
a511b1 12     binary_type,
0c1c39 13     is_nonstr_iter,
c08f76 14     decode_path_info,
0c29cf 15 )
0c1c39 16
b60bdb 17 from pyramid.exceptions import URLDecodeError
0c1c39 18
0c29cf 19 from pyramid.traversal import quote_path_segment, split_path_info, PATH_SAFE
17358d 20
dfc2b6 21 _marker = object()
277df8 22
0c29cf 23
3b7334 24 @implementer(IRoute)
05c023 25 class Route(object):
0c29cf 26     def __init__(
MM 27         self, name, pattern, factory=None, predicates=(), pregenerator=None
28     ):
74409d 29         self.pattern = pattern
0c29cf 30         self.path = pattern  # indefinite b/w compat, not in interface
4018ad 31         self.match, self.generate = _compile_route(pattern)
05c023 32         self.name = name
CM 33         self.factory = factory
65476e 34         self.predicates = predicates
70f1cd 35         self.pregenerator = pregenerator
0c29cf 36
05c023 37
3b7334 38 @implementer(IRoutesMapper)
cbfafb 39 class RoutesMapper(object):
CM 40     def __init__(self):
05c023 41         self.routelist = []
582c2e 42         self.static_routes = []
JA 43
05c023 44         self.routes = {}
62267e 45
CM 46     def has_routes(self):
05c023 47         return bool(self.routelist)
62267e 48
582c2e 49     def get_routes(self, include_static=False):
JA 50         if include_static is True:
51             return self.routelist + self.static_routes
52
750ce4 53         return self.routelist
CM 54
74409d 55     def get_route(self, name):
CM 56         return self.routes.get(name)
57
0c29cf 58     def connect(
MM 59         self,
60         name,
61         pattern,
62         factory=None,
63         predicates=(),
64         pregenerator=None,
65         static=False,
66     ):
0bf286 67         if name in self.routes:
CM 68             oldroute = self.routes[name]
e725cf 69             if oldroute in self.routelist:
CM 70                 self.routelist.remove(oldroute)
582c2e 71
4018ad 72         route = Route(name, pattern, factory, predicates, pregenerator)
e725cf 73         if not static:
CM 74             self.routelist.append(route)
582c2e 75         else:
JA 76             self.static_routes.append(route)
77
05c023 78         self.routes[name] = route
CM 79         return route
80
81     def generate(self, name, kw):
82         return self.routes[name].generate(kw)
8392d7 83
11644e 84     def __call__(self, request):
cbfafb 85         environ = request.environ
11644e 86         try:
cbfafb 87             # empty if mounted under a path in mod_wsgi, for example
6212db 88             path = decode_path_info(environ['PATH_INFO'] or '/')
f646fe 89         except KeyError:
22e72d 90             path = '/'
6212db 91         except UnicodeDecodeError as e:
0c29cf 92             raise URLDecodeError(
MM 93                 e.encoding, e.object, e.start, e.end, e.reason
94             )
22e72d 95
05c023 96         for route in self.routelist:
CM 97             match = route.match(path)
98             if match is not None:
65476e 99                 preds = route.predicates
0c29cf 100                 info = {'match': match, 'route': route}
44bc11 101                 if preds and not all((p(info, request) for p in preds)):
65476e 102                     continue
44bc11 103                 return info
8392d7 104
0c29cf 105         return {'route': None, 'match': None}
MM 106
62267e 107
05c023 108 # stolen from bobo and modified
dceff5 109 old_route_re = re.compile(r'(\:[_a-zA-Z]\w*)')
06f57f 110 star_at_end = re.compile(r'\*(\w*)$')
b8fc93 111
6516a8 112 # The tortuous nature of the regex named ``route_re`` below is due to the
e39ddf 113 # fact that we need to support at least one level of "inner" squigglies
JG 114 # inside the expr of a {name:expr} pattern.  This regex used to be just
115 # (\{[a-zA-Z][^\}]*\}) but that choked when supplied with e.g. {foo:\d{4}}.
dceff5 116 route_re = re.compile(r'(\{[_a-zA-Z][^{}]*(?:\{[^{}]*\}[^{}]*)*\})')
959523 117
0c29cf 118
4018ad 119 def update_pattern(matchobj):
BB 120     name = matchobj.group(0)
121     return '{%s}' % name[1:]
0c29cf 122
4018ad 123
BB 124 def _compile_route(route):
a511b1 125     # This function really wants to consume Unicode patterns natively, but if
CM 126     # someone passes us a bytestring, we allow it by converting it to Unicode
6516a8 127     # using the ASCII decoding.  We decode it using ASCII because we don't
a511b1 128     # want to accept bytestrings with high-order characters in them here as
CM 129     # we have no idea what the encoding represents.
130     if route.__class__ is not text_type:
785358 131         try:
CM 132             route = text_(route, 'ascii')
133         except UnicodeDecodeError:
134             raise ValueError(
135                 'The pattern value passed to add_route must be '
136                 'either a Unicode string or a plain string without '
0c29cf 137                 'any non-ASCII characters (you provided %r).' % route
MM 138             )
a511b1 139
4018ad 140     if old_route_re.search(route) and not route_re.search(route):
BB 141         route = old_route_re.sub(update_pattern, route)
142
05c023 143     if not route.startswith('/'):
CM 144         route = '/' + route
560e6e 145
a511b1 146     remainder = None
da171d 147     if star_at_end.search(route):
a511b1 148         route, remainder = route.rsplit('*', 1)
da171d 149
05c023 150     pat = route_re.split(route)
a511b1 151
CM 152     # every element in "pat" will be Unicode (regardless of whether the
153     # route_re regex pattern is itself Unicode or str)
05c023 154     pat.reverse()
CM 155     rpat = []
156     gen = []
0c29cf 157     prefix = pat.pop()  # invar: always at least one element (route='/'+route)
a511b1 158
CM 159     # We want to generate URL-encoded URLs, so we url-quote the prefix, being
160     # careful not to quote any embedded slashes.  We have to replace '%' with
161     # '%%' afterwards, as the strings that go into "gen" are used as string
162     # replacement targets.
0c29cf 163     gen.append(
MM 164         quote_path_segment(prefix, safe='/').replace('%', '%%')
165     )  # native
166     rpat.append(re.escape(prefix))  # unicode
560e6e 167
05c023 168     while pat:
0c29cf 169         name = pat.pop()  # unicode
4018ad 170         name = name[1:-1]
BB 171         if ':' in name:
88bbd4 172             # reg may contain colons as well,
MA 173             # so we must strictly split name into two parts
174             name, reg = name.split(':', 1)
4018ad 175         else:
BB 176             reg = '[^/]+'
0c29cf 177         gen.append('%%(%s)s' % native_(name))  # native
MM 178         name = '(?P<%s>%s)' % (name, reg)  # unicode
05c023 179         rpat.append(name)
0c29cf 180         s = pat.pop()  # unicode
05c023 181         if s:
0c29cf 182             rpat.append(re.escape(s))  # unicode
a511b1 183             # We want to generate URL-encoded URLs, so we url-quote this
CM 184             # literal in the pattern, being careful not to quote the embedded
185             # slashes.  We have to replace '%' with '%%' afterwards, as the
186             # strings that go into "gen" are used as string replacement
187             # targets.  What is appended to gen is a native string.
188             gen.append(quote_path_segment(s, safe='/').replace('%', '%%'))
05c023 189
a511b1 190     if remainder:
0c29cf 191         rpat.append('(?P<%s>.*?)' % remainder)  # unicode
MM 192         gen.append('%%(%s)s' % native_(remainder))  # native
05c023 193
0c29cf 194     pattern = ''.join(rpat) + '$'  # unicode
05c023 195
CM 196     match = re.compile(pattern).match
0c29cf 197
05c023 198     def matcher(path):
a511b1 199         # This function really wants to consume Unicode patterns natively,
CM 200         # but if someone passes us a bytestring, we allow it by converting it
201         # to Unicode using the ASCII decoding.  We decode it using ASCII
6516a8 202         # because we don't want to accept bytestrings with high-order
a511b1 203         # characters in them here as we have no idea what the encoding
CM 204         # represents.
205         if path.__class__ is not text_type:
206             path = text_(path, 'ascii')
05c023 207         m = match(path)
CM 208         if m is None:
a511b1 209             return None
25cbe1 210         d = {}
e6c2d2 211         for k, v in m.groupdict().items():
a511b1 212             # k and v will be Unicode 2.6.4 and lower doesnt accept unicode
CM 213             # kwargs as **kw, so we explicitly cast the keys to native
214             # strings in case someone wants to pass the result as **kw
215             nk = native_(k, 'ascii')
216             if k == remainder:
217                 d[nk] = split_path_info(v)
ac4689 218             else:
a511b1 219                 d[nk] = v
25cbe1 220         return d
05c023 221
CM 222     gen = ''.join(gen)
ece7e5 223
MK 224     def q(v):
f52759 225         return quote_path_segment(v, safe=PATH_SAFE)
ece7e5 226
05c023 227     def generator(dict):
CM 228         newdict = {}
229         for k, v in dict.items():
bc37a5 230             if PY2:
a511b1 231                 if v.__class__ is text_type:
CM 232                     # url_quote below needs bytes, not unicode on Py2
233                     v = v.encode('utf-8')
bc37a5 234             else:
MM 235                 if v.__class__ is binary_type:
236                     # url_quote below needs a native string, not bytes on Py3
237                     v = v.decode('utf-8')
785358 238
CM 239             if k == remainder:
240                 # a stararg argument
241                 if is_nonstr_iter(v):
0c29cf 242                     v = '/'.join([q(x) for x in v])  # native
785358 243                 else:
CM 244                     if v.__class__ not in string_types:
245                         v = str(v)
ece7e5 246                     v = q(v)
785358 247             else:
9d7eea 248                 if v.__class__ not in string_types:
CM 249                     v = str(v)
a511b1 250                 # v may be bytes (py2) or native string (py3)
ece7e5 251                 v = q(v)
a511b1 252
CM 253             # at this point, the value will be a native string
05c023 254             newdict[k] = v
a511b1 255
0c29cf 256         result = gen % newdict  # native string result
a511b1 257         return result
05c023 258
CM 259     return matcher, generator