diff --git a/doc/reference/vary.rst b/doc/reference/vary.rst new file mode 100644 index 0000000..bb9af59 --- /dev/null +++ b/doc/reference/vary.rst @@ -0,0 +1,6 @@ +========== +milla.vary +========== + +.. automodule:: milla.vary + :members: diff --git a/src/milla/vary.py b/src/milla/vary.py new file mode 100644 index 0000000..1c87f50 --- /dev/null +++ b/src/milla/vary.py @@ -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) diff --git a/test/test_vary.py b/test/test_vary.py new file mode 100644 index 0000000..db48c53 --- /dev/null +++ b/test/test_vary.py @@ -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')