Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Merge pull request #1 from yeeland/master

add_resource
  • Loading branch information...
commit 9402837cad87a48b30781f533bd7121ba5a20077 2 parents 92c8eb3 + 033c05b
@bbangert bbangert authored
Showing with 528 additions and 1 deletion.
  1. +287 −1 pyramid_routehelper/__init__.py
  2. +241 −0 pyramid_routehelper/tests.py
View
288 pyramid_routehelper/__init__.py
@@ -1 +1,287 @@
-#
+from pyramid.config import ConfigurationError
+import inspect
+
+__all__ = ['includeme', 'add_resource', 'action']
+
+def includeme(config):
+ config.add_directive('add_resource', add_resource)
+
+def strip_slashes(name):
+ """Remove slashes from the beginning and end of a part/URL."""
+ if name.startswith('/'):
+ name = name[1:]
+ if name.endswith('/'):
+ name = name[:-1]
+ return name
+
+class action(object):
+ """Decorate a method for registration by
+ :func:`~pyramid_routehelper.add_resource`.
+
+ Keyword arguments are identical to :class:`~pyramid.view.view_config`, with
+ the exception to how the ``name`` argument is used.
+
+ ``alt_for``
+ Designate a method as another view for the specified action if
+ the decorated method is not the desired action name instead of registering
+ the method with an action of the same name.
+
+ ``format``
+ Specify a format for the view that this decorator describes.
+ """
+ def __init__(self, **kw):
+ self.kw = kw
+
+ def __call__(self, wrapped):
+ if hasattr(wrapped, '__exposed__'):
+ wrapped.__exposed__.append(self.kw)
+ else:
+ wrapped.__exposed__ = [self.kw]
+ return wrapped
+
+# map.resource port
+def add_resource(self, handler, member_name, collection_name, **kwargs):
+ """ Add some RESTful routes for a resource handler.
+
+ This function should never be called directly; instead the
+ ``pyramid_routehelper.includeme`` function should be used to include this
+ function into an application; the function will thereafter be available
+ as a method of the resulting configurator.
+
+ The concept of a web resource maps somewhat directly to 'CRUD'
+ operations. The overlying things to keep in mind is that
+ adding a resource handler is about handling creating, viewing, and
+ editing that resource.
+
+ ``handler`` is a dotted name of (or direct reference to) a
+ Python handler class,
+ e.g. ``'my.package.handlers.MyHandler'``.
+
+ ``member_name`` should be the appropriate singular version of the resource
+ given your locale and used with members of the collection.
+
+ ``collection_name`` will be used to refer to the resource collection methods
+ and should be a plural version of the member_name argument.
+
+ All keyword arguments are optional.
+
+ ``collection``
+ Additional action mappings used to manipulate/view the
+ entire set of resources provided by the handler.
+
+ Example::
+
+ config.add_resource('myproject.handlers:MessageHandler', 'message', 'messages', collection={'rss':'GET'})
+ # GET /messages/rss (maps to the rss action)
+ # also adds named route "rss_message"
+
+ ``member``
+ Additional action mappings used to access an individual
+ 'member' of this handler's resources.
+
+ Example::
+
+ config.add_resource('myproject.handlers:MessageHandler', 'message', 'messages', member={'mark':'POST'})
+ # POST /messages/1/mark (maps to the mark action)
+ # also adds named route "mark_message"
+
+ ``new``
+ Action mappings that involve dealing with a new member in
+ the controller resources.
+
+ Example::
+
+ config.add_resource('myproject.handlers:MessageHandler', 'message', 'messages', new={'preview':'POST'})
+ # POST /messages/new/preview (maps to the preview action)
+ # also adds a url named "preview_new_message"
+
+ ``path_prefix``
+ Prepends the URL path for the Route with the path_prefix
+ given. This is most useful for cases where you want to mix
+ resources or relations between resources.
+
+ ``name_prefix``
+ Perpends the route names that are generated with the
+ name_prefix given. Combined with the path_prefix option,
+ it's easy to generate route names and paths that represent
+ resources that are in relations.
+
+ Example::
+
+ config.add_resource('myproject.handlers:CategoryHandler', 'message', 'messages',
+ path_prefix='/category/:category_id',
+ name_prefix="category_")
+ # GET /category/7/messages/1
+ # has named route "category_message"
+
+ ``parent_resource``
+ A ``dict`` containing information about the parent
+ resource, for creating a nested resource. It should contain
+ the ``member_name`` and ``collection_name`` of the parent
+ resource.
+
+ If ``parent_resource`` is supplied and ``path_prefix``
+ isn't, ``path_prefix`` will be generated from
+ ``parent_resource`` as
+ "<parent collection name>/:<parent member name>_id".
+
+ If ``parent_resource`` is supplied and ``name_prefix``
+ isn't, ``name_prefix`` will be generated from
+ ``parent_resource`` as "<parent member name>_".
+
+ Example::
+
+ >>> from pyramid.url import route_path
+ >>> config.add_resource('myproject.handlers:LocationHandler', 'location', 'locations',
+ ... parent_resource=dict(member_name='region',
+ ... collection_name='regions'))
+ >>> # path_prefix is "regions/:region_id"
+ >>> # name prefix is "region_"
+ >>> route_path('region_locations', region_id=13)
+ '/regions/13/locations'
+ >>> route_path('region_new_location', region_id=13)
+ '/regions/13/locations/new'
+ >>> route_path('region_location', region_id=13, id=60)
+ '/regions/13/locations/60'
+ >>> route_path('region_edit_location', region_id=13, id=60)
+ '/regions/13/locations/60/edit'
+
+ Overriding generated ``path_prefix``::
+
+ >>> config.add_resource('myproject.handlers:LocationHandler', 'location', 'locations',
+ ... parent_resource=dict(member_name='region',
+ ... collection_name='regions'),
+ ... path_prefix='areas/:area_id')
+ >>> # name prefix is "region_"
+ >>> route_path('region_locations', area_id=51)
+ '/areas/51/locations'
+
+ Overriding generated ``name_prefix``::
+
+ >>> config.add_resource('myproject.handlers:LocationHandler', 'location', 'locations',
+ ... parent_resource=dict(member_name='region',
+ ... collection_name='regions'),
+ ... name_prefix='')
+ >>> # path_prefix is "regions/:region_id"
+ >>> route_path('locations', region_id=51)
+ '/regions/51/locations'
+ """
+ handler = self.maybe_dotted(handler)
+
+ action_kwargs = {}
+ for name,meth in inspect.getmembers(handler, inspect.ismethod):
+ if hasattr(meth, '__exposed__'):
+ for settings in meth.__exposed__:
+ config_settings = settings.copy()
+ action_name = config_settings.pop('alt_for', name)
+
+ # If format is not set, use the route that doesn't specify a format
+ if 'format' not in config_settings:
+ if 'default' in action_kwargs.get(action_name,{}):
+ raise ConfigurationError("Two methods have been decorated without specifying a format.")
+ else:
+ action_kwargs.setdefault(action_name, {})['default'] = config_settings
+ # Otherwise, append to the list of view config settings for formatted views
+ else:
+ config_settings['attr'] = name
+ action_kwargs.setdefault(action_name, {}).setdefault('formatted',[]).append(config_settings)
+
+ collection = kwargs.pop('collection', {})
+ member = kwargs.pop('member', {})
+ new = kwargs.pop('new', {})
+ path_prefix = kwargs.pop('path_prefix', None)
+ name_prefix = kwargs.pop('name_prefix', None)
+ parent_resource = kwargs.pop('parent_resource', None)
+
+ if parent_resource is not None:
+ if path_prefix is None:
+ path_prefix = '%s/:%s_id' % (parent_resource['collection_name'], parent_resource['member_name'])
+ if name_prefix is None:
+ name_prefix = '%s_' % parent_resource['member_name']
+ else:
+ if path_prefix is None: path_prefix = ''
+ if name_prefix is None: name_prefix = ''
+
+ member['edit'] = 'GET'
+ new['new'] = 'GET'
+
+ def swap(dct, newdct):
+ map(lambda (key,value): newdct.setdefault(value.upper(), []).append(key), dct.items())
+ return newdct
+
+ collection_methods = swap(collection, {})
+ member_methods = swap(member, {})
+ new_methods = swap(new, {})
+
+ collection_methods.setdefault('POST', []).insert(0, 'create')
+ member_methods.setdefault('PUT', []).insert(0, 'update')
+ member_methods.setdefault('DELETE', []).insert(0, 'delete')
+
+ # Continue porting code
+ controller = strip_slashes(collection_name)
+ path_prefix = strip_slashes(path_prefix)
+ path_prefix = '/' + path_prefix
+ if path_prefix and path_prefix != '/':
+ path = path_prefix + '/' + controller
+ else:
+ path = '/' + controller
+ collection_path = path
+ new_path = path + '/new'
+ member_path = path + '/:id'
+
+ def add_route_and_view(self, action, route_name, path, request_method='any'):
+ if request_method != 'any':
+ request_method = request_method.upper()
+ else:
+ request_method = None
+
+ self.add_route(route_name, path, **kwargs)
+ self.add_view(view=handler, attr=action, route_name=route_name, request_method=request_method, **action_kwargs.get(action, {}).get('default', {}))
+
+ for format_kwargs in action_kwargs.get(action, {}).get('formatted', []):
+ format = format_kwargs.pop('format')
+ self.add_route("%s_formatted_%s" % (format, route_name),
+ "%s.%s" % (path, format), **kwargs)
+ self.add_view(view=handler, attr=format_kwargs.pop('attr'), request_method=request_method,
+ route_name = "%s_formatted_%s" % (format, route_name), **format_kwargs)
+
+ for method, lst in collection_methods.iteritems():
+ primary = (method != 'GET' and lst.pop(0)) or None
+ for action in lst:
+ add_route_and_view(self, action, "%s%s_%s" % (name_prefix, action, collection_name), "%s/%s" % (collection_path,action))
+
+ if primary:
+ add_route_and_view(self, primary, name_prefix + collection_name, collection_path, method)
+
+ # Add route and view for collection
+ add_route_and_view(self, 'index', name_prefix + collection_name, collection_path, 'GET')
+
+ for method, lst in new_methods.iteritems():
+ for action in lst:
+ path = (action == 'new' and new_path) or "%s/%s" % (new_path, action)
+ name = "new_" + member_name
+ if action != 'new':
+ name = action + "_" + name
+ formatted_path = (action == 'new' and new_path + '.:format') or "%s/%s.:format" % (new_path, action)
+ add_route_and_view(self, action, name_prefix + name, path, method)
+
+ for method, lst in member_methods.iteritems():
+ if method not in ['POST', 'GET', 'any']:
+ primary = lst.pop(0)
+ else:
+ primary = None
+ for action in lst:
+ add_route_and_view(self, action, '%s%s_%s' % (name_prefix, action, member_name), '%s/%s' % (member_path, action))
+
+ if primary:
+ add_route_and_view(self, primary, name_prefix + member_name, member_path, method)
+
+ add_route_and_view(self, 'show', name_prefix + member_name, member_path, method)
+
+# Submapper support
+
+
+# Sub_domain option
+
+
+# Converters??
View
241 pyramid_routehelper/tests.py
@@ -0,0 +1,241 @@
+import unittest
+from pyramid import testing
+from pyramid.config import Configurator
+from pyramid_routehelper import includeme, add_resource, action, ConfigurationError
+from pyramid.url import route_path
+
+
+class TestResourceGeneration_add_resource(unittest.TestCase):
+ def _create_config(self, autocommit=True):
+ config = Configurator(autocommit=autocommit)
+ includeme(config)
+ return config
+
+ def setUp(self):
+ self.config = self._create_config()
+ self.config.begin()
+
+ def tearDown(self):
+ self.config.end()
+ del self.config
+
+ def test_basic_resources(self):
+ self.config.add_resource('pyramid_routehelper.tests:DummyCrudHandler', 'message', 'messages')
+
+ assert route_path('messages', testing.DummyRequest()) == '/messages'
+ assert route_path('json_formatted_messages', testing.DummyRequest()) == '/messages.json'
+ assert route_path('new_message', testing.DummyRequest()) == '/messages/new'
+ assert route_path('json_formatted_new_message', testing.DummyRequest()) == '/messages/new.json'
+
+ assert route_path('json_formatted_message', testing.DummyRequest(), id=1) == '/messages/1.json'
+ assert route_path('message', testing.DummyRequest(), id=1) == '/messages/1'
+ assert route_path('json_formatted_edit_message', testing.DummyRequest(), id=1) == '/messages/1/edit.json'
+ assert route_path('edit_message', testing.DummyRequest(), id=1) == '/messages/1/edit'
+
+ def test_resources_with_path_prefix(self):
+ self.config.add_resource('pyramid_routehelper.tests:DummyCrudHandler', 'message', 'messages', path_prefix='/category/:category_id')
+
+ assert route_path('messages', testing.DummyRequest(), category_id=2) == '/category/2/messages'
+ assert route_path('json_formatted_messages', testing.DummyRequest(), category_id=2) == '/category/2/messages.json'
+ assert route_path('new_message', testing.DummyRequest(), category_id=2) == '/category/2/messages/new'
+ assert route_path('json_formatted_new_message', testing.DummyRequest(), category_id=2) == '/category/2/messages/new.json'
+
+ assert route_path('json_formatted_message', testing.DummyRequest(), id=1, category_id=2) == '/category/2/messages/1.json'
+ assert route_path('message', testing.DummyRequest(), id=1, category_id=2) == '/category/2/messages/1'
+ assert route_path('json_formatted_edit_message', testing.DummyRequest(), id=1, category_id=2) == '/category/2/messages/1/edit.json'
+ assert route_path('edit_message', testing.DummyRequest(), id=1, category_id=2) == '/category/2/messages/1/edit'
+
+ def test_resources_with_path_prefix_with_trailing_slash(self):
+ self.config.add_resource('pyramid_routehelper.tests:DummyCrudHandler', 'message', 'messages', path_prefix='/category/:category_id/')
+
+ assert route_path('messages', testing.DummyRequest(), category_id=2) == '/category/2/messages'
+ assert route_path('json_formatted_messages', testing.DummyRequest(), category_id=2) == '/category/2/messages.json'
+ assert route_path('new_message', testing.DummyRequest(), category_id=2) == '/category/2/messages/new'
+ assert route_path('json_formatted_new_message', testing.DummyRequest(), category_id=2) == '/category/2/messages/new.json'
+
+ assert route_path('json_formatted_message', testing.DummyRequest(), id=1, category_id=2) == '/category/2/messages/1.json'
+ assert route_path('message', testing.DummyRequest(), id=1, category_id=2) == '/category/2/messages/1'
+ assert route_path('json_formatted_edit_message', testing.DummyRequest(), id=1, category_id=2) == '/category/2/messages/1/edit.json'
+ assert route_path('edit_message', testing.DummyRequest(), id=1, category_id=2) == '/category/2/messages/1/edit'
+
+ def test_resources_with_collection_action(self):
+ self.config.add_resource('pyramid_routehelper.tests:DummyCrudHandler', 'message', 'messages', collection=dict(sorted='GET'))
+
+ assert route_path('sorted_messages', testing.DummyRequest()) == '/messages/sorted'
+
+ def test_resources_with_member_action(self):
+ self.config.add_resource('pyramid_routehelper.tests:DummyCrudHandler', 'message', 'messages', member=dict(comment='GET'))
+
+ assert route_path('comment_message', testing.DummyRequest(), id=1) == '/messages/1/comment'
+
+ def test_resources_with_new_action(self):
+ self.config.add_resource('pyramid_routehelper.tests:DummyCrudHandler', 'message', 'messages', new=dict(preview='GET'))
+
+ assert route_path('preview_new_message', testing.DummyRequest(), id=1) == '/messages/new/preview'
+
+ def test_resources_with_name_prefix(self):
+ self.config.add_resource('pyramid_routehelper.tests:DummyCrudHandler', 'message', 'messages', name_prefix="special_")
+
+ assert route_path('special_message', testing.DummyRequest(), id=1) == '/messages/1'
+
+ def test_resources_with_parent_resource(self):
+ self.config.add_resource('pyramid_routehelper.tests:DummyCrudHandler',
+ 'message', 'messages',
+ parent_resource = dict(member_name='category', collection_name='categories'))
+
+ assert route_path('category_messages', testing.DummyRequest(), category_id=2) == '/categories/2/messages'
+ assert route_path('category_message', testing.DummyRequest(), category_id=2, id=1) == '/categories/2/messages/1'
+
+ def test_resources_with_parent_resource_override_path_prefix(self):
+ self.config.add_resource('pyramid_routehelper.tests:DummyCrudHandler',
+ 'message', 'messages',
+ parent_resource = dict(member_name='category', collection_name='categories'),
+ path_prefix = 'folders/:folder_id')
+
+ assert route_path('category_messages', testing.DummyRequest(), folder_id=2) == '/folders/2/messages'
+ assert route_path('category_message', testing.DummyRequest(), folder_id=2, id=1) == '/folders/2/messages/1'
+
+ def test_resources_with_parent_resource_override_name_prefix(self):
+ self.config.add_resource('pyramid_routehelper.tests:DummyCrudHandler',
+ 'message', 'messages',
+ parent_resource = dict(member_name='category', collection_name='categories'),
+ name_prefix = '')
+
+ assert route_path('messages', testing.DummyRequest(), category_id=2) == '/categories/2/messages'
+ assert route_path('message', testing.DummyRequest(), category_id=2, id=1) == '/categories/2/messages/1'
+
+ def test_resources_with_double_default_views(self):
+ class MessedUpHandler(object):
+ @action(renderer='json')
+ @action(renderer='template.mak')
+ def index(self):
+ return {}
+
+ try:
+ self.config.add_resource(MessedUpHandler, 'message', 'messages')
+ except ConfigurationError, e:
+ assert str(e) == "Two methods have been decorated without specifying a format."
+
+class TestResourceRecognition(unittest.TestCase):
+ def _create_config(self, autocommit=True):
+ config = Configurator(autocommit=autocommit)
+ includeme(config)
+ return config
+
+ def setUp(self):
+ self.config = self._create_config()
+ self.config.add_resource('pyramid_routehelper.tests:DummyCrudHandler', 'message', 'messages')
+ self.config.begin()
+
+ self.wsgi_app = self.config.make_wsgi_app()
+
+ self.collection_path = '/messages'
+ self.collection_name = 'messages'
+ self.member_path = '/messages/:id'
+ self.member_name = 'message'
+
+ def tearDown(self):
+ self.config.end()
+ del self.config
+
+ def _get(self, path):
+ return self._makeRequest(path, 'GET')
+
+ def _post(self, path):
+ return self._makeRequest(path, 'POST')
+
+ def _put(self, path):
+ return self._makeRequest(path, 'PUT')
+
+ def _delete(self, path):
+ return self._makeRequest(path, 'DELETE')
+
+ def _makeRequest(self, path, request_method = 'GET'):
+ wsgi_environ = dict(
+ PATH_INFO = path,
+ REQUEST_METHOD = request_method
+ )
+ resp_body = self.wsgi_app(wsgi_environ, lambda status,headers: None)[0]
+ return resp_body
+
+ def test_get_collection(self):
+ result = self._get('/messages')
+ assert result == 'index'
+
+ def test_get_formatted_collection(self):
+ result = self._get('/messages.json')
+ assert result == '{"format": "json"}'
+
+ def test_post_collection(self):
+ result = self._post('/messages')
+ assert result == 'create'
+
+ def test_get_member(self):
+ result = self._get('/messages/1')
+ assert result == 'show'
+
+ def test_put_member(self):
+ result = self._put('/messages/1')
+ assert result == 'update'
+
+ def test_delete_member(self):
+ result = self._delete('/messages/1')
+ assert result == 'delete'
+
+ def test_new_member(self):
+ result = self._get('/messages/new')
+ assert result == 'new'
+
+ def test_edit_member(self):
+ result = self._get('/messages/1/edit')
+ assert result == 'edit'
+
+class Test_includeme(unittest.TestCase):
+ def test_includme(self):
+ config = Configurator(autocommit=True)
+ includeme(config)
+ assert config.add_resource.im_func.__docobj__ is add_resource
+
+class DummyCrudHandler(object):
+ def __init__(self, request):
+ self.request = request
+
+ @action(renderer='string')
+ def index(self):
+ return "index"
+
+ @action(alt_for='index', renderer='xml', xhr=True, format='xml')
+ @action(alt_for='index', renderer='json', format='json')
+ def api_index(self):
+ return {'format':'json'}
+
+ @action(renderer='string')
+ def create(self):
+ return "create"
+
+ @action(renderer='json', format='json')
+ @action(renderer='string')
+ def show(self):
+ return "show"
+
+ @action(renderer='string')
+ def update(self):
+ return "update"
+
+ @action(renderer='string')
+ def delete(self):
+ return "delete"
+
+ @action(renderer='json', format='json')
+ @action(renderer='string')
+ def new(self):
+ return "new"
+
+ @action(renderer='json', format='json')
+ @action(renderer='string')
+ def edit(self):
+ return "edit"
+
+ @action(renderer='string')
+ def sorted(self):
+ return "sorted"
Please sign in to comment.
Something went wrong with that request. Please try again.