From e5023a420f0c4db5698df974d66cdfbdd24b6107 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Fri, 7 Aug 2015 17:11:08 -0400 Subject: [PATCH 01/10] RST --- MANIFEST.in | 5 ++- requirements.txt => requires/installation.txt | 0 test-requirements.txt => requires/testing.txt | 0 setup.py | 43 +++++++++++++++---- .../{media_type/__init__.py => media_type.py} | 2 +- 5 files changed, 38 insertions(+), 12 deletions(-) rename requirements.txt => requires/installation.txt (100%) rename test-requirements.txt => requires/testing.txt (100%) mode change 100644 => 100755 setup.py rename sprockets/mixins/{media_type/__init__.py => media_type.py} (80%) diff --git a/MANIFEST.in b/MANIFEST.in index 949d311..e697e00 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include LICENSE -include README.rst -include test-requirements.txt +include tests.py +graft docs +graft requires diff --git a/requirements.txt b/requires/installation.txt similarity index 100% rename from requirements.txt rename to requires/installation.txt diff --git a/test-requirements.txt b/requires/testing.txt similarity index 100% rename from test-requirements.txt rename to requires/testing.txt diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 6ff3bbb..8b30d54 --- a/setup.py +++ b/setup.py @@ -1,10 +1,37 @@ +#!/usr/bin/env python +# + +import os import setuptools + +def read_requirements(file_name): + requirements = [] + try: + with open(os.path.join('requires', file_name)) as req_file: + for req_line in req_file: + req_line = req_line.strip() + if '#' in req_line: + req_line = req_line[0:req_line.find('#')].strip() + if req_line.startswith('-r'): + req_line = req_line[2:].strip() + requirements.extend(read_requirements(req_line)) + else: + requirements.append(req_line) + except IOError: + pass + return requirements + + +install_requires = read_requirements('install.txt') +setup_requires = read_requirements('setup.txt') +tests_require = read_requirements('testing.txt') + setuptools.setup( name='sprockets.mixins.media_type', version='0.0.0', description='A mixin for reporting handling content-type/accept headers', - long_description=open('test-requirements.txt', 'r').read(), + long_description='\n' + open('README.rst').read(), url='https://github.com/sprockets/sprockets.mixins.media_type', author='AWeber Communications', author_email='api@aweber.com', @@ -27,12 +54,10 @@ setuptools.setup( 'Topic :: Software Development :: Libraries', 'Topic :: Software Development :: Libraries :: Python Modules' ], - packages=['sprockets', - 'sprockets.mixins', - 'sprockets.mixins.media_type'], - package_data={'': ['LICENSE', 'README.md']}, - include_package_data=True, - install_requires=['tornado'], - namespace_packages=['sprockets', - 'sprockets.mixins'], + packages=setuptools.find_packages(), + install_requires=install_requires, + setup_requires=setup_requires, + tests_require=tests_require, + namespace_packages=['sprockets', 'sprockets.mixins'], + test_suite='nose.collector', zip_safe=False) diff --git a/sprockets/mixins/media_type/__init__.py b/sprockets/mixins/media_type.py similarity index 80% rename from sprockets/mixins/media_type/__init__.py rename to sprockets/mixins/media_type.py index eb885a0..5fedb77 100644 --- a/sprockets/mixins/media_type/__init__.py +++ b/sprockets/mixins/media_type.py @@ -1,5 +1,5 @@ """ -Sprockets.Mixins.Media_type +Sprockets.Mixins.media_type =========================== """ From c67d1af19de256b53216c16d6ae8122425dc706a Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Wed, 19 Aug 2015 17:53:46 -0400 Subject: [PATCH 02/10] Implement basic content handling framework. --- examples.py | 43 +++++++ requires/development.txt | 2 + requires/testing.txt | 7 +- sprockets/mixins/media_type.py | 214 ++++++++++++++++++++++++++++++++- tests.py | 24 ++++ 5 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 examples.py create mode 100644 requires/development.txt diff --git a/examples.py b/examples.py new file mode 100644 index 0000000..370ba4d --- /dev/null +++ b/examples.py @@ -0,0 +1,43 @@ +import json +import logging +import signal + +from sprockets.mixins import media_type +from tornado import ioloop, web +import msgpack + + +class SimpleHandler(media_type.ContentMixin, web.RequestHandler): + + def post(self): + body = self.get_request_body() + self.set_status(200) + self.send_response(body) + self.finish() + + +def make_application(**settings): + application = web.Application([web.url(r'/', SimpleHandler)], **settings) + media_type.set_default_content_type(application, 'application/json', + encoding='utf-8') + media_type.add_binary_content_type(application, 'application/msgpack', + msgpack.packb, msgpack.unpackb) + media_type.add_text_content_type(application, 'application/json', 'utf-8', + json.dumps, json.loads) + return application + + +def _signal_handler(signo, _): + logging.info('received signal %d, stopping application', signo) + iol = ioloop.IOLoop.instance() + iol.add_callback_from_signal(iol.stop) + + +if __name__ == '__main__': + logging.basicConfig(level=logging.DEBUG, + format='%(levelname)1.1s - %(name)s: %(message)s') + application = make_application(debug=True) + application.listen(8000) + signal.signal(signal.SIGINT, _signal_handler) + signal.signal(signal.SIGTERM, _signal_handler) + ioloop.IOLoop.instance().start() diff --git a/requires/development.txt b/requires/development.txt new file mode 100644 index 0000000..ac151af --- /dev/null +++ b/requires/development.txt @@ -0,0 +1,2 @@ +-r testing.txt +-r installation.txt diff --git a/requires/testing.txt b/requires/testing.txt index 06fd124..c5be104 100644 --- a/requires/testing.txt +++ b/requires/testing.txt @@ -1,5 +1,4 @@ -coverage>=3.7,<4 -codecov +coverage>=3.7,<3.99 # prevent installing 4.0b on ALL pip versions +mock>=1.3,<2 +msgpack-python>=0.4,<0.5 nose>=1.3,<2 -mock --r requirements.txt diff --git a/sprockets/mixins/media_type.py b/sprockets/mixins/media_type.py index 5fedb77..f3bbc38 100644 --- a/sprockets/mixins/media_type.py +++ b/sprockets/mixins/media_type.py @@ -1,8 +1,220 @@ """ -Sprockets.Mixins.media_type +sprockets.mixins.media_type =========================== """ +import logging + version_info = (0, 0, 0) __version__ = '.'.join(str(v) for v in version_info) +logger = logging.getLogger(__name__) + + +class ContentSettings(object): + """ + Content selection settings. + + An instance of this class is stashed as the ``_content_settings`` + attribute on the application object. It contains the list of + available content types and handlers associated with them. Each + handler implements a simple interface: + + - ``to_bytes(dict, encoding:str) -> bytes`` + - ``from_bytes(bytes, encoding:str) -> dict`` + + Use the :func:`add_binary_content_type` and :func:`add_text_content_type` + helper functions to modify the settings for the application. + + This class acts as a mapping from content-type string to the + appropriate handler instance. Add new content types and find + handlers using the common ``dict`` syntax: + + .. code-block:: python + + class SomeHandler(web.RequestHandler): + + def get(self): + settings = ContentSettings.from_application(self.application) + response_body = settings['application/msgpack'].to_bytes( + response_dict, encoding='utf-8') + self.write(response_body) + self.finish() + + def make_application(): + app = web.Application([web.url('/', SomeHandler)]) + add_binary_content_type(app, 'application/msgpack', + msgpack.packb, msgpack.unpackb) + add_text_content_type(app, 'application/json', 'utf-8', + json.dumps, json.loads) + return app + + Of course, that is quite tedious, so use the :class:`.ContentMixin` + instead. + + """ + + def __init__(self): + self._handlers = {} + self.default_content_type = None + self.default_encoding = None + + def __getitem__(self, content_type): + return self._handlers[content_type] + + def __setitem__(self, content_type, handler): + if content_type in self._handlers: + logger.warning('handler for %s already set to %r', + content_type, self._handers[content_type]) + return + + self._handlers[content_type] = handler + + @classmethod + def from_application(cls, application): + """Retrieve the content settings from an application.""" + if not hasattr(application, '_content_settings'): + setattr(application, '_content_settings', cls()) + return application._content_settings + +def _get_content_settings(application): + if not hasattr(application, '_content_settings'): + setattr(application, '_content_settings', ContentSettings()) + return getattr(application, '_content_settings') + + +def add_binary_content_type(application, content_type, pack, unpack): + """ + Add handler for a binary content type. + + :param tornado.web.Application application: the application to modify + :param str content_type: the content type to add + :param pack: function that packs a dictionary to a byte string. + ``pack(dict) -> bytes`` + :param unpack: function that takes a byte string and returns a + dictionary. ``unpack(bytes) -> dict`` + + """ + settings = ContentSettings.from_application(application) + settings[content_type] = _BinaryContentHandler(content_type, pack, unpack) + + +def add_text_content_type(application, content_type, default_encoding, + dumps, loads): + """ + Add handler for a text content type. + + :param tornado.web.Application application: the application to modify + :param str content_type: the content type to add + :param str default_encoding: encoding to use when one is unspecified + :param dumps: function that dumps a dictionary to a string. + ``dumps(dict, encoding:str) -> str`` + :param loads: function that loads a dictionary from a string. + ``loads(str, encoding:str) -> dict`` + + """ + settings = ContentSettings.from_application(application) + settings[content_type] = _TextContentHandler(content_type, dumps, loads, + default_encoding) + + +def set_default_content_type(application, content_type, encoding=None): + """ + Store the default content type for an application. + + :param tornado.web.Application application: the application to modify + :param str content_type: the content type to default to + :param str|None encoding: encoding to use when one is unspecified + + """ + settings = ContentSettings.from_application(application) + settings.default_content_type = content_type + settings.default_encoding = encoding + + +class ContentMixin(object): + """ + Mix this in to add some content handling methods. + + .. code-block:: python + + class MyHandler(ContentMixin, web.RequestHandler): + def post(self): + body = self.get_request_body() + # do stuff --> response_dict + self.send_response(response_dict) + self.finish() + + :meth:`get_request_body` will deserialize the request data into + a dictionary based on the :http:header:`Content-Type` request + header. Similarly, :meth:`send_response` takes a dictionary, + serializes it based on the :http:header:`Accept` request header + and the application :class:`ContentSettings`, and writes it out, + using ``self.write()``. + + """ + + def initialize(self): + super(ContentMixin, self).initialize() + self._request_body = None + self._best_response_match = None + + def get_response_content_type(self): + """Figure out what content type will be used in the response.""" + settings = ContentSettings.from_application(self.application) + return settings.default_content_type + + def get_request_body(self): + """Fetch (and cache) the request body as a dictionary.""" + if self._request_body is None: + settings = ContentSettings.from_application(self.application) + handler = settings[settings.default_content_type] + self._request_body = handler.from_bytes(self.request.body) + return self._request_body + + def send_response(self, body, set_content_type=True): + """ + Serialize and send ``body`` in the response. + + :param dict body: the body to serialize + :param bool set_content_type: should the :http:header:`Content-Type` + header be set? Defaults to :data:`True` + + """ + settings = ContentSettings.from_application(self.application) + handler = settings[self.get_response_content_type()] + content_type, data_bytes = handler.to_bytes(body) + if set_content_type: + self.set_header('Content-Type', content_type) + self.write(data_bytes) + + +class _BinaryContentHandler(object): + + def __init__(self, content_type, pack, unpack): + self._pack = pack + self._unpack = unpack + self.content_type = content_type + + def to_bytes(self, data_dict, encoding=None): + return self.content_type, self._pack(data_dict) + + def from_bytes(self, data, encoding=None): + return self._unpack(data) + + +class _TextContentHandler(object): + + def __init__(self, content_type, dumps, loads, default_encoding): + self._dumps = dumps + self._loads = loads + self.content_type = content_type + self.default_encoding = default_encoding + + def to_bytes(self, data_dict, encoding=None): + selected = encoding or self.default_encoding + content_type = '{0}; charset="{1}"'.format(self.content_type, selected) + return content_type, self._dumps(data_dict).encode(selected) + + def from_bytes(self, data, encoding=None): + return self._loads(data.decode(encoding or self.default_encoding)) diff --git a/tests.py b/tests.py index e69de29..b5fa249 100644 --- a/tests.py +++ b/tests.py @@ -0,0 +1,24 @@ +from tornado import testing + +import examples + + +class ContentTypeTests(testing.AsyncHTTPTestCase): + + def get_app(self): + return examples.make_application(debug=True) + + def test_that_content_type_default_works(self): + response = self.fetch('/', method='POST', + body='{"attribute": "value"}', + headers={'Content-Type': 'application/json'}) + self.assertEqual(response.code, 200) + self.assertEqual(response.headers['Content-Type'], + 'application/json; charset="utf-8"') + + def test_that_missing_content_type_uses_default(self): + response = self.fetch('/', method='POST', body='{}', + headers={'Accept': 'application/xml'}) + self.assertEqual(response.code, 200) + self.assertEqual(response.headers['Content-Type'], + 'application/json; charset="utf-8"') From 7305cad7e77e4bb423ebc58375afa7f92aa48878 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Wed, 19 Aug 2015 18:02:41 -0400 Subject: [PATCH 03/10] Add response type negotiation. --- requires/installation.txt | 4 ++-- sprockets/mixins/media_type.py | 33 +++++++++++++++++++++++++++------ tests.py | 7 +++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/requires/installation.txt b/requires/installation.txt index cff7c84..60d5fca 100644 --- a/requires/installation.txt +++ b/requires/installation.txt @@ -1,2 +1,2 @@ -tornado -msgpack-python +ietfparse>=1.2.2,<2 +tornado>=3.2,<5 diff --git a/sprockets/mixins/media_type.py b/sprockets/mixins/media_type.py index f3bbc38..1305377 100644 --- a/sprockets/mixins/media_type.py +++ b/sprockets/mixins/media_type.py @@ -5,6 +5,8 @@ sprockets.mixins.media_type """ import logging +from ietfparse import algorithms, errors, headers + version_info = (0, 0, 0) __version__ = '.'.join(str(v) for v in version_info) @@ -56,6 +58,7 @@ class ContentSettings(object): def __init__(self): self._handlers = {} + self._available_types = [] self.default_content_type = None self.default_encoding = None @@ -68,6 +71,7 @@ class ContentSettings(object): content_type, self._handers[content_type]) return + self._available_types.append(headers.parse_content_type(content_type)) self._handlers[content_type] = handler @classmethod @@ -77,10 +81,16 @@ class ContentSettings(object): setattr(application, '_content_settings', cls()) return application._content_settings -def _get_content_settings(application): - if not hasattr(application, '_content_settings'): - setattr(application, '_content_settings', ContentSettings()) - return getattr(application, '_content_settings') + @property + def available_content_types(self): + """ + List of the content types that are registered. + + This is a sequence of :class:`ietfparse.datastructures.ContentType` + instances. + + """ + return self._available_types def add_binary_content_type(application, content_type, pack, unpack): @@ -161,8 +171,19 @@ class ContentMixin(object): def get_response_content_type(self): """Figure out what content type will be used in the response.""" - settings = ContentSettings.from_application(self.application) - return settings.default_content_type + if self._best_response_match is None: + settings = ContentSettings.from_application(self.application) + acceptable = headers.parse_http_accept_header( + self.request.headers.get('Accept', '*/*')) + try: + selected, _ = algorithms.select_content_type( + acceptable, settings.available_content_types) + self._best_response_match = '/'.join( + [selected.content_type, selected.content_subtype]) + except errors.NoMatch: + self._best_response_match = settings.default_content_type + + return self._best_response_match def get_request_body(self): """Fetch (and cache) the request body as a dictionary.""" diff --git a/tests.py b/tests.py index b5fa249..390fa1b 100644 --- a/tests.py +++ b/tests.py @@ -22,3 +22,10 @@ class ContentTypeTests(testing.AsyncHTTPTestCase): self.assertEqual(response.code, 200) self.assertEqual(response.headers['Content-Type'], 'application/json; charset="utf-8"') + + def test_that_accept_header_is_obeyed(self): + response = self.fetch('/', method='POST', body='{}', + headers={'Accept': 'application/msgpack'}) + self.assertEqual(response.code, 200) + self.assertEqual(response.headers['Content-Type'], + 'application/msgpack') From 123793cd868e4d8fcd6696c3589cc10a88e96c4e Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Wed, 19 Aug 2015 18:29:32 -0400 Subject: [PATCH 04/10] Clean up documentation. --- README.rst | 116 ++++++++++++++------------------------- docs/api.rst | 19 ++++++- docs/conf.py | 22 +++++++- docs/contributing.rst | 112 +++++++++++++++++++++++++++++++++++++ docs/examples.rst | 52 +----------------- docs/index.rst | 65 ++++------------------ docs/static/custom.css | 4 ++ requires/development.txt | 2 + 8 files changed, 208 insertions(+), 184 deletions(-) create mode 100644 docs/contributing.rst create mode 100644 docs/static/custom.css diff --git a/README.rst b/README.rst index 54a43ae..faa0e36 100644 --- a/README.rst +++ b/README.rst @@ -1,93 +1,57 @@ sprockets.mixins.media_type =========================== -A mixin that performs Content-Type negotiation and request/response (de)serialization. +A mixin that performs Content-Type negotiation and request/response +(de)serialization. -|Version| |Downloads| |Status| |Coverage| |License| +This mix-in adds two methods to a ``tornado.web.RequestHandler`` instance: -Installation ------------- -``sprockets.mixins.media_type`` is available on the -`Python Package Index `_ -and can be installed via ``pip`` or ``easy_install``: +- ``get_request_body() -> dict``: deserializes the request body according + to the HTTP ``Content-Type`` header and returns the deserialized body. +- ``send_response(object)``: serializes the response into the content type + requested by the ``Accept`` header. -.. code:: bash +Support for a content types is enabled by calling either the +``add_binary_content_type`` or ``add_text_content_type`` function with the +``tornado.web.Application`` instance, the content type, encoding and decoding +functions as parameters: - pip install sprockets.mixins.media_type +.. code-block:: python -Documentation -------------- -http://sprocketsmixinsmedia-type.readthedocs.org/en/latest/ + import json -Example -------- -The following example demonstrates how to use the Mix-in to handle media -type validation and serialization. + from sprockets.mixins import media_type + from tornado import web -.. code:: python + def make_application(): + application = web.Application([ + # insert your handlers here + ]) - from tornado import web, gen - from sprockets.mixins import media_type + media_type.add_text_content_type(application, + 'application/json', 'utf-8', + json.dumps, json.loads) + return application - class MyRequestHandler(media_type.MediaTypeMixin, web.RequestHandler): +The *add content type* functions will add a attribute to the ``Application`` +instance that the mix-in uses to manipulate the request and response bodies. - @gen.coroutine - def post(self, **kwargs): - # Validate the Content-Type header using the Mix-in - if not self.is_valid_content_type(): - self.set_status(415, 'Unsupported content type') - self.finish() - return +.. code-block:: python - # Deserialize your request payload - data = self.decode_request() + from sprockets.mixins import media_type + from tornado import web - # Ensure that you get some data out of it! - if not data: - self.set_status(400) - self.finish() - return + class SomeHandler(media_type.ContentMixin, web.RequestHandler): + def get(self): + self.send_response({'data': 'value'}) + self.finish() - # Manipulate your data and do business stuff with it - data.pop('the_key') + def post(self): + body = self.get_request_body() + # do whatever + self.send_response({'action': 'performed'}) + self.finish() - self.set_status(200) - - # Automatically serialize your data using the HTTP Accept headers - self.write(data) - - @gen.coroutine - def get(self, some_id): - # Validate the Accept headers using the Mix-in - if not self.is_valid_accept_header(): - self.set_status(406, 'Invalid Accept header') - self.finish() - return - - # Maybe do some lookups from the database or get some data from somewhere - data = {'some_id': some_id} - - self.set_status(200) - - # Automatically serialize your data using the HTTP Accept headers - self.write(data) - - -Version History ---------------- -Available at http://sprocketsmixinsmedia-type.readthedocs.org/en/latest//en/latest/history.html - -.. |Version| image:: https://img.shields.io/pypi/v/sprockets.mixins.media_type.svg? - :target: http://badge.fury.io/py/sprockets.mixins.media_type - -.. |Status| image:: https://img.shields.io/travis/sprockets/sprockets.mixins.media_type.svg? - :target: https://travis-ci.org/sprockets/sprockets.mixins.media_type - -.. |Coverage| image:: https://img.shields.io/codecov/c/github/sprockets/sprockets.mixins.media_type.svg? - :target: https://codecov.io/github/sprockets/sprockets.mixins.media_type?branch=master - -.. |Downloads| image:: https://img.shields.io/pypi/dm/sprockets.mixins.media_type.svg? - :target: https://pypi.python.org/pypi/sprockets.mixins.media_type - -.. |License| image:: http://img.shields.io/:license-mit-blue.svg - :target: http://doge.mit-license.org +Based on the settings stored in the ``Application`` instance and the HTTP +headers, the request and response data will be handled correctly or the +appropriate HTTP exceptions will be raised. diff --git a/docs/api.rst b/docs/api.rst index 5543b46..5833dc8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,2 +1,19 @@ -.. automodule:: sprockets.mixins.media_type +API Documentation +================= +.. currentmodule:: sprockets.mixins.media_type + +Content Type Handling +--------------------- +.. autoclass:: ContentMixin + :members: + +Content Type Registration +------------------------- +.. autofunction:: set_default_content_type + +.. autofunction:: add_binary_content_type + +.. autofunction:: add_text_content_type + +.. autoclass:: ContentSettings :members: diff --git a/docs/conf.py b/docs/conf.py index 8ea2725..dc3d18f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,9 +1,11 @@ +import alabaster from sprockets.mixins.media_type import __version__ needs_sphinx = '1.0' extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', - 'sphinx.ext.intersphinx'] + 'sphinx.ext.intersphinx', + 'sphinxcontrib.autohttp.tornado'] source_suffix = '.rst' master_doc = 'index' project = 'sprockets.mixins.media_type' @@ -11,8 +13,26 @@ copyright = '2015, AWeber Communications' release = __version__ version = '.'.join(release.split('.')[0:1]) +pygments_style = 'sphinx' +html_theme = 'alabaster' +html_style = 'custom.css' +html_static_path = ['static'] +html_theme_path = [alabaster.get_path()] +html_sidebars = { + '**': ['about.html', 'navigation.html'], +} +html_theme_options = { + 'github_user': 'sprockets', + 'github_repo': 'sprockets.mixins.media_type', + 'description': 'Content-Type negotation mix-in', + 'github_banner': True, + 'travis_button': True, + 'sidebar_width': '230px', +} + intersphinx_mapping = { 'python': ('https://docs.python.org/', None), 'requests': ('https://requests.readthedocs.org/en/latest/', None), 'sprockets': ('https://sprockets.readthedocs.org/en/latest/', None), + 'tornado': ('http://tornadoweb.org/en/latest/', None), } diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..112ba2a --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,112 @@ +How to Contribute +================= +Do you want to contribute fixes or improvements? + + **AWesome!** *Thank you very much, and let's get started.* + +Set up a development environment +-------------------------------- +The first thing that you need is a development environment so that you can +run the test suite, update the documentation, and everything else that is +involved in contributing. The easiest way to do that is to create a virtual +environment for your endevours:: + + $ virtualenv -p python2.7 env + +Don't worry about writing code against previous versions of Python unless +you you don't have a choice. That is why we run our tests through `tox`_. +If you don't have a choice, then install `virtualenv`_ to create the +environment instead. The next step is to install the development tools +that this project uses. These are listed in *requires/development.txt*:: + + $ env/bin/pip install -qr requires/development.txt + +At this point, you will have everything that you need to develop at your +disposal. *setup.py* is the swiss-army knife in your development tool +chest. It provides the following commands: + +**./setup.py nosetests** + Run the test suite using `nose`_ and generate a nice coverage report. + +**./setup.py build_sphinx** + Generate the documentation using `sphinx`_. + +**./setup.py flake8** + Run `flake8`_ over the code and report style violations. + +If any of the preceding commands give you problems, then you will have to +fix them **before** your pull request will be accepted. + +Running Tests +------------- +The easiest (and quickest) way to run the test suite is to use the +*nosetests* command. It will run the test suite against the currently +installed python version and report not only the test result but the +test coverage as well:: + + $ ./setup.py nosetests + + running nosetests + running egg_info + writing dependency_links to sprockets.mixins.media_type.egg-info/dependency_links.txt + writing top-level names to sprockets.mixins.media_type.egg-info/top_level.txt + writing sprockets.mixins.media_type.egg-info/PKG-INFO + reading manifest file 'sprockets.mixins.media_type.egg-info/SOURCES.txt' + reading manifest template 'MANIFEST.in' + warning: no previously-included files matching '__pycache__'... + warning: no previously-included files matching '*.swp' found ... + writing manifest file 'sprockets.mixins.media_type.egg-info/SOURCES.txt' + ... + + Name Stmts Miss Branch BrMiss Cover Missing + ---------------------------------------------------------------------- + ... + ---------------------------------------------------------------------- + TOTAL 95 2 59 2 97% + ---------------------------------------------------------------------- + Ran 44 tests in 0.054s + + OK + +That's the quick way to run tests. The slightly longer way is to run +the `detox`_ utility. It will run the test suite against all of the +supported python versions in parallel. This is essentially what Travis-CI +will do when you issue a pull request anyway:: + + $ env/bin/detox + py27 recreate: /.../sprockets.mixins.media_type/build/tox/py27 + GLOB sdist-make: /.../sprockets.mixins.media_type/setup.py + py34 recreate: /.../sprockets.mixins.media_type/build/tox/py34 + py27 installdeps: -rtest-requirements.txt, mock + py34 installdeps: -rtest-requirements.txt + py27 inst: /.../sprockets.mixins.media_type/build/tox/dist/sprockets.mixins.media_type-0.0.0.zip + py27 runtests: PYTHONHASHSEED='2156646470' + py27 runtests: commands[0] | /../sprockets.mixins.media_type/build/tox/py27/bin/nosetests + py34 inst: /../sprockets.mixins.media_type/.build/tox/dist/sprockets.mixins.media_type-0.0.0.zip + py34 runtests: PYTHONHASHSEED='2156646470' + py34 runtests: commands[0] | /.../sprockets.mixins.media_type/build/tox/py34/bin/nosetests + _________________________________ summary _________________________________ + py27: commands succeeded + py34: commands succeeded + congratulations :) + +This is what you want to see. Now you can make your modifications and keep +the tests passing. + +Submitting a Pull Request +------------------------- +Once you have made your modifications, gotten all of the tests to pass, +and added any necessary documentation, it is time to contribute back for +posterity. You've probably already cloned this repository and created a +new branch. If you haven't, then checkout what you have as a branch and +roll back *master* to where you found it. Then push your repository up +to github and issue a pull request. Describe your changes in the request, +if Travis isn't too annoyed someone will review it, and eventually merge +it back. + +.. _flake8: http://flake8.readthedocs.org/ +.. _nose: http://nose.readthedocs.org/ +.. _sphinx: http://sphinx-doc.org/ +.. _detox: http://testrun.org/tox/ +.. _tox: http://testrun.org/tox/ +.. _virtualenv: http://virtualenv.pypa.io/ diff --git a/docs/examples.rst b/docs/examples.rst index aed8104..0ac43f1 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,54 +1,4 @@ Examples ======== -The following example demonstrates how to use the Mix-in to handle media -type validation and serialization. - -.. code:: python - - from tornado import web, gen - from sprockets.mixins import media_type - - - class MyRequestHandler(media_type.MediaTypeMixin, web.RequestHandler): - - @gen.coroutine - def post(self, **kwargs): - # Validate the Content-Type header using the Mix-in - if not self.is_valid_content_type(): - self.set_status(415, 'Unsupported content type') - self.finish() - return - - # Deserialize your request payload - data = self.decode_request() - - # Ensure that you get some data out of it! - if not data: - self.set_status(400) - self.finish() - return - - # Manipulate your data and do business stuff with it - data.pop('the_key') - - self.set_status(200) - - # Automatically serialize your data using the HTTP Accept headers - self.write(data) - - @gen.coroutine - def get(self, some_id): - # Validate the Accept headers using the Mix-in - if not self.is_valid_accept_header(): - self.set_status(406, 'Invalid Accept header') - self.finish() - return - - # Maybe do some lookups from the database or get some data from somewhere - data = {'some_id': some_id} - - self.set_status(200) - - # Automatically serialize your data using the HTTP Accept headers - self.write(data) +.. literalinclude:: ../examples.py diff --git a/docs/index.rst b/docs/index.rst index 7a68294..3b7c360 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,61 +1,16 @@ -sprockets.mixins.media_type -=========================== -A mixin that performs Content-Type negotiation and request/response (de)serialization. +.. include:: ../README.rst -|Version| |Downloads| |Status| |Coverage| |License| - -Installation ------------- -``sprockets.mixins.media_type`` is available on the -`Python Package Index `_ -and can be installed via ``pip`` or ``easy_install``: - -.. code:: bash - - pip install sprockets.mixins.media_type - -API Documentation ------------------ -.. toctree:: - :maxdepth: 2 - - api - examples - -Version History ---------------- -See :doc:`history` - -Issues ------- -Please report any issues to the Github project at `https://github.com/sprockets/sprockets.mixins.media_type/issues `_ - -Source ------- -``sprockets.mixins.media_type`` source is available on Github at `https://github.com/sprockets/sprockets.mixins.media_type `_ +Examples +-------- +.. literalinclude:: ../examples.py License ------- -``sprockets.mixins.media_type`` is released under the `3-Clause BSD license `_. +.. include:: ../LICENSE -Indices and tables ------------------- +.. toctree:: + :hidden: -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - -.. |Version| image:: https://img.shields.io/pypi/v/sprockets.mixins.media_type.svg? - :target: http://badge.fury.io/py/sprockets.mixins.media_type - -.. |Status| image:: https://img.shields.io/travis/sprockets/sprockets.mixins.media_type.svg? - :target: https://travis-ci.org/sprockets/sprockets.mixins.media_type - -.. |Coverage| image:: https://img.shields.io/codecov/c/github/sprockets/sprockets.mixins.media_type.svg? - :target: https://codecov.io/github/sprockets/sprockets.mixins.media_type?branch=master - -.. |Downloads| image:: https://img.shields.io/pypi/dm/sprockets.mixins.media_type.svg? - :target: https://pypi.python.org/pypi/sprockets.mixins.media_type - -.. |License| image:: http://img.shields.io/:license-mit-blue.svg - :target: http://doge.mit-license.org + api + contributing + history diff --git a/docs/static/custom.css b/docs/static/custom.css new file mode 100644 index 0000000..9e9b139 --- /dev/null +++ b/docs/static/custom.css @@ -0,0 +1,4 @@ +@import url("alabaster.css"); +h1.logo { + font-size: 12pt; +} diff --git a/requires/development.txt b/requires/development.txt index ac151af..d49abfe 100644 --- a/requires/development.txt +++ b/requires/development.txt @@ -1,2 +1,4 @@ -r testing.txt -r installation.txt +sphinx>=1.2,<2 +sphinxcontrib-httpdomain>=1.3,<2 From f766a24e26ba2f44002d95fc9675bc9052ad1f79 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Wed, 26 Aug 2015 14:50:48 -0400 Subject: [PATCH 05/10] Add request body negotiation. --- sprockets/mixins/media_type.py | 27 ++++++++++++++++++++--- tests.py | 40 +++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/sprockets/mixins/media_type.py b/sprockets/mixins/media_type.py index 1305377..6965b17 100644 --- a/sprockets/mixins/media_type.py +++ b/sprockets/mixins/media_type.py @@ -6,6 +6,7 @@ sprockets.mixins.media_type import logging from ietfparse import algorithms, errors, headers +from tornado import web version_info = (0, 0, 0) @@ -74,6 +75,9 @@ class ContentSettings(object): self._available_types.append(headers.parse_content_type(content_type)) self._handlers[content_type] = handler + def get(self, content_type, default=None): + return self._handlers.get(content_type, default) + @classmethod def from_application(cls, application): """Retrieve the content settings from an application.""" @@ -186,11 +190,28 @@ class ContentMixin(object): return self._best_response_match def get_request_body(self): - """Fetch (and cache) the request body as a dictionary.""" + """ + Fetch (and cache) the request body as a dictionary. + + :raise web.HTTPError: if the content type cannot be decoded. + The status code is set to 415 Unsupported Media Type + + """ if self._request_body is None: settings = ContentSettings.from_application(self.application) - handler = settings[settings.default_content_type] - self._request_body = handler.from_bytes(self.request.body) + content_type_header = headers.parse_content_type( + self.request.headers.get('Content-Type', + settings.default_content_type)) + content_type = '/'.join([content_type_header.content_type, + content_type_header.content_subtype]) + try: + handler = settings[content_type] + self._request_body = handler.from_bytes(self.request.body) + + except KeyError: + raise web.HTTPError(415, 'cannot decode body of type %s', + content_type) + return self._request_body def send_response(self, body, set_content_type=True): diff --git a/tests.py b/tests.py index 390fa1b..e815601 100644 --- a/tests.py +++ b/tests.py @@ -1,16 +1,18 @@ +import json + from tornado import testing +import msgpack import examples -class ContentTypeTests(testing.AsyncHTTPTestCase): +class SendResponseTests(testing.AsyncHTTPTestCase): def get_app(self): return examples.make_application(debug=True) def test_that_content_type_default_works(self): - response = self.fetch('/', method='POST', - body='{"attribute": "value"}', + response = self.fetch('/', method='POST', body='{}', headers={'Content-Type': 'application/json'}) self.assertEqual(response.code, 200) self.assertEqual(response.headers['Content-Type'], @@ -18,14 +20,42 @@ class ContentTypeTests(testing.AsyncHTTPTestCase): def test_that_missing_content_type_uses_default(self): response = self.fetch('/', method='POST', body='{}', - headers={'Accept': 'application/xml'}) + headers={'Accept': 'application/xml', + 'Content-Type': 'application/json'}) self.assertEqual(response.code, 200) self.assertEqual(response.headers['Content-Type'], 'application/json; charset="utf-8"') def test_that_accept_header_is_obeyed(self): response = self.fetch('/', method='POST', body='{}', - headers={'Accept': 'application/msgpack'}) + headers={'Accept': 'application/msgpack', + 'Content-Type': 'application/json'}) self.assertEqual(response.code, 200) self.assertEqual(response.headers['Content-Type'], 'application/msgpack') + + +class GetRequestBodyTests(testing.AsyncHTTPTestCase): + + def get_app(self): + return examples.make_application(debug=True) + + def test_that_request_with_unhandled_type_results_in_415(self): + response = self.fetch( + '/', method='POST', headers={'Content-Type': 'application/xml'}, + body=(u'value' + u'\u2731' + u'').encode('utf-8')) + self.assertEqual(response.code, 415) + + def test_that_msgpack_request_returns_default_type(self): + body = { + 'name': 'value', + 'embedded': { + 'utf8': u'\u2731' + } + } + response = self.fetch('/', method='POST', body=msgpack.packb(body), + headers={'Content-Type': 'application/msgpack'}) + self.assertEqual(response.code, 200) + self.assertEqual(json.loads(response.body.decode('utf-8')), body) From c76b753234e1afd677cf36aa7a5d3159199a193a Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Wed, 26 Aug 2015 15:10:27 -0400 Subject: [PATCH 06/10] Appease Travis-CI --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index ec993f0..deab0be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ python: - pypy install: - pip install codecov - - pip install -r test-requirements.txt + - pip install -r requires/testing.txt script: nosetests after_success: - codecov From 8946534d46db27e01dac813963d73d78ce869208 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Wed, 26 Aug 2015 15:10:35 -0400 Subject: [PATCH 07/10] Fix badges. --- README.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.rst b/README.rst index faa0e36..6117dfb 100644 --- a/README.rst +++ b/README.rst @@ -3,6 +3,8 @@ sprockets.mixins.media_type A mixin that performs Content-Type negotiation and request/response (de)serialization. +|Documentation| |Build Badge| |Package Info| + This mix-in adds two methods to a ``tornado.web.RequestHandler`` instance: - ``get_request_body() -> dict``: deserializes the request body according @@ -55,3 +57,10 @@ instance that the mix-in uses to manipulate the request and response bodies. Based on the settings stored in the ``Application`` instance and the HTTP headers, the request and response data will be handled correctly or the appropriate HTTP exceptions will be raised. + +.. |Documentation| image:: https://readthedocs.org/projects/sprocketsmixinsmedia-type/?badge=latest + :target: https://sprocketsmixinsmedia_type.readthedocs.org/ +.. |Build Badge| image:: https://travis-ci.org/sprockets/sprockets.mixins.media_type.svg + :target: https://travis-ci.org/sprockets/sprockets.mixins.media_type +.. |Package Info| image:: https://img.shields.io/pypi/v/sprockets.mixins.media_type.svg + :target: https://pypi.python.org/sprockets.mixins.media_type From 7f4a8f14372bc11476ec271356e195b6b7426a8c Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Wed, 26 Aug 2015 15:17:52 -0400 Subject: [PATCH 08/10] More Travis-CI appeasement. --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index deab0be..d979597 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,7 @@ python: - pypy install: - pip install codecov + - pip install -r requires/installation.txt - pip install -r requires/testing.txt script: nosetests after_success: From 7e12355311512939e7b6614653f3238eb09bc296 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Wed, 26 Aug 2015 15:19:53 -0400 Subject: [PATCH 09/10] More Travis-CI fun. --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index d979597..d17efa5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,6 @@ language: python python: - 2.7 - - 3.2 - - 3.3 - 3.4 - pypy install: From e99e4308067aa8f1aeadeeaaee53948492984d13 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Wed, 26 Aug 2015 15:29:48 -0400 Subject: [PATCH 10/10] Fix Python 3 issues. --- sprockets/mixins/media_type.py | 5 +++-- tox.ini | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 tox.ini diff --git a/sprockets/mixins/media_type.py b/sprockets/mixins/media_type.py index 6965b17..c706f71 100644 --- a/sprockets/mixins/media_type.py +++ b/sprockets/mixins/media_type.py @@ -6,7 +6,7 @@ sprockets.mixins.media_type import logging from ietfparse import algorithms, errors, headers -from tornado import web +from tornado import escape, web version_info = (0, 0, 0) @@ -256,7 +256,8 @@ class _TextContentHandler(object): def to_bytes(self, data_dict, encoding=None): selected = encoding or self.default_encoding content_type = '{0}; charset="{1}"'.format(self.content_type, selected) - return content_type, self._dumps(data_dict).encode(selected) + dumped = self._dumps(escape.recursive_unicode(data_dict)) + return content_type, dumped.encode(selected) def from_bytes(self, data, encoding=None): return self._loads(data.decode(encoding or self.default_encoding)) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..71a7562 --- /dev/null +++ b/tox.ini @@ -0,0 +1,11 @@ +[tox] +envlist = py27,py34 +indexserver = + default = https://pypi.python.org/simple +toxworkdir = build/tox + +[testenv] +deps = + -rrequires/installation.txt + -rrequires/testing.txt +commands = nosetests []