Compare commits

..

13 Commits
0.3 ... master

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

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

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

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

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

The major difference between `BaseApplication` and `Application` is that
the latter requires a request dispatcher, while the former does not.
This pattern allows simple applications to construct a `Router` or root
object and then initialize the `Application` without needing to define a
sub-class.
2016-07-14 12:37:47 -05:00
Dustin 4090df1286 Begin 1.0 development 2016-07-14 12:37:47 -05:00
Dustin 95caf1020b Add .gitignore 2016-07-14 12:37:47 -05:00
Dustin a593bb762f Rename README for GitLab compatibility? 2016-07-06 20:58:27 -05:00
10 changed files with 540 additions and 51 deletions

5
.gitignore vendored Normal file
View File

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

View File

View File

@ -49,7 +49,7 @@ copyright = u'2011-2015 Dustin C. Hatch'
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = '0.2' version = '1.0'
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = version release = version

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

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

View File

@ -15,7 +15,7 @@ if sys.version_info < (2, 7):
setup( setup(
name='Milla', name='Milla',
version='0.3', version='1.0',
description='Lightweight WSGI framework for web applications', description='Lightweight WSGI framework for web applications',
long_description='''\ long_description='''\
Milla is a simple WSGI framework for Python web applications. It is mostly Milla is a simple WSGI framework for Python web applications. It is mostly

View File

@ -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"); # 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,48 +13,57 @@
# limitations under the License. # limitations under the License.
'''Module milla.app '''Module milla.app
Please give me a docstring! The :py:class:`BaseApplication` class is the core of the Milla
framework. This class implements a WSGI application that dispatches
requests to callables based on their URL-path.
Most applications can use :py:class:`Application` directly, without
creating a sub-class. For advanced use, applications can define a
sub-class of :py:class:`BaseApplication` and customize the behavior of
the framework.
:Created: Mar 26, 2011 :Created: Mar 26, 2011
:Author: dustin :Author: dustin
:Updated: $Date$
:Updater: $Author$
''' '''
from milla import util
from milla.controllers import FaviconController from milla.controllers import FaviconController
from milla.util import asbool
from webob.exc import HTTPNotFound, WSGIHTTPException, HTTPMethodNotAllowed from webob.exc import HTTPNotFound, WSGIHTTPException, HTTPMethodNotAllowed
import milla.dispatch.traversal import milla.dispatch.traversal
import os
import sys import sys
__all__ = ['Application'] __all__ = [
'Application',
'BaseApplication',
]
class Application(object): try:
'''Represents a Milla web application # 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
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 class BaseApplication(object):
object to be used as the root for a Traverser '''Base class for Milla applications
``Application`` instances are WSGI applications. This class can be used by applications that need to customize the
behavior of the framework. In most cases, :py:class:`Application`
.. py:attribute:: config instances can be created directly and a sublcass is not necessary.
A mapping of configuration settings. For each request, the
configuration is copied and assigned to ``request.config``.
''' '''
def __init__(self, obj): DEFAULT_CONFIG = {}
if not hasattr(obj, 'resolve'):
# Object is not a dispatcher, but the root object for traversal #: Enable HTTP method emulation for POST requests?
obj = milla.dispatch.traversal.Traverser(obj) post_method_emulation = True
self.dispatcher = obj
self.config = {'milla.favicon': True} def __init__(self):
self.config = self.DEFAULT_CONFIG.copy()
self.dispatcher = None
def __call__(self, environ, start_response): def __call__(self, environ, start_response):
start_response = StartResponseWrapper(start_response) start_response = StartResponseWrapper(start_response)
@ -66,6 +75,11 @@ class Application(object):
allowed_methods = self._find_attr(func, 'allowed_methods') allowed_methods = self._find_attr(func, 'allowed_methods')
except AttributeError: except AttributeError:
allowed_methods = milla.DEFAULT_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: 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':
@ -87,20 +101,10 @@ class Application(object):
# 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
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: if isinstance(response, _string) or not response:
response = request.ResponseClass(response) response = request.ResponseClass(response)
if environ['REQUEST_METHOD'] == 'HEAD': return response(environ, start_response)
start_response(response.status, response.headerlist)
return ''
else:
return response(environ, start_response)
def _call_after(self, func): def _call_after(self, func):
try: try:
@ -127,6 +131,23 @@ class Application(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): def make_request(self, environ):
'''Create a :py:class:`~milla.Request` from a WSGI environment '''Create a :py:class:`~milla.Request` from a WSGI environment
@ -139,8 +160,9 @@ class Application(object):
# Sometimes, hacky applications will try to "emulate" some HTTP # Sometimes, hacky applications will try to "emulate" some HTTP
# methods like PUT or DELETE by specifying an _method parameter # methods like PUT or DELETE by specifying an _method parameter
# in a POST request. # in a POST request.
if request.method == 'POST' and '_method' in request.POST: if self.post_method_emulation:
request.method = request.POST.pop('_method') if request.method == 'POST' and '_method' in request.POST:
request.method = request.POST.pop('_method')
return request return request
def resolve_path(self, path_info): def resolve_path(self, path_info):
@ -162,7 +184,7 @@ class Application(object):
return self.dispatcher.resolve(path_info) return self.dispatcher.resolve(path_info)
except milla.dispatch.UnresolvedPath: except milla.dispatch.UnresolvedPath:
if (path_info == '/favicon.ico' and if (path_info == '/favicon.ico' and
asbool(self.config.get('milla.favicon'))): util.asbool(self.config.get('milla.favicon'))):
return FaviconController() return FaviconController()
else: else:
return path_not_found return path_not_found
@ -182,6 +204,36 @@ class Application(object):
raise 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):

View File

@ -20,13 +20,18 @@
from functools import wraps from functools import wraps
from milla.auth import permissions from milla.auth import permissions
import milla.auth import milla.auth
import warnings
try: try:
import pkg_resources import pkg_resources
except ImportError: except ImportError:
pkg_resources = None pkg_resources = None
__all__ = ['auth_required', 'require_perms'] __all__ = [
'auth_required',
'require_perms',
'validate_request',
]
VALIDATOR_EP_GROUP = 'milla.request_validator' VALIDATOR_EP_GROUP = 'milla.request_validator'
@ -42,6 +47,33 @@ def _find_request(*args, **kwargs):
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)
rv = request.config.get('request_validator', 'default') rv = request.config.get('request_validator', 'default')
@ -103,7 +135,7 @@ 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
@ -154,5 +186,5 @@ class require_perms(object):
def __call__(self, func): def __call__(self, func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
return _validate_request(func, self.requirement, *args, **kwargs) return validate_request(func, self.requirement, *args, **kwargs)
return wrapper return wrapper

View File

@ -104,15 +104,8 @@ class HTTPVerbController(Controller):
def GET(self, request): def GET(self, request):
return 'Hello, world!' return 'Hello, world!'
HEAD = GET
def POST(self, request): def POST(self, request):
return 'Thanks!' return 'Thanks!'
This example also allows ``HEAD`` requests, by processing them as
``GET`` requests. *Milla* handles this correctly, as it does not
send a response body for ``HEAD`` requests, even if the controller
callable returns one.
''' '''
def __call__(self, request, *args, **kwargs): def __call__(self, request, *args, **kwargs):

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)

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