Compare commits

...

35 Commits
0.2 ... master

Author SHA1 Message Date
Dustin bd65cf01fb app: BaseApplication: Remove setup_routes method
Originally, I envisioned the `BaseApplication` class calling its
`setup_routes` method to set up the request dispatcher, eliminating the
need for subclasses to explicitly call it. This is unlikely to work,
however, as it will end up being called too early, as there will not yet
have been a chance to update the application configuration dictionary.
If any controller callables use the application configuration when they
are initialized, they will not have correct information.

As such, I decided to go ahead and remove this method and let subclasses
implement and call it when it makes sense.
2016-07-15 09:55:56 -05:00
Dustin c97d570f66 vary: Fix duplicate class name error 2016-07-15 09:55:37 -05:00
Dustin 40a0e8ead4 vary: Add framework for multi-format responses
The `milla.vary` module includes tools for handling multi-format
responses. The `VariedResponseBase` class is an abstract class that
provides a framework for calling a particular method to render the
response in a format acceptable to the requester, based on the value of
the Accept request header.
2016-07-14 12:37:48 -05:00
Dustin d955d23e91 auth: decorators: Make validate_request public
The `_validate_request` function in `milla.auth.decorators` is a helper
used by the `auth_required` and `require_perms` decorators. It can also
be used by custom decorators, so it is now a first-class, documented,
public function.
2016-07-14 12:37:48 -05:00
Dustin f3a98c2697 controllers: HTTPVerbController: Update docstring
This commit updates the docstring of the `HTTPVerbController` class to
reflect the change in the way HEAD requests are handled by Milla.
2016-07-14 12:37:48 -05:00
Dustin 807a487639 app: Map HEAD requests to GET
After much discussion, the WSGI community has mostly agreed that
applications should not treat GET and HEAD requests differently. The
HTTP specification practically demands that they are handled the same
way by applications, with the exception that responses to HEAD requests
do not send a body. When they are handled differently, there is an
unfortunate tendency to over-optimize the HEAD request, causing
discrepancies between the headers it returns and those returned by GET.
Further, WSGI middleware may need access to the response body in order
to properly manipulate the response (e.g. to rewrite links, etc.), which
could effect the response to HEAD requests as well.

This commit changes the behavior of `BaseApplication` to change the
method of all `HEAD` requests to `GET` prior to dispatching them.
`Request` objects now have a `real_method` attribute, which indicates
the original method of the request.

http://blog.dscpl.com.au/2009/10/wsgi-issues-with-http-head-requests.html
2016-07-14 12:37:48 -05:00
Dustin ff27f3a917 app: Don't check Python version for every request
Checking for the `basestring` name takes a nonzero amount of time, the
result of the check never changes. As such, is not appropriate to do
this for every request.
2016-07-14 12:37:47 -05:00
Dustin 71d00e4207 app: Make HTTP POST method emulation optional
For some applications, the HTTP method emulation for POST requests is
undesirable, particularly when the request contains a large payload
(e.g. file uploads, etc.). For these applications, this feature can be
disabled by setting the `post_method_emulation` attribute of their
application objects to `False`.
2016-07-14 12:37:47 -05:00
Dustin 83971013d0 app: Update module docstring 2016-07-14 12:37:47 -05:00
Dustin 44a28fda68 app: Add BaseApplication class
The `Application` class is now a sub-class of `BaseApplication`.
Applications that require more control of the framework's behavior can
extend `BaseApplication` instead of `Application`. The `BaseApplication`
class also provides some new features:

* `BaseApplication.setup_routes` is always called when an instance is
  created, and can be used to configure the request dispatcher
* `BaseApplication.update_config` updates the application configuration
  from a configuration file

The major difference between `BaseApplication` and `Application` is that
the latter requires a request dispatcher, while the former does not.
This pattern allows simple applications to construct a `Router` or root
object and then initialize the `Application` without needing to define a
sub-class.
2016-07-14 12:37:47 -05:00
Dustin 4090df1286 Begin 1.0 development 2016-07-14 12:37:47 -05:00
Dustin 95caf1020b Add .gitignore 2016-07-14 12:37:47 -05:00
Dustin a593bb762f Rename README for GitLab compatibility? 2016-07-06 20:58:27 -05:00
Dustin 40ede10425 doc: Update changelog for 0.3 release 2015-04-25 13:54:46 -05:00
Dustin 634c914f6d dispatch: routing: Remove Generator class 2015-04-25 13:54:42 -05:00
Dustin 3beca18523 controllers: Add HTTPVerbController
The `HTTPVerbController` base class can be used by controllers to delegate
request handling to different instance methods based on the HTTP request
method.
2015-04-25 13:48:49 -05:00
Dustin da406fcce8 app: Refactor Application class
This commit breaks up the `Application.__call__` method into smaller methods
that can be overridden by subclasses. These methods allow customization of
various steps of the request/response handling process:

* `make_request`: Create the `Request` object from the WSGI environment
  dictionary. The default implementation creates a `milla.Request` object,
  copies the application configuration to its `config` attribute, and handles
  "emulated" HTTP methods from POST data.
* `resolve_path`: Locates a controller callable from the given path info. The
  default implementation calls the `resolve` method on the application's
  `dispatcher` attribute. If `UnresolvePath` is raised, it returns a callable
  that raises `HTTPNotFound`.
* `handle_error`: Called inside the exception handler when a controller
  callable raises an exception. The method should return a callable WSGI
  application (such as a `Response` or `WSGIHTTPException` object). To access
  the exception that was raised, use the `sys.exc_info` function. The default
  implementation returns the exception if it is an instance of
  `WSGIHTTPException`, or re-raises the exception otherwise. This allows
  middleware applications to handle the exception, if desired.
2015-04-25 12:42:42 -05:00
Dustin 922a82e4e8 FaviconController: Work without pkg_resources (fixes #4)
If the `pkg_resources` module is not available, the `FaviconController` will
fall back to finding the default image in the same directory on the filesystem
as the `milla` package.
2015-04-15 20:08:50 -05:00
Dustin 94b98a0620 auth: Allow Python classes as request validators (see #4)
In addition to setuptools entry point names, the authentication subsystem now
accepts Python classes directly as the value of the `milla.request_validator`
configuration setting. This removes the dependency on setuptools for
authentication, and allows more flexibility in application configuration.
2015-04-15 19:47:24 -05:00
Dustin 86b19bb9e7 Whitespace cleanup 2015-04-15 19:45:17 -05:00
Dustin 6519cfbb9e Remove deprecated CLI tool 2015-04-15 20:11:25 -05:00
Dustin fc04300140 Bump to version 0.3 2015-04-15 20:10:47 -05:00
Dustin C. Hatch f2602388e3 Bump to version 0.2.2 2015-02-21 10:31:27 -06:00
Dustin C. Hatch 4ffb3f3707 Added tag 0.2.1 for changeset 3b8acd86b010 2015-02-21 10:29:48 -06:00
Dustin C. Hatch f972382d12 doc: Update changelog 2015-02-21 00:09:10 -06:00
Dustin C. Hatch 1018519d73 doc: Update copyright date 2015-02-19 21:28:28 -06:00
Dustin C. Hatch 0754f4b823 meta: ignore coverage cache 2015-02-19 20:39:57 -06:00
Dustin C. Hatch 230da47d5b app: Use _find_attr to locate controller's allowed_methods
Using the `milla.app.Application._find_attr` method to find the
`allowed_methods` of returned controller callables allows the attribute to be
defined e.g. on the class for instance methods, etc.
2015-02-19 20:37:39 -06:00
Dustin C. Hatch 922778ee4c test: Fix tests broken by move 2015-02-19 20:29:01 -06:00
Dustin C. Hatch 7cfb10066e Move tests outside distribution 2015-02-19 20:13:27 -06:00
Dustin C. Hatch e6774204a6 app: Return an empty string for HEAD requests
Some WSGI servers, e.g. Werkzeug, unconditionally attempt to iterate over the
application response, even for HEAD requests. If `None` is returned, then the
server will crash in this case, because it is not iterable. This commit alters
the behavior of `milla.Application` to return an empty string, which is
iterable and has the same effect of not sending a body.
2015-02-19 20:01:05 -06:00
Dustin C. Hatch c69dbed7ee routing: Correctly redirect when path info is empty (fixes #7)
When the application path info is empty (e.g. the WSGI script is mounted
somewhere other than the root), appending a trailing slash and redirecting
causes the new location to be calculated incorrectly. This is because the part
of the request URL before the application path is not taken into account, so
the new path always ends up being a literal `/`. This commit changes the
behavior of the `redir` function that is returned by
`milla.dispatch.routing.Router.resolve` to calculate the new path info
correctly in the redirect response.
2015-02-19 19:58:03 -06:00
Dustin C. Hatch cf94a4d600 app: Fixed an issue with unicode responses in Python 2.7
If a controller callable returns a string, it needs to be wrapped in a
Response object. To determine if this is the case, the Application tests to
see if the returned object is an instance of `basestring`. Since `basestring`
doesn't exist in Python 3, only `str` is a valid return type.

Unfortunately, my way of testing whether the `basestring` type existed was
flawed. Instead of raising `NameError` when it doesn't exist,
`UnboundLocalError` (a subclass `NameError`) is *always* raised. Since the
exception handler sets `basestring` equal to `str` assuming this is Python 3,
most of the time this isn't a problem. If, however, the controller returns a
`unicode` object in Python 2, the `isinstance` call returns `False`, so the
response is not wrapped in a Response object.

Rather than try to reassign the `basestring` name, now we just use `_string`,
which will either be `basestring` (in Python 2) or `str` (in Python 3).

Apparently, the unit tests didn't cover this case...
2014-02-07 23:22:50 -06:00
Dustin C. Hatch a2d8f6f098 Version bump 2013-01-22 13:02:17 -06:00
Dustin C. Hatch 8e699969e2 Added tag 0.2 for changeset 2d04d03ce334 2013-01-22 13:01:28 -06:00
26 changed files with 1081 additions and 518 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/build/
/dist/
*.egg-info/
__pycache__/
*.py[co]

View File

@ -4,3 +4,4 @@ syntax: regexp
.*\.egg-info/ .*\.egg-info/
.*\.swp .*\.swp
.*\.py[co] .*\.py[co]
^\.coverage$

View File

@ -1,2 +1,4 @@
b3553fb88649e28a7fae7c1ce348625b38d06b65 0.1 b3553fb88649e28a7fae7c1ce348625b38d06b65 0.1
e7c7497afb2137fec4445e4d04c6d7405f0fa289 0.1.2 e7c7497afb2137fec4445e4d04c6d7405f0fa289 0.1.2
2d04d03ce334502eff4e07fd36f6536ded58a2d3 0.2
3b8acd86b010ac48b99dfa95859f6522073c142a 0.2.1

View File

View File

@ -2,6 +2,28 @@
Change Log Change Log
========== ==========
0.3
===
* Removed dependency on *setuptools* (`Issue #4`_)
* Added support for classes as request validators (as opposed to entry point
names)
* Added ability to customize applications by overriding methods:
* :py:meth:`~milla.app.Application.make_request`
* :py:meth:`~milla.app.Application.resolve_path`
* :py:meth:`~milla.app.Application.handle_error`
* Added :py:class:`~milla.controllers.HTTPVerbController`
* Removed deprecated ``milla.cli``
* Removed deprecated ``milla.dispatch.routing.Generator``
0.2.1
=====
* Fixed trailing slash redirect with empty path inf (`Issue #7`_)
* Fixed a compatibility issue with some servers and ``HEAD`` responses
* Allow specifying ``allowed_methods`` on controller classes
0.2 0.2
=== ===
@ -37,3 +59,5 @@ Initial release
.. _Issue #1: https://bitbucket.org/AdmiralNemo/milla/issue/1 .. _Issue #1: https://bitbucket.org/AdmiralNemo/milla/issue/1
.. _Issue #5: https://bitbucket.org/AdmiralNemo/milla/issue/5 .. _Issue #5: https://bitbucket.org/AdmiralNemo/milla/issue/5
.. _Issue #7: https://bitbucket.org/AdmiralNemo/milla/issue/7
.. _Issue #4: https://bitbucket.org/AdmiralNemo/milla/issue/4

View File

@ -42,14 +42,14 @@ master_doc = 'index'
# General information about the project. # General information about the project.
project = u'Milla' project = u'Milla'
copyright = u'2011-2013 Dustin C. Hatch' copyright = u'2011-2015 Dustin C. Hatch'
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the # |version| and |release|, also used in various other places throughout the
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = '0.2' version = '1.0'
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = version release = version

6
doc/reference/vary.rst Normal file
View File

@ -0,0 +1,6 @@
==========
milla.vary
==========
.. automodule:: milla.vary
:members:

View File

@ -0,0 +1,2 @@
[egg_info]
tag_build = dev

View File

@ -15,7 +15,7 @@ if sys.version_info < (2, 7):
setup( setup(
name='Milla', name='Milla',
version='0.2', version='1.0',
description='Lightweight WSGI framework for web applications', description='Lightweight WSGI framework for web applications',
long_description='''\ long_description='''\
Milla is a simple WSGI framework for Python web applications. It is mostly Milla is a simple WSGI framework for Python web applications. It is mostly
@ -45,8 +45,5 @@ I use for web applications in the future.
'milla.request_validator': [ 'milla.request_validator': [
'default = milla.auth:RequestValidator' 'default = milla.auth:RequestValidator'
], ],
'console_scripts': [
'milla-cli = milla.cli:main'
],
} }
) )

View File

@ -1,11 +1,11 @@
# Copyright 2011 Dustin C. Hatch # Copyright 2011 Dustin C. Hatch
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
# You may obtain a copy of the License at # You may obtain a copy of the License at
# #
# http://www.apache.org/licenses/LICENSE-2.0 # http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software # Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, # distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -21,17 +21,34 @@ from webob.exc import *
import webob import webob
try: try:
import urllib.parse import urllib.parse
except ImportError: #pragma: no cover except ImportError: # pragma: no cover
import urllib import urllib
import urlparse import urlparse
urllib.parse = urlparse urllib.parse = urlparse
urllib.parse.urlencode = urllib.urlencode #@UndefinedVariable urllib.parse.urlencode = urllib.urlencode
class _AllowAll(object):
def __contains__(self, other):
return True
ALL_METHODS = _AllowAll()
'''Allow all HTTP methods (even non-standard ones)'''
DEFAULT_METHODS = ['GET', 'HEAD']
'''Default methods allowed on controller callables'''
STANDARD_METHODS = ['OPTIONS', 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'TRACE']
'''All standard HTTP methods'''
def allow(*methods): def allow(*methods):
'''Specify the allowed HTTP verbs for a controller callable '''Specify the allowed HTTP verbs for a controller callable
Example:: Example::
@milla.allow('GET', 'POST') @milla.allow('GET', 'POST')
def controller(request): def controller(request):
return 'Hello, world!' return 'Hello, world!'
@ -56,12 +73,12 @@ class Request(webob.Request):
@classmethod @classmethod
def blank(cls, path, *args, **kwargs): def blank(cls, path, *args, **kwargs):
'''Create a simple request for the specified path '''Create a simple request for the specified path
See :py:meth:`webob.Request.blank <webob.request.BaseRequest.blank>` See :py:meth:`webob.Request.blank <webob.request.BaseRequest.blank>`
for information on other arguments and keywords for information on other arguments and keywords
''' '''
req = super(Request, cls).blank(path, *args, **kwargs) req = super(Request, cls).blank(path, *args, **kwargs)
req.config = {} req.config = {}
return req return req
@ -73,7 +90,7 @@ class Request(webob.Request):
Any other keyword arguments will be encoded and appended to the URL Any other keyword arguments will be encoded and appended to the URL
as querystring arguments. as querystring arguments.
The HREF returned will will be the absolute path on the same host The HREF returned will will be the absolute path on the same host
and protocol as the request. To get the full URL including scheme and protocol as the request. To get the full URL including scheme
and host information, use :py:meth:`create_href_full` instead. and host information, use :py:meth:`create_href_full` instead.
@ -88,22 +105,22 @@ class Request(webob.Request):
def create_href_full(self, path, **keywords): def create_href_full(self, path, **keywords):
'''Combine the application's full URL with a path to form a new URL '''Combine the application's full URL with a path to form a new URL
:param path: relative path to join with the request URL :param path: relative path to join with the request URL
Any other keyword arguments will be encoded and appended to the Any other keyword arguments will be encoded and appended to the
URL as querystring arguments/ URL as querystring arguments/
The HREF returned will be the full URL, including scheme and host The HREF returned will be the full URL, including scheme and host
information. To get the path only, use :py:meth:`create_href` information. To get the path only, use :py:meth:`create_href`
instead. instead.
''' '''
url = self._merge_url(self.application_url, path) url = self._merge_url(self.application_url, path)
if keywords: if keywords:
url += '?' + urllib.parse.urlencode(keywords) url += '?' + urllib.parse.urlencode(keywords)
return url return url
def static_resource(self, path): def static_resource(self, path):

View File

@ -1,4 +1,4 @@
# Copyright 2011 Dustin C. Hatch # Copyright 2011, 2012, 2014-2016 Dustin C. Hatch
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -13,22 +13,198 @@
# limitations under the License. # limitations under the License.
'''Module milla.app '''Module milla.app
Please give me a docstring! The :py:class:`BaseApplication` class is the core of the Milla
framework. This class implements a WSGI application that dispatches
requests to callables based on their URL-path.
Most applications can use :py:class:`Application` directly, without
creating a sub-class. For advanced use, applications can define a
sub-class of :py:class:`BaseApplication` and customize the behavior of
the framework.
:Created: Mar 26, 2011 :Created: Mar 26, 2011
:Author: dustin :Author: dustin
:Updated: $Date$
:Updater: $Author$
''' '''
from milla import util
from milla.controllers import FaviconController from milla.controllers import FaviconController
from milla.util import asbool
from webob.exc import HTTPNotFound, WSGIHTTPException, HTTPMethodNotAllowed from webob.exc import HTTPNotFound, WSGIHTTPException, HTTPMethodNotAllowed
import milla.dispatch.traversal import milla.dispatch.traversal
import os
import sys
__all__ = ['Application']
class Application(object): __all__ = [
'Application',
'BaseApplication',
]
try:
# In Python 2, it could be a str or a unicode object
_string = basestring
except NameError:
# In Python 3, we are only interested in str objects
_string = str
class BaseApplication(object):
'''Base class for Milla applications
This class can be used by applications that need to customize the
behavior of the framework. In most cases, :py:class:`Application`
instances can be created directly and a sublcass is not necessary.
'''
DEFAULT_CONFIG = {}
#: Enable HTTP method emulation for POST requests?
post_method_emulation = True
def __init__(self):
self.config = self.DEFAULT_CONFIG.copy()
self.dispatcher = None
def __call__(self, environ, start_response):
start_response = StartResponseWrapper(start_response)
func = self.resolve_path(environ['PATH_INFO'])
request = self.make_request(environ)
request.__dict__['start_response'] = start_response
try:
allowed_methods = self._find_attr(func, 'allowed_methods')
except AttributeError:
allowed_methods = milla.DEFAULT_METHODS
if request.method == 'HEAD':
request.real_method = 'HEAD'
request.method = 'GET'
else:
request.real_method = request.method
if request.method not in allowed_methods:
allow_header = {'Allow': ', '.join(allowed_methods)}
if request.method == 'OPTIONS':
def func(request):
response = request.ResponseClass()
response.headers = allow_header
return response
else:
def func(request):
raise HTTPMethodNotAllowed(headers=allow_header)
try:
self._call_before(func)(request)
response = func(request)
except:
response = self.handle_error(request)
finally:
self._call_after(func)(request)
# The callable might have returned just a string, which is OK,
# but we need to wrap it in a Response object
if isinstance(response, _string) or not response:
response = request.ResponseClass(response)
return response(environ, start_response)
def _call_after(self, func):
try:
return self._find_attr(func, '__after__')
except AttributeError:
return lambda r: None
def _call_before(self, func):
try:
return self._find_attr(func, '__before__')
except AttributeError:
return lambda r: None
def _find_attr(self, obj, attr):
try:
# Object has the specified attribute itself
return getattr(obj, attr)
except AttributeError:
# Object is a bound method; look for the attribute on the instance
if hasattr(obj, '__self__'):
return self._find_attr(obj.__self__, attr)
# Object is a partial; look for the attribute on the inner function
elif hasattr(obj, 'func'):
return self._find_attr(obj.func, attr)
raise
def update_config(self, filename):
'''Update application configuration from a file
:param filename: Path to configuration file
This method will update the application configuration using
the values found in the specified configuration file. If the
specified file does not exist or is not accessible, no
changes will be made.
The configuration file will be read using
:py:func:`milla.util.read_config`.
'''
if filename and os.access(filename, os.R_OK):
self.config.update(util.read_config(filename))
def make_request(self, environ):
'''Create a :py:class:`~milla.Request` from a WSGI environment
:param environ: WSGI environment dictionary
:returns: :py:class:`milla.Request` object for this request
'''
request = milla.Request(environ)
request.__dict__['config'] = self.config.copy()
# Sometimes, hacky applications will try to "emulate" some HTTP
# methods like PUT or DELETE by specifying an _method parameter
# in a POST request.
if self.post_method_emulation:
if request.method == 'POST' and '_method' in request.POST:
request.method = request.POST.pop('_method')
return request
def resolve_path(self, path_info):
'''Find the controller for a given path
:param path_info: The request path, relative to the application
:returns: Controller callable
If no controller could be resolved for the path, a function
that raises :py:exc:`HTTPNotFound` will be returned.
'''
def path_not_found(request):
raise HTTPNotFound
path_not_found.allowed_methods = milla.ALL_METHODS
try:
return self.dispatcher.resolve(path_info)
except milla.dispatch.UnresolvedPath:
if (path_info == '/favicon.ico' and
util.asbool(self.config.get('milla.favicon'))):
return FaviconController()
else:
return path_not_found
def handle_error(self, request):
'''Handle errors raised by controller callables
Subclasses can override this method to customize the error
handling behavior of applications. The default implementation
only handles :py:exc:`WSGIHTTPException` exceptions, by calling
them as WSGI applications
'''
typ, value, tb = sys.exc_info()
if issubclass(typ, WSGIHTTPException):
return value
raise
class Application(BaseApplication):
'''Represents a Milla web application '''Represents a Milla web application
Constructing an ``Application`` instance needs a dispatcher, or Constructing an ``Application`` instance needs a dispatcher, or
@ -46,100 +222,17 @@ class Application(object):
configuration is copied and assigned to ``request.config``. configuration is copied and assigned to ``request.config``.
''' '''
DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD'] DEFAULT_CONFIG = {
'milla.favicon': True,
}
def __init__(self, obj): def __init__(self, obj):
super(Application, self).__init__()
if not hasattr(obj, 'resolve'): if not hasattr(obj, 'resolve'):
# Object is not a dispatcher, but the root object for traversal # Object is not a dispatcher, but the root object for traversal
obj = milla.dispatch.traversal.Traverser(obj) obj = milla.dispatch.traversal.Traverser(obj)
self.dispatcher = obj self.dispatcher = obj
self.config = {'milla.favicon': True}
def __call__(self, environ, start_response):
path_info = environ['PATH_INFO']
try:
func = self.dispatcher.resolve(path_info)
except milla.dispatch.UnresolvedPath:
if asbool(self.config.get('milla.favicon')) and \
path_info == '/favicon.ico':
func = FaviconController()
else:
return HTTPNotFound()(environ, start_response)
request = milla.Request(environ)
request.config = self.config.copy()
# Sometimes, hacky applications will try to "emulate" some HTTP
# method like POST or DELETE by specifying an _method parameter
# in a POST request.
if request.method == 'POST' and '_method' in request.POST:
request.method = request.POST.pop('_method')
allowed_methods = getattr(func, 'allowed_methods',
self.DEFAULT_ALLOWED_METHODS)
if request.method not in allowed_methods:
allow_header = {'Allow': ', '.join(allowed_methods)}
if request.method == 'OPTIONS':
def options_response(request, *args, **kwargs):
response = request.ResponseClass()
response.headers = allow_header
return response
func = options_response
else:
func = HTTPMethodNotAllowed(headers=allow_header)
return func(environ, start_response)
start_response_wrapper = StartResponseWrapper(start_response)
request.start_response = start_response_wrapper
try:
self._call_before(func)(request)
response = func(request)
except WSGIHTTPException as e:
return e(environ, start_response)
finally:
self._call_after(func)(request)
# The callable might have returned just a string, which is OK,
# but we need to wrap it in a Response object
try:
# In Python 2, it could be a str or a unicode object
basestring = basestring #@UndefinedVariable
except NameError:
# Python 3 has no unicode objects and thus no need for
# basestring so we, just make it an alias for str
basestring = str
if isinstance(response, basestring) or not response:
response = request.ResponseClass(response)
if not start_response_wrapper.called:
start_response(response.status, response.headerlist)
if not environ['REQUEST_METHOD'] == 'HEAD':
return response.app_iter
def _call_after(self, func):
try:
return self._find_attr(func, '__after__')
except AttributeError:
return lambda r: None
def _call_before(self, func):
try:
return self._find_attr(func, '__before__')
except AttributeError:
return lambda r: None
def _find_attr(self, obj, attr):
try:
# Object has the specified attribute itself
return getattr(obj, attr)
except AttributeError:
# Object is a bound method; look for the attribute on the instance
if hasattr(obj, '__self__'):
return self._find_attr(obj.__self__, attr)
# Object is a partial; look for the attribute on the inner function
elif hasattr(obj, 'func'):
return self._find_attr(obj.func, attr)
raise
class StartResponseWrapper(): class StartResponseWrapper():
@ -148,5 +241,6 @@ class StartResponseWrapper():
self.called = False self.called = False
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
self.called = True if not self.called:
return self.start_response(*args, **kwargs) self.called = True
self.start_response(*args, **kwargs)

View File

@ -1,11 +1,11 @@
# Copyright 2011 Dustin C. Hatch # Copyright 2011 Dustin C. Hatch
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
# You may obtain a copy of the License at # You may obtain a copy of the License at
# #
# http://www.apache.org/licenses/LICENSE-2.0 # http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software # Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, # distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -21,7 +21,7 @@
class NotAuthorized(Exception): class NotAuthorized(Exception):
'''Base class for unauthorized exceptions '''Base class for unauthorized exceptions
This class is both an exception and a controller callable. If the This class is both an exception and a controller callable. If the
request validator raises an instance of this class, it will be request validator raises an instance of this class, it will be
called and the resulting value will become the HTTP response. The called and the resulting value will become the HTTP response. The
@ -33,17 +33,17 @@ class NotAuthorized(Exception):
'''Return a response indicating the request is not authorized '''Return a response indicating the request is not authorized
:param request: WebOb Request instance for the current request :param request: WebOb Request instance for the current request
All other arguments and keywords are ignored. All other arguments and keywords are ignored.
''' '''
response = request.ResponseClass(str(self)) response = request.ResponseClass(str(self))
response.status_int = 403 response.status_int = 403
return response return response
class RequestValidator(object): class RequestValidator(object):
'''Base class for request validators '''Base class for request validators
A request validator is a class that exposes a ``validate`` method, A request validator is a class that exposes a ``validate`` method,
which accepts an instance of :py:class:`webob.Request` and an which accepts an instance of :py:class:`webob.Request` and an
optional ``requirement``. The ``validate`` method should return optional ``requirement``. The ``validate`` method should return
@ -51,7 +51,7 @@ class RequestValidator(object):
:py:exc:`NotAuthorized` on failure. The base implementation will :py:exc:`NotAuthorized` on failure. The base implementation will
raise an instance of the exception specified by raise an instance of the exception specified by
:py:attr:`exc_class`, which defaults to :py:class`NotAuthorized`. :py:attr:`exc_class`, which defaults to :py:class`NotAuthorized`.
To customize the response to unauthorized requests, it is To customize the response to unauthorized requests, it is
sufficient to subclass :py:class:`NotAuthorized`, override its sufficient to subclass :py:class:`NotAuthorized`, override its
:py:meth:`~NotAuthorized.__call__` method, and specify the class :py:meth:`~NotAuthorized.__call__` method, and specify the class
@ -63,7 +63,7 @@ class RequestValidator(object):
def validate(self, request, requirement=None): def validate(self, request, requirement=None):
'''Validates a request '''Validates a request
:param request: The request to validate. Should be an instance :param request: The request to validate. Should be an instance
of :py:class:`webob.Request`. of :py:class:`webob.Request`.
:param requirement: (Optional) A requirement to check. Should be :param requirement: (Optional) A requirement to check. Should be
@ -71,10 +71,10 @@ class RequestValidator(object):
or :py:class:`~milla.auth.permissions.PermissionRequirement`, or :py:class:`~milla.auth.permissions.PermissionRequirement`,
or some other class with a ``check`` method that accepts a or some other class with a ``check`` method that accepts a
sequence of permissions. sequence of permissions.
The base implementation will perform authorization in the The base implementation will perform authorization in the
following way: following way:
1. Does the ``request`` have a ``user`` attribute? If not, 1. Does the ``request`` have a ``user`` attribute? If not,
raise :py:exc:`NotAuthorized`. raise :py:exc:`NotAuthorized`.
2. Is the truth value of ``request.user`` true? If not, raise 2. Is the truth value of ``request.user`` true? If not, raise
@ -83,16 +83,16 @@ class RequestValidator(object):
attribute? If not, raise :py:exc:`NotAuthorized`. attribute? If not, raise :py:exc:`NotAuthorized`.
4. Do the user's permissions meet the requirements? If not, 4. Do the user's permissions meet the requirements? If not,
raise :py:exc:`NotAuthorized`. raise :py:exc:`NotAuthorized`.
If none of the above steps raised an exception, the method will If none of the above steps raised an exception, the method will
return ``None``, indicating that the validation was successful. return ``None``, indicating that the validation was successful.
.. note:: WebOb Request instances do not have a ``user`` .. note:: WebOb Request instances do not have a ``user``
attribute by default. You will need to supply this yourself, attribute by default. You will need to supply this yourself,
i.e. in a WSGI middleware or in the ``__before__`` method of i.e. in a WSGI middleware or in the ``__before__`` method of
your controller class. your controller class.
''' '''
try: try:
user = request.user user = request.user
except AttributeError: except AttributeError:

View File

@ -1,11 +1,11 @@
# Copyright 2011 Dustin C. Hatch # Copyright 2011 Dustin C. Hatch
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
# You may obtain a copy of the License at # You may obtain a copy of the License at
# #
# http://www.apache.org/licenses/LICENSE-2.0 # http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software # Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, # distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -15,19 +15,28 @@
:Created: Mar 3, 2011 :Created: Mar 3, 2011
:Author: dustin :Author: dustin
:Updated: $Date$
:Updater: $Author$
''' '''
from functools import wraps from functools import wraps
from milla.auth import RequestValidator, NotAuthorized, permissions from milla.auth import permissions
import milla import milla.auth
import pkg_resources import warnings
try:
import pkg_resources
except ImportError:
pkg_resources = None
__all__ = [
'auth_required',
'require_perms',
'validate_request',
]
__all__ = ['auth_required', 'require_perms']
VALIDATOR_EP_GROUP = 'milla.request_validator' VALIDATOR_EP_GROUP = 'milla.request_validator'
def _find_request(*args, **kwargs): def _find_request(*args, **kwargs):
try: try:
return kwargs['request'] return kwargs['request']
@ -36,39 +45,80 @@ def _find_request(*args, **kwargs):
if isinstance(arg, milla.Request): if isinstance(arg, milla.Request):
return arg return arg
def _validate_request(func, requirement, *args, **kwargs):
request = _find_request(*args, **kwargs)
ep_name = request.config.get('request_validator', 'default')
# Override the RequestVariable name with a class from the specified def _validate_request(func, requirement, *args, **kwargs):
# entry point, if one is available. Otherwise, the default is used. warnings.warn(
for ep in pkg_resources.iter_entry_points(VALIDATOR_EP_GROUP, ep_name): '_validate_request is deprecated; use validate_request instead',
try: DeprecationWarning,
RequestValidator = ep.load() stacklevel=2,
except: )
continue validate_request(func, requirement, *args, **kwargs)
def validate_request(func, requirement, *args, **kwargs):
'''Validate a request meets a given requirement
:param func: Decorated callable
:param requirement: A requirement that the request must meet in
order to be considered valid, as specified by the request
validator used by the application. This is usally a sub-class of
:py:class:`~milla.auth.permissions.PermissionRequirement`, or
some other class that has a ``check`` method that accepts a
:py:class:`~milla.Request` object as its only argument.
:param args: Positional arguments to pass through to the decorated
callable
:param kwargs: Keyword arguments to pass through to the decorated
callable
This is a helper function used by :py:func:`auth_required` and
:py:func:`require_perms` that can be used by other request
decorators as well.
'''
request = _find_request(*args, **kwargs)
rv = request.config.get('request_validator', 'default')
if hasattr(rv, 'validate'):
# Config specifies a request validator class explicitly instead
# of an entry point name, so use it directly
validator = rv()
elif pkg_resources:
for ep in pkg_resources.iter_entry_points(VALIDATOR_EP_GROUP, rv):
try:
validator = ep.load()()
break
except:
# Ignore errors loading entry points or creating instances
continue
else:
# No entry point loaded or request validator instance
# created, use the default
validator = milla.auth.RequestValidator()
else:
# config does not specify a request validator class, and
# setuptools is not available, use the default
validator = milla.auth.RequestValidator()
try: try:
validator = RequestValidator()
validator.validate(request, requirement) validator.validate(request, requirement)
except NotAuthorized as e: except milla.auth.NotAuthorized as e:
return e(request) return e(request)
return func(*args, **kwargs) return func(*args, **kwargs)
def auth_required(func): def auth_required(func):
'''Simple decorator to enforce authentication for a controller '''Simple decorator to enforce authentication for a controller
Example usage:: Example usage::
class SomeController(object): class SomeController(object):
def __before__(request): def __before__(request):
request.user = find_a_user_somehow(request) request.user = find_a_user_somehow(request)
@milla.auth_required @milla.auth_required
def __call__(request): def __call__(request):
return 'Hello, world!' return 'Hello, world!'
In this example, the ``SomeController`` controller class implements In this example, the ``SomeController`` controller class implements
an ``__before__`` method that adds the ``user`` attribute to the an ``__before__`` method that adds the ``user`` attribute to the
``request`` instance. This could be done by extracting user ``request`` instance. This could be done by extracting user
@ -76,7 +126,7 @@ def auth_required(func):
method is decorated with ``auth_required``, which will ensure that method is decorated with ``auth_required``, which will ensure that
the user is successfully authenticated. This is handled by a the user is successfully authenticated. This is handled by a
*request validator*. *request validator*.
If the request is not authorized, the decorated method will never If the request is not authorized, the decorated method will never
be called. Instead, the response is generated by calling the be called. Instead, the response is generated by calling the
:py:exc:`~milla.auth.NotAuthorized` exception raised inside :py:exc:`~milla.auth.NotAuthorized` exception raised inside
@ -85,23 +135,24 @@ def auth_required(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
return _validate_request(func, None, *args, **kwargs) return validate_request(func, None, *args, **kwargs)
return wrapper return wrapper
class require_perms(object): class require_perms(object):
'''Decorator that requires the user have certain permissions '''Decorator that requires the user have certain permissions
Example usage:: Example usage::
class SomeController(object): class SomeController(object):
def __before__(request): def __before__(request):
request.user = find_a_user_somehow(request) request.user = find_a_user_somehow(request)
@milla.require_perms('some_permission', 'and_this_permission') @milla.require_perms('some_permission', 'and_this_permission')
def __call__(request): def __call__(request):
return 'Hello, world!' return 'Hello, world!'
In this example, the ``SomeController`` controller class implements In this example, the ``SomeController`` controller class implements
an ``__before__`` method that adds the ``user`` attribute to the an ``__before__`` method that adds the ``user`` attribute to the
``request`` instance. This could be done by extracting user ``request`` instance. This could be done by extracting user
@ -109,9 +160,9 @@ class require_perms(object):
method is decorated with ``require_perms``, which will ensure that method is decorated with ``require_perms``, which will ensure that
the user is successfully authenticated and the the user has the the user is successfully authenticated and the the user has the
specified permissions. This is handled by a *request validator*. specified permissions. This is handled by a *request validator*.
There are two ways to specify the required permissions: There are two ways to specify the required permissions:
* By passing the string name of all required permissions as * By passing the string name of all required permissions as
positional arguments. A complex permission requirement will be positional arguments. A complex permission requirement will be
constructed that requires *all* of the given permissions to be constructed that requires *all* of the given permissions to be
@ -135,5 +186,5 @@ class require_perms(object):
def __call__(self, func): def __call__(self, func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
return _validate_request(func, self.requirement, *args, **kwargs) return validate_request(func, self.requirement, *args, **kwargs)
return wrapper return wrapper

View File

@ -1,11 +1,11 @@
# Copyright 2011 Dustin C. Hatch # Copyright 2011 Dustin C. Hatch
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
# You may obtain a copy of the License at # You may obtain a copy of the License at
# #
# http://www.apache.org/licenses/LICENSE-2.0 # http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software # Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, # distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -18,11 +18,11 @@ Examples::
>>> req = Permission('foo') & Permission('bar') >>> req = Permission('foo') & Permission('bar')
>>> req.check(PermissionContainer(['foo', 'baz'], ['bar'])) >>> req.check(PermissionContainer(['foo', 'baz'], ['bar']))
True True
>>> req = Permission('login') >>> req = Permission('login')
>>> req.check(['login']) >>> req.check(['login'])
True True
>>> req = Permission('login') | Permission('admin') >>> req = Permission('login') | Permission('admin')
>>> req.check(['none']) >>> req.check(['none'])
False False
@ -30,62 +30,62 @@ Examples::
class PermissionContainer(object): class PermissionContainer(object):
'''Container object for user and group permissions '''Container object for user and group permissions
:param list user_perms: List of permissions held by the user itself :param list user_perms: List of permissions held by the user itself
:param list group_perms: List of permissions held by the groups to :param list group_perms: List of permissions held by the groups to
which the user belongs which the user belongs
Iterating over :py:class:`PermissionContainer` objects results in Iterating over :py:class:`PermissionContainer` objects results in
a flattened representation of all permissions. a flattened representation of all permissions.
''' '''
def __init__(self, user_perms=[], group_perms=[]): def __init__(self, user_perms=[], group_perms=[]):
self._user_perms = user_perms self._user_perms = user_perms
self._group_perms = group_perms self._group_perms = group_perms
def __iter__(self): def __iter__(self):
for perm in self._user_perms: for perm in self._user_perms:
yield perm yield perm
for perm in self._group_perms: for perm in self._group_perms:
yield perm yield perm
def __contains__(self, perm): def __contains__(self, perm):
return perm in self._user_perms or perm in self._group_perms return perm in self._user_perms or perm in self._group_perms
class BasePermission(object): class BasePermission(object):
'''Base class for permissions and requirements '''Base class for permissions and requirements
Complex permission requirements can be created using the bitwise Complex permission requirements can be created using the bitwise
``and`` and ``or`` operators:: ``and`` and ``or`` operators::
login_and_view = Permission('login') & Permission('view') login_and_view = Permission('login') & Permission('view')
admin_or_root = Permission('admin') | Permission('root') admin_or_root = Permission('admin') | Permission('root')
complex = Permission('login') & Permission('view') | Permission('admin') complex = Permission('login') & Permission('view') | Permission('admin')
''' '''
def __and__(self, other): def __and__(self, other):
assert isinstance(other, BasePermission) assert isinstance(other, BasePermission)
return PermissionRequirementAll(self, other) return PermissionRequirementAll(self, other)
def __or__(self, other): def __or__(self, other):
assert isinstance(other, BasePermission) assert isinstance(other, BasePermission)
return PermissionRequirementAny(self, other) return PermissionRequirementAny(self, other)
class Permission(BasePermission): class Permission(BasePermission):
'''Simple permission implementation '''Simple permission implementation
:param str name: Name of the permission :param str name: Name of the permission
Permissions must implement a ``check`` method that accepts an Permissions must implement a ``check`` method that accepts an
iterable and returns ``True`` if the permission is present or iterable and returns ``True`` if the permission is present or
``False`` otherwise. ``False`` otherwise.
''' '''
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
def __str__(self): def __str__(self):
return str(self.name) return str(self.name)
@ -94,27 +94,27 @@ class Permission(BasePermission):
def check(self, perms): def check(self, perms):
'''Check if the permission is held '''Check if the permission is held
This method can be overridden to provide more robust This method can be overridden to provide more robust
support, but this implementation is simple:: support, but this implementation is simple::
return self in perms return self in perms
''' '''
return self in perms return self in perms
class PermissionRequirement(BasePermission): class PermissionRequirement(BasePermission):
'''Base class for complex permission requirements''' '''Base class for complex permission requirements'''
def __init__(self, *requirements): def __init__(self, *requirements):
self.requirements = requirements self.requirements = requirements
def __str__(self): def __str__(self):
return ', '.join(self.requirements) return ', '.join(self.requirements)
class PermissionRequirementAll(PermissionRequirement): class PermissionRequirementAll(PermissionRequirement):
'''Complex permission requirement needing all given permissions''' '''Complex permission requirement needing all given permissions'''
def check(self, perms): def check(self, perms):
for req in self.requirements: for req in self.requirements:
if not req.check(perms): if not req.check(perms):
@ -123,7 +123,7 @@ class PermissionRequirementAll(PermissionRequirement):
class PermissionRequirementAny(PermissionRequirement): class PermissionRequirementAny(PermissionRequirement):
'''Complex permission requirement needing any given permissions''' '''Complex permission requirement needing any given permissions'''
def check(self, perms): def check(self, perms):
for req in self.requirements: for req in self.requirements:
if req.check(perms): if req.check(perms):

View File

@ -1,116 +0,0 @@
# Copyright 2011 Dustin C. Hatch
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
'''Module milla.cli
.. deprecated:: 0.2
This module is unmaintained and will be removed soon. Please do not use it.
:Created: May 30, 2011
:Author: dustin
:Updated: $Date$
:Updater: $Author$
'''
import argparse
import pkg_resources
import warnings
warnings.warn('The milla.cli module is unmaintained and will be removed soon',
DeprecationWarning, stacklevel=2)
class CommandWarning(UserWarning):
'''A warning raised when a command cannot be loaded or used'''
class CommandLineInterface(object):
'''Wrapper class for the Milla CLI'''
PROGNAME = 'milla-cli'
EPGROUP = 'milla.command'
def __init__(self):
self.parser = argparse.ArgumentParser(prog=self.PROGNAME)
subparsers = None
subcmds = pkg_resources.iter_entry_points(self.EPGROUP)
for cmd in subcmds:
try:
Command = cmd.load()
except Exception as e:
warnings.warn("Unable to load command from entry point named "
"'{epname}': {e}".format(
epname=cmd,
e=e,
), CommandWarning)
continue
if not hasattr(Command, 'name') or not Command.name:
warnings.warn("Command '{cmd}' from '{mod}' does not have a "
"name and cannot be used".format(
cmd=Command.__name__,
mod=Command.__module__
), CommandWarning)
continue
if not subparsers:
subparsers = self.parser.add_subparsers()
subcmd = subparsers.add_parser(Command.name, *Command.parser_args,
**Command.parser_kwargs)
Command.setup_args(subcmd)
subcmd.set_defaults(func=Command())
class Command(object):
'''Base class for "commands"
To create a command, subclass this class and override the following
attributes:
* ``name`` -- Name of the subcommand
* ``parser_args`` -- arguments to pass to ``add_parser`` when
creating the ArgumentParser for the command
* ``parser_kwargs`` -- keywords to pass to ``add_parser``
* ``setup_args()`` -- Class method called to add arguments, etc. to
the command subparser.
'''
#: Tuple of arguments to pass to the ArgumentParser constructor
parser_args = ()
#: Dict of keywords to pass to the ArgumentParser constructor
parser_kwargs = {}
@classmethod
def setup_args(cls, parser):
'''Add arguments, etc to the command subparser
Override this method to add arguments, options, etc. to the
subparser for the command. The default implementation does
not add anything.
:param parser: An instance of ``ArgumentParser`` for the
``milla-cli`` subcommand
'''
pass
def main():
'''Entry point for the ``milla-cli`` console script'''
cli = CommandLineInterface()
args = cli.parser.parse_args()
func = args.func
del args.func
func.args = args
func()

View File

@ -19,13 +19,15 @@ from one or more of these classes can make things significantly easier.
:Created: Mar 27, 2011 :Created: Mar 27, 2011
:Author: dustin :Author: dustin
:Updated: $Date$
:Updater: $Author$
''' '''
import datetime import datetime
import milla.util import milla.util
import pkg_resources import os
try:
import pkg_resources
except ImportError:
pkg_resources = None
class Controller(object): class Controller(object):
@ -58,17 +60,23 @@ class FaviconController(Controller):
EXPIRY_DAYS = 365 EXPIRY_DAYS = 365
def __init__(self, icon=None, content_type='image/x-icon'): def __init__(self, icon=None, content_type='image/x-icon'):
if icon: try:
try: if icon:
self.icon = open(icon) self.icon = open(icon, 'rb')
except (IOError, OSError): self.content_type = content_type
self.icon = None elif pkg_resources:
else:
try:
self.icon = pkg_resources.resource_stream('milla', 'milla.ico') self.icon = pkg_resources.resource_stream('milla', 'milla.ico')
except IOError: self.content_type = 'image/x-icon'
self.icon = None else:
self.content_type = content_type icon = os.path.join(
os.path.dirname(milla.__file__),
'milla.ico'
)
self.icon = open(icon, 'rb')
self.content_type = 'image/x-icon'
except (IOError, OSError):
self.icon = self.content_type = None
def __call__(self, request): def __call__(self, request):
if not self.icon: if not self.icon:
@ -80,3 +88,35 @@ class FaviconController(Controller):
datetime.timedelta(days=self.EXPIRY_DAYS)) datetime.timedelta(days=self.EXPIRY_DAYS))
response.headers['Expires'] = milla.util.http_date(expires) response.headers['Expires'] = milla.util.http_date(expires)
return response return response
class HTTPVerbController(Controller):
'''A controller that delegates requests based on the HTTP method
Subclasses of this controller should have an instance method for
every HTTP method they support. For example, to support the ``GET``
and ``POST`` methods, a class might look like this:
.. code-block:: python
class MyController(HTTPVerbController):
def GET(self, request):
return 'Hello, world!'
def POST(self, request):
return 'Thanks!'
'''
def __call__(self, request, *args, **kwargs):
try:
func = getattr(self, request.method)
except AttributeError:
raise milla.HTTPMethodNotAllowed
return func(request, *args, **kwargs)
@property
def allowed_methods(self):
for attr in dir(self):
if attr.upper() == attr:
yield attr

View File

@ -1,11 +1,11 @@
# Copyright 2011 Dustin C. Hatch # Copyright 2011 Dustin C. Hatch
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
# You may obtain a copy of the License at # You may obtain a copy of the License at
# #
# http://www.apache.org/licenses/LICENSE-2.0 # http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software # Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, # distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

View File

@ -1,11 +1,11 @@
# Copyright 2011 Dustin C. Hatch # Copyright 2011, 2012, 2015 Dustin C. Hatch
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
# You may obtain a copy of the License at # You may obtain a copy of the License at
# #
# http://www.apache.org/licenses/LICENSE-2.0 # http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software # Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, # distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -28,13 +28,13 @@ import warnings
class Router(object): class Router(object):
'''A dispatcher that maps arbitrary paths to controller callables '''A dispatcher that maps arbitrary paths to controller callables
Typical usage:: Typical usage::
router = Router() router = Router()
router.add_route('/foo/{bar}/{baz:\d+}', some_func) router.add_route('/foo/{bar}/{baz:\d+}', some_func)
app = milla.Application(dispatcher=router) app = milla.Application(dispatcher=router)
In many cases, paths with trailing slashes need special handling. In many cases, paths with trailing slashes need special handling.
The ``Router`` has two ways of dealing with requests that should The ``Router`` has two ways of dealing with requests that should
have a trailing slash but do not. The default is to send the client have a trailing slash but do not. The default is to send the client
@ -44,19 +44,19 @@ class Router(object):
return HTTP 404 Not Found for requests with missing trailing return HTTP 404 Not Found for requests with missing trailing
slashes. To change the behavior, pass a different value to the slashes. To change the behavior, pass a different value to the
constructor's ``trailing_slash`` keyword. constructor's ``trailing_slash`` keyword.
Redirect the client to the proper path (the default):: Redirect the client to the proper path (the default)::
router = Router(trailing_slash=Router.REDIRECT) router = Router(trailing_slash=Router.REDIRECT)
router.add_route('/my_collection/', some_func) router.add_route('/my_collection/', some_func)
Pretend the request had a trailing slash, even if it didn't:: Pretend the request had a trailing slash, even if it didn't::
router = Router(trailing_slash=Router.SILENT) router = Router(trailing_slash=Router.SILENT)
router.add_route('/my_collection/', some_func) router.add_route('/my_collection/', some_func)
Do nothing, let the client get a 404 error:: Do nothing, let the client get a 404 error::
router = Router(trailing_slash=None) router = Router(trailing_slash=None)
router.add_route('/my_collection/', some_func) router.add_route('/my_collection/', some_func)
''' '''
@ -74,12 +74,12 @@ class Router(object):
def resolve(self, path_info): def resolve(self, path_info):
'''Find a controller for a given path '''Find a controller for a given path
:param path_info: Path for which to locate a controller :param path_info: Path for which to locate a controller
:returns: A :py:class:`functools.partial` instance that sets :returns: A :py:class:`functools.partial` instance that sets
the values collected from variable segments as keyword the values collected from variable segments as keyword
arguments to the callable arguments to the callable
This method walks through the routing table created with calls This method walks through the routing table created with calls
to :py:meth:`add_route` and finds the first whose template to :py:meth:`add_route` and finds the first whose template
matches the given path. Variable segments are added as keywords matches the given path. Variable segments are added as keywords
@ -121,8 +121,9 @@ class Router(object):
# Return a dummy function that just raises # Return a dummy function that just raises
# HTTPMovedPermanently to redirect the client to # HTTPMovedPermanently to redirect the client to
# the canonical URL # the canonical URL
def redir(*args, **kwargs): def redir(request, *args, **kwargs):
raise milla.HTTPMovedPermanently(location=new_path_info) raise milla.HTTPMovedPermanently(
location=request.create_href(new_path_info))
return redir return redir
elif func and self.trailing_slash is Router.SILENT: elif func and self.trailing_slash is Router.SILENT:
# Return the function found at the alternate path # Return the function found at the alternate path
@ -131,9 +132,9 @@ class Router(object):
def _compile_template(self, template): def _compile_template(self, template):
'''Compiles a template into a real regular expression '''Compiles a template into a real regular expression
:param template: A route template string :param template: A route template string
Converts the ``{name}`` or ``{name:regexp}`` syntax into a full Converts the ``{name}`` or ``{name:regexp}`` syntax into a full
regular expression for later parsing. regular expression for later parsing.
''' '''
@ -162,40 +163,40 @@ class Router(object):
def add_route(self, template, controller, **vars): def add_route(self, template, controller, **vars):
'''Add a route to the routing table '''Add a route to the routing table
:param template: Route template string :param template: Route template string
:param controller: Controller callable or string Python path :param controller: Controller callable or string Python path
Route template strings are path segments, beginning with ``/``. Route template strings are path segments, beginning with ``/``.
Paths can also contain variable segments, delimited with curly Paths can also contain variable segments, delimited with curly
braces. braces.
Example:: Example::
/some/other/{variable}/{path} /some/other/{variable}/{path}
By default, variable segments will match any character except a By default, variable segments will match any character except a
``/``. Alternate expressions can be passed by specifying them ``/``. Alternate expressions can be passed by specifying them
alongside the name, separated by a ``:``. alongside the name, separated by a ``:``.
Example:: Example::
/some/other/{alternate:[a-zA-Z]} /some/other/{alternate:[a-zA-Z]}
Variable path segments will be passed as keywords to the Variable path segments will be passed as keywords to the
controller. In the first example above, assuming ``controller`` controller. In the first example above, assuming ``controller``
is the name of the callable passed, and the request path was is the name of the callable passed, and the request path was
``/some/other/great/place``:: ``/some/other/great/place``::
controller(request, variable='great', path='place') controller(request, variable='great', path='place')
The ``controller`` argument itself can be any callable that The ``controller`` argument itself can be any callable that
accepts a *WebOb* request as its first argument, and any accepts a *WebOb* request as its first argument, and any
keywords that may be passed from variable segments. It can keywords that may be passed from variable segments. It can
also be a string Python path to such a callable. For example:: also be a string Python path to such a callable. For example::
`some.module:function` `some.module:function`
This string will resolve to the function ``function`` in the This string will resolve to the function ``function`` in the
module ``some.module``. module ``some.module``.
''' '''
@ -204,43 +205,3 @@ class Router(object):
controller = self._import_controller(controller) controller = self._import_controller(controller)
self.routes.append((self._compile_template(template), self.routes.append((self._compile_template(template),
controller, vars)) 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
.. deprecated:: 0.2
Use :py:meth:`milla.Request.create_href` instead.
'''
def __init__(self, request, path_only=True):
self.request = request
self.path_only = path_only
warnings.warn(
'Use of Generator is deprecated; '
'use milla.Request.create_href instead',
DeprecationWarning,
stacklevel=2
)
def generate(self, *segments, **vars):
'''Combines segments and the application's URL into a new URL
'''
path = '/'.join(str(s) for s in segments)
if self.path_only:
return self.request.create_href(path, **vars)
else:
return self.request.create_href_full(path, **vars)

View File

@ -1,11 +1,11 @@
# Copyright 2011 Dustin C. Hatch # Copyright 2011 Dustin C. Hatch
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
# You may obtain a copy of the License at # You may obtain a copy of the License at
# #
# http://www.apache.org/licenses/LICENSE-2.0 # http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software # Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, # distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -23,35 +23,35 @@ from milla.dispatch import UnresolvedPath
class Traverser(object): class Traverser(object):
'''Default URL dispatcher '''Default URL dispatcher
:param root: The root object at which lookup will begin :param root: The root object at which lookup will begin
The default URL dispatcher uses object attribute traversal to The default URL dispatcher uses object attribute traversal to
locate a handler for a given path. For example, consider the locate a handler for a given path. For example, consider the
following class:: following class::
class Root(object): class Root(object):
def foo(self): def foo(self):
return 'Hello, world!' return 'Hello, world!'
The path ``/foo`` would resolve to the ``foo`` method of the The path ``/foo`` would resolve to the ``foo`` method of the
``Root`` class. ``Root`` class.
If a path cannot be resolved, :py:exc:`UnresolvedPath` will be If a path cannot be resolved, :py:exc:`UnresolvedPath` will be
raised. raised.
''' '''
def __init__(self, root): def __init__(self, root):
self.root = root self.root = root
def resolve(self, path_info): def resolve(self, path_info):
'''Find a handler given a path '''Find a handler given a path
:param path_info: Path for which to find a handler :param path_info: Path for which to find a handler
:returns: A handler callable :returns: A handler callable
''' '''
def walk_path(handler, parts): def walk_path(handler, parts):
if not parts or not parts[0]: if not parts or not parts[0]:
# No more parts, or the last part is blank, we're done # No more parts, or the last part is blank, we're done
@ -66,9 +66,9 @@ class Traverser(object):
except AttributeError: except AttributeError:
# No default either, can't resolve # No default either, can't resolve
raise UnresolvedPath raise UnresolvedPath
# Strip the leading slash and split the path # Strip the leading slash and split the path
split_path = path_info.lstrip('/').split('/') split_path = path_info.lstrip('/').split('/')
handler = walk_path(self.root, split_path) handler = walk_path(self.root, split_path)
return handler return handler

181
src/milla/vary.py Normal file
View File

@ -0,0 +1,181 @@
# Copyright 2016 Dustin C. Hatch
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
'''Multi-format response handling
:Created: Jul 1, 2016
:Author: dustin
'''
import milla
import collections
import inspect
import functools
class renders(object):
'''Mark a method as a renderer for one or more media types
:param content_types: Internet media types supported by the
renderer
'''
def __init__(self, *content_types):
self.content_types = content_types
def __call__(self, func):
func.renders = self.content_types
return func
def default_renderer(func):
'''Mark a :py:class:`VariedResponseMixin` renderer as default'''
func.default_renderer = True
return func
class VariedResponseMeta(type):
def __new__(mcs, name, bases, attrs):
cls = type.__new__(mcs, name, bases, attrs)
cls.renderers = {}
cls.default_type = None
for attr in attrs.values():
if not isinstance(attr, collections.Callable):
continue
if hasattr(attr, 'renders'):
for content_type in attr.renders:
cls.renderers[content_type] = attr
if getattr(attr, 'default_renderer', False):
cls.default_type = attr.renders[0]
return cls
_VariedResponseBase = VariedResponseMeta(
'_VariedResponseBase', (milla.Response,), {})
class VariedResponseBase(_VariedResponseBase):
'''Base class for responses with variable representations
In many cases, a a response can be represented in more than one
format (e.g. HTML, JSON, XML, etc.). This class can be used to
present the correct format based on the value of the ``Accept``
header in the request.
To use this class, create a subclass with a method to render each
supported representation format. The render methods must have
a ``renders`` attribute that contains a sequence of Internet media
(MIME) types the renderer is capable of producing. The
:py:func:`renders` decorator can be used to set this attribute.
Each renderer must take at least one argument, which is the context
data passed to :py:meth:`set_payload`. Additional arguments are
allowed, but they must be passed through :py:meth:`set_payload` as
keyword arguments.
If the ``Accept`` header of the request does not specify a media
type supported by any renderer, :py:exc:`~webob.exc.NotAcceptable`
will be raised. To avoid this, select a renderer as the "default"
by setting its `default_renderer` attribute to ``True`` (e.g. with
:py:func:`default_renderer`). This renderer will be used for all
requests unless a more appropriate renderer is available.
Example:
.. code-block:: python
class VariedResponse(Response, VariedResponse):
@default_renderer
@renders('text/html')
def render_html(self, context, template):
self.body = render_jinja_template(template, context)
@renders('application/json')
def render_json(self, context):
self.body = json.dumps(context)
The custom response class can be set as the default by extending the
:py:meth:`~milla.app.BaseApplication.make_request` method. For
example:
.. code-block:: python
class Application(milla.app.Application):
def make_request(self, environ):
request = super(Application, self).make_request(environ)
request.ResponseClass = VariedResponse.for_request(request)
return request
'''
def __init__(self, request, *args, **kwargs):
super(VariedResponseBase, self).__init__(*args, **kwargs)
self.request = request
@classmethod
def for_request(cls, request):
return functools.partial(cls, request)
def set_payload(self, context, **kwargs):
'''Set the response payload using the most appropriate renderer
:param context: The data to pass to the renderer
:param kwargs: Additional keyword arguments to pass to the
renderer
This method will determine the most appropriate representation
format for the response based on the ``Accept`` header in the
request and delegate to the method that can render that format.
Example:
.. code-block:: python
def controller(request):
response = VariedResponse.for_request(request)
response.set_payload(
{'hello': 'world'},
template='hello.html',
)
return response
In this example, the context is ``{'hello': 'world'}``. This
will be passed as the first argument to any renderer. If the
selected renderer accepts a ``template`` argument,
``'hello.html'`` will be passed as well.
'''
if not self.vary:
self.vary = ['Accept']
elif 'accept' not in (v.lower() for v in self.vary):
self.vary = self.vary + ('Accept',)
offer_types = self.renderers.keys()
match = self.request.accept.best_match(offer_types, self.default_type)
if match is None:
raise milla.HTTPNotAcceptable
renderer = self.renderers[match]
kwargs = _filter_kwargs(renderer, kwargs)
renderer(self, context, **kwargs)
def _filter_kwargs(func, kwargs):
if hasattr(inspect, 'signature'): # Python 3
sig = inspect.signature(func)
accepted = (p.name for p in sig.parameters.values()
if p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD)
else: # Python 2
accepted = inspect.getargspec(func)[0]
return dict((k, kwargs[k]) for k in accepted if k in kwargs)

View File

@ -3,13 +3,31 @@
:Created: Nov 27, 2012 :Created: Nov 27, 2012
:Author: dustin :Author: dustin
''' '''
from unittest.case import SkipTest
import functools import functools
import milla.app import milla.app
import milla.dispatch import milla.dispatch
import nose.tools import nose.tools
import sys
import wsgiref.util import wsgiref.util
import webob.exc import webob.exc
def python2_only(test):
@functools.wraps(test)
def wrapper():
if sys.version_info[0] != 2:
raise SkipTest
return test()
return wrapper
def python3_only(test):
@functools.wraps(test)
def wrapper():
if sys.version_info[0] != 3:
raise SkipTest
return test()
return wrapper
class StubResolver(object): class StubResolver(object):
'''Stub resolver for testing purposes''' '''Stub resolver for testing purposes'''
@ -122,7 +140,7 @@ def test_allow_header_options():
def test_emulated_method(): def test_emulated_method():
'''Emulated HTTP methods are interpreted correctly '''Emulated HTTP methods are interpreted correctly
For applications that cannot use the proper HTTP method and instead For applications that cannot use the proper HTTP method and instead
use HTTP POST with an ``_method`` parameter use HTTP POST with an ``_method`` parameter
''' '''
@ -145,6 +163,64 @@ def test_emulated_method():
response.finish_response(app_iter) response.finish_response(app_iter)
assert response.headers.startswith('HTTP/1.1 200'), response.headers assert response.headers.startswith('HTTP/1.1 200'), response.headers
def test_return_none():
'''Controllers can return None
'''
def controller(request):
return None
app = milla.app.Application(StubResolver(controller))
environ = environ_for_testing()
response = ResponseMaker()
app_iter = app(environ, response.start_response)
response.finish_response(app_iter)
assert not response.body, response.body
def test_return_str():
'''Controllers can return str objects
'''
def controller(request):
return 'Hello, world'
app = milla.app.Application(StubResolver(controller))
environ = environ_for_testing()
response = ResponseMaker()
app_iter = app(environ, response.start_response)
response.finish_response(app_iter)
assert response.body == b'Hello, world', response.body
@python2_only
def test_return_unicode():
'''Controllers can return unicode objects
'''
def controller(request):
return unicode('Hello, world')
app = milla.app.Application(StubResolver(controller))
environ = environ_for_testing()
response = ResponseMaker()
app_iter = app(environ, response.start_response)
response.finish_response(app_iter)
assert response.body == unicode('Hello, world'), response.body
@nose.tools.raises(TypeError)
@python3_only
def test_return_bytes():
'''Controllers cannot return bytes objects
'''
def controller(request):
return b'Hello, world'
app = milla.app.Application(StubResolver(controller))
environ = environ_for_testing()
response = ResponseMaker()
app_iter = app(environ, response.start_response)
response.finish_response(app_iter)
@nose.tools.raises(BeforeCalled) @nose.tools.raises(BeforeCalled)
def test_function_before(): def test_function_before():
'''__before__ attribute is called for controller functions '''__before__ attribute is called for controller functions
@ -460,10 +536,10 @@ def test_create_href_full_keywords():
def test_static_resource(): def test_static_resource():
'''Request.static_resource creates valid URL from config''' '''Request.static_resource creates valid URL from config'''
def controller(request): def controller(request):
return request.static_resource('/image.png') return request.static_resource('/image.png')
environ = environ_for_testing() environ = environ_for_testing()
app = milla.Application(StubResolver(controller)) app = milla.Application(StubResolver(controller))
app.config['milla.static_root'] = '/static' app.config['milla.static_root'] = '/static'
@ -474,14 +550,13 @@ def test_static_resource():
def test_static_resource_undefined(): def test_static_resource_undefined():
'''Request.static_resource returns the path unmodified with no root defined''' '''Request.static_resource returns the path unmodified with no root defined'''
def controller(request): def controller(request):
return request.static_resource('/image.png') return request.static_resource('/image.png')
environ = environ_for_testing() environ = environ_for_testing()
app = milla.Application(StubResolver(controller)) app = milla.Application(StubResolver(controller))
response = ResponseMaker() response = ResponseMaker()
app_iter = app(environ, response.start_response) app_iter = app(environ, response.start_response)
response.finish_response(app_iter) response.finish_response(app_iter)
assert response.body == b'/image.png', response.body assert response.body == b'/image.png', response.body

View File

@ -10,21 +10,21 @@ import nose.tools
def test_permission_check(): def test_permission_check():
'''Ensure Permission.check returns True for lists of strings '''Ensure Permission.check returns True for lists of strings
''' '''
perm = milla.auth.permissions.Permission('foo') perm = milla.auth.permissions.Permission('foo')
assert perm.check(['foo']) assert perm.check(['foo'])
def test_permission_check_false(): def test_permission_check_false():
'''Ensure Permission.check returns False for lists of strings '''Ensure Permission.check returns False for lists of strings
''' '''
perm = milla.auth.permissions.Permission('foo') perm = milla.auth.permissions.Permission('foo')
assert not perm.check(['bar']) assert not perm.check(['bar'])
def test_permission_check_perm(): def test_permission_check_perm():
'''Ensure Permission.check returns True for lists of Permissions '''Ensure Permission.check returns True for lists of Permissions
''' '''
req = milla.auth.permissions.Permission('foo') req = milla.auth.permissions.Permission('foo')
perm = milla.auth.permissions.Permission('foo') perm = milla.auth.permissions.Permission('foo')
assert req.check([perm]) assert req.check([perm])
@ -32,7 +32,7 @@ def test_permission_check_perm():
def test_permission_check_perm_false(): def test_permission_check_perm_false():
'''Ensure Permission.check returns True for lists of Permissions '''Ensure Permission.check returns True for lists of Permissions
''' '''
req = milla.auth.permissions.Permission('foo') req = milla.auth.permissions.Permission('foo')
perm = milla.auth.permissions.Permission('bar') perm = milla.auth.permissions.Permission('bar')
assert not req.check([perm]) assert not req.check([perm])
@ -40,7 +40,7 @@ def test_permission_check_perm_false():
def test_permission_check_container(): def test_permission_check_container():
'''Ensure Permission.check returns True for PermissionContainers of strings '''Ensure Permission.check returns True for PermissionContainers of strings
''' '''
perm = milla.auth.permissions.Permission('foo') perm = milla.auth.permissions.Permission('foo')
container = milla.auth.permissions.PermissionContainer(['foo']) container = milla.auth.permissions.PermissionContainer(['foo'])
assert perm.check(container) assert perm.check(container)
@ -48,7 +48,7 @@ def test_permission_check_container():
def test_permission_check_container_false(): def test_permission_check_container_false():
'''Ensure Permission.check returns True for PermissionContainers of strings '''Ensure Permission.check returns True for PermissionContainers of strings
''' '''
perm = milla.auth.permissions.Permission('foo') perm = milla.auth.permissions.Permission('foo')
container = milla.auth.permissions.PermissionContainer(['bar']) container = milla.auth.permissions.PermissionContainer(['bar'])
assert not perm.check(container) assert not perm.check(container)
@ -56,7 +56,7 @@ def test_permission_check_container_false():
def test_permission_check_container_perm(): def test_permission_check_container_perm():
'''Ensure Permission.check returns True for PermissionContainers of Permissions '''Ensure Permission.check returns True for PermissionContainers of Permissions
''' '''
perm = milla.auth.permissions.Permission('foo') perm = milla.auth.permissions.Permission('foo')
req = milla.auth.permissions.Permission('foo') req = milla.auth.permissions.Permission('foo')
container = milla.auth.permissions.PermissionContainer([perm]) container = milla.auth.permissions.PermissionContainer([perm])
@ -65,7 +65,7 @@ def test_permission_check_container_perm():
def test_permission_check_container_perm_false(): def test_permission_check_container_perm_false():
'''Ensure Permission.check returns False for PermissionContainers of Permissions '''Ensure Permission.check returns False for PermissionContainers of Permissions
''' '''
perm = milla.auth.permissions.Permission('foo') perm = milla.auth.permissions.Permission('foo')
req = milla.auth.permissions.Permission('bar') req = milla.auth.permissions.Permission('bar')
container = milla.auth.permissions.PermissionContainer([perm]) container = milla.auth.permissions.PermissionContainer([perm])
@ -74,14 +74,14 @@ def test_permission_check_container_perm_false():
def test_permission_container_iter(): def test_permission_container_iter():
'''Ensure iterating a PermissionContainer yields all permissions '''Ensure iterating a PermissionContainer yields all permissions
''' '''
container = milla.auth.permissions.PermissionContainer(['foo'], ['bar']) container = milla.auth.permissions.PermissionContainer(['foo'], ['bar'])
assert list(container) == ['foo', 'bar'] assert list(container) == ['foo', 'bar']
def test_permission_and(): def test_permission_and():
'''Ensure AND-ing Permissions returns a PermissionRequirementAll '''Ensure AND-ing Permissions returns a PermissionRequirementAll
''' '''
perm1 = milla.auth.permissions.Permission('foo') perm1 = milla.auth.permissions.Permission('foo')
perm2 = milla.auth.permissions.Permission('bar') perm2 = milla.auth.permissions.Permission('bar')
req = perm1 & perm2 req = perm1 & perm2
@ -91,7 +91,7 @@ def test_permission_and():
def test_permission_or(): def test_permission_or():
'''Ensure OR-ing Permissions returns a PermissionRequirementAny '''Ensure OR-ing Permissions returns a PermissionRequirementAny
''' '''
perm1 = milla.auth.permissions.Permission('foo') perm1 = milla.auth.permissions.Permission('foo')
perm2 = milla.auth.permissions.Permission('bar') perm2 = milla.auth.permissions.Permission('bar')
req = perm1 | perm2 req = perm1 | perm2
@ -101,7 +101,7 @@ def test_permission_or():
def test_permission_str(): def test_permission_str():
'''Ensure calling str on a Permission returns its name '''Ensure calling str on a Permission returns its name
''' '''
perm_name = 'foo' perm_name = 'foo'
perm = milla.auth.permissions.Permission(perm_name) perm = milla.auth.permissions.Permission(perm_name)
assert str(perm) == perm_name assert str(perm) == perm_name
@ -109,7 +109,7 @@ def test_permission_str():
def test_permission_eq(): def test_permission_eq():
'''Ensure two Permissions with the same name are equal but not identical '''Ensure two Permissions with the same name are equal but not identical
''' '''
perm_name = 'foo' perm_name = 'foo'
perm1 = milla.auth.permissions.Permission(perm_name) perm1 = milla.auth.permissions.Permission(perm_name)
perm2 = milla.auth.permissions.Permission(perm_name) perm2 = milla.auth.permissions.Permission(perm_name)
@ -119,7 +119,7 @@ def test_permission_eq():
def test_permission_check_container_group(): def test_permission_check_container_group():
'''Test group permissions in PermissionContainer objects '''Test group permissions in PermissionContainer objects
''' '''
perm = milla.auth.permissions.Permission('foo') perm = milla.auth.permissions.Permission('foo')
req = milla.auth.permissions.Permission('foo') req = milla.auth.permissions.Permission('foo')
container = milla.auth.permissions.PermissionContainer([], [perm]) container = milla.auth.permissions.PermissionContainer([], [perm])
@ -128,7 +128,7 @@ def test_permission_check_container_group():
def test_permissionrequirement_all(): def test_permissionrequirement_all():
'''Ensure PermissionRequirementAll requires all listed permissions '''Ensure PermissionRequirementAll requires all listed permissions
''' '''
perm1 = milla.auth.permissions.Permission('foo') perm1 = milla.auth.permissions.Permission('foo')
perm2 = milla.auth.permissions.Permission('bar') perm2 = milla.auth.permissions.Permission('bar')
req = milla.auth.permissions.PermissionRequirementAll(perm1, perm2) req = milla.auth.permissions.PermissionRequirementAll(perm1, perm2)
@ -141,7 +141,7 @@ def test_permissionrequirement_all():
def test_permissionrequirement_any(): def test_permissionrequirement_any():
'''Ensure PermissionRequirementAll requires only one permission '''Ensure PermissionRequirementAll requires only one permission
''' '''
perm1 = milla.auth.permissions.Permission('foo') perm1 = milla.auth.permissions.Permission('foo')
perm2 = milla.auth.permissions.Permission('bar') perm2 = milla.auth.permissions.Permission('bar')
req = milla.auth.permissions.PermissionRequirementAny(perm1, perm2) req = milla.auth.permissions.PermissionRequirementAny(perm1, perm2)
@ -154,7 +154,7 @@ def test_permissionrequirement_any():
def test_exception_callable(): def test_exception_callable():
'''Ensure that NotAuthorizedException is a valid controller callable '''Ensure that NotAuthorizedException is a valid controller callable
''' '''
exc = milla.auth.NotAuthorized() exc = milla.auth.NotAuthorized()
request = milla.Request.blank('/') request = milla.Request.blank('/')
response = exc(request) response = exc(request)
@ -165,7 +165,7 @@ def test_exception_callable():
def test_request_validator_nouser(): def test_request_validator_nouser():
'''Ensure ensure requests without a user attribute raise NotAuthorized '''Ensure ensure requests without a user attribute raise NotAuthorized
''' '''
validator = milla.auth.RequestValidator() validator = milla.auth.RequestValidator()
request = milla.Request.blank('/') request = milla.Request.blank('/')
validator.validate(request) validator.validate(request)
@ -174,7 +174,7 @@ def test_request_validator_nouser():
def test_request_validator_emptyuser(): def test_request_validator_emptyuser():
'''Ensure requests with an empty user raise NotAuthorized '''Ensure requests with an empty user raise NotAuthorized
''' '''
validator = milla.auth.RequestValidator() validator = milla.auth.RequestValidator()
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = None request.user = None
@ -182,13 +182,13 @@ def test_request_validator_emptyuser():
def test_request_validator_user_noperms(): def test_request_validator_user_noperms():
'''Ensure user permissions are not checked if no requirement is given '''Ensure user permissions are not checked if no requirement is given
If no ``requirement`` is given to If no ``requirement`` is given to
:py:meth:`milla.auth.RequestValidator.validate`, then the fact that the :py:meth:`milla.auth.RequestValidator.validate`, then the fact that the
request's ``user`` attribute doesn't have a ``permissions`` attribute request's ``user`` attribute doesn't have a ``permissions`` attribute
shouldn't matter. shouldn't matter.
''' '''
class User(object): class User(object):
pass pass
@ -201,10 +201,10 @@ def test_request_validator_user_noperms():
def test_request_validator_missingperms(): def test_request_validator_missingperms():
'''Ensure requests whose user has no permissions attribute are invalid '''Ensure requests whose user has no permissions attribute are invalid
''' '''
class User(object): class User(object):
pass pass
validator = milla.auth.RequestValidator() validator = milla.auth.RequestValidator()
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = User() request.user = User()
@ -215,10 +215,10 @@ def test_request_validator_missingperms():
def test_request_validator_emptyperms(): def test_request_validator_emptyperms():
'''Ensure requests whose user has an empty set of permissions are invalid '''Ensure requests whose user has an empty set of permissions are invalid
''' '''
class User(object): class User(object):
pass pass
validator = milla.auth.RequestValidator() validator = milla.auth.RequestValidator()
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = User() request.user = User()
@ -230,10 +230,10 @@ def test_request_validator_emptyperms():
def test_request_validator_incorrectperms(): def test_request_validator_incorrectperms():
'''Ensure requests whose user has incorrect permissions raise NotAuthorized '''Ensure requests whose user has incorrect permissions raise NotAuthorized
''' '''
class User(object): class User(object):
pass pass
validator = milla.auth.RequestValidator() validator = milla.auth.RequestValidator()
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = User() request.user = User()
@ -244,10 +244,10 @@ def test_request_validator_incorrectperms():
def test_request_validator_correctperms(): def test_request_validator_correctperms():
'''Ensure requests from users with appropriate permissions are valid '''Ensure requests from users with appropriate permissions are valid
''' '''
class User(object): class User(object):
pass pass
validator = milla.auth.RequestValidator() validator = milla.auth.RequestValidator()
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = User() request.user = User()
@ -258,7 +258,7 @@ def test_request_validator_correctperms():
def test_find_request_kwarg(): def test_find_request_kwarg():
'''Ensure _find_request finds a request in keyword arguments '''Ensure _find_request finds a request in keyword arguments
''' '''
request = milla.Request.blank('/') request = milla.Request.blank('/')
found = milla.auth.decorators._find_request('foo', request=request) found = milla.auth.decorators._find_request('foo', request=request)
assert found is request assert found is request
@ -266,7 +266,7 @@ def test_find_request_kwarg():
def test_find_request_arg1(): def test_find_request_arg1():
'''Ensure _find_request finds a request in position 1 '''Ensure _find_request finds a request in position 1
''' '''
request = milla.Request.blank('/') request = milla.Request.blank('/')
found = milla.auth.decorators._find_request(request) found = milla.auth.decorators._find_request(request)
assert found is request assert found is request
@ -274,7 +274,7 @@ def test_find_request_arg1():
def test_find_request_arg2(): def test_find_request_arg2():
'''Ensure _find_request finds a request in another position '''Ensure _find_request finds a request in another position
''' '''
request = milla.Request.blank('/') request = milla.Request.blank('/')
found = milla.auth.decorators._find_request('foo', request) found = milla.auth.decorators._find_request('foo', request)
assert found is request assert found is request
@ -282,14 +282,14 @@ def test_find_request_arg2():
def test_auth_required_true(): def test_auth_required_true():
'''Test the auth_required decorator with a valid user '''Test the auth_required decorator with a valid user
''' '''
class User(object): class User(object):
pass pass
@milla.auth.decorators.auth_required @milla.auth.decorators.auth_required
def controller(request): def controller(request):
return 'success' return 'success'
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = User() request.user = User()
response = controller(request) response = controller(request)
@ -298,7 +298,7 @@ def test_auth_required_true():
def test_auth_required_false(): def test_auth_required_false():
'''Test the auth_required decorator with no user '''Test the auth_required decorator with no user
''' '''
@milla.auth.decorators.auth_required @milla.auth.decorators.auth_required
def controller(request): def controller(request):
return 'success' return 'success'
@ -312,10 +312,10 @@ def test_auth_required_false():
def test_require_perms_none(): def test_require_perms_none():
'''Test the require_perms decorator with no requirement '''Test the require_perms decorator with no requirement
''' '''
class User(object): class User(object):
pass pass
@milla.auth.decorators.require_perms() @milla.auth.decorators.require_perms()
def controller(request): def controller(request):
return 'success' return 'success'
@ -328,14 +328,14 @@ def test_require_perms_none():
def test_require_perms_valid_str(): def test_require_perms_valid_str():
'''Test the require_perms decorator with valid permissions as strings '''Test the require_perms decorator with valid permissions as strings
''' '''
class User(object): class User(object):
pass pass
@milla.auth.decorators.require_perms('foo') @milla.auth.decorators.require_perms('foo')
def controller(request): def controller(request):
return 'success' return 'success'
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = User() request.user = User()
request.user.permissions = ['foo'] request.user.permissions = ['foo']
@ -345,15 +345,15 @@ def test_require_perms_valid_str():
def test_require_perms_valid_permission(): def test_require_perms_valid_permission():
'''Test the require_perms decorator with valid permissions as Permissions '''Test the require_perms decorator with valid permissions as Permissions
''' '''
class User(object): class User(object):
pass pass
req = milla.auth.permissions.Permission('foo') req = milla.auth.permissions.Permission('foo')
@milla.auth.decorators.require_perms(req) @milla.auth.decorators.require_perms(req)
def controller(request): def controller(request):
return 'success' return 'success'
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = User() request.user = User()
request.user.permissions = ['foo'] request.user.permissions = ['foo']
@ -363,10 +363,10 @@ def test_require_perms_valid_permission():
def test_require_perms_multi_valid_string(): def test_require_perms_multi_valid_string():
'''Test the require_perms decorator with multiple requirements as strings '''Test the require_perms decorator with multiple requirements as strings
''' '''
class User(object): class User(object):
pass pass
@milla.auth.decorators.require_perms('foo', 'bar') @milla.auth.decorators.require_perms('foo', 'bar')
def controller(request): def controller(request):
return 'success' return 'success'
@ -380,10 +380,10 @@ def test_require_perms_multi_valid_string():
def test_require_perms_multi_valid_permission(): def test_require_perms_multi_valid_permission():
'''Test the require_perms decorator with multiple requirements as Permissions '''Test the require_perms decorator with multiple requirements as Permissions
''' '''
class User(object): class User(object):
pass pass
req1 = milla.auth.permissions.Permission('foo') req1 = milla.auth.permissions.Permission('foo')
req2 = milla.auth.permissions.Permission('bar') req2 = milla.auth.permissions.Permission('bar')
@milla.auth.decorators.require_perms(req1, req2) @milla.auth.decorators.require_perms(req1, req2)
@ -399,14 +399,14 @@ def test_require_perms_multi_valid_permission():
def test_require_perms_invalid_none(): def test_require_perms_invalid_none():
'''Test the require_perms decorator with no permissions '''Test the require_perms decorator with no permissions
''' '''
class User(object): class User(object):
pass pass
@milla.auth.decorators.require_perms('foo') @milla.auth.decorators.require_perms('foo')
def controller(request): def controller(request):
return 'success' return 'success'
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = User() request.user = User()
response = controller(request) response = controller(request)
@ -416,10 +416,10 @@ def test_require_perms_invalid_none():
def test_require_perms_invalid_empty(): def test_require_perms_invalid_empty():
'''Test the require_perms decorator with an empty permissions set '''Test the require_perms decorator with an empty permissions set
''' '''
class User(object): class User(object):
pass pass
@milla.auth.decorators.require_perms('foo') @milla.auth.decorators.require_perms('foo')
def controller(request): def controller(request):
return 'success' return 'success'
@ -434,14 +434,14 @@ def test_require_perms_invalid_empty():
def test_require_perms_invalid_string(): def test_require_perms_invalid_string():
'''Test the require_perms decorator with invalid permissions as strings '''Test the require_perms decorator with invalid permissions as strings
''' '''
class User(object): class User(object):
pass pass
@milla.auth.decorators.require_perms('foo') @milla.auth.decorators.require_perms('foo')
def controller(request): def controller(request):
return 'success' return 'success'
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = User() request.user = User()
request.user.permissions = ['bar'] request.user.permissions = ['bar']
@ -452,15 +452,15 @@ def test_require_perms_invalid_string():
def test_require_perms_invalid_permission(): def test_require_perms_invalid_permission():
'''Test the require_perms decorator with invalid permissions as Permissions '''Test the require_perms decorator with invalid permissions as Permissions
''' '''
class User(object): class User(object):
pass pass
req = milla.auth.permissions.Permission('foo') req = milla.auth.permissions.Permission('foo')
@milla.auth.decorators.require_perms(req) @milla.auth.decorators.require_perms(req)
def controller(request): def controller(request):
return 'success' return 'success'
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = User() request.user = User()
request.user.permissions = ['bar'] request.user.permissions = ['bar']
@ -471,14 +471,14 @@ def test_require_perms_invalid_permission():
def test_require_perms_multi_invalid_string(): def test_require_perms_multi_invalid_string():
'''Test the require_perms decorator with multiple invalid permissions as strings '''Test the require_perms decorator with multiple invalid permissions as strings
''' '''
class User(object): class User(object):
pass pass
@milla.auth.decorators.require_perms('foo', 'bar') @milla.auth.decorators.require_perms('foo', 'bar')
def controller(request): def controller(request):
return 'success' return 'success'
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = User() request.user = User()
request.user.permissions = ['bar'] request.user.permissions = ['bar']
@ -489,16 +489,16 @@ def test_require_perms_multi_invalid_string():
def test_require_perms_multi_invalid_permission(): def test_require_perms_multi_invalid_permission():
'''Test the require_perms decorator with multiple invalid permissions as Permissions '''Test the require_perms decorator with multiple invalid permissions as Permissions
''' '''
class User(object): class User(object):
pass pass
req1 = milla.auth.permissions.Permission('foo') req1 = milla.auth.permissions.Permission('foo')
req2 = milla.auth.permissions.Permission('foo') req2 = milla.auth.permissions.Permission('foo')
@milla.auth.decorators.require_perms(req1, req2) @milla.auth.decorators.require_perms(req1, req2)
def controller(request): def controller(request):
return 'success' return 'success'
request = milla.Request.blank('/') request = milla.Request.blank('/')
request.user = User() request.user = User()
request.user.permissions = ['bar'] request.user.permissions = ['bar']

View File

@ -6,14 +6,13 @@
:Updater: $Author$ :Updater: $Author$
''' '''
import milla.dispatch.routing import milla.dispatch.routing
import milla.controllers
import nose.tools import nose.tools
def fake_controller():
pass
def test_static(): def test_static():
'''Ensure the dispatcher can resolve a static path '''Ensure the dispatcher can resolve a static path
Given the path ``/foo/bar/baz`` and a route for the exact same Given the path ``/foo/bar/baz`` and a route for the exact same
path, the resolver should return the controller mapped to the path, the resolver should return the controller mapped to the
route. route.
@ -29,7 +28,7 @@ def test_static():
def test_urlvars(): def test_urlvars():
'''Ensure the dispatcher can resolve a path with variable segments '''Ensure the dispatcher can resolve a path with variable segments
Given the path ``/foo/abc/def`` and a route ``/foo/{bar}/{baz}``, Given the path ``/foo/abc/def`` and a route ``/foo/{bar}/{baz}``,
the resolver should return the controller mapped to the route with the resolver should return the controller mapped to the route with
preset keywords ``bar='abc', baz='def'``. preset keywords ``bar='abc', baz='def'``.
@ -48,7 +47,7 @@ def test_urlvars():
@nose.tools.raises(milla.dispatch.UnresolvedPath) @nose.tools.raises(milla.dispatch.UnresolvedPath)
def test_regexp_urlvar(): def test_regexp_urlvar():
'''Ensure the dispatcher can resolve alternate regexps in urlvars '''Ensure the dispatcher can resolve alternate regexps in urlvars
Given a route ``/test/{arg:[a-z]+}``, the resolver should return Given a route ``/test/{arg:[a-z]+}``, the resolver should return
the mapped controller for the path ``/test/abcde``, but not the the mapped controller for the path ``/test/abcde``, but not the
path ``/test/1234``. path ``/test/1234``.
@ -68,7 +67,7 @@ def test_regexp_urlvar():
@nose.tools.raises(milla.dispatch.UnresolvedPath) @nose.tools.raises(milla.dispatch.UnresolvedPath)
def test_unresolved(): def test_unresolved():
'''Ensure the resolver raises an exception for unresolved paths '''Ensure the resolver raises an exception for unresolved paths
Given a route ``/test``, the resolver should raise Given a route ``/test``, the resolver should raise
:py:exc:`~milla.dispatch.UnresolvedPath` for the path ``/tset``. :py:exc:`~milla.dispatch.UnresolvedPath` for the path ``/tset``.
''' '''
@ -82,7 +81,7 @@ def test_unresolved():
def test_unrelated(): def test_unrelated():
'''Ensure the dispatcher is not confused by unrelated paths '''Ensure the dispatcher is not confused by unrelated paths
Given routes for ``/testA`` and ``/testB``, the resolver should Given routes for ``/testA`` and ``/testB``, the resolver should
return the controller mapped to the former for the path ``/testA``, return the controller mapped to the former for the path ``/testA``,
without regard for the latter. without regard for the latter.
@ -102,18 +101,17 @@ def test_unrelated():
def test_string_controller(): def test_string_controller():
'''Ensure the dispatcher can find a controller given a string '''Ensure the dispatcher can find a controller given a string
Given a string path to a controller function, the callable defined Given a string path to a controller function, the callable defined
therein should be returned by the resolver for the corresponding therein should be returned by the resolver for the corresponding
path. path.
''' '''
router = milla.dispatch.routing.Router() router = milla.dispatch.routing.Router()
router.add_route('/test', 'milla.tests.test_routing:fake_controller') router.add_route('/test', 'milla.controllers:Controller')
func = router.resolve('/test') func = router.resolve('/test')
assert func.func == fake_controller assert func.func == milla.controllers.Controller
@nose.tools.raises(milla.HTTPMovedPermanently)
def test_trailing_slash_redir(): def test_trailing_slash_redir():
'''Paths that match except the trailing slash return a HTTP redirect '''Paths that match except the trailing slash return a HTTP redirect
''' '''
@ -125,16 +123,21 @@ def test_trailing_slash_redir():
router.add_route('/test/', controller) router.add_route('/test/', controller)
func = router.resolve('/test') func = router.resolve('/test')
assert func is not controller assert func is not controller
func() try:
func(milla.Request.blank('/test'))
except milla.HTTPMovedPermanently as e:
assert e.location == '/test/'
else:
raise AssertionError('Redirect not raised')
@nose.tools.raises(milla.dispatch.routing.UnresolvedPath) @nose.tools.raises(milla.dispatch.routing.UnresolvedPath)
def test_trailing_slash_none(): def test_trailing_slash_none():
'''Paths that match except the trailing slash are ignored '''Paths that match except the trailing slash are ignored
''' '''
def controller(): def controller():
pass pass
router = milla.dispatch.routing.Router(None) router = milla.dispatch.routing.Router(None)
router.add_route('/test/', controller) router.add_route('/test/', controller)
router.resolve('/test') router.resolve('/test')
@ -142,11 +145,11 @@ def test_trailing_slash_none():
def test_trailing_slash_silent(): def test_trailing_slash_silent():
'''Paths that match except the trailing slash are treated the same '''Paths that match except the trailing slash are treated the same
''' '''
def controller(): def controller():
pass pass
router = milla.dispatch.routing.Router(milla.dispatch.routing.Router.SILENT) router = milla.dispatch.routing.Router(milla.dispatch.routing.Router.SILENT)
router.add_route('/test/', controller) router.add_route('/test/', controller)
func = router.resolve('/test') func = router.resolve('/test')
assert func.func is controller assert func.func is controller

View File

@ -9,7 +9,7 @@ import milla.dispatch.traversal
def test_root(): def test_root():
'''Ensure the root path resolves to the root handler '''Ensure the root path resolves to the root handler
Given the path ``/``, the resolver should return the root handler, Given the path ``/``, the resolver should return the root handler,
which was given to it at initialization which was given to it at initialization
''' '''
@ -24,8 +24,8 @@ def test_root():
def test_unrelated(): def test_unrelated():
'''Ensure unrelated attributes do not confuse the dispatcher '''Ensure unrelated attributes do not confuse the dispatcher
Given the path ``/`` and a root handler with attributes and Given the path ``/`` and a root handler with attributes and
methods, the resolver should still return the root handler methods, the resolver should still return the root handler
''' '''
@ -41,7 +41,7 @@ def test_unrelated():
def test_unresolved(): def test_unresolved():
'''Ensure that the resolver returns remaining parts '''Ensure that the resolver returns remaining parts
Given the path ``/foo/bar/baz`` and a root handler with no Given the path ``/foo/bar/baz`` and a root handler with no
children, the resolver should raise children, the resolver should raise
:py:exc:`~milla.dispatch.UnresolvedPath` :py:exc:`~milla.dispatch.UnresolvedPath`
@ -61,7 +61,7 @@ def test_unresolved():
def test_method(): def test_method():
'''Ensure the resolver finds an instance method handler '''Ensure the resolver finds an instance method handler
Given the path ``/test`` and a root handler with an instance Given the path ``/test`` and a root handler with an instance
method named ``test``, the resolver should return that method. method named ``test``, the resolver should return that method.
''' '''
@ -77,7 +77,7 @@ def test_method():
def test_nested_class(): def test_nested_class():
'''Ensure the resolver finds a nested class handler '''Ensure the resolver finds a nested class handler
Given the path ``/test`` and a root handler with an inner class Given the path ``/test`` and a root handler with an inner class
named ``test``, the resolver should return the inner class. named ``test``, the resolver should return the inner class.
''' '''
@ -93,7 +93,7 @@ def test_nested_class():
def test_nested_class_method(): def test_nested_class_method():
'''Ensure the resolver finds an instance method of a nested class '''Ensure the resolver finds an instance method of a nested class
Given the path ``/test/test`` and a root handler with an inner Given the path ``/test/test`` and a root handler with an inner
class named ``test``, which in turn has an instance method named class named ``test``, which in turn has an instance method named
``test``, the resolver should return the ``test`` method of the ``test``, the resolver should return the ``test`` method of the
@ -112,7 +112,7 @@ def test_nested_class_method():
def test_attribute(): def test_attribute():
'''Ensure the resolver finds a handler in an instance attribute '''Ensure the resolver finds a handler in an instance attribute
Given the path ``/test`` and a root handler with an attribute named Given the path ``/test`` and a root handler with an attribute named
``test`` containing another class, the resolver should return that ``test`` containing another class, the resolver should return that
class. class.
@ -130,10 +130,10 @@ def test_attribute():
def test_default(): def test_default():
'''Ensure the resolver finds the default handler '''Ensure the resolver finds the default handler
Given the path ``/test`` and a root handler with a method named Given the path ``/test`` and a root handler with a method named
``default``, but no method named ``test``, the resolver should ``default``, but no method named ``test``, the resolver should
return the ``default`` method. return the ``default`` method.
''' '''
class Root(object): class Root(object):
@ -147,7 +147,7 @@ def test_default():
def test_nested_default(): def test_nested_default():
'''Ensure the resolver finds a nested default handler '''Ensure the resolver finds a nested default handler
Given the path ``/test/bar`` and a root handler with a ``test`` Given the path ``/test/bar`` and a root handler with a ``test``
attribute containing a class instance with a ``default`` method but attribute containing a class instance with a ``default`` method but
no ``bar`` method, the resolver should return the ``default`` no ``bar`` method, the resolver should return the ``default``

220
test/test_vary.py Normal file
View File

@ -0,0 +1,220 @@
from milla import vary
import collections
import functools
import milla
import nose.tools
import sys
try:
from unittest import mock
except ImportError:
import mock
PY2 = sys.version_info[0] == 2
PY3 = sys.version_info[0] == 3
def test_renders_decorator():
'''renders modifies and returns the decorated object'''
def func():
pass
func2 = vary.renders('text/html')(func)
assert func2 is func
assert 'text/html' in func.renders
def test_default_renderer_decorator():
'''default_renderer modifies and returns the decorated object'''
def func():
pass
func2 = vary.default_renderer(func)
assert func2 is func
assert func.default_renderer
def test_variedresponsemeta_renderers():
'''VariedResponseMeta adds renderers dict to implementation classes'''
TestClass = vary.VariedResponseMeta('TestClass', (object,), {})
assert isinstance(TestClass.renderers, collections.Mapping)
def test_variedresponsemeta_default_renderer():
'''VariedResponseMeta adds default_type to implementation classes'''
TestClass = vary.VariedResponseMeta('TestClass', (object,), {})
assert TestClass.default_type is None
def test_variedresponsemeta_renders():
'''Test VariedResponseMeta implementation class renderers population'''
VariedResponse = vary.VariedResponseMeta('VariedResponse', (object,), {})
class TestClass(VariedResponse):
@vary.renders('text/html')
def render_html(self, context):
pass
if PY2:
want_func = TestClass.render_html.__func__
else:
want_func = TestClass.render_html
assert TestClass.renderers['text/html'] is want_func
def test_variedresponsemeta_default_renderer():
'''Test VariedResponseMeta implementation class sets default type'''
VariedResponse = vary.VariedResponseMeta('VariedResponse', (object,), {})
class TestClass(VariedResponse):
@vary.default_renderer
@vary.renders('text/html')
def render_html(self, context):
pass
assert TestClass.default_type == 'text/html'
def test_variedresponsebase_init_super():
'''VariedResponseBase.__init__ calls Response.__init__'''
request = milla.Request.blank('http://localhost/')
with mock.patch.object(milla.Response, '__init__') as init:
vary.VariedResponseBase(request, 'a', b='c')
assert init.called_with('a', b='c')
def test_variedresponsebase_for_request():
'''VariedResponseBase.for_request returns a partial'''
request = milla.Request.blank('http://localhost/')
klass = vary.VariedResponseBase.for_request(request)
assert isinstance(klass, functools.partial), klass
def test_variedresponsebase_set_payload_set_vary():
'''VariedResponseBase.set_payload sets the Vary response header'''
def render_html(response, context):
pass
request = milla.Request.blank('http://localhost/')
response = vary.VariedResponseBase(request)
response.renderers['text/html'] = render_html
response.set_payload({})
assert response.headers['Vary'] == 'Accept'
def test_variedresponsebase_set_payload_add_vary():
'''VariedResponseBase.set_payload adds to the Vary response header'''
def render_html(response, context):
pass
request = milla.Request.blank('http://localhost/')
response = vary.VariedResponseBase(request)
response.renderers['text/html'] = render_html
response.vary = ('Cookie',)
response.set_payload({})
assert response.headers['Vary'] == 'Cookie, Accept'
def test_variedresponsebase_set_payload_match():
'''VariedResponseBase.set_payload calls the matching renderer'''
class State(object):
html_called = False
json_called = False
def render_html(response, state):
state.html_called = True
render_html.renders = ('text/html',)
def render_json(response, state):
state.json_called = True
render_json.renders = ('application/json',)
def check_type(accept, attr):
request = milla.Request.blank('http://localhost/')
request.accept = accept
response = vary.VariedResponseBase(request)
response.renderers = {
'text/html': render_html,
'application/json': render_json,
}
state = State()
response.set_payload(state)
assert getattr(state, attr)
tests = [
('text/html', 'html_called'),
('application/json', 'json_called'),
]
for accept, attr in tests:
yield check_type, accept, attr
@nose.tools.raises(milla.HTTPNotAcceptable)
def test_variedresponsebase_set_payload_not_acceptable():
'''VariedResponseBase.set_payload raises HTTPNotAcceptable'''
def render_html(response, context):
pass
request = milla.Request.blank('http://localhost/')
request.accept = 'text/plain'
response = vary.VariedResponseBase(request)
response.renderers['text/html'] = render_html
response.set_payload({})
def test_variedresponsebase_set_payload_default_format():
'''VariedResponseBase.set_payload falls back to the default renderer'''
class State(object):
called = False
state = State()
def render_html(response, context):
state.called = True
request = milla.Request.blank('http://localhost/')
request.accept = 'text/plain'
response = vary.VariedResponseBase(request)
response.renderers['text/html'] = render_html
response.default_type = 'text/html'
ctx = {}
response.set_payload(ctx)
assert state.called
def test_variedresponsebase_set_payload_renderer_unknown_kwargs():
'''VariedResponseBase.set_payload ignores unknown keyword arguments'''
def render_html(response, context):
pass
request = milla.Request.blank('http://localhost/')
response = vary.VariedResponseBase(request)
response.renderers['text/html'] = render_html
response.set_payload({}, foo='bar')