From 55784ef8f77cce6f788f4f26ffda8a0b62896025 Mon Sep 17 00:00:00 2001 From: chud0 Date: Wed, 25 Jun 2025 22:57:09 +0300 Subject: [PATCH 1/7] add pydentic --- poetry.lock | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 1639233..bea52e4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -716,4 +716,4 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3) [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "9f9180b91ecef4ff4515c5ee6672dcde7de0740ada48aabf240683bc75de5d21" +content-hash = "4ffaeb11a66f7eda3ccdc421323e30e544db3574dcc602984aebb713faae6a8b" diff --git a/pyproject.toml b/pyproject.toml index ea31fa5..8e96bd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ license = "MIT" [tool.poetry.dependencies] python = "^3.11" fastapi = ">=0.111.1,<1.0.0" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" [tool.poetry.group.dev.dependencies] From a2037ee5bdbc9d6139d1ff632b4c0fa101053223 Mon Sep 17 00:00:00 2001 From: chud0 Date: Wed, 25 Jun 2025 23:37:55 +0300 Subject: [PATCH 2/7] add CacheConfigSchema --- fast_cache_middleware/schemas.py | 45 ++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/fast_cache_middleware/schemas.py b/fast_cache_middleware/schemas.py index ba1d76e..69ee59a 100644 --- a/fast_cache_middleware/schemas.py +++ b/fast_cache_middleware/schemas.py @@ -1,10 +1,51 @@ -import typing as tp +import re +from typing import Any, Callable +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from starlette.requests import Request from starlette.routing import Route from .depends import CacheConfig, CacheDropConfig +class CacheConfigSchema(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + max_age: int | None = Field( + default=None, + description="Cache lifetime in seconds. If None, caching is disabled.", + ) + key_func: Callable[[Request], str] | None = Field( + default=None, + description="Custom cache key generation function. If None, default key generation is used.", + ) + invalidate_paths: list[re.Pattern] | None = Field( + default=None, + description="Paths for cache invalidation (strings or regex patterns). No invalidation if None.", + ) + + @model_validator(mode="after") + def one_of_field_is_set(self) -> "CacheConfigSchema": + if self.max_age is None and self.key_func is None: + raise ValueError("At least one of max_age or key_func must be set.") + return self + + @field_validator("invalidate_paths") + @classmethod + def compile_paths(cls, item: Any) -> Any: + if item is None: + return None + if isinstance(item, str): + return re.compile(f"^{item}") + if isinstance(item, re.Pattern): + return item + if isinstance(item, list): + return [cls.compile_paths(i) for i in item] + raise ValueError( + "invalidate_paths must be a string, regex pattern, or list of them." + ) + + class RouteInfo: """Route information with cache configuration.""" @@ -18,4 +59,4 @@ def __init__( self.cache_config = cache_config self.cache_drop_config = cache_drop_config self.path: str = getattr(route, "path") - self.methods: tp.Set[str] = getattr(route, "methods", set()) + self.methods: set[str] = getattr(route, "methods", set()) From 87af71acc78fb222d2d4441613d8489673969230 Mon Sep 17 00:00:00 2001 From: chud0 Date: Thu, 26 Jun 2025 00:29:24 +0300 Subject: [PATCH 3/7] RouteInfo as model --- fast_cache_middleware/controller.py | 15 +++++---- fast_cache_middleware/middleware.py | 31 ++++++++++++------ fast_cache_middleware/schemas.py | 50 +++++++++++++++++++---------- tests/test_controller.py | 23 ++++--------- 4 files changed, 69 insertions(+), 50 deletions(-) diff --git a/fast_cache_middleware/controller.py b/fast_cache_middleware/controller.py index 56e4c67..70e0ba0 100644 --- a/fast_cache_middleware/controller.py +++ b/fast_cache_middleware/controller.py @@ -1,12 +1,13 @@ import http import logging +import re import typing as tp from hashlib import blake2b from starlette.requests import Request from starlette.responses import Response -from .depends import CacheConfig, CacheDropConfig +from .schemas import CacheConfiguration from .storages import BaseStorage logger = logging.getLogger(__name__) @@ -136,7 +137,7 @@ async def is_cachable_response(self, response: Response) -> bool: return True async def generate_cache_key( - self, request: Request, cache_config: CacheConfig + self, request: Request, cache_configuration: CacheConfiguration ) -> str: """Generates cache key for request. @@ -148,8 +149,8 @@ async def generate_cache_key( str: Cache key """ # Use custom key generation function if available - if cache_config.key_func: - return cache_config.key_func(request) + if cache_configuration.key_func: + return cache_configuration.key_func(request) # Use standard function return generate_key(request) @@ -198,13 +199,13 @@ async def get_cached_response( async def invalidate_cache( self, - cache_drop_config: CacheDropConfig, + invalidate_paths: list[re.Pattern], storage: BaseStorage, ) -> None: """Invalidates cache by configuration. Args: - cache_drop_config: Cache invalidation configuration + invalidate_paths: List of regex patterns for cache invalidation storage: Cache storage TODO: Comments on improvements: @@ -226,6 +227,6 @@ async def invalidate_cache( 5. Add tag support for grouping related caches and their joint invalidation """ - for path in cache_drop_config.paths: + for path in invalidate_paths: await storage.remove(path) logger.info("Invalidated cache for pattern: %s", path.pattern) diff --git a/fast_cache_middleware/middleware.py b/fast_cache_middleware/middleware.py index 0834d93..32a3a72 100644 --- a/fast_cache_middleware/middleware.py +++ b/fast_cache_middleware/middleware.py @@ -10,7 +10,7 @@ from .controller import Controller from .depends import BaseCacheConfigDepends, CacheConfig, CacheDropConfig -from .schemas import RouteInfo +from .schemas import CacheConfiguration, RouteInfo from .storages import BaseStorage, InMemoryStorage logger = logging.getLogger(__name__) @@ -240,19 +240,23 @@ async def on_http(self, scope: Scope, receive: Receive, send: Send) -> bool | No if not route_info: return None + cache_configuration = route_info.cache_config + # Handle invalidation if specified - if cc := route_info.cache_drop_config: - await self.controller.invalidate_cache(cc, storage=self.storage) + if cache_configuration.invalidate_paths: + await self.controller.invalidate_cache( + cache_configuration.invalidate_paths, storage=self.storage + ) - # Handle caching if config exists - cache_config = route_info.cache_config - if not cache_config: + if not cache_configuration.max_age: return None if not await self.controller.is_cachable_request(request): return None - cache_key = await self.controller.generate_cache_key(request, cache_config) + cache_key = await self.controller.generate_cache_key( + request, cache_configuration=cache_configuration + ) cached_response = await self.controller.get_cached_response( cache_key, self.storage @@ -272,7 +276,7 @@ async def on_http(self, scope: Scope, receive: Receive, send: Send) -> bool | No storage=self.storage, request=request, cache_key=cache_key, - ttl=cache_config.max_age, + ttl=cache_configuration.max_age, )() return True @@ -290,10 +294,17 @@ def _extract_routes_info(self, routes: list[routing.APIRoute]) -> list[RouteInfo ) = self._extract_cache_configs_from_route(route) if cache_config or cache_drop_config: + cache_configuration = CacheConfiguration( + max_age=cache_config.max_age if cache_config else None, + key_func=cache_config.key_func if cache_config else None, + invalidate_paths=( + cache_drop_config.paths if cache_drop_config else None + ), + ) + route_info = RouteInfo( route=route, - cache_config=cache_config, - cache_drop_config=cache_drop_config, + cache_config=cache_configuration, ) routes_info.append(route_info) diff --git a/fast_cache_middleware/schemas.py b/fast_cache_middleware/schemas.py index 69ee59a..d4d9e4a 100644 --- a/fast_cache_middleware/schemas.py +++ b/fast_cache_middleware/schemas.py @@ -1,14 +1,21 @@ import re from typing import Any, Callable -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + computed_field, + field_validator, + model_validator, +) from starlette.requests import Request from starlette.routing import Route from .depends import CacheConfig, CacheDropConfig -class CacheConfigSchema(BaseModel): +class CacheConfiguration(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") max_age: int | None = Field( @@ -25,9 +32,15 @@ class CacheConfigSchema(BaseModel): ) @model_validator(mode="after") - def one_of_field_is_set(self) -> "CacheConfigSchema": - if self.max_age is None and self.key_func is None: - raise ValueError("At least one of max_age or key_func must be set.") + def one_of_field_is_set(self) -> "CacheConfiguration": + if ( + self.max_age is None + and self.key_func is None + and self.invalidate_paths is None + ): + raise ValueError( + "At least one of max_age, key_func, or invalidate_paths must be set." + ) return self @field_validator("invalidate_paths") @@ -46,17 +59,20 @@ def compile_paths(cls, item: Any) -> Any: ) -class RouteInfo: +class RouteInfo(BaseModel): """Route information with cache configuration.""" - def __init__( - self, - route: Route, - cache_config: CacheConfig | None = None, - cache_drop_config: CacheDropConfig | None = None, - ): - self.route = route - self.cache_config = cache_config - self.cache_drop_config = cache_drop_config - self.path: str = getattr(route, "path") - self.methods: set[str] = getattr(route, "methods", set()) + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + route: Route + cache_config: CacheConfiguration + + @computed_field # type: ignore[prop-decorator] + @property + def path(self) -> str: + return getattr(self.route, "path", "") + + @computed_field # type: ignore[prop-decorator] + @property + def methods(self) -> set[str]: + return getattr(self.route, "methods", set()) diff --git a/tests/test_controller.py b/tests/test_controller.py index 85c718c..c9122b7 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,18 +1,15 @@ """Тесты для контроллера кеширования.""" -import asyncio -import time import typing as tp -from datetime import datetime, timedelta +from datetime import datetime from unittest.mock import AsyncMock, MagicMock import pytest from starlette.requests import Request from starlette.responses import Response -from fast_cache_middleware.controller import Controller, generate_key -from fast_cache_middleware.depends import CacheConfig, CacheDropConfig -from fast_cache_middleware.schemas import RouteInfo +from fast_cache_middleware.controller import Controller +from fast_cache_middleware.schemas import CacheConfiguration, RouteInfo from fast_cache_middleware.storages import BaseStorage @@ -45,22 +42,16 @@ def controller() -> Controller: @pytest.fixture -def cache_config() -> CacheConfig: +def cache_config() -> CacheConfiguration: """Создает конфигурацию кеширования.""" - return CacheConfig(max_age=600) + return CacheConfiguration(max_age=600) @pytest.fixture -def cache_drop_config() -> CacheDropConfig: - """Создает конфигурацию инвалидации кеша.""" - return CacheDropConfig(paths=["/api/*"]) - - -@pytest.fixture -def route_info(cache_config: CacheConfig) -> RouteInfo: +def route_info(cache_config: CacheConfiguration) -> RouteInfo: """Создает информацию о роуте.""" route = MagicMock() - return RouteInfo(route=route, cache_config=cache_config, cache_drop_config=None) + return RouteInfo(route=route, cache_config=cache_config) class TestShouldCacheRequest: From 9d703ab1c59f9a3f8d901ce96173d2190e7b6335 Mon Sep 17 00:00:00 2001 From: Vakilov Vadim Date: Thu, 26 Jun 2025 17:57:23 +0300 Subject: [PATCH 4/7] change annotate method --- fast_cache_middleware/controller.py | 6 +++--- fast_cache_middleware/depends.py | 4 ++-- fast_cache_middleware/serializers.py | 16 +++++++--------- fast_cache_middleware/storages.py | 18 +++++++++--------- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/fast_cache_middleware/controller.py b/fast_cache_middleware/controller.py index 70e0ba0..cf78285 100644 --- a/fast_cache_middleware/controller.py +++ b/fast_cache_middleware/controller.py @@ -1,8 +1,8 @@ import http import logging import re -import typing as tp from hashlib import blake2b +from typing import Optional from starlette.requests import Request from starlette.responses import Response @@ -161,7 +161,7 @@ async def cache_response( request: Request, response: Response, storage: BaseStorage, - ttl: tp.Optional[int] = None, + ttl: Optional[int] = None, ) -> None: """Saves response to cache. @@ -181,7 +181,7 @@ async def cache_response( async def get_cached_response( self, cache_key: str, storage: BaseStorage - ) -> tp.Optional[Response]: + ) -> Optional[Response]: """Gets cached response if it exists and is valid. Args: diff --git a/fast_cache_middleware/depends.py b/fast_cache_middleware/depends.py index 326eca7..9b4528a 100644 --- a/fast_cache_middleware/depends.py +++ b/fast_cache_middleware/depends.py @@ -1,5 +1,5 @@ import re -import typing as tp +from typing import Callable, Optional from fastapi import params from starlette.requests import Request @@ -29,7 +29,7 @@ class CacheConfig(BaseCacheConfigDepends): def __init__( self, max_age: int = 5 * 60, - key_func: tp.Optional[tp.Callable[[Request], str]] = None, + key_func: Optional[Callable[[Request], str]] = None, ) -> None: self.max_age = max_age self.key_func = key_func diff --git a/fast_cache_middleware/serializers.py b/fast_cache_middleware/serializers.py index 2314e97..365e235 100644 --- a/fast_cache_middleware/serializers.py +++ b/fast_cache_middleware/serializers.py @@ -1,23 +1,21 @@ import json -import typing as tp +from typing import Any, Callable, Dict, Optional, Tuple, TypeAlias, Union from starlette.requests import Request from starlette.responses import Response # Define types for metadata and stored response -Metadata: tp.TypeAlias = tp.Dict[str, tp.Any] # todo: make it models -StoredResponse: tp.TypeAlias = tp.Tuple[Response, Request, Metadata] +Metadata: TypeAlias = Dict[str, Any] # todo: make it models +StoredResponse: TypeAlias = Tuple[Response, Request, Metadata] class BaseSerializer: def dumps( self, response: Response, request: Request, metadata: Metadata - ) -> tp.Union[str, bytes]: + ) -> Union[str, bytes]: raise NotImplementedError() - def loads( - self, data: tp.Union[str, bytes] - ) -> tp.Tuple[Response, Request, Metadata]: + def loads(self, data: Union[str, bytes]) -> Tuple[Response, Request, Metadata]: raise NotImplementedError() @property @@ -29,7 +27,7 @@ class JSONSerializer(BaseSerializer): def dumps(self, response: Response, request: Request, metadata: Metadata) -> str: raise NotImplementedError() # fixme: bad implementation now, maybe async? - def loads(self, data: tp.Union[str, bytes]) -> StoredResponse: + def loads(self, data: Union[str, bytes]) -> StoredResponse: if isinstance(data, bytes): data = data.decode() @@ -63,7 +61,7 @@ def loads(self, data: tp.Union[str, bytes]) -> StoredResponse: } # Create empty receive function - async def receive() -> tp.Dict[str, tp.Any]: + async def receive() -> Dict[str, Any]: return {"type": "http.request", "body": b""} request = Request(scope, receive) diff --git a/fast_cache_middleware/storages.py b/fast_cache_middleware/storages.py index 8e04030..8dbeed8 100644 --- a/fast_cache_middleware/storages.py +++ b/fast_cache_middleware/storages.py @@ -1,8 +1,8 @@ import logging import re import time -import typing as tp from collections import OrderedDict +from typing import Any, Dict, Optional, Tuple, Union from starlette.requests import Request from starlette.responses import Response @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) # Define type for stored response -StoredResponse: TypeAlias = tp.Tuple[Response, Request, Metadata] +StoredResponse: TypeAlias = Tuple[Response, Request, Metadata] # Define base class for cache storage @@ -28,8 +28,8 @@ class BaseStorage: def __init__( self, - serializer: tp.Optional[BaseSerializer] = None, - ttl: tp.Optional[tp.Union[int, float]] = None, + serializer: Optional[BaseSerializer] = None, + ttl: Optional[Union[int, float]] = None, ) -> None: self._serializer = serializer or JSONSerializer() @@ -43,7 +43,7 @@ async def store( ) -> None: raise NotImplementedError() - async def retrieve(self, key: str) -> tp.Optional[StoredResponse]: + async def retrieve(self, key: str) -> Optional[StoredResponse]: raise NotImplementedError() async def remove(self, path: re.Pattern) -> None: @@ -70,8 +70,8 @@ class InMemoryStorage(BaseStorage): def __init__( self, max_size: int = 1000, - serializer: tp.Optional[BaseSerializer] = None, - ttl: tp.Optional[tp.Union[int, float]] = None, + serializer: Optional[BaseSerializer] = None, + ttl: Optional[Union[int, float]] = None, ) -> None: super().__init__(serializer=serializer, ttl=ttl) @@ -87,7 +87,7 @@ def __init__( # OrderedDict for efficient LRU self._storage: OrderedDict[str, StoredResponse] = OrderedDict() # Separate expiry time storage for fast TTL checking - self._expiry_times: tp.Dict[str, float] = {} + self._expiry_times: Dict[str, float] = {} self._last_expiry_check_time: float = 0 self._expiry_check_interval: float = 60 @@ -126,7 +126,7 @@ async def store( self._cleanup_lru_items() - async def retrieve(self, key: str) -> tp.Optional[StoredResponse]: + async def retrieve(self, key: str) -> Optional[StoredResponse]: """Gets response from cache with lazy TTL checking. Element moves to the end to update LRU position. From 6a5362d7dff0668062cab9308844c000c5671a2e Mon Sep 17 00:00:00 2001 From: chud0 Date: Mon, 30 Jun 2025 22:48:13 +0300 Subject: [PATCH 5/7] fix import lint --- tests/test_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_controller.py b/tests/test_controller.py index 3e963e7..c6fdbb5 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -2,7 +2,6 @@ import typing as tp from datetime import UTC, datetime - from unittest.mock import AsyncMock, MagicMock import pytest From bd5e5737e2f5f43eae86cbe759ffef5980ccdb3c Mon Sep 17 00:00:00 2001 From: chud0 Date: Mon, 30 Jun 2025 23:10:08 +0300 Subject: [PATCH 6/7] install dev deps for lint && test --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24c05d6..cb6078c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' - run: poetry install --no-interaction --no-root + run: poetry install --no-interaction --no-root --with dev - name: Run lint run: | From 3b874dec95c0b6fa17c26c5752fc0eeae07e25f2 Mon Sep 17 00:00:00 2001 From: chud0 Date: Mon, 30 Jun 2025 23:21:30 +0300 Subject: [PATCH 7/7] invalidate ci venv cache on one week --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb6078c..2c70362 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,12 +28,17 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true + - name: Set cache key date + id: cache-date + run: echo "CACHE_DATE=$(date +%Y-%U)" >> $GITHUB_ENV + - name: Load cached venv id: cached-poetry-dependencies uses: actions/cache@v3 with: path: .venv - key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ env.CACHE_DATE }}-${{ hashFiles('**/poetry.lock') }} + - name: Install dependencies if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'