diff options
author | Mike Burrows <mjb@asplake.co.uk> | 2009-12-26 14:34:56 +0000 |
---|---|---|
committer | Mike Burrows <mjb@asplake.co.uk> | 2009-12-26 14:34:56 +0000 |
commit | 301e7aadb0a13b4a53fe7196d69f3c18cdb5ff5e (patch) | |
tree | 0d3e4558350dd78da90b051407cd58d86148802a | |
parent | b0bd2d8f97720be5ec183a136297fbd2f0619ee7 (diff) | |
download | routes-301e7aadb0a13b4a53fe7196d69f3c18cdb5ff5e.tar.gz |
initial commit: nestable submappers, collection(), prettyprinter
--HG--
branch : trunk
-rw-r--r-- | README | 9 | ||||
-rw-r--r-- | routes/mapper.py | 273 | ||||
-rw-r--r-- | tests/test_functional/test_submapper.py | 153 |
3 files changed, 398 insertions, 37 deletions
@@ -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()
|