diff --git a/.gitignore b/.gitignore index 6190c0ad..8a3d9f01 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,8 @@ package.json # Test / coverage reports .coverage -.tox +.coverage.* +.nox .mypy_cache/ test-reports/ diff --git a/HISTORY.md b/HISTORY.md index 3ffab9c9..9b64f5f5 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,7 @@ ## 0.9.5 (Unreleased) * Fix usage of memory backend with `install_cache()` +* Add compatibility with cattrs 22.1 ## 0.9.4 (2022-04-22) * Fix forwarding connection parameters passed to `RedisCache` for redis-py 4.2 and python <=3.8 diff --git a/poetry.lock b/poetry.lock index 23e089be..ed0e7a7d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -133,7 +133,7 @@ six = ">=1.9.0" [[package]] name = "cattrs" -version = "1.10.0" +version = "22.1.0" description = "Composable complex class support for attrs and dataclasses." category = "main" optional = false @@ -141,6 +141,7 @@ python-versions = ">=3.7,<4.0" [package.dependencies] attrs = ">=20" +exceptiongroup = {version = "*", markers = "python_version <= \"3.10\""} typing_extensions = {version = "*", markers = "python_version >= \"3.7\" and python_version < \"3.8\""} [[package]] @@ -247,6 +248,17 @@ category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "exceptiongroup" +version = "1.0.0rc5" +description = "Backport of PEP 654 (exception groups)" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "execnet" version = "1.9.0" @@ -1242,7 +1254,7 @@ yaml = ["pyyaml"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "920beed174831845dc1e2470962a46da02dde19c5ea95038fbbe12da1a6f3dec" +content-hash = "33a291d3df252b6316184384e7db5e5b20b0670cf7910701ddb4f4e2057e5b2b" [metadata.files] alabaster = [ @@ -1289,8 +1301,8 @@ bson = [ {file = "bson-0.5.10.tar.gz", hash = "sha256:d6511b2ab051139a9123c184de1a04227262173ad593429d21e443d6462d6590"}, ] cattrs = [ - {file = "cattrs-1.10.0-py3-none-any.whl", hash = "sha256:35dd9063244263e63bd0bd24ea61e3015b00272cead084b2c40d788b0f857c46"}, - {file = "cattrs-1.10.0.tar.gz", hash = "sha256:211800f725cdecedcbcf4c753bbd22d248312b37d130f06045434acb7d9b34e1"}, + {file = "cattrs-22.1.0-py3-none-any.whl", hash = "sha256:d55c477b4672f93606e992049f15d526dc7867e6c756cd6256d4af92e2b1e364"}, + {file = "cattrs-22.1.0.tar.gz", hash = "sha256:94b67b64cf92c994f8784c40c082177dc916e0489a73a9a36b24eb18a9db40c6"}, ] certifi = [ {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, @@ -1371,6 +1383,10 @@ docutils = [ {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, ] +exceptiongroup = [ + {file = "exceptiongroup-1.0.0rc5-py3-none-any.whl", hash = "sha256:295a9d7847f9ad08267f47c701a676ec70a64200a360dd49eb513f72209b09f4"}, + {file = "exceptiongroup-1.0.0rc5.tar.gz", hash = "sha256:665422550b9653acd46e9cd35d933f28c5158ca4c058c53619cfa112915cd69e"}, +] execnet = [ {file = "execnet-1.9.0-py2.py3-none-any.whl", hash = "sha256:a295f7cc774947aac58dde7fdc85f4aa00c42adf5d8f5468fc630c1acf30a142"}, {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"}, diff --git a/pyproject.toml b/pyproject.toml index aabf2252..623e3709 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ requests = "^2.22" # Needs no introduction urllib3 = "^1.25.5" # Use a slightly newer version than required by requests (for bugfixes) appdirs = "^1.4.4" # For options that use platform-specific user cache dirs attrs = "^21.2" # For response data models -cattrs = "^1.8" # For response serialization +cattrs = ">=1.8,<=22.2" # For response serialization url-normalize = "^1.4" # For improved request matching # Optional backend dependencies @@ -62,6 +62,9 @@ sphinx-notfound-page = {optional=true, version="*"} sphinx-panels = {optional=true, version="^0.6"} sphinxcontrib-apidoc = {optional=true, version="^0.3"} +# Temporary fix until cattrs 22.2.0 is released +exceptiongroup = {version = ">=1.0.0-rc.3", python = "^3.10.0"} + [tool.poetry.extras] # Package extras for optional backend dependencies dynamodb = ["boto3", "botocore"] diff --git a/requests_cache/serializers/preconf.py b/requests_cache/serializers/preconf.py index ed19fb41..8c55c4fb 100644 --- a/requests_cache/serializers/preconf.py +++ b/requests_cache/serializers/preconf.py @@ -1,9 +1,10 @@ +# flake8: noqa: F841 """The ``cattrs`` library includes a number of `pre-configured converters `_ that perform some pre-serialization steps required for specific serialization formats. 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 +stages 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 @@ -13,70 +14,95 @@ class that raises an ``ImportError`` at initialization time instead of at import :nosignatures: """ import pickle +from datetime import timedelta +from decimal import Decimal from functools import partial +from importlib import import_module -from cattr.preconf import bson as bson_preconf -from cattr.preconf import json as json_preconf -from cattr.preconf import msgpack, orjson, pyyaml, tomlkit, ujson +from cattr import GenConverter from .._utils import get_placeholder_class from .cattrs import CattrStage from .pipeline import SerializerPipeline, Stage -base_stage = ( - CattrStage() -) #: Base stage for all serializer pipelines (or standalone dict serializer) -dict_serializer = base_stage #: Partial serializer that unstructures responses into dicts -bson_preconf_stage = CattrStage(bson_preconf.make_converter) #: Pre-serialization steps for BSON -json_preconf_stage = CattrStage(json_preconf.make_converter) #: Pre-serialization steps for JSON -msgpack_preconf_stage = CattrStage(msgpack.make_converter) #: Pre-serialization steps for msgpack -orjson_preconf_stage = CattrStage(orjson.make_converter) #: Pre-serialization steps for orjson -yaml_preconf_stage = CattrStage(pyyaml.make_converter) #: Pre-serialization steps for YAML -toml_preconf_stage = CattrStage(tomlkit.make_converter) #: Pre-serialization steps for TOML -ujson_preconf_stage = CattrStage(ujson.make_converter) #: Pre-serialization steps for ultrajson -pickle_serializer = SerializerPipeline( - [base_stage, pickle], is_binary=True -) #: Complete pickle serializer -utf8_encoder = Stage(dumps=str.encode, loads=lambda x: x.decode()) #: Encode to bytes +def make_stage(preconf_module: str, **kwargs): + """Create a preconf serializer stage from a module name, if dependencies are installed""" + try: + factory = import_module(preconf_module).make_converter + return CattrStage(factory, **kwargs) + except ImportError as e: + return get_placeholder_class(e) + + +# Pre-serialization stages +base_stage = CattrStage() #: Base stage for all serializer pipelines +utf8_encoder = Stage(dumps=str.encode, loads=lambda x: x.decode()) #: Encode to bytes +bson_preconf_stage = make_stage('cattr.preconf.bson') #: Pre-serialization steps for BSON +json_preconf_stage = make_stage('cattr.preconf.json') #: Pre-serialization steps for JSON +msgpack_preconf_stage = make_stage('cattr.preconf.msgpack') #: Pre-serialization steps for msgpack +orjson_preconf_stage = make_stage('cattr.preconf.orjson') #: Pre-serialization steps for orjson +toml_preconf_stage = make_stage('cattr.preconf.tomlkit') #: Pre-serialization steps for TOML +ujson_preconf_stage = make_stage('cattr.preconf.ujson') #: Pre-serialization steps for ultrajson +yaml_preconf_stage = make_stage('cattr.preconf.pyyaml') #: Pre-serialization steps for YAML + +# Basic serializers with no additional dependencies +dict_serializer = SerializerPipeline( + [base_stage], is_binary=False +) #: Partial serializer that unstructures responses into dicts +pickle_serializer = SerializerPipeline( + [base_stage, Stage(pickle)], is_binary=True +) #: Pickle serializer # Safe pickle serializer -try: +def signer_stage(secret_key=None, salt='requests-cache') -> Stage: + """Create a stage that uses ``itsdangerous`` to add a signature to responses on write, and + validate that signature with a secret key on read. Can be used in a + :py:class:`.SerializerPipeline` in combination with any other serialization steps. + """ from itsdangerous import Signer - def signer_stage(secret_key=None, salt='requests-cache') -> Stage: - """Create a stage that uses ``itsdangerous`` to add a signature to responses on write, and - validate that signature with a secret key on read. Can be used in a - :py:class:`.SerializerPipeline` in combination with any other serialization steps. - """ - return Stage(Signer(secret_key=secret_key, salt=salt), dumps='sign', loads='unsign') - - def safe_pickle_serializer( - secret_key=None, salt='requests-cache', **kwargs - ) -> SerializerPipeline: - """Create a serializer that uses ``pickle`` + ``itsdangerous`` to add a signature to - responses on write, and validate that signature with a secret key on read. - """ - return SerializerPipeline( - [base_stage, pickle, signer_stage(secret_key, salt)], is_binary=True - ) + return Stage( + Signer(secret_key=secret_key, salt=salt), + dumps='sign', + loads='unsign', + ) + +def safe_pickle_serializer(secret_key=None, salt='requests-cache', **kwargs) -> SerializerPipeline: + """Create a serializer that uses ``pickle`` + ``itsdangerous`` to add a signature to + responses on write, and validate that signature with a secret key on read. + """ + return SerializerPipeline( + [base_stage, Stage(pickle), signer_stage(secret_key, salt)], + is_binary=True, + ) + + +try: + import itsdangerous # noqa: F401 except ImportError as e: signer_stage = get_placeholder_class(e) safe_pickle_serializer = get_placeholder_class(e) # BSON serializer -try: +def _get_bson_functions(): + """Handle different function names between pymongo's bson and standalone bson""" try: - from bson import decode as _bson_loads - from bson import encode as _bson_dumps + import pymongo # noqa: F401 + + return {'dumps': 'encode', 'loads': 'decode'} except ImportError: - from bson import dumps as _bson_dumps - from bson import loads as _bson_loads + return {'dumps': 'dumps', 'loads': 'loads'} + + +try: + import bson bson_serializer = SerializerPipeline( - [bson_preconf_stage, Stage(dumps=_bson_dumps, loads=_bson_loads)], is_binary=True + [bson_preconf_stage, Stage(bson, **_get_bson_functions())], + is_binary=True, ) #: Complete BSON serializer; uses pymongo's ``bson`` if installed, otherwise standalone ``bson`` codec except ImportError as e: bson_serializer = get_placeholder_class(e) @@ -94,7 +120,8 @@ def safe_pickle_serializer( _json_stage = Stage(dumps=partial(json.dumps, indent=2), loads=json.loads) json_serializer = SerializerPipeline( - [_json_preconf_stage, _json_stage], is_binary=False + [_json_preconf_stage, _json_stage], + is_binary=False, ) #: Complete JSON serializer; uses ultrajson if available @@ -102,11 +129,9 @@ def safe_pickle_serializer( try: import yaml + _yaml_stage = Stage(yaml, loads='safe_load', dumps='safe_dump') yaml_serializer = SerializerPipeline( - [ - yaml_preconf_stage, - Stage(yaml, loads='safe_load', dumps='safe_dump'), - ], + [yaml_preconf_stage, _yaml_stage], is_binary=False, ) #: Complete YAML serializer except ImportError as e: