From 8a8a60815294eb9d5db0c2d451c6c9d959c7c499 Mon Sep 17 00:00:00 2001 From: Josh Rowe Date: Thu, 23 Feb 2017 15:23:44 +0000 Subject: [PATCH 1/6] Move object_hook outside loads method so class can be extend and reused --- flask/sessions.py | 145 +++++++++++++++++++++++++++++++------------- tests/test_basic.py | 6 ++ 2 files changed, 108 insertions(+), 43 deletions(-) diff --git a/flask/sessions.py b/flask/sessions.py index 525ff246..4335eeaa 100644 --- a/flask/sessions.py +++ b/flask/sessions.py @@ -8,7 +8,6 @@ :copyright: (c) 2015 by Armin Ronacher. :license: BSD, see LICENSE for more details. """ - import uuid import hashlib from base64 import b64encode, b64decode @@ -49,22 +48,82 @@ class SessionMixin(object): modified = True -def _tag(value): - if isinstance(value, tuple): - return {' t': [_tag(x) for x in value]} - elif isinstance(value, uuid.UUID): - return {' u': value.hex} - elif isinstance(value, bytes): - return {' b': b64encode(value).decode('ascii')} - elif callable(getattr(value, '__html__', None)): - return {' m': text_type(value.__html__())} - elif isinstance(value, list): - return [_tag(x) for x in value] - elif isinstance(value, datetime): - return {' d': http_date(value)} - elif isinstance(value, dict): - return dict((k, _tag(v)) for k, v in iteritems(value)) - elif isinstance(value, str): +class TaggedJSONSerializer(object): + """A customized JSON serializer that supports a few extra types that + we take for granted when serializing (tuples, markup objects, datetime). + """ + + def __init__(self): + self.conversions = [ + { + 'check': lambda value: self._is_dict_with_used_key(value), + 'tag': lambda value: self._tag_dict_used_with_key(value), + 'untag': lambda value: self._untag_dict_used_with_key(value), + 'key': ' di', + }, + { + 'check': lambda value: isinstance(value, tuple), + 'tag': lambda value: [self._tag(x) for x in value], + 'untag': lambda value: tuple(value), + 'key': ' t', + }, + { + 'check': lambda value: isinstance(value, uuid.UUID), + 'tag': lambda value: value.hex, + 'untag': lambda value: uuid.UUID(value), + 'key': ' u', + }, + { + 'check': lambda value: isinstance(value, bytes), + 'tag': lambda value: b64encode(value).decode('ascii'), + 'untag': lambda value: b64decode(value), + 'key': ' b', + }, + { + 'check': lambda value: callable(getattr(value, '__html__', + None)), + 'tag': lambda value: text_type(value.__html__()), + 'untag': lambda value: Markup(value), + 'key': ' m', + }, + { + 'check': lambda value: isinstance(value, list), + 'tag': lambda value: [self._tag(x) for x in value], + }, + { + 'check': lambda value: isinstance(value, datetime), + 'tag': lambda value: http_date(value), + 'untag': lambda value: parse_date(value), + 'key': ' d', + }, + { + 'check': lambda value: isinstance(value, dict), + 'tag': lambda value: dict((k, self._tag(v)) for k, v in + iteritems(value)), + }, + { + 'check': lambda value: isinstance(value, str), + 'tag': lambda value: self._tag_string(value), + } + ] + + @property + def keys(self): + return [c['key'] for c in self.conversions if c.get('key')] + + def _get_conversion_untag(self, key): + return next( + (c['untag'] for c in self.conversions if c.get('key') == key), + lambda v: None + ) + + def _is_dict_with_used_key(self, v): + return isinstance(v, dict) and len(v) == 1 and list(v)[0] in self.keys + + def _was_dict_with_used_key(self, k): + return k.endswith('__') and k[:-2] in self.keys + + def _tag_string(self, value): try: return text_type(value) except UnicodeError: @@ -73,38 +132,38 @@ def _tag(value): u'non-ASCII data was passed to the session system ' u'which can only store unicode strings. Consider ' u'base64 encoding your string (String was %r)' % value) - return value + def _tag_dict_used_with_key(self, value): + k, v = next(iteritems(value)) + return {'%s__' % k: v} -class TaggedJSONSerializer(object): - """A customized JSON serializer that supports a few extra types that - we take for granted when serializing (tuples, markup objects, datetime). - """ + def _tag(self, value): + for tag_ops in self.conversions: + if tag_ops['check'](value): + tag = tag_ops.get('key') + if tag: + return {tag: tag_ops['tag'](value)} + return tag_ops['tag'](value) + return value + + def _untag_dict_used_with_key(self, the_value): + k, v = next(iteritems(the_value)) + if self._was_dict_with_used_key(k): + return {k[:-2]: self._untag(v)} + + def _untag(self, obj): + if len(obj) != 1: + return obj + the_key, the_value = next(iteritems(obj)) + untag = self._get_conversion_untag(the_key) + new_value = untag(the_value) + return new_value if new_value else obj def dumps(self, value): - return json.dumps(_tag(value), separators=(',', ':')) - - LOADS_MAP = { - ' t': tuple, - ' u': uuid.UUID, - ' b': b64decode, - ' m': Markup, - ' d': parse_date, - } + return json.dumps(self._tag(value), separators=(',', ':')) def loads(self, value): - def object_hook(obj): - if len(obj) != 1: - return obj - the_key, the_value = next(iteritems(obj)) - # Check the key for a corresponding function - return_function = self.LOADS_MAP.get(the_key) - if return_function: - # Pass the value to the function - return return_function(the_value) - # Didn't find a function for this object - return obj - return json.loads(value, object_hook=object_hook) + return json.loads(value, object_hook=self._untag) session_json_serializer = TaggedJSONSerializer() diff --git a/tests/test_basic.py b/tests/test_basic.py index 6341234b..62a90cf6 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -383,6 +383,9 @@ def test_session_special_types(): flask.session['dt'] = now flask.session['b'] = b'\xff' flask.session['t'] = (1, 2, 3) + flask.session['spacefirst'] = {' t': 'not-a-tuple'} + flask.session['withunderscores'] = {' t__': 'not-a-tuple'} + flask.session['notadict'] = {' di': 'not-a-dict'} return response @app.route('/') @@ -399,6 +402,9 @@ def test_session_special_types(): assert rv['b'] == b'\xff' assert type(rv['b']) == bytes assert rv['t'] == (1, 2, 3) + assert rv['spacefirst'] == {' t': 'not-a-tuple'} + assert rv['withunderscores'] == {' t__': 'not-a-tuple'} + assert rv['notadict'] == {' di': 'not-a-dict'} def test_session_cookie_setting(): From 5e1ced3c055f7eb567bf7266c98de3d44ceea1b4 Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 1 Jun 2017 22:47:23 -0700 Subject: [PATCH 2/6] make session serializer extensible support serializing 1-item dicts with tag as key refactor serializer into flask.json.tag module continues #1452, closes #1438, closes #1908 --- CHANGES | 3 + flask/{json.py => json/__init__.py} | 13 +- flask/json/tag.py | 188 ++++++++++++++++++++++++++++ flask/sessions.py | 137 ++------------------ 4 files changed, 201 insertions(+), 140 deletions(-) rename flask/{json.py => json/__init__.py} (97%) create mode 100644 flask/json/tag.py diff --git a/CHANGES b/CHANGES index dc39a95d..a3504ad5 100644 --- a/CHANGES +++ b/CHANGES @@ -65,6 +65,8 @@ Major release, unreleased - ``TRAP_BAD_REQUEST_ERRORS`` is enabled by default in debug mode. ``BadRequestKeyError`` has a message with the bad key in debug mode instead of the generic bad request message. (`#2348`_) +- Allow registering new tags with ``TaggedJSONSerializer`` to support + storing other types in the session cookie. (`#2352`_) .. _#1489: https://github.com/pallets/flask/pull/1489 .. _#1621: https://github.com/pallets/flask/pull/1621 @@ -84,6 +86,7 @@ Major release, unreleased .. _#2319: https://github.com/pallets/flask/pull/2319 .. _#2326: https://github.com/pallets/flask/pull/2326 .. _#2348: https://github.com/pallets/flask/pull/2348 +.. _#2352: https://github.com/pallets/flask/pull/2352 Version 0.12.2 -------------- diff --git a/flask/json.py b/flask/json/__init__.py similarity index 97% rename from flask/json.py rename to flask/json/__init__.py index a029e73a..93e6fdc4 100644 --- a/flask/json.py +++ b/flask/json/__init__.py @@ -1,18 +1,9 @@ # -*- coding: utf-8 -*- -""" - flask.json - ~~~~~~~~~~ - - Implementation helpers for the JSON support in Flask. - - :copyright: (c) 2015 by Armin Ronacher. - :license: BSD, see LICENSE for more details. -""" import io import uuid from datetime import date -from .globals import current_app, request -from ._compat import text_type, PY2 +from flask.globals import current_app, request +from flask._compat import text_type, PY2 from werkzeug.http import http_date from jinja2 import Markup diff --git a/flask/json/tag.py b/flask/json/tag.py new file mode 100644 index 00000000..40594282 --- /dev/null +++ b/flask/json/tag.py @@ -0,0 +1,188 @@ +from base64 import b64decode, b64encode +from datetime import datetime +from uuid import UUID + +from jinja2 import Markup +from werkzeug.http import http_date, parse_date + +from flask._compat import iteritems, text_type +from flask.json import dumps, loads + + +class JSONTag(object): + __slots__ = () + key = None + + def check(self, serializer, value): + raise NotImplementedError + + def to_json(self, serializer, value): + raise NotImplementedError + + def to_python(self, serializer, value): + raise NotImplementedError + + def tag(self, serializer, value): + return {self.key: self.to_json(serializer, value)} + + +class TagDict(JSONTag): + __slots__ = () + key = ' di' + + def check(self, serializer, value): + return isinstance(value, dict) + + def to_json(self, serializer, value, key=None): + if key is not None: + return {key + '__': serializer._tag(value[key])} + + return dict((k, serializer._tag(v)) for k, v in iteritems(value)) + + def to_python(self, serializer, value): + key = next(iter(value)) + return {key[:-2]: value[key]} + + def tag(self, serializer, value): + if len(value) == 1: + key = next(iter(value)) + + if key in serializer._tags: + return {self.key: self.to_json(serializer, value, key=key)} + + return self.to_json(serializer, value) + + +class TagTuple(JSONTag): + __slots__ = () + key = ' t' + + def check(self, serializer, value): + return isinstance(value, tuple) + + def to_json(self, serializer, value): + return [serializer._tag(item) for item in value] + + def to_python(self, serializer, value): + return tuple(value) + + +class PassList(JSONTag): + __slots__ = () + + def check(self, serializer, value): + return isinstance(value, list) + + def to_json(self, serializer, value): + return [serializer._tag(item) for item in value] + + tag = to_json + + +class TagBytes(JSONTag): + __slots__ = () + key = ' b' + + def check(self, serializer, value): + return isinstance(value, bytes) + + def to_json(self, serializer, value): + return b64encode(value).decode('ascii') + + def to_python(self, serializer, value): + return b64decode(value) + + +class TagMarkup(JSONTag): + __slots__ = () + key = ' m' + + def check(self, serializer, value): + return callable(getattr(value, '__html__', None)) + + def to_json(self, serializer, value): + return text_type(value.__html__()) + + def to_python(self, serializer, value): + return Markup(value) + + +class TagUUID(JSONTag): + __slots__ = () + key = ' u' + + def check(self, serializer, value): + return isinstance(value, UUID) + + def to_json(self, serializer, value): + return value.hex + + def to_python(self, serializer, value): + return UUID(value) + + +class TagDateTime(JSONTag): + __slots__ = () + key = ' d' + + def check(self, serializer, value): + return isinstance(value, datetime) + + def to_json(self, serializer, value): + return http_date(value) + + def to_python(self, serializer, value): + return parse_date(value) + + +class TaggedJSONSerializer(object): + __slots__ = ('_tags', '_order') + _default_tags = [ + TagDict(), TagTuple(), PassList(), TagBytes(), TagMarkup(), TagUUID(), + TagDateTime(), + ] + + def __init__(self): + self._tags = {} + self._order = [] + + for tag in self._default_tags: + self.register(tag) + + def register(self, tag, force=False, index=-1): + key = tag.key + + if key is not None: + if not force and key in self._tags: + raise KeyError("Tag '{0}' is already registered.".format(key)) + + self._tags[key] = tag + + if index == -1: + self._order.append(tag) + else: + self._order.insert(index, tag) + + def _tag(self, value): + for tag in self._order: + if tag.check(self, value): + return tag.tag(self, value) + + return value + + def _untag(self, value): + if len(value) != 1: + return value + + key = next(iter(value)) + + if key not in self._tags: + return value + + return self._tags[key].to_python(self, value[key]) + + def dumps(self, value): + return dumps(self._tag(value), separators=(',', ':')) + + def loads(self, value): + return loads(value, object_hook=self._untag) diff --git a/flask/sessions.py b/flask/sessions.py index a334e703..82b588bc 100644 --- a/flask/sessions.py +++ b/flask/sessions.py @@ -9,18 +9,14 @@ :license: BSD, see LICENSE for more details. """ import hashlib -import uuid import warnings -from base64 import b64decode, b64encode from datetime import datetime from itsdangerous import BadSignature, URLSafeTimedSerializer from werkzeug.datastructures import CallbackDict -from werkzeug.http import http_date, parse_date -from . import Markup, json -from ._compat import iteritems, text_type -from .helpers import is_ip, total_seconds +from flask.helpers import is_ip, total_seconds +from flask.json.tag import TaggedJSONSerializer class SessionMixin(object): @@ -57,126 +53,6 @@ class SessionMixin(object): #: from being served the same cache. accessed = True -class TaggedJSONSerializer(object): - """A customized JSON serializer that supports a few extra types that - we take for granted when serializing (tuples, markup objects, datetime). - """ - - def __init__(self): - self.conversions = [ - { - 'check': lambda value: self._is_dict_with_used_key(value), - 'tag': lambda value: self._tag_dict_used_with_key(value), - 'untag': lambda value: self._untag_dict_used_with_key(value), - 'key': ' di', - }, - { - 'check': lambda value: isinstance(value, tuple), - 'tag': lambda value: [self._tag(x) for x in value], - 'untag': lambda value: tuple(value), - 'key': ' t', - }, - { - 'check': lambda value: isinstance(value, uuid.UUID), - 'tag': lambda value: value.hex, - 'untag': lambda value: uuid.UUID(value), - 'key': ' u', - }, - { - 'check': lambda value: isinstance(value, bytes), - 'tag': lambda value: b64encode(value).decode('ascii'), - 'untag': lambda value: b64decode(value), - 'key': ' b', - }, - { - 'check': lambda value: callable(getattr(value, '__html__', - None)), - 'tag': lambda value: text_type(value.__html__()), - 'untag': lambda value: Markup(value), - 'key': ' m', - }, - { - 'check': lambda value: isinstance(value, list), - 'tag': lambda value: [self._tag(x) for x in value], - }, - { - 'check': lambda value: isinstance(value, datetime), - 'tag': lambda value: http_date(value), - 'untag': lambda value: parse_date(value), - 'key': ' d', - }, - { - 'check': lambda value: isinstance(value, dict), - 'tag': lambda value: dict((k, self._tag(v)) for k, v in - iteritems(value)), - }, - { - 'check': lambda value: isinstance(value, str), - 'tag': lambda value: self._tag_string(value), - } - ] - - @property - def keys(self): - return [c['key'] for c in self.conversions if c.get('key')] - - def _get_conversion_untag(self, key): - return next( - (c['untag'] for c in self.conversions if c.get('key') == key), - lambda v: None - ) - - def _is_dict_with_used_key(self, v): - return isinstance(v, dict) and len(v) == 1 and list(v)[0] in self.keys - - def _was_dict_with_used_key(self, k): - return k.endswith('__') and k[:-2] in self.keys - - def _tag_string(self, value): - try: - return text_type(value) - except UnicodeError: - from flask.debughelpers import UnexpectedUnicodeError - raise UnexpectedUnicodeError(u'A byte string with ' - u'non-ASCII data was passed to the session system ' - u'which can only store unicode strings. Consider ' - u'base64 encoding your string (String was %r)' % value) - - def _tag_dict_used_with_key(self, value): - k, v = next(iteritems(value)) - return {'%s__' % k: v} - - def _tag(self, value): - for tag_ops in self.conversions: - if tag_ops['check'](value): - tag = tag_ops.get('key') - if tag: - return {tag: tag_ops['tag'](value)} - return tag_ops['tag'](value) - return value - - def _untag_dict_used_with_key(self, the_value): - k, v = next(iteritems(the_value)) - if self._was_dict_with_used_key(k): - return {k[:-2]: self._untag(v)} - - def _untag(self, obj): - if len(obj) != 1: - return obj - the_key, the_value = next(iteritems(obj)) - untag = self._get_conversion_untag(the_key) - new_value = untag(the_value) - return new_value if new_value else obj - - def dumps(self, value): - return json.dumps(self._tag(value), separators=(',', ':')) - - def loads(self, value): - return json.loads(value, object_hook=self._untag) - - -session_json_serializer = TaggedJSONSerializer() - class SecureCookieSession(CallbackDict, SessionMixin): """Base class for sessions based on signed cookies.""" @@ -284,10 +160,10 @@ class SessionInterface(object): def get_cookie_domain(self, app): """Returns the domain that should be set for the session cookie. - + Uses ``SESSION_COOKIE_DOMAIN`` if it is configured, otherwise falls back to detecting the domain based on ``SERVER_NAME``. - + Once detected (or if not set at all), ``SESSION_COOKIE_DOMAIN`` is updated to avoid re-running the logic. """ @@ -377,7 +253,7 @@ class SessionInterface(object): has been modified, the cookie is set. If the session is permanent and the ``SESSION_REFRESH_EACH_REQUEST`` config is true, the cookie is always set. - + This check is usually skipped if the session was deleted. .. versionadded:: 0.11 @@ -404,6 +280,9 @@ class SessionInterface(object): raise NotImplementedError() +session_json_serializer = TaggedJSONSerializer() + + class SecureCookieSessionInterface(SessionInterface): """The default session interface that stores sessions in signed cookies through the :mod:`itsdangerous` module. From ca176cb903f9566be07e359791e364ce2fe0b1bc Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 2 Jun 2017 06:36:13 -0700 Subject: [PATCH 3/6] pass serializer at tag init instead of to each method split tagged dict and passthrough into separate cases add docstrings --- flask/json/tag.py | 201 ++++++++++++++++++++++++++++++---------------- 1 file changed, 133 insertions(+), 68 deletions(-) diff --git a/flask/json/tag.py b/flask/json/tag.py index 40594282..3cf32a0c 100644 --- a/flask/json/tag.py +++ b/flask/json/tag.py @@ -10,179 +10,244 @@ from flask.json import dumps, loads class JSONTag(object): - __slots__ = () + """Base class for defining type tags for :class:`TaggedJSONSerializer`.""" + + __slots__ = ('serializer',) key = None + """The tag to mark the serialized object with. If ``None``, this tag is + only used as an intermediate step during tagging.""" + + def __init__(self, serializer): + """Create a tagger for the given serializer.""" + + self.serializer = serializer + + def check(self, value): + """Check if the given value should be tagged by this tag.""" - def check(self, serializer, value): raise NotImplementedError - def to_json(self, serializer, value): + def to_json(self, value): + """Convert the Python object to an object that is a valid JSON type. + The tag will be added later.""" + raise NotImplementedError - def to_python(self, serializer, value): + def to_python(self, value): + """Convert the JSON representation back to the correct type. The tag + will already be removed.""" + raise NotImplementedError - def tag(self, serializer, value): - return {self.key: self.to_json(serializer, value)} + def tag(self, value): + """Convert the value to a valid JSON type and add the tag structure + around it.""" + + return {self.key: self.to_json(value)} class TagDict(JSONTag): - __slots__ = () + """Tag for 1-item dicts whose only key matches a registered tag. + + Internally, the dict key is suffixed with `__`, and the suffix is removed + when deserializing. + """ + + __slots__ = ('serializer',) key = ' di' - def check(self, serializer, value): - return isinstance(value, dict) + def check(self, value): + return ( + isinstance(value, dict) + and len(value) == 1 + and next(iter(value)) in self.serializer.tags + ) - def to_json(self, serializer, value, key=None): - if key is not None: - return {key + '__': serializer._tag(value[key])} + def to_json(self, value): + key = next(iter(value)) + return {key + '__': self.serializer.tag(value[key])} - return dict((k, serializer._tag(v)) for k, v in iteritems(value)) - - def to_python(self, serializer, value): + def to_python(self, value): key = next(iter(value)) return {key[:-2]: value[key]} - def tag(self, serializer, value): - if len(value) == 1: - key = next(iter(value)) - if key in serializer._tags: - return {self.key: self.to_json(serializer, value, key=key)} +class PassDict(JSONTag): + __slots__ = ('serializer',) - return self.to_json(serializer, value) + def check(self, value): + return isinstance(value, dict) + + def to_json(self, value): + # JSON objects may only have string keys, so don't bother tagging the + # key here. + return dict((k, self.serializer.tag(v)) for k, v in iteritems(value)) + + tag = to_json class TagTuple(JSONTag): - __slots__ = () + __slots__ = ('serializer',) key = ' t' - def check(self, serializer, value): + def check(self, value): return isinstance(value, tuple) - def to_json(self, serializer, value): - return [serializer._tag(item) for item in value] + def to_json(self, value): + return [self.serializer.tag(item) for item in value] - def to_python(self, serializer, value): + def to_python(self, value): return tuple(value) class PassList(JSONTag): - __slots__ = () + __slots__ = ('serializer',) - def check(self, serializer, value): + def check(self, value): return isinstance(value, list) - def to_json(self, serializer, value): - return [serializer._tag(item) for item in value] + def to_json(self, value): + return [self.serializer.tag(item) for item in value] tag = to_json class TagBytes(JSONTag): - __slots__ = () + __slots__ = ('serializer',) key = ' b' - def check(self, serializer, value): + def check(self, value): return isinstance(value, bytes) - def to_json(self, serializer, value): + def to_json(self, value): return b64encode(value).decode('ascii') - def to_python(self, serializer, value): + def to_python(self, value): return b64decode(value) class TagMarkup(JSONTag): - __slots__ = () + """Serialize anything matching the :class:`~markupsafe.Markup` API by + having a ``__html__`` method to the result of that method. Always + deserializes to an instance of :class:`~markupsafe.Markup`.""" + + __slots__ = ('serializer',) key = ' m' - def check(self, serializer, value): + def check(self, value): return callable(getattr(value, '__html__', None)) - def to_json(self, serializer, value): + def to_json(self, value): return text_type(value.__html__()) - def to_python(self, serializer, value): + def to_python(self, value): return Markup(value) class TagUUID(JSONTag): - __slots__ = () + __slots__ = ('serializer',) key = ' u' - def check(self, serializer, value): + def check(self, value): return isinstance(value, UUID) - def to_json(self, serializer, value): + def to_json(self, value): return value.hex - def to_python(self, serializer, value): + def to_python(self, value): return UUID(value) class TagDateTime(JSONTag): - __slots__ = () + __slots__ = ('serializer',) key = ' d' - def check(self, serializer, value): + def check(self, value): return isinstance(value, datetime) - def to_json(self, serializer, value): + def to_json(self, value): return http_date(value) - def to_python(self, serializer, value): + def to_python(self, value): return parse_date(value) class TaggedJSONSerializer(object): - __slots__ = ('_tags', '_order') - _default_tags = [ - TagDict(), TagTuple(), PassList(), TagBytes(), TagMarkup(), TagUUID(), - TagDateTime(), + """Serializer that uses a tag system to compactly represent objects that + are not JSON types. Passed as the intermediate serializer to + :class:`itsdangerous.Serializer`.""" + + __slots__ = ('tags', 'order') + default_tags = [ + TagDict, PassDict, TagTuple, PassList, TagBytes, TagMarkup, TagUUID, + TagDateTime, ] + """Tag classes to bind when creating the serializer. Other tags can be + added later using :meth:`~register`.""" def __init__(self): - self._tags = {} - self._order = [] + self.tags = {} + self.order = [] - for tag in self._default_tags: - self.register(tag) + for cls in self.default_tags: + self.register(cls) - def register(self, tag, force=False, index=-1): + def register(self, tag_class, force=False, index=-1): + """Register a new tag with this serializer. + + :param tag_class: tag class to register. Will be instantiated with this + serializer instance. + :param force: overwrite an existing tag. If false (default), a + :exc:`KeyError` is raised. + :param index: index to insert the new tag in the tag order. Useful when + the new tag is a special case of an existing tag. If -1 (default), + the tag is appended to the end of the order. + + :raise KeyError: if the tag key is already registered and ``force`` is + not true. + """ + + tag = tag_class(self) key = tag.key if key is not None: - if not force and key in self._tags: + if not force and key in self.tags: raise KeyError("Tag '{0}' is already registered.".format(key)) - self._tags[key] = tag + self.tags[key] = tag if index == -1: - self._order.append(tag) + self.order.append(tag) else: - self._order.insert(index, tag) + self.order.insert(index, tag) - def _tag(self, value): - for tag in self._order: - if tag.check(self, value): - return tag.tag(self, value) + def tag(self, value): + """Convert a value to a tagged representation if necessary.""" + + for tag in self.order: + if tag.check(value): + return tag.tag(value) return value - def _untag(self, value): + def untag(self, value): + """Convert a tagged representation back to the original type.""" + if len(value) != 1: return value key = next(iter(value)) - if key not in self._tags: + if key not in self.tags: return value - return self._tags[key].to_python(self, value[key]) + return self.tags[key].to_python(value[key]) def dumps(self, value): - return dumps(self._tag(value), separators=(',', ':')) + """Tag the value and dump it to a compact JSON string.""" + + return dumps(self.tag(value), separators=(',', ':')) def loads(self, value): - return loads(value, object_hook=self._untag) + """Load data from a JSON string and deserialized any tagged objects.""" + return loads(value, object_hook=self.untag) From bbd15d53ad129a2ef984fdf4dca1d93f2d913803 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 2 Jun 2017 06:49:55 -0700 Subject: [PATCH 4/6] docs style cleanup [ci skip] --- docs/api.rst | 27 +++++++++++++++------------ flask/json/tag.py | 35 +++++++++++++++++++---------------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index b5009907..1b0da45a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -171,18 +171,6 @@ implementation that Flask is using. .. autoclass:: SessionMixin :members: -.. autodata:: session_json_serializer - - This object provides dumping and loading methods similar to simplejson - but it also tags certain builtin Python objects that commonly appear in - sessions. Currently the following extended values are supported in - the JSON it dumps: - - - :class:`~markupsafe.Markup` objects - - :class:`~uuid.UUID` objects - - :class:`~datetime.datetime` objects - - :class:`tuple`\s - .. admonition:: Notice The ``PERMANENT_SESSION_LIFETIME`` config key can also be an integer @@ -354,6 +342,21 @@ you are using Flask 0.10 which implies that: .. autoclass:: JSONDecoder :members: +Tagged JSON +~~~~~~~~~~~ + +A compact representation for lossless serialization of non-standard JSON types. +:class:`~flask.sessions.SecureCookieSessionInterface` uses this to serialize +the session data, but it may be useful in other places. + +.. py:currentmodule:: flask.json.tag + +.. autoclass:: TaggedJSONSerializer + :members: + +.. autoclass:: JSONTag + :members: + Template Rendering ------------------ diff --git a/flask/json/tag.py b/flask/json/tag.py index 3cf32a0c..0ac583b5 100644 --- a/flask/json/tag.py +++ b/flask/json/tag.py @@ -13,36 +13,32 @@ class JSONTag(object): """Base class for defining type tags for :class:`TaggedJSONSerializer`.""" __slots__ = ('serializer',) + + #: The tag to mark the serialized object with. If ``None``, this tag is + #: only used as an intermediate step during tagging. key = None - """The tag to mark the serialized object with. If ``None``, this tag is - only used as an intermediate step during tagging.""" def __init__(self, serializer): """Create a tagger for the given serializer.""" - self.serializer = serializer def check(self, value): """Check if the given value should be tagged by this tag.""" - raise NotImplementedError def to_json(self, value): """Convert the Python object to an object that is a valid JSON type. The tag will be added later.""" - raise NotImplementedError def to_python(self, value): """Convert the JSON representation back to the correct type. The tag will already be removed.""" - raise NotImplementedError def tag(self, value): """Convert the value to a valid JSON type and add the tag structure around it.""" - return {self.key: self.to_json(value)} @@ -127,9 +123,9 @@ class TagBytes(JSONTag): class TagMarkup(JSONTag): - """Serialize anything matching the :class:`~markupsafe.Markup` API by + """Serialize anything matching the :class:`~flask.Markup` API by having a ``__html__`` method to the result of that method. Always - deserializes to an instance of :class:`~markupsafe.Markup`.""" + deserializes to an instance of :class:`~flask.Markup`.""" __slots__ = ('serializer',) key = ' m' @@ -175,15 +171,26 @@ class TagDateTime(JSONTag): class TaggedJSONSerializer(object): """Serializer that uses a tag system to compactly represent objects that are not JSON types. Passed as the intermediate serializer to - :class:`itsdangerous.Serializer`.""" + :class:`itsdangerous.Serializer`. + + The following extra types are supported: + + * :class:`dict` + * :class:`tuple` + * :class:`bytes` + * :class:`~flask.Markup` + * :class:`~uuid.UUID` + * :class:`~datetime.datetime` + """ __slots__ = ('tags', 'order') + + #: Tag classes to bind when creating the serializer. Other tags can be + #: added later using :meth:`~register`. default_tags = [ TagDict, PassDict, TagTuple, PassList, TagBytes, TagMarkup, TagUUID, TagDateTime, ] - """Tag classes to bind when creating the serializer. Other tags can be - added later using :meth:`~register`.""" def __init__(self): self.tags = {} @@ -206,7 +213,6 @@ class TaggedJSONSerializer(object): :raise KeyError: if the tag key is already registered and ``force`` is not true. """ - tag = tag_class(self) key = tag.key @@ -223,7 +229,6 @@ class TaggedJSONSerializer(object): def tag(self, value): """Convert a value to a tagged representation if necessary.""" - for tag in self.order: if tag.check(value): return tag.tag(value) @@ -232,7 +237,6 @@ class TaggedJSONSerializer(object): def untag(self, value): """Convert a tagged representation back to the original type.""" - if len(value) != 1: return value @@ -245,7 +249,6 @@ class TaggedJSONSerializer(object): def dumps(self, value): """Tag the value and dump it to a compact JSON string.""" - return dumps(self.tag(value), separators=(',', ':')) def loads(self, value): From 9bee2500dde2976befd7e88de5d403a26d7ad675 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 2 Jun 2017 08:09:37 -0700 Subject: [PATCH 5/6] finish documentation [ci skip] --- docs/api.rst | 15 +------------ flask/json/tag.py | 57 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 1b0da45a..fe4f151f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -342,20 +342,7 @@ you are using Flask 0.10 which implies that: .. autoclass:: JSONDecoder :members: -Tagged JSON -~~~~~~~~~~~ - -A compact representation for lossless serialization of non-standard JSON types. -:class:`~flask.sessions.SecureCookieSessionInterface` uses this to serialize -the session data, but it may be useful in other places. - -.. py:currentmodule:: flask.json.tag - -.. autoclass:: TaggedJSONSerializer - :members: - -.. autoclass:: JSONTag - :members: +.. automodule:: flask.json.tag Template Rendering ------------------ diff --git a/flask/json/tag.py b/flask/json/tag.py index 0ac583b5..3c57884e 100644 --- a/flask/json/tag.py +++ b/flask/json/tag.py @@ -1,3 +1,44 @@ +""" +Tagged JSON +~~~~~~~~~~~ + +A compact representation for lossless serialization of non-standard JSON types. +:class:`~flask.sessions.SecureCookieSessionInterface` uses this to serialize +the session data, but it may be useful in other places. It can be extended to +support other types. + +.. autoclass:: TaggedJSONSerializer + :members: + +.. autoclass:: JSONTag + :members: + +Let's seen an example that adds support for :class:`~collections.OrderedDict`. +Dicts don't have an order in Python or JSON, so to handle this we will dump +the items as a list of ``[key, value]`` pairs. Subclass :class:`JSONTag` and +give it the new key ``' od'`` to identify the type. The session serializer +processes dicts first, so insert the new tag at the front of the order since +``OrderedDict`` must be processed before ``dict``. :: + + from flask.json.tag import JSONTag + + class TagOrderedDict(JSONTag): + __slots__ = ('serializer',) + key = ' od' + + def check(self, value): + return isinstance(value, OrderedDict) + + def to_json(self, value): + return [[k, self.serializer.tag(v)] for k, v in iteritems(value)] + + def to_python(self, value): + return OrderedDict(value) + + app.session_interface.serializer.register(TagOrderedDict, 0) + +""" + from base64 import b64decode, b64encode from datetime import datetime from uuid import UUID @@ -49,7 +90,7 @@ class TagDict(JSONTag): when deserializing. """ - __slots__ = ('serializer',) + __slots__ = () key = ' di' def check(self, value): @@ -69,7 +110,7 @@ class TagDict(JSONTag): class PassDict(JSONTag): - __slots__ = ('serializer',) + __slots__ = () def check(self, value): return isinstance(value, dict) @@ -83,7 +124,7 @@ class PassDict(JSONTag): class TagTuple(JSONTag): - __slots__ = ('serializer',) + __slots__ = () key = ' t' def check(self, value): @@ -97,7 +138,7 @@ class TagTuple(JSONTag): class PassList(JSONTag): - __slots__ = ('serializer',) + __slots__ = () def check(self, value): return isinstance(value, list) @@ -109,7 +150,7 @@ class PassList(JSONTag): class TagBytes(JSONTag): - __slots__ = ('serializer',) + __slots__ = () key = ' b' def check(self, value): @@ -127,7 +168,7 @@ class TagMarkup(JSONTag): having a ``__html__`` method to the result of that method. Always deserializes to an instance of :class:`~flask.Markup`.""" - __slots__ = ('serializer',) + __slots__ = () key = ' m' def check(self, value): @@ -141,7 +182,7 @@ class TagMarkup(JSONTag): class TagUUID(JSONTag): - __slots__ = ('serializer',) + __slots__ = () key = ' u' def check(self, value): @@ -155,7 +196,7 @@ class TagUUID(JSONTag): class TagDateTime(JSONTag): - __slots__ = ('serializer',) + __slots__ = () key = ' d' def check(self, value): From fd8b95952c3891c402b18d1d35c6a2d263b8c6fb Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 2 Jun 2017 10:01:30 -0700 Subject: [PATCH 6/6] add tests for flask.json.tag --- tests/test_basic.py | 47 ++++++++++++++---------------- tests/test_json_tag.py | 65 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 25 deletions(-) create mode 100644 tests/test_json_tag.py diff --git a/tests/test_basic.py b/tests/test_basic.py index e4f68832..108c1409 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -435,34 +435,31 @@ def test_session_special_types(app, client): now = datetime.utcnow().replace(microsecond=0) the_uuid = uuid.uuid4() - @app.after_request - def modify_session(response): - flask.session['m'] = flask.Markup('Hello!') - flask.session['u'] = the_uuid - flask.session['dt'] = now - flask.session['b'] = b'\xff' - flask.session['t'] = (1, 2, 3) - flask.session['spacefirst'] = {' t': 'not-a-tuple'} - flask.session['withunderscores'] = {' t__': 'not-a-tuple'} - flask.session['notadict'] = {' di': 'not-a-dict'} - return response - @app.route('/') def dump_session_contents(): - return pickle.dumps(dict(flask.session)) + flask.session['t'] = (1, 2, 3) + flask.session['b'] = b'\xff' + flask.session['m'] = flask.Markup('') + flask.session['u'] = the_uuid + flask.session['d'] = now + flask.session['t_tag'] = {' t': 'not-a-tuple'} + flask.session['di_t_tag'] = {' t__': 'not-a-tuple'} + flask.session['di_tag'] = {' di': 'not-a-dict'} + return '', 204 - client.get('/') - rv = pickle.loads(client.get('/').data) - assert rv['m'] == flask.Markup('Hello!') - assert type(rv['m']) == flask.Markup - assert rv['dt'] == now - assert rv['u'] == the_uuid - assert rv['b'] == b'\xff' - assert type(rv['b']) == bytes - assert rv['t'] == (1, 2, 3) - assert rv['spacefirst'] == {' t': 'not-a-tuple'} - assert rv['withunderscores'] == {' t__': 'not-a-tuple'} - assert rv['notadict'] == {' di': 'not-a-dict'} + with client: + client.get('/') + s = flask.session + assert s['t'] == (1, 2, 3) + assert type(s['b']) == bytes + assert s['b'] == b'\xff' + assert type(s['m']) == flask.Markup + assert s['m'] == flask.Markup('') + assert s['u'] == the_uuid + assert s['d'] == now + assert s['t_tag'] == {' t': 'not-a-tuple'} + assert s['di_t_tag'] == {' t__': 'not-a-tuple'} + assert s['di_tag'] == {' di': 'not-a-dict'} def test_session_cookie_setting(app): diff --git a/tests/test_json_tag.py b/tests/test_json_tag.py new file mode 100644 index 00000000..b8cb6550 --- /dev/null +++ b/tests/test_json_tag.py @@ -0,0 +1,65 @@ +from datetime import datetime +from uuid import uuid4 + +import pytest + +from flask import Markup +from flask.json.tag import TaggedJSONSerializer, JSONTag + + +@pytest.mark.parametrize("data", ( + {' t': (1, 2, 3)}, + {' t__': b'a'}, + {' di': ' di'}, + {'x': (1, 2, 3), 'y': 4}, + (1, 2, 3), + [(1, 2, 3)], + b'\xff', + Markup(''), + uuid4(), + datetime.utcnow().replace(microsecond=0), +)) +def test_dump_load_unchanged(data): + s = TaggedJSONSerializer() + assert s.loads(s.dumps(data)) == data + + +def test_duplicate_tag(): + class TagDict(JSONTag): + key = ' d' + + s = TaggedJSONSerializer() + pytest.raises(KeyError, s.register, TagDict) + s.register(TagDict, force=True, index=0) + assert isinstance(s.tags[' d'], TagDict) + assert isinstance(s.order[0], TagDict) + + +def test_custom_tag(): + class Foo(object): + def __init__(self, data): + self.data = data + + class TagFoo(JSONTag): + __slots__ = () + key = ' f' + + def check(self, value): + return isinstance(value, Foo) + + def to_json(self, value): + return self.serializer.tag(value.data) + + def to_python(self, value): + return Foo(value) + + s = TaggedJSONSerializer() + s.register(TagFoo) + assert s.loads(s.dumps(Foo('bar'))).data == 'bar' + + +def test_tag_interface(): + t = JSONTag(None) + pytest.raises(NotImplementedError, t.check, None) + pytest.raises(NotImplementedError, t.to_json, None) + pytest.raises(NotImplementedError, t.to_python, None)