diff options
author | Ben Bangert <ben@groovie.org> | 2010-02-20 16:41:37 -0500 |
---|---|---|
committer | Ben Bangert <ben@groovie.org> | 2010-02-20 16:41:37 -0500 |
commit | e66d83c31582e3aa58e34eaa25e8d4cb5c7852d1 (patch) | |
tree | b723eace406822b419890e537219ac6ab140a63e | |
parent | 179638a726241282039ad7759b68c3c7c2693d91 (diff) | |
parent | 08a1b5964a1abb1ee275b4c55aa6f5ed60b55521 (diff) | |
download | routes-e66d83c31582e3aa58e34eaa25e8d4cb5c7852d1.tar.gz |
merge
--HG--
branch : trunk
-rw-r--r-- | docs/manual.rst | 98 | ||||
-rw-r--r-- | routes/mapper.py | 52 | ||||
-rw-r--r-- | routes/route.py | 65 | ||||
-rw-r--r-- | tests/test_functional/test_generation.py | 13 | ||||
-rw-r--r-- | tests/test_functional/test_recognition.py | 14 |
5 files changed, 201 insertions, 41 deletions
diff --git a/docs/manual.rst b/docs/manual.rst index 8634fb0..f5ffcd2 100644 --- a/docs/manual.rst +++ b/docs/manual.rst @@ -286,6 +286,28 @@ components to the right of it not to match:: The lesson is to always test wildcard patterns. +Format extensions +----------------- + +A path component of ``{.format}`` will match an optional format extension (e.g. +".html" or ".json"), setting the format variable to the part after the "." +(e.g. "html" or "json") if there is one, or to ``None`` otherwise. For example:: + + map.connect('/entries/{id}{.format}') + +will match "/entries/1" and "/entries/1.mp3". You can use requirements to +limit which extensions will match, for example:: + + map.connect('/entries/{id:\d+}{.format:json}') + +will match "/entries/1" and "/entries/1.json" but not "/entries/1.mp3". + +As with wildcard routes, it's important to understand and test this. Without +the ``\d+`` requirement on the ``id`` variable above, "/entries/1.mp3" would match +successfully, with the ``id`` variable capturing "1.mp3". + +*New in Routes 1.12.* + Submappers ---------- @@ -294,13 +316,13 @@ without having to repeat identical keyword arguments. There are two syntaxes, one using a Python ``with`` block, and the other avoiding it. :: # Using 'with' - map.connect("home", "/", controller="home", action="splash") with map.submapper(controller="home") as m: + m.connect("home", "/", action="splash") m.connect("index", "/index", action="index") # Not using 'with' - map.connect("home", "/", controller="home", action="splash") m = map.submapper(controller="home") + m.connect("home", "/", action="splash") m.connect("index", "/index", action="index") # Both of these syntaxes create the following routes:: @@ -324,6 +346,58 @@ from. *New in Routes 1.11.* +Submapper helpers +----------------- + +Submappers contain a number of helpers that further simplify routing +configuration. This:: + + with map.submapper(controller="home") as m: + m.connect("home", "/", action="splash") + m.connect("index", "/index", action="index") + +can be written:: + + with map.submapper(controller="home", path_prefix="/") as m: + m.action("home", action="splash") + m.link("index") + +The ``action`` helper generates a route for one or more HTTP methods ('GET' is +assumed) at the submapper's path ('/' in the example above). The ``link`` +helper generates a route at a relative path. + +There are specific helpers corresponding to the standard ``index``, ``new``, +``create``, ``show``, ``edit``, ``update`` and ``delete`` actions. +You can use these directly:: + + with map.submapper(controller="entries", path_prefix="/entries") as entries: + entries.index() + with entries.submapper(path_prefix="/{id}") as entry: + entry.show() + +or indirectly:: + + with map.submapper(controller="entries", path_prefix="/entries", + actions=["index"]) as entries: + entries.submapper(path_prefix="/{id}", actions=["show"]) + +Collection/member submappers nested in this way are common enough that there is +helper for this too:: + + map.collection(collection_name="entries", member_name="entry", + controller="entries", + collection_actions=["index"], member_actions["show"]) + +This returns a submapper instance to which further routes may be added; it has +a ``member`` property (a nested submapper) to which which member-specific routes +can be added. When ``collection_actions`` or ``member_actions`` are omitted, +the full set of actions is generated (see the example under "Printing" below). + +See "RESTful services" below for ``map.resource``, a precursor to +``map.collection`` that does not use submappers. + +*New in Routes 1.12.* + Adding routes from a nested application --------------------------------------- @@ -808,6 +882,26 @@ string. :: *New in Routes 1.10.* +Printing +======== + +Mappers now have a formatted string representation. In your python shell, +simply print your application's mapper:: + + >>> map.collection("entries", "entry") + >>> print map + Route name Methods Path + entries GET /entries{.format} + create_entry POST /entries{.format} + new_entry GET /entries/new{.format} + entry GET /entries/{id}{.format} + update_entry PUT /entries/{id}{.format} + delete_entry DELETE /entries/{id}{.format} + edit_entry GET /entries/{id}/edit{.format} + +*New in Routes 1.12.* + + Introspection ============= diff --git a/routes/mapper.py b/routes/mapper.py index 298680c..bd48c4f 100644 --- a/routes/mapper.py +++ b/routes/mapper.py @@ -57,21 +57,24 @@ class SubMapperParent(object): >>> map.matchlist[1].defaults['controller'] == 'home' True - Optional ``collection_name``, ``resource_name`` and ``formatted`` - arguments are used in the generation of route names by the ``action`` - and ``link`` methods. These in turn are used by the ``index``, + Optional ``collection_name`` and ``resource_name`` arguments are + used in the generation of route names by the ``action`` and + ``link`` methods. These in turn are used by the ``index``, ``new``, ``create``, ``show``, ``edit``, ``update`` and - ``delete`` methods which may be invoked by listing them in the - ``actions`` argument. + ``delete`` methods which may be invoked indirectly by listing + them in the ``actions`` argument. If the ``formatted`` argument + is set to ``True`` (the default), generated paths are given the + suffix '{.format}' which matches or generates an optional format + extension. Example:: >>> from routes.util import url_for >>> map = Mapper(controller_scan=None) - >>> m = map.submapper(path_prefix='/entries', collection_name='entries', resource_name='entry', formatted=True, actions=['index', 'new']) + >>> m = map.submapper(path_prefix='/entries', collection_name='entries', resource_name='entry', actions=['index', 'new']) >>> url_for('entries') == '/entries' True - >>> url_for('formatted_new_entry', format='xml') == '/entries/new.xml' + >>> url_for('new_entry', format='xml') == '/entries/new.xml' True """ @@ -137,8 +140,12 @@ class SubMapper(SubMapperParent): or getattr(obj, 'resource_name', None) \ or kwargs.get('controller', None) \ or getattr(obj, 'controller', None) - self.formatted = formatted or \ - (formatted is None and getattr(obj, 'formatted', None)) + if formatted is not None: + self.formatted = formatted + else: + self.formatted = getattr(obj, 'formatted', None) + if self.formatted is None: + self.formatted = True self.add_actions(actions or []) @@ -180,18 +187,17 @@ class SubMapper(SubMapperParent): True >>> url_for('ping_entry', id=1) == '/entries/1/ping' True - >>> url_for('formatted_ping_entry', id=1, format='xml') == '/entries/1/ping.xml' + >>> url_for('ping_entry', id=1, format='xml') == '/entries/1/ping.xml' True """ if formatted or (formatted is None and self.formatted): - self.connect('formatted_' + - (name or (rel + '_' + self.resource_name)), - ('/' + (rel or name)) + '.{format}', - action=action or rel or name, - **_kwargs_with_conditions(kwargs, method)) + suffix = '{.format}' + else: + suffix = '' + return self.connect(name or (rel + '_' + self.resource_name), - '/' + (rel or name), + '/' + (rel or name) + suffix, action=action or rel or name, **_kwargs_with_conditions(kwargs, method)) @@ -223,13 +229,11 @@ class SubMapper(SubMapperParent): """ if formatted or (formatted is None and self.formatted): - self.connect('formatted_' + - (name or (action + '_' + self.resource_name)), - '.{format}', - action=action or name, - **_kwargs_with_conditions(kwargs, method)) + suffix = '{.format}' + else: + suffix = '' return self.connect(name or (action + '_' + self.resource_name), - '', + suffix, action=action or name, **_kwargs_with_conditions(kwargs, method)) @@ -757,7 +761,7 @@ class Mapper(SubMapperParent): cacheset = True newlist = [] for route in keylist: - if len(route.minkeys-keys) == 0: + if len(route.minkeys - route.dotkeys - keys) == 0: newlist.append(route) keylist = newlist @@ -800,7 +804,7 @@ class Mapper(SubMapperParent): keylist.sort(keysort) if cacheset: sortcache[cachekey] = keylist - + # Iterate through the keylist of sorted routes (or a single route if # it was passed in explicitly for hardcoded named routes) for route in keylist: diff --git a/routes/route.py b/routes/route.py index 85fd601..72009cd 100644 --- a/routes/route.py +++ b/routes/route.py @@ -87,9 +87,12 @@ class Route(object): def _setup_route(self): # Build our routelist, and the keys used in the route self.routelist = routelist = self._pathkeys(self.routepath) - routekeys = frozenset([key['name'] for key in routelist \ + routekeys = frozenset([key['name'] for key in routelist if isinstance(key, dict)]) - + self.dotkeys = frozenset([key['name'] for key in routelist + if isinstance(key, dict) and + key['type'] == '.']) + if not self.minimization: self.make_full_route() @@ -171,11 +174,15 @@ class Route(object): elif collecting: collecting = False if var_type == '{': + if current[0] == '.': + var_type = '.' + current = current[1:] + else: + var_type = ':' opts = current.split(':') if len(opts) > 1: current = opts[0] self.reqs[current] = opts[1] - var_type = ':' routelist.append(dict(type=var_type, name=current)) if char in self.done_chars: routelist.append(char) @@ -308,12 +315,18 @@ class Route(object): partmatch = '|'.join(map(re.escape, clist)) elif part['type'] == ':': partmatch = self.reqs.get(var) or '[^/]+?' + elif part['type'] == '.': + partmatch = self.reqs.get(var) or '[^/.]+?' else: partmatch = self.reqs.get(var) or '.+?' if include_names: - regparts.append('(?P<%s>%s)' % (var, partmatch)) + regpart = '(?P<%s>%s)' % (var, partmatch) else: - regparts.append('(?:%s)' % partmatch) + regpart = '(?:%s)' % partmatch + if part['type'] == '.': + regparts.append('(?:\.%s)??' % regpart) + else: + regparts.append(regpart) else: regparts.append(re.escape(part)) regexp = ''.join(regparts) + '$' @@ -341,8 +354,9 @@ class Route(object): self.prior = part (rest, noreqs, allblank) = self.buildnextreg(path[1:], clist, include_names) - if isinstance(part, dict) and part['type'] == ':': + if isinstance(part, dict) and part['type'] in (':', '.'): var = part['name'] + typ = part['type'] partreg = '' # First we plug in the proper part matcher @@ -351,6 +365,8 @@ class Route(object): partreg = '(?P<%s>%s)' % (var, self.reqs[var]) else: partreg = '(?:%s)' % self.reqs[var] + if typ == '.': + partreg = '(?:\.%s)??' % partreg elif var == 'controller': if include_names: partreg = '(?P<%s>%s)' % (var, '|'.join(map(re.escape, clist))) @@ -363,10 +379,16 @@ class Route(object): partreg = '(?:[^' + self.prior + ']+?)' else: if not rest: + if typ == '.': + exclude_chars = '/.' + else: + exclude_chars = '/' if include_names: - partreg = '(?P<%s>[^%s]+?)' % (var, '/') + partreg = '(?P<%s>[^%s]+?)' % (var, exclude_chars) else: - partreg = '(?:[^%s]+?)' % '/' + partreg = '(?:[^%s]+?)' % exclude_chars + if typ == '.': + partreg = '(?:\.%s)??' % partreg else: end = ''.join(self.done_chars) rem = rest @@ -568,16 +590,24 @@ class Route(object): elif self.make_unicode(kargs[k]) != \ self.make_unicode(self.defaults[k]): return False - + # Ensure that all the args in the route path are present and not None for arg in self.minkeys: if arg not in kargs or kargs[arg] is None: - return False - + if arg in self.dotkeys: + kargs[arg] = '' + else: + return False + # Encode all the argument that the regpath can use for k in kargs: if k in self.maxkeys: - kargs[k] = url_quote(kargs[k], self.encoding) + if k in self.dotkeys: + if kargs[k]: + kargs[k] = url_quote('.' + kargs[k], self.encoding) + else: + kargs[k] = url_quote(kargs[k], self.encoding) + return self.regpath % kargs def generate_minimized(self, kargs): @@ -586,7 +616,7 @@ class Route(object): urllist = [] gaps = False for part in routelist: - if isinstance(part, dict) and part['type'] == ':': + if isinstance(part, dict) and part['type'] in (':', '.'): arg = part['name'] # For efficiency, check these just once @@ -616,12 +646,17 @@ class Route(object): elif has_default and self.defaults[arg] is not None: val = self.defaults[arg] - + # Optional format parameter? + elif part['type'] == '.': + continue # No arg at all? This won't work else: return False - + urllist.append(url_quote(val, self.encoding)) + if part['type'] == '.': + urllist.append('.') + if has_arg: del kargs[arg] gaps = True diff --git a/tests/test_functional/test_generation.py b/tests/test_functional/test_generation.py index 8260897..68b6cba 100644 --- a/tests/test_functional/test_generation.py +++ b/tests/test_functional/test_generation.py @@ -635,6 +635,19 @@ class TestGeneration(unittest.TestCase): eq_('/2007/test.xml,ja', m.generate(year=2007, slug='test', format='xml', locale='ja')) eq_(None, m.generate(year=2007, format='html')) + def test_dot_format_args(self): + for minimization in [False, True]: + m = Mapper(explicit=True) + m.minimization=minimization + m.connect('/songs/{title}{.format}') + m.connect('/stories/{slug}{.format:pdf}') + + eq_('/songs/my-way', m.generate(title='my-way')) + eq_('/songs/my-way.mp3', m.generate(title='my-way', format='mp3')) + eq_('/stories/frist-post', m.generate(slug='frist-post')) + eq_('/stories/frist-post.pdf', m.generate(slug='frist-post', format='pdf')) + eq_(None, m.generate(slug='frist-post', format='doc')) + if __name__ == '__main__': unittest.main() else: diff --git a/tests/test_functional/test_recognition.py b/tests/test_functional/test_recognition.py index d6400dd..798e678 100644 --- a/tests/test_functional/test_recognition.py +++ b/tests/test_functional/test_recognition.py @@ -930,6 +930,20 @@ class TestRecognition(unittest.TestCase): m.match('') assert_raises(RoutesException, call_func) + def test_dot_format_args(self): + for minimization in [False, True]: + m = Mapper(explicit=True) + m.minimization=minimization + m.connect('/songs/{title}{.format}') + m.connect('/stories/{slug:[^./]+?}{.format:pdf}') + + eq_({'title': 'my-way', 'format': None}, m.match('/songs/my-way')) + eq_({'title': 'my-way', 'format': 'mp3'}, m.match('/songs/my-way.mp3')) + eq_({'slug': 'frist-post', 'format': None}, m.match('/stories/frist-post')) + eq_({'slug': 'frist-post', 'format': 'pdf'}, m.match('/stories/frist-post.pdf')) + eq_(None, m.match('/stories/frist-post.doc')) + + if __name__ == '__main__': unittest.main() else: |