diff options
author | Jordan Cook <jordan.cook@pioneer.com> | 2021-06-22 13:05:56 -0500 |
---|---|---|
committer | Jordan Cook <jordan.cook@pioneer.com> | 2021-07-03 15:44:10 -0500 |
commit | a08c39ad6b213eb379df5872de0a695348c82542 (patch) | |
tree | 6ad91cb2aaf45d4b62c89e2c33fb3b060b42ce77 /requests_cache/serializers | |
parent | c4b9e4d4dcad5470de4a30464a6ac8a875615ad9 (diff) | |
download | requests-cache-a08c39ad6b213eb379df5872de0a695348c82542.tar.gz |
Some serialization fixes & updates:
* Fix tests on python 3.6:
* Make `cattrs` optional again
* Don't run tests for serializers with missing optional dependencies
* Show any skipped tests in pytest output
* Fix redirect serialization for backends that serialize all values (DynamoDB and Redis)
* Otherwise, the redirect value (which is just another key) will get converted into a `CachedResponse`
* Make `pickle` serializer use `cattrs` if installed
* Make `bson` serializer compatible with both `pymongo` version and standalone `bson` library
* Split up `CattrStage` and preconf converters into separate modules
* Turn preconf converters into `Stage` objects
* Add `DeprecationWarning` for previous method of using `itsdangerous`, now that there's a better way to initialize it via `SerializerPipeline`
* Remove `suppress_warnings` kwarg that's now unused
* Make `SerializerPipeline`, `Stage`, and `CattrStage` importable from top-level package (`from requests_cache import ...`)
* Add some more details to docs and docstrings
Diffstat (limited to 'requests_cache/serializers')
-rw-r--r-- | requests_cache/serializers/__init__.py | 93 | ||||
-rw-r--r-- | requests_cache/serializers/cattrs.py | 74 | ||||
-rw-r--r-- | requests_cache/serializers/pipeline.py | 5 | ||||
-rw-r--r-- | requests_cache/serializers/preconf.py | 140 |
4 files changed, 183 insertions, 129 deletions
diff --git a/requests_cache/serializers/__init__.py b/requests_cache/serializers/__init__.py index ebf5d15..0c07a77 100644 --- a/requests_cache/serializers/__init__.py +++ b/requests_cache/serializers/__init__.py @@ -1,72 +1,49 @@ +# flake8: noqa: F401 import pickle +from warnings import warn from .. import get_placeholder_class -from . import preconf from .pipeline import SerializerPipeline, Stage -pickle_serializer = pickle - -try: - from itsdangerous import Signer - - def safe_pickle_serializer(secret_key=None, salt="requests-cache", **kwargs): - return SerializerPipeline( - [ - pickle_serializer, - Stage(Signer(secret_key=secret_key, salt=salt), dumps='sign', loads='unsign'), - ], - ) - - -except ImportError as e: - safe_pickle_serializer = get_placeholder_class(e) - - -try: - import ujson as json - - json_serializer = SerializerPipeline( - [ - preconf.ujson_converter, # CachedResponse -> JSON - json, # JSON -> str - ], - ) -except ImportError: - import json - - json_serializer = SerializerPipeline( - [ - preconf.json_converter, # CachedResponse -> JSON - json, # JSON -> str - ], - ) - +# If cattrs isn't installed, use plain pickle for pickle_serializer, and placeholders for the rest. +# Additional checks for format-specific optional libraries are handled in the preconf module. try: - import bson.json_util - - bson_serializer = SerializerPipeline( - [ - preconf.bson_converter, # CachedResponse -> BSON - bson.json_util, # BSON -> str - ], + from .cattrs import CattrStage + from .preconf import ( + bson_serializer, + json_serializer, + pickle_serializer, + safe_pickle_serializer, + yaml_serializer, ) except ImportError as e: + CattrStage = get_placeholder_class(e) bson_serializer = get_placeholder_class(e) - -try: - import yaml - - yaml_serializer = SerializerPipeline( - [preconf.pyyaml_converter, Stage(yaml, loads='load', dumps='dump')] - ) -except ImportError as e: + json_serializer = get_placeholder_class(e) + pickle_serializer = pickle + safe_pickle_serializer = get_placeholder_class(e) yaml_serializer = get_placeholder_class(e) SERIALIZERS = { - "pickle": pickle_serializer, - "safe_pickle": safe_pickle_serializer, - "json": json_serializer, - "bson": bson_serializer, - "yaml": yaml_serializer, + 'bson': bson_serializer, + 'json': json_serializer, + 'pickle': pickle_serializer, + 'yaml': yaml_serializer, } + + +def init_serializer(serializer=None, **kwargs): + """Initialize a serializer from a name, class, or instance""" + serializer = serializer or 'pickle' + # Backwards=compatibility with 0.6; will be removed in 0.8 + if serializer == 'safe_pickle' or (serializer == 'pickle' and 'secret_key' in kwargs): + serializer = safe_pickle_serializer(**kwargs) + msg = ( + 'Please initialize with safe_pickle_serializer(secret_key) instead. ' + 'This usage is deprecated and will be removed in a future version.' + ) + warn(DeprecationWarning(msg)) + elif isinstance(serializer, str): + serializer = SERIALIZERS[serializer] + return serializer diff --git a/requests_cache/serializers/cattrs.py b/requests_cache/serializers/cattrs.py new file mode 100644 index 0000000..5aa5951 --- /dev/null +++ b/requests_cache/serializers/cattrs.py @@ -0,0 +1,74 @@ +from datetime import datetime, timedelta +from typing import Callable, Dict, ForwardRef, MutableMapping + +from cattr import GenConverter +from requests.cookies import RequestsCookieJar, cookiejar_from_dict +from requests.structures import CaseInsensitiveDict +from urllib3.response import HTTPHeaderDict + +from ..models import CachedResponse +from .pipeline import Stage + + +class CattrStage(Stage): + """Base serializer class for :py:class:`.CachedResponse` that does pre/post-processing with + ``cattrs``. This does the majority of the work needed for any other serialization format, + breaking down objects into python builtin types. + + This can be used as a stage within a :py:class:`.SerializerPipeline`. Requires python 3.7+. + """ + + def __init__(self, factory: Callable[..., GenConverter] = None): + self.converter = init_converter(factory) + + def dumps(self, value: CachedResponse) -> Dict: + if not isinstance(value, CachedResponse): + return value + return self.converter.unstructure(value) + + def loads(self, value: Dict) -> CachedResponse: + if not isinstance(value, MutableMapping): + return value + return self.converter.structure(value, cl=CachedResponse) + + +def init_converter(factory: Callable[..., GenConverter] = None): + """Make a converter to structure and unstructure nested objects within a :py:class:`.CachedResponse`""" + factory = factory or GenConverter + converter = factory(omit_if_default=True) + + # Convert datetimes to and from iso-formatted strings + converter.register_unstructure_hook(datetime, lambda obj: obj.isoformat() if obj else None) + converter.register_structure_hook(datetime, to_datetime) + + # Convert timedeltas to and from float values in seconds + converter.register_unstructure_hook(timedelta, lambda obj: obj.total_seconds() if obj else None) + converter.register_structure_hook(timedelta, to_timedelta) + + # Convert dict-like objects to and from plain dicts + converter.register_unstructure_hook(RequestsCookieJar, lambda obj: dict(obj.items())) + converter.register_structure_hook(RequestsCookieJar, lambda obj, cls: cookiejar_from_dict(obj)) + converter.register_unstructure_hook(CaseInsensitiveDict, dict) + converter.register_structure_hook(CaseInsensitiveDict, lambda obj, cls: CaseInsensitiveDict(obj)) + converter.register_unstructure_hook(HTTPHeaderDict, dict) + converter.register_structure_hook(HTTPHeaderDict, lambda obj, cls: HTTPHeaderDict(obj)) + + # Tell cattrs that a 'CachedResponse' forward ref is equivalent to the CachedResponse class + converter.register_structure_hook( + ForwardRef('CachedResponse'), + lambda obj, cls: converter.structure(obj, CachedResponse), + ) + + return converter + + +def to_datetime(obj, cls) -> datetime: + if isinstance(obj, str): + obj = datetime.fromisoformat(obj) + return obj + + +def to_timedelta(obj, cls) -> timedelta: + if isinstance(obj, (int, float)): + obj = timedelta(seconds=obj) + return obj diff --git a/requests_cache/serializers/pipeline.py b/requests_cache/serializers/pipeline.py index 9cd2fcf..e16799f 100644 --- a/requests_cache/serializers/pipeline.py +++ b/requests_cache/serializers/pipeline.py @@ -4,8 +4,7 @@ from ..models import CachedResponse class Stage: - # Generic utility class for aliasing dumps and loads to - # other methods + """Generic class to wrap serialization steps with consistent ``dumps()`` and ``loads()`` methods""" def __init__(self, obj: Any, dumps: str = "dumps", loads: str = "loads"): self.obj = obj @@ -14,6 +13,8 @@ class Stage: class SerializerPipeline: + """A sequence of steps used to serialize and deserialize response objects""" + def __init__(self, steps: List): self.steps = steps self.dump_steps = [step.dumps for step in steps] diff --git a/requests_cache/serializers/preconf.py b/requests_cache/serializers/preconf.py index b044f27..8a15902 100644 --- a/requests_cache/serializers/preconf.py +++ b/requests_cache/serializers/preconf.py @@ -1,81 +1,83 @@ -import datetime -from functools import partial +"""The ``cattrs`` library includes a number of pre-configured converters that perform some +additional steps required for specific serialization formats. -from requests.cookies import RequestsCookieJar, cookiejar_from_dict -from requests.structures import CaseInsensitiveDict -from urllib3.response import HTTPHeaderDict +This module wraps those converters as serializer :py:class:`.Stage` objects. These are then used as +a stage in a :py:class:`.SerializerPipeline`, which runs after the base converter and before the +format's ``dumps()`` (or equivalent) method. + +For any optional libraries that aren't installed, the corresponding serializer will be a placeholder +class that raises an ``ImportError`` at initialization time instead of at import time. + +Requires python 3.7+. +""" +import pickle + +from cattr.preconf import bson, json, msgpack, orjson, pyyaml, tomlkit, ujson from .. import get_placeholder_class -from ..models import CachedResponse -from .pipeline import Stage +from .cattrs import CattrStage +from .pipeline import SerializerPipeline, Stage -try: - from typing import ForwardRef +base_stage = CattrStage() +bson_preconf_stage = CattrStage(bson.make_converter) +json_preconf_stage = CattrStage(json.make_converter) +msgpack_preconf_stage = CattrStage(msgpack.make_converter) +orjson_preconf_stage = CattrStage(orjson.make_converter) +yaml_preconf_stage = CattrStage(pyyaml.make_converter) +toml_preconf_stage = CattrStage(tomlkit.make_converter) +ujson_preconf_stage = CattrStage(ujson.make_converter) - from cattr import GenConverter - from cattr.preconf import bson, json, msgpack, orjson, pyyaml, tomlkit, ujson - def to_datetime(obj, cls) -> datetime: - if isinstance(obj, str): - obj = datetime.fromisoformat(obj) - return obj +# Pickle serializer that uses the cattrs base converter +pickle_serializer = SerializerPipeline([base_stage, pickle]) - def to_timedelta(obj, cls) -> datetime.timedelta: - if isinstance(obj, (int, float)): - obj = datetime.timedelta(seconds=obj) - return obj +# Pickle serializer with an additional stage using itsdangerous +try: + from itsdangerous import Signer - class CattrsStage(Stage): - def __init__(self, converter, *args, **kwargs): - super().__init__(converter, *args, **kwargs) - self.loads = partial(converter.structure, cl=CachedResponse) + def signer_stage(secret_key=None, salt='requests-cache'): + return Stage(Signer(secret_key=secret_key, salt=salt), dumps='sign', loads='unsign') - def init_converter(factory: GenConverter = None): - """Make a converter to structure and unstructure some of the nested objects within a response, - if cattrs is installed. + def safe_pickle_serializer(secret_key=None, salt='requests-cache', **kwargs): + """Create a serializer that uses ``itsdangerous`` to add a signature to responses during + writes, and validate that signature with a secret key during reads. """ - converter = factory(omit_if_default=True) - - # Convert datetimes to and from iso-formatted strings - converter.register_unstructure_hook(datetime, lambda obj: obj.isoformat() if obj else None) - converter.register_structure_hook(datetime, to_datetime) - - # Convert timedeltas to and from float values in seconds - converter.register_unstructure_hook( - datetime.timedelta, lambda obj: obj.total_seconds() if obj else None - ) - converter.register_structure_hook(datetime.timedelta, to_timedelta) - - # Convert dict-like objects to and from plain dicts - converter.register_unstructure_hook(RequestsCookieJar, lambda obj: dict(obj.items())) - converter.register_structure_hook(RequestsCookieJar, lambda obj, cls: cookiejar_from_dict(obj)) - converter.register_unstructure_hook(CaseInsensitiveDict, dict) - converter.register_structure_hook(CaseInsensitiveDict, lambda obj, cls: CaseInsensitiveDict(obj)) - converter.register_unstructure_hook(HTTPHeaderDict, dict) - converter.register_structure_hook(HTTPHeaderDict, lambda obj, cls: HTTPHeaderDict(obj)) - - # Tell cattrs that a 'CachedResponse' forward ref is equivalent to the CachedResponse class - converter.register_structure_hook( - ForwardRef('CachedResponse'), - lambda obj, cls: converter.structure(obj, CachedResponse), - ) - converter = CattrsStage(converter, dumps='unstructure', loads='structure') - - return converter - - bson_converter = init_converter(bson.make_converter) - json_converter = init_converter(json.make_converter) - msgpack_converter = init_converter(msgpack.make_converter) - orjson_converter = init_converter(orjson.make_converter) - pyyaml_converter = init_converter(pyyaml.make_converter) - tomlkit_converter = init_converter(tomlkit.make_converter) - ujson_converter = init_converter(ujson.make_converter) + return SerializerPipeline([base_stage, pickle, signer_stage(secret_key, salt)]) + + +except ImportError as e: + signer_stage = get_placeholder_class(e) + safe_pickle_serializer = get_placeholder_class(e) + +# BSON serializer using either PyMongo's bson.json_util if installed, otherwise standalone bson codec +try: + try: + from bson import json_util as bson + except ImportError: + import bson + + bson_serializer = SerializerPipeline([bson_preconf_stage, bson]) +except ImportError as e: + bson_serializer = get_placeholder_class(e) + +# JSON serailizer using ultrajson if installed, otherwise stdlib json +try: + import ujson as json + + converter = ujson_preconf_stage +except ImportError: + import json + + converter = json_preconf_stage + +json_serializer = SerializerPipeline([converter, json]) + +# YAML serializer using pyyaml +try: + import yaml + yaml_serializer = SerializerPipeline( + [yaml_preconf_stage, Stage(yaml, loads='safe_load', dumps='safe_dump')] + ) except ImportError as e: - bson_converter = get_placeholder_class(e) - json_converter = get_placeholder_class(e) - msgpack_converter = get_placeholder_class(e) - orjson_converter = get_placeholder_class(e) - pyyaml_converter = get_placeholder_class(e) - tomlkit_converter = get_placeholder_class(e) - ujson_converter = get_placeholder_class(e) + yaml_serializer = get_placeholder_class(e) |