diff --git a/src/milla/__init__.py b/src/milla/__init__.py index e7beb37..307ad66 100644 --- a/src/milla/__init__.py +++ b/src/milla/__init__.py @@ -1,3 +1,5 @@ '''Milla is an extremely simple WSGI framework for web applications -''' \ No newline at end of file +''' +from app import * +from webob.exc import * \ No newline at end of file diff --git a/src/milla/app.py b/src/milla/app.py new file mode 100644 index 0000000..409dd46 --- /dev/null +++ b/src/milla/app.py @@ -0,0 +1,54 @@ +'''Module milla.app + +Please give me a docstring! + +:Created: Mar 26, 2011 +:Author: dustin +:Updated: $Date$ +:Updater: $Author$ +''' +import milla.dispatch.traversal +from webob.exc import HTTPNotFound, WSGIHTTPException +import webob + +class Application(object): + '''Represents a Milla web application + + Constructing an ``Application`` instance needs a dispatcher, or + alternatively, a root object that will be passed to a new + :py:class:``milla.dispatch.traversal.Traverser`. + + :param root: A root object, passed to a traverser, which is + automatically created if a root is given + :param dispatcher: An object implementing the dispatcher protocol + + ``Application`` instances are WSGI applications + ''' + + def __init__(self, root=None, dispatcher=None): + if not dispatcher: + if root: + self.dispatcher = milla.dispatch.traversal.Traverser(root) + else: + raise ValueError('Must specify either a root object or a ' + 'dispatcher') + else: + self.dispatcher = dispatcher + + def __call__(self, environ, start_response): + try: + func = self.dispatcher.resolve(environ['PATH_INFO']) + except milla.dispatch.UnresolvedPath: + return HTTPNotFound()(environ, start_response) + + request = webob.Request(environ) + try: + response = func(request) + except WSGIHTTPException as e: + return e(environ, start_response) + + if isinstance(response, basestring): + response = webob.Response(response) + + start_response(response.status, response.headerlist) + return response.app_iter diff --git a/src/milla/controller.py b/src/milla/controller.py deleted file mode 100644 index 66d2da1..0000000 --- a/src/milla/controller.py +++ /dev/null @@ -1,28 +0,0 @@ -'''Module milla.controller - -Please give me a docstring! - -:Created: Mar 13, 2011 -:Author: dustin -:Updated: $Date$ -:Updater: $Author$ -''' -import webob -import webob.exc - -class Controller(object): - - def __call__(self, environ, start_response): - request = webob.Request(environ) - try: - response = getattr(self, request.method)(request, **request.urlvars) - except AttributeError: - return webob.exc.HTTPMethodNotAllowed()(environ, start_response) - except webob.exc.WSGIHTTPException as e: - return e(environ, start_response) - - if isinstance(response, basestring): - response = webob.Response(response) - - start_response(response.status, response.headerlist) - return response.app_iter diff --git a/src/milla/dispatch/__init__.py b/src/milla/dispatch/__init__.py new file mode 100644 index 0000000..53ee6ad --- /dev/null +++ b/src/milla/dispatch/__init__.py @@ -0,0 +1,2 @@ +class UnresolvedPath(Exception): + '''Raised when a path cannot be resolved into a handler''' diff --git a/src/milla/dispatch/routing.py b/src/milla/dispatch/routing.py new file mode 100644 index 0000000..1927bff --- /dev/null +++ b/src/milla/dispatch/routing.py @@ -0,0 +1,164 @@ +'''URL router + +:Created: Mar 13, 2011 +:Author: dustin +:Updated: $Date$ +:Updater: $Author$ +''' +from milla.dispatch import UnresolvedPath +import functools +import re +import sys +import urllib + +class Router(object): + '''A dispatcher that maps arbitrary paths to controller callables + + Typical usage:: + + router = Router() + router.add_route('/foo/{bar}/{baz:\d+}', some_func) + app = milla.Application(dispatcher=router) + ''' + + #: Compiled regular expression for variable segments + template_re = re.compile(r'\{(\w+)(?::([^}]+))?\}') + + def __init__(self): + self.routes = [] + self._cache = {} + + def resolve(self, path_info): + '''Find a controller for a given path + + :param path_info: Path for which to locate a controller + :returns: A :py:class:`functools.partial` instance that sets + the values collected from variable segments as keyword + arguments to the callable + + This method walks through the routing table created with calls + to :py:meth:`add_route` and finds the first whose template + matches the given path. Variable segments are added as keywords + to the controller function. + ''' + try: + return self._cache[path_info] + except KeyError: + for regex, controller, vars in self.routes: + match = regex.match(path_info) + if match: + urlvars = match.groupdict() + urlvars.update(vars) + func = functools.partial(controller, **urlvars) + func.__name__ = controller.__name__ + func.__doc__ = controller.__doc__ + self._cache[path_info] = func + return func + raise UnresolvedPath + + def _compile_template(self, template): + '''Compiles a template into a real regular expression + + :param template: A route template string + + Converts the ``{name}`` or ``{name:regexp}`` syntax into a full + regular expression for later parsing. + ''' + + regex = '' + last_pos = 0 + for match in self.template_re.finditer(template): + regex += re.escape(template[last_pos:match.start()]) + var_name = match.group(1) + expr = match.group(2) or '[^/]+' + expr = '(?P<%s>%s)' % (var_name, expr) + regex += expr + last_pos = match.end() + regex += re.escape(template[last_pos:]) + regex = '^%s$' % regex + return re.compile(regex) + + def _import_controller(self, name): + '''Resolves a string Python path to a callable''' + + module_name, func_name = name.split(':', 1) + __import__(module_name) + module = sys.modules[module_name] + func = getattr(module, func_name) + return func + + def add_route(self, template, controller, **vars): + '''Add a route to the routing table + + :param template: Route template string + :param controller: Controller callable or string Python path + + Route template strings are path segments, beginning with ``/``. + Paths can also contain variable segments, delimited with curly + braces. + + Example:: + + /some/other/{variable}/{path} + + By default, variable segments will match any character except a + ``/``. Alternate expressions can be passed by specifying them + alongside the name, separated by a ``:``. + + Example:: + + /some/other/{alternate:[a-zA-Z]} + + Variable path segments will be passed as keywords to the + controller. In the first example above, assuming ``controller`` + is the name of the callable passed, and the request path was + ``/some/other/great/place``:: + + controller(request, variable='great', path='place') + + The ``controller`` argument itself can be any callable that + accepts a *WebOb* request as its first argument, and any + keywords that may be passed from variable segments. It can + also be a string Python path to such a callable. For example:: + + `some.module:function` + + This string will resolve to the function ``function`` in the + module ``some.module``. + ''' + + if isinstance(controller, basestring): + controller = self._import_controller(controller) + self.routes.append((self._compile_template(template), + controller, vars)) + +class Generator(object): + '''URL generator + + Creates URL references based on a *WebOb* request. + + Typical usage: + + >>> generator = Generator(request) + >>> generator.generate('foo', 'bar') + '/foo/bar' + + A common pattern is to wrap this in a stub function:: + + url = Generator(request).generate + ''' + + def __init__(self, request): + self.request = request + + def generate(self, *segments, **vars): + '''Combines segments and the application's URL into a new URL + ''' + + base_url = self.request.application_url + path = '/'.join(str(s) for s in segments) + if not path.startswith('/'): + path = '/' + path + if vars: + path += '?' + urllib.urlencode(vars) + return base_url + path diff --git a/src/milla/dispatch/traversal.py b/src/milla/dispatch/traversal.py new file mode 100644 index 0000000..63aab6c --- /dev/null +++ b/src/milla/dispatch/traversal.py @@ -0,0 +1,60 @@ +'''URL Dispatching + +:Created: Mar 26, 2011 +:Author: dustin +:Updated: $Date$ +:Updater: $Author$ +''' +from milla.dispatch import UnresolvedPath + +class Traverser(object): + '''Default URL dispatcher + + :param root: The root object at which lookup will begin + + The default URL dispatcher uses object attribute traversal to + locate a handler for a given path. For example, consider the + following class:: + + class Root(object): + + def foo(self): + return 'Hello, world!' + + The path ``/foo`` would resolve to the ``foo`` method of the + ``Root`` class. + + If a path cannot be resolved, :py:exc:`UnresolvedPath` will be + raised. + ''' + + def __init__(self, root): + self.root = root + + def resolve(self, path_info): + '''Find a handler given a path + + :param path_info: Path for which to find a handler + :returns: A handler callable + ''' + + def walk_path(handler, parts): + if not parts or not parts[0]: + # No more parts, or the last part is blank, we're done + return handler + try: + return walk_path(getattr(handler, parts[0]), parts[1:]) + except AttributeError: + # The handler doesn't have an attribute with the current + # segment value, try the default + try: + return handler.default + except AttributeError: + # No default either, can't resolve + raise UnresolvedPath + + # Strip the leading slash and split the path + split_path = path_info.lstrip('/').split('/') + + handler = walk_path(self.root, split_path) + return handler \ No newline at end of file diff --git a/src/milla/routing.py b/src/milla/routing.py deleted file mode 100644 index 77daa9f..0000000 --- a/src/milla/routing.py +++ /dev/null @@ -1,71 +0,0 @@ -'''URL router - -TODO: Document me! - -:Created: Mar 13, 2011 -:Author: dustin -:Updated: $Date$ -:Updater: $Author$ -''' -import re -import sys -import urllib -import webob - -class Router(object): - - template_re = re.compile(r'\{(\w+)(?::([^}]+))?\}') - - def __init__(self): - self.routes = [] - - def __call__(self, environ, start_response): - request = webob.Request(environ) - for regex, controller, vars in self.routes: - match = regex.match(request.path_info) - if match: - request.urlvars = match.groupdict() - request.urlvars.update(vars) - return controller(environ, start_response) - return webob.exc.HTTPNotFound()(environ, start_response) - - def _compile_template(self, template): - regex = '' - last_pos = 0 - for match in self.template_re.finditer(template): - regex += re.escape(template[last_pos:match.start()]) - var_name = match.group(1) - expr = match.group(2) or '[^/]+' - expr = '(?P<%s>%s)' % (var_name, expr) - regex += expr - last_pos = match.end() - regex += re.escape(template[last_pos:]) - regex = '^%s$' % regex - return re.compile(regex) - - def _import_controller(self, name): - module_name, func_name = name.split(':', 1) - __import__(module_name) - module = sys.modules[module_name] - func = getattr(module, func_name) - return func - - def add_route(self, template, controller, **vars): - if isinstance(controller, basestring): - controller = self._import_controller(controller) - self.routes.append((self._compile_template(template), - controller, vars)) - -class Generator(object): - - def __init__(self, request): - self.request = request - - def generate(self, *segments, **vars): - base_url = self.request.application_url - path = '/'.join(str(s) for s in segments) - if not path.startswith('/'): - path = '/' + path - if vars: - path += '?' + urllib.urlencode(vars) - return base_url + path diff --git a/src/milla/tests/__init__.py b/src/milla/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/milla/tests/test_routing.py b/src/milla/tests/test_routing.py new file mode 100644 index 0000000..8c9597c --- /dev/null +++ b/src/milla/tests/test_routing.py @@ -0,0 +1,121 @@ +'''Tests for the routing URL dispatcher + +:Created: Mar 26, 2011 +:Author: dustin +:Updated: $Date$ +:Updater: $Author$ +''' +import milla.dispatch.routing + +def fake_controller(): + pass + +def test_static(): + '''Ensure the dispatcher can resolve a static path + + Given the path ``/foo/bar/baz`` and a route for the exact same + path, the resolver should return the controller mapped to the + route. + ''' + + def controller(): + pass + + router = milla.dispatch.routing.Router() + router.add_route('/foo/bar/baz', controller) + func = router.resolve('/foo/bar/baz') + assert func.func == controller + +def test_urlvars(): + '''Ensure the dispatcher can resolve a path with variable segments + + Given the path ``/foo/abc/def`` and a route ``/foo/{bar}/{baz}``, + the resolver should return the controller mapped to the route with + preset keywords ``bar='abc', baz='def'``. + ''' + + def controller(): + pass + + router = milla.dispatch.routing.Router() + router.add_route('/foo/{bar}/{baz}', controller) + func = router.resolve('/foo/abc/def') + assert func.func == controller + assert func.keywords['bar'] == 'abc' + assert func.keywords['baz'] == 'def' + +def test_regexp_urlvar(): + '''Ensure the dispatcher can resolve alternate regexps in urlvars + + Given a route ``/test/{arg:[a-z]+}``, the resolver should return + the mapped controller for the path ``/test/abcde``, but not the + path ``/test/1234``. + ''' + + def controller(): + pass + + router = milla.dispatch.routing.Router() + router.add_route('/test/{arg:[a-z]+}', controller) + func = router.resolve('/test/abcde') + assert func.func == controller + assert func.keywords['arg'] == 'abcde' + + try: + func = router.resolve('/test/1234') + except milla.dispatch.UnresolvedPath: + pass + else: + raise AssertionError + +def test_unresolved(): + '''Ensure the resolver raises an exception for unresolved paths + + Given a route ``/test``, the resolver should raise + :py:exc:`~milla.dispatch.UnresolvedPath` for the path ``/tset``. + ''' + + def controller(): + pass + + router = milla.dispatch.routing.Router() + router.add_route('/test', controller) + try: + router.resolve('/tset') + except milla.dispatch.UnresolvedPath: + pass + else: + raise AssertionError + +def test_unrelated(): + '''Ensure the dispatcher is not confused by unrelated paths + + Given routes for ``/testA`` and ``/testB``, the resolver should + return the controller mapped to the former for the path ``/testA``, + without regard for the latter. + ''' + + def controller_a(): + pass + + def controller_b(): + pass + + router = milla.dispatch.routing.Router() + router.add_route('/testA', controller_a) + router.add_route('/testB', controller_b) + func = router.resolve('/testA') + assert func.func == controller_a + +def test_string_controller(): + '''Ensure the dispatcher can find a controller given a string + + Given a string path to a controller function, the callable defined + therein should be returned by the resolver for the corresponding + path. + ''' + + router = milla.dispatch.routing.Router() + router.add_route('/test', 'milla.tests.test_routing:fake_controller') + func = router.resolve('/test') + assert func.func == fake_controller diff --git a/src/milla/tests/test_traversal.py b/src/milla/tests/test_traversal.py new file mode 100644 index 0000000..ab53eb8 --- /dev/null +++ b/src/milla/tests/test_traversal.py @@ -0,0 +1,166 @@ +'''Unit tests for the URL dispatcher + +:Created: Mar 26, 2011 +:Author: dustin +:Updated: $Date$ +:Updater: $Author$ +''' +import milla.dispatch.traversal + +def test_root(): + '''Ensure the root path resolves to the root handler + + Given the path ``/``, the resolver should return the root handler, + which was given to it at initialization + ''' + + class Root(object): + pass + + root = Root() + dispatcher = milla.dispatch.traversal.Traverser(root) + func = dispatcher.resolve('/') + assert func == root + +def test_unrelated(): + '''Ensure unrelated attributes do not confuse the dispatcher + + Given the path ``/`` and a root handler with attributes and + methods, the resolver should still return the root handler + ''' + + class Root(object): + def test(self): + pass + foo = 'bar' + + root = Root() + dispatcher = milla.dispatch.traversal.Traverser(root) + func = dispatcher.resolve('/') + assert func == root + +def test_unresolved(): + '''Ensure that the resolver returns remaining parts + + Given the path ``/foo/bar/baz`` and a root handler with no + children, the resolver should raise + :py:exc:`~milla.dispatch.UnresolvedPath` + ''' + + class Root(object): + pass + + root = Root() + dispatcher = milla.dispatch.traversal.Traverser(root) + try: + dispatcher.resolve('/foo/bar/baz') + except milla.dispatch.UnresolvedPath: + pass + else: + raise AssertionError + +def test_method(): + '''Ensure the resolver finds an instance method handler + + Given the path ``/test`` and a root handler with an instance + method named ``test``, the resolver should return that method. + ''' + + class Root(object): + def test(self): + pass + + root = Root() + dispatcher = milla.dispatch.traversal.Traverser(root) + func = dispatcher.resolve('/test') + assert func == root.test + +def test_nested_class(): + '''Ensure the resolver finds a nested class handler + + Given the path ``/test`` and a root handler with an inner class + named ``test``, the resolver should return the inner class. + ''' + + class Root(object): + class test(object): + pass + + root = Root() + dispatcher = milla.dispatch.traversal.Traverser(root) + func = dispatcher.resolve('/test') + assert func == root.test + +def test_nested_class_method(): + '''Ensure the resolver finds an instance method of a nested class + + Given the path ``/test/test`` and a root handler with an inner + class named ``test``, which in turn has an instance method named + ``test``, the resolver should return the ``test`` method of the + inner class. + ''' + + class Root(object): + class test(object): + def test(self): + pass + + root = Root() + dispatcher = milla.dispatch.traversal.Traverser(root) + func = dispatcher.resolve('/test/test') + assert func == root.test.test + +def test_attribute(): + '''Ensure the resolver finds a handler in an instance attribute + + Given the path ``/test`` and a root handler with an attribute named + ``test`` containing another class, the resolver should return that + class. + ''' + + class Test(object): + pass + class Root(object): + test = Test() + + root = Root() + dispatcher = milla.dispatch.traversal.Traverser(root) + func = dispatcher.resolve('/test') + assert func == Root.test + +def test_default(): + '''Ensure the resolver finds the default handler + + Given the path ``/test`` and a root handler with a method named + ``default``, but no method named ``test``, the resolver should + return the ``default`` method. + ''' + + class Root(object): + def default(self): + pass + + root = Root() + dispatcher = milla.dispatch.traversal.Traverser(root) + func = dispatcher.resolve('/test') + assert func == root.default + +def test_nested_default(): + '''Ensure the resolver finds a nested default handler + + Given the path ``/test/bar`` and a root handler with a ``test`` + attribute containing a class instance with a ``default`` method but + no ``bar`` method, the resolver should return the ``default`` + of the nested instance. + ''' + + class Test(object): + def default(self): + pass + class Root(object): + test = Test() + + root = Root() + dispatcher = milla.dispatch.traversal.Traverser(root) + func = dispatcher.resolve('/test/bar') + assert func == root.test.default