Compare commits
24 Commits
Author | SHA1 | Date |
---|---|---|
|
bd65cf01fb | |
|
c97d570f66 | |
|
40a0e8ead4 | |
|
d955d23e91 | |
|
f3a98c2697 | |
|
807a487639 | |
|
ff27f3a917 | |
|
71d00e4207 | |
|
83971013d0 | |
|
44a28fda68 | |
|
4090df1286 | |
|
95caf1020b | |
|
a593bb762f | |
|
40ede10425 | |
|
634c914f6d | |
|
3beca18523 | |
|
da406fcce8 | |
|
922a82e4e8 | |
|
94b98a0620 | |
|
86b19bb9e7 | |
|
6519cfbb9e | |
|
fc04300140 | |
|
f2602388e3 | |
|
4ffb3f3707 |
|
@ -0,0 +1,5 @@
|
|||
/build/
|
||||
/dist/
|
||||
*.egg-info/
|
||||
__pycache__/
|
||||
*.py[co]
|
1
.hgtags
1
.hgtags
|
@ -1,3 +1,4 @@
|
|||
b3553fb88649e28a7fae7c1ce348625b38d06b65 0.1
|
||||
e7c7497afb2137fec4445e4d04c6d7405f0fa289 0.1.2
|
||||
2d04d03ce334502eff4e07fd36f6536ded58a2d3 0.2
|
||||
3b8acd86b010ac48b99dfa95859f6522073c142a 0.2.1
|
||||
|
|
|
@ -2,6 +2,21 @@
|
|||
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
|
||||
=====
|
||||
|
@ -45,3 +60,4 @@ 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
|
||||
|
|
|
@ -49,7 +49,7 @@ copyright = u'2011-2015 Dustin C. Hatch'
|
|||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.2'
|
||||
version = '1.0'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = version
|
||||
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
==========
|
||||
milla.vary
|
||||
==========
|
||||
|
||||
.. automodule:: milla.vary
|
||||
:members:
|
5
setup.py
5
setup.py
|
@ -15,7 +15,7 @@ if sys.version_info < (2, 7):
|
|||
|
||||
setup(
|
||||
name='Milla',
|
||||
version='0.2.1',
|
||||
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'
|
||||
],
|
||||
}
|
||||
)
|
||||
|
|
|
@ -21,11 +21,28 @@ from webob.exc import *
|
|||
import webob
|
||||
try:
|
||||
import urllib.parse
|
||||
except ImportError: #pragma: no cover
|
||||
except ImportError: # pragma: no cover
|
||||
import urllib
|
||||
import urlparse
|
||||
urllib.parse = urlparse
|
||||
urllib.parse.urlencode = urllib.urlencode #@UndefinedVariable
|
||||
urllib.parse.urlencode = urllib.urlencode
|
||||
|
||||
|
||||
class _AllowAll(object):
|
||||
|
||||
def __contains__(self, other):
|
||||
return True
|
||||
|
||||
|
||||
ALL_METHODS = _AllowAll()
|
||||
'''Allow all HTTP methods (even non-standard ones)'''
|
||||
|
||||
DEFAULT_METHODS = ['GET', 'HEAD']
|
||||
'''Default methods allowed on controller callables'''
|
||||
|
||||
STANDARD_METHODS = ['OPTIONS', 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'TRACE']
|
||||
'''All standard HTTP methods'''
|
||||
|
||||
|
||||
def allow(*methods):
|
||||
'''Specify the allowed HTTP verbs for a controller callable
|
||||
|
|
219
src/milla/app.py
219
src/milla/app.py
|
@ -1,4 +1,4 @@
|
|||
# Copyright 2011, 2012, 2014, 2015 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,111 +13,98 @@
|
|||
# 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.traversal
|
||||
import os
|
||||
import sys
|
||||
|
||||
__all__ = ['Application']
|
||||
|
||||
class Application(object):
|
||||
'''Represents a Milla web application
|
||||
__all__ = [
|
||||
'Application',
|
||||
'BaseApplication',
|
||||
]
|
||||
|
||||
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 obj: An object implementing the dispatcher protocol, or an
|
||||
object to be used as the root for a Traverser
|
||||
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
|
||||
|
||||
``Application`` instances are WSGI applications.
|
||||
|
||||
.. py:attribute:: config
|
||||
class BaseApplication(object):
|
||||
'''Base class for Milla applications
|
||||
|
||||
A mapping of configuration settings. For each request, the
|
||||
configuration is copied and assigned to ``request.config``.
|
||||
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_ALLOWED_METHODS = ['GET', 'HEAD']
|
||||
DEFAULT_CONFIG = {}
|
||||
|
||||
def __init__(self, obj):
|
||||
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
|
||||
self.config = {'milla.favicon': True}
|
||||
#: 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):
|
||||
path_info = environ['PATH_INFO']
|
||||
try:
|
||||
func = self.dispatcher.resolve(path_info)
|
||||
except milla.dispatch.UnresolvedPath:
|
||||
if asbool(self.config.get('milla.favicon')) and \
|
||||
path_info == '/favicon.ico':
|
||||
func = FaviconController()
|
||||
else:
|
||||
return HTTPNotFound()(environ, start_response)
|
||||
|
||||
request = milla.Request(environ)
|
||||
request.config = self.config.copy()
|
||||
|
||||
# Sometimes, hacky applications will try to "emulate" some HTTP
|
||||
# method like POST or DELETE by specifying an _method parameter
|
||||
# in a POST request.
|
||||
if request.method == 'POST' and '_method' in request.POST:
|
||||
request.method = request.POST.pop('_method')
|
||||
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 = self.DEFAULT_ALLOWED_METHODS
|
||||
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 options_response(request, *args, **kwargs):
|
||||
def func(request):
|
||||
response = request.ResponseClass()
|
||||
response.headers = allow_header
|
||||
return response
|
||||
func = options_response
|
||||
else:
|
||||
func = HTTPMethodNotAllowed(headers=allow_header)
|
||||
return func(environ, start_response)
|
||||
def func(request):
|
||||
raise HTTPMethodNotAllowed(headers=allow_header)
|
||||
|
||||
start_response_wrapper = StartResponseWrapper(start_response)
|
||||
request.start_response = start_response_wrapper
|
||||
try:
|
||||
self._call_before(func)(request)
|
||||
response = func(request)
|
||||
except WSGIHTTPException as e:
|
||||
return e(environ, start_response)
|
||||
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
|
||||
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
|
||||
if isinstance(response, _string) or not response:
|
||||
response = request.ResponseClass(response)
|
||||
|
||||
if not start_response_wrapper.called:
|
||||
start_response(response.status, response.headerlist)
|
||||
if environ['REQUEST_METHOD'] == 'HEAD':
|
||||
return ''
|
||||
else:
|
||||
return response.app_iter
|
||||
return response(environ, start_response)
|
||||
|
||||
def _call_after(self, func):
|
||||
try:
|
||||
|
@ -144,6 +131,109 @@ class Application(object):
|
|||
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 obj: An object implementing the dispatcher protocol, or an
|
||||
object to be used as the root for a Traverser
|
||||
|
||||
``Application`` instances are WSGI applications.
|
||||
|
||||
.. py:attribute:: config
|
||||
|
||||
A mapping of configuration settings. For each request, the
|
||||
configuration is copied and assigned to ``request.config``.
|
||||
'''
|
||||
|
||||
DEFAULT_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
|
||||
|
||||
|
||||
class StartResponseWrapper():
|
||||
|
||||
def __init__(self, start_response):
|
||||
|
@ -151,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)
|
||||
|
|
|
@ -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
|
||||
import pkg_resources
|
||||
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
|
||||
|
||||
|
@ -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
|
||||
|
|
116
src/milla/cli.py
116
src/milla/cli.py
|
@ -1,116 +0,0 @@
|
|||
# Copyright 2011 Dustin C. Hatch
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
'''Module milla.cli
|
||||
|
||||
.. deprecated:: 0.2
|
||||
|
||||
This module is unmaintained and will be removed soon. Please do not use it.
|
||||
|
||||
:Created: May 30, 2011
|
||||
:Author: dustin
|
||||
:Updated: $Date$
|
||||
:Updater: $Author$
|
||||
'''
|
||||
import argparse
|
||||
import pkg_resources
|
||||
import warnings
|
||||
|
||||
|
||||
warnings.warn('The milla.cli module is unmaintained and will be removed soon',
|
||||
DeprecationWarning, stacklevel=2)
|
||||
|
||||
|
||||
class CommandWarning(UserWarning):
|
||||
'''A warning raised when a command cannot be loaded or used'''
|
||||
|
||||
|
||||
class CommandLineInterface(object):
|
||||
'''Wrapper class for the Milla CLI'''
|
||||
|
||||
PROGNAME = 'milla-cli'
|
||||
EPGROUP = 'milla.command'
|
||||
|
||||
def __init__(self):
|
||||
self.parser = argparse.ArgumentParser(prog=self.PROGNAME)
|
||||
subparsers = None
|
||||
subcmds = pkg_resources.iter_entry_points(self.EPGROUP)
|
||||
for cmd in subcmds:
|
||||
try:
|
||||
Command = cmd.load()
|
||||
except Exception as e:
|
||||
warnings.warn("Unable to load command from entry point named "
|
||||
"'{epname}': {e}".format(
|
||||
epname=cmd,
|
||||
e=e,
|
||||
), CommandWarning)
|
||||
continue
|
||||
|
||||
if not hasattr(Command, 'name') or not Command.name:
|
||||
warnings.warn("Command '{cmd}' from '{mod}' does not have a "
|
||||
"name and cannot be used".format(
|
||||
cmd=Command.__name__,
|
||||
mod=Command.__module__
|
||||
), CommandWarning)
|
||||
continue
|
||||
|
||||
if not subparsers:
|
||||
subparsers = self.parser.add_subparsers()
|
||||
subcmd = subparsers.add_parser(Command.name, *Command.parser_args,
|
||||
**Command.parser_kwargs)
|
||||
Command.setup_args(subcmd)
|
||||
subcmd.set_defaults(func=Command())
|
||||
|
||||
|
||||
class Command(object):
|
||||
'''Base class for "commands"
|
||||
|
||||
To create a command, subclass this class and override the following
|
||||
attributes:
|
||||
|
||||
* ``name`` -- Name of the subcommand
|
||||
* ``parser_args`` -- arguments to pass to ``add_parser`` when
|
||||
creating the ArgumentParser for the command
|
||||
* ``parser_kwargs`` -- keywords to pass to ``add_parser``
|
||||
* ``setup_args()`` -- Class method called to add arguments, etc. to
|
||||
the command subparser.
|
||||
'''
|
||||
|
||||
#: Tuple of arguments to pass to the ArgumentParser constructor
|
||||
parser_args = ()
|
||||
#: Dict of keywords to pass to the ArgumentParser constructor
|
||||
parser_kwargs = {}
|
||||
|
||||
@classmethod
|
||||
def setup_args(cls, parser):
|
||||
'''Add arguments, etc to the command subparser
|
||||
|
||||
Override this method to add arguments, options, etc. to the
|
||||
subparser for the command. The default implementation does
|
||||
not add anything.
|
||||
|
||||
:param parser: An instance of ``ArgumentParser`` for the
|
||||
``milla-cli`` subcommand
|
||||
'''
|
||||
|
||||
pass
|
||||
|
||||
def main():
|
||||
'''Entry point for the ``milla-cli`` console script'''
|
||||
|
||||
cli = CommandLineInterface()
|
||||
args = cli.parser.parse_args()
|
||||
func = args.func
|
||||
del args.func
|
||||
func.args = args
|
||||
func()
|
|
@ -19,13 +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 datetime
|
||||
import milla.util
|
||||
import pkg_resources
|
||||
import os
|
||||
try:
|
||||
import pkg_resources
|
||||
except ImportError:
|
||||
pkg_resources = None
|
||||
|
||||
|
||||
class Controller(object):
|
||||
|
@ -58,17 +60,23 @@ class FaviconController(Controller):
|
|||
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:
|
||||
|
@ -80,3 +88,35 @@ class FaviconController(Controller):
|
|||
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
|
||||
|
|
|
@ -205,43 +205,3 @@ class Router(object):
|
|||
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
|
||||
|
||||
.. deprecated:: 0.2
|
||||
Use :py:meth:`milla.Request.create_href` instead.
|
||||
'''
|
||||
|
||||
def __init__(self, request, path_only=True):
|
||||
self.request = request
|
||||
self.path_only = path_only
|
||||
warnings.warn(
|
||||
'Use of Generator is deprecated; '
|
||||
'use milla.Request.create_href instead',
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
def generate(self, *segments, **vars):
|
||||
'''Combines segments and the application's URL into a new URL
|
||||
'''
|
||||
|
||||
path = '/'.join(str(s) for s in segments)
|
||||
|
||||
if self.path_only:
|
||||
return self.request.create_href(path, **vars)
|
||||
else:
|
||||
return self.request.create_href_full(path, **vars)
|
||||
|
|
|
@ -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)
|
|
@ -206,7 +206,7 @@ def test_return_unicode():
|
|||
response.finish_response(app_iter)
|
||||
assert response.body == unicode('Hello, world'), response.body
|
||||
|
||||
@nose.tools.raises(AttributeError)
|
||||
@nose.tools.raises(TypeError)
|
||||
@python3_only
|
||||
def test_return_bytes():
|
||||
'''Controllers cannot return bytes objects
|
||||
|
|
|
@ -0,0 +1,220 @@
|
|||
from milla import vary
|
||||
import collections
|
||||
import functools
|
||||
import milla
|
||||
import nose.tools
|
||||
import sys
|
||||
try:
|
||||
from unittest import mock
|
||||
except ImportError:
|
||||
import mock
|
||||
|
||||
|
||||
PY2 = sys.version_info[0] == 2
|
||||
PY3 = sys.version_info[0] == 3
|
||||
|
||||
def test_renders_decorator():
|
||||
'''renders modifies and returns the decorated object'''
|
||||
|
||||
def func():
|
||||
pass
|
||||
|
||||
func2 = vary.renders('text/html')(func)
|
||||
|
||||
assert func2 is func
|
||||
assert 'text/html' in func.renders
|
||||
|
||||
|
||||
def test_default_renderer_decorator():
|
||||
'''default_renderer modifies and returns the decorated object'''
|
||||
|
||||
def func():
|
||||
pass
|
||||
|
||||
func2 = vary.default_renderer(func)
|
||||
|
||||
assert func2 is func
|
||||
assert func.default_renderer
|
||||
|
||||
|
||||
def test_variedresponsemeta_renderers():
|
||||
'''VariedResponseMeta adds renderers dict to implementation classes'''
|
||||
|
||||
TestClass = vary.VariedResponseMeta('TestClass', (object,), {})
|
||||
|
||||
assert isinstance(TestClass.renderers, collections.Mapping)
|
||||
|
||||
|
||||
def test_variedresponsemeta_default_renderer():
|
||||
'''VariedResponseMeta adds default_type to implementation classes'''
|
||||
|
||||
TestClass = vary.VariedResponseMeta('TestClass', (object,), {})
|
||||
|
||||
assert TestClass.default_type is None
|
||||
|
||||
|
||||
def test_variedresponsemeta_renders():
|
||||
'''Test VariedResponseMeta implementation class renderers population'''
|
||||
|
||||
VariedResponse = vary.VariedResponseMeta('VariedResponse', (object,), {})
|
||||
|
||||
class TestClass(VariedResponse):
|
||||
|
||||
@vary.renders('text/html')
|
||||
def render_html(self, context):
|
||||
pass
|
||||
|
||||
|
||||
if PY2:
|
||||
want_func = TestClass.render_html.__func__
|
||||
else:
|
||||
want_func = TestClass.render_html
|
||||
assert TestClass.renderers['text/html'] is want_func
|
||||
|
||||
|
||||
def test_variedresponsemeta_default_renderer():
|
||||
'''Test VariedResponseMeta implementation class sets default type'''
|
||||
|
||||
VariedResponse = vary.VariedResponseMeta('VariedResponse', (object,), {})
|
||||
|
||||
class TestClass(VariedResponse):
|
||||
|
||||
@vary.default_renderer
|
||||
@vary.renders('text/html')
|
||||
def render_html(self, context):
|
||||
pass
|
||||
|
||||
assert TestClass.default_type == 'text/html'
|
||||
|
||||
|
||||
def test_variedresponsebase_init_super():
|
||||
'''VariedResponseBase.__init__ calls Response.__init__'''
|
||||
|
||||
request = milla.Request.blank('http://localhost/')
|
||||
with mock.patch.object(milla.Response, '__init__') as init:
|
||||
vary.VariedResponseBase(request, 'a', b='c')
|
||||
|
||||
assert init.called_with('a', b='c')
|
||||
|
||||
|
||||
def test_variedresponsebase_for_request():
|
||||
'''VariedResponseBase.for_request returns a partial'''
|
||||
|
||||
request = milla.Request.blank('http://localhost/')
|
||||
klass = vary.VariedResponseBase.for_request(request)
|
||||
assert isinstance(klass, functools.partial), klass
|
||||
|
||||
|
||||
def test_variedresponsebase_set_payload_set_vary():
|
||||
'''VariedResponseBase.set_payload sets the Vary response header'''
|
||||
|
||||
def render_html(response, context):
|
||||
pass
|
||||
|
||||
request = milla.Request.blank('http://localhost/')
|
||||
response = vary.VariedResponseBase(request)
|
||||
response.renderers['text/html'] = render_html
|
||||
response.set_payload({})
|
||||
|
||||
assert response.headers['Vary'] == 'Accept'
|
||||
|
||||
|
||||
def test_variedresponsebase_set_payload_add_vary():
|
||||
'''VariedResponseBase.set_payload adds to the Vary response header'''
|
||||
|
||||
def render_html(response, context):
|
||||
pass
|
||||
|
||||
request = milla.Request.blank('http://localhost/')
|
||||
response = vary.VariedResponseBase(request)
|
||||
response.renderers['text/html'] = render_html
|
||||
response.vary = ('Cookie',)
|
||||
response.set_payload({})
|
||||
|
||||
assert response.headers['Vary'] == 'Cookie, Accept'
|
||||
|
||||
|
||||
def test_variedresponsebase_set_payload_match():
|
||||
'''VariedResponseBase.set_payload calls the matching renderer'''
|
||||
|
||||
class State(object):
|
||||
html_called = False
|
||||
json_called = False
|
||||
|
||||
def render_html(response, state):
|
||||
state.html_called = True
|
||||
|
||||
render_html.renders = ('text/html',)
|
||||
|
||||
def render_json(response, state):
|
||||
state.json_called = True
|
||||
|
||||
render_json.renders = ('application/json',)
|
||||
|
||||
def check_type(accept, attr):
|
||||
request = milla.Request.blank('http://localhost/')
|
||||
request.accept = accept
|
||||
response = vary.VariedResponseBase(request)
|
||||
response.renderers = {
|
||||
'text/html': render_html,
|
||||
'application/json': render_json,
|
||||
}
|
||||
state = State()
|
||||
response.set_payload(state)
|
||||
|
||||
assert getattr(state, attr)
|
||||
|
||||
tests = [
|
||||
('text/html', 'html_called'),
|
||||
('application/json', 'json_called'),
|
||||
]
|
||||
for accept, attr in tests:
|
||||
yield check_type, accept, attr
|
||||
|
||||
|
||||
@nose.tools.raises(milla.HTTPNotAcceptable)
|
||||
def test_variedresponsebase_set_payload_not_acceptable():
|
||||
'''VariedResponseBase.set_payload raises HTTPNotAcceptable'''
|
||||
|
||||
def render_html(response, context):
|
||||
pass
|
||||
|
||||
request = milla.Request.blank('http://localhost/')
|
||||
request.accept = 'text/plain'
|
||||
response = vary.VariedResponseBase(request)
|
||||
response.renderers['text/html'] = render_html
|
||||
response.set_payload({})
|
||||
|
||||
|
||||
def test_variedresponsebase_set_payload_default_format():
|
||||
'''VariedResponseBase.set_payload falls back to the default renderer'''
|
||||
|
||||
class State(object):
|
||||
called = False
|
||||
|
||||
state = State()
|
||||
|
||||
def render_html(response, context):
|
||||
state.called = True
|
||||
|
||||
request = milla.Request.blank('http://localhost/')
|
||||
request.accept = 'text/plain'
|
||||
response = vary.VariedResponseBase(request)
|
||||
response.renderers['text/html'] = render_html
|
||||
response.default_type = 'text/html'
|
||||
ctx = {}
|
||||
response.set_payload(ctx)
|
||||
|
||||
assert state.called
|
||||
|
||||
|
||||
def test_variedresponsebase_set_payload_renderer_unknown_kwargs():
|
||||
'''VariedResponseBase.set_payload ignores unknown keyword arguments'''
|
||||
|
||||
def render_html(response, context):
|
||||
pass
|
||||
|
||||
request = milla.Request.blank('http://localhost/')
|
||||
response = vary.VariedResponseBase(request)
|
||||
response.renderers['text/html'] = render_html
|
||||
response.set_payload({}, foo='bar')
|
Loading…
Reference in New Issue