Merge pull request #133 from mjpieters/correct_json_decode_return

Clean up type annotations
This commit is contained in:
long2ice
2023-05-12 14:53:40 +08:00
committed by GitHub
14 changed files with 813 additions and 86 deletions

View File

@@ -2,12 +2,17 @@ name: ci
on: [ push, pull_request ] on: [ push, pull_request ]
jobs: jobs:
ci: ci:
strategy:
matrix:
python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
name: "Test on Python ${{ matrix.python }}"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-python@v4 - uses: actions/setup-python@v4
with: with:
python-version: "3.x" python-version: "${{ matrix.python }}"
- name: Install and configure Poetry - name: Install and configure Poetry
run: | run: |
pip install -U pip poetry pip install -U pip poetry

View File

@@ -14,6 +14,8 @@ style: deps
check: deps check: deps
@black $(checkfiles) || (echo "Please run 'make style' to auto-fix style issues" && false) @black $(checkfiles) || (echo "Please run 'make style' to auto-fix style issues" && false)
@flake8 $(checkfiles) @flake8 $(checkfiles)
@mypy ${checkfiles}
@pyright ${checkfiles}
test: deps test: deps
$(py_warn) pytest $(py_warn) pytest

View File

@@ -1,3 +1,6 @@
# pyright: reportGeneralTypeIssues=false
from typing import Dict, Optional
import pendulum import pendulum
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
@@ -84,9 +87,9 @@ app.get("/method")(cache(namespace="test")(instance.handler_method))
# cache a Pydantic model instance; the return type annotation is required in this case # cache a Pydantic model instance; the return type annotation is required in this case
class Item(BaseModel): class Item(BaseModel):
name: str name: str
description: str | None = None description: Optional[str] = None
price: float price: float
tax: float | None = None tax: Optional[float] = None
@app.get("/pydantic_instance") @app.get("/pydantic_instance")
@@ -110,7 +113,7 @@ async def uncached_put():
@cache(namespace="test", expire=5, injected_dependency_namespace="monty_python") @cache(namespace="test", expire=5, injected_dependency_namespace="monty_python")
def namespaced_injection( def namespaced_injection(
__fastapi_cache_request: int = 42, __fastapi_cache_response: int = 17 __fastapi_cache_request: int = 42, __fastapi_cache_response: int = 17
) -> dict[str, int]: ) -> Dict[str, int]:
return { return {
"__fastapi_cache_request": __fastapi_cache_request, "__fastapi_cache_request": __fastapi_cache_request,
"__fastapi_cache_response": __fastapi_cache_response, "__fastapi_cache_response": __fastapi_cache_response,
@@ -123,4 +126,4 @@ async def startup():
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("main:app", debug=True, reload=True) uvicorn.run("main:app", reload=True)

View File

@@ -1,3 +1,4 @@
# pyright: reportGeneralTypeIssues=false
import time import time
import pendulum import pendulum
@@ -87,4 +88,4 @@ async def startup():
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("main:app", debug=True, reload=True) uvicorn.run("main:app", reload=True)

View File

@@ -1,11 +1,16 @@
import datetime import datetime
from typing import Optional, Tuple from typing import TYPE_CHECKING, Optional, Tuple
from aiobotocore.client import AioBaseClient from aiobotocore.client import AioBaseClient
from aiobotocore.session import get_session, AioSession from aiobotocore.session import AioSession, get_session
from fastapi_cache.backends import Backend from fastapi_cache.backends import Backend
if TYPE_CHECKING:
from types_aiobotocore_dynamodb import DynamoDBClient
else:
DynamoDBClient = AioBaseClient
class DynamoBackend(Backend): class DynamoBackend(Backend):
""" """
@@ -25,14 +30,18 @@ class DynamoBackend(Backend):
>> FastAPICache.init(dynamodb) >> FastAPICache.init(dynamodb)
""" """
client: DynamoDBClient
session: AioSession
table_name: str
region: Optional[str]
def __init__(self, table_name: str, region: Optional[str] = None) -> None: def __init__(self, table_name: str, region: Optional[str] = None) -> None:
self.session: AioSession = get_session() self.session: AioSession = get_session()
self.client: Optional[AioBaseClient] = None # Needs async init
self.table_name = table_name self.table_name = table_name
self.region = region self.region = region
async def init(self) -> None: async def init(self) -> None:
self.client = await self.session.create_client( self.client = await self.session.create_client( # pyright: ignore[reportUnknownMemberType]
"dynamodb", region_name=self.region "dynamodb", region_name=self.region
).__aenter__() ).__aenter__()
@@ -60,6 +69,7 @@ class DynamoBackend(Backend):
response = await self.client.get_item(TableName=self.table_name, Key={"key": {"S": key}}) response = await self.client.get_item(TableName=self.table_name, Key={"key": {"S": key}})
if "Item" in response: if "Item" in response:
return response["Item"].get("value", {}).get("B") return response["Item"].get("value", {}).get("B")
return None
async def set(self, key: str, value: bytes, expire: Optional[int] = None) -> None: async def set(self, key: str, value: bytes, expire: Optional[int] = None) -> None:
ttl = ( ttl = (

View File

@@ -13,18 +13,18 @@ class RedisBackend(Backend):
async def get_with_ttl(self, key: str) -> Tuple[int, Optional[bytes]]: async def get_with_ttl(self, key: str) -> Tuple[int, Optional[bytes]]:
async with self.redis.pipeline(transaction=not self.is_cluster) as pipe: async with self.redis.pipeline(transaction=not self.is_cluster) as pipe:
return await pipe.ttl(key).get(key).execute() return await pipe.ttl(key).get(key).execute() # type: ignore[union-attr,no-any-return]
async def get(self, key: str) -> Optional[bytes]: async def get(self, key: str) -> Optional[bytes]:
return await self.redis.get(key) return await self.redis.get(key) # type: ignore[union-attr]
async def set(self, key: str, value: bytes, expire: Optional[int] = None) -> None: async def set(self, key: str, value: bytes, expire: Optional[int] = None) -> None:
return await self.redis.set(key, value, ex=expire) await self.redis.set(key, value, ex=expire) # type: ignore[union-attr]
async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int: async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int:
if namespace: if namespace:
lua = f"for i, name in ipairs(redis.call('KEYS', '{namespace}:*')) do redis.call('DEL', name); end" lua = f"for i, name in ipairs(redis.call('KEYS', '{namespace}:*')) do redis.call('DEL', name); end"
return await self.redis.eval(lua, numkeys=0) return await self.redis.eval(lua, numkeys=0) # type: ignore[union-attr,no-any-return]
elif key: elif key:
return await self.redis.delete(key) return await self.redis.delete(key) # type: ignore[union-attr]
return 0 return 0

View File

@@ -8,28 +8,31 @@ import pendulum
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from pydantic import BaseConfig, ValidationError, fields from pydantic import BaseConfig, ValidationError, fields
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.templating import _TemplateResponse as TemplateResponse from starlette.templating import (
_TemplateResponse as TemplateResponse, # pyright: ignore[reportPrivateUsage]
)
_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), # Pendulum 3.0.0 adds parse to __all__, at which point these ignores can be removed
"datetime": lambda x: pendulum.parse(x, exact=True), "date": lambda x: pendulum.parse(x, exact=True), # type: ignore[attr-defined]
"datetime": lambda x: pendulum.parse(x, exact=True), # type: ignore[attr-defined]
"decimal": Decimal, "decimal": Decimal,
} }
class JsonEncoder(json.JSONEncoder): class JsonEncoder(json.JSONEncoder):
def default(self, obj: Any) -> Any: def default(self, o: Any) -> Any:
if isinstance(obj, datetime.datetime): if isinstance(o, datetime.datetime):
return {"val": str(obj), "_spec_type": "datetime"} return {"val": str(o), "_spec_type": "datetime"}
elif isinstance(obj, datetime.date): elif isinstance(o, datetime.date):
return {"val": str(obj), "_spec_type": "date"} return {"val": str(o), "_spec_type": "date"}
elif isinstance(obj, Decimal): elif isinstance(o, Decimal):
return {"val": str(obj), "_spec_type": "decimal"} return {"val": str(o), "_spec_type": "decimal"}
else: else:
return jsonable_encoder(obj) return jsonable_encoder(o)
def object_hook(obj: Any) -> Any: def object_hook(obj: Any) -> Any:

View File

@@ -2,7 +2,7 @@ import logging
import sys import sys
from functools import wraps from functools import wraps
from inspect import Parameter, Signature, isawaitable, iscoroutinefunction 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, cast
if sys.version_info >= (3, 10): if sys.version_info >= (3, 10):
from typing import ParamSpec from typing import ParamSpec
@@ -29,14 +29,14 @@ def _augment_signature(signature: Signature, *extra: Parameter) -> Signature:
return signature return signature
parameters = list(signature.parameters.values()) parameters = list(signature.parameters.values())
variadic_keyword_params = [] variadic_keyword_params: List[Parameter] = []
while parameters and parameters[-1].kind is Parameter.VAR_KEYWORD: while parameters and parameters[-1].kind is Parameter.VAR_KEYWORD:
variadic_keyword_params.append(parameters.pop()) variadic_keyword_params.append(parameters.pop())
return signature.replace(parameters=[*parameters, *extra, *variadic_keyword_params]) 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 """Locate an existing parameter in the decorated endpoint
If not found, returns the injectable parameter, and adds it to the to_inject list. 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, expire: Optional[int] = None,
coder: Optional[Type[Coder]] = None, coder: Optional[Type[Coder]] = None,
key_builder: Optional[KeyBuilder] = None, key_builder: Optional[KeyBuilder] = None,
namespace: Optional[str] = "", namespace: str = "",
injected_dependency_namespace: str = "__fastapi_cache", 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 cache all function
:param namespace: :param namespace:
@@ -80,16 +80,16 @@ def cache(
kind=Parameter.KEYWORD_ONLY, 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 # get_typed_signature ensures that any forward references are resolved first
wrapped_signature = get_typed_signature(func) wrapped_signature = get_typed_signature(func)
to_inject: list[Parameter] = [] to_inject: List[Parameter] = []
request_param = _locate_param(wrapped_signature, injected_request, to_inject) request_param = _locate_param(wrapped_signature, injected_request, to_inject)
response_param = _locate_param(wrapped_signature, injected_response, to_inject) response_param = _locate_param(wrapped_signature, injected_response, to_inject)
return_type = get_typed_return_annotation(func) return_type = get_typed_return_annotation(func)
@wraps(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 coder
nonlocal expire nonlocal expire
nonlocal key_builder nonlocal key_builder
@@ -111,11 +111,11 @@ def cache(
else: else:
# sync, wrap in thread and return async # sync, wrap in thread and return async
# see above why we have to await even although caller also awaits. # see above why we have to await even although caller also awaits.
return await run_in_threadpool(func, *args, **kwargs) return await run_in_threadpool(func, *args, **kwargs) # type: ignore[arg-type]
copy_kwargs = kwargs.copy() copy_kwargs = kwargs.copy()
request: Optional[Request] = copy_kwargs.pop(request_param.name, None) request: Optional[Request] = copy_kwargs.pop(request_param.name, None) # type: ignore[assignment]
response: Optional[Response] = copy_kwargs.pop(response_param.name, None) response: Optional[Response] = copy_kwargs.pop(response_param.name, None) # type: ignore[assignment]
if ( if (
request and request.headers.get("Cache-Control") in ("no-store", "no-cache") request and request.headers.get("Cache-Control") in ("no-store", "no-cache")
) or not FastAPICache.get_enable(): ) or not FastAPICache.get_enable():
@@ -137,17 +137,18 @@ def cache(
) )
if isawaitable(cache_key): if isawaitable(cache_key):
cache_key = await cache_key cache_key = await cache_key
assert isinstance(cache_key, str)
try: try:
ttl, ret = await backend.get_with_ttl(cache_key) ttl, cached = await backend.get_with_ttl(cache_key)
except Exception: except Exception:
logger.warning( logger.warning(
f"Error retrieving cache key '{cache_key}' from backend:", exc_info=True f"Error retrieving cache key '{cache_key}' from backend:", exc_info=True
) )
ttl, ret = 0, None ttl, cached = 0, None
if not request: if not request:
if ret is not None: if cached is not None:
return coder.decode_as_type(ret, type_=return_type) return cast(R, coder.decode_as_type(cached, type_=return_type))
ret = await ensure_async_func(*args, **kwargs) ret = await ensure_async_func(*args, **kwargs)
try: try:
await backend.set(cache_key, coder.encode(ret), expire) await backend.set(cache_key, coder.encode(ret), expire)
@@ -161,15 +162,15 @@ def cache(
return await ensure_async_func(*args, **kwargs) return await ensure_async_func(*args, **kwargs)
if_none_match = request.headers.get("if-none-match") if_none_match = request.headers.get("if-none-match")
if ret is not None: if cached is not None:
if response: if response:
response.headers["Cache-Control"] = f"max-age={ttl}" response.headers["Cache-Control"] = f"max-age={ttl}"
etag = f"W/{hash(ret)}" etag = f"W/{hash(cached)}"
if if_none_match == etag: if if_none_match == etag:
response.status_code = 304 response.status_code = 304
return response return response
response.headers["ETag"] = etag response.headers["ETag"] = etag
return coder.decode_as_type(ret, type_=return_type) return cast(R, coder.decode_as_type(cached, type_=return_type))
ret = await ensure_async_func(*args, **kwargs) ret = await ensure_async_func(*args, **kwargs)
encoded_ret = coder.encode(ret) encoded_ret = coder.encode(ret)
@@ -179,12 +180,13 @@ def cache(
except Exception: except Exception:
logger.warning(f"Error setting cache key '{cache_key}' in backend:", exc_info=True) logger.warning(f"Error setting cache key '{cache_key}' in backend:", exc_info=True)
response.headers["Cache-Control"] = f"max-age={expire}" if response:
etag = f"W/{hash(encoded_ret)}" response.headers["Cache-Control"] = f"max-age={expire}"
response.headers["ETag"] = etag etag = f"W/{hash(encoded_ret)}"
response.headers["ETag"] = etag
return ret return ret
inner.__signature__ = _augment_signature(wrapped_signature, *to_inject) inner.__signature__ = _augment_signature(wrapped_signature, *to_inject) # type: ignore[attr-defined]
return inner return inner
return wrapper return wrapper

View File

@@ -1,5 +1,5 @@
import hashlib import hashlib
from typing import Any, Callable, Optional from typing import Any, Callable, Dict, Optional, Tuple
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import Response from starlette.responses import Response
@@ -8,10 +8,11 @@ from starlette.responses import Response
def default_key_builder( def default_key_builder(
func: Callable[..., Any], func: Callable[..., Any],
namespace: str = "", namespace: str = "",
*,
request: Optional[Request] = None, request: Optional[Request] = None,
response: Optional[Response] = None, response: Optional[Response] = None,
args: Optional[tuple[Any, ...]] = None, args: Tuple[Any, ...],
kwargs: Optional[dict[str, Any]] = None, kwargs: Dict[str, Any],
) -> str: ) -> str:
cache_key = hashlib.md5( # nosec:B303 cache_key = hashlib.md5( # nosec:B303
f"{func.__module__}:{func.__name__}:{args}:{kwargs}".encode() f"{func.__module__}:{func.__name__}:{args}:{kwargs}".encode()

View File

@@ -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.requests import Request
from starlette.responses import Response from starlette.responses import Response
from typing_extensions import Protocol
_Func = Callable[..., Any] _Func = Callable[..., Any]
@@ -10,12 +11,12 @@ _Func = Callable[..., Any]
class KeyBuilder(Protocol): class KeyBuilder(Protocol):
def __call__( def __call__(
self, self,
_function: _Func, __function: _Func,
_namespace: str = ..., __namespace: str = ...,
*, *,
request: Optional[Request] = ..., request: Optional[Request] = ...,
response: Optional[Response] = ..., response: Optional[Response] = ...,
args: tuple[Any, ...] = ..., args: Tuple[Any, ...],
kwargs: dict[str, Any] = ..., kwargs: Dict[str, Any],
) -> Union[Awaitable[str], str]: ) -> Union[Awaitable[str], str]:
... ...

691
poetry.lock generated

File diff suppressed because one or more lines are too long

View File

@@ -22,7 +22,7 @@ redis = { version = "^4.2.0rc1", optional = true }
aiomcache = { version = "*", optional = true } aiomcache = { version = "*", optional = true }
pendulum = "*" pendulum = "*"
aiobotocore = { version = "^1.4.1", optional = true } 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\"" } aiohttp = { version = ">=3.8.3", markers = "python_version >= \"3.11\"" }
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
@@ -33,6 +33,10 @@ pytest = "*"
requests = "*" requests = "*"
coverage = "^6.5.0" coverage = "^6.5.0"
httpx = "*" httpx = "*"
mypy = "^1.2.0"
types-redis = "^4.5.4.2"
pyright = "^1.1.306"
types-aiobotocore = { extras = ["dynamodb"], version = "^2.5.0.post2" }
[build-system] [build-system]
requires = ["poetry>=0.12"] requires = ["poetry>=0.12"]
@@ -47,3 +51,31 @@ all = ["redis", "aiomcache", "aiobotocore"]
[tool.black] [tool.black]
line-length = 100 line-length = 100
target-version = ['py36', 'py37', 'py38', 'py39'] target-version = ['py36', 'py37', 'py38', 'py39']
[tool.mypy]
files = ["fastapi_cache", "examples", "tests"]
python_version = "3.7"
# equivalent of --strict
warn_unused_configs = true
disallow_any_generics = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_return_any = true
no_implicit_reexport = true
strict_equality = true
strict_concatenate = true
[[tool.mypy.overrides]]
module = "examples.*.main"
ignore_errors = true
[tool.pyright]
include = ["fastapi_cache", "tests", "examples"]
strict = ["fastapi_cache", "tests"]
pythonVersion = "3.7"

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Optional from typing import Any, Optional, Tuple, Type
import pytest import pytest
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
@@ -46,14 +46,14 @@ def test_pickle_coder(value: Any) -> None:
[ [
(1, None), (1, None),
("some_string", None), ("some_string", None),
((1, 2), tuple[int, int]), ((1, 2), Tuple[int, int]),
([1, 2, 3], None), ([1, 2, 3], None),
({"some_key": 1, "other_key": 2}, None), ({"some_key": 1, "other_key": 2}, None),
(DCItem(name="foo", price=42.0, description="some dataclass item", tax=0.2), DCItem), (DCItem(name="foo", price=42.0, description="some dataclass item", tax=0.2), DCItem),
(PDItem(name="foo", price=42.0, description="some pydantic item", tax=0.2), PDItem), (PDItem(name="foo", price=42.0, description="some pydantic item", tax=0.2), PDItem),
], ],
) )
def test_json_coder(value: Any, return_type) -> None: def test_json_coder(value: Any, return_type: Type[Any]) -> None:
encoded_value = JsonCoder.encode(value) encoded_value = JsonCoder.encode(value)
assert isinstance(encoded_value, bytes) assert isinstance(encoded_value, bytes)
decoded_value = JsonCoder.decode_as_type(encoded_value, type_=return_type) decoded_value = JsonCoder.decode_as_type(encoded_value, type_=return_type)

View File

@@ -1,5 +1,5 @@
import time import time
from typing import Generator from typing import Generator, Any
import pendulum import pendulum
import pytest import pytest
@@ -11,7 +11,7 @@ from fastapi_cache.backends.inmemory import InMemoryBackend
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def init_cache() -> Generator: def init_cache() -> Generator[Any, Any, None]:
FastAPICache.init(InMemoryBackend()) FastAPICache.init(InMemoryBackend())
yield yield
FastAPICache.reset() FastAPICache.reset()
@@ -21,34 +21,34 @@ def test_datetime() -> None:
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/datetime") response = client.get("/datetime")
now = response.json().get("now") now = response.json().get("now")
now_ = pendulum.now().replace(microsecond=0) now_ = pendulum.now().replace(microsecond=0) # type: ignore[no-untyped-call]
assert pendulum.parse(now).replace(microsecond=0) == now_ assert pendulum.parse(now).replace(microsecond=0) == now_ # type: ignore[attr-defined]
response = client.get("/datetime") response = client.get("/datetime")
now = response.json().get("now") now = response.json().get("now")
assert pendulum.parse(now).replace(microsecond=0) == now_ assert pendulum.parse(now).replace(microsecond=0) == now_ # type: ignore[attr-defined]
time.sleep(3) time.sleep(3)
response = client.get("/datetime") response = client.get("/datetime")
now = response.json().get("now") now = response.json().get("now")
now = pendulum.parse(now).replace(microsecond=0) now = pendulum.parse(now).replace(microsecond=0) # type: ignore[attr-defined]
assert now != now_ assert now != now_
assert now == pendulum.now().replace(microsecond=0) assert now == pendulum.now().replace(microsecond=0) # type: ignore[no-untyped-call]
def test_date() -> None: def test_date() -> None:
"""Test path function without request or response arguments.""" """Test path function without request or response arguments."""
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/date") response = client.get("/date")
assert pendulum.parse(response.json()) == pendulum.today() assert pendulum.parse(response.json()) == pendulum.today() # type: ignore[attr-defined]
# do it again to test cache # do it again to test cache
response = client.get("/date") response = client.get("/date")
assert pendulum.parse(response.json()) == pendulum.today() assert pendulum.parse(response.json()) == pendulum.today() # type: ignore[attr-defined]
# now test with cache disabled, as that's a separate code path # now test with cache disabled, as that's a separate code path
FastAPICache._enable = False FastAPICache._enable = False # pyright: ignore[reportPrivateUsage]
response = client.get("/date") response = client.get("/date")
assert pendulum.parse(response.json()) == pendulum.today() assert pendulum.parse(response.json()) == pendulum.today() # type: ignore[attr-defined]
FastAPICache._enable = True FastAPICache._enable = True # pyright: ignore[reportPrivateUsage]
def test_sync() -> None: def test_sync() -> None: