From e3294af26cef0ab783c9b8a89bc1dac0d36d3c3f Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Sun, 10 Jan 2016 16:05:25 -0500 Subject: [PATCH 01/16] docs: Update links from Python 2 to Python 3. --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 0b7d560..44c6e1e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ html_theme_options = { } intersphinx_mapping = { - 'python': ('https://docs.python.org/', None), + 'python': ('https://docs.python.org/3', None), 'requests': ('https://requests.readthedocs.org/en/latest/', None), 'sprockets': ('https://sprockets.readthedocs.org/en/latest/', None), 'tornado': ('http://tornadoweb.org/en/latest/', None), From d7f1bb1e4e5040b2ac7645efa2fc8903d2519822 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Sun, 10 Jan 2016 13:51:14 -0500 Subject: [PATCH 02/16] Add content.add_transcoder. --- docs/api.rst | 2 ++ docs/history.rst | 1 + sprockets/mixins/mediatype/content.py | 40 +++++++++++++++++++++++---- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index bf98f6f..ce9c084 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -15,5 +15,7 @@ Content Type Registration .. autofunction:: add_text_content_type +.. autofunction:: add_transcoder + .. autoclass:: ContentSettings :members: diff --git a/docs/history.rst b/docs/history.rst index e71a345..8838d22 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -5,6 +5,7 @@ Version History --------------- - Repackage from a module into a package. Distributing raw modules inside of a namespace package is unreliable and questionably correct. +- Add :func:`sprockets.mixins.mediatype.content.add_transcoder`. `1.0.4`_ (14 Sep 2015) ---------------------- diff --git a/sprockets/mixins/mediatype/content.py b/sprockets/mixins/mediatype/content.py index c0fbea5..8fb8a09 100644 --- a/sprockets/mixins/mediatype/content.py +++ b/sprockets/mixins/mediatype/content.py @@ -7,6 +7,8 @@ Content handling for Tornado. content type - :func:`.add_text_content_type` register transcoders for a textual content type +- :func:`.add_transcoder` register a custom transcoder instance + for a content type - :class:`.ContentSettings` an instance of this is attached to :class:`tornado.web.Application` to hold the content mapping information for the application @@ -126,9 +128,8 @@ def add_binary_content_type(application, content_type, pack, unpack): dictionary. ``unpack(bytes) -> dict`` """ - settings = ContentSettings.from_application(application) - settings[content_type] = handlers.BinaryContentHandler( - content_type, pack, unpack) + add_transcoder(application, content_type, + handlers.BinaryContentHandler(content_type, pack, unpack)) def add_text_content_type(application, content_type, default_encoding, @@ -144,10 +145,39 @@ def add_text_content_type(application, content_type, default_encoding, :param loads: function that loads a dictionary from a string. ``loads(str, encoding:str) -> dict`` + """ + add_transcoder(application, content_type, + handlers.TextContentHandler(content_type, dumps, loads, + default_encoding)) + + +def add_transcoder(application, content_type, transcoder): + """ + Register a transcoder for a specific content type. + + :param tornado.web.Application application: the application to modify + :param str content_type: the content type to add + :param transcoder: object that translates between :class:`bytes` and + :class:`object` instances + + The `transcoder` instance is required to implement the following + simple protocol: + + .. method:: transcoder.to_bytes(inst_data, encoding=None) -> bytes + + :param object inst_data: the object to encode + :param str encoding: character encoding to apply or :data:`None` + :returns: the encoded :class:`bytes` instance + + .. method:: transcoder.from_bytes(data_bytes, encoding=None) -> object + + :param bytes data_bytes: the :class:`bytes` instance to decode + :param str encoding: character encoding to use or :data:`None` + :returns: the decoded :class:`object` instance + """ settings = ContentSettings.from_application(application) - settings[content_type] = handlers.TextContentHandler( - content_type, dumps, loads, default_encoding) + settings[content_type] = transcoder def set_default_content_type(application, content_type, encoding=None): From ee8f645a51ce397952782347158c65873caf9eb4 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Sun, 10 Jan 2016 14:46:27 -0500 Subject: [PATCH 03/16] Add sprockets.mixins.mediatype.transcoders.JSONTranscoder. --- docs/api.rst | 7 +++ docs/history.rst | 1 + examples.py | 7 ++- sprockets/mixins/mediatype/transcoders.py | 56 +++++++++++++++++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 sprockets/mixins/mediatype/transcoders.py diff --git a/docs/api.rst b/docs/api.rst index ce9c084..dcba83c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -19,3 +19,10 @@ Content Type Registration .. autoclass:: ContentSettings :members: + +Bundled Transcoders +------------------- +.. currentmodule:: sprockets.mixins.mediatype.transcoders + +.. autoclass:: JSONTranscoder + :members: diff --git a/docs/history.rst b/docs/history.rst index 8838d22..7fdc368 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -6,6 +6,7 @@ Version History - Repackage from a module into a package. Distributing raw modules inside of a namespace package is unreliable and questionably correct. - Add :func:`sprockets.mixins.mediatype.content.add_transcoder`. +- Add :class:`sprockets.mixins.mediatype.transcoders.JSONTranscoder` `1.0.4`_ (14 Sep 2015) ---------------------- diff --git a/examples.py b/examples.py index 4b558ae..8eb0f24 100644 --- a/examples.py +++ b/examples.py @@ -1,8 +1,7 @@ -import json import logging import signal -from sprockets.mixins.mediatype import content +from sprockets.mixins.mediatype import content, transcoders from tornado import ioloop, web import msgpack @@ -22,8 +21,8 @@ def make_application(**settings): encoding='utf-8') content.add_binary_content_type(application, 'application/msgpack', msgpack.packb, msgpack.unpackb) - content.add_text_content_type(application, 'application/json', 'utf-8', - json.dumps, json.loads) + content.add_transcoder(application, 'application/json', + transcoders.JSONTranscoder()) return application diff --git a/sprockets/mixins/mediatype/transcoders.py b/sprockets/mixins/mediatype/transcoders.py new file mode 100644 index 0000000..eedf471 --- /dev/null +++ b/sprockets/mixins/mediatype/transcoders.py @@ -0,0 +1,56 @@ +"""Bundled media type transcoders.""" +import json +import uuid + +from sprockets.mixins.mediatype import handlers + + +class JSONTranscoder(handlers.TextContentHandler): + """ + JSON transcoder instance. + + :param str content_type: the content type that this encoder instance + implements. If omitted, ``application/json`` is used. This is + passed directly to the ``TextContentHandler`` initializer. + :param str default_encoding: the encoding to use if none is specified. + If omitted, this defaults to ``utf-8``. This is passed directly to + the ``TextContentHandler`` initializer. + + .. attribute:: dump_options + + Keyword parameters that are passed to :func:`json.dumps` when + :meth:`.dumps` is called. + + .. attribute:: load_options + + Keyword parameters that are passed to :func:`json.loads` when + :meth:`.loads` is called. + + """ + + def __init__(self, content_type='application/json', + default_encoding='utf-8'): + super(JSONTranscoder, self).__init__(content_type, self.dumps, + self.loads, default_encoding) + self.dump_options = {} + self.load_options = {} + + def dumps(self, obj): + """ + Dump a :class:`object` instance into a JSON :class:`str` + + :param object obj: the object to dump + :return: the JSON representation of :class:`object` + + """ + return json.dumps(obj, **self.dump_options) + + def loads(self, str_repr): + """ + Transform :class:`str` into an :class:`object` instance. + + :param str str_repr: the UNICODE representation of an object + :return: the decoded :class:`object` representation + + """ + return json.loads(str_repr, **self.load_options) From 61713f9f1514d00d8b162a1ba68ffa19436734d3 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Sun, 10 Jan 2016 16:04:38 -0500 Subject: [PATCH 04/16] JSONTranscoder: Add support for datetime, UUID, and binary types. --- sprockets/mixins/mediatype/transcoders.py | 64 ++++++++++++++++++++- tests.py | 68 +++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/sprockets/mixins/mediatype/transcoders.py b/sprockets/mixins/mediatype/transcoders.py index eedf471..56877d0 100644 --- a/sprockets/mixins/mediatype/transcoders.py +++ b/sprockets/mixins/mediatype/transcoders.py @@ -1,4 +1,10 @@ -"""Bundled media type transcoders.""" +""" +Bundled media type transcoders. + +- :class:`.JSONTranscoder` implements JSON encoding/decoding + +""" +import base64 import json import uuid @@ -16,10 +22,16 @@ class JSONTranscoder(handlers.TextContentHandler): If omitted, this defaults to ``utf-8``. This is passed directly to the ``TextContentHandler`` initializer. + This JSON encoder uses :func:`json.loads` and :func:`json.dumps` to + implement JSON encoding/decoding. The :meth:`dump_object` method is + configured to handle types that the standard JSON module does not + support. + .. attribute:: dump_options Keyword parameters that are passed to :func:`json.dumps` when - :meth:`.dumps` is called. + :meth:`.dumps` is called. By default, the :meth:`dump_object` + method is enabled as the default object hook. .. attribute:: load_options @@ -32,7 +44,10 @@ class JSONTranscoder(handlers.TextContentHandler): default_encoding='utf-8'): super(JSONTranscoder, self).__init__(content_type, self.dumps, self.loads, default_encoding) - self.dump_options = {} + self.dump_options = { + 'default': self.dump_object, + 'separators': (',', ':'), + } self.load_options = {} def dumps(self, obj): @@ -54,3 +69,46 @@ class JSONTranscoder(handlers.TextContentHandler): """ return json.loads(str_repr, **self.load_options) + + def dump_object(self, obj): + """ + Called to encode unrecognized object. + + :param object obj: the object to encode + :return: the encoded object + :raises TypeError: when `obj` cannot be encoded + + This method is passed as the ``default`` keyword parameter + to :func:`json.dumps`. It provides default representations for + a number of Python language/standard library types. + + +----------------------------+---------------------------------------+ + | Python Type | String Format | + +----------------------------+---------------------------------------+ + | :class:`bytes`, | Base64 encoded string. | + | :class:`bytearray`, | | + | :class:`memoryview` | | + +----------------------------+---------------------------------------+ + | :class:`datetime.datetime` | ISO8601 formatted timestamp in the | + | | extended format including separators, | + | | milliseconds, and the timezone | + | | designator. | + +----------------------------+---------------------------------------+ + | :class:`uuid.UUID` | Same as ``str(value)`` | + +----------------------------+---------------------------------------+ + + .. warning:: + + :class:`bytes` instances are treated as character strings by the + standard JSON module in Python 2.7 so the *default* object hook + is never called. In other words, :class:`bytes` values will not + be serialized as Base64 strings in Python 2.7. + + """ + if isinstance(obj, uuid.UUID): + return str(obj) + if hasattr(obj, 'isoformat'): + return obj.isoformat() + if isinstance(obj, (bytes, bytearray, memoryview)): + return base64.b64encode(obj).decode('ASCII') + raise TypeError('{!r} is not JSON serializable'.format(obj)) diff --git a/tests.py b/tests.py index eddaeaa..8018c3f 100644 --- a/tests.py +++ b/tests.py @@ -1,11 +1,31 @@ +import base64 +import datetime import json +import os +import sys +import unittest +import uuid from tornado import testing import msgpack +from sprockets.mixins.mediatype import transcoders import examples +class UTC(datetime.tzinfo): + ZERO = datetime.timedelta(0) + + def utcoffset(self, dt): + return self.ZERO + + def dst(self, dt): + return self.ZERO + + def tzname(self, dt): + return 'UTC' + + class SendResponseTests(testing.AsyncHTTPTestCase): def get_app(self): @@ -66,3 +86,51 @@ class GetRequestBodyTests(testing.AsyncHTTPTestCase): headers={'Content-Type': 'application/msgpack'}) self.assertEqual(response.code, 200) self.assertEqual(json.loads(response.body.decode('utf-8')), body) + + +class JSONTranscoderTests(unittest.TestCase): + + def setUp(self): + super(JSONTranscoderTests, self).setUp() + self.transcoder = transcoders.JSONTranscoder() + + def test_that_uuids_are_dumped_as_strings(self): + obj = {'id': uuid.uuid4()} + dumped = self.transcoder.dumps(obj) + self.assertEqual(dumped.replace(' ', ''), '{"id":"%s"}' % obj['id']) + + def test_that_datetimes_are_dumped_in_isoformat(self): + obj = {'now': datetime.datetime.now()} + dumped = self.transcoder.dumps(obj) + self.assertEqual(dumped.replace(' ', ''), + '{"now":"%s"}' % obj['now'].isoformat()) + + def test_that_tzaware_datetimes_include_tzoffset(self): + obj = {'now': datetime.datetime.now().replace(tzinfo=UTC())} + self.assertTrue(obj['now'].isoformat().endswith('+00:00')) + dumped = self.transcoder.dumps(obj) + self.assertEqual(dumped.replace(' ', ''), + '{"now":"%s"}' % obj['now'].isoformat()) + + @unittest.skipIf(sys.version_info[0] == 2, 'bytes unsupported on python 2') + def test_that_bytes_are_base64_encoded(self): + bin = bytes(os.urandom(127)) + dumped = self.transcoder.dumps({'bin': bin}) + self.assertEqual( + dumped, '{"bin":"%s"}' % base64.b64encode(bin).decode('ASCII')) + + def test_that_bytearrays_are_base64_encoded(self): + bin = bytearray(os.urandom(127)) + dumped = self.transcoder.dumps({'bin': bin}) + self.assertEqual( + dumped, '{"bin":"%s"}' % base64.b64encode(bin).decode('ASCII')) + + def test_that_memoryviews_are_base64_encoded(self): + bin = memoryview(os.urandom(127)) + dumped = self.transcoder.dumps({'bin': bin}) + self.assertEqual( + dumped, '{"bin":"%s"}' % base64.b64encode(bin).decode('ASCII')) + + def test_that_unhandled_objects_raise_type_error(self): + with self.assertRaises(TypeError): + self.transcoder.dumps(object()) From d6493a70e2e942e2bc6256c9e70d666c560c0d88 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Sun, 10 Jan 2016 16:05:07 -0500 Subject: [PATCH 05/16] tox.ini: Fix skip missing interpreters flag. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 6871ecb..a8ff895 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ envlist = py27,py34,py35,pypy indexserver = default = https://pypi.python.org/simple toxworkdir = build/tox -skip_unknown_interpreters = true +skip_missing_interpreters = true [testenv] deps = From 3eecbad5c5f2e9e687e9d90ef696006d02e264d6 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Mon, 11 Jan 2016 07:36:39 -0500 Subject: [PATCH 06/16] content.ContentSettings: Fix AttributeError in __setitem__. Code coverage FTW! --- sprockets/mixins/mediatype/content.py | 2 +- tests.py | 29 ++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/sprockets/mixins/mediatype/content.py b/sprockets/mixins/mediatype/content.py index 8fb8a09..df6fea6 100644 --- a/sprockets/mixins/mediatype/content.py +++ b/sprockets/mixins/mediatype/content.py @@ -88,7 +88,7 @@ class ContentSettings(object): 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]) + content_type, self._handlers[content_type]) return self._available_types.append(headers.parse_content_type(content_type)) diff --git a/tests.py b/tests.py index 8018c3f..9a13179 100644 --- a/tests.py +++ b/tests.py @@ -9,7 +9,7 @@ import uuid from tornado import testing import msgpack -from sprockets.mixins.mediatype import transcoders +from sprockets.mixins.mediatype import content, transcoders import examples @@ -134,3 +134,30 @@ class JSONTranscoderTests(unittest.TestCase): def test_that_unhandled_objects_raise_type_error(self): with self.assertRaises(TypeError): self.transcoder.dumps(object()) + + +class ContentSettingsTests(unittest.TestCase): + + def test_that_from_application_creates_instance(self): + class Context(object): + pass + + context = Context() + settings = content.ContentSettings.from_application(context) + self.assertIs(content.ContentSettings.from_application(context), + settings) + + def test_that_handler_listed_in_available_content_types(self): + settings = content.ContentSettings() + settings['application/json'] = object() + self.assertEqual(len(settings.available_content_types), 1) + self.assertEqual(settings.available_content_types[0].content_type, + 'application') + self.assertEqual(settings.available_content_types[0].content_subtype, + 'json') + + def test_that_handler_is_not_overwritten(self): + settings = content.ContentSettings() + settings['application/json'] = handler = object() + settings['application/json'] = object() + self.assertIs(settings.get('application/json'), handler) From 8a44e527b1dbddfc52f6a5c1b4ec67a3d1a29258 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Wed, 13 Jan 2016 07:50:09 -0500 Subject: [PATCH 07/16] Add explicit tests for handlers.add_* Adding JSON and msgpack transcoders in the examples will obliviate testing of the add_binary_content_type and add_text_content_type functions. --- tests.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests.py b/tests.py index 9a13179..ea523b2 100644 --- a/tests.py +++ b/tests.py @@ -2,6 +2,7 @@ import base64 import datetime import json import os +import pickle import sys import unittest import uuid @@ -9,7 +10,7 @@ import uuid from tornado import testing import msgpack -from sprockets.mixins.mediatype import content, transcoders +from sprockets.mixins.mediatype import content, handlers, transcoders import examples @@ -26,6 +27,10 @@ class UTC(datetime.tzinfo): return 'UTC' +class Context(object): + pass + + class SendResponseTests(testing.AsyncHTTPTestCase): def get_app(self): @@ -161,3 +166,29 @@ class ContentSettingsTests(unittest.TestCase): settings['application/json'] = handler = object() settings['application/json'] = object() self.assertIs(settings.get('application/json'), handler) + + +class ContentFunctionTests(unittest.TestCase): + + def setUp(self): + super(ContentFunctionTests, self).setUp() + self.context = Context() + + def test_that_add_binary_content_type_creates_binary_handler(self): + content.add_binary_content_type(self.context, + 'application/vnd.python.pickle', + pickle.dumps, pickle.loads) + settings = content.ContentSettings.from_application(self.context) + transcoder = settings['application/vnd.python.pickle'] + self.assertIsInstance(transcoder, handlers.BinaryContentHandler) + self.assertIs(transcoder._pack, pickle.dumps) + self.assertIs(transcoder._unpack, pickle.loads) + + def test_that_add_text_content_type_creates_text_handler(self): + content.add_text_content_type(self.context, 'application/json', 'utf8', + json.dumps, json.loads) + settings = content.ContentSettings.from_application(self.context) + transcoder = settings['application/json'] + self.assertIsInstance(transcoder, handlers.TextContentHandler) + self.assertIs(transcoder._dumps, json.dumps) + self.assertIs(transcoder._loads, json.loads) From 7d2237745efc168d7699e5cb9ec1c6c75a71f5b8 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Wed, 13 Jan 2016 07:28:44 -0500 Subject: [PATCH 08/16] Add s.m.mediatype.transcoders.MsgPackTranscoder. --- docs/api.rst | 3 + docs/history.rst | 1 + examples.py | 5 +- requires/testing.txt | 2 +- sprockets/mixins/mediatype/transcoders.py | 123 ++++++++++++++++++++++ tests.py | 66 +++++++++++- 6 files changed, 191 insertions(+), 9 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index dcba83c..0135104 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -26,3 +26,6 @@ Bundled Transcoders .. autoclass:: JSONTranscoder :members: + +.. autoclass:: MsgPackTranscoder + :members: diff --git a/docs/history.rst b/docs/history.rst index 7fdc368..ee6bbcf 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -7,6 +7,7 @@ Version History of a namespace package is unreliable and questionably correct. - Add :func:`sprockets.mixins.mediatype.content.add_transcoder`. - Add :class:`sprockets.mixins.mediatype.transcoders.JSONTranscoder` +- Add :class:`sprockets.mixins.mediatype.transcoders.MsgPackTranscoder` `1.0.4`_ (14 Sep 2015) ---------------------- diff --git a/examples.py b/examples.py index 8eb0f24..db6ea78 100644 --- a/examples.py +++ b/examples.py @@ -3,7 +3,6 @@ import signal from sprockets.mixins.mediatype import content, transcoders from tornado import ioloop, web -import msgpack class SimpleHandler(content.ContentMixin, web.RequestHandler): @@ -19,8 +18,8 @@ def make_application(**settings): application = web.Application([web.url(r'/', SimpleHandler)], **settings) content.set_default_content_type(application, 'application/json', encoding='utf-8') - content.add_binary_content_type(application, 'application/msgpack', - msgpack.packb, msgpack.unpackb) + content.add_transcoder(application, 'application/msgpack', + transcoders.MsgPackTranscoder()) content.add_transcoder(application, 'application/json', transcoders.JSONTranscoder()) return application diff --git a/requires/testing.txt b/requires/testing.txt index c5be104..9ce02c6 100644 --- a/requires/testing.txt +++ b/requires/testing.txt @@ -1,4 +1,4 @@ coverage>=3.7,<3.99 # prevent installing 4.0b on ALL pip versions mock>=1.3,<2 -msgpack-python>=0.4,<0.5 +u-msgpack-python>=2,<3 nose>=1.3,<2 diff --git a/sprockets/mixins/mediatype/transcoders.py b/sprockets/mixins/mediatype/transcoders.py index 56877d0..2ff0c87 100644 --- a/sprockets/mixins/mediatype/transcoders.py +++ b/sprockets/mixins/mediatype/transcoders.py @@ -2,12 +2,21 @@ Bundled media type transcoders. - :class:`.JSONTranscoder` implements JSON encoding/decoding +- :class:`.MsgPackTranscoder` implements msgpack encoding/decoding """ import base64 import json +import sys import uuid +import collections + +try: + import umsgpack +except ImportError: + umsgpack = None + from sprockets.mixins.mediatype import handlers @@ -112,3 +121,117 @@ class JSONTranscoder(handlers.TextContentHandler): if isinstance(obj, (bytes, bytearray, memoryview)): return base64.b64encode(obj).decode('ASCII') raise TypeError('{!r} is not JSON serializable'.format(obj)) + + +class MsgPackTranscoder(handlers.BinaryContentHandler): + """ + Msgpack Transcoder instance. + + :param str content_type: the content type that this encoder instance + implements. If omitted, ``application/msgpack`` is used. This + is passed directly to the ``BinaryContentHandler`` initializer. + + This transcoder uses the `umsgpack`_ library to encode and decode + objects according to the `msgpack`_ format. + + .. _umsgpack: https://github.com/vsergeev/u-msgpack-python + .. _msgpack: http://msgpack.org/index.html + + """ + if sys.version_info[0] < 3: + PACKABLE_TYPES = (bool, int, float, long) + else: + PACKABLE_TYPES = (bool, int, float) + + def __init__(self, content_type='application/msgpack'): + if umsgpack is None: + raise RuntimeError('Cannot import MsgPackTranscoder, ' + 'umsgpack is not available') + + super(MsgPackTranscoder, self).__init__(content_type, self.packb, + self.unpackb) + + def packb(self, data): + """Pack `data` into a :class:`bytes` instance.""" + return umsgpack.packb(self.normalize_datum(data)) + + def unpackb(self, data): + """Unpack a :class:`object` from a :class:`bytes` instance.""" + return umsgpack.unpackb(data) + + def normalize_datum(self, datum): + """ + Convert `datum` into something that umsgpack likes. + + :param datum: something that we want to process with umsgpack + :return: a packable version of `datum` + :raises TypeError: if `datum` cannot be packed + + This message is called by :meth:`.packb` to recursively normalize + an input value before passing it to :func:`umsgpack.packb`. Values + are normalized according to the following table. + + +-------------------------------+-------------------------------+ + | **Value** | **MsgPack Family** | + +-------------------------------+-------------------------------+ + | :data:`None` | `nil byte`_ (0xC0) | + +-------------------------------+-------------------------------+ + | :data:`True` | `true byte`_ (0xC3) | + +-------------------------------+-------------------------------+ + | :data:`False` | `false byte`_ (0xC2) | + +-------------------------------+-------------------------------+ + | :class:`int` | `integer family`_ | + +-------------------------------+-------------------------------+ + | :class:`float` | `float family`_ | + +-------------------------------+-------------------------------+ + | String | `str family`_ | + +-------------------------------+-------------------------------+ + | :class:`collections.Sequence` | `array family`_ | + +-------------------------------+-------------------------------+ + | :class:`collections.Set` | `array family`_ | + +-------------------------------+-------------------------------+ + | :class:`collections.Mapping` | `map family`_ | + +-------------------------------+-------------------------------+ + + .. _nil byte: https://github.com/msgpack/msgpack/blob/ + 0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md#formats-nil + .. _true byte: https://github.com/msgpack/msgpack/blob/ + 0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md#bool-format-family + .. _false byte: https://github.com/msgpack/msgpack/blob/ + 0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md#bool-format-family + .. _integer family: https://github.com/msgpack/msgpack/blob/ + 0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md#int-format-family + .. _float family: https://github.com/msgpack/msgpack/blob/ + 0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md#float-format-family + .. _str family: https://github.com/msgpack/msgpack/blob/ + 0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md#str-format-family + .. _array family: https://github.com/msgpack/msgpack/blob/ + 0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md#array-format-family + .. _map family: https://github.com/msgpack/msgpack/blob/ + 0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md + #mapping-format-family + + """ + if datum is None: + return datum + + if isinstance(datum, self.PACKABLE_TYPES): + return datum + + if isinstance(datum, str): + return datum + + if sys.version_info[0] < 3 and isinstance(datum, unicode): + return datum + + if isinstance(datum, (collections.Sequence, collections.Set)): + return [self.normalize_datum(item) for item in datum] + + if isinstance(datum, collections.Mapping): + out = {} + for k, v in datum.items(): + out[k] = self.normalize_datum(v) + return out + + raise TypeError( + '{} is not msgpackable'.format(datum.__class__.__name__)) diff --git a/tests.py b/tests.py index ea523b2..3a93d08 100644 --- a/tests.py +++ b/tests.py @@ -8,7 +8,7 @@ import unittest import uuid from tornado import testing -import msgpack +import umsgpack from sprockets.mixins.mediatype import content, handlers, transcoders import examples @@ -28,6 +28,7 @@ class UTC(datetime.tzinfo): class Context(object): + """Super simple class to call setattr on""" pass @@ -60,7 +61,7 @@ class SendResponseTests(testing.AsyncHTTPTestCase): 'application/msgpack') def test_that_default_content_type_is_set_on_response(self): - response = self.fetch('/', method='POST', body=msgpack.packb({}), + response = self.fetch('/', method='POST', body=umsgpack.packb({}), headers={'Content-Type': 'application/msgpack'}) self.assertEqual(response.code, 200) self.assertEqual(response.headers['Content-Type'], @@ -87,7 +88,7 @@ class GetRequestBodyTests(testing.AsyncHTTPTestCase): 'utf8': u'\u2731' } } - response = self.fetch('/', method='POST', body=msgpack.packb(body), + response = self.fetch('/', method='POST', body=umsgpack.packb(body), headers={'Content-Type': 'application/msgpack'}) self.assertEqual(response.code, 200) self.assertEqual(json.loads(response.body.decode('utf-8')), body) @@ -144,8 +145,6 @@ class JSONTranscoderTests(unittest.TestCase): class ContentSettingsTests(unittest.TestCase): def test_that_from_application_creates_instance(self): - class Context(object): - pass context = Context() settings = content.ContentSettings.from_application(context) @@ -192,3 +191,60 @@ class ContentFunctionTests(unittest.TestCase): self.assertIsInstance(transcoder, handlers.TextContentHandler) self.assertIs(transcoder._dumps, json.dumps) self.assertIs(transcoder._loads, json.loads) + + +class MsgPackTranscoderTests(unittest.TestCase): + + def setUp(self): + super(MsgPackTranscoderTests, self).setUp() + self.transcoder = transcoders.MsgPackTranscoder() + + def test_that_strings_are_dumped_as_strings(self): + dumped = self.transcoder.packb(u'foo') + self.assertEqual(self.transcoder.unpackb(dumped), 'foo') + self.assertEqual(dumped, b'\xA3foo') + + def test_that_none_is_packed_as_nil_byte(self): + self.assertEqual(self.transcoder.packb(None), b'\xC0') + + def test_that_bools_are_dumped_appropriately(self): + self.assertEqual(self.transcoder.packb(False), b'\xC2') + self.assertEqual(self.transcoder.packb(True), b'\xC3') + + def test_that_ints_are_packed_appropriately(self): + self.assertEqual(self.transcoder.packb((2 ** 7) - 1), b'\x7F') + self.assertEqual(self.transcoder.packb(2 ** 7), b'\xCC\x80') + self.assertEqual(self.transcoder.packb(2 ** 8), b'\xCD\x01\x00') + self.assertEqual(self.transcoder.packb(2 ** 16), + b'\xCE\x00\x01\x00\x00') + self.assertEqual(self.transcoder.packb(2 ** 32), + b'\xCF\x00\x00\x00\x01\x00\x00\x00\x00') + + def test_that_negative_ints_are_packed_accordingly(self): + self.assertEqual(self.transcoder.packb(-(2 ** 0)), b'\xFF') + self.assertEqual(self.transcoder.packb(-(2 ** 5)), b'\xE0') + self.assertEqual(self.transcoder.packb(-(2 ** 7)), b'\xD0\x80') + self.assertEqual(self.transcoder.packb(-(2 ** 15)), b'\xD1\x80\x00') + self.assertEqual(self.transcoder.packb(-(2 ** 31)), + b'\xD2\x80\x00\x00\x00') + self.assertEqual(self.transcoder.packb(-(2 ** 63)), + b'\xD3\x80\x00\x00\x00\x00\x00\x00\x00') + + def test_that_lists_are_treated_as_arrays(self): + dumped = self.transcoder.packb(list()) + self.assertEqual(self.transcoder.unpackb(dumped), []) + self.assertEqual(dumped, b'\x90') + + def test_that_tuples_are_treated_as_arrays(self): + dumped = self.transcoder.packb(tuple()) + self.assertEqual(self.transcoder.unpackb(dumped), []) + self.assertEqual(dumped, b'\x90') + + def test_that_sets_are_treated_as_arrays(self): + dumped = self.transcoder.packb(set()) + self.assertEqual(self.transcoder.unpackb(dumped), []) + self.assertEqual(dumped, b'\x90') + + def test_that_unhandled_objects_raise_type_error(self): + with self.assertRaises(TypeError): + self.transcoder.packb(object()) From ba3d7cfc84eb7425e0f2e5159781bddad2b173ff Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Sat, 16 Jan 2016 09:24:34 -0500 Subject: [PATCH 09/16] MsgPackTranscoder: Add support for UUID and datetimes. --- sprockets/mixins/mediatype/transcoders.py | 11 +++++++ tests.py | 37 ++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/sprockets/mixins/mediatype/transcoders.py b/sprockets/mixins/mediatype/transcoders.py index 2ff0c87..1db8658 100644 --- a/sprockets/mixins/mediatype/transcoders.py +++ b/sprockets/mixins/mediatype/transcoders.py @@ -192,6 +192,8 @@ class MsgPackTranscoder(handlers.BinaryContentHandler): +-------------------------------+-------------------------------+ | :class:`collections.Mapping` | `map family`_ | +-------------------------------+-------------------------------+ + | :class:`uuid.UUID` | Converted to String | + +-------------------------------+-------------------------------+ .. _nil byte: https://github.com/msgpack/msgpack/blob/ 0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md#formats-nil @@ -218,6 +220,15 @@ class MsgPackTranscoder(handlers.BinaryContentHandler): if isinstance(datum, self.PACKABLE_TYPES): return datum + if isinstance(datum, uuid.UUID): + datum = str(datum) + + if hasattr(datum, 'isoformat'): + datum = datum.isoformat() + + if isinstance(datum, bytes): + datum = datum.decode('utf-8') + if isinstance(datum, str): return datum diff --git a/tests.py b/tests.py index 3a93d08..eded984 100644 --- a/tests.py +++ b/tests.py @@ -3,6 +3,7 @@ import datetime import json import os import pickle +import struct import sys import unittest import uuid @@ -32,6 +33,21 @@ class Context(object): pass +def pack_string(obj): + """Optimally pack a string according to msgpack format""" + payload = str(obj).encode('ASCII') + l = len(payload) + if l < (2 ** 5): + prefix = struct.pack('B', 0b10100000 | l) + elif l < (2 ** 8): + prefix = struct.pack('BB', 0xD9, l) + elif l < (2 ** 16): + prefix = struct.pack('>BH', 0xDA, l) + else: + prefix = struct.pack('>BI', 0xDB, l) + return prefix + payload + + class SendResponseTests(testing.AsyncHTTPTestCase): def get_app(self): @@ -202,7 +218,7 @@ class MsgPackTranscoderTests(unittest.TestCase): def test_that_strings_are_dumped_as_strings(self): dumped = self.transcoder.packb(u'foo') self.assertEqual(self.transcoder.unpackb(dumped), 'foo') - self.assertEqual(dumped, b'\xA3foo') + self.assertEqual(dumped, pack_string('foo')) def test_that_none_is_packed_as_nil_byte(self): self.assertEqual(self.transcoder.packb(None), b'\xC0') @@ -248,3 +264,22 @@ class MsgPackTranscoderTests(unittest.TestCase): def test_that_unhandled_objects_raise_type_error(self): with self.assertRaises(TypeError): self.transcoder.packb(object()) + + def test_that_uuids_are_dumped_as_strings(self): + uid = uuid.uuid4() + dumped = self.transcoder.packb(uid) + self.assertEqual(self.transcoder.unpackb(dumped), str(uid)) + self.assertEqual(dumped, pack_string(uid)) + + def test_that_datetimes_are_dumped_in_isoformat(self): + now = datetime.datetime.now() + dumped = self.transcoder.packb(now) + self.assertEqual(self.transcoder.unpackb(dumped), now.isoformat()) + self.assertEqual(dumped, pack_string(now.isoformat())) + + def test_that_tzaware_datetimes_include_tzoffset(self): + now = datetime.datetime.now().replace(tzinfo=UTC()) + self.assertTrue(now.isoformat().endswith('+00:00')) + dumped = self.transcoder.packb(now) + self.assertEqual(self.transcoder.unpackb(dumped), now.isoformat()) + self.assertEqual(dumped, pack_string(now.isoformat())) From 3a65dea0533bc949b13700787e9d5565f3bc2a56 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Sat, 16 Jan 2016 09:48:39 -0500 Subject: [PATCH 10/16] MsgPackTranscoder: Handle binary data values. --- sprockets/mixins/mediatype/transcoders.py | 26 ++++++++++++++++---- tests.py | 30 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/sprockets/mixins/mediatype/transcoders.py b/sprockets/mixins/mediatype/transcoders.py index 1db8658..c852d4d 100644 --- a/sprockets/mixins/mediatype/transcoders.py +++ b/sprockets/mixins/mediatype/transcoders.py @@ -186,6 +186,12 @@ class MsgPackTranscoder(handlers.BinaryContentHandler): +-------------------------------+-------------------------------+ | String | `str family`_ | +-------------------------------+-------------------------------+ + | :class:`bytes` | `bin family`_ | + +-------------------------------+-------------------------------+ + | :class:`bytearray` | `bin family`_ | + +-------------------------------+-------------------------------+ + | :class:`memoryview` | `bin family`_ | + +-------------------------------+-------------------------------+ | :class:`collections.Sequence` | `array family`_ | +-------------------------------+-------------------------------+ | :class:`collections.Set` | `array family`_ | @@ -223,16 +229,26 @@ class MsgPackTranscoder(handlers.BinaryContentHandler): if isinstance(datum, uuid.UUID): datum = str(datum) + if isinstance(datum, bytearray): + datum = bytes(datum) + + if isinstance(datum, memoryview): + datum = datum.tobytes() + if hasattr(datum, 'isoformat'): datum = datum.isoformat() - if isinstance(datum, bytes): - datum = datum.decode('utf-8') - - if isinstance(datum, str): + if sys.version_info[0] < 3 and isinstance(datum, (str, unicode)): + if isinstance(datum, str): + # try to decode this into a string to make the common + # case work. If we fail, then send along the bytes. + try: + datum = datum.decode('utf-8') + except UnicodeDecodeError: + pass return datum - if sys.version_info[0] < 3 and isinstance(datum, unicode): + if isinstance(datum, (bytes, str)): return datum if isinstance(datum, (collections.Sequence, collections.Set)): diff --git a/tests.py b/tests.py index eded984..c6c8b4b 100644 --- a/tests.py +++ b/tests.py @@ -48,6 +48,18 @@ def pack_string(obj): return prefix + payload +def pack_bytes(payload): + """Optimally pack a byte string according to msgpack format""" + l = len(payload) + if l < (2 ** 8): + prefix = struct.pack('BB', 0xC4, l) + elif l < (2 ** 16): + prefix = struct.pack('>BH', 0xC5, l) + else: + prefix = struct.pack('>BI', 0xC6, l) + return prefix + payload + + class SendResponseTests(testing.AsyncHTTPTestCase): def get_app(self): @@ -283,3 +295,21 @@ class MsgPackTranscoderTests(unittest.TestCase): dumped = self.transcoder.packb(now) self.assertEqual(self.transcoder.unpackb(dumped), now.isoformat()) self.assertEqual(dumped, pack_string(now.isoformat())) + + def test_that_bytes_are_sent_as_bytes(self): + data = bytes(os.urandom(127)) + dumped = self.transcoder.packb(data) + self.assertEqual(self.transcoder.unpackb(dumped), data) + self.assertEqual(dumped, pack_bytes(data)) + + def test_that_bytearrays_are_sent_as_bytes(self): + data = bytearray(os.urandom(127)) + dumped = self.transcoder.packb(data) + self.assertEqual(self.transcoder.unpackb(dumped), data) + self.assertEqual(dumped, pack_bytes(data)) + + def test_that_memoryviews_are_sent_as_bytes(self): + data = memoryview(os.urandom(127)) + dumped = self.transcoder.packb(data) + self.assertEqual(self.transcoder.unpackb(dumped), data) + self.assertEqual(dumped, pack_bytes(data.tobytes())) From 98e3719a8ba376268325eb02d3123963ddde6d8e Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Sat, 16 Jan 2016 09:54:39 -0500 Subject: [PATCH 11/16] MsgPackTranscoder: Add BinaryWrapper override. --- docs/api.rst | 3 ++ docs/history.rst | 1 + sprockets/mixins/mediatype/transcoders.py | 41 +++++++++++++++++++++-- tests.py | 6 ++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 0135104..9065776 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -29,3 +29,6 @@ Bundled Transcoders .. autoclass:: MsgPackTranscoder :members: + +.. autoclass:: BinaryWrapper + :members: diff --git a/docs/history.rst b/docs/history.rst index ee6bbcf..4df82b8 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -8,6 +8,7 @@ Version History - Add :func:`sprockets.mixins.mediatype.content.add_transcoder`. - Add :class:`sprockets.mixins.mediatype.transcoders.JSONTranscoder` - Add :class:`sprockets.mixins.mediatype.transcoders.MsgPackTranscoder` +- Add :class:`sprockets.mixins.mediatype.transcoders.BinaryWrapper` `1.0.4`_ (14 Sep 2015) ---------------------- diff --git a/sprockets/mixins/mediatype/transcoders.py b/sprockets/mixins/mediatype/transcoders.py index c852d4d..2a00295 100644 --- a/sprockets/mixins/mediatype/transcoders.py +++ b/sprockets/mixins/mediatype/transcoders.py @@ -20,6 +20,28 @@ except ImportError: from sprockets.mixins.mediatype import handlers +class BinaryWrapper(bytes): + """ + Ensures that a Python 2 ``str`` is treated as binary. + + Since :class:`bytes` is a synonym for :class:`str` in Python 2, + you cannot distinguish between something that should be binary + and something that should be encoded as a string. This is a + problem in formats such as `msgpack`_ where binary data and + strings are encoded differently. The :class:`MsgPackTranscoder` + accomodates this by trying to UTF-8 encode a :class:`str` instance + and falling back to binary encoding if the transcode fails. + + You can avoid this by wrapping binary content in an instance of + this class. The transcoder will then treat it as a binary payload + instead of trying to detect whether it is a string or not. + + .. _msgpack: http://msgpack.org + + """ + pass + + class JSONTranscoder(handlers.TextContentHandler): """ JSON transcoder instance. @@ -184,7 +206,7 @@ class MsgPackTranscoder(handlers.BinaryContentHandler): +-------------------------------+-------------------------------+ | :class:`float` | `float family`_ | +-------------------------------+-------------------------------+ - | String | `str family`_ | + | String (see note) | `str family`_ | +-------------------------------+-------------------------------+ | :class:`bytes` | `bin family`_ | +-------------------------------+-------------------------------+ @@ -192,6 +214,8 @@ class MsgPackTranscoder(handlers.BinaryContentHandler): +-------------------------------+-------------------------------+ | :class:`memoryview` | `bin family`_ | +-------------------------------+-------------------------------+ + | :class:`.BinaryWrapper` | `bin family`_ | + +-------------------------------+-------------------------------+ | :class:`collections.Sequence` | `array family`_ | +-------------------------------+-------------------------------+ | :class:`collections.Set` | `array family`_ | @@ -201,6 +225,19 @@ class MsgPackTranscoder(handlers.BinaryContentHandler): | :class:`uuid.UUID` | Converted to String | +-------------------------------+-------------------------------+ + .. note:: + + :class:`str` and :class:`bytes` are the same before Python 3. + If you want a value to be treated as a binary value, then you + should wrap it in :class:`.BinaryWrapper` if there is any + chance of running under Python 2.7. + + The processing of :class:`str` in Python 2.x attempts to + encode the string as a UTF-8 stream. If the ``encode`` succeeds, + then the string is encoded according to the `str family`_. + If ``encode`` fails, then the string is encoded according to + the `bin family`_ . + .. _nil byte: https://github.com/msgpack/msgpack/blob/ 0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md#formats-nil .. _true byte: https://github.com/msgpack/msgpack/blob/ @@ -239,7 +276,7 @@ class MsgPackTranscoder(handlers.BinaryContentHandler): datum = datum.isoformat() if sys.version_info[0] < 3 and isinstance(datum, (str, unicode)): - if isinstance(datum, str): + if isinstance(datum, str) and not isinstance(datum, BinaryWrapper): # try to decode this into a string to make the common # case work. If we fail, then send along the bytes. try: diff --git a/tests.py b/tests.py index c6c8b4b..fdbd605 100644 --- a/tests.py +++ b/tests.py @@ -313,3 +313,9 @@ class MsgPackTranscoderTests(unittest.TestCase): dumped = self.transcoder.packb(data) self.assertEqual(self.transcoder.unpackb(dumped), data) self.assertEqual(dumped, pack_bytes(data.tobytes())) + + def test_that_utf8_values_can_be_forced_to_bytes(self): + data = b'a ascii value' + dumped = self.transcoder.packb(transcoders.BinaryWrapper(data)) + self.assertEqual(self.transcoder.unpackb(dumped), data) + self.assertEqual(dumped, pack_bytes(data)) From 995c7154056830d8b052b787de6519f9886a8c26 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Sun, 31 Jan 2016 12:09:07 -0500 Subject: [PATCH 12/16] Normalize registered content types. MIME content type strings are normalized by lower-casing the content type parameters and then sorting them. Each parameter is preceded by a space. --- docs/history.rst | 1 + sprockets/mixins/mediatype/content.py | 7 +++++-- tests.py | 20 ++++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/history.rst b/docs/history.rst index 4df82b8..b310248 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -9,6 +9,7 @@ Version History - Add :class:`sprockets.mixins.mediatype.transcoders.JSONTranscoder` - Add :class:`sprockets.mixins.mediatype.transcoders.MsgPackTranscoder` - Add :class:`sprockets.mixins.mediatype.transcoders.BinaryWrapper` +- Normalize registered MIME types. `1.0.4`_ (14 Sep 2015) ---------------------- diff --git a/sprockets/mixins/mediatype/content.py b/sprockets/mixins/mediatype/content.py index df6fea6..bdc8bc6 100644 --- a/sprockets/mixins/mediatype/content.py +++ b/sprockets/mixins/mediatype/content.py @@ -83,15 +83,18 @@ class ContentSettings(object): self.default_encoding = None def __getitem__(self, content_type): - return self._handlers[content_type] + parsed = headers.parse_content_type(content_type) + return self._handlers[str(parsed)] def __setitem__(self, content_type, handler): + parsed = headers.parse_content_type(content_type) + content_type = str(parsed) if content_type in self._handlers: logger.warning('handler for %s already set to %r', content_type, self._handlers[content_type]) return - self._available_types.append(headers.parse_content_type(content_type)) + self._available_types.append(parsed) self._handlers[content_type] = handler def get(self, content_type, default=None): diff --git a/tests.py b/tests.py index fdbd605..ebd9546 100644 --- a/tests.py +++ b/tests.py @@ -194,6 +194,26 @@ class ContentSettingsTests(unittest.TestCase): settings['application/json'] = object() self.assertIs(settings.get('application/json'), handler) + def test_that_registered_content_types_are_normalized(self): + settings = content.ContentSettings() + handler = object() + settings['application/json; VerSion=foo; type=WhatEver'] = handler + self.assertIs(settings['application/json; type=whatever; version=foo'], + handler) + self.assertIn('application/json; type=whatever; version=foo', + (str(c) for c in settings.available_content_types)) + + def test_that_normalized_content_types_do_not_overwrite(self): + settings = content.ContentSettings() + settings['application/json; charset=UTF-8'] = handler = object() + settings['application/json; charset=utf-8'] = object() + self.assertEqual(len(settings.available_content_types), 1) + self.assertEqual(settings.available_content_types[0].content_type, + 'application') + self.assertEqual(settings.available_content_types[0].content_subtype, + 'json') + self.assertEqual(settings['application/json; charset=utf-8'], handler) + class ContentFunctionTests(unittest.TestCase): From ed357d878f5c4670c4c24ded69810345f876edfc Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Sun, 31 Jan 2016 12:13:28 -0500 Subject: [PATCH 13/16] Strip charset parameter from text content types. --- sprockets/mixins/mediatype/content.py | 10 ++++++++-- tests.py | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/sprockets/mixins/mediatype/content.py b/sprockets/mixins/mediatype/content.py index bdc8bc6..7675b7e 100644 --- a/sprockets/mixins/mediatype/content.py +++ b/sprockets/mixins/mediatype/content.py @@ -148,9 +148,15 @@ def add_text_content_type(application, content_type, default_encoding, :param loads: function that loads a dictionary from a string. ``loads(str, encoding:str) -> dict`` + Note that the ``charset`` parameter is stripped from `content_type` + if it is present. + """ - add_transcoder(application, content_type, - handlers.TextContentHandler(content_type, dumps, loads, + parsed = headers.parse_content_type(content_type) + parsed.parameters.pop('charset', None) + normalized = str(parsed) + add_transcoder(application, normalized, + handlers.TextContentHandler(normalized, dumps, loads, default_encoding)) diff --git a/tests.py b/tests.py index ebd9546..9464209 100644 --- a/tests.py +++ b/tests.py @@ -240,6 +240,14 @@ class ContentFunctionTests(unittest.TestCase): self.assertIs(transcoder._dumps, json.dumps) self.assertIs(transcoder._loads, json.loads) + def test_that_add_text_content_type_discards_charset_parameter(self): + content.add_text_content_type(self.context, + 'application/json;charset=UTF-8', 'utf8', + json.dumps, json.loads) + settings = content.ContentSettings.from_application(self.context) + transcoder = settings['application/json'] + self.assertIsInstance(transcoder, handlers.TextContentHandler) + class MsgPackTranscoderTests(unittest.TestCase): From f920298c54175d17f19edd9cfe9b778a128cdf41 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Sun, 31 Jan 2016 12:31:35 -0500 Subject: [PATCH 14/16] Clean up a few documents. --- README.rst | 27 +++++++++++++++++++++-- sprockets/mixins/mediatype/transcoders.py | 10 +++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index c1a1b56..4601b70 100644 --- a/README.rst +++ b/README.rst @@ -12,8 +12,8 @@ This mix-in adds two methods to a ``tornado.web.RequestHandler`` instance: - ``send_response(object)``: serializes the response into the content type requested by the ``Accept`` header. -Support for a content types is enabled by calling either the -``add_binary_content_type`` or ``add_text_content_type`` function with the +Support for a content types is enabled by calling ``add_binary_content_type``, +``add_text_content_type`` or the ``add_transcoder`` functions with the ``tornado.web.Application`` instance, the content type, encoding and decoding functions as parameters: @@ -37,6 +37,29 @@ functions as parameters: 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. +The *add_transcoder* function is similar except that it takes an object +that implements transcoding methods instead of simple functions. The +``transcoders`` module includes ready-to-use transcoders for a few content +types: + +.. code-block:: python + + from sprockets.mixins.mediatype import content, transcoders + from tornado import web + + def make_application(): + application = web.Application([ + # insert your handlers here + ]) + + content.add_transcoder(application, 'application/json', + transcoders.JSONTranscoder()) + + return application + +In either case, the ``ContentMixin`` uses the registered content type +information to provide transparent content type negotiation for your +request handlers. .. code-block:: python diff --git a/sprockets/mixins/mediatype/transcoders.py b/sprockets/mixins/mediatype/transcoders.py index 2a00295..98291b6 100644 --- a/sprockets/mixins/mediatype/transcoders.py +++ b/sprockets/mixins/mediatype/transcoders.py @@ -27,7 +27,7 @@ class BinaryWrapper(bytes): Since :class:`bytes` is a synonym for :class:`str` in Python 2, you cannot distinguish between something that should be binary and something that should be encoded as a string. This is a - problem in formats such as `msgpack`_ where binary data and + problem in formats `such as msgpack`_ where binary data and strings are encoded differently. The :class:`MsgPackTranscoder` accomodates this by trying to UTF-8 encode a :class:`str` instance and falling back to binary encoding if the transcode fails. @@ -36,7 +36,7 @@ class BinaryWrapper(bytes): this class. The transcoder will then treat it as a binary payload instead of trying to detect whether it is a string or not. - .. _msgpack: http://msgpack.org + .. _such as msgpack: http://msgpack.org """ pass @@ -154,10 +154,10 @@ class MsgPackTranscoder(handlers.BinaryContentHandler): is passed directly to the ``BinaryContentHandler`` initializer. This transcoder uses the `umsgpack`_ library to encode and decode - objects according to the `msgpack`_ format. + objects according to the `msgpack format`_. .. _umsgpack: https://github.com/vsergeev/u-msgpack-python - .. _msgpack: http://msgpack.org/index.html + .. _msgpack format: http://msgpack.org/index.html """ if sys.version_info[0] < 3: @@ -255,6 +255,8 @@ class MsgPackTranscoder(handlers.BinaryContentHandler): .. _map family: https://github.com/msgpack/msgpack/blob/ 0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md #mapping-format-family + .. _bin family: https://github.com/msgpack/msgpack/blob/ + 0b8f5ac67cdd130f4d4d4fe6afb839b989fdb86a/spec.md#bin-format-family """ if datum is None: From 36a916a557ed6bec888a9054d9c5e1d7580f0287 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Mon, 1 Feb 2016 09:07:56 -0500 Subject: [PATCH 15/16] add_transcoder: Change parameter order. This is the first part of making the content type parameter optional --- README.rst | 4 ++-- examples.py | 8 ++++---- sprockets/mixins/mediatype/content.py | 14 ++++++++------ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/README.rst b/README.rst index 4601b70..ff6d16d 100644 --- a/README.rst +++ b/README.rst @@ -52,8 +52,8 @@ types: # insert your handlers here ]) - content.add_transcoder(application, 'application/json', - transcoders.JSONTranscoder()) + content.add_transcoder(application, transcoders.JSONTranscoder(), + 'application/json') return application diff --git a/examples.py b/examples.py index db6ea78..5e07470 100644 --- a/examples.py +++ b/examples.py @@ -18,10 +18,10 @@ def make_application(**settings): application = web.Application([web.url(r'/', SimpleHandler)], **settings) content.set_default_content_type(application, 'application/json', encoding='utf-8') - content.add_transcoder(application, 'application/msgpack', - transcoders.MsgPackTranscoder()) - content.add_transcoder(application, 'application/json', - transcoders.JSONTranscoder()) + content.add_transcoder(application, transcoders.MsgPackTranscoder(), + content_type='application/msgpack') + content.add_transcoder(application, transcoders.JSONTranscoder(), + content_type='application/json') return application diff --git a/sprockets/mixins/mediatype/content.py b/sprockets/mixins/mediatype/content.py index 7675b7e..1633dae 100644 --- a/sprockets/mixins/mediatype/content.py +++ b/sprockets/mixins/mediatype/content.py @@ -131,8 +131,9 @@ def add_binary_content_type(application, content_type, pack, unpack): dictionary. ``unpack(bytes) -> dict`` """ - add_transcoder(application, content_type, - handlers.BinaryContentHandler(content_type, pack, unpack)) + add_transcoder(application, + handlers.BinaryContentHandler(content_type, pack, unpack), + content_type) def add_text_content_type(application, content_type, default_encoding, @@ -155,19 +156,20 @@ def add_text_content_type(application, content_type, default_encoding, parsed = headers.parse_content_type(content_type) parsed.parameters.pop('charset', None) normalized = str(parsed) - add_transcoder(application, normalized, + add_transcoder(application, handlers.TextContentHandler(normalized, dumps, loads, - default_encoding)) + default_encoding), + normalized) -def add_transcoder(application, content_type, transcoder): +def add_transcoder(application, transcoder, content_type): """ Register a transcoder for a specific content type. :param tornado.web.Application application: the application to modify - :param str content_type: the content type to add :param transcoder: object that translates between :class:`bytes` and :class:`object` instances + :param str content_type: the content type to add The `transcoder` instance is required to implement the following simple protocol: From ee28d54036d9bb37ab5858fe93abf3e6ea049d37 Mon Sep 17 00:00:00 2001 From: Dave Shawley Date: Mon, 1 Feb 2016 10:06:46 -0500 Subject: [PATCH 16/16] Make content-type parameter to add_transcoder optional. --- README.rst | 3 +-- examples.py | 6 ++---- sprockets/mixins/mediatype/content.py | 19 ++++++++++++------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index ff6d16d..56bb13c 100644 --- a/README.rst +++ b/README.rst @@ -52,8 +52,7 @@ types: # insert your handlers here ]) - content.add_transcoder(application, transcoders.JSONTranscoder(), - 'application/json') + content.add_transcoder(application, transcoders.JSONTranscoder()) return application diff --git a/examples.py b/examples.py index 5e07470..bef1f27 100644 --- a/examples.py +++ b/examples.py @@ -18,10 +18,8 @@ def make_application(**settings): application = web.Application([web.url(r'/', SimpleHandler)], **settings) content.set_default_content_type(application, 'application/json', encoding='utf-8') - content.add_transcoder(application, transcoders.MsgPackTranscoder(), - content_type='application/msgpack') - content.add_transcoder(application, transcoders.JSONTranscoder(), - content_type='application/json') + content.add_transcoder(application, transcoders.MsgPackTranscoder()) + content.add_transcoder(application, transcoders.JSONTranscoder()) return application diff --git a/sprockets/mixins/mediatype/content.py b/sprockets/mixins/mediatype/content.py index 1633dae..b0e69a2 100644 --- a/sprockets/mixins/mediatype/content.py +++ b/sprockets/mixins/mediatype/content.py @@ -132,8 +132,7 @@ def add_binary_content_type(application, content_type, pack, unpack): """ add_transcoder(application, - handlers.BinaryContentHandler(content_type, pack, unpack), - content_type) + handlers.BinaryContentHandler(content_type, pack, unpack)) def add_text_content_type(application, content_type, default_encoding, @@ -158,22 +157,28 @@ def add_text_content_type(application, content_type, default_encoding, normalized = str(parsed) add_transcoder(application, handlers.TextContentHandler(normalized, dumps, loads, - default_encoding), - normalized) + default_encoding)) -def add_transcoder(application, transcoder, content_type): +def add_transcoder(application, transcoder, content_type=None): """ Register a transcoder for a specific content type. :param tornado.web.Application application: the application to modify :param transcoder: object that translates between :class:`bytes` and :class:`object` instances - :param str content_type: the content type to add + :param str content_type: the content type to add. If this is + unspecified or :data:`None`, then the transcoder's ``content_type`` + attribute is used. The `transcoder` instance is required to implement the following simple protocol: + .. attribute:: transcoder.content_type + + :class:`str` that identifies the MIME type that the transcoder + implements. + .. method:: transcoder.to_bytes(inst_data, encoding=None) -> bytes :param object inst_data: the object to encode @@ -188,7 +193,7 @@ def add_transcoder(application, transcoder, content_type): """ settings = ContentSettings.from_application(application) - settings[content_type] = transcoder + settings[content_type or transcoder.content_type] = transcoder def set_default_content_type(application, content_type, encoding=None):