From 013be85f97ae696b97d1d348e4f07d19fda86228 Mon Sep 17 00:00:00 2001 From: Martijn Pieters Date: Tue, 9 May 2023 15:30:46 +0100 Subject: [PATCH] Typing cleanup - Compatibility with older Python versions - use `Optional` and `Union` instead of `... | None` and `a | b` - use `typing_extensions.Protocol` instead of `typing.Protocol` - use `typing.Dict`, `typing.List`, etc. instead of the concrete types. - Fix backend `.get()` annotations; not all were marked as `Optional[str]` - Don't return anything from `Backend.set()` methods. - The `Coder.decode_as_type()` type parameter must be a type to be compatible with `ModelField(..., type_=...)`. - Clean up `Optional[]` use, remove where it is not needed. - Clean up variable use in decorator, keeping the raw cached value separate from the return value from the wrapped endpoint. - Annotate the wrapper as returning either the original type _or_ a Response (returning a 304 Not Modified response). - Clean up small edge-case where `response` could be `None`. - Correct type annotation on `JsonCoder.decode()` to match `Coder.decode()`. --- examples/in_memory/main.py | 8 +++++--- fastapi_cache/coder.py | 4 ++-- fastapi_cache/decorator.py | 33 +++++++++++++++++---------------- fastapi_cache/key_builder.py | 6 +++--- fastapi_cache/types.py | 7 ++++--- poetry.lock | 24 ++++++++++++------------ pyproject.toml | 2 +- tests/test_codecs.py | 4 ++-- 8 files changed, 46 insertions(+), 42 deletions(-) diff --git a/examples/in_memory/main.py b/examples/in_memory/main.py index eb469af..f1ae83b 100644 --- a/examples/in_memory/main.py +++ b/examples/in_memory/main.py @@ -1,3 +1,5 @@ +from typing import Dict, Optional + import pendulum import uvicorn from fastapi import FastAPI @@ -84,9 +86,9 @@ app.get("/method")(cache(namespace="test")(instance.handler_method)) # cache a Pydantic model instance; the return type annotation is required in this case class Item(BaseModel): name: str - description: str | None = None + description: Optional[str] = None price: float - tax: float | None = None + tax: Optional[float] = None @app.get("/pydantic_instance") @@ -110,7 +112,7 @@ async def uncached_put(): @cache(namespace="test", expire=5, injected_dependency_namespace="monty_python") def namespaced_injection( __fastapi_cache_request: int = 42, __fastapi_cache_response: int = 17 -) -> dict[str, int]: +) -> Dict[str, int]: return { "__fastapi_cache_request": __fastapi_cache_request, "__fastapi_cache_response": __fastapi_cache_response, diff --git a/fastapi_cache/coder.py b/fastapi_cache/coder.py index 30c0ee2..6c96e90 100644 --- a/fastapi_cache/coder.py +++ b/fastapi_cache/coder.py @@ -10,10 +10,10 @@ from pydantic import BaseConfig, ValidationError, fields from starlette.responses import JSONResponse from starlette.templating import _TemplateResponse as TemplateResponse -_T = TypeVar("_T") +_T = TypeVar("_T", bound=type) -CONVERTERS: dict[str, Callable[[str], Any]] = { +CONVERTERS: Dict[str, Callable[[str], Any]] = { "date": lambda x: pendulum.parse(x, exact=True), "datetime": lambda x: pendulum.parse(x, exact=True), "decimal": Decimal, diff --git a/fastapi_cache/decorator.py b/fastapi_cache/decorator.py index 679e221..f108a76 100644 --- a/fastapi_cache/decorator.py +++ b/fastapi_cache/decorator.py @@ -2,7 +2,7 @@ import logging import sys from functools import wraps from inspect import Parameter, Signature, isawaitable, iscoroutinefunction -from typing import Awaitable, Callable, Optional, Type, TypeVar +from typing import Awaitable, Callable, List, Optional, Type, TypeVar, Union if sys.version_info >= (3, 10): from typing import ParamSpec @@ -36,7 +36,7 @@ def _augment_signature(signature: Signature, *extra: Parameter) -> Signature: return signature.replace(parameters=[*parameters, *extra, *variadic_keyword_params]) -def _locate_param(sig: Signature, dep: Parameter, to_inject: list[Parameter]) -> Parameter: +def _locate_param(sig: Signature, dep: Parameter, to_inject: List[Parameter]) -> Parameter: """Locate an existing parameter in the decorated endpoint If not found, returns the injectable parameter, and adds it to the to_inject list. @@ -56,9 +56,9 @@ def cache( expire: Optional[int] = None, coder: Optional[Type[Coder]] = None, key_builder: Optional[KeyBuilder] = None, - namespace: Optional[str] = "", + namespace: str = "", injected_dependency_namespace: str = "__fastapi_cache", -) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]: +) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[Union[R, Response]]]]: """ cache all function :param namespace: @@ -80,7 +80,7 @@ def cache( kind=Parameter.KEYWORD_ONLY, ) - def wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + def wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[Union[R, Response]]]: # get_typed_signature ensures that any forward references are resolved first wrapped_signature = get_typed_signature(func) to_inject: list[Parameter] = [] @@ -89,7 +89,7 @@ def cache( return_type = get_typed_return_annotation(func) @wraps(func) - async def inner(*args: P.args, **kwargs: P.kwargs) -> R: + async def inner(*args: P.args, **kwargs: P.kwargs) -> Union[R, Response]: nonlocal coder nonlocal expire nonlocal key_builder @@ -139,15 +139,15 @@ def cache( cache_key = await cache_key try: - ttl, ret = await backend.get_with_ttl(cache_key) + ttl, cached = await backend.get_with_ttl(cache_key) except Exception: logger.warning( f"Error retrieving cache key '{cache_key}' from backend:", exc_info=True ) - ttl, ret = 0, None + ttl, cached = 0, None if not request: - if ret is not None: - return coder.decode_as_type(ret, type_=return_type) + if cached is not None: + return coder.decode_as_type(cached, type_=return_type) ret = await ensure_async_func(*args, **kwargs) try: await backend.set(cache_key, coder.encode(ret), expire) @@ -161,15 +161,15 @@ def cache( return await ensure_async_func(*args, **kwargs) if_none_match = request.headers.get("if-none-match") - if ret is not None: + if cached is not None: if response: response.headers["Cache-Control"] = f"max-age={ttl}" - etag = f"W/{hash(ret)}" + etag = f"W/{hash(cached)}" if if_none_match == etag: response.status_code = 304 return response response.headers["ETag"] = etag - return coder.decode_as_type(ret, type_=return_type) + return coder.decode_as_type(cached, type_=return_type) ret = await ensure_async_func(*args, **kwargs) encoded_ret = coder.encode(ret) @@ -179,9 +179,10 @@ def cache( except Exception: logger.warning(f"Error setting cache key '{cache_key}' in backend:", exc_info=True) - response.headers["Cache-Control"] = f"max-age={expire}" - etag = f"W/{hash(encoded_ret)}" - response.headers["ETag"] = etag + if response: + response.headers["Cache-Control"] = f"max-age={expire}" + etag = f"W/{hash(encoded_ret)}" + response.headers["ETag"] = etag return ret inner.__signature__ = _augment_signature(wrapped_signature, *to_inject) diff --git a/fastapi_cache/key_builder.py b/fastapi_cache/key_builder.py index c680619..c83e65e 100644 --- a/fastapi_cache/key_builder.py +++ b/fastapi_cache/key_builder.py @@ -1,5 +1,5 @@ import hashlib -from typing import Any, Callable, Optional +from typing import Any, Callable, Dict, Optional, Tuple from starlette.requests import Request from starlette.responses import Response @@ -10,8 +10,8 @@ def default_key_builder( namespace: str = "", request: Optional[Request] = None, response: Optional[Response] = None, - args: Optional[tuple[Any, ...]] = None, - kwargs: Optional[dict[str, Any]] = None, + args: Optional[Tuple[Any, ...]] = None, + kwargs: Optional[Dict[str, Any]] = None, ) -> str: cache_key = hashlib.md5( # nosec:B303 f"{func.__module__}:{func.__name__}:{args}:{kwargs}".encode() diff --git a/fastapi_cache/types.py b/fastapi_cache/types.py index c409dfb..f2c65e1 100644 --- a/fastapi_cache/types.py +++ b/fastapi_cache/types.py @@ -1,7 +1,8 @@ -from typing import Any, Awaitable, Callable, Optional, Protocol, Union +from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, Union from starlette.requests import Request from starlette.responses import Response +from typing_extensions import Protocol _Func = Callable[..., Any] @@ -15,7 +16,7 @@ class KeyBuilder(Protocol): *, request: Optional[Request] = ..., response: Optional[Response] = ..., - args: tuple[Any, ...] = ..., - kwargs: dict[str, Any] = ..., + args: Tuple[Any, ...] = ..., + kwargs: Dict[str, Any] = ..., ) -> Union[Awaitable[str], str]: ... diff --git a/poetry.lock b/poetry.lock index 785614c..75698b4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -304,14 +304,14 @@ crt = ["awscrt (==0.11.24)"] [[package]] name = "certifi" -version = "2022.12.7" +version = "2023.5.7" description = "Python package for providing Mozilla's CA Bundle." category = "dev" optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, + {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, + {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, ] [[package]] @@ -1099,18 +1099,18 @@ files = [ [[package]] name = "redis" -version = "4.5.4" +version = "4.5.5" description = "Python client for Redis database and key-value store" category = "main" optional = true python-versions = ">=3.7" files = [ - {file = "redis-4.5.4-py3-none-any.whl", hash = "sha256:2c19e6767c474f2e85167909061d525ed65bea9301c0770bb151e041b7ac89a2"}, - {file = "redis-4.5.4.tar.gz", hash = "sha256:73ec35da4da267d6847e47f68730fdd5f62e2ca69e3ef5885c6a78a9374c3893"}, + {file = "redis-4.5.5-py3-none-any.whl", hash = "sha256:77929bc7f5dab9adf3acba2d3bb7d7658f1e0c2f1cafe7eb36434e751c471119"}, + {file = "redis-4.5.5.tar.gz", hash = "sha256:dc87a0bdef6c8bfe1ef1e1c40be7034390c2ae02d92dcd0c7ca1729443899880"}, ] [package.dependencies] -async-timeout = {version = ">=4.0.2", markers = "python_version <= \"3.11.2\""} +async-timeout = {version = ">=4.0.2", markers = "python_full_version <= \"3.11.2\""} importlib-metadata = {version = ">=1.0", markers = "python_version < \"3.8\""} typing-extensions = {version = "*", markers = "python_version < \"3.8\""} @@ -1120,21 +1120,21 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)" [[package]] name = "requests" -version = "2.29.0" +version = "2.30.0" description = "Python HTTP for Humans." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "requests-2.29.0-py3-none-any.whl", hash = "sha256:e8f3c9be120d3333921d213eef078af392fba3933ab7ed2d1cba3b56f2568c3b"}, - {file = "requests-2.29.0.tar.gz", hash = "sha256:f2e34a75f4749019bb0e3effb66683630e4ffeaf75819fb51bebef1bf5aef059"}, + {file = "requests-2.30.0-py3-none-any.whl", hash = "sha256:10e94cc4f3121ee6da529d358cdaeaff2f1c409cd377dbc72b825852f2f7e294"}, + {file = "requests-2.30.0.tar.gz", hash = "sha256:239d7d4458afcb28a692cdd298d87542235f4ca8d36d03a15bfc128a6559a2f4"}, ] [package.dependencies] certifi = ">=2017.4.17" charset-normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<1.27" +urllib3 = ">=1.21.1,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] @@ -1476,4 +1476,4 @@ redis = ["redis"] [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "4325f045144b309e8378c02465b4e66544d28fc74751883f9a50ff391df08dbe" +content-hash = "479b55889016688ab9b82e0a1c998ac2faaddf0807d0174b99289d02613387a8" diff --git a/pyproject.toml b/pyproject.toml index de4e11f..7d89dff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ redis = { version = "^4.2.0rc1", optional = true } aiomcache = { version = "*", optional = true } pendulum = "*" aiobotocore = { version = "^1.4.1", optional = true } -typing-extensions = { version = ">=4.1.0", markers = "python_version < \"3.10\"" } +typing-extensions = { version = ">=4.1.0" } aiohttp = { version = ">=3.8.3", markers = "python_version >= \"3.11\"" } [tool.poetry.group.dev.dependencies] diff --git a/tests/test_codecs.py b/tests/test_codecs.py index ff1dc23..8b574ac 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Optional +from typing import Any, Optional, Tuple import pytest from pydantic import BaseModel, ValidationError @@ -46,7 +46,7 @@ def test_pickle_coder(value: Any) -> None: [ (1, None), ("some_string", None), - ((1, 2), tuple[int, int]), + ((1, 2), Tuple[int, int]), ([1, 2, 3], None), ({"some_key": 1, "other_key": 2}, None), (DCItem(name="foo", price=42.0, description="some dataclass item", tax=0.2), DCItem),