summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMike Burrows <mjb@asplake.co.uk>2009-12-26 14:34:56 +0000
committerMike Burrows <mjb@asplake.co.uk>2009-12-26 14:34:56 +0000
commit301e7aadb0a13b4a53fe7196d69f3c18cdb5ff5e (patch)
tree0d3e4558350dd78da90b051407cd58d86148802a
parentb0bd2d8f97720be5ec183a136297fbd2f0619ee7 (diff)
downloadroutes-301e7aadb0a13b4a53fe7196d69f3c18cdb5ff5e.tar.gz
initial commit: nestable submappers, collection(), prettyprinter
--HG-- branch : trunk
-rw-r--r--README9
-rw-r--r--routes/mapper.py273
-rw-r--r--tests/test_functional/test_submapper.py153
3 files changed, 398 insertions, 37 deletions
diff --git a/README b/README
index c16c06a..5966ba0 100644
--- a/README
+++ b/README
@@ -1,3 +1,12 @@
+Routes 1.11(ish) with:
+* Routes prettyprinter
+* A working, nestable submapper
+* collection(), a more flexible and powerful alternative to resource()
+
+See http://positiveincline.com/?p=550 and http://positiveincline.com/?p=561
+
+--
+
Routes is a Python re-implementation of the Rails routes system for mapping
URL's to Controllers/Actions and generating URL's. Routes makes it easy to
create pretty and concise URL's that are RESTful with little effort.
diff --git a/routes/mapper.py b/routes/mapper.py
index 9778242..3095a04 100644
--- a/routes/mapper.py
+++ b/routes/mapper.py
@@ -12,6 +12,10 @@ from routes.util import controller_scan, MatchException, RoutesException
from routes.route import Route
+COLLECTION_ACTIONS = ['index', 'create', 'new']
+MEMBER_ACTIONS = ['show', 'update', 'delete', 'edit']
+
+
def strip_slashes(name):
"""Remove slashes from the beginning and end of a part/URL."""
if name.startswith('/'):
@@ -21,11 +25,94 @@ def strip_slashes(name):
return name
-class SubMapper(object):
+class SubMapperParent(object):
+ """Base class for Mapper and SubMapper, both of which may be the parent
+ of SubMapper objects
+ """
+
+ def submapper(self, **kargs):
+ """Create a partial version of the Mapper with the designated
+ options set
+
+ This results in a :class:`routes.mapper.SubMapper` object.
+
+ Only keyword arguments can be saved for use with the submapper
+ and only a 'connect' method is present on the submapper.
+
+ If keyword arguments provided to this method also exist in the
+ keyword arguments provided to the submapper, their values will
+ be merged with the saved options going first.
+
+ In addition to :class:`routes.route.Route` arguments, submapper
+ can also take a ``path_prefix`` argument which will be
+ prepended to the path of all routes that are connected.
+
+ Example::
+
+ >>> map = Mapper(controller_scan=None)
+ >>> map.connect('home', '/', controller='home', action='splash')
+ >>> map.matchlist[0].name == 'home'
+ True
+ >>> m = map.submapper(controller='home')
+ >>> m.connect('index', '/index', action='index')
+ >>> map.matchlist[1].name == 'index'
+ True
+ >>> map.matchlist[1].defaults['controller'] == 'home'
+ True
+
+ """
+ return SubMapper(self, **kargs)
+
+ def collection(
+ self,
+ collection_name,
+ resource_name,
+ path_prefix=None,
+ member_prefix='/{id}',
+ controller=None,
+ collection_actions=COLLECTION_ACTIONS,
+ member_actions = MEMBER_ACTIONS,
+ member_options=None,
+ **kwargs):
+ """TODO
+ """
+ if not controller:
+ controller =resource_name or collection_name
+
+ if not path_prefix:
+ path_prefix = '/' + collection_name
+
+ collection = SubMapper(
+ self,
+ collection_name=collection_name,
+ resource_name=resource_name,
+ path_prefix = path_prefix,
+ controller=controller,
+ actions=collection_actions,
+ **kwargs)
+
+ collection.member = SubMapper(
+ collection,
+ path_prefix = member_prefix,
+ actions=member_actions,
+ **(member_options or {}))
+
+ return collection
+
+
+class SubMapper(SubMapperParent):
"""Partial mapper for use with_options"""
- def __init__(self, obj, **kwargs):
+ def __init__(self, obj, resource_name=None, collection_name=None, actions=None, **kwargs):
self.kwargs = kwargs
self.obj = obj
+ self.collection_name = collection_name
+ self.member = None
+ self.resource_name = resource_name \
+ or getattr(obj, 'resource_name', None) \
+ or kwargs.get('controller', None) \
+ or getattr(obj, 'controller', None)
+
+ self.add_actions(actions or [])
def connect(self, *args, **kwargs):
newkargs = {}
@@ -37,7 +124,10 @@ class SubMapper(object):
else:
newargs = (self.kwargs[key] + args[0],)
elif key in kwargs:
- newkargs[key] = self.kwargs[key] + kwargs[key]
+ if isinstance(value, dict):
+ newkargs[key] = dict(value, **kwargs[key]) # merge dicts
+ else:
+ newkargs[key] = value + kwargs[key]
else:
newkargs[key] = self.kwargs[key]
for key in kwargs:
@@ -45,6 +135,111 @@ class SubMapper(object):
newkargs[key] = kwargs[key]
return self.obj.connect(*newargs, **newkargs)
+ # Generate a subresource linked by "rel", e.g.
+ #
+ # with mapper.submapper(controller='thing', path_prefix='/things') as c:
+ # c.link('new')
+ # with c.submapper(path_prefix='/{id}')) as m:
+ # m.link('edit')
+ #
+ # generates
+ #
+ # mapper.connect(
+ # 'new_thing', '/things/edit',
+ # controller='thing', action='new',
+ # conditions={'method': 'GET'})
+ # mapper.connect(
+ # 'edit_thing', '/things/{id}/edit',
+ # controller='thing', action='edit',
+ # conditions={'method': 'GET'})
+ #
+ # Overridable defaults:
+ # name: {rel}_{self.resource_name}
+ # action: rel
+ # rel: name
+ # method: 'GET'
+ #
+ # At least one of rel and name (the route name) must be supplied. It would
+ # be unusual not to supply rel.
+ #
+ def link(self, rel=None, name=None, action=None, method='GET', **kwargs):
+ return self.connect(
+ name or (rel + '_' + self.resource_name),
+ '/' + (rel or name),
+ action=action or rel or name,
+ **_kwargs_with_conditions(kwargs, method))
+
+ def new(self, **kwargs):
+ return self.link(rel='new', **kwargs)
+
+ def edit(self, **kwargs):
+ return self.link(rel='edit', **kwargs)
+
+ # Generate an action (typically with the POST method) on a resource that
+ # supports other methods (typically GET).
+ #
+ # with mapper.submapper(controller='thing', path_prefix='/things') as m:
+ # with m.submapper(path_prefix='/{id}')) as o:
+ # o.action('show', name='thing')
+ # o.action('update', method='PUT')
+ #
+ # generates
+ #
+ # mapper.connect(
+ # 'thing', '/things/{id}',
+ # controller='thing', action='show',
+ # conditions={'method': 'GET'})
+ # mapper.connect(
+ # 'save_thing', '/things/{id}',
+ # controller='thing', action='update',
+ # conditions={'method': 'PUT'})
+ #
+ # Overridable defaults:
+ # name: {action}_{self.resource_name}
+ # action: name
+ # method: GET
+ #
+ # At least one of name (the route name) and action must be supplied.
+ #
+ def action(self, name=None, action=None, method='GET', **kwargs):
+ return self.connect(
+ name or (action + '_' + self.resource_name),
+ '',
+ action=action or name,
+ **_kwargs_with_conditions(kwargs, method))
+
+ def index(self, name=None, **kwargs):
+ return self.action(
+ name=name or self.collection_name,
+ action='index', method='GET', **kwargs)
+
+ def show(self, name = None, **kwargs):
+ return self.action(
+ name=name or self.resource_name,
+ action='show', method='GET', **kwargs)
+
+ def create(self, **kwargs):
+ return self.action(action='create', method='POST', **kwargs)
+
+ def update(self, **kwargs):
+ return self.action(action='update', method='PUT', **kwargs)
+
+ def delete(self, **kwargs):
+ return self.action(action='delete', method='DELETE', **kwargs)
+
+ def add_actions(self, actions):
+ [getattr(self, action)() for action in actions]
+
+
+# Create kwargs with a 'conditions' member generated for the given method
+def _kwargs_with_conditions(kwargs, method):
+ if method and 'conditions' not in kwargs:
+ newkwargs = kwargs.copy()
+ newkwargs['conditions'] = {'method': method}
+ return newkwargs
+ else:
+ return kwargs
+
# Provided for those who prefer using the 'with' syntax in Python 2.5+
def __enter__(self):
return self
@@ -53,7 +248,7 @@ class SubMapper(object):
pass
-class Mapper(object):
+class Mapper(SubMapperParent):
"""Mapper handles URL generation and URL recognition in a web
application.
@@ -161,6 +356,43 @@ class Mapper(object):
config = request_config()
config.mapper = self
+ def __str__(self):
+ """Pretty string representation. For example, in the paster shell:
+ >>> print mapper
+
+ Route name Methods Path
+ POST /entries
+ entries GET /entries
+ new_entry GET /entries/new
+ PUT /entries/{id}
+ DELETE /entries/{id}
+ edit_entry GET /entries/{id}/edit
+ entry GET /entries/{id}
+
+ """
+ def format_methods(r):
+ if r.conditions:
+ method = r.conditions.get('method', '')
+ return method if type(method) is str else ', '.join(method)
+ else:
+ return ''
+
+ table = [('Route name', 'Methods', 'Path')] + [
+ (
+ r.name or '',
+ format_methods(r),
+ r.routepath or ''
+ )
+ for r in self.matchlist]
+
+ widths = [
+ max(len(row[col]) for row in table)
+ for col in range(len(table[0]))]
+
+ return '\n'.join(
+ ' '.join(row[col].ljust(widths[col]) for col in range(len(widths)))
+ for row in table)
+
def _envget(self):
try:
return self.req_data.environ
@@ -172,39 +404,6 @@ class Mapper(object):
del self.req_data.environ
environ = property(_envget, _envset, _envdel)
- def submapper(self, **kargs):
- """Create a partial version of the Mapper with the designated
- options set
-
- This results in a :class:`routes.mapper.SubMapper` object.
-
- Only keyword arguments can be saved for use with the submapper
- and only a 'connect' method is present on the submapper.
-
- If keyword arguments provided to this method also exist in the
- keyword arguments provided to the submapper, their values will
- be merged with the saved options going first.
-
- In addition to :class:`routes.route.Route` arguments, submapper
- can also take a ``path_prefix`` argument which will be
- prepended to the path of all routes that are connected.
-
- Example::
-
- >>> map = Mapper(controller_scan=None)
- >>> map.connect('home', '/', controller='home', action='splash')
- >>> map.matchlist[0].name == 'home'
- True
- >>> m = map.submapper(controller='home')
- >>> m.connect('index', '/index', action='index')
- >>> map.matchlist[1].name == 'index'
- True
- >>> map.matchlist[1].defaults['controller'] == 'home'
- True
-
- """
- return SubMapper(self, **kargs)
-
def extend(self, routes, path_prefix=''):
"""Extends the mapper routes with a list of Route objects
diff --git a/tests/test_functional/test_submapper.py b/tests/test_functional/test_submapper.py
new file mode 100644
index 0000000..3733eaf
--- /dev/null
+++ b/tests/test_functional/test_submapper.py
@@ -0,0 +1,153 @@
+"""test_resources"""
+import unittest
+from nose.tools import eq_, assert_raises
+
+from routes import *
+
+class TestSubmapper(unittest.TestCase):
+ def test_submapper(self):
+ m = Mapper()
+ c = m.submapper(
+ path_prefix='/entries',
+ requirements=dict(id='\d+'))
+ c.connect('entry', '/{id}')
+
+ eq_('/entries/1', url_for('entry', id=1))
+ assert_raises(Exception, url_for, 'entry', id='foo')
+
+ def test_submapper_nesting(self):
+ m = Mapper()
+ c = m.submapper(
+ path_prefix='/entries',
+ controller='entry',
+ requirements=dict(id='\d+'))
+ e = c.submapper(path_prefix='/{id}')
+
+ eq_('entry', c.resource_name)
+ eq_('entry', e.resource_name)
+
+ e.connect('entry', '')
+ e.connect('edit_entry', '/edit')
+
+ eq_('/entries/1', url_for('entry', id=1))
+ eq_('/entries/1/edit', url_for('edit_entry', id=1))
+ assert_raises(Exception, url_for, 'entry', id='foo')
+
+ def test_submapper_action(self):
+ m = Mapper()
+ c = m.submapper(
+ path_prefix='/entries',
+ controller='entry')
+
+ c.action(name='entries', action='list')
+ c.action(action='create', method='POST')
+
+ eq_('/entries', url_for('entries', method='GET'))
+ eq_('/entries', url_for('create_entry', method='POST'))
+ eq_('/entries', url_for(controller='entry', action='list', method='GET'))
+ eq_('/entries', url_for(controller='entry', action='create', method='POST'))
+ assert_raises(Exception, url_for, 'entries', method='DELETE')
+
+ def test_submapper_link(self):
+ m = Mapper()
+ c = m.submapper(
+ path_prefix='/entries',
+ controller='entry')
+
+ c.link(rel='new')
+ c.link(rel='ping', method='POST')
+
+ eq_('/entries/new', url_for('new_entry', method='GET'))
+ eq_('/entries/ping', url_for('ping_entry', method='POST'))
+ eq_('/entries/new', url_for(controller='entry', action='new', method='GET'))
+ eq_('/entries/ping', url_for(controller='entry', action='ping', method='POST'))
+ assert_raises(Exception, url_for, 'new_entry', method='PUT')
+ assert_raises(Exception, url_for, 'ping_entry', method='PUT')
+
+ def test_submapper_standard_actions(self):
+ m = Mapper()
+ c = m.submapper(
+ path_prefix='/entries',
+ collection_name='entries',
+ controller='entry')
+ e = c.submapper(path_prefix='/{id}')
+
+ c.index()
+ c.create()
+ e.show()
+ e.update()
+ e.delete()
+
+ eq_('/entries', url_for('entries', method='GET'))
+ eq_('/entries', url_for('create_entry', method='POST'))
+ assert_raises(Exception, url_for, 'entries', method='DELETE')
+
+ eq_('/entries/1', url_for('entry', id=1, method='GET'))
+ eq_('/entries/1', url_for('update_entry', id=1, method='PUT'))
+ eq_('/entries/1', url_for('delete_entry', id=1, method='DELETE'))
+ assert_raises(Exception, url_for, 'entry', id=1, method='POST')
+
+ def test_submapper_standard_links(self):
+ m = Mapper()
+ c = m.submapper(
+ path_prefix='/entries',
+ controller='entry')
+ e = c.submapper(path_prefix='/{id}')
+
+ c.new()
+ e.edit()
+
+ eq_('/entries/new', url_for('new_entry', method='GET'))
+ assert_raises(Exception, url_for, 'new_entry', method='POST')
+
+ eq_('/entries/1/edit', url_for('edit_entry', id=1, method='GET'))
+ assert_raises(Exception, url_for, 'edit_entry', id=1, method='POST')
+
+ def test_submapper_action_and_link_generation(self):
+ m = Mapper()
+ c = m.submapper(
+ path_prefix='/entries',
+ controller='entry',
+ collection_name='entries',
+ actions=['index', 'new', 'create'])
+ e = c.submapper(
+ path_prefix='/{id}',
+ actions=['show', 'edit', 'update', 'delete'])
+
+ eq_('/entries', url_for('entries', method='GET'))
+ eq_('/entries', url_for('create_entry', method='POST'))
+ assert_raises(Exception, url_for, 'entries', method='DELETE')
+
+ eq_('/entries/1', url_for('entry', id=1, method='GET'))
+ eq_('/entries/1', url_for('update_entry', id=1, method='PUT'))
+ eq_('/entries/1', url_for('delete_entry', id=1, method='DELETE'))
+ assert_raises(Exception, url_for, 'entry', id=1, method='POST')
+
+ eq_('/entries/new', url_for('new_entry', method='GET'))
+ assert_raises(Exception, url_for, 'new_entry', method='POST')
+
+ eq_('/entries/1/edit', url_for('edit_entry', id=1, method='GET'))
+ assert_raises(Exception, url_for, 'edit_entry', id=1, method='POST')
+
+ def test_collection(self):
+ m = Mapper()
+ c = m.collection('entries', 'entry')
+
+ eq_('/entries', url_for('entries', method='GET'))
+ eq_('/entries', url_for('create_entry', method='POST'))
+ assert_raises(Exception, url_for, 'entries', method='DELETE')
+
+ eq_('/entries/1', url_for('entry', id=1, method='GET'))
+ eq_('/entries/1', url_for('update_entry', id=1, method='PUT'))
+ eq_('/entries/1', url_for('delete_entry', id=1, method='DELETE'))
+ assert_raises(Exception, url_for, 'entry', id=1, method='POST')
+
+ eq_('/entries/new', url_for('new_entry', method='GET'))
+ assert_raises(Exception, url_for, 'new_entry', method='POST')
+
+ eq_('/entries/1/edit', url_for('edit_entry', id=1, method='GET'))
+ assert_raises(Exception, url_for, 'edit_entry', id=1, method='POST')
+
+
+if __name__ == '__main__':
+ unittest.main()