summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBen Bangert <ben@groovie.org>2010-02-20 16:41:37 -0500
committerBen Bangert <ben@groovie.org>2010-02-20 16:41:37 -0500
commite66d83c31582e3aa58e34eaa25e8d4cb5c7852d1 (patch)
treeb723eace406822b419890e537219ac6ab140a63e
parent179638a726241282039ad7759b68c3c7c2693d91 (diff)
parent08a1b5964a1abb1ee275b4c55aa6f5ed60b55521 (diff)
downloadroutes-e66d83c31582e3aa58e34eaa25e8d4cb5c7852d1.tar.gz
merge
--HG-- branch : trunk
-rw-r--r--docs/manual.rst98
-rw-r--r--routes/mapper.py52
-rw-r--r--routes/route.py65
-rw-r--r--tests/test_functional/test_generation.py13
-rw-r--r--tests/test_functional/test_recognition.py14
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: