Compare commits

..

No commits in common. "master" and "0.2" have entirely different histories.
master ... 0.2

26 changed files with 516 additions and 1079 deletions

5
.gitignore vendored
View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,28 +21,11 @@ from webob.exc import *
import webob import webob
try: try:
import urllib.parse import urllib.parse
except ImportError: # pragma: no cover except ImportError: #pragma: no cover
import urllib import urllib
import urlparse import urlparse
urllib.parse = urlparse urllib.parse = urlparse
urllib.parse.urlencode = urllib.urlencode urllib.parse.urlencode = urllib.urlencode #@UndefinedVariable
class _AllowAll(object):
def __contains__(self, other):
return True
ALL_METHODS = _AllowAll()
'''Allow all HTTP methods (even non-standard ones)'''
DEFAULT_METHODS = ['GET', 'HEAD']
'''Default methods allowed on controller callables'''
STANDARD_METHODS = ['OPTIONS', 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'TRACE']
'''All standard HTTP methods'''
def allow(*methods): def allow(*methods):
'''Specify the allowed HTTP verbs for a controller callable '''Specify the allowed HTTP verbs for a controller callable

View File

@ -1,4 +1,4 @@
# Copyright 2011, 2012, 2014-2016 Dustin C. Hatch # Copyright 2011 Dustin C. Hatch
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -13,98 +13,108 @@
# limitations under the License. # limitations under the License.
'''Module milla.app '''Module milla.app
The :py:class:`BaseApplication` class is the core of the Milla Please give me a docstring!
framework. This class implements a WSGI application that dispatches
requests to callables based on their URL-path.
Most applications can use :py:class:`Application` directly, without
creating a sub-class. For advanced use, applications can define a
sub-class of :py:class:`BaseApplication` and customize the behavior of
the framework.
:Created: Mar 26, 2011 :Created: Mar 26, 2011
:Author: dustin :Author: dustin
:Updated: $Date$
:Updater: $Author$
''' '''
from milla import util
from milla.controllers import FaviconController from milla.controllers import FaviconController
from milla.util import asbool
from webob.exc import HTTPNotFound, WSGIHTTPException, HTTPMethodNotAllowed from webob.exc import HTTPNotFound, WSGIHTTPException, HTTPMethodNotAllowed
import milla.dispatch.traversal import milla.dispatch.traversal
import os
import sys
__all__ = ['Application']
__all__ = [ class Application(object):
'Application', '''Represents a Milla web 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`.
try: :param obj: An object implementing the dispatcher protocol, or an
# In Python 2, it could be a str or a unicode object object to be used as the root for a Traverser
_string = basestring
except NameError:
# In Python 3, we are only interested in str objects
_string = str
``Application`` instances are WSGI applications.
class BaseApplication(object): .. py:attribute:: config
'''Base class for Milla applications
This class can be used by applications that need to customize the A mapping of configuration settings. For each request, the
behavior of the framework. In most cases, :py:class:`Application` configuration is copied and assigned to ``request.config``.
instances can be created directly and a sublcass is not necessary.
''' '''
DEFAULT_CONFIG = {} DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD']
#: Enable HTTP method emulation for POST requests? def __init__(self, obj):
post_method_emulation = True if not hasattr(obj, 'resolve'):
# Object is not a dispatcher, but the root object for traversal
def __init__(self): obj = milla.dispatch.traversal.Traverser(obj)
self.config = self.DEFAULT_CONFIG.copy() self.dispatcher = obj
self.dispatcher = None self.config = {'milla.favicon': True}
def __call__(self, environ, start_response): def __call__(self, environ, start_response):
start_response = StartResponseWrapper(start_response) path_info = environ['PATH_INFO']
func = self.resolve_path(environ['PATH_INFO'])
request = self.make_request(environ)
request.__dict__['start_response'] = start_response
try: try:
allowed_methods = self._find_attr(func, 'allowed_methods') func = self.dispatcher.resolve(path_info)
except AttributeError: except milla.dispatch.UnresolvedPath:
allowed_methods = milla.DEFAULT_METHODS if asbool(self.config.get('milla.favicon')) and \
if request.method == 'HEAD': path_info == '/favicon.ico':
request.real_method = 'HEAD' func = FaviconController()
request.method = 'GET' else:
else: return HTTPNotFound()(environ, start_response)
request.real_method = request.method
request = milla.Request(environ)
request.config = self.config.copy()
# Sometimes, hacky applications will try to "emulate" some HTTP
# method like POST or DELETE by specifying an _method parameter
# in a POST request.
if request.method == 'POST' and '_method' in request.POST:
request.method = request.POST.pop('_method')
allowed_methods = getattr(func, 'allowed_methods',
self.DEFAULT_ALLOWED_METHODS)
if request.method not in allowed_methods: if request.method not in allowed_methods:
allow_header = {'Allow': ', '.join(allowed_methods)} allow_header = {'Allow': ', '.join(allowed_methods)}
if request.method == 'OPTIONS': if request.method == 'OPTIONS':
def func(request): def options_response(request, *args, **kwargs):
response = request.ResponseClass() response = request.ResponseClass()
response.headers = allow_header response.headers = allow_header
return response return response
func = options_response
else: else:
def func(request): func = HTTPMethodNotAllowed(headers=allow_header)
raise HTTPMethodNotAllowed(headers=allow_header) return func(environ, start_response)
start_response_wrapper = StartResponseWrapper(start_response)
request.start_response = start_response_wrapper
try: try:
self._call_before(func)(request) self._call_before(func)(request)
response = func(request) response = func(request)
except: except WSGIHTTPException as e:
response = self.handle_error(request) return e(environ, start_response)
finally: finally:
self._call_after(func)(request) self._call_after(func)(request)
# The callable might have returned just a string, which is OK, # The callable might have returned just a string, which is OK,
# but we need to wrap it in a Response object # but we need to wrap it in a Response object
if isinstance(response, _string) or not response: try:
# In Python 2, it could be a str or a unicode object
basestring = basestring #@UndefinedVariable
except NameError:
# Python 3 has no unicode objects and thus no need for
# basestring so we, just make it an alias for str
basestring = str
if isinstance(response, basestring) or not response:
response = request.ResponseClass(response) response = request.ResponseClass(response)
return response(environ, start_response) if not start_response_wrapper.called:
start_response(response.status, response.headerlist)
if not environ['REQUEST_METHOD'] == 'HEAD':
return response.app_iter
def _call_after(self, func): def _call_after(self, func):
try: try:
@ -131,109 +141,6 @@ class BaseApplication(object):
return self._find_attr(obj.func, attr) return self._find_attr(obj.func, attr)
raise 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(): class StartResponseWrapper():
def __init__(self, start_response): def __init__(self, start_response):
@ -241,6 +148,5 @@ class StartResponseWrapper():
self.called = False self.called = False
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
if not self.called: self.called = True
self.called = True return self.start_response(*args, **kwargs)
self.start_response(*args, **kwargs)

View File

@ -15,28 +15,19 @@
:Created: Mar 3, 2011 :Created: Mar 3, 2011
:Author: dustin :Author: dustin
:Updated: $Date$
:Updater: $Author$
''' '''
from functools import wraps from functools import wraps
from milla.auth import permissions from milla.auth import RequestValidator, NotAuthorized, permissions
import milla.auth import milla
import warnings import pkg_resources
try:
import pkg_resources
except ImportError:
pkg_resources = None
__all__ = [
'auth_required',
'require_perms',
'validate_request',
]
__all__ = ['auth_required', 'require_perms']
VALIDATOR_EP_GROUP = 'milla.request_validator' VALIDATOR_EP_GROUP = 'milla.request_validator'
def _find_request(*args, **kwargs): def _find_request(*args, **kwargs):
try: try:
return kwargs['request'] return kwargs['request']
@ -45,66 +36,25 @@ def _find_request(*args, **kwargs):
if isinstance(arg, milla.Request): if isinstance(arg, milla.Request):
return arg return arg
def _validate_request(func, requirement, *args, **kwargs): 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) request = _find_request(*args, **kwargs)
ep_name = request.config.get('request_validator', 'default')
rv = request.config.get('request_validator', 'default') # Override the RequestVariable name with a class from the specified
if hasattr(rv, 'validate'): # entry point, if one is available. Otherwise, the default is used.
# Config specifies a request validator class explicitly instead for ep in pkg_resources.iter_entry_points(VALIDATOR_EP_GROUP, ep_name):
# of an entry point name, so use it directly try:
validator = rv() RequestValidator = ep.load()
elif pkg_resources: except:
for ep in pkg_resources.iter_entry_points(VALIDATOR_EP_GROUP, rv): continue
try:
validator = ep.load()()
break
except:
# Ignore errors loading entry points or creating instances
continue
else:
# No entry point loaded or request validator instance
# created, use the default
validator = milla.auth.RequestValidator()
else:
# config does not specify a request validator class, and
# setuptools is not available, use the default
validator = milla.auth.RequestValidator()
try: try:
validator = RequestValidator()
validator.validate(request, requirement) validator.validate(request, requirement)
except milla.auth.NotAuthorized as e: except NotAuthorized as e:
return e(request) return e(request)
return func(*args, **kwargs) return func(*args, **kwargs)
def auth_required(func): def auth_required(func):
'''Simple decorator to enforce authentication for a controller '''Simple decorator to enforce authentication for a controller
@ -135,10 +85,9 @@ def auth_required(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
return validate_request(func, None, *args, **kwargs) return _validate_request(func, None, *args, **kwargs)
return wrapper return wrapper
class require_perms(object): class require_perms(object):
'''Decorator that requires the user have certain permissions '''Decorator that requires the user have certain permissions
@ -186,5 +135,5 @@ class require_perms(object):
def __call__(self, func): def __call__(self, func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
return validate_request(func, self.requirement, *args, **kwargs) return _validate_request(func, self.requirement, *args, **kwargs)
return wrapper return wrapper

116
src/milla/cli.py Normal file
View File

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

View File

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

View File

@ -1,4 +1,4 @@
# Copyright 2011, 2012, 2015 Dustin C. Hatch # Copyright 2011 Dustin C. Hatch
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -121,9 +121,8 @@ class Router(object):
# Return a dummy function that just raises # Return a dummy function that just raises
# HTTPMovedPermanently to redirect the client to # HTTPMovedPermanently to redirect the client to
# the canonical URL # the canonical URL
def redir(request, *args, **kwargs): def redir(*args, **kwargs):
raise milla.HTTPMovedPermanently( raise milla.HTTPMovedPermanently(location=new_path_info)
location=request.create_href(new_path_info))
return redir return redir
elif func and self.trailing_slash is Router.SILENT: elif func and self.trailing_slash is Router.SILENT:
# Return the function found at the alternate path # Return the function found at the alternate path
@ -205,3 +204,43 @@ class Router(object):
controller = self._import_controller(controller) controller = self._import_controller(controller)
self.routes.append((self._compile_template(template), self.routes.append((self._compile_template(template),
controller, vars)) controller, vars))
class Generator(object):
'''URL generator
Creates URL references based on a *WebOb* request.
Typical usage:
>>> generator = Generator(request)
>>> generator.generate('foo', 'bar')
'/foo/bar'
A common pattern is to wrap this in a stub function::
url = Generator(request).generate
.. deprecated:: 0.2
Use :py:meth:`milla.Request.create_href` instead.
'''
def __init__(self, request, path_only=True):
self.request = request
self.path_only = path_only
warnings.warn(
'Use of Generator is deprecated; '
'use milla.Request.create_href instead',
DeprecationWarning,
stacklevel=2
)
def generate(self, *segments, **vars):
'''Combines segments and the application's URL into a new URL
'''
path = '/'.join(str(s) for s in segments)
if self.path_only:
return self.request.create_href(path, **vars)
else:
return self.request.create_href_full(path, **vars)

View File

@ -3,31 +3,13 @@
:Created: Nov 27, 2012 :Created: Nov 27, 2012
:Author: dustin :Author: dustin
''' '''
from unittest.case import SkipTest
import functools import functools
import milla.app import milla.app
import milla.dispatch import milla.dispatch
import nose.tools import nose.tools
import sys
import wsgiref.util import wsgiref.util
import webob.exc import webob.exc
def python2_only(test):
@functools.wraps(test)
def wrapper():
if sys.version_info[0] != 2:
raise SkipTest
return test()
return wrapper
def python3_only(test):
@functools.wraps(test)
def wrapper():
if sys.version_info[0] != 3:
raise SkipTest
return test()
return wrapper
class StubResolver(object): class StubResolver(object):
'''Stub resolver for testing purposes''' '''Stub resolver for testing purposes'''
@ -163,64 +145,6 @@ def test_emulated_method():
response.finish_response(app_iter) response.finish_response(app_iter)
assert response.headers.startswith('HTTP/1.1 200'), response.headers assert response.headers.startswith('HTTP/1.1 200'), response.headers
def test_return_none():
'''Controllers can return None
'''
def controller(request):
return None
app = milla.app.Application(StubResolver(controller))
environ = environ_for_testing()
response = ResponseMaker()
app_iter = app(environ, response.start_response)
response.finish_response(app_iter)
assert not response.body, response.body
def test_return_str():
'''Controllers can return str objects
'''
def controller(request):
return 'Hello, world'
app = milla.app.Application(StubResolver(controller))
environ = environ_for_testing()
response = ResponseMaker()
app_iter = app(environ, response.start_response)
response.finish_response(app_iter)
assert response.body == b'Hello, world', response.body
@python2_only
def test_return_unicode():
'''Controllers can return unicode objects
'''
def controller(request):
return unicode('Hello, world')
app = milla.app.Application(StubResolver(controller))
environ = environ_for_testing()
response = ResponseMaker()
app_iter = app(environ, response.start_response)
response.finish_response(app_iter)
assert response.body == unicode('Hello, world'), response.body
@nose.tools.raises(TypeError)
@python3_only
def test_return_bytes():
'''Controllers cannot return bytes objects
'''
def controller(request):
return b'Hello, world'
app = milla.app.Application(StubResolver(controller))
environ = environ_for_testing()
response = ResponseMaker()
app_iter = app(environ, response.start_response)
response.finish_response(app_iter)
@nose.tools.raises(BeforeCalled) @nose.tools.raises(BeforeCalled)
def test_function_before(): def test_function_before():
'''__before__ attribute is called for controller functions '''__before__ attribute is called for controller functions
@ -560,3 +484,4 @@ def test_static_resource_undefined():
app_iter = app(environ, response.start_response) app_iter = app(environ, response.start_response)
response.finish_response(app_iter) response.finish_response(app_iter)
assert response.body == b'/image.png', response.body assert response.body == b'/image.png', response.body

View File

@ -6,9 +6,10 @@
:Updater: $Author$ :Updater: $Author$
''' '''
import milla.dispatch.routing import milla.dispatch.routing
import milla.controllers
import nose.tools import nose.tools
def fake_controller():
pass
def test_static(): def test_static():
'''Ensure the dispatcher can resolve a static path '''Ensure the dispatcher can resolve a static path
@ -108,10 +109,11 @@ def test_string_controller():
''' '''
router = milla.dispatch.routing.Router() router = milla.dispatch.routing.Router()
router.add_route('/test', 'milla.controllers:Controller') router.add_route('/test', 'milla.tests.test_routing:fake_controller')
func = router.resolve('/test') func = router.resolve('/test')
assert func.func == milla.controllers.Controller assert func.func == fake_controller
@nose.tools.raises(milla.HTTPMovedPermanently)
def test_trailing_slash_redir(): def test_trailing_slash_redir():
'''Paths that match except the trailing slash return a HTTP redirect '''Paths that match except the trailing slash return a HTTP redirect
''' '''
@ -123,12 +125,7 @@ def test_trailing_slash_redir():
router.add_route('/test/', controller) router.add_route('/test/', controller)
func = router.resolve('/test') func = router.resolve('/test')
assert func is not controller assert func is not controller
try: func()
func(milla.Request.blank('/test'))
except milla.HTTPMovedPermanently as e:
assert e.location == '/test/'
else:
raise AssertionError('Redirect not raised')
@nose.tools.raises(milla.dispatch.routing.UnresolvedPath) @nose.tools.raises(milla.dispatch.routing.UnresolvedPath)
def test_trailing_slash_none(): def test_trailing_slash_none():

View File

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

View File

@ -1,220 +0,0 @@
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')