comparison piecrust/routing.py @ 792:58ebf50235a5

routing: Simplify how routes are defined. * No more declaring the type of route parameters -- the sources and generators already know what type each parameter is supposed to be. * Same for variadic parameters -- we know already. * Update cache version to force a clear reload of the config. * Update tests. TODO: simplify code in the `Route` class to use source or generator transparently.
author Ludovic Chabant <ludovic@chabant.com>
date Wed, 07 Sep 2016 08:58:41 -0700
parents 504d6817352d
children 4850f8c21b6e
comparison
equal deleted inserted replaced
791:504d6817352d 792:58ebf50235a5
22 pass 22 pass
23 23
24 24
25 def create_route_metadata(page): 25 def create_route_metadata(page):
26 route_metadata = copy.deepcopy(page.source_metadata) 26 route_metadata = copy.deepcopy(page.source_metadata)
27 route_metadata.update(page.getRouteMetadata())
28 return route_metadata 27 return route_metadata
29
30
31 class IRouteMetadataProvider(object):
32 def getRouteMetadata(self):
33 raise NotImplementedError()
34 28
35 29
36 ROUTE_TYPE_SOURCE = 0 30 ROUTE_TYPE_SOURCE = 0
37 ROUTE_TYPE_GENERATOR = 1 31 ROUTE_TYPE_GENERATOR = 1
32
33
34 class RouteParameter(object):
35 TYPE_STRING = 0
36 TYPE_PATH = 1
37 TYPE_INT2 = 2
38 TYPE_INT4 = 3
39
40 def __init__(self, param_name, param_type=TYPE_STRING, *, variadic=False):
41 self.param_name = param_name
42 self.param_type = param_type
43 self.variadic = variadic
38 44
39 45
40 class Route(object): 46 class Route(object):
41 """ Information about a route for a PieCrust application. 47 """ Information about a route for a PieCrust application.
42 Each route defines the "shape" of an URL and how it maps to 48 Each route defines the "shape" of an URL and how it maps to
49 self.generator_name = cfg.get('generator') 55 self.generator_name = cfg.get('generator')
50 if not self.source_name and not self.generator_name: 56 if not self.source_name and not self.generator_name:
51 raise InvalidRouteError( 57 raise InvalidRouteError(
52 "Both `source` and `generator` are specified.") 58 "Both `source` and `generator` are specified.")
53 59
60 self.uri_pattern = cfg['url'].lstrip('/')
61
62 if self.is_source_route:
63 self.supported_params = self.source.getSupportedRouteParameters()
64 else:
65 self.supported_params = self.generator.getSupportedRouteParameters()
66
54 self.pretty_urls = app.config.get('site/pretty_urls') 67 self.pretty_urls = app.config.get('site/pretty_urls')
55 self.trailing_slash = app.config.get('site/trailing_slash') 68 self.trailing_slash = app.config.get('site/trailing_slash')
56 self.show_debug_info = app.config.get('site/show_debug_info') 69 self.show_debug_info = app.config.get('site/show_debug_info')
57 self.pagination_suffix_format = app.config.get( 70 self.pagination_suffix_format = app.config.get(
58 '__cache/pagination_suffix_format') 71 '__cache/pagination_suffix_format')
59 self.uri_root = app.config.get('site/root') 72 self.uri_root = app.config.get('site/root')
60 73
61 uri = cfg['url'] 74 self.uri_params = []
62 self.uri_pattern = uri.lstrip('/')
63 self.uri_format = route_re.sub(self._uriFormatRepl, self.uri_pattern) 75 self.uri_format = route_re.sub(self._uriFormatRepl, self.uri_pattern)
64 76
65 # Get the straight-forward regex for matching this URI pattern. 77 # Get the straight-forward regex for matching this URI pattern.
66 p = route_esc_re.sub(self._uriPatternRepl, 78 p = route_esc_re.sub(self._uriPatternRepl,
67 re.escape(self.uri_pattern)) + '$' 79 re.escape(self.uri_pattern)) + '$'
83 re.escape(uri_pattern_no_path)) + '$' 95 re.escape(uri_pattern_no_path)) + '$'
84 self.uri_re_no_path = re.compile(p) 96 self.uri_re_no_path = re.compile(p)
85 else: 97 else:
86 self.uri_re_no_path = None 98 self.uri_re_no_path = None
87 99
88 # Determine the parameters for the route function.
89 self.func_name = self._validateFuncName(cfg.get('func')) 100 self.func_name = self._validateFuncName(cfg.get('func'))
90 self.func_parameters = []
91 self.func_has_variadic_parameter = False 101 self.func_has_variadic_parameter = False
92 self.param_types = {} 102 for p in self.uri_params[:-1]:
93 variadic_param_idx = -1 103 param = self.getParameter(p)
94 for m in route_re.finditer(self.uri_pattern): 104 if param.variadic:
95 name = m.group('name') 105 raise Exception(
96 self.func_parameters.append(name) 106 "Only the last route URL parameter can be variadic. "
97 107 "Got: %s" % self.uri_pattern)
98 qual = m.group('qual') 108 if len(self.uri_params) > 0:
99 if not qual: 109 last_param = self.getParameter(self.uri_params[-1])
100 qual = self._getBackwardCompatibleParamType(name) 110 self.func_has_variadic_parameter = last_param.variadic
101 if qual:
102 self.param_types[name] = qual
103
104 if m.group('var'):
105 self.func_has_variadic_parameter = True
106 variadic_param_idx = len(self.func_parameters) - 1
107
108 if (variadic_param_idx >= 0 and
109 variadic_param_idx != len(self.func_parameters) - 1):
110 raise Exception(
111 "Only the last route URL parameter can be variadic. "
112 "Got: %s" % self.uri_pattern)
113 111
114 @property 112 @property
115 def route_type(self): 113 def route_type(self):
116 if self.source_name: 114 if self.source_name:
117 return ROUTE_TYPE_SOURCE 115 return ROUTE_TYPE_SOURCE
134 return InvalidRouteError("This is not a source route.") 132 return InvalidRouteError("This is not a source route.")
135 for src in self.app.sources: 133 for src in self.app.sources:
136 if src.name == self.source_name: 134 if src.name == self.source_name:
137 return src 135 return src
138 raise Exception("Can't find source '%s' for route '%s'." % ( 136 raise Exception("Can't find source '%s' for route '%s'." % (
139 self.source_name, self.uri)) 137 self.source_name, self.uri_pattern))
140 138
141 @cached_property 139 @cached_property
142 def generator(self): 140 def generator(self):
143 if not self.is_generator_route: 141 if not self.is_generator_route:
144 return InvalidRouteError("This is not a generator route.") 142 return InvalidRouteError("This is not a generator route.")
145 for gen in self.app.generators: 143 for gen in self.app.generators:
146 if gen.name == self.generator_name: 144 if gen.name == self.generator_name:
147 return gen 145 return gen
148 raise Exception("Can't find generator '%s' for route '%s'." % ( 146 raise Exception("Can't find generator '%s' for route '%s'." % (
149 self.generator_name, self.uri)) 147 self.generator_name, self.uri_pattern))
148
149 def hasParameter(self, name):
150 return any(lambda p: p.param_name == name, self.supported_params)
151
152 def getParameter(self, name):
153 for p in self.supported_params:
154 if p.param_name == name:
155 return p
156 raise Exception("No such supported route parameter '%s' in: %s" %
157 (name, self.uri_pattern))
158
159 def getParameterType(self, name):
160 return self.getParameter(name).param_type
150 161
151 def matchesMetadata(self, route_metadata): 162 def matchesMetadata(self, route_metadata):
152 return set(self.func_parameters).issubset(route_metadata.keys()) 163 return set(self.uri_params).issubset(route_metadata.keys())
153 164
154 def matchUri(self, uri, strict=False): 165 def matchUri(self, uri, strict=False):
155 if not uri.startswith(self.uri_root): 166 if not uri.startswith(self.uri_root):
156 raise Exception("The given URI is not absolute: %s" % uri) 167 raise Exception("The given URI is not absolute: %s" % uri)
157 uri = uri[len(self.uri_root):] 168 uri = uri[len(self.uri_root):]
176 # When matching URIs, if the URI is a match but is missing some 187 # When matching URIs, if the URI is a match but is missing some
177 # metadata, fill those up with empty strings. This can happen if, 188 # metadata, fill those up with empty strings. This can happen if,
178 # say, a route's pattern is `/foo/%slug%`, and we're matching an 189 # say, a route's pattern is `/foo/%slug%`, and we're matching an
179 # URL like `/foo`. 190 # URL like `/foo`.
180 matched_keys = set(route_metadata.keys()) 191 matched_keys = set(route_metadata.keys())
181 missing_keys = set(self.func_parameters) - matched_keys 192 missing_keys = set(self.uri_params) - matched_keys
182 for k in missing_keys: 193 for k in missing_keys:
194 if self.getParameterType(k) != RouteParameter.TYPE_PATH:
195 return None
183 route_metadata[k] = '' 196 route_metadata[k] = ''
184 197
185 for k in route_metadata: 198 for k in route_metadata:
186 route_metadata[k] = self._coerceRouteParameter( 199 route_metadata[k] = self._coerceRouteParameter(
187 k, route_metadata[k]) 200 k, route_metadata[k])
237 uri += '?!debug' 250 uri += '?!debug'
238 251
239 return uri 252 return uri
240 253
241 def execTemplateFunc(self, *args): 254 def execTemplateFunc(self, *args):
242 fixed_param_count = len(self.func_parameters) 255 fixed_param_count = len(self.uri_params)
243 if self.func_has_variadic_parameter: 256 if self.func_has_variadic_parameter:
244 fixed_param_count -= 1 257 fixed_param_count -= 1
245 258
246 if len(args) < fixed_param_count: 259 if len(args) < fixed_param_count:
247 raise Exception( 260 raise Exception(
256 coerced_args.append(var_arg) 269 coerced_args.append(var_arg)
257 else: 270 else:
258 coerced_args = args 271 coerced_args = args
259 272
260 metadata = {} 273 metadata = {}
261 for arg_name, arg_val in zip(self.func_parameters, coerced_args): 274 for arg_name, arg_val in zip(self.uri_params, coerced_args):
262 metadata[arg_name] = self._coerceRouteParameter( 275 metadata[arg_name] = self._coerceRouteParameter(
263 arg_name, arg_val) 276 arg_name, arg_val)
264 277
265 if self.is_generator_route: 278 if self.is_generator_route:
266 self.generator.onRouteFunctionUsed(self, metadata) 279 self.generator.onRouteFunctionUsed(self, metadata)
267 280
268 return self.getUri(metadata) 281 return self.getUri(metadata)
269 282
270 def _uriFormatRepl(self, m): 283 def _uriFormatRepl(self, m):
271 qual = m.group('qual') 284 if m.group('qual') or m.group('var'):
285 # Print a warning only if we're not in a worker process.
286 print_warning = not self.app.config.has('baker/worker_id')
287 if print_warning:
288 logger.warning("Route '%s' specified parameter types -- "
289 "they're not needed anymore." %
290 self.uri_pattern)
291
272 name = m.group('name') 292 name = m.group('name')
273 293 self.uri_params.append(name)
274 # Backwards compatibility... this will print a warning later. 294 try:
275 if qual is None: 295 param_type = self.getParameterType(name)
276 if name == 'year': 296 if param_type == RouteParameter.TYPE_INT4:
277 qual = 'int4' 297 return '%%(%s)04d' % name
278 elif name in ['month', 'day']: 298 elif param_type == RouteParameter.TYPE_INT2:
279 qual = 'int2' 299 return '%%(%s)02d' % name
280 300 return '%%(%s)s' % name
281 if qual == 'int4': 301 except:
282 return '%%(%s)04d' % name 302 known = [p.name for p in self.supported_params]
283 elif qual == 'int2': 303 raise Exception("Unknown route parameter '%s' for route '%s'. "
284 return '%%(%s)02d' % name 304 "Must be one of: %s'" %
285 elif qual and qual != 'path': 305 (name, self.uri_pattern, known))
286 raise Exception("Unknown route parameter type: %s" % qual)
287 return '%%(%s)s' % name
288 306
289 def _uriPatternRepl(self, m): 307 def _uriPatternRepl(self, m):
290 name = m.group('name') 308 name = m.group('name')
291 qual = m.group('qual') 309 param_type = self.getParameterType(name)
292 310 if param_type == RouteParameter.TYPE_PATH:
293 # Backwards compatibility... this will print a warning later.
294 if qual is None:
295 if name == 'year':
296 qual = 'int4'
297 elif name in ['month', 'day']:
298 qual = 'int2'
299
300 if qual == 'path' or m.group('var'):
301 return r'(?P<%s>[^\?]*)' % name 311 return r'(?P<%s>[^\?]*)' % name
302 elif qual == 'int4': 312 elif param_type == RouteParameter.TYPE_INT4:
303 return r'(?P<%s>\d{4})' % name 313 return r'(?P<%s>\d{4})' % name
304 elif qual == 'int2': 314 elif param_type == RouteParameter.TYPE_INT2:
305 return r'(?P<%s>\d{2})' % name 315 return r'(?P<%s>\d{2})' % name
306 elif qual and qual != 'path':
307 raise Exception("Unknown route parameter type: %s" % qual)
308 return r'(?P<%s>[^/\?]+)' % name 316 return r'(?P<%s>[^/\?]+)' % name
309 317
310 def _uriNoPathRepl(self, m): 318 def _uriNoPathRepl(self, m):
311 name = m.group('name') 319 name = m.group('name')
312 qualifier = m.group('qual') 320 param_type = self.getParameterType(name)
313 if qualifier == 'path': 321 if param_type == RouteParameter.TYPE_PATH:
314 return '' 322 return ''
315 return r'(?P<%s>[^/\?]+)' % name 323 return r'(?P<%s>[^/\?]+)' % name
316 324
317 def _coerceRouteParameter(self, name, val): 325 def _coerceRouteParameter(self, name, val):
318 param_type = self.param_types.get(name) 326 try:
319 if param_type is None: 327 param_type = self.getParameterType(name)
328 except:
329 # Unknown parameter... just leave it.
320 return val 330 return val
321 if param_type in ['int', 'int2', 'int4']: 331
332 if param_type in [RouteParameter.TYPE_INT2, RouteParameter.TYPE_INT4]:
322 try: 333 try:
323 return int(val) 334 return int(val)
324 except ValueError: 335 except ValueError:
325 raise Exception( 336 raise Exception(
326 "Expected route parameter '%s' to be of type " 337 "Expected route parameter '%s' to be an integer, "
327 "'%s', but was: %s" % 338 "but was: %s" % (name, param_type, val))
328 (name, param_type, val)) 339 return val
329 if param_type == 'path':
330 return val
331 raise Exception("Unknown route parameter type: %s" % param_type)
332
333 def _getBackwardCompatibleParamType(self, name):
334 # Print a warning only if we're not in a worker process.
335 print_warning = not self.app.config.has('baker/worker_id')
336
337 if name in ['year']:
338 if print_warning:
339 logger.warning(
340 "Route parameter '%%%s%%' has no type qualifier. "
341 "You probably meant '%%int4:%s%%' so we'll use that." %
342 (name, name))
343 return 'int4'
344 if name in ['month', 'day']:
345 if print_warning:
346 logger.warning(
347 "Route parameter '%%%s%%' has no type qualifier. "
348 "You probably meant '%%int2:%s%%' so we'll use that." %
349 (name, name))
350 return 'int2'
351 return None
352 340
353 def _validateFuncName(self, name): 341 def _validateFuncName(self, name):
354 if not name: 342 if not name:
355 return None 343 return None
356 i = name.find('(') 344 i = name.find('(')
367 self._routes = [] 355 self._routes = []
368 self._arg_names = None 356 self._arg_names = None
369 357
370 def addFunc(self, route): 358 def addFunc(self, route):
371 if self._arg_names is None: 359 if self._arg_names is None:
372 self._arg_names = list(route.func_parameters) 360 self._arg_names = list(route.uri_params)
373 361
374 if route.func_parameters != self._arg_names: 362 if route.uri_params != self._arg_names:
375 raise Exception("Cannot merge route function with arguments '%s' " 363 raise Exception("Cannot merge route function with arguments '%s' "
376 "with route function with arguments '%s'." % 364 "with route function with arguments '%s'." %
377 (route.func_parameters, self._arg_names)) 365 (route.uri_params, self._arg_names))
378 self._routes.append(route) 366 self._routes.append(route)
379 367
380 def __call__(self, *args, **kwargs): 368 def __call__(self, *args, **kwargs):
381 if len(self._routes) == 1 or len(args) == len(self._arg_names): 369 if len(self._routes) == 1 or len(args) == len(self._arg_names):
382 return self._routes[0].execTemplateFunc(*args, **kwargs) 370 return self._routes[0].execTemplateFunc(*args, **kwargs)