Compare commits
83 Commits
Author | SHA1 | Date |
---|---|---|
|
bd65cf01fb | |
|
c97d570f66 | |
|
40a0e8ead4 | |
|
d955d23e91 | |
|
f3a98c2697 | |
|
807a487639 | |
|
ff27f3a917 | |
|
71d00e4207 | |
|
83971013d0 | |
|
44a28fda68 | |
|
4090df1286 | |
|
95caf1020b | |
|
a593bb762f | |
|
40ede10425 | |
|
634c914f6d | |
|
3beca18523 | |
|
da406fcce8 | |
|
922a82e4e8 | |
|
94b98a0620 | |
|
86b19bb9e7 | |
|
6519cfbb9e | |
|
fc04300140 | |
|
f2602388e3 | |
|
4ffb3f3707 | |
|
f972382d12 | |
|
1018519d73 | |
|
0754f4b823 | |
|
230da47d5b | |
|
922778ee4c | |
|
7cfb10066e | |
|
e6774204a6 | |
|
c69dbed7ee | |
|
cf94a4d600 | |
|
a2d8f6f098 | |
|
8e699969e2 | |
|
872461d515 | |
|
d068d22e38 | |
|
345333614c | |
|
14c8c28b9a | |
|
243df2be47 | |
|
708e59125e | |
|
fc42519f0d | |
|
9da12232d4 | |
|
65ebf76b45 | |
|
3408919faa | |
|
0064f70160 | |
|
fed7d0fb3d | |
|
4085039997 | |
|
d635e83431 | |
|
f5f7e76dae | |
|
f14b744ef7 | |
|
32e96f5807 | |
|
f8e89d46c6 | |
|
c4e9397e8c | |
|
11271ebc31 | |
|
47103b76a4 | |
|
91f2d954e7 | |
|
17cac57721 | |
|
232bb17d6f | |
|
dc79fea9db | |
|
5cd113ee46 | |
|
0c7ebae8a9 | |
|
5735d2b027 | |
|
afcbf3d314 | |
|
a8a90294d2 | |
|
d2705ad18e | |
|
c721c7d06a | |
|
e36f17452e | |
|
fe54d0a666 | |
|
a89e2396db | |
|
aba45a2107 | |
|
fbc58de27a | |
|
10e2ad803b | |
|
17e7486784 | |
|
eb347e7fb5 | |
|
5529b66a57 | |
|
dd05d95dba | |
|
b50ca751fb | |
|
4c06340cea | |
|
2980b438da | |
|
fa97bd7b03 | |
|
6c034b88d3 | |
|
3b6571623f |
|
@ -0,0 +1,5 @@
|
||||||
|
/build/
|
||||||
|
/dist/
|
||||||
|
*.egg-info/
|
||||||
|
__pycache__/
|
||||||
|
*.py[co]
|
|
@ -4,3 +4,4 @@ syntax: regexp
|
||||||
.*\.egg-info/
|
.*\.egg-info/
|
||||||
.*\.swp
|
.*\.swp
|
||||||
.*\.py[co]
|
.*\.py[co]
|
||||||
|
^\.coverage$
|
||||||
|
|
3
.hgtags
3
.hgtags
|
@ -1 +1,4 @@
|
||||||
b3553fb88649e28a7fae7c1ce348625b38d06b65 0.1
|
b3553fb88649e28a7fae7c1ce348625b38d06b65 0.1
|
||||||
|
e7c7497afb2137fec4445e4d04c6d7405f0fa289 0.1.2
|
||||||
|
2d04d03ce334502eff4e07fd36f6536ded58a2d3 0.2
|
||||||
|
3b8acd86b010ac48b99dfa95859f6522073c142a 0.2.1
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
<?eclipse-pydev version="1.0"?>
|
<?eclipse-pydev version="1.0"?>
|
||||||
|
|
||||||
<pydev_project>
|
<pydev_project>
|
||||||
<pydev_property name="org.python.pydev.PYTHON_PROJECT_INTERPRETER">Default</pydev_property>
|
<pydev_property name="org.python.pydev.PYTHON_PROJECT_INTERPRETER">python3</pydev_property>
|
||||||
<pydev_property name="org.python.pydev.PYTHON_PROJECT_VERSION">python 2.7</pydev_property>
|
<pydev_property name="org.python.pydev.PYTHON_PROJECT_VERSION">python 3.0</pydev_property>
|
||||||
<pydev_pathproperty name="org.python.pydev.PROJECT_SOURCE_PATH">
|
<pydev_pathproperty name="org.python.pydev.PROJECT_SOURCE_PATH">
|
||||||
<path>/Milla/src</path>
|
<path>/Milla/src</path>
|
||||||
</pydev_pathproperty>
|
</pydev_pathproperty>
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
.. vim: set ft=rst :
|
||||||
|
|
||||||
|
=====
|
||||||
|
Milla
|
||||||
|
=====
|
||||||
|
|
||||||
|
*Milla* is a simple and lightweight web framework for Python. It built on top
|
||||||
|
of `WebOb`_ and thus implements the `WSGI`_ standard. It aims to be easy to use
|
||||||
|
while imposing no restrictions, allowing web developers to write code the way
|
||||||
|
they want, using the tools, platform, and extensions they choose.
|
||||||
|
|
||||||
|
To get started using *Milla* right away, visit `Downloads`_ to get the latest
|
||||||
|
version, then read the `Documentation`_.
|
||||||
|
|
||||||
|
Example
|
||||||
|
=======
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
from wsgiref import simple_server
|
||||||
|
from milla.dispatch import routing
|
||||||
|
import milla
|
||||||
|
|
||||||
|
|
||||||
|
def hello(request):
|
||||||
|
return 'Hello, world!'
|
||||||
|
|
||||||
|
router = routing.Router()
|
||||||
|
router.add_route('/', hello)
|
||||||
|
app = milla.Application(router)
|
||||||
|
|
||||||
|
httpd = simple_server.make_server('', 8080, app)
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
.. _WebOb: http://webob.org/
|
||||||
|
.. _WSGI: http://wsgi.readthedocs.org/
|
||||||
|
.. _Downloads: https://bitbucket.org/AdmiralNemo/milla/downloads
|
||||||
|
.. _Documentation: http://milla.readthedocs.org/
|
|
@ -0,0 +1,272 @@
|
||||||
|
=================
|
||||||
|
Advanced Features
|
||||||
|
=================
|
||||||
|
|
||||||
|
*Milla* contains several powerful tools that allow web developers complete
|
||||||
|
control over how their applications behave.
|
||||||
|
|
||||||
|
.. contents:: Contents
|
||||||
|
:local:
|
||||||
|
|
||||||
|
Propagating Configuration
|
||||||
|
=========================
|
||||||
|
|
||||||
|
While one possible way for :term:`controller` callables to obtain configuration
|
||||||
|
information would be for them to read it each time a request is made, it would
|
||||||
|
be extremely inefficient. To help with this, *Milla* provides a simple
|
||||||
|
configuration dictionary that can be populated when the
|
||||||
|
:py:class:`~milla.app.Application` is created and will be available to
|
||||||
|
controllers as the :py:attr:`~milla.Request.config` attribute of the request.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
def controller(request):
|
||||||
|
if request.config['t_virus'] == 'escaped':
|
||||||
|
return 'Zombies!'
|
||||||
|
else:
|
||||||
|
return 'Raccoon City is safe, for now'
|
||||||
|
|
||||||
|
router = milla.dispatch.routing.Router()
|
||||||
|
router.add_route('/', controller)
|
||||||
|
application = milla.Application(router)
|
||||||
|
application.config['t_virus'] = 'contained'
|
||||||
|
|
||||||
|
*Milla* provides a simple utility called :py:func:`~milla.util.read_config`
|
||||||
|
that can produce a flat dictionary from a standard configuration file:
|
||||||
|
|
||||||
|
.. code-block:: ini
|
||||||
|
|
||||||
|
; umbrella.ini
|
||||||
|
[t_virus]
|
||||||
|
status = escaped
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# app.py
|
||||||
|
class Root(object):
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if request.config['t_virus.status'] == 'escaped':
|
||||||
|
return 'Zombies!'
|
||||||
|
else:
|
||||||
|
return 'Raccoon City is safe, for now'
|
||||||
|
|
||||||
|
application = milla.Application(Root())
|
||||||
|
application.config.update(read_config('umbrella.ini'))
|
||||||
|
|
||||||
|
Notice that the section name appears in the dictionary key as well as the
|
||||||
|
option name, separated by a dot (``.``). This allows you to specify have
|
||||||
|
multiple options with the same name, as long as they are in different sections.
|
||||||
|
|
||||||
|
Allowing Various HTTP Methods
|
||||||
|
=============================
|
||||||
|
|
||||||
|
By default, *Milla* will reject HTTP requests using methods other than ``GET``,
|
||||||
|
``HEAD``, or ``OPTIONS`` by returning an `HTTP 405`_ response. If you need a
|
||||||
|
controller callable to accept these requests, you need to explicitly specify
|
||||||
|
which methods are allowed.
|
||||||
|
|
||||||
|
To change the request methods that a controller callable accepts, use the
|
||||||
|
:py:meth:`~milla.allow` decorator.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
@milla.allow('GET', 'HEAD', 'POST')
|
||||||
|
def controller(request):
|
||||||
|
response = request.ResponseClass()
|
||||||
|
if request.method == 'POST':
|
||||||
|
release_t_virus()
|
||||||
|
response.text = 'The T Virus has been released. Beware of Zombies'
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
status = check_t_virus()
|
||||||
|
response.text = 'The T Virus is {0}'.format(status)
|
||||||
|
return response
|
||||||
|
|
||||||
|
.. note:: You do not need to explicitly allow the ``OPTIONS`` method; it is
|
||||||
|
always allowed. If an ``OPTIONS`` request is made, *Milla* will
|
||||||
|
automatically create a valid response informing the user of the allowed HTTP
|
||||||
|
request methods for the given request path. Your controller will not be
|
||||||
|
called in this case.
|
||||||
|
|
||||||
|
Controlling Access
|
||||||
|
==================
|
||||||
|
|
||||||
|
*Milla* provides a powerful and extensible authorization framework that can be
|
||||||
|
used to restrict access to different parts of a web application based on
|
||||||
|
properties of the request. This framework has two major components---request
|
||||||
|
validators and permission requirements. To use the framework, you must
|
||||||
|
implement a :term:`request validator` and then apply a :term:`permission
|
||||||
|
requirement` decorator to your :py:term:`controller` callables as needed.
|
||||||
|
|
||||||
|
Request Validators
|
||||||
|
******************
|
||||||
|
|
||||||
|
The default request validator (:py:class:`milla.auth.RequestValidator`) is
|
||||||
|
likely sufficient for most needs, as it assumes that a user is associated with
|
||||||
|
a request (via the ``user`` attribute on the :py:class:`~milla.Request` object)
|
||||||
|
and that the user has a ``permissions`` attribute that contains a list of
|
||||||
|
permissions the user holds.
|
||||||
|
|
||||||
|
.. note:: *Milla* does not automatically add a ``user`` attribute to
|
||||||
|
``Request`` instances, nor does it provide any way of determining what
|
||||||
|
permissions the user has. As such, you will need to handle both of these on
|
||||||
|
your own by utilizing the :ref:`before-after-hooks`.
|
||||||
|
|
||||||
|
Request validators are classes that have a ``validate`` method that takes a
|
||||||
|
request and optionally a permission requirement. The ``validate`` method should
|
||||||
|
return ``None`` if the request meets the requirements or raise
|
||||||
|
:py:exc:`~milla.auth.NotAuthorized` (or a subclass thereof) if it does not.
|
||||||
|
This exception will be called as the controller instead of the actual
|
||||||
|
controller if the request is not valid.
|
||||||
|
|
||||||
|
If you'd like to customize the response to invalid requests or the default
|
||||||
|
request validator is otherwise insufficient for your needs, you can create your
|
||||||
|
own request validator. To do this, you need to do the following:
|
||||||
|
|
||||||
|
1. Create a subclass of :py:class:`~milla.auth.RequestValidator` that overrides
|
||||||
|
:py:meth:`~milla.auth.RequestValidator.validate` method (taking care to
|
||||||
|
return ``None`` for valid requests and raise a subclass of
|
||||||
|
:py:exc:`~milla.auth.NotAuthorized` for invalid requests)
|
||||||
|
2. Register the new request validator in the ``milla.request_validator`` entry
|
||||||
|
point group in your ``setup.py``
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
setup(name='UmbrellaCorpWeb',
|
||||||
|
...
|
||||||
|
entry_points={
|
||||||
|
'milla.request_validator': [
|
||||||
|
'html_login = umbrellacorpweb.lib:RequestValidatorLogin'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
3. Set the ``request_validator`` application config key to the entry point name
|
||||||
|
of the new request validator
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
application = milla.Application(Root())
|
||||||
|
application.config['request_validator'] = 'html_login'
|
||||||
|
|
||||||
|
Permission Requirements
|
||||||
|
***********************
|
||||||
|
|
||||||
|
Permission requirements are used by request validators to check whether or not
|
||||||
|
a request is authorized for a particular controller. Permission requirements
|
||||||
|
are applied to controller callables by using the
|
||||||
|
:py:meth:`~milla.auth.decorators.require_perms` decorator.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class Root(object):
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
return 'This controller requires no permission'
|
||||||
|
|
||||||
|
@milla.require_perms('priority1')
|
||||||
|
def special(self, request):
|
||||||
|
return 'This controller requires Priority 1 permission'
|
||||||
|
|
||||||
|
You can specify advanced permission requirements by using
|
||||||
|
:py:class:`~milla.auth.permissions.Permission` objects:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class Root(object):
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
return 'This controller requires no permission'
|
||||||
|
|
||||||
|
@milla.require_perms(Permission('priority1') | Permission('alpha2'))
|
||||||
|
def special(self, request):
|
||||||
|
return 'This controller requires Priority 1 or Alpha 2 permission'
|
||||||
|
|
||||||
|
Example
|
||||||
|
*******
|
||||||
|
|
||||||
|
The following example will demonstrate how to define a custom request validator
|
||||||
|
that presents an HTML form to the user for failed requests, allowing them to
|
||||||
|
log in:
|
||||||
|
|
||||||
|
``setup.py``:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
setup(name='MyMillaApp',
|
||||||
|
version='1.0',
|
||||||
|
install_requires='Milla',
|
||||||
|
py_modules=['mymillaapp'],
|
||||||
|
entry_points={
|
||||||
|
'milla.request_validator': [
|
||||||
|
'html_login = mymillaapp:RequestValidatorLogin',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
``mymillaapp.py``:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import milla
|
||||||
|
import milla.auth
|
||||||
|
|
||||||
|
class NotAuthorizedLogin(milla.auth.NotAuthorized):
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
response = request.ResponseClass()
|
||||||
|
response.text = '''\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Please Log In</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Please Log In</h1>
|
||||||
|
<div style="color: #ff0000;">{error}</div>
|
||||||
|
<form action="login" method="post">
|
||||||
|
<div>Username:</div>
|
||||||
|
<div><input type="text" name="username"></div>
|
||||||
|
<div>Password:</div>
|
||||||
|
<div><input type="password" name="password"></div>
|
||||||
|
<div><button type="submit">Submit</button></div>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>'''.format(error=self)
|
||||||
|
response.status_int = 401
|
||||||
|
response.headers['WWW-Authenticate'] = 'HTML-Form'
|
||||||
|
return response
|
||||||
|
|
||||||
|
class RequestValidatorLogin(milla.auth.RequestValidator):
|
||||||
|
|
||||||
|
exc_class = NotAuthorizedLogin
|
||||||
|
|
||||||
|
class Root(object):
|
||||||
|
|
||||||
|
def __before__(self, request):
|
||||||
|
# Actually determining the user from the request is beyond the
|
||||||
|
# scope of this example. You'll probably want to use a cookie-
|
||||||
|
# based session and a database for this.
|
||||||
|
request.user = get_user_from_request(request)
|
||||||
|
|
||||||
|
@milla.require_perms('kill_zombies')
|
||||||
|
def kill_zombies(self, request):
|
||||||
|
response = request.ResponseClass()
|
||||||
|
response.text = 'You can kill zombies'
|
||||||
|
return response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
response = request.ResponseClass()
|
||||||
|
response.text = "Nothing to see here. No zombies, that's for sure"
|
||||||
|
return response
|
||||||
|
|
||||||
|
application = milla.Application(Root())
|
||||||
|
|
||||||
|
.. _HTTP 405: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.6
|
|
@ -0,0 +1,63 @@
|
||||||
|
==========
|
||||||
|
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
|
||||||
|
===
|
||||||
|
|
||||||
|
* Python 3 support
|
||||||
|
* Added new utility functions:
|
||||||
|
|
||||||
|
* :py:func:`~milla.util.http_date`
|
||||||
|
* :py:func:`~milla.util.read_config`
|
||||||
|
|
||||||
|
* Added :py:meth:`~milla.Request.static_resource`
|
||||||
|
* Corrected default handling of HTTP ``OPTIONS`` requests (`Issue #5`_)
|
||||||
|
* Deprecated :py:mod:`milla.cli`
|
||||||
|
* Deprecated :py:class:`~milla.dispatch.routing.Generator` in favor of
|
||||||
|
:py:meth:`~milla.Request.create_href`
|
||||||
|
|
||||||
|
0.1.2
|
||||||
|
=====
|
||||||
|
|
||||||
|
* Improvements to :py:class:`~milla.controllers.FaviconController` (`Issue
|
||||||
|
#1`_)
|
||||||
|
|
||||||
|
0.1.1
|
||||||
|
=====
|
||||||
|
|
||||||
|
* Fixed a bug when generating application-relative URLs with
|
||||||
|
:py:class:`~milla.routing.dispatch.URLGenerator`:
|
||||||
|
|
||||||
|
0.1
|
||||||
|
===
|
||||||
|
|
||||||
|
Initial release
|
||||||
|
|
||||||
|
.. _Issue #1: https://bitbucket.org/AdmiralNemo/milla/issue/1
|
||||||
|
.. _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
|
10
doc/conf.py
10
doc/conf.py
|
@ -25,7 +25,8 @@ import sys, os
|
||||||
|
|
||||||
# Add any Sphinx extension module names here, as strings. They can be extensions
|
# Add any Sphinx extension module names here, as strings. They can be extensions
|
||||||
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||||
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode']
|
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.coverage',
|
||||||
|
'sphinx.ext.viewcode', 'sphinx.ext.intersphinx']
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
templates_path = ['_templates']
|
templates_path = ['_templates']
|
||||||
|
@ -41,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, 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.1.2'
|
version = '1.0'
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = version
|
release = version
|
||||||
|
|
||||||
|
@ -86,6 +87,9 @@ pygments_style = 'sphinx'
|
||||||
# A list of ignored prefixes for module index sorting.
|
# A list of ignored prefixes for module index sorting.
|
||||||
#modindex_common_prefix = []
|
#modindex_common_prefix = []
|
||||||
|
|
||||||
|
intersphinx_mapping = {
|
||||||
|
'webob': ('http://docs.webob.org/en/latest/', None)
|
||||||
|
}
|
||||||
|
|
||||||
# -- Options for HTML output ---------------------------------------------------
|
# -- Options for HTML output ---------------------------------------------------
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,321 @@
|
||||||
|
===============
|
||||||
|
Getting Started
|
||||||
|
===============
|
||||||
|
|
||||||
|
*Milla* aims to be lightweight and easy to use. As such, it provides only the
|
||||||
|
tools you need to build your application the way you want, without imposing any
|
||||||
|
restrictions on how to do it.
|
||||||
|
|
||||||
|
.. contents:: Contents
|
||||||
|
:local:
|
||||||
|
|
||||||
|
Milla's Components
|
||||||
|
==================
|
||||||
|
|
||||||
|
*Milla* provides a small set of components that help you build your web
|
||||||
|
application in a simple, efficient manner:
|
||||||
|
|
||||||
|
* WSGI Application wrapper
|
||||||
|
* Two types of URL Dispatchers:
|
||||||
|
|
||||||
|
* Traversal (like CherryPy or Pyramid)
|
||||||
|
* Routing (like Django or Pylons)
|
||||||
|
|
||||||
|
* Authorization framework
|
||||||
|
* Utility functions
|
||||||
|
|
||||||
|
*Milla* does not provide an HTTP server, so you'll have to use one of the many
|
||||||
|
implementations already available, such as `Meinheld`_ or `Paste`_, or another
|
||||||
|
application that understands `WSGI`_, like `Apache HTTPD`_ with the `mod_wsgi`_
|
||||||
|
module.
|
||||||
|
|
||||||
|
``Application`` Objects
|
||||||
|
=======================
|
||||||
|
|
||||||
|
The core class in a *Milla*-based project is its
|
||||||
|
:py:class:`~milla.app.Application` object. ``Application`` objects are used to
|
||||||
|
set up the environment for the application and handle incoming requests.
|
||||||
|
``Application`` instances are *WSGI* callables, meaning they implement the
|
||||||
|
standard ``application(environ, start_response)`` signature.
|
||||||
|
|
||||||
|
To set up an ``Application``, you will need a :term:`URL dispatcher`, which is
|
||||||
|
an object that maps request paths to :term:`controller` callables.
|
||||||
|
|
||||||
|
Choosing a URL Dispatcher
|
||||||
|
=========================
|
||||||
|
|
||||||
|
*Milla* provides two types of URL dispatchers by default, but you can create
|
||||||
|
your own if neither of these suit your needs. The default dispatchers are
|
||||||
|
modeled after the URL dispatchers of other popular web frameworks, but may have
|
||||||
|
small differences.
|
||||||
|
|
||||||
|
A *Milla* application can only have one URL dispatcher, so make sure you choose
|
||||||
|
the one that will work for all of your application's needs.
|
||||||
|
|
||||||
|
Traversal
|
||||||
|
*********
|
||||||
|
|
||||||
|
Object traversal is the simplest form of URL dispatcher, and is the default for
|
||||||
|
*Milla* applications. Object traversal works by looking for path segments as
|
||||||
|
object attributes, beginning with a :term:`root object` until a
|
||||||
|
:term:`controller` is found.
|
||||||
|
|
||||||
|
For example, consider the URL ``http://example.org/myapp/hello``. Assuming the
|
||||||
|
*Milla* application is available at ``/myapp`` (which is controlled by the HTTP
|
||||||
|
server), then the ``/hello`` portion becomes the request path. It contains only
|
||||||
|
one segment, ``hello``. Thus, an attribute called ``hello`` on the :term:`root
|
||||||
|
object` must be the controller that will produce a response to that request.
|
||||||
|
The following code snippet will produce just such an object.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class Root(object):
|
||||||
|
|
||||||
|
def hello(self, request):
|
||||||
|
return 'Hello, world!'
|
||||||
|
|
||||||
|
To use this class as the :term:`root object` for a *Milla* application, pass an
|
||||||
|
instance of it to the :py:class:`~milla.app.Application` constructor:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
application = milla.Application(Root())
|
||||||
|
|
||||||
|
To create URL paths with multiple segments, such as ``/hello/world`` or
|
||||||
|
``/umbrella/corp/bio``, the root object will need to have other objects
|
||||||
|
corresponding to path segments as its attributes.
|
||||||
|
|
||||||
|
This example uses static methods and nested classes:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class Root(object):
|
||||||
|
|
||||||
|
class hello(object):
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def world(request):
|
||||||
|
return 'Hello, world!'
|
||||||
|
|
||||||
|
application = milla.Application(Root)
|
||||||
|
|
||||||
|
This example uses instance methods to create the hierarchy at runtime:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class Root(object):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.umbrella = Umbrella()
|
||||||
|
|
||||||
|
class Umbrella(object):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.corp = Corp()
|
||||||
|
|
||||||
|
class Corp(object):
|
||||||
|
|
||||||
|
def bio(self, request):
|
||||||
|
return 'T-Virus research facility'
|
||||||
|
|
||||||
|
application = milla.Application(Root())
|
||||||
|
|
||||||
|
If an attribute with the name of the next path segment cannot be found, *Milla*
|
||||||
|
will look for a ``default`` attribute.
|
||||||
|
|
||||||
|
While the object traversal dispatch mechanism is simple, it is not very
|
||||||
|
flexible. Because path segments correspond to Python object names, they must
|
||||||
|
adhere to the same restrictions. This means they can only contain ASCII letters
|
||||||
|
and numbers and the underscore (``_``) character. If you need more complex
|
||||||
|
names, dynamic segments, or otherwise more control over the path mapping, you
|
||||||
|
may need to use routing.
|
||||||
|
|
||||||
|
Routing
|
||||||
|
*******
|
||||||
|
|
||||||
|
Routing offers more control of how URL paths are mapped to :term:`controller`
|
||||||
|
callables, but require more specific configuration.
|
||||||
|
|
||||||
|
To use routing, you need to instantiate a
|
||||||
|
:py:class:`~milla.dispatch.routing.Router` object and then populate its routing
|
||||||
|
table with path-to-controller maps. This is done using the
|
||||||
|
:py:meth:`~milla.dispatch.routing.Router.add_route` method.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
def hello(request):
|
||||||
|
return 'Hello, world!'
|
||||||
|
|
||||||
|
router = milla.dispatch.routing.Router()
|
||||||
|
router.add_route('/hello', hello)
|
||||||
|
|
||||||
|
Aft er you've set up a ``Router`` and populated its routing table, pass it to
|
||||||
|
the :py:class:`~milla.app.Application` constructor to use it in a *Milla*
|
||||||
|
application:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
application = milla.Application(router)
|
||||||
|
|
||||||
|
Using routing allows paths to contain dynamic portions which will be passed to
|
||||||
|
controller callables as keyword arguments.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
def hello(request, name):
|
||||||
|
return 'Hello, {0}'.format(name)
|
||||||
|
|
||||||
|
router = milla.dispatch.routing.Router()
|
||||||
|
router.add_route('/hello/{name}', hello)
|
||||||
|
|
||||||
|
application = milla.Application(router)
|
||||||
|
|
||||||
|
In the above example, the path ``/hello/alice`` would map to the ``hello``
|
||||||
|
function, and would return the response ``Hello, alice`` when visited.
|
||||||
|
|
||||||
|
``Router`` instances can have any number of routes in their routing table. To
|
||||||
|
add more routes, simply call ``add_route`` for each path and controller
|
||||||
|
combination you want to expose.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
def hello(request):
|
||||||
|
return 'Hello, world!'
|
||||||
|
|
||||||
|
def tvirus(request):
|
||||||
|
return 'Beware of zombies'
|
||||||
|
|
||||||
|
router = milla.dispatch.routing.Router()
|
||||||
|
router.add_route('/hello', hello)
|
||||||
|
router.add_route('/hello-world', hello)
|
||||||
|
router.add_route('/umbrellacorp/tvirus', tvirus)
|
||||||
|
|
||||||
|
Controller Callables
|
||||||
|
====================
|
||||||
|
|
||||||
|
*Controller callables* are where most of your application's logic will take
|
||||||
|
place. Based on the :abbr:`MVC (Model, View, Controller)` pattern, controllers
|
||||||
|
handle the logic of interaction between the user interface (the *view*) and the
|
||||||
|
data (the *model*). In the context of a *Milla*-based web application,
|
||||||
|
controllers take input (the HTTP request, represented by a
|
||||||
|
:py:class:`~milla.Request` object) and deliver output (the HTTP response,
|
||||||
|
represented by a :py:class:`~milla.Response` object).
|
||||||
|
|
||||||
|
Once you've decided which URL dispatcher you will use, it's time to write
|
||||||
|
controller callables. These can be any type of Python callable, including
|
||||||
|
functions, instance methods, classmethods, or partials. *Milla* will
|
||||||
|
automatically determine the callable type and call it appropriately for each
|
||||||
|
controller callable mapped to a request path.
|
||||||
|
|
||||||
|
This example shows a controller callable as a function (using routing):
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
def index(request):
|
||||||
|
return 'this is the index page'
|
||||||
|
|
||||||
|
def hello(request):
|
||||||
|
return 'hello, world'
|
||||||
|
|
||||||
|
router = milla.dispatch.routing.Router()
|
||||||
|
router.add_route('/', index)
|
||||||
|
router.add_route('/hello', hello)
|
||||||
|
application = milla.Application(router)
|
||||||
|
|
||||||
|
This example is equivalent to the first, but shows a controller callable as a
|
||||||
|
class instance (using traversal):
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
return 'this is the index page'
|
||||||
|
|
||||||
|
def hello(self, request):
|
||||||
|
return 'hello, world'
|
||||||
|
|
||||||
|
application = milla.Application(Controller())
|
||||||
|
|
||||||
|
Controller callables must take at least one argument, which will be an instance
|
||||||
|
of :py:class:`~milla.Request` representing the HTTP request that was made by
|
||||||
|
the user. The ``Request`` instance wraps the *WSGI* environment and exposes all
|
||||||
|
of the available information from the HTTP headers, including path, method
|
||||||
|
name, query string variables, POST data, etc.
|
||||||
|
|
||||||
|
If you are using `Routing`_ and have routes with dynamic path segments, these
|
||||||
|
segments will be passed by name as keyword arguments, so make sure your
|
||||||
|
controller callables accept the same keywords.
|
||||||
|
|
||||||
|
.. _before-after-hooks:
|
||||||
|
|
||||||
|
Before and After Hooks
|
||||||
|
**********************
|
||||||
|
|
||||||
|
You can instruct *Milla* to perform additional operations before and after the
|
||||||
|
controller callable is run. This could, for example, create a `SQLAlchemy`_
|
||||||
|
session before the controller is called and roll back any outstanding
|
||||||
|
transactions after it completes.
|
||||||
|
|
||||||
|
To define the before and after hooks, create an ``__before__`` and/or an
|
||||||
|
``__after__`` attribute on your controller callable. These attributes should be
|
||||||
|
methods that take exactly one argument: the request. For example:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
def setup(request):
|
||||||
|
request.user = 'Alice'
|
||||||
|
|
||||||
|
def teardown(request):
|
||||||
|
del request.user
|
||||||
|
|
||||||
|
def controller(request):
|
||||||
|
return 'Hello, {user}!'.format(user=request.user)
|
||||||
|
controller.__before__ = setup
|
||||||
|
controller.__after__ = teardown
|
||||||
|
|
||||||
|
To simplify this, *Milla* handles instance methods specially, by looking for
|
||||||
|
the ``__before__`` and ``__after__`` methods on the controller callable's class
|
||||||
|
as well as itself.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
|
||||||
|
def __before__(self, request):
|
||||||
|
request.user = 'Alice'
|
||||||
|
|
||||||
|
def __after__(self, request):
|
||||||
|
del request.user
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
return 'Hello, {user}'.format(user=request.user)
|
||||||
|
|
||||||
|
Returing a Response
|
||||||
|
===================
|
||||||
|
|
||||||
|
Up until now, the examples have shown :term:`controller` callables returning a
|
||||||
|
string. This is the simplest way to return a plain HTML response; *Milla* will
|
||||||
|
automatically send the appropriate HTTP headers for you in this case. If,
|
||||||
|
however, you need to send special headers, change the content type, or stream
|
||||||
|
data instead of sending a single response, you will need to return a
|
||||||
|
:py:class:`~milla.Response` object. This object contains all the properties
|
||||||
|
necessary to instruct *Milla* on what headers to send, etc. for your response.
|
||||||
|
|
||||||
|
To create a :py:class:`~milla.Response` instance, use the
|
||||||
|
:py:attr:`~milla.Request.ResponseClass` attribute from the request:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
def controller(request):
|
||||||
|
response = request.ResponseClass()
|
||||||
|
response.content_type = 'text/plain'
|
||||||
|
response.text = 'Hello, world!'
|
||||||
|
return response
|
||||||
|
|
||||||
|
.. _Meinheld: http://meinheld.org/
|
||||||
|
.. _Paste: http://pythonpaste.org/
|
||||||
|
.. _WSGI: http://www.python.org/dev/peps/pep-0333/
|
||||||
|
.. _Apache HTTPD: http://httpd.apache.org/
|
||||||
|
.. _mod_wsgi: http://code.google.com/p/modwsgi/
|
||||||
|
.. _SQLAlchemy: http://www.sqlalchemy.org/
|
|
@ -0,0 +1,28 @@
|
||||||
|
========
|
||||||
|
Glossary
|
||||||
|
========
|
||||||
|
|
||||||
|
.. glossary::
|
||||||
|
|
||||||
|
controller
|
||||||
|
controller callable
|
||||||
|
A callable that accepts a :py:class:`~milla.Request` instance and any
|
||||||
|
optional parameters and returns a response
|
||||||
|
|
||||||
|
permission requirement
|
||||||
|
A set of permissions required to access a particular URL path. Permission
|
||||||
|
requirements are specified by using the
|
||||||
|
:py:meth:`~milla.auth.require_perm` decorator on a restricted
|
||||||
|
:term:`controller callable`
|
||||||
|
|
||||||
|
request validator
|
||||||
|
A function that checks a request to ensure it meets the specified
|
||||||
|
:term:`permission requirement` before calling a :term:`controller
|
||||||
|
callable`
|
||||||
|
|
||||||
|
root object
|
||||||
|
The starting object in the object traversal URL dispatch mechanism from
|
||||||
|
which all path lookups are performed
|
||||||
|
|
||||||
|
URL dispatcher
|
||||||
|
An object that maps request paths to :term:`controller` callables
|
|
@ -6,15 +6,41 @@
|
||||||
Welcome to Milla's documentation!
|
Welcome to Milla's documentation!
|
||||||
=================================
|
=================================
|
||||||
|
|
||||||
.. automodule:: milla
|
|
||||||
|
|
||||||
Contents:
|
Contents:
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 1
|
||||||
|
|
||||||
rationale
|
getting-started
|
||||||
|
advanced
|
||||||
|
changelog
|
||||||
reference/index
|
reference/index
|
||||||
|
glossary
|
||||||
|
|
||||||
|
*Milla* is a simple and lightweight web framework for Python. It built on top
|
||||||
|
of `WebOb`_ and thus implements the `WSGI`_ standard. It aims to be easy to use
|
||||||
|
while imposing no restrictions, allowing web developers to write code the way
|
||||||
|
they want, using the tools, platform, and extensions they choose.
|
||||||
|
|
||||||
|
Example
|
||||||
|
=======
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
from wsgiref import simple_server
|
||||||
|
from milla.dispatch import routing
|
||||||
|
import milla
|
||||||
|
|
||||||
|
|
||||||
|
def hello(request):
|
||||||
|
return 'Hello, world!'
|
||||||
|
|
||||||
|
router = routing.Router()
|
||||||
|
router.add_route('/', hello)
|
||||||
|
app = milla.Application(router)
|
||||||
|
|
||||||
|
httpd = simple_server.make_server('', 8080, app)
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
*Milla* is released under the terms of the `Apache License, version 2.0`_.
|
*Milla* is released under the terms of the `Apache License, version 2.0`_.
|
||||||
|
|
||||||
|
@ -25,5 +51,6 @@ Indices and tables
|
||||||
* :ref:`modindex`
|
* :ref:`modindex`
|
||||||
* :ref:`search`
|
* :ref:`search`
|
||||||
|
|
||||||
|
.. _WebOb: http://webob.org/
|
||||||
|
.. _WSGI: http://wsgi.readthedocs.org/
|
||||||
.. _Apache License, version 2.0: http://www.apache.org/licenses/LICENSE-2.0
|
.. _Apache License, version 2.0: http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
|
@ -1,120 +0,0 @@
|
||||||
=========
|
|
||||||
Rationale
|
|
||||||
=========
|
|
||||||
|
|
||||||
As of early 2011, there is a lot of flux in the Python world with
|
|
||||||
regard to web frameworks. There are a couple of big players, namely
|
|
||||||
`Django`_, `Pylons`_, and `TurboGears`_, as well as several more
|
|
||||||
obscure projects like `CherryPy`_ and `Bottle`_. Having worked with
|
|
||||||
many of these projects, I decided that although each has its strengths,
|
|
||||||
they all also had something about them that just made me feel
|
|
||||||
uncomfortable working with them.
|
|
||||||
|
|
||||||
Framework Comparison
|
|
||||||
====================
|
|
||||||
|
|
||||||
Django
|
|
||||||
++++++
|
|
||||||
|
|
||||||
.. rubric:: Strengths
|
|
||||||
|
|
||||||
* Very popular and actively developed, making it easy to get help and
|
|
||||||
solve problems
|
|
||||||
* Fully-featured, including an Object-Relational Mapper, URL dispatcher,
|
|
||||||
template engine, and form library. Also includes "goodies" like
|
|
||||||
authentication, an "admin" application, and sessions
|
|
||||||
|
|
||||||
.. rubric:: Discomforts
|
|
||||||
|
|
||||||
I am not specifically listing any of these issues as weaknesses or
|
|
||||||
drawbacks, because they aren't *per-se*. Honestly, there isn't anything
|
|
||||||
wrong with Django, and many people love it. Personally, I don't feel
|
|
||||||
comfortable working with it for a few reasons.
|
|
||||||
|
|
||||||
* Storing configuration in a Python module is absurd
|
|
||||||
* All of the components are so tightly-integrated it is nearly
|
|
||||||
impossible to use some pieces without the others.
|
|
||||||
|
|
||||||
* I really don't like its ORM. `SQLAlchemy`_ is tremendously more
|
|
||||||
powerful, and isn't nearly as restrictive (naming conventions, etc.)
|
|
||||||
* The session handling middleware is very limited in comparison to
|
|
||||||
other projects like `Beaker`_
|
|
||||||
* I am not fond of the template engine and would prefer to use
|
|
||||||
`Genshi`_.
|
|
||||||
|
|
||||||
Pylons/Pyramid
|
|
||||||
++++++++++++++
|
|
||||||
|
|
||||||
The original Pylons was a very powerful web framework. It was probably
|
|
||||||
my favorite framework, and I have built a number of applications using
|
|
||||||
it. Unfortunately, development has been discontinued and efforts are
|
|
||||||
now being concentrated on `Pyramid`_ instead.
|
|
||||||
|
|
||||||
Pylons
|
|
||||||
------
|
|
||||||
|
|
||||||
.. rubric:: Strengths
|
|
||||||
|
|
||||||
* While not as popular as Django, there still a significant following
|
|
||||||
* The code base is very decoupled, allowing developers to swap out
|
|
||||||
components without affecting the overall functionality of the
|
|
||||||
framework.
|
|
||||||
|
|
||||||
.. rubric:: Weaknesses
|
|
||||||
|
|
||||||
* Overutilization of StackedObject proxies and global variables
|
|
||||||
|
|
||||||
Pyramid
|
|
||||||
-------
|
|
||||||
|
|
||||||
I simply do not like Pyramid at all, and it is really disappointing that
|
|
||||||
the Pylons project has moved in this direction. Essentially everything
|
|
||||||
that I liked about Pylons is gone. The idea of using *traversal* to map
|
|
||||||
URLs to routines is clever, but it is overly complex compared to the
|
|
||||||
familiar URL dispatching in other frameworks.
|
|
||||||
|
|
||||||
* Tightly integrated with several Zope components, mostly interfaces
|
|
||||||
(*puke*)
|
|
||||||
* Template renderers are insanely complex and again, I don't like Zope
|
|
||||||
interfaces. There is no simple way to use Genshi, which I absolutely
|
|
||||||
adore.
|
|
||||||
|
|
||||||
|
|
||||||
Other Frameworks
|
|
||||||
++++++++++++++++
|
|
||||||
|
|
||||||
I haven't used the other frameworks as much. In general, I try to avoid
|
|
||||||
having my applications depend on obscure or unmaintained libraries
|
|
||||||
because when I find a bug (and I will), I need some assurance that it
|
|
||||||
will be fixed soon. I do not like having to patch other people's code
|
|
||||||
in production environments, especially if it is an application I am
|
|
||||||
passing along to a client.
|
|
||||||
|
|
||||||
I never really looked at TurboGears at all, and with the recent changes
|
|
||||||
to the Pylons project, upon which TurboGears is based, there is a great
|
|
||||||
deal of uncertainty with regard to its future.
|
|
||||||
|
|
||||||
CherryPy is very nice, and I did a bit of work with it a while back. I
|
|
||||||
thought it was dead for a long time, though, and I have never really
|
|
||||||
produced a production application built on it. With its most recent
|
|
||||||
release (3.2.0), it is the first web framework to support Python 3,
|
|
||||||
which is exciting. I may revisit it in the near future, as a matter
|
|
||||||
of fact.
|
|
||||||
|
|
||||||
The Truth
|
|
||||||
=========
|
|
||||||
|
|
||||||
The truth is, I started *Milla* as an exercise to better understand
|
|
||||||
WSGI. All of the frameworks discussed above are great, and will most
|
|
||||||
likely serve everyone's needs. There really isn't any reason for anyone
|
|
||||||
to use *Milla* over any of them, but I won't stop you.
|
|
||||||
|
|
||||||
.. _Django: http://www.djangoproject.com/
|
|
||||||
.. _Pylons: http://pylonshq.com/
|
|
||||||
.. _TurboGears: http://www.turbogears.org/
|
|
||||||
.. _CherryPy: http://www.cherrypy.org/
|
|
||||||
.. _Bottle: http://bottlepy.org/
|
|
||||||
.. _SQLAlchemy: http://www.sqlalchemy.org/
|
|
||||||
.. _Beaker: http://beaker.groovie.org/
|
|
||||||
.. _Genshi: http://genshi.edgewall.org/
|
|
||||||
.. _Pyramid: http://docs.pylonsproject.org/projects/pyramid/1.0/index.html
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
=====
|
||||||
|
milla
|
||||||
|
=====
|
||||||
|
|
||||||
|
.. automodule:: milla
|
||||||
|
:members:
|
||||||
|
:inherited-members:
|
|
@ -0,0 +1,6 @@
|
||||||
|
==========
|
||||||
|
milla.vary
|
||||||
|
==========
|
||||||
|
|
||||||
|
.. automodule:: milla.vary
|
||||||
|
:members:
|
5
setup.py
5
setup.py
|
@ -15,7 +15,7 @@ if sys.version_info < (2, 7):
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='Milla',
|
name='Milla',
|
||||||
version='0.1.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'
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,17 +15,40 @@
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
from app import *
|
from milla.app import *
|
||||||
|
from milla.auth.decorators import *
|
||||||
from webob.exc import *
|
from webob.exc import *
|
||||||
from webob.request import *
|
import webob
|
||||||
from webob.response import *
|
try:
|
||||||
from auth.decorators import *
|
import urllib.parse
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
import urllib
|
||||||
|
import urlparse
|
||||||
|
urllib.parse = urlparse
|
||||||
|
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!'
|
||||||
|
@ -35,3 +58,109 @@ def allow(*methods):
|
||||||
func.allowed_methods = methods
|
func.allowed_methods = methods
|
||||||
return func
|
return func
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class Response(webob.Response):
|
||||||
|
''':py:class:`WebOb Response <webob.response.Response>` with minor tweaks
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class Request(webob.Request):
|
||||||
|
''':py:class:`WebOb Request <webob.request.BaseRequest>` with minor tweaks
|
||||||
|
'''
|
||||||
|
|
||||||
|
ResponseClass = Response
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def blank(cls, path, *args, **kwargs):
|
||||||
|
'''Create a simple request for the specified path
|
||||||
|
|
||||||
|
See :py:meth:`webob.Request.blank <webob.request.BaseRequest.blank>`
|
||||||
|
for information on other arguments and keywords
|
||||||
|
'''
|
||||||
|
|
||||||
|
req = super(Request, cls).blank(path, *args, **kwargs)
|
||||||
|
req.config = {}
|
||||||
|
return req
|
||||||
|
|
||||||
|
def create_href(self, path, **keywords):
|
||||||
|
'''Combine the application's path with a path to form an HREF
|
||||||
|
|
||||||
|
:param path: relative path to join with the request URL
|
||||||
|
|
||||||
|
Any other keyword arguments will be encoded and appended to the URL
|
||||||
|
as querystring arguments.
|
||||||
|
|
||||||
|
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 host information, use :py:meth:`create_href_full` instead.
|
||||||
|
'''
|
||||||
|
|
||||||
|
url = self._merge_url(self.script_name, path)
|
||||||
|
|
||||||
|
if keywords:
|
||||||
|
url += '?' + urllib.parse.urlencode(keywords)
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
def create_href_full(self, path, **keywords):
|
||||||
|
'''Combine the application's full URL with a path to form a new URL
|
||||||
|
|
||||||
|
:param path: relative path to join with the request URL
|
||||||
|
|
||||||
|
Any other keyword arguments will be encoded and appended to the
|
||||||
|
URL as querystring arguments/
|
||||||
|
|
||||||
|
The HREF returned will be the full URL, including scheme and host
|
||||||
|
information. To get the path only, use :py:meth:`create_href`
|
||||||
|
instead.
|
||||||
|
'''
|
||||||
|
|
||||||
|
url = self._merge_url(self.application_url, path)
|
||||||
|
|
||||||
|
if keywords:
|
||||||
|
url += '?' + urllib.parse.urlencode(keywords)
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
def static_resource(self, path):
|
||||||
|
'''Return a URL to the given static resource
|
||||||
|
|
||||||
|
This method combines the defined static resource root URL with the
|
||||||
|
given path to construct a complete URL to the given resource. The
|
||||||
|
resource root should be defined in the application configuration
|
||||||
|
dictionary, under the name ``milla.static_root``, for example::
|
||||||
|
|
||||||
|
app = milla.Application(dispatcher)
|
||||||
|
app.config.update({
|
||||||
|
'milla.static_root': '/static/'
|
||||||
|
})
|
||||||
|
|
||||||
|
Then, calling ``static_resource`` on a :py:class:`Request` object
|
||||||
|
(i.e. inside a controller callable) would combine the given path
|
||||||
|
with ``/static/``, like this::
|
||||||
|
|
||||||
|
request.static_resource('/images/foo.png')
|
||||||
|
|
||||||
|
would return ``/static/images/foo.png``.
|
||||||
|
|
||||||
|
If no ``milla.static_root`` key is found in the configuration
|
||||||
|
dictionary, the path will be returned unaltered.
|
||||||
|
|
||||||
|
:param path: Path to the resource, relative to the defined root
|
||||||
|
'''
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = self.config['milla.static_root']
|
||||||
|
except KeyError:
|
||||||
|
return path
|
||||||
|
|
||||||
|
return self._merge_url(root, path)
|
||||||
|
|
||||||
|
def _merge_url(self, root, path):
|
||||||
|
if path.startswith('/'):
|
||||||
|
path = path[1:]
|
||||||
|
if not root.endswith('/'):
|
||||||
|
root += '/'
|
||||||
|
|
||||||
|
return urllib.parse.urljoin(root, path)
|
||||||
|
|
318
src/milla/app.py
318
src/milla/app.py
|
@ -1,11 +1,11 @@
|
||||||
# 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.
|
||||||
# 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.
|
||||||
|
@ -13,134 +13,234 @@
|
||||||
# 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
|
import milla.dispatch.traversal
|
||||||
import webob
|
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
|
||||||
alternatively, a root object that will be passed to a new
|
alternatively, a root object that will be passed to a new
|
||||||
:py:class:`milla.dispatch.traversal.Traverser`.
|
:py:class:`milla.dispatch.traversal.Traverser`.
|
||||||
|
|
||||||
:param root: A root object, passed to a traverser, which is
|
:param obj: An object implementing the dispatcher protocol, or an
|
||||||
automatically created if a root is given
|
object to be used as the root for a Traverser
|
||||||
:param dispatcher: An object implementing the dispatcher protocol
|
|
||||||
|
|
||||||
``Application`` instances are WSGI applications.
|
``Application`` instances are WSGI applications.
|
||||||
|
|
||||||
.. py:attribute:: config
|
.. py:attribute:: config
|
||||||
|
|
||||||
A mapping of configuration settings. For each request, the
|
A mapping of configuration settings. For each request, the
|
||||||
configuration is copied and assigned to ``request.config``.
|
configuration is copied and assigned to ``request.config``.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD', 'OPTIONS']
|
DEFAULT_CONFIG = {
|
||||||
|
'milla.favicon': True,
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, dispatcher):
|
def __init__(self, obj):
|
||||||
self.dispatcher = dispatcher
|
super(Application, self).__init__()
|
||||||
self.config = {'milla.favicon': True}
|
if not hasattr(obj, 'resolve'):
|
||||||
|
# Object is not a dispatcher, but the root object for traversal
|
||||||
|
obj = milla.dispatch.traversal.Traverser(obj)
|
||||||
|
self.dispatcher = obj
|
||||||
|
|
||||||
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 = webob.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
|
|
||||||
return HTTPMethodNotAllowed(headers=allow_header)(environ,
|
|
||||||
start_response)
|
|
||||||
|
|
||||||
start_response_wrapper = StartResponseWrapper(start_response)
|
|
||||||
request.start_response = start_response_wrapper
|
|
||||||
try:
|
|
||||||
# If the callable has an __before__ attribute, call it
|
|
||||||
if hasattr(func, '__before__'):
|
|
||||||
func.__before__(request)
|
|
||||||
# If the callable is an instance method and its class has
|
|
||||||
# a __before__ method, call that
|
|
||||||
elif hasattr(func, 'im_self') and \
|
|
||||||
hasattr(func.im_self, '__before__'):
|
|
||||||
func.im_self.__before__(request)
|
|
||||||
# The callable might be a partial, so check the inner func
|
|
||||||
elif hasattr(func, 'func'):
|
|
||||||
if hasattr(func.func, '__before__'):
|
|
||||||
func.func.__before__(request)
|
|
||||||
elif hasattr(func.func, 'im_self') and \
|
|
||||||
hasattr(func.func.im_self, '__before__'):
|
|
||||||
func.func.im_self.__before__(request)
|
|
||||||
response = func(request)
|
|
||||||
except WSGIHTTPException as e:
|
|
||||||
return e(environ, start_response)
|
|
||||||
finally:
|
|
||||||
# If the callable has an __after__ method, call it
|
|
||||||
if hasattr(func, '__after__'):
|
|
||||||
func.__after__(request)
|
|
||||||
# If the callable is an instance method and its class has
|
|
||||||
# an __after__ method, call that
|
|
||||||
elif hasattr(func, 'im_self') and \
|
|
||||||
hasattr(func.im_self, '__after__'):
|
|
||||||
func.im_self.__after__(request)
|
|
||||||
# The callable might be a partial, so check the inner func
|
|
||||||
elif hasattr(func, 'func'):
|
|
||||||
if hasattr(func.func, '__after__'):
|
|
||||||
func.func.__after__(request)
|
|
||||||
elif hasattr(func.func, 'im_self') and \
|
|
||||||
hasattr(func.func.im_self, '__after__'):
|
|
||||||
func.func.im_self.__after__(request)
|
|
||||||
|
|
||||||
# The callable might have returned just a string, which is OK,
|
|
||||||
# but we need to wrap it in a WebOb response
|
|
||||||
if isinstance(response, basestring) or not response:
|
|
||||||
response = webob.Response(response)
|
|
||||||
|
|
||||||
if not start_response_wrapper.called:
|
|
||||||
start_response(response.status, response.headerlist)
|
|
||||||
if not environ['REQUEST_METHOD'] == 'HEAD':
|
|
||||||
return response.app_iter
|
|
||||||
|
|
||||||
class StartResponseWrapper():
|
class StartResponseWrapper():
|
||||||
|
|
||||||
def __init__(self, start_response):
|
def __init__(self, start_response):
|
||||||
self.start_response = start_response
|
self.start_response = start_response
|
||||||
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)
|
||||||
|
|
|
@ -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(unicode(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:
|
||||||
|
|
|
@ -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
|
||||||
|
@ -124,7 +175,7 @@ class require_perms(object):
|
||||||
def __init__(self, *requirements):
|
def __init__(self, *requirements):
|
||||||
requirement = None
|
requirement = None
|
||||||
for req in requirements:
|
for req in requirements:
|
||||||
if isinstance(req, basestring):
|
if not hasattr(req, 'check'):
|
||||||
req = permissions.Permission(req)
|
req = permissions.Permission(req)
|
||||||
if not requirement:
|
if not requirement:
|
||||||
requirement = req
|
requirement = req
|
||||||
|
@ -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
|
||||||
|
|
|
@ -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,100 +30,91 @@ 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 __unicode__(self):
|
|
||||||
if isinstance(self.name, unicode):
|
|
||||||
return self.name
|
|
||||||
else:
|
|
||||||
return self.name.decode('utf-8')
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if isinstance(self.name, str):
|
return str(self.name)
|
||||||
return self.name
|
|
||||||
else:
|
|
||||||
return self.name.encode('utf-8')
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
return self is other or str(self) == str(other)
|
return self is other or str(self) == str(other)
|
||||||
|
|
||||||
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 unicode(self).encode('utf-8')
|
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):
|
||||||
|
@ -132,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):
|
||||||
|
|
109
src/milla/cli.py
109
src/milla/cli.py
|
@ -1,109 +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
|
|
||||||
|
|
||||||
Please give me a docstring!
|
|
||||||
|
|
||||||
:Created: May 30, 2011
|
|
||||||
:Author: dustin
|
|
||||||
:Updated: $Date$
|
|
||||||
:Updater: $Author$
|
|
||||||
'''
|
|
||||||
import argparse
|
|
||||||
import pkg_resources
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
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()
|
|
|
@ -19,12 +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 milla
|
import datetime
|
||||||
import pkg_resources
|
import milla.util
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
import pkg_resources
|
||||||
|
except ImportError:
|
||||||
|
pkg_resources = None
|
||||||
|
|
||||||
|
|
||||||
class Controller(object):
|
class Controller(object):
|
||||||
|
@ -53,18 +56,27 @@ class FaviconController(Controller):
|
||||||
used as the favicon, defaults to 'image/x-icon' (Windows ICO format)
|
used as the favicon, defaults to 'image/x-icon' (Windows ICO format)
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
#: Number of days in the future to set the cache expiration for the icon
|
||||||
|
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:
|
||||||
|
@ -72,4 +84,39 @@ class FaviconController(Controller):
|
||||||
response = milla.Response()
|
response = milla.Response()
|
||||||
response.app_iter = self.icon
|
response.app_iter = self.icon
|
||||||
response.headers['Content-Type'] = self.content_type
|
response.headers['Content-Type'] = self.content_type
|
||||||
|
expires = (datetime.datetime.utcnow() +
|
||||||
|
datetime.timedelta(days=self.EXPIRY_DAYS))
|
||||||
|
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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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.
|
||||||
|
@ -24,18 +24,17 @@ import functools
|
||||||
import milla
|
import milla
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import urllib
|
import warnings
|
||||||
import urlparse
|
|
||||||
|
|
||||||
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
|
||||||
|
@ -45,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)
|
||||||
'''
|
'''
|
||||||
|
@ -75,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
|
||||||
|
@ -98,7 +97,7 @@ class Router(object):
|
||||||
for attr in functools.WRAPPER_ASSIGNMENTS:
|
for attr in functools.WRAPPER_ASSIGNMENTS:
|
||||||
try:
|
try:
|
||||||
value = getattr(controller, attr)
|
value = getattr(controller, attr)
|
||||||
except AttributeError:
|
except AttributeError: #pragma: no cover
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
setattr(func, attr, value)
|
setattr(func, attr, value)
|
||||||
|
@ -122,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
|
||||||
|
@ -132,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.
|
||||||
'''
|
'''
|
||||||
|
@ -163,81 +163,45 @@ 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``.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if isinstance(controller, basestring):
|
if not hasattr(controller, '__call__'):
|
||||||
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
|
|
||||||
'''
|
|
||||||
|
|
||||||
def __init__(self, request, path_only=True):
|
|
||||||
self.request = request
|
|
||||||
self.path_only = path_only
|
|
||||||
|
|
||||||
def generate(self, *segments, **vars):
|
|
||||||
'''Combines segments and the application's URL into a new URL
|
|
||||||
'''
|
|
||||||
|
|
||||||
path = '/'.join(str(s) for s in segments)
|
|
||||||
while path.startswith('/'):
|
|
||||||
path = path[1:]
|
|
||||||
|
|
||||||
url = self.request.relative_url(path, to_application=True)
|
|
||||||
if self.path_only:
|
|
||||||
split = urlparse.urlsplit(url)
|
|
||||||
url = split.path
|
|
||||||
if vars:
|
|
||||||
url += '?' + urllib.urlencode(vars)
|
|
||||||
return url
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,43 +1,47 @@
|
||||||
# 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.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
'''Module milla.uti
|
'''Convenience utility functions
|
||||||
|
|
||||||
Please give me a docstring!
|
|
||||||
|
|
||||||
:Created: Mar 30, 2011
|
:Created: Mar 30, 2011
|
||||||
:Author: dustin
|
:Author: dustin
|
||||||
:Updated: $Date$
|
|
||||||
:Updater: $Author$
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
from wsgiref.handlers import format_date_time
|
||||||
|
import datetime
|
||||||
|
import time
|
||||||
|
try:
|
||||||
|
import configparser
|
||||||
|
except ImportError:
|
||||||
|
import ConfigParser as configparser
|
||||||
|
|
||||||
def asbool(val):
|
def asbool(val):
|
||||||
'''Test a value for truth
|
'''Test a value for truth
|
||||||
|
|
||||||
Returns ``False`` values evaluating as false, such as the integer
|
Returns ``False`` values evaluating as false, such as the integer
|
||||||
``0`` or ``None``, and for the following strings, irrespective of
|
``0`` or ``None``, and for the following strings, irrespective of
|
||||||
letter case:
|
letter case:
|
||||||
|
|
||||||
* false
|
* false
|
||||||
* no
|
* no
|
||||||
* f
|
* f
|
||||||
* n
|
* n
|
||||||
* off
|
* off
|
||||||
* 0
|
* 0
|
||||||
|
|
||||||
Returns ``True`` for all other values.
|
Returns ``True`` for all other values.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if not val:
|
if not val:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
|
@ -46,4 +50,75 @@ def asbool(val):
|
||||||
pass
|
pass
|
||||||
if val in ('false', 'no', 'f', 'n', 'off', '0'):
|
if val in ('false', 'no', 'f', 'n', 'off', '0'):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def http_date(date):
|
||||||
|
'''Format a datetime object as a string in RFC 1123 format
|
||||||
|
|
||||||
|
This function returns a string representing the date according to
|
||||||
|
RFC 1123. The string returned will always be in English, as
|
||||||
|
required by the specification.
|
||||||
|
|
||||||
|
:param date: A :py:class:`datetime.datetime` object
|
||||||
|
:return: RFC 1123-formatted string
|
||||||
|
'''
|
||||||
|
|
||||||
|
stamp = time.mktime(date.timetuple())
|
||||||
|
return format_date_time(stamp)
|
||||||
|
|
||||||
|
|
||||||
|
def read_config(filename, defaults=None):
|
||||||
|
'''Parse an ini file into a nested dictionary
|
||||||
|
|
||||||
|
:param string filename: Path to the ini file to read
|
||||||
|
:param dict defaults: (Optional) A mapping of default values that can be
|
||||||
|
used for interpolation when reading the configuration file
|
||||||
|
:returns: A dictionary whose keys correspond to the section and
|
||||||
|
option, joined with a dot character (.)
|
||||||
|
|
||||||
|
For example, consider the following ini file::
|
||||||
|
|
||||||
|
[xmen]
|
||||||
|
storm = Ororo Monroe
|
||||||
|
cyclops = Scott Summers
|
||||||
|
|
||||||
|
[avengers]
|
||||||
|
hulk = Bruce Banner
|
||||||
|
iron_man = Tony Stark
|
||||||
|
|
||||||
|
The resulting dictionary would look like this::
|
||||||
|
|
||||||
|
{
|
||||||
|
'xmen.storm': 'Ororo Monroe',
|
||||||
|
'xmen.cyclops': 'Scott Summers',
|
||||||
|
'avengers.hulk': 'Bruce Banner',
|
||||||
|
'avengers.iron_man': 'Tony Stark',
|
||||||
|
}
|
||||||
|
|
||||||
|
Thus, the option values for any section can be obtained as follows::
|
||||||
|
|
||||||
|
config['xmen.storm']
|
||||||
|
|
||||||
|
This dictionary can be used to configure an :py:class:`~milla.Application`
|
||||||
|
instance by using the ``update`` method::
|
||||||
|
|
||||||
|
config = milla.util.read_config('superheros.ini')
|
||||||
|
app = milla.Application(router)
|
||||||
|
app.config.update(config)
|
||||||
|
'''
|
||||||
|
|
||||||
|
with open(filename) as f:
|
||||||
|
# ConfigParser API changed in Python 3.2
|
||||||
|
if hasattr(configparser.ConfigParser, 'read_file'):
|
||||||
|
cparser = configparser.ConfigParser(defaults)
|
||||||
|
cparser.read_file(f)
|
||||||
|
else:
|
||||||
|
cparser = configparser.SafeConfigParser(defaults)
|
||||||
|
cparser.readfp(f)
|
||||||
|
|
||||||
|
config = {}
|
||||||
|
for section in cparser.sections():
|
||||||
|
for option in cparser.options(section):
|
||||||
|
config['.'.join((section, option))] = cparser.get(section, option)
|
||||||
|
return config
|
||||||
|
|
|
@ -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)
|
|
@ -0,0 +1,562 @@
|
||||||
|
'''Tests for the main Application logic
|
||||||
|
|
||||||
|
:Created: Nov 27, 2012
|
||||||
|
:Author: dustin
|
||||||
|
'''
|
||||||
|
from unittest.case import SkipTest
|
||||||
|
import functools
|
||||||
|
import milla.app
|
||||||
|
import milla.dispatch
|
||||||
|
import nose.tools
|
||||||
|
import sys
|
||||||
|
import wsgiref.util
|
||||||
|
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):
|
||||||
|
'''Stub resolver for testing purposes'''
|
||||||
|
|
||||||
|
def __init__(self, controller=None):
|
||||||
|
if not controller:
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
self.controller = controller
|
||||||
|
|
||||||
|
def resolve(self, path_info):
|
||||||
|
return self.controller
|
||||||
|
|
||||||
|
class StubResolverUnresolved(object):
|
||||||
|
'''Stub resolver that always raises UnresolvedPath'''
|
||||||
|
|
||||||
|
def resolve(self, path_info):
|
||||||
|
raise milla.dispatch.UnresolvedPath()
|
||||||
|
|
||||||
|
class ResponseMaker(object):
|
||||||
|
|
||||||
|
def __init__(self, http_version=1.1):
|
||||||
|
self.http_version = http_version
|
||||||
|
self.headers = ''
|
||||||
|
self.body = b''
|
||||||
|
|
||||||
|
def start_response(self, status, response_headers):
|
||||||
|
self.headers += 'HTTP/{0} {1}\r\n'.format(self.http_version, status)
|
||||||
|
for header, value in response_headers:
|
||||||
|
self.headers += '{0}: {1}\r\n'.format(header, value)
|
||||||
|
|
||||||
|
def finish_response(self, app_iter):
|
||||||
|
for data in app_iter:
|
||||||
|
self.body += data
|
||||||
|
|
||||||
|
def environ_for_testing():
|
||||||
|
environ = {}
|
||||||
|
wsgiref.util.setup_testing_defaults(environ)
|
||||||
|
return environ
|
||||||
|
|
||||||
|
class AfterCalled(Exception):
|
||||||
|
'''Raised in tests for the __after__ method'''
|
||||||
|
|
||||||
|
class BeforeCalled(Exception):
|
||||||
|
'''Raised in tests for the __before__ method'''
|
||||||
|
|
||||||
|
|
||||||
|
def test_notfound():
|
||||||
|
'''Application returns a 404 response for unresolved paths
|
||||||
|
'''
|
||||||
|
|
||||||
|
app = milla.app.Application(StubResolverUnresolved())
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app_iter = app(environ, response.start_response)
|
||||||
|
response.finish_response(app_iter)
|
||||||
|
assert response.headers.startswith('HTTP/1.1 404'), response.headers
|
||||||
|
|
||||||
|
def test_favicon():
|
||||||
|
'''Application returns the default favicon image when requested
|
||||||
|
'''
|
||||||
|
|
||||||
|
app = milla.app.Application(StubResolverUnresolved())
|
||||||
|
environ = environ_for_testing()
|
||||||
|
environ.update({'PATH_INFO': '/favicon.ico'})
|
||||||
|
response = ResponseMaker()
|
||||||
|
app_iter = app(environ, response.start_response)
|
||||||
|
response.finish_response(app_iter)
|
||||||
|
assert response.headers.startswith('HTTP/1.1 200'), response.headers
|
||||||
|
assert response.body.startswith(b'\x00\x00\x01\x00'), response.body
|
||||||
|
|
||||||
|
def test_allow_header_disallowed():
|
||||||
|
'''HTTP 405 is returned for disallowed HTTP request methods
|
||||||
|
'''
|
||||||
|
|
||||||
|
app = milla.app.Application(StubResolver())
|
||||||
|
environ = environ_for_testing()
|
||||||
|
environ.update({'REQUEST_METHOD': 'POST'})
|
||||||
|
response = ResponseMaker()
|
||||||
|
app_iter = app(environ, response.start_response)
|
||||||
|
response.finish_response(app_iter)
|
||||||
|
assert response.headers.startswith('HTTP/1.1 405'), response.headers
|
||||||
|
|
||||||
|
def test_allow_header_allowed():
|
||||||
|
'''HTTP 405 is not returned for explicitly allowed HTTP request methods
|
||||||
|
'''
|
||||||
|
|
||||||
|
resolver = StubResolver()
|
||||||
|
resolver.controller.allowed_methods = ('POST',)
|
||||||
|
app = milla.app.Application(resolver)
|
||||||
|
environ = environ_for_testing()
|
||||||
|
environ.update({'REQUEST_METHOD': 'POST'})
|
||||||
|
response = ResponseMaker()
|
||||||
|
app_iter = app(environ, response.start_response)
|
||||||
|
response.finish_response(app_iter)
|
||||||
|
assert response.headers.startswith('HTTP/1.1 200'), response.headers
|
||||||
|
|
||||||
|
def test_allow_header_options():
|
||||||
|
'''HTTP OPTIONS requests returns HTTP 200
|
||||||
|
'''
|
||||||
|
|
||||||
|
resolver = StubResolver()
|
||||||
|
resolver.controller.allowed_methods = ('GET',)
|
||||||
|
app = milla.app.Application(resolver)
|
||||||
|
environ = environ_for_testing()
|
||||||
|
environ.update({'REQUEST_METHOD': 'OPTIONS'})
|
||||||
|
response = ResponseMaker()
|
||||||
|
app_iter = app(environ, response.start_response)
|
||||||
|
response.finish_response(app_iter)
|
||||||
|
assert response.headers.startswith('HTTP/1.1 200'), response.headers
|
||||||
|
|
||||||
|
def test_emulated_method():
|
||||||
|
'''Emulated HTTP methods are interpreted correctly
|
||||||
|
|
||||||
|
For applications that cannot use the proper HTTP method and instead
|
||||||
|
use HTTP POST with an ``_method`` parameter
|
||||||
|
'''
|
||||||
|
|
||||||
|
resolver = StubResolver()
|
||||||
|
resolver.controller.allowed_methods = ('PUT',)
|
||||||
|
app = milla.app.Application(resolver)
|
||||||
|
environ = environ_for_testing()
|
||||||
|
environ.update({
|
||||||
|
'REQUEST_METHOD': 'POST',
|
||||||
|
'CONTENT_TYPE': 'application/x-www-form-urlencoded',
|
||||||
|
'CONTENT_LENGTH': '11'
|
||||||
|
})
|
||||||
|
body = environ['wsgi.input']
|
||||||
|
body.seek(0)
|
||||||
|
body.write(b'_method=PUT')
|
||||||
|
body.seek(0)
|
||||||
|
response = ResponseMaker()
|
||||||
|
app_iter = app(environ, response.start_response)
|
||||||
|
response.finish_response(app_iter)
|
||||||
|
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)
|
||||||
|
def test_function_before():
|
||||||
|
'''__before__ attribute is called for controller functions
|
||||||
|
'''
|
||||||
|
|
||||||
|
def before(request):
|
||||||
|
raise BeforeCalled()
|
||||||
|
|
||||||
|
resolver = StubResolver()
|
||||||
|
resolver.controller.__before__ = before
|
||||||
|
app = milla.app.Application(resolver)
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app(environ, response.start_response)
|
||||||
|
|
||||||
|
@nose.tools.raises(BeforeCalled)
|
||||||
|
def test_instance_before():
|
||||||
|
'''Class's __before__ is called for controller instances
|
||||||
|
'''
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
def __before__(self, request):
|
||||||
|
raise BeforeCalled()
|
||||||
|
def __call__(self, request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
app = milla.app.Application(StubResolver(Controller()))
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app(environ, response.start_response)
|
||||||
|
|
||||||
|
@nose.tools.raises(BeforeCalled)
|
||||||
|
def test_instancemethod_before():
|
||||||
|
'''Class's __before__ is called for controller instance methods
|
||||||
|
'''
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
def __before__(self, request):
|
||||||
|
raise BeforeCalled()
|
||||||
|
def foo(self, request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
app = milla.app.Application(StubResolver(Controller().foo))
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app(environ, response.start_response)
|
||||||
|
|
||||||
|
@nose.tools.raises(BeforeCalled)
|
||||||
|
def test_partial_function_before():
|
||||||
|
'''__before__ attribute is called for wrapped controller functions
|
||||||
|
'''
|
||||||
|
|
||||||
|
def before(request):
|
||||||
|
raise BeforeCalled()
|
||||||
|
def controller(request, text):
|
||||||
|
return text
|
||||||
|
controller.__before__ = before
|
||||||
|
|
||||||
|
resolver = StubResolver()
|
||||||
|
resolver.controller = functools.partial(controller, text='success')
|
||||||
|
app = milla.app.Application(resolver)
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app(environ, response.start_response)
|
||||||
|
|
||||||
|
@nose.tools.raises(BeforeCalled)
|
||||||
|
def test_partial_instance_before():
|
||||||
|
'''Class's __before__ is called for wrapped controller instances
|
||||||
|
'''
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
def __before__(self, request):
|
||||||
|
raise BeforeCalled()
|
||||||
|
def __call__(self, request, text):
|
||||||
|
return text
|
||||||
|
|
||||||
|
resolver = StubResolver()
|
||||||
|
resolver.controller = functools.partial(Controller(), text='success')
|
||||||
|
app = milla.app.Application(resolver)
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app(environ, response.start_response)
|
||||||
|
|
||||||
|
@nose.tools.raises(BeforeCalled)
|
||||||
|
def test_partial_instancemethod_before():
|
||||||
|
'''Class's __before__ is called for wrapped controller instance methods
|
||||||
|
'''
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
def __before__(self, request):
|
||||||
|
raise BeforeCalled()
|
||||||
|
def foo(self, request, text):
|
||||||
|
if not hasattr(request, 'before_called'):
|
||||||
|
return 'before not called'
|
||||||
|
else:
|
||||||
|
return text
|
||||||
|
|
||||||
|
resolver = StubResolver()
|
||||||
|
resolver.controller = functools.partial(Controller().foo, text='success')
|
||||||
|
app = milla.app.Application(resolver)
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app(environ, response.start_response)
|
||||||
|
|
||||||
|
@nose.tools.raises(AfterCalled)
|
||||||
|
def test_function_after():
|
||||||
|
'''__after__ attribute is called for controller functions
|
||||||
|
'''
|
||||||
|
|
||||||
|
def after(request):
|
||||||
|
raise AfterCalled()
|
||||||
|
|
||||||
|
resolver = StubResolver()
|
||||||
|
resolver.controller.__after__ = after
|
||||||
|
app = milla.app.Application(resolver)
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app(environ, response.start_response)
|
||||||
|
|
||||||
|
@nose.tools.raises(AfterCalled)
|
||||||
|
def test_instance_after():
|
||||||
|
'''Class's __after__ is called for controller instances
|
||||||
|
'''
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
def __after__(self, request):
|
||||||
|
raise AfterCalled()
|
||||||
|
def __call__(self, request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
app = milla.app.Application(StubResolver(Controller()))
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app(environ, response.start_response)
|
||||||
|
|
||||||
|
@nose.tools.raises(AfterCalled)
|
||||||
|
def test_instancemethod_after():
|
||||||
|
'''Class's __after__ is called for controller instance methods
|
||||||
|
'''
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
def __after__(self, request):
|
||||||
|
raise AfterCalled()
|
||||||
|
def foo(self, request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
app = milla.app.Application(StubResolver(Controller().foo))
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app(environ, response.start_response)
|
||||||
|
|
||||||
|
@nose.tools.raises(AfterCalled)
|
||||||
|
def test_partial_function_after():
|
||||||
|
'''__after__ attribute is called for wrapped controller functions
|
||||||
|
'''
|
||||||
|
|
||||||
|
def after(request):
|
||||||
|
raise AfterCalled()
|
||||||
|
def controller(request, text):
|
||||||
|
return text
|
||||||
|
controller.__after__ = after
|
||||||
|
|
||||||
|
resolver = StubResolver()
|
||||||
|
resolver.controller = functools.partial(controller, text='success')
|
||||||
|
app = milla.app.Application(resolver)
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app(environ, response.start_response)
|
||||||
|
|
||||||
|
@nose.tools.raises(AfterCalled)
|
||||||
|
def test_partial_instance_after():
|
||||||
|
'''Class's __after__ is called for wrapped controller instances
|
||||||
|
'''
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
def __after__(self, request):
|
||||||
|
raise AfterCalled()
|
||||||
|
def __call__(self, request, text):
|
||||||
|
return text
|
||||||
|
|
||||||
|
resolver = StubResolver()
|
||||||
|
resolver.controller = functools.partial(Controller(), text='success')
|
||||||
|
app = milla.app.Application(resolver)
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app(environ, response.start_response)
|
||||||
|
|
||||||
|
@nose.tools.raises(AfterCalled)
|
||||||
|
def test_partial_instancemethod_after():
|
||||||
|
'''Class's __after__ is called for wrapped controller instance methods
|
||||||
|
'''
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
def __after__(self, request):
|
||||||
|
raise AfterCalled()
|
||||||
|
def foo(self, request, text):
|
||||||
|
if not hasattr(request, 'after_called'):
|
||||||
|
return 'after not called'
|
||||||
|
else:
|
||||||
|
return text
|
||||||
|
|
||||||
|
resolver = StubResolver()
|
||||||
|
resolver.controller = functools.partial(Controller().foo, text='success')
|
||||||
|
app = milla.app.Application(resolver)
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
app(environ, response.start_response)
|
||||||
|
|
||||||
|
def test_httperror_response():
|
||||||
|
'''HTTPErrors raised by controllers should used as the response
|
||||||
|
'''
|
||||||
|
|
||||||
|
def controller(request):
|
||||||
|
raise webob.exc.HTTPClientError('NotFound')
|
||||||
|
|
||||||
|
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.headers.startswith('HTTP/1.1 400'), response.headers
|
||||||
|
assert b'NotFound' in response.body, response.body
|
||||||
|
|
||||||
|
def test_single_start_response():
|
||||||
|
'''Ensure start_response is only called once'''
|
||||||
|
|
||||||
|
class TestStartResponse(object):
|
||||||
|
def __init__(self, func):
|
||||||
|
self.call_count = 0
|
||||||
|
self.func = func
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
self.call_count += 1
|
||||||
|
return self.func(*args, **kwargs)
|
||||||
|
|
||||||
|
def controller(request):
|
||||||
|
status = '200 OK'
|
||||||
|
headers = [('Content-Type', 'text/plain')]
|
||||||
|
request.start_response(status, headers)
|
||||||
|
return 'test'
|
||||||
|
|
||||||
|
app = milla.app.Application(StubResolver(controller))
|
||||||
|
environ = environ_for_testing()
|
||||||
|
response = ResponseMaker()
|
||||||
|
start_response = TestStartResponse(response.start_response)
|
||||||
|
app_iter = app(environ, start_response)
|
||||||
|
response.finish_response(app_iter)
|
||||||
|
assert start_response.call_count == 1, start_response.call_count
|
||||||
|
assert response.headers.startswith('HTTP/1.1 200 OK'), response.headers
|
||||||
|
assert response.body == b'test', response.body
|
||||||
|
|
||||||
|
def test_allow_decorator():
|
||||||
|
'''Ensure allow decorator sets allowed_methods on controllers'''
|
||||||
|
|
||||||
|
@milla.allow('GET', 'HEAD', 'POST')
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
assert controller.allowed_methods == ('GET', 'HEAD', 'POST')
|
||||||
|
|
||||||
|
def test_create_href_simple():
|
||||||
|
'''Request.create_href creates a valid URL path from the application root'''
|
||||||
|
|
||||||
|
environ = environ_for_testing()
|
||||||
|
request = milla.Request(environ)
|
||||||
|
url = request.create_href('/bar')
|
||||||
|
assert url == '/bar', url
|
||||||
|
|
||||||
|
def test_create_href_nonroot():
|
||||||
|
'''Request.create_href handles applications mounted somewhere besides /'''
|
||||||
|
|
||||||
|
environ = environ_for_testing()
|
||||||
|
environ.update({
|
||||||
|
'SCRIPT_NAME': '/test'
|
||||||
|
})
|
||||||
|
request = milla.Request(environ)
|
||||||
|
url = request.create_href('/bar')
|
||||||
|
assert url == '/test/bar', url
|
||||||
|
|
||||||
|
def test_create_href_full():
|
||||||
|
'''Request.create_href_full creates appropriate full URL'''
|
||||||
|
|
||||||
|
environ = environ_for_testing()
|
||||||
|
request = milla.Request(environ)
|
||||||
|
url = request.create_href_full('/bar')
|
||||||
|
assert url == 'http://127.0.0.1/bar', url
|
||||||
|
|
||||||
|
def test_create_href_full_nonroot():
|
||||||
|
'''Request.create_href_full creates correct full URL for nonroot applications'''
|
||||||
|
|
||||||
|
environ = environ_for_testing()
|
||||||
|
environ.update({
|
||||||
|
'SCRIPT_NAME': '/test'
|
||||||
|
})
|
||||||
|
request = milla.Request(environ)
|
||||||
|
url = request.create_href_full('/bar')
|
||||||
|
assert url == 'http://127.0.0.1/test/bar', url
|
||||||
|
|
||||||
|
def test_create_href_keywords():
|
||||||
|
'''Request.create_href properly appends querystring arguments'''
|
||||||
|
|
||||||
|
environ = environ_for_testing()
|
||||||
|
request = milla.Request(environ)
|
||||||
|
url = request.create_href('/bar', foo='baz')
|
||||||
|
assert url == '/bar?foo=baz'
|
||||||
|
|
||||||
|
def test_create_href_full_keywords():
|
||||||
|
'''Request.create_href_full properly appends querystring arguments'''
|
||||||
|
|
||||||
|
environ = environ_for_testing()
|
||||||
|
request = milla.Request(environ)
|
||||||
|
url = request.create_href_full('/bar', foo='baz')
|
||||||
|
assert url == 'http://127.0.0.1/bar?foo=baz'
|
||||||
|
|
||||||
|
def test_static_resource():
|
||||||
|
'''Request.static_resource creates valid URL from config'''
|
||||||
|
|
||||||
|
def controller(request):
|
||||||
|
return request.static_resource('/image.png')
|
||||||
|
|
||||||
|
environ = environ_for_testing()
|
||||||
|
app = milla.Application(StubResolver(controller))
|
||||||
|
app.config['milla.static_root'] = '/static'
|
||||||
|
response = ResponseMaker()
|
||||||
|
app_iter = app(environ, response.start_response)
|
||||||
|
response.finish_response(app_iter)
|
||||||
|
assert response.body == b'/static/image.png', response.body
|
||||||
|
|
||||||
|
def test_static_resource_undefined():
|
||||||
|
'''Request.static_resource returns the path unmodified with no root defined'''
|
||||||
|
|
||||||
|
def controller(request):
|
||||||
|
return request.static_resource('/image.png')
|
||||||
|
|
||||||
|
environ = environ_for_testing()
|
||||||
|
app = milla.Application(StubResolver(controller))
|
||||||
|
response = ResponseMaker()
|
||||||
|
app_iter = app(environ, response.start_response)
|
||||||
|
response.finish_response(app_iter)
|
||||||
|
assert response.body == b'/image.png', response.body
|
|
@ -0,0 +1,507 @@
|
||||||
|
'''Tests for the Authentication/Authorization framework
|
||||||
|
|
||||||
|
:Created: Nov 27, 2012
|
||||||
|
:Author: dustin
|
||||||
|
'''
|
||||||
|
import milla.auth
|
||||||
|
import nose.tools
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_check():
|
||||||
|
'''Ensure Permission.check returns True for lists of strings
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm = milla.auth.permissions.Permission('foo')
|
||||||
|
assert perm.check(['foo'])
|
||||||
|
|
||||||
|
def test_permission_check_false():
|
||||||
|
'''Ensure Permission.check returns False for lists of strings
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm = milla.auth.permissions.Permission('foo')
|
||||||
|
assert not perm.check(['bar'])
|
||||||
|
|
||||||
|
def test_permission_check_perm():
|
||||||
|
'''Ensure Permission.check returns True for lists of Permissions
|
||||||
|
'''
|
||||||
|
|
||||||
|
req = milla.auth.permissions.Permission('foo')
|
||||||
|
perm = milla.auth.permissions.Permission('foo')
|
||||||
|
assert req.check([perm])
|
||||||
|
|
||||||
|
def test_permission_check_perm_false():
|
||||||
|
'''Ensure Permission.check returns True for lists of Permissions
|
||||||
|
'''
|
||||||
|
|
||||||
|
req = milla.auth.permissions.Permission('foo')
|
||||||
|
perm = milla.auth.permissions.Permission('bar')
|
||||||
|
assert not req.check([perm])
|
||||||
|
|
||||||
|
def test_permission_check_container():
|
||||||
|
'''Ensure Permission.check returns True for PermissionContainers of strings
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm = milla.auth.permissions.Permission('foo')
|
||||||
|
container = milla.auth.permissions.PermissionContainer(['foo'])
|
||||||
|
assert perm.check(container)
|
||||||
|
|
||||||
|
def test_permission_check_container_false():
|
||||||
|
'''Ensure Permission.check returns True for PermissionContainers of strings
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm = milla.auth.permissions.Permission('foo')
|
||||||
|
container = milla.auth.permissions.PermissionContainer(['bar'])
|
||||||
|
assert not perm.check(container)
|
||||||
|
|
||||||
|
def test_permission_check_container_perm():
|
||||||
|
'''Ensure Permission.check returns True for PermissionContainers of Permissions
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm = milla.auth.permissions.Permission('foo')
|
||||||
|
req = milla.auth.permissions.Permission('foo')
|
||||||
|
container = milla.auth.permissions.PermissionContainer([perm])
|
||||||
|
assert req.check(container)
|
||||||
|
|
||||||
|
def test_permission_check_container_perm_false():
|
||||||
|
'''Ensure Permission.check returns False for PermissionContainers of Permissions
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm = milla.auth.permissions.Permission('foo')
|
||||||
|
req = milla.auth.permissions.Permission('bar')
|
||||||
|
container = milla.auth.permissions.PermissionContainer([perm])
|
||||||
|
assert not req.check(container)
|
||||||
|
|
||||||
|
def test_permission_container_iter():
|
||||||
|
'''Ensure iterating a PermissionContainer yields all permissions
|
||||||
|
'''
|
||||||
|
|
||||||
|
container = milla.auth.permissions.PermissionContainer(['foo'], ['bar'])
|
||||||
|
assert list(container) == ['foo', 'bar']
|
||||||
|
|
||||||
|
def test_permission_and():
|
||||||
|
'''Ensure AND-ing Permissions returns a PermissionRequirementAll
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm1 = milla.auth.permissions.Permission('foo')
|
||||||
|
perm2 = milla.auth.permissions.Permission('bar')
|
||||||
|
req = perm1 & perm2
|
||||||
|
assert isinstance(req, milla.auth.permissions.PermissionRequirementAll)
|
||||||
|
assert req.requirements == (perm1, perm2)
|
||||||
|
|
||||||
|
def test_permission_or():
|
||||||
|
'''Ensure OR-ing Permissions returns a PermissionRequirementAny
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm1 = milla.auth.permissions.Permission('foo')
|
||||||
|
perm2 = milla.auth.permissions.Permission('bar')
|
||||||
|
req = perm1 | perm2
|
||||||
|
assert isinstance(req, milla.auth.permissions.PermissionRequirementAny)
|
||||||
|
assert req.requirements == (perm1, perm2)
|
||||||
|
|
||||||
|
def test_permission_str():
|
||||||
|
'''Ensure calling str on a Permission returns its name
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm_name = 'foo'
|
||||||
|
perm = milla.auth.permissions.Permission(perm_name)
|
||||||
|
assert str(perm) == perm_name
|
||||||
|
|
||||||
|
def test_permission_eq():
|
||||||
|
'''Ensure two Permissions with the same name are equal but not identical
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm_name = 'foo'
|
||||||
|
perm1 = milla.auth.permissions.Permission(perm_name)
|
||||||
|
perm2 = milla.auth.permissions.Permission(perm_name)
|
||||||
|
assert perm1 == perm2
|
||||||
|
assert perm1 is not perm2
|
||||||
|
|
||||||
|
def test_permission_check_container_group():
|
||||||
|
'''Test group permissions in PermissionContainer objects
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm = milla.auth.permissions.Permission('foo')
|
||||||
|
req = milla.auth.permissions.Permission('foo')
|
||||||
|
container = milla.auth.permissions.PermissionContainer([], [perm])
|
||||||
|
assert req.check(container)
|
||||||
|
|
||||||
|
def test_permissionrequirement_all():
|
||||||
|
'''Ensure PermissionRequirementAll requires all listed permissions
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm1 = milla.auth.permissions.Permission('foo')
|
||||||
|
perm2 = milla.auth.permissions.Permission('bar')
|
||||||
|
req = milla.auth.permissions.PermissionRequirementAll(perm1, perm2)
|
||||||
|
assert req.check(['foo', 'bar'])
|
||||||
|
assert not req.check(['foo'])
|
||||||
|
assert not req.check(['bar'])
|
||||||
|
assert not req.check([])
|
||||||
|
assert not req.check(['baz'])
|
||||||
|
|
||||||
|
def test_permissionrequirement_any():
|
||||||
|
'''Ensure PermissionRequirementAll requires only one permission
|
||||||
|
'''
|
||||||
|
|
||||||
|
perm1 = milla.auth.permissions.Permission('foo')
|
||||||
|
perm2 = milla.auth.permissions.Permission('bar')
|
||||||
|
req = milla.auth.permissions.PermissionRequirementAny(perm1, perm2)
|
||||||
|
assert req.check(['foo'])
|
||||||
|
assert req.check(['bar'])
|
||||||
|
assert req.check(['foo', 'bar'])
|
||||||
|
assert not req.check([])
|
||||||
|
assert not req.check(['baz'])
|
||||||
|
|
||||||
|
def test_exception_callable():
|
||||||
|
'''Ensure that NotAuthorizedException is a valid controller callable
|
||||||
|
'''
|
||||||
|
|
||||||
|
exc = milla.auth.NotAuthorized()
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
response = exc(request)
|
||||||
|
assert isinstance(response, milla.Response)
|
||||||
|
assert response.status.startswith('4')
|
||||||
|
|
||||||
|
@nose.tools.raises(milla.auth.NotAuthorized)
|
||||||
|
def test_request_validator_nouser():
|
||||||
|
'''Ensure ensure requests without a user attribute raise NotAuthorized
|
||||||
|
'''
|
||||||
|
|
||||||
|
validator = milla.auth.RequestValidator()
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
validator.validate(request)
|
||||||
|
|
||||||
|
@nose.tools.raises(milla.auth.NotAuthorized)
|
||||||
|
def test_request_validator_emptyuser():
|
||||||
|
'''Ensure requests with an empty user raise NotAuthorized
|
||||||
|
'''
|
||||||
|
|
||||||
|
validator = milla.auth.RequestValidator()
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = None
|
||||||
|
validator.validate(request)
|
||||||
|
|
||||||
|
def test_request_validator_user_noperms():
|
||||||
|
'''Ensure user permissions are not checked if no requirement is given
|
||||||
|
|
||||||
|
If no ``requirement`` is given to
|
||||||
|
:py:meth:`milla.auth.RequestValidator.validate`, then the fact that the
|
||||||
|
request's ``user`` attribute doesn't have a ``permissions`` attribute
|
||||||
|
shouldn't matter.
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
validator = milla.auth.RequestValidator()
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
validator.validate(request)
|
||||||
|
|
||||||
|
@nose.tools.raises(milla.auth.NotAuthorized)
|
||||||
|
def test_request_validator_missingperms():
|
||||||
|
'''Ensure requests whose user has no permissions attribute are invalid
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
validator = milla.auth.RequestValidator()
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
requirement = milla.auth.permissions.Permission('foo')
|
||||||
|
validator.validate(request, requirement)
|
||||||
|
|
||||||
|
@nose.tools.raises(milla.auth.NotAuthorized)
|
||||||
|
def test_request_validator_emptyperms():
|
||||||
|
'''Ensure requests whose user has an empty set of permissions are invalid
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
validator = milla.auth.RequestValidator()
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
request.user.permissions = []
|
||||||
|
requirement = milla.auth.permissions.Permission('foo')
|
||||||
|
validator.validate(request, requirement)
|
||||||
|
|
||||||
|
@nose.tools.raises(milla.auth.NotAuthorized)
|
||||||
|
def test_request_validator_incorrectperms():
|
||||||
|
'''Ensure requests whose user has incorrect permissions raise NotAuthorized
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
validator = milla.auth.RequestValidator()
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
request.user.permissions = ['bar']
|
||||||
|
requirement = milla.auth.permissions.Permission('foo')
|
||||||
|
validator.validate(request, requirement)
|
||||||
|
|
||||||
|
def test_request_validator_correctperms():
|
||||||
|
'''Ensure requests from users with appropriate permissions are valid
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
validator = milla.auth.RequestValidator()
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
request.user.permissions = ['foo']
|
||||||
|
requirement = milla.auth.permissions.Permission('foo')
|
||||||
|
validator.validate(request, requirement)
|
||||||
|
|
||||||
|
def test_find_request_kwarg():
|
||||||
|
'''Ensure _find_request finds a request in keyword arguments
|
||||||
|
'''
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
found = milla.auth.decorators._find_request('foo', request=request)
|
||||||
|
assert found is request
|
||||||
|
|
||||||
|
def test_find_request_arg1():
|
||||||
|
'''Ensure _find_request finds a request in position 1
|
||||||
|
'''
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
found = milla.auth.decorators._find_request(request)
|
||||||
|
assert found is request
|
||||||
|
|
||||||
|
def test_find_request_arg2():
|
||||||
|
'''Ensure _find_request finds a request in another position
|
||||||
|
'''
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
found = milla.auth.decorators._find_request('foo', request)
|
||||||
|
assert found is request
|
||||||
|
|
||||||
|
def test_auth_required_true():
|
||||||
|
'''Test the auth_required decorator with a valid user
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@milla.auth.decorators.auth_required
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
response = controller(request)
|
||||||
|
assert response == 'success'
|
||||||
|
|
||||||
|
def test_auth_required_false():
|
||||||
|
'''Test the auth_required decorator with no user
|
||||||
|
'''
|
||||||
|
|
||||||
|
@milla.auth.decorators.auth_required
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = None
|
||||||
|
response = controller(request)
|
||||||
|
assert response != 'success'
|
||||||
|
assert response.status.startswith('4')
|
||||||
|
|
||||||
|
def test_require_perms_none():
|
||||||
|
'''Test the require_perms decorator with no requirement
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@milla.auth.decorators.require_perms()
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
response = controller(request)
|
||||||
|
assert response == 'success'
|
||||||
|
|
||||||
|
def test_require_perms_valid_str():
|
||||||
|
'''Test the require_perms decorator with valid permissions as strings
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@milla.auth.decorators.require_perms('foo')
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
request.user.permissions = ['foo']
|
||||||
|
response = controller(request)
|
||||||
|
assert response == 'success'
|
||||||
|
|
||||||
|
def test_require_perms_valid_permission():
|
||||||
|
'''Test the require_perms decorator with valid permissions as Permissions
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
req = milla.auth.permissions.Permission('foo')
|
||||||
|
@milla.auth.decorators.require_perms(req)
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
request.user.permissions = ['foo']
|
||||||
|
response = controller(request)
|
||||||
|
assert response == 'success'
|
||||||
|
|
||||||
|
def test_require_perms_multi_valid_string():
|
||||||
|
'''Test the require_perms decorator with multiple requirements as strings
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@milla.auth.decorators.require_perms('foo', 'bar')
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
request.user.permissions = ['foo', 'bar']
|
||||||
|
response = controller(request)
|
||||||
|
assert response == 'success'
|
||||||
|
|
||||||
|
def test_require_perms_multi_valid_permission():
|
||||||
|
'''Test the require_perms decorator with multiple requirements as Permissions
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
req1 = milla.auth.permissions.Permission('foo')
|
||||||
|
req2 = milla.auth.permissions.Permission('bar')
|
||||||
|
@milla.auth.decorators.require_perms(req1, req2)
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
request.user.permissions = ['foo', 'bar']
|
||||||
|
response = controller(request)
|
||||||
|
assert response == 'success'
|
||||||
|
|
||||||
|
def test_require_perms_invalid_none():
|
||||||
|
'''Test the require_perms decorator with no permissions
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@milla.auth.decorators.require_perms('foo')
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
response = controller(request)
|
||||||
|
assert response != 'success'
|
||||||
|
assert response.status.startswith('4')
|
||||||
|
|
||||||
|
def test_require_perms_invalid_empty():
|
||||||
|
'''Test the require_perms decorator with an empty permissions set
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@milla.auth.decorators.require_perms('foo')
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
request.user.permissions = []
|
||||||
|
response = controller(request)
|
||||||
|
assert response != 'success'
|
||||||
|
assert response.status.startswith('4')
|
||||||
|
|
||||||
|
def test_require_perms_invalid_string():
|
||||||
|
'''Test the require_perms decorator with invalid permissions as strings
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@milla.auth.decorators.require_perms('foo')
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
request.user.permissions = ['bar']
|
||||||
|
response = controller(request)
|
||||||
|
assert response != 'success'
|
||||||
|
assert response.status.startswith('4')
|
||||||
|
|
||||||
|
def test_require_perms_invalid_permission():
|
||||||
|
'''Test the require_perms decorator with invalid permissions as Permissions
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
req = milla.auth.permissions.Permission('foo')
|
||||||
|
@milla.auth.decorators.require_perms(req)
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
request.user.permissions = ['bar']
|
||||||
|
response = controller(request)
|
||||||
|
assert response != 'success'
|
||||||
|
assert response.status.startswith('4')
|
||||||
|
|
||||||
|
def test_require_perms_multi_invalid_string():
|
||||||
|
'''Test the require_perms decorator with multiple invalid permissions as strings
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@milla.auth.decorators.require_perms('foo', 'bar')
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
request.user.permissions = ['bar']
|
||||||
|
response = controller(request)
|
||||||
|
assert response != 'success'
|
||||||
|
assert response.status.startswith('4')
|
||||||
|
|
||||||
|
def test_require_perms_multi_invalid_permission():
|
||||||
|
'''Test the require_perms decorator with multiple invalid permissions as Permissions
|
||||||
|
'''
|
||||||
|
|
||||||
|
class User(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
req1 = milla.auth.permissions.Permission('foo')
|
||||||
|
req2 = milla.auth.permissions.Permission('foo')
|
||||||
|
@milla.auth.decorators.require_perms(req1, req2)
|
||||||
|
def controller(request):
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
request = milla.Request.blank('/')
|
||||||
|
request.user = User()
|
||||||
|
request.user.permissions = ['bar']
|
||||||
|
response = controller(request)
|
||||||
|
assert response != 'success'
|
||||||
|
assert response.status.startswith('4')
|
|
@ -6,13 +6,13 @@
|
||||||
:Updater: $Author$
|
:Updater: $Author$
|
||||||
'''
|
'''
|
||||||
import milla.dispatch.routing
|
import milla.dispatch.routing
|
||||||
|
import milla.controllers
|
||||||
|
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.
|
||||||
|
@ -28,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'``.
|
||||||
|
@ -44,9 +44,10 @@ def test_urlvars():
|
||||||
assert func.keywords['bar'] == 'abc'
|
assert func.keywords['bar'] == 'abc'
|
||||||
assert func.keywords['baz'] == 'def'
|
assert func.keywords['baz'] == 'def'
|
||||||
|
|
||||||
|
@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``.
|
||||||
|
@ -61,16 +62,12 @@ def test_regexp_urlvar():
|
||||||
assert func.func == controller
|
assert func.func == controller
|
||||||
assert func.keywords['arg'] == 'abcde'
|
assert func.keywords['arg'] == 'abcde'
|
||||||
|
|
||||||
try:
|
router.resolve('/test/1234')
|
||||||
func = router.resolve('/test/1234')
|
|
||||||
except milla.dispatch.UnresolvedPath:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AssertionError
|
|
||||||
|
|
||||||
|
@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``.
|
||||||
'''
|
'''
|
||||||
|
@ -80,16 +77,11 @@ def test_unresolved():
|
||||||
|
|
||||||
router = milla.dispatch.routing.Router()
|
router = milla.dispatch.routing.Router()
|
||||||
router.add_route('/test', controller)
|
router.add_route('/test', controller)
|
||||||
try:
|
router.resolve('/tset')
|
||||||
router.resolve('/tset')
|
|
||||||
except milla.dispatch.UnresolvedPath:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AssertionError
|
|
||||||
|
|
||||||
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.
|
||||||
|
@ -109,13 +101,55 @@ 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
|
||||||
|
|
||||||
|
def test_trailing_slash_redir():
|
||||||
|
'''Paths that match except the trailing slash return a HTTP redirect
|
||||||
|
'''
|
||||||
|
|
||||||
|
def controller():
|
||||||
|
pass
|
||||||
|
|
||||||
|
router = milla.dispatch.routing.Router()
|
||||||
|
router.add_route('/test/', controller)
|
||||||
|
func = router.resolve('/test')
|
||||||
|
assert func is not controller
|
||||||
|
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)
|
||||||
|
def test_trailing_slash_none():
|
||||||
|
'''Paths that match except the trailing slash are ignored
|
||||||
|
'''
|
||||||
|
|
||||||
|
def controller():
|
||||||
|
pass
|
||||||
|
|
||||||
|
router = milla.dispatch.routing.Router(None)
|
||||||
|
router.add_route('/test/', controller)
|
||||||
|
router.resolve('/test')
|
||||||
|
|
||||||
|
def test_trailing_slash_silent():
|
||||||
|
'''Paths that match except the trailing slash are treated the same
|
||||||
|
'''
|
||||||
|
|
||||||
|
def controller():
|
||||||
|
pass
|
||||||
|
|
||||||
|
router = milla.dispatch.routing.Router(milla.dispatch.routing.Router.SILENT)
|
||||||
|
router.add_route('/test/', controller)
|
||||||
|
func = router.resolve('/test')
|
||||||
|
assert func.func is controller
|
|
@ -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``
|
|
@ -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')
|
Loading…
Reference in New Issue