Compare commits

...

83 Commits

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

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

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

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

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

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

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

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

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

Apparently, the unit tests didn't cover this case...
2014-02-07 23:22:50 -06:00
Dustin C. Hatch a2d8f6f098 Version bump 2013-01-22 13:02:17 -06:00
Dustin C. Hatch 8e699969e2 Added tag 0.2 for changeset 2d04d03ce334 2013-01-22 13:01:28 -06:00
Dustin C. Hatch 872461d515 Remove dev version tag for 0.2 release 2013-01-22 13:01:16 -06:00
Dustin C. Hatch d068d22e38 Added README 2013-01-22 12:00:56 -06:00
Dustin C. Hatch 345333614c New documentation 2013-01-22 10:47:39 -06:00
Dustin C. Hatch 14c8c28b9a Update docstring for util.read_config 2013-01-21 22:10:24 -06:00
Dustin C. Hatch 243df2be47 Mark the milla.cli module as deprecated 2013-01-20 15:14:25 -06:00
Dustin C. Hatch 708e59125e Bump documentation version 2013-01-20 15:00:45 -06:00
Dustin C. Hatch fc42519f0d Bump setup.py version 2013-01-20 14:45:55 -06:00
Dustin C. Hatch 9da12232d4 Add a `defaults` keyword to `milla.util.read_config` 2013-01-20 14:49:24 -06:00
Dustin C. Hatch 65ebf76b45 Move read_config to milla.util 2013-01-20 14:48:23 -06:00
Dustin C. Hatch 3408919faa Merged branch py3k (closes #2) 2013-01-20 14:53:38 -06:00
Dustin C. Hatch 0064f70160 Update read_config for new API in Python 3.2
--HG--
branch : py3k
2013-01-05 10:38:05 -06:00
Dustin C. Hatch fed7d0fb3d Merge default into py3k
--HG--
branch : py3k
2012-12-19 15:38:36 -06:00
Dustin C. Hatch 4085039997 Added simple inifile-to-dictionary parser 2012-12-19 15:26:19 -06:00
Dustin C. Hatch d635e83431 Automatically create a Traverser instance if Application is given a root object 2012-11-30 22:47:06 -06:00
Dustin C. Hatch f5f7e76dae Added tests for Router's trailing slash handling options 2012-11-30 22:46:28 -06:00
Dustin C. Hatch f14b744ef7 Added tests for Request.static_resource 2012-11-30 22:24:35 -06:00
Dustin C. Hatch 32e96f5807 Fixed more Python 3 import errors
--HG--
branch : py3k
2012-11-30 22:16:08 -06:00
Dustin C. Hatch f8e89d46c6 Merge default into py3k
--HG--
branch : py3k
2012-11-30 22:11:23 -06:00
Dustin C. Hatch c4e9397e8c Replace Request.relative_url with Request.create_url and friends 2012-11-30 22:02:28 -06:00
Dustin C. Hatch 11271ebc31 app: Cleaned up imports and fixed Request.blank docstring 2012-11-30 22:01:25 -06:00
Dustin C. Hatch 47103b76a4 Add test for the milla.allow decorator 2012-11-30 21:17:16 -06:00
Dustin C. Hatch 91f2d954e7 test_app: Rename environ maker so nose doesn't think it is a test 2012-11-30 14:58:26 -06:00
Dustin C. Hatch 17cac57721 Clean up __before__ and __after__ handling and make it Python 3-compatible
--HG--
branch : py3k
2012-11-30 14:37:02 -06:00
Dustin C. Hatch 232bb17d6f Fix test_app to run in Python 3
--HG--
branch : py3k
2012-11-30 14:16:57 -06:00
Dustin C. Hatch dc79fea9db Merged default into py3k
--HG--
branch : py3k
2012-11-30 13:19:55 -06:00
Dustin C. Hatch 5cd113ee46 Correctly use options_response for OPTIONS requests (fixes #5) 2012-11-30 13:13:17 -06:00
Dustin C. Hatch 0c7ebae8a9 Added tests for main Application logic 2012-11-30 13:09:13 -06:00
Dustin C. Hatch 5735d2b027 Better handling of string-type responses from controllers
--HG--
branch : py3k
2012-11-28 12:26:58 -06:00
Dustin C. Hatch afcbf3d314 Get rid of unicode business in Permission objects
--HG--
branch : py3k
2012-11-28 12:26:36 -06:00
Dustin C. Hatch a8a90294d2 Call str() instead of unicode() on Exceptions to get the message
--HG--
branch : py3k
2012-11-28 12:13:34 -06:00
Dustin C. Hatch d2705ad18e Better way to determine if a requirement is a Permission
--HG--
branch : py3k
2012-11-28 12:13:03 -06:00
hg c721c7d06a Merge default into py3k
--HG--
branch : py3k
2012-11-28 11:59:25 -06:00
hg e36f17452e Add tests for auth decorators 2012-11-28 11:54:54 -06:00
hg fe54d0a666 Add a blank config dictionary to blank Requests 2012-11-28 11:54:41 -06:00
Dustin C. Hatch a89e2396db Added tests for the permissions framework 2012-11-27 23:08:35 -06:00
Dustin C. Hatch aba45a2107 Merge default into py3k
--HG--
branch : py3k
2012-11-27 19:12:59 -06:00
Dustin C. Hatch fbc58de27a Started unit tests for the auth framework 2012-11-27 18:55:38 -06:00
Dustin C. Hatch 10e2ad803b Bump PyDev interpreter and grammer for Python 3
--HG--
branch : py3k
2012-11-27 17:26:34 -06:00
Dustin C. Hatch 17e7486784 Change version in setup.py to 'py3k' to allow projects to require Milla==py3k
--HG--
branch : py3k
2012-11-19 22:34:44 -06:00
Dustin C. Hatch eb347e7fb5 request: add a static_resource method to Request instances 2012-11-19 21:35:31 -06:00
Dustin C. Hatch 5529b66a57 Add Expires header to favicon controller responses 2012-11-19 17:30:13 -06:00
Dustin C. Hatch dd05d95dba Added http_date utility function 2012-11-19 17:29:04 -06:00
Dustin C. Hatch b50ca751fb Change version in setup.py to 'tip' to allow projects to require Milla==tip 2012-11-19 17:02:34 -06:00
Dustin C. Hatch 4c06340cea Update URL Generator for compatibility with milla.Request and mark it deprecated 2012-11-03 19:04:26 -05:00
Dustin C. Hatch 2980b438da Subclass WebOb Request and override relative_url (see #3) 2012-11-03 19:03:13 -05:00
Dustin C. Hatch fa97bd7b03 py3k: Handle non-callable controllers in routes better
--HG--
branch : py3k
2012-11-03 11:26:01 -05:00
Dustin C. Hatch 6c034b88d3 py3k: Fix imports
--HG--
branch : py3k
2012-11-03 11:07:30 -05:00
Dustin C. Hatch 3b6571623f Added tag 0.1.2 for changeset e7c7497afb21 2012-10-16 20:44:07 -05:00
34 changed files with 3011 additions and 604 deletions

5
.gitignore vendored Normal file
View File

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

View File

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

View File

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

View File

@ -2,8 +2,8 @@
<?eclipse-pydev version="1.0"?>
<pydev_project>
<pydev_property name="org.python.pydev.PYTHON_PROJECT_INTERPRETER">Default</pydev_property>
<pydev_property name="org.python.pydev.PYTHON_PROJECT_VERSION">python 2.7</pydev_property>
<pydev_property name="org.python.pydev.PYTHON_PROJECT_INTERPRETER">python3</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">
<path>/Milla/src</path>
</pydev_pathproperty>

39
README.rst Normal file
View File

@ -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/

272
doc/advanced.rst Normal file
View File

@ -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

63
doc/changelog.rst Normal file
View File

@ -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

View File

@ -25,7 +25,8 @@ import sys, os
# Add any Sphinx extension module names here, as strings. They can be extensions
# 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.
templates_path = ['_templates']
@ -41,14 +42,14 @@ master_doc = 'index'
# General information about the project.
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
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '0.1.2'
version = '1.0'
# The full version, including alpha/beta/rc tags.
release = version
@ -86,6 +87,9 @@ pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
intersphinx_mapping = {
'webob': ('http://docs.webob.org/en/latest/', None)
}
# -- Options for HTML output ---------------------------------------------------

321
doc/getting-started.rst Normal file
View File

@ -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/

28
doc/glossary.rst Normal file
View File

@ -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

View File

@ -6,15 +6,41 @@
Welcome to Milla's documentation!
=================================
.. automodule:: milla
Contents:
.. toctree::
:maxdepth: 2
:maxdepth: 1
rationale
getting-started
advanced
changelog
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`_.
@ -25,5 +51,6 @@ Indices and tables
* :ref:`modindex`
* :ref:`search`
.. _WebOb: http://webob.org/
.. _WSGI: http://wsgi.readthedocs.org/
.. _Apache License, version 2.0: http://www.apache.org/licenses/LICENSE-2.0

View File

@ -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

7
doc/reference/milla.rst Normal file
View File

@ -0,0 +1,7 @@
=====
milla
=====
.. automodule:: milla
:members:
:inherited-members:

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

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

2
setup.cfg Normal file
View File

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

View File

@ -15,7 +15,7 @@ if sys.version_info < (2, 7):
setup(
name='Milla',
version='0.1.2',
version='1.0',
description='Lightweight WSGI framework for web applications',
long_description='''\
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': [
'default = milla.auth:RequestValidator'
],
'console_scripts': [
'milla-cli = milla.cli:main'
],
}
)

View File

@ -15,11 +15,34 @@
'''
from app import *
from milla.app import *
from milla.auth.decorators import *
from webob.exc import *
from webob.request import *
from webob.response import *
from auth.decorators import *
import webob
try:
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):
'''Specify the allowed HTTP verbs for a controller callable
@ -35,3 +58,109 @@ def allow(*methods):
func.allowed_methods = methods
return func
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)

View File

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

View File

@ -37,7 +37,7 @@ class NotAuthorized(Exception):
All other arguments and keywords are ignored.
'''
response = request.ResponseClass(unicode(self))
response = request.ResponseClass(str(self))
response.status_int = 403
return response

View File

@ -15,19 +15,28 @@
:Created: Mar 3, 2011
:Author: dustin
:Updated: $Date$
:Updater: $Author$
'''
from functools import wraps
from milla.auth import RequestValidator, NotAuthorized, permissions
import milla
from milla.auth import permissions
import milla.auth
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'
def _find_request(*args, **kwargs):
try:
return kwargs['request']
@ -36,25 +45,66 @@ def _find_request(*args, **kwargs):
if isinstance(arg, milla.Request):
return arg
def _validate_request(func, requirement, *args, **kwargs):
warnings.warn(
'_validate_request is deprecated; use validate_request instead',
DeprecationWarning,
stacklevel=2,
)
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)
ep_name = request.config.get('request_validator', 'default')
# Override the RequestVariable name with a class from the specified
# entry point, if one is available. Otherwise, the default is used.
for ep in pkg_resources.iter_entry_points(VALIDATOR_EP_GROUP, ep_name):
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:
RequestValidator = ep.load()
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:
validator = RequestValidator()
validator.validate(request, requirement)
except NotAuthorized as e:
except milla.auth.NotAuthorized as e:
return e(request)
return func(*args, **kwargs)
def auth_required(func):
'''Simple decorator to enforce authentication for a controller
@ -85,9 +135,10 @@ def auth_required(func):
@wraps(func)
def wrapper(*args, **kwargs):
return _validate_request(func, None, *args, **kwargs)
return validate_request(func, None, *args, **kwargs)
return wrapper
class require_perms(object):
'''Decorator that requires the user have certain permissions
@ -124,7 +175,7 @@ class require_perms(object):
def __init__(self, *requirements):
requirement = None
for req in requirements:
if isinstance(req, basestring):
if not hasattr(req, 'check'):
req = permissions.Permission(req)
if not requirement:
requirement = req
@ -135,5 +186,5 @@ class require_perms(object):
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
return _validate_request(func, self.requirement, *args, **kwargs)
return validate_request(func, self.requirement, *args, **kwargs)
return wrapper

View File

@ -86,17 +86,8 @@ class Permission(BasePermission):
def __init__(self, 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):
if isinstance(self.name, str):
return self.name
else:
return self.name.encode('utf-8')
return str(self.name)
def __eq__(self, other):
return self is other or str(self) == str(other)
@ -119,7 +110,7 @@ class PermissionRequirement(BasePermission):
self.requirements = requirements
def __str__(self):
return unicode(self).encode('utf-8')
return ', '.join(self.requirements)
class PermissionRequirementAll(PermissionRequirement):
'''Complex permission requirement needing all given permissions'''

View File

@ -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()

View File

@ -19,12 +19,15 @@ from one or more of these classes can make things significantly easier.
:Created: Mar 27, 2011
:Author: dustin
:Updated: $Date$
:Updater: $Author$
'''
import milla
import datetime
import milla.util
import os
try:
import pkg_resources
except ImportError:
pkg_resources = None
class Controller(object):
@ -53,18 +56,27 @@ class FaviconController(Controller):
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'):
try:
if icon:
try:
self.icon = open(icon)
except (IOError, OSError):
self.icon = None
else:
try:
self.icon = pkg_resources.resource_stream('milla', 'milla.ico')
except IOError:
self.icon = None
self.icon = open(icon, 'rb')
self.content_type = content_type
elif pkg_resources:
self.icon = pkg_resources.resource_stream('milla', 'milla.ico')
self.content_type = 'image/x-icon'
else:
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):
if not self.icon:
@ -72,4 +84,39 @@ class FaviconController(Controller):
response = milla.Response()
response.app_iter = self.icon
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
class HTTPVerbController(Controller):
'''A controller that delegates requests based on the HTTP method
Subclasses of this controller should have an instance method for
every HTTP method they support. For example, to support the ``GET``
and ``POST`` methods, a class might look like this:
.. code-block:: python
class MyController(HTTPVerbController):
def GET(self, request):
return 'Hello, world!'
def POST(self, request):
return 'Thanks!'
'''
def __call__(self, request, *args, **kwargs):
try:
func = getattr(self, request.method)
except AttributeError:
raise milla.HTTPMethodNotAllowed
return func(request, *args, **kwargs)
@property
def allowed_methods(self):
for attr in dir(self):
if attr.upper() == attr:
yield attr

View File

@ -1,4 +1,4 @@
# Copyright 2011 Dustin C. Hatch
# Copyright 2011, 2012, 2015 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.
@ -24,8 +24,7 @@ import functools
import milla
import re
import sys
import urllib
import urlparse
import warnings
class Router(object):
'''A dispatcher that maps arbitrary paths to controller callables
@ -98,7 +97,7 @@ class Router(object):
for attr in functools.WRAPPER_ASSIGNMENTS:
try:
value = getattr(controller, attr)
except AttributeError:
except AttributeError: #pragma: no cover
pass
else:
setattr(func, attr, value)
@ -122,8 +121,9 @@ class Router(object):
# Return a dummy function that just raises
# HTTPMovedPermanently to redirect the client to
# the canonical URL
def redir(*args, **kwargs):
raise milla.HTTPMovedPermanently(location=new_path_info)
def redir(request, *args, **kwargs):
raise milla.HTTPMovedPermanently(
location=request.create_href(new_path_info))
return redir
elif func and self.trailing_slash is Router.SILENT:
# Return the function found at the alternate path
@ -201,43 +201,7 @@ class Router(object):
module ``some.module``.
'''
if isinstance(controller, basestring):
if not hasattr(controller, '__call__'):
controller = self._import_controller(controller)
self.routes.append((self._compile_template(template),
controller, vars))
class Generator(object):
'''URL generator
Creates URL references based on a *WebOb* request.
Typical usage:
>>> generator = Generator(request)
>>> generator.generate('foo', 'bar')
'/foo/bar'
A common pattern is to wrap this in a stub function::
url = Generator(request).generate
'''
def __init__(self, request, 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

View File

@ -11,16 +11,20 @@
# 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.uti
Please give me a docstring!
'''Convenience utility functions
:Created: Mar 30, 2011
: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):
'''Test a value for truth
@ -47,3 +51,74 @@ def asbool(val):
if val in ('false', 'no', 'f', 'n', 'off', '0'):
return False
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

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

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

562
test/test_app.py Normal file
View File

@ -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

507
test/test_auth.py Normal file
View File

@ -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')

View File

@ -6,9 +6,9 @@
:Updater: $Author$
'''
import milla.dispatch.routing
import milla.controllers
import nose.tools
def fake_controller():
pass
def test_static():
'''Ensure the dispatcher can resolve a static path
@ -44,6 +44,7 @@ def test_urlvars():
assert func.keywords['bar'] == 'abc'
assert func.keywords['baz'] == 'def'
@nose.tools.raises(milla.dispatch.UnresolvedPath)
def test_regexp_urlvar():
'''Ensure the dispatcher can resolve alternate regexps in urlvars
@ -61,13 +62,9 @@ def test_regexp_urlvar():
assert func.func == controller
assert func.keywords['arg'] == 'abcde'
try:
func = router.resolve('/test/1234')
except milla.dispatch.UnresolvedPath:
pass
else:
raise AssertionError
router.resolve('/test/1234')
@nose.tools.raises(milla.dispatch.UnresolvedPath)
def test_unresolved():
'''Ensure the resolver raises an exception for unresolved paths
@ -80,12 +77,7 @@ def test_unresolved():
router = milla.dispatch.routing.Router()
router.add_route('/test', controller)
try:
router.resolve('/tset')
except milla.dispatch.UnresolvedPath:
pass
else:
raise AssertionError
def test_unrelated():
'''Ensure the dispatcher is not confused by unrelated paths
@ -116,6 +108,48 @@ def test_string_controller():
'''
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')
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

220
test/test_vary.py Normal file
View File

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