diff --git a/README.md b/README.md index 79aab868..2d3bd42b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ backends supporting Redis, Memcached, and Amazon DynamoDB. - Supports `redis`, `memcache`, `dynamodb`, and `in-memory` backends. - Easy integration with [FastAPI](https://fastapi.tiangolo.com/). - Support for HTTP cache headers like `ETag` and `Cache-Control`, as well as conditional `If-Match-None` requests. +- Dogpile prevention (cache stampede mitigation) to prevent multiple simultaneous cache refreshes. ## Requirements @@ -103,9 +104,71 @@ Parameter | type | default | description `key_builder` | `KeyBuilder` callable | `default_key_builder` | which key builder to use `injected_dependency_namespace` | `str` | `__fastapi_cache` | prefix for injected dependency keywords. `cache_status_header` | `str` | `X-FastAPI-Cache` | Name for the header on the response indicating if the request was served from cache; either `HIT` or `MISS`. +`enable_dogpile_prevention` | `bool` | `None` | Enable dogpile prevention (defaults to global setting) +`dogpile_grace_time` | `float` | `None` | Maximum time to wait for another request to complete (defaults to global setting) +`dogpile_wait_time` | `float` | `None` | Time to wait between checks (defaults to global setting) +`dogpile_max_wait_time` | `float` | `None` | Maximum total wait time (defaults to global setting) You can also use the `@cache` decorator on regular functions to cache their result. +### Dogpile Prevention + +`fastapi-cache` includes built-in dogpile prevention (also known as cache stampede prevention) to handle the common scenario where multiple concurrent requests try to refresh an expired cache entry simultaneously. + +When dogpile prevention is enabled: +1. The first request to find an expired cache entry will proceed to compute the new value +2. Subsequent requests for the same key will wait briefly for the first request to complete +3. Once the value is computed, all waiting requests will use the newly cached value +4. If the computation takes too long, waiting requests will eventually proceed independently + +This prevents overwhelming your backend when popular cache entries expire. + +#### Configuring Dogpile Prevention + +You can configure dogpile prevention globally when initializing FastAPICache: + +```python +from fastapi import FastAPI +from fastapi_cache import FastAPICache +from fastapi_cache.backends.redis import RedisBackend + +app = FastAPI() + +@app.on_event("startup") +async def startup(): + FastAPICache.init( + RedisBackend(redis_client), + prefix="my-app", + enable_dogpile_prevention=True, # Enable globally + dogpile_grace_time=60.0, # Wait up to 60 seconds for computation + dogpile_wait_time=0.1, # Check every 100ms + dogpile_max_wait_time=5.0, # Wait maximum 5 seconds + ) +``` + +Or configure it per endpoint: + +```python +@app.get("/expensive-computation") +@cache( + expire=300, + enable_dogpile_prevention=True, + dogpile_grace_time=30.0, # This endpoint's computation might take up to 30s +) +async def expensive_endpoint(): + # Expensive computation here + return compute_expensive_result() +``` + +To disable dogpile prevention for specific endpoints: + +```python +@app.get("/fast-endpoint") +@cache(expire=60, enable_dogpile_prevention=False) +async def fast_endpoint(): + return {"data": "fast"} +``` + ### Injected Request and Response dependencies The `cache` decorator injects dependencies for the `Request` and `Response` diff --git a/changelog.d/dogpile-prevention.feature.md b/changelog.d/dogpile-prevention.feature.md new file mode 100644 index 00000000..2885320f --- /dev/null +++ b/changelog.d/dogpile-prevention.feature.md @@ -0,0 +1,8 @@ +Add dogpile prevention (cache stampede mitigation) to prevent multiple simultaneous cache refreshes + +When enabled, if multiple requests arrive for an expired cache entry: +- The first request will compute the new value +- Subsequent requests will wait briefly for the computation to complete +- All waiting requests will use the newly cached value once available + +This feature can be configured globally or per-endpoint with customizable wait times and grace periods. \ No newline at end of file diff --git a/examples/dogpile_prevention/__init__.py b/examples/dogpile_prevention/__init__.py new file mode 100644 index 00000000..bd887393 --- /dev/null +++ b/examples/dogpile_prevention/__init__.py @@ -0,0 +1 @@ +# Dogpile prevention example for fastapi-cache diff --git a/examples/dogpile_prevention/main.py b/examples/dogpile_prevention/main.py new file mode 100644 index 00000000..6212d1c1 --- /dev/null +++ b/examples/dogpile_prevention/main.py @@ -0,0 +1,306 @@ +"""Example demonstrating dogpile prevention in fastapi-cache.""" +import asyncio +import time +from contextlib import asynccontextmanager +from typing import AsyncIterator + +import uvicorn +from fastapi import BackgroundTasks, FastAPI +from fastapi.responses import HTMLResponse +from fastapi_cache import FastAPICache +from fastapi_cache.backends.inmemory import InMemoryBackend +from fastapi_cache.decorator import cache +from starlette.requests import Request +from starlette.responses import Response + +# Track computation times for demonstration +computation_tracker = {} + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + # Initialize cache with dogpile prevention enabled + FastAPICache.init( + InMemoryBackend(), + prefix="dogpile-demo", + enable_dogpile_prevention=True, + dogpile_grace_time=30.0, # Allow 30 seconds for computation + dogpile_wait_time=0.1, # Check every 100ms + dogpile_max_wait_time=5.0, # Wait max 5 seconds + ) + yield + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/") +async def index(): + """Home page with example links.""" + return HTMLResponse(""" + +
+Dogpile prevention (also known as cache stampede prevention) ensures that when multiple + requests arrive for the same uncached resource, only one request computes the value while + others wait for the result.
+Try opening multiple tabs quickly for the same item!
+ +Compare the behavior when dogpile prevention is disabled.
+ +This will simulate 5 concurrent requests for the same resource.
+ +| Resource | +Start Time | +Duration | +Status | +Total Computations | +
|---|---|---|---|---|
| {key} | +{start_time} | +{duration} | +{comp['status']} | +{len(computations)} | +
Page auto-refreshes every 2 seconds
+ + + """ + + return HTMLResponse(stats_html) + + +@app.get("/clear-stats") +async def clear_stats(): + """Clear computation statistics.""" + computation_tracker.clear() + return {"message": "Statistics cleared", "redirect": "/"} + + +@app.get("/clear-cache") +async def clear_cache(): + """Clear all cached data.""" + await FastAPICache.clear() + return {"message": "Cache cleared", "items_cleared": "all"} + + +if __name__ == "__main__": + uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True) diff --git a/fastapi_cache/__init__.py b/fastapi_cache/__init__.py index 1c85203b..a1748336 100644 --- a/fastapi_cache/__init__.py +++ b/fastapi_cache/__init__.py @@ -25,6 +25,10 @@ class FastAPICache: _key_builder: ClassVar[Optional[KeyBuilder]] = None _cache_status_header: ClassVar[Optional[str]] = None _enable: ClassVar[bool] = True + _enable_dogpile_prevention: ClassVar[bool] = True + _dogpile_grace_time: ClassVar[float] = 60.0 + _dogpile_wait_time: ClassVar[float] = 0.1 + _dogpile_max_wait_time: ClassVar[float] = 5.0 @classmethod def init( @@ -36,6 +40,10 @@ def init( key_builder: KeyBuilder = default_key_builder, cache_status_header: str = "X-FastAPI-Cache", enable: bool = True, + enable_dogpile_prevention: bool = True, + dogpile_grace_time: float = 60.0, + dogpile_wait_time: float = 0.1, + dogpile_max_wait_time: float = 5.0, ) -> None: if cls._init: return @@ -47,6 +55,10 @@ def init( cls._key_builder = key_builder cls._cache_status_header = cache_status_header cls._enable = enable + cls._enable_dogpile_prevention = enable_dogpile_prevention + cls._dogpile_grace_time = dogpile_grace_time + cls._dogpile_wait_time = dogpile_wait_time + cls._dogpile_max_wait_time = dogpile_max_wait_time @classmethod def reset(cls) -> None: @@ -58,6 +70,10 @@ def reset(cls) -> None: cls._key_builder = None cls._cache_status_header = None cls._enable = True + cls._enable_dogpile_prevention = True + cls._dogpile_grace_time = 60.0 + cls._dogpile_wait_time = 0.1 + cls._dogpile_max_wait_time = 5.0 @classmethod def get_backend(cls) -> Backend: @@ -92,6 +108,22 @@ def get_cache_status_header(cls) -> str: def get_enable(cls) -> bool: return cls._enable + @classmethod + def get_enable_dogpile_prevention(cls) -> bool: + return cls._enable_dogpile_prevention + + @classmethod + def get_dogpile_grace_time(cls) -> float: + return cls._dogpile_grace_time + + @classmethod + def get_dogpile_wait_time(cls) -> float: + return cls._dogpile_wait_time + + @classmethod + def get_dogpile_max_wait_time(cls) -> float: + return cls._dogpile_max_wait_time + @classmethod async def clear( cls, namespace: Optional[str] = None, key: Optional[str] = None diff --git a/fastapi_cache/coder.py b/fastapi_cache/coder.py index 644c1821..80caec19 100644 --- a/fastapi_cache/coder.py +++ b/fastapi_cache/coder.py @@ -92,6 +92,42 @@ def decode_as_type(cls, value: bytes, *, type_: Optional[_T]) -> Union[_T, Any]: """ result = cls.decode(value) + + # If no type is specified, return the raw result + if type_ is None: + return result + + # Import here to avoid circular imports + import dataclasses + import inspect + from typing import get_origin + + BaseModel = None + try: + from pydantic import BaseModel as PydanticBaseModel + BaseModel = PydanticBaseModel + except ImportError: + pass + + # Handle different types + origin = get_origin(type_) or type_ + + # Handle tuples + if origin is tuple: + if isinstance(result, list): + return tuple(result) # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType] + return result + + # Handle Pydantic models + if BaseModel is not None and inspect.isclass(type_) and issubclass(type_, BaseModel): + if isinstance(result, dict): + return type_(**result) + return result + + # Handle dataclasses + if dataclasses.is_dataclass(type_) and isinstance(result, dict): + return type_(**result) + return result diff --git a/fastapi_cache/decorator.py b/fastapi_cache/decorator.py index 7df09e88..d7ed2c5d 100644 --- a/fastapi_cache/decorator.py +++ b/fastapi_cache/decorator.py @@ -1,3 +1,4 @@ +import asyncio import logging import sys from functools import wraps @@ -29,6 +30,7 @@ from fastapi_cache import FastAPICache from fastapi_cache.coder import Coder +from fastapi_cache.lock import OptimisticLock from fastapi_cache.types import KeyBuilder logger: logging.Logger = logging.getLogger(__name__) @@ -90,16 +92,24 @@ def cache( key_builder: Optional[KeyBuilder] = None, namespace: str = "", injected_dependency_namespace: str = "__fastapi_cache", + enable_dogpile_prevention: Optional[bool] = None, + dogpile_grace_time: Optional[float] = None, + dogpile_wait_time: Optional[float] = None, + dogpile_max_wait_time: Optional[float] = None, ) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[Union[R, Response]]]]: """ - cache all function - :param injected_dependency_namespace: - :param namespace: - :param expire: - :param coder: - :param key_builder: - - :return: + Cache decorator with dogpile prevention support. + + :param expire: Cache expiration time in seconds + :param coder: Encoder/decoder for cache values + :param key_builder: Function to build cache keys + :param namespace: Cache key namespace + :param injected_dependency_namespace: Namespace for injected dependencies + :param enable_dogpile_prevention: Enable dogpile/cache stampede prevention + :param dogpile_grace_time: Max time to wait for another request to compute the value + :param dogpile_wait_time: Time to wait between checks when another request is computing + :param dogpile_max_wait_time: Maximum total time to wait for another request + :return: Decorated function """ injected_request = Parameter( @@ -128,6 +138,10 @@ async def inner(*args: P.args, **kwargs: P.kwargs) -> Union[R, Response]: nonlocal coder nonlocal expire nonlocal key_builder + nonlocal enable_dogpile_prevention + nonlocal dogpile_grace_time + nonlocal dogpile_wait_time + nonlocal dogpile_max_wait_time async def ensure_async_func(*args: P.args, **kwargs: P.kwargs) -> R: """Run cached sync functions in thread pool just like FastAPI.""" @@ -162,6 +176,16 @@ async def ensure_async_func(*args: P.args, **kwargs: P.kwargs) -> R: backend = FastAPICache.get_backend() cache_status_header = FastAPICache.get_cache_status_header() + # Get dogpile prevention settings + if enable_dogpile_prevention is None: + enable_dogpile_prevention = FastAPICache.get_enable_dogpile_prevention() + if dogpile_grace_time is None: + dogpile_grace_time = FastAPICache.get_dogpile_grace_time() + if dogpile_wait_time is None: + dogpile_wait_time = FastAPICache.get_dogpile_wait_time() + if dogpile_max_wait_time is None: + dogpile_max_wait_time = FastAPICache.get_dogpile_max_wait_time() + cache_key = key_builder( func, f"{prefix}:{namespace}", @@ -184,16 +208,83 @@ async def ensure_async_func(*args: P.args, **kwargs: P.kwargs) -> R: ttl, cached = 0, None if cached is None or (request is not None and request.headers.get("Cache-Control") == "no-cache") : # cache miss - result = await ensure_async_func(*args, **kwargs) - to_cache = coder.encode(result) - - try: - await backend.set(cache_key, to_cache, expire) - except Exception: - logger.warning( - f"Error setting cache key '{cache_key}' in backend:", - exc_info=True, - ) + # Dogpile prevention logic + if enable_dogpile_prevention and cached is None: + lock = OptimisticLock(backend, cache_key, dogpile_grace_time) + + # Check if another request is already computing + is_computing, remaining_time = await lock.is_computing() + + if is_computing and remaining_time: + # Another request is computing, wait for it + logger.debug(f"Another request is computing {cache_key}, waiting...") + + total_wait_time = 0.0 + while total_wait_time < min(remaining_time, dogpile_max_wait_time): + await asyncio.sleep(dogpile_wait_time) + total_wait_time += dogpile_wait_time + + # Try to get the value again + try: + ttl, cached = await backend.get_with_ttl(cache_key) + if cached is not None: + # Value was computed by another request + logger.debug(f"Got value for {cache_key} after waiting {total_wait_time}s") + break + except Exception as e: + logger.debug(f"Error while checking cache during dogpile wait: {e}") + + # If we got a value, treat it as a cache hit + if cached is not None: + if response: + etag = f"W/{hash(cached)}" + response.headers.update( + { + "Cache-Control": f"max-age={ttl}", + "ETag": etag, + cache_status_header: "HIT", + } + ) + + if_none_match = request and request.headers.get("if-none-match") + if if_none_match == etag: + response.status_code = HTTP_304_NOT_MODIFIED + return response + + result = cast(R, coder.decode_as_type(cached, type_=return_type)) + return result + + # Mark that we're computing + await lock.start_computing() + + try: + # Compute the value + result = await ensure_async_func(*args, **kwargs) + to_cache = coder.encode(result) + + # Store in cache + try: + await backend.set(cache_key, to_cache, expire) + except Exception: + logger.warning( + f"Error setting cache key '{cache_key}' in backend:", + exc_info=True, + ) + finally: + # Clear the computing flag + await lock.finish_computing() + else: + # Dogpile prevention disabled or forced refresh + result = await ensure_async_func(*args, **kwargs) + to_cache = coder.encode(result) + + try: + await backend.set(cache_key, to_cache, expire) + except Exception: + logger.warning( + f"Error setting cache key '{cache_key}' in backend:", + exc_info=True, + ) if response: response.headers.update( diff --git a/fastapi_cache/lock.py b/fastapi_cache/lock.py new file mode 100644 index 00000000..4afa20b3 --- /dev/null +++ b/fastapi_cache/lock.py @@ -0,0 +1,220 @@ +"""Lock implementations for dogpile prevention in fastapi-cache.""" +import asyncio +import logging +import time +import uuid +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type, cast + +from fastapi_cache.types import Backend + +if TYPE_CHECKING: + from fastapi_cache.backends.redis import RedisBackend + +logger = logging.getLogger(__name__) + + +class LockError(Exception): + """Base exception for lock-related errors.""" + pass + + +class DogpileLock(ABC): + """Abstract base class for dogpile prevention locks.""" + + def __init__(self, backend: Backend, key: str, lease: float): + self.backend = backend + self.key = key + self.lease = lease + self._lock_key = f"__lock:{key}" + self._identifier = str(uuid.uuid4()) + + @abstractmethod + async def acquire(self) -> bool: + """Try to acquire the lock.""" + raise NotImplementedError + + @abstractmethod + async def release(self) -> bool: + """Release the lock.""" + raise NotImplementedError + + async def __aenter__(self) -> "DogpileLock": + """Async context manager entry.""" + if not await self.acquire(): + raise LockError(f"Failed to acquire lock for key: {self.key}") + return self + + async def __aexit__(self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[Any]) -> None: + """Async context manager exit.""" + await self.release() + + +class RedisLock(DogpileLock): + """Redis-based implementation of dogpile lock using SET NX with expiration.""" + + async def acquire(self) -> bool: + """Try to acquire the lock using Redis SET NX.""" + try: + # Try to set the lock key with NX (only if not exists) and EX (expiration) + # We need to use the raw Redis client for this + if hasattr(self.backend, 'redis'): + # For RedisBackend + redis_backend = cast("RedisBackend", self.backend) + result = await redis_backend.redis.set( # type: ignore[union-attr] + self._lock_key, + self._identifier.encode(), + nx=True, + ex=int(self.lease) + ) + return bool(result) # pyright: ignore[reportUnknownArgumentType] + else: + # Fallback for other backends - use basic set if get is None + existing = await self.backend.get(self._lock_key) + if existing is None: + await self.backend.set(self._lock_key, self._identifier.encode(), int(self.lease)) + return True + return False + except Exception: + return False + + async def release(self) -> bool: + """Release the lock only if we own it.""" + try: + if hasattr(self.backend, 'redis'): + # Use Lua script to ensure atomic check-and-delete + lua_script = """ + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + """ + redis_backend = cast("RedisBackend", self.backend) + result = await redis_backend.redis.eval( # type: ignore[union-attr] + lua_script, + 1, + self._lock_key, + self._identifier + ) + return bool(result) # pyright: ignore[reportUnknownArgumentType] + else: + # Fallback - just delete (not atomic but better than nothing) + value = await self.backend.get(self._lock_key) + if value and value.decode() == self._identifier: + await self.backend.clear(key=self._lock_key) + return True + return False + except Exception: + return False + + +class InMemoryLock(DogpileLock): + """In-memory implementation of dogpile lock for single-process scenarios.""" + + # Class-level lock storage + _locks: Dict[str, Tuple[float, str]] = {} + _lock: asyncio.Lock = asyncio.Lock() + + async def acquire(self) -> bool: + """Try to acquire the lock in memory.""" + async with self._lock: + now = time.time() + + # Check if lock exists and is still valid + if self._lock_key in self._locks: + lock_time, _ = self._locks[self._lock_key] + if now < lock_time + self.lease: + # Lock is still valid + return False + + # Acquire the lock + self._locks[self._lock_key] = (now, self._identifier) + return True + + async def release(self) -> bool: + """Release the lock if we own it.""" + async with self._lock: + if self._lock_key in self._locks: + _, lock_id = self._locks[self._lock_key] + if lock_id == self._identifier: + del self._locks[self._lock_key] + return True + return False + + +class OptimisticLock: + """ + Optimistic lock implementation for dogpile prevention. + + This lock allows the first request to proceed with computation while + subsequent requests wait and then use the computed value. + """ + + def __init__(self, backend: Backend, key: str, grace_time: float = 60.0): + self.backend = backend + self.key = key + self.grace_time = grace_time + self._computing_key = f"__computing:{key}" + self._identifier = str(uuid.uuid4()) + self._start_time: Optional[float] = None + + async def is_computing(self) -> Tuple[bool, Optional[float]]: + """Check if another process is computing the value.""" + computing_data = await self.backend.get(self._computing_key) + if computing_data: + try: + # Store timestamp when computation started + start_time = float(computing_data.decode()) + elapsed = time.time() - start_time + if elapsed < self.grace_time: + return True, self.grace_time - elapsed + except (ValueError, AttributeError): + pass + return False, None + + async def start_computing(self) -> bool: + """Mark that we're starting to compute the value.""" + # Try to set the computing flag with current timestamp + try: + # Check if someone else is already computing + is_computing, _ = await self.is_computing() + if is_computing: + return False + + # Set the computing flag with current timestamp + self._start_time = time.time() + await self.backend.set( + self._computing_key, + str(self._start_time).encode(), + int(self.grace_time) + ) + return True + except Exception: + return False + + async def finish_computing(self) -> None: + """Mark that we've finished computing the value.""" + try: + # Only clear if we were the ones computing + if self._start_time: + computing_data = await self.backend.get(self._computing_key) + if computing_data: + stored_time = float(computing_data.decode()) + if abs(stored_time - self._start_time) < 0.001: # Allow small float precision errors + await self.backend.clear(key=self._computing_key) + except Exception as e: + logger.debug(f"Error while finishing computing for key {self._computing_key}: {e}") + + +def create_lock(backend: Backend, key: str, lease: float) -> DogpileLock: + """Factory function to create appropriate lock based on backend type.""" + backend_class = backend.__class__.__name__ + + if backend_class == "RedisBackend": + return RedisLock(backend, key, lease) + elif backend_class == "InMemoryBackend": + return InMemoryLock(backend, key, lease) + else: + # For other backends, try Redis-style lock first + return RedisLock(backend, key, lease) diff --git a/poetry.lock b/poetry.lock index bd21407c..7b88e117 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiobotocore" @@ -6,6 +6,8 @@ version = "2.15.0" description = "Async client for aws services using botocore and aiohttp" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dynamodb\" or extra == \"all\"" files = [ {file = "aiobotocore-2.15.0-py3-none-any.whl", hash = "sha256:6d0b4a51d70bc33b1b4eba411076b0cc979aecbdad8e084bab202202423c0725"}, {file = "aiobotocore-2.15.0.tar.gz", hash = "sha256:988eef33fd9dd4b070959cfec922278e84166950695b2160bd581623cb6a420c"}, @@ -27,6 +29,8 @@ version = "2.4.0" description = "Happy Eyeballs for asyncio" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dynamodb\" or extra == \"all\"" files = [ {file = "aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd"}, {file = "aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2"}, @@ -38,6 +42,8 @@ version = "3.10.5" description = "Async http client/server framework (asyncio)" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dynamodb\" or extra == \"all\"" files = [ {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3"}, {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6"}, @@ -142,7 +148,7 @@ multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] [[package]] name = "aioitertools" @@ -150,6 +156,8 @@ version = "0.12.0" description = "itertools and builtins for AsyncIO and mixed iterables" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dynamodb\" or extra == \"all\"" files = [ {file = "aioitertools-0.12.0-py3-none-any.whl", hash = "sha256:fc1f5fac3d737354de8831cbba3eb04f79dd649d8f3afb4c5b114925e662a796"}, {file = "aioitertools-0.12.0.tar.gz", hash = "sha256:c2a9055b4fbb7705f561b9d86053e8af5d10cc845d22c32008c43490b2d8dd6b"}, @@ -168,6 +176,8 @@ version = "0.8.2" description = "Minimal pure python memcached client" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"memcache\" or extra == \"all\"" files = [ {file = "aiomcache-0.8.2-py3-none-any.whl", hash = "sha256:9d78d6b6e74e775df18b350b1cddfa96bd2f0a44d49ad27fa87759a3469cef5e"}, {file = "aiomcache-0.8.2.tar.gz", hash = "sha256:43b220d7f499a32a71871c4f457116eb23460fa216e69c1d32b81e3209e51359"}, @@ -182,6 +192,8 @@ version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"dynamodb\" or extra == \"all\"" files = [ {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, @@ -196,6 +208,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -210,6 +223,7 @@ version = "4.4.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, @@ -223,7 +237,7 @@ typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] trio = ["trio (>=0.23)"] [[package]] @@ -232,6 +246,8 @@ version = "4.0.3" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.7" +groups = ["main"] +markers = "python_version <= \"3.10\" and (extra == \"dynamodb\" or extra == \"all\" or extra == \"redis\") or python_full_version < \"3.11.3\" and (extra == \"redis\" or extra == \"all\")" files = [ {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, @@ -243,18 +259,20 @@ version = "24.2.0" description = "Classes Without Boilerplate" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"dynamodb\" or extra == \"all\"" files = [ {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] [[package]] name = "backports-tarfile" @@ -262,6 +280,8 @@ version = "1.2.0" description = "Backport of CPython tarfile module" optional = false python-versions = ">=3.8" +groups = ["distributing"] +markers = "python_version >= \"3.10\" and python_version < \"3.12\"" files = [ {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, @@ -277,6 +297,8 @@ version = "0.2.1" description = "Backport of the standard library zoneinfo module" optional = false python-versions = ">=3.6" +groups = ["main"] +markers = "python_version == \"3.8\"" files = [ {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, @@ -305,6 +327,8 @@ version = "1.35.16" description = "Low-level, data-driven core of boto 3." optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dynamodb\" or extra == \"all\"" files = [ {file = "botocore-1.35.16-py3-none-any.whl", hash = "sha256:3564a980d95ff2861a6ca74313173d8778aa659125c63cf49c93ad23896c63b1"}, {file = "botocore-1.35.16.tar.gz", hash = "sha256:1b48c94e8a4bbe23143f3d1c21a32b9ffc7476b651ef42371ab45d678f6dbfbc"}, @@ -314,8 +338,8 @@ files = [ jmespath = ">=0.7.1,<2.0.0" python-dateutil = ">=2.1,<3.0.0" urllib3 = [ - {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}, {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, + {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}, ] [package.extras] @@ -327,6 +351,8 @@ version = "1.35.22" description = "Type annotations and code completion for botocore" optional = false python-versions = ">=3.8" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "botocore_stubs-1.35.22-py3-none-any.whl", hash = "sha256:dae8cc433df3044ece464f4985e67a154bab51fd042757d0c59e4a9ea26d0376"}, {file = "botocore_stubs-1.35.22.tar.gz", hash = "sha256:14dec6b00d974dce4747d6917a791bcea0d8efd1c8959821d8ce6ca69ce40d70"}, @@ -344,6 +370,7 @@ version = "5.5.0" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, @@ -355,10 +382,12 @@ version = "2024.8.30" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["dev", "distributing"] files = [ {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] +markers = {distributing = "python_version >= \"3.10\""} [[package]] name = "cffi" @@ -366,6 +395,7 @@ version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" +groups = ["distributing", "linting"] files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -435,6 +465,7 @@ files = [ {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] +markers = {distributing = "sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" and python_version >= \"3.10\"", linting = "platform_python_implementation != \"PyPy\" and python_version >= \"3.10\""} [package.dependencies] pycparser = "*" @@ -445,6 +476,7 @@ version = "5.2.0" description = "Universal encoding detector for Python 3" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, @@ -456,6 +488,7 @@ version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" +groups = ["dev", "distributing"] files = [ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, @@ -548,6 +581,7 @@ files = [ {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] +markers = {distributing = "python_version >= \"3.10\""} [[package]] name = "click" @@ -555,6 +589,7 @@ version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, @@ -569,6 +604,7 @@ version = "1.2.4" description = "click_default_group" optional = false python-versions = ">=2.7" +groups = ["dev"] files = [ {file = "click_default_group-1.2.4-py2.py3-none-any.whl", hash = "sha256:9b60486923720e7fc61731bdb32b617039aba820e22e1c88766b1125592eaa5f"}, {file = "click_default_group-1.2.4.tar.gz", hash = "sha256:eb3f3c99ec0d456ca6cd2a7f08f7d4e91771bef51b01bdd9580cc6450fe1251e"}, @@ -586,10 +622,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\""} [[package]] name = "coverage" @@ -597,6 +635,7 @@ version = "7.6.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, @@ -673,7 +712,7 @@ files = [ ] [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" @@ -681,6 +720,7 @@ version = "43.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" +groups = ["distributing", "linting"] files = [ {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, @@ -710,6 +750,7 @@ files = [ {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, ] +markers = {distributing = "sys_platform == \"linux\" and python_version >= \"3.10\"", linting = "python_version >= \"3.10\""} [package.dependencies] cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} @@ -730,6 +771,7 @@ version = "0.3.8" description = "Distribution utilities" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, @@ -741,6 +783,8 @@ version = "0.21.2" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.9" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, @@ -752,6 +796,8 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version <= \"3.10\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -766,6 +812,7 @@ version = "0.115.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "fastapi-0.115.0-py3-none-any.whl", hash = "sha256:17ea427674467486e997206a5ab25760f6b09e069f099b96f5b55a32fb6f1631"}, {file = "fastapi-0.115.0.tar.gz", hash = "sha256:f93b4ca3529a8ebc6fc3fcf710e5efa8de3df9b41570958abf1d97d843138004"}, @@ -786,6 +833,7 @@ version = "3.16.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, @@ -794,7 +842,7 @@ files = [ [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] -typing = ["typing-extensions (>=4.12.2)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] [[package]] name = "frozenlist" @@ -802,6 +850,8 @@ version = "1.4.1" description = "A list-like structure which implements collections.abc.MutableSequence" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dynamodb\" or extra == \"all\"" files = [ {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, @@ -888,6 +938,7 @@ version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, @@ -899,6 +950,7 @@ version = "1.0.5" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, @@ -920,6 +972,7 @@ version = "0.27.2" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, @@ -933,7 +986,7 @@ idna = "*" sniffio = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -945,10 +998,12 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main", "dev", "distributing"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] +markers = {distributing = "python_version >= \"3.10\""} [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] @@ -959,6 +1014,8 @@ version = "8.5.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, @@ -968,12 +1025,12 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -982,6 +1039,8 @@ version = "6.4.5" description = "Read resources from Python packages" optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "python_version == \"3.8\"" files = [ {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, @@ -991,7 +1050,7 @@ files = [ zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] @@ -1004,6 +1063,7 @@ version = "24.7.2" description = "A small library that versions your Python projects." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "incremental-24.7.2-py3-none-any.whl", hash = "sha256:8cb2c3431530bec48ad70513931a760f446ad6c25e8333ca5d95e24b0ed7b8fe"}, {file = "incremental-24.7.2.tar.gz", hash = "sha256:fb4f1d47ee60efe87d4f6f0ebb5f70b9760db2b2574c59c8e8912be4ebd464c9"}, @@ -1022,6 +1082,7 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -1033,6 +1094,8 @@ version = "3.4.0" description = "Utility functions for Python class constructs" optional = false python-versions = ">=3.8" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, @@ -1051,6 +1114,8 @@ version = "6.0.1" description = "Useful decorators and context managers" optional = false python-versions = ">=3.8" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4"}, {file = "jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3"}, @@ -1061,7 +1126,7 @@ files = [ [package.extras] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] [[package]] name = "jaraco-functools" @@ -1069,6 +1134,8 @@ version = "4.0.2" description = "Functools like those found in stdlib" optional = false python-versions = ">=3.8" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "jaraco.functools-4.0.2-py3-none-any.whl", hash = "sha256:c9d16a3ed4ccb5a889ad8e0b7a343401ee5b2a71cee6ed192d3f68bc351e94e3"}, {file = "jaraco_functools-4.0.2.tar.gz", hash = "sha256:3460c74cd0d32bf82b9576bbb3527c4364d5b27a21f5158a62aed6c4b42e23f5"}, @@ -1079,7 +1146,7 @@ more-itertools = "*" [package.extras] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -test = ["jaraco.classes", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +test = ["jaraco.classes", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] [[package]] name = "jeepney" @@ -1087,6 +1154,8 @@ version = "0.8.0" description = "Low-level, pure Python DBus protocol wrapper." optional = false python-versions = ">=3.7" +groups = ["distributing"] +markers = "sys_platform == \"linux\" and python_version >= \"3.10\"" files = [ {file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"}, {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"}, @@ -1094,7 +1163,7 @@ files = [ [package.extras] test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] -trio = ["async_generator", "trio"] +trio = ["async_generator ; python_version == \"3.6\"", "trio"] [[package]] name = "jinja2" @@ -1102,6 +1171,7 @@ version = "3.1.4" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, @@ -1119,6 +1189,8 @@ version = "1.0.1" description = "JSON Matching Expressions" optional = true python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"dynamodb\" or extra == \"all\"" files = [ {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, @@ -1130,6 +1202,8 @@ version = "25.4.0" description = "Store and access your passwords safely." optional = false python-versions = ">=3.8" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "keyring-25.4.0-py3-none-any.whl", hash = "sha256:a2a630d5c9bef5d3f0968d15ef4e42b894a83e17494edcb67b154c36491c9920"}, {file = "keyring-25.4.0.tar.gz", hash = "sha256:ae8263fd9264c94f91ad82d098f8a5bb1b7fa71ce0a72388dc4fc0be3f6a034e"}, @@ -1145,7 +1219,7 @@ pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] completion = ["shtab (>=1.1.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] @@ -1159,6 +1233,8 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -1183,6 +1259,7 @@ version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, @@ -1252,6 +1329,8 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1263,6 +1342,8 @@ version = "10.5.0" description = "More routines for operating on iterables, beyond itertools" optional = false python-versions = ">=3.8" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "more-itertools-10.5.0.tar.gz", hash = "sha256:5482bfef7849c25dc3c6dd53a6173ae4795da2a41a80faea6700d9f5846c5da6"}, {file = "more_itertools-10.5.0-py3-none-any.whl", hash = "sha256:037b0d3203ce90cca8ab1defbbdac29d5f993fc20131f3664dc8d6acfa872aef"}, @@ -1274,6 +1355,8 @@ version = "6.1.0" description = "multidict implementation" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dynamodb\" or extra == \"all\"" files = [ {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, @@ -1378,6 +1461,8 @@ version = "1.11.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, @@ -1425,6 +1510,8 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -1436,6 +1523,8 @@ version = "0.2.18" description = "Python bindings to the ammonia HTML sanitization library." optional = false python-versions = "*" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:14c5a72e9fe82aea5fe3072116ad4661af5cf8e8ff8fc5ad3450f123e4925e86"}, {file = "nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7b7c2a3c9eb1a827d42539aa64091640bd275b81e097cd1d8d82ef91ffa2e811"}, @@ -1461,6 +1550,8 @@ version = "1.9.1" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, @@ -1472,6 +1563,7 @@ version = "24.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, @@ -1483,6 +1575,7 @@ version = "3.0.0" description = "Python datetimes made easy" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pendulum-3.0.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2cf9e53ef11668e07f73190c805dbdf07a1939c3298b78d5a9203a86775d1bfd"}, {file = "pendulum-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fb551b9b5e6059377889d2d878d940fd0bbb80ae4810543db18e6f77b02c5ef6"}, @@ -1576,7 +1669,7 @@ python-dateutil = ">=2.6" tzdata = ">=2020.1" [package.extras] -test = ["time-machine (>=2.6.0)"] +test = ["time-machine (>=2.6.0) ; implementation_name != \"pypy\""] [[package]] name = "pkginfo" @@ -1584,6 +1677,8 @@ version = "1.11.1" description = "Query metadata from sdists / bdists / installed packages." optional = false python-versions = ">=3.8" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "pkginfo-1.11.1-py3-none-any.whl", hash = "sha256:bfa76a714fdfc18a045fcd684dbfc3816b603d9d075febef17cb6582bea29573"}, {file = "pkginfo-1.11.1.tar.gz", hash = "sha256:2e0dca1cf4c8e39644eed32408ea9966ee15e0d324c62ba899a393b3c6b467aa"}, @@ -1598,6 +1693,7 @@ version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, @@ -1614,6 +1710,7 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -1629,10 +1726,12 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" +groups = ["distributing", "linting"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] +markers = {distributing = "sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" and python_version >= \"3.10\"", linting = "platform_python_implementation != \"PyPy\" and python_version >= \"3.10\""} [[package]] name = "pydantic" @@ -1640,6 +1739,7 @@ version = "2.9.2" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, @@ -1649,13 +1749,13 @@ files = [ annotated-types = ">=0.6.0" pydantic-core = "2.23.4" typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, ] [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and sys_platform == \"win32\""] [[package]] name = "pydantic-core" @@ -1663,6 +1763,7 @@ version = "2.23.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, @@ -1764,6 +1865,8 @@ version = "2.18.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, @@ -1778,6 +1881,7 @@ version = "1.8.0" description = "API to interact with the python pyproject.toml based projects" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pyproject_api-1.8.0-py3-none-any.whl", hash = "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228"}, {file = "pyproject_api-1.8.0.tar.gz", hash = "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496"}, @@ -1797,6 +1901,8 @@ version = "1.1.381" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "pyright-1.1.381-py3-none-any.whl", hash = "sha256:5dc0aa80a265675d36abab59c674ae01dbe476714f91845b61b841d34aa99081"}, {file = "pyright-1.1.381.tar.gz", hash = "sha256:314cf0c1351c189524fb10c7ac20688ecd470e8cc505c394d642c9c80bf7c3a5"}, @@ -1815,6 +1921,7 @@ version = "8.3.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, @@ -1831,12 +1938,32 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.21.2" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b"}, + {file = "pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -1851,6 +1978,8 @@ version = "0.2.3" description = "A (partial) reimplementation of pywin32 using ctypes/cffi" optional = false python-versions = ">=3.6" +groups = ["distributing"] +markers = "sys_platform == \"win32\" and python_version >= \"3.10\"" files = [ {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, @@ -1862,6 +1991,8 @@ version = "44.0" description = "readme_renderer is a library for rendering readme descriptions for Warehouse" optional = false python-versions = ">=3.9" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151"}, {file = "readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1"}, @@ -1881,6 +2012,8 @@ version = "5.0.8" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"redis\" or extra == \"all\"" files = [ {file = "redis-5.0.8-py3-none-any.whl", hash = "sha256:56134ee08ea909106090934adc36f65c9bcbbaecea5b21ba704ba6fb561f8eb4"}, {file = "redis-5.0.8.tar.gz", hash = "sha256:0c5b10d387568dfe0698c6fad6615750c24170e548ca2deac10c649d463e9870"}, @@ -1899,10 +2032,12 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["dev", "distributing"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] +markers = {distributing = "python_version >= \"3.10\""} [package.dependencies] certifi = ">=2017.4.17" @@ -1920,6 +2055,8 @@ version = "1.0.0" description = "A utility belt for advanced users of python-requests" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, @@ -1934,6 +2071,8 @@ version = "2.0.0" description = "Validating URI References per RFC 3986" optional = false python-versions = ">=3.7" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"}, {file = "rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"}, @@ -1948,6 +2087,8 @@ version = "13.8.1" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.7.0" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"}, {file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"}, @@ -1966,6 +2107,8 @@ version = "0.1.1" description = "An extremely fast Python linter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "ruff-0.1.1-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:b7cdc893aef23ccc14c54bd79a8109a82a2c527e11d030b62201d86f6c2b81c5"}, {file = "ruff-0.1.1-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:620d4b34302538dbd8bbbe8fdb8e8f98d72d29bd47e972e2b59ce6c1e8862257"}, @@ -1992,6 +2135,8 @@ version = "3.3.3" description = "Python bindings to FreeDesktop.org Secret Service API" optional = false python-versions = ">=3.6" +groups = ["distributing"] +markers = "sys_platform == \"linux\" and python_version >= \"3.10\"" files = [ {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"}, {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"}, @@ -2007,19 +2152,20 @@ version = "75.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "setuptools-75.1.0-py3-none-any.whl", hash = "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2"}, {file = "setuptools-75.1.0.tar.gz", hash = "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.5.2) ; sys_platform != \"cygwin\""] +core = ["importlib-metadata (>=6) ; python_version < \"3.10\"", "importlib-resources (>=5.10.2) ; python_version < \"3.9\"", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.11.*)", "pytest-mypy"] [[package]] name = "six" @@ -2027,6 +2173,7 @@ version = "1.16.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -2038,6 +2185,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -2049,6 +2197,7 @@ version = "0.38.5" description = "The little ASGI library that shines." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "starlette-0.38.5-py3-none-any.whl", hash = "sha256:632f420a9d13e3ee2a6f18f437b0a9f1faecb0bc42e1942aa2ea0e379a4c4206"}, {file = "starlette-0.38.5.tar.gz", hash = "sha256:04a92830a9b6eb1442c766199d62260c3d4dc9c4f9188360626b1e0273cb7077"}, @@ -2067,10 +2216,12 @@ version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" +groups = ["dev", "linting"] files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +markers = {dev = "python_version <= \"3.10\"", linting = "python_version == \"3.10\""} [[package]] name = "towncrier" @@ -2078,6 +2229,7 @@ version = "22.12.0" description = "Building newsfiles for your project." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "towncrier-22.12.0-py3-none-any.whl", hash = "sha256:9767a899a4d6856950f3598acd9e8f08da2663c49fdcda5ea0f9e6ba2afc8eea"}, {file = "towncrier-22.12.0.tar.gz", hash = "sha256:9c49d7e75f646a9aea02ae904c0bc1639c8fd14a01292d2b123b8d307564034d"}, @@ -2100,6 +2252,7 @@ version = "4.20.0" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "tox-4.20.0-py3-none-any.whl", hash = "sha256:21a8005e3d3fe5658a8e36b8ca3ed13a4230429063c5cc2a2fdac6ee5aa0de34"}, {file = "tox-4.20.0.tar.gz", hash = "sha256:5b78a49b6eaaeab3ae4186415e7c97d524f762ae967c63562687c3e5f0ec23d5"}, @@ -2119,7 +2272,7 @@ virtualenv = ">=20.26.3" [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-argparse-cli (>=1.17)", "sphinx-autodoc-typehints (>=2.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=24.8)"] -testing = ["build[virtualenv] (>=1.2.2)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=9.1.1)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=74.1.2)", "time-machine (>=2.15)", "wheel (>=0.44)"] +testing = ["build[virtualenv] (>=1.2.2)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=9.1.1)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=74.1.2)", "time-machine (>=2.15) ; implementation_name != \"pypy\"", "wheel (>=0.44)"] [[package]] name = "twine" @@ -2127,6 +2280,8 @@ version = "4.0.2" description = "Collection of utilities for publishing packages on PyPI" optional = false python-versions = ">=3.7" +groups = ["distributing"] +markers = "python_version >= \"3.10\"" files = [ {file = "twine-4.0.2-py3-none-any.whl", hash = "sha256:929bc3c280033347a00f847236564d1c52a3e61b1ac2516c97c48f3ceab756d8"}, {file = "twine-4.0.2.tar.gz", hash = "sha256:9e102ef5fdd5a20661eb88fad46338806c3bd32cf1db729603fe3697b1bc83c8"}, @@ -2149,6 +2304,8 @@ version = "2.15.0" description = "Type annotations for aiobotocore 2.15.0 generated with mypy-boto3-builder 8.0.1" optional = false python-versions = ">=3.8" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "types_aiobotocore-2.15.0-py3-none-any.whl", hash = "sha256:a3b9ccfb7f1a7689af564f7e9079ac76d701a1b0757cf6235de433520bdfae80"}, {file = "types_aiobotocore-2.15.0.tar.gz", hash = "sha256:eb9c21780cab3887baaf0ae3ca02c5fe0ab7b988f4a9a5c43e7aff2f894d7ca7"}, @@ -2556,6 +2713,8 @@ version = "2.15.0.post1" description = "Type annotations for aiobotocore.DynamoDB 2.15.0 service generated with mypy-boto3-builder 8.1.1" optional = false python-versions = ">=3.8" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "types_aiobotocore_dynamodb-2.15.0.post1-py3-none-any.whl", hash = "sha256:f52658e5626ed1b6fc97f60b18c0c3d1d919d84e8dfeb03bafd487dde1dda1b4"}, {file = "types_aiobotocore_dynamodb-2.15.0.post1.tar.gz", hash = "sha256:43ee250f274f4fc3a9ff895a3ffea6b54cbfce8eb5784296f68352a9cb2c2263"}, @@ -2570,6 +2729,8 @@ version = "0.21.5" description = "Type annotations and code completion for awscrt" optional = false python-versions = ">=3.8" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "types_awscrt-0.21.5-py3-none-any.whl", hash = "sha256:117ff2b1bb657f09d01b7e0ce3fe3fa6e039be12d30b826896182725c9ce85b1"}, {file = "types_awscrt-0.21.5.tar.gz", hash = "sha256:9f7f47de68799cb2bcb9e486f48d77b9f58962b92fba43cb8860da70b3c57d1b"}, @@ -2581,6 +2742,8 @@ version = "1.16.0.20240331" description = "Typing stubs for cffi" optional = false python-versions = ">=3.8" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "types-cffi-1.16.0.20240331.tar.gz", hash = "sha256:b8b20d23a2b89cfed5f8c5bc53b0cb8677c3aac6d970dbc771e28b9c698f5dee"}, {file = "types_cffi-1.16.0.20240331-py3-none-any.whl", hash = "sha256:a363e5ea54a4eb6a4a105d800685fde596bc318089b025b27dee09849fe41ff0"}, @@ -2595,6 +2758,8 @@ version = "24.1.0.20240722" description = "Typing stubs for pyOpenSSL" optional = false python-versions = ">=3.8" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39"}, {file = "types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54"}, @@ -2610,6 +2775,8 @@ version = "4.6.0.20240903" description = "Typing stubs for redis" optional = false python-versions = ">=3.8" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "types-redis-4.6.0.20240903.tar.gz", hash = "sha256:4bab1a378dbf23c2c95c370dfdb89a8f033957c4fd1a53fee71b529c182fe008"}, {file = "types_redis-4.6.0.20240903-py3-none-any.whl", hash = "sha256:0e7537e5c085fe96b7d468d5edae0cf667b4ba4b62c6e4a5dfc340bd3b868c23"}, @@ -2625,6 +2792,8 @@ version = "75.1.0.20240917" description = "Typing stubs for setuptools" optional = false python-versions = ">=3.8" +groups = ["linting"] +markers = "python_version >= \"3.10\"" files = [ {file = "types-setuptools-75.1.0.20240917.tar.gz", hash = "sha256:12f12a165e7ed383f31def705e5c0fa1c26215dd466b0af34bd042f7d5331f55"}, {file = "types_setuptools-75.1.0.20240917-py3-none-any.whl", hash = "sha256:06f78307e68d1bbde6938072c57b81cf8a99bc84bd6dc7e4c5014730b097dc0c"}, @@ -2636,10 +2805,12 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main", "dev", "linting"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +markers = {dev = "python_version < \"3.13\"", linting = "python_version >= \"3.10\""} [[package]] name = "tzdata" @@ -2647,6 +2818,7 @@ version = "2024.1" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" +groups = ["main"] files = [ {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, @@ -2658,14 +2830,16 @@ version = "1.26.20" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e"}, {file = "urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32"}, ] +markers = {main = "python_version < \"3.10\" and (extra == \"dynamodb\" or extra == \"all\")", dev = "python_version < \"3.10\""} [package.extras] -brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and platform_python_implementation == \"CPython\"", "brotli (>=1.0.9) ; python_version >= \"3\" and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] @@ -2674,13 +2848,15 @@ version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" +groups = ["main", "dev", "distributing"] files = [ {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, ] +markers = {main = "python_version >= \"3.10\" and (extra == \"dynamodb\" or extra == \"all\")", dev = "python_version >= \"3.10\"", distributing = "python_version >= \"3.10\""} [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -2691,6 +2867,7 @@ version = "0.30.6" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, @@ -2702,7 +2879,7 @@ h11 = ">=0.8" typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] [[package]] name = "virtualenv" @@ -2710,6 +2887,7 @@ version = "20.26.5" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "virtualenv-20.26.5-py3-none-any.whl", hash = "sha256:4f3ac17b81fba3ce3bd6f4ead2749a72da5929c01774948e243db9ba41df4ff6"}, {file = "virtualenv-20.26.5.tar.gz", hash = "sha256:ce489cac131aa58f4b25e321d6d186171f78e6cb13fafbf32a840cee67733ff4"}, @@ -2722,7 +2900,7 @@ platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [[package]] name = "wrapt" @@ -2730,6 +2908,8 @@ version = "1.16.0" description = "Module for decorators, wrappers and monkey patching." optional = true python-versions = ">=3.6" +groups = ["main"] +markers = "extra == \"dynamodb\" or extra == \"all\"" files = [ {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, @@ -2809,6 +2989,8 @@ version = "1.11.1" description = "Yet another URL library" optional = true python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dynamodb\" or extra == \"all\"" files = [ {file = "yarl-1.11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:400cd42185f92de559d29eeb529e71d80dfbd2f45c36844914a4a34297ca6f00"}, {file = "yarl-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8258c86f47e080a258993eed877d579c71da7bda26af86ce6c2d2d072c11320d"}, @@ -2914,17 +3096,19 @@ version = "3.20.2" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" +groups = ["main", "distributing"] files = [ {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, ] +markers = {main = "python_version == \"3.8\"", distributing = "python_version >= \"3.10\""} [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [extras] @@ -2934,6 +3118,6 @@ memcache = ["aiomcache"] redis = ["redis"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.8" -content-hash = "77643cd3f90f01bfbec41b9815d3d935129ee563952c3b04d20daf216dd3ee23" +content-hash = "cf908077eded448cf36395cd1bacf76af226a13359b24a9f8303864c6c4478ef" diff --git a/pyproject.toml b/pyproject.toml index a1248490..45c7ac7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ coverage = ">=6.5,<8.0" httpx = "*" tox = "^4.5.1" towncrier = "^22.12.0" +pytest-asyncio = "<0.22" [tool.poetry.group.distributing] optional = true diff --git a/tests/test_decorator.py b/tests/test_decorator.py index abd6390f..006eeb87 100644 --- a/tests/test_decorator.py +++ b/tests/test_decorator.py @@ -23,18 +23,27 @@ def test_datetime() -> None: assert response.headers.get("X-FastAPI-Cache") == "MISS" now = response.json().get("now") now_ = pendulum.now() - assert pendulum.parse(now) == now_ + # Allow for small time difference (within 1 second) + parsed_time = pendulum.parse(now) + assert isinstance(parsed_time, pendulum.DateTime) + assert abs((parsed_time - now_).total_seconds()) < 1 + + # Second request should hit cache response = client.get("/datetime") assert response.headers.get("X-FastAPI-Cache") == "HIT" - now = response.json().get("now") - assert pendulum.parse(now) == now_ + cached_now = response.json().get("now") + # Cached value should be the same as the first request + assert cached_now == now time.sleep(3) response = client.get("/datetime") - now = response.json().get("now") + now_after_expiry = response.json().get("now") assert response.headers.get("X-FastAPI-Cache") == "MISS" - now = pendulum.parse(now) - assert now != now_ - assert now == pendulum.now() + # This should be a new value, different from the cached one + assert now_after_expiry != cached_now + # And it should be close to current time + parsed_after_expiry = pendulum.parse(now_after_expiry) + assert isinstance(parsed_after_expiry, pendulum.DateTime) + assert abs((parsed_after_expiry - pendulum.now()).total_seconds()) < 1 def test_date() -> None: @@ -99,10 +108,10 @@ def test_pydantic_model() -> None: def test_non_get() -> None: with TestClient(app) as client: - response = client.put("/cached_put") + response = client.put("/uncached_put") assert "X-FastAPI-Cache" not in response.headers assert response.json() == {"value": 1} - response = client.put("/cached_put") + response = client.put("/uncached_put") assert "X-FastAPI-Cache" not in response.headers assert response.json() == {"value": 2} diff --git a/tests/test_dogpile_prevention.py b/tests/test_dogpile_prevention.py new file mode 100644 index 00000000..4228652a --- /dev/null +++ b/tests/test_dogpile_prevention.py @@ -0,0 +1,336 @@ +"""Tests for dogpile prevention functionality.""" +import asyncio +import time +from typing import Any, Coroutine, Dict, Generator, List, Optional, Tuple, Union + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fastapi_cache import FastAPICache +from fastapi_cache.backends.inmemory import InMemoryBackend +from fastapi_cache.decorator import cache +from fastapi_cache.types import Backend + +# Track function calls for testing +call_tracker: Dict[Union[str, int], List[float]] = {} + + +@pytest.fixture() +def app() -> Generator[FastAPI, None, None]: + """Create a test FastAPI app.""" + # Initialize cache before creating the app + FastAPICache.init( + InMemoryBackend(), + enable_dogpile_prevention=True, + dogpile_grace_time=10.0, + dogpile_wait_time=0.1, + dogpile_max_wait_time=2.0, + ) + + app = FastAPI() + + @app.get("/expensive/{item_id}") + @cache(expire=60) + async def get_expensive_resource(item_id: int) -> Dict[str, Union[int, str]]: # pyright: ignore[reportUnusedFunction] + """Simulate an expensive operation.""" + # Track when this function is called + if item_id not in call_tracker: + call_tracker[item_id] = [] + call_tracker[item_id].append(time.time()) + + # Simulate expensive computation + await asyncio.sleep(1.0) + return {"item_id": item_id, "data": f"expensive data for {item_id}"} + + @app.get("/no-dogpile/{item_id}") + @cache(expire=60, enable_dogpile_prevention=False) + async def get_resource_no_dogpile_prevention(item_id: int) -> Dict[str, Union[int, str]]: # pyright: ignore[reportUnusedFunction] + """Resource without dogpile prevention.""" + if item_id not in call_tracker: + call_tracker[item_id] = [] + call_tracker[item_id].append(time.time()) + + await asyncio.sleep(1.0) + return {"item_id": item_id, "data": f"data for {item_id}"} + + yield app + # Clean up after the test + FastAPICache.reset() + + +@pytest.fixture() +def client(app: FastAPI) -> TestClient: + """Create a test client.""" + return TestClient(app) + + +def test_dogpile_prevention_single_request(client: TestClient) -> None: + """Test that a single request works normally.""" + call_tracker.clear() + + response = client.get("/expensive/1") + assert response.status_code == 200 + assert response.json() == {"item_id": 1, "data": "expensive data for 1"} + assert len(call_tracker.get(1, [])) == 1 + + # Second request should hit cache + response = client.get("/expensive/1") + assert response.status_code == 200 + assert response.json() == {"item_id": 1, "data": "expensive data for 1"} + assert len(call_tracker.get(1, [])) == 1 # Still only one call + + +@pytest.mark.asyncio() +async def test_dogpile_prevention_concurrent_requests() -> None: + """Test that concurrent requests are handled properly with dogpile prevention.""" + call_tracker.clear() + + # Initialize cache first + FastAPICache.init( + InMemoryBackend(), + enable_dogpile_prevention=True, + dogpile_grace_time=10.0, + dogpile_wait_time=0.1, + dogpile_max_wait_time=5.0, + ) + + # Create app with async client + app = FastAPI() + + @app.get("/expensive/{item_id}") + @cache(expire=60) + async def get_expensive_resource(item_id: int) -> Dict[str, Union[int, str]]: # pyright: ignore[reportUnusedFunction] + if item_id not in call_tracker: + call_tracker[item_id] = [] + call_tracker[item_id].append(time.time()) + + await asyncio.sleep(1.0) + return {"item_id": item_id, "data": f"expensive data for {item_id}"} + + # Simulate concurrent requests + async def make_request(item_id: int) -> Dict[str, Union[int, str]]: + # Simulate the cache decorator behavior + backend = FastAPICache.get_backend() + from fastapi_cache.lock import OptimisticLock + + cache_key = f"test:expensive:{item_id}" + cached = await backend.get(cache_key) + + if cached is None: + lock = OptimisticLock(backend, cache_key, 10.0) + is_computing, remaining_time = await lock.is_computing() + + if is_computing: + # Wait for the other request + total_wait = 0.0 + while total_wait < min(remaining_time or 0, 5.0): + await asyncio.sleep(0.1) + total_wait += 0.1 + cached = await backend.get(cache_key) + if cached is not None: + return {"item_id": item_id, "data": f"expensive data for {item_id}"} + else: + # We're the first, start computing + await lock.start_computing() + try: + # Call the function logic directly + if item_id not in call_tracker: + call_tracker[item_id] = [] + call_tracker[item_id].append(time.time()) + await asyncio.sleep(1.0) + result: Dict[str, Union[int, str]] = {"item_id": item_id, "data": f"expensive data for {item_id}"} + await backend.set(cache_key, b"cached_result", 60) + return result + finally: + await lock.finish_computing() + + return {"item_id": item_id, "data": f"expensive data for {item_id}"} + + # Start 5 concurrent requests for the same item + item_id = 42 + tasks = [make_request(item_id) for _ in range(5)] + results = await asyncio.gather(*tasks) + + # All requests should get the same result + for result in results: + assert result == {"item_id": item_id, "data": f"expensive data for {item_id}"} + + # But the expensive function should only be called once + assert len(call_tracker.get(item_id, [])) == 1 + + # Clean up + FastAPICache.reset() + + +@pytest.mark.asyncio() +async def test_dogpile_prevention_different_keys() -> None: + """Test that different cache keys don't interfere with each other.""" + call_tracker.clear() + + backend = InMemoryBackend() + FastAPICache.reset() + FastAPICache.init( + backend, + enable_dogpile_prevention=True, + dogpile_grace_time=10.0, + dogpile_wait_time=0.1, + dogpile_max_wait_time=5.0, + ) + + async def expensive_operation(key: str) -> str: + if key not in call_tracker: + call_tracker[key] = [] + call_tracker[key].append(time.time()) + await asyncio.sleep(0.5) + return f"result for {key}" + + # Start requests for different keys + tasks: List[Coroutine[Any, Any, str]] = [] + for i in range(3): + for _ in range(2): # 2 requests per key + key = f"key_{i}" + tasks.append(expensive_operation(key)) + + await asyncio.gather(*tasks) + + # Each key should have been computed multiple times (no prevention across keys) + assert len(call_tracker["key_0"]) == 2 + assert len(call_tracker["key_1"]) == 2 + assert len(call_tracker["key_2"]) == 2 + + # Clean up + FastAPICache.reset() + + +@pytest.mark.asyncio() +async def test_dogpile_prevention_timeout() -> None: + """Test that dogpile prevention respects the grace time.""" + call_tracker.clear() + + backend = InMemoryBackend() + FastAPICache.reset() + FastAPICache.init( + backend, + enable_dogpile_prevention=True, + dogpile_grace_time=2.0, # Grace time longer than operation + dogpile_wait_time=0.1, + dogpile_max_wait_time=0.5, # Max wait time + ) + + from fastapi_cache.lock import OptimisticLock + + async def slow_operation(key: str) -> str: + if key not in call_tracker: + call_tracker[key] = [] + call_tracker[key].append(time.time()) + await asyncio.sleep(0.5) # Operation time + return f"result for {key}" + + cache_key = "test_key" + lock = OptimisticLock(backend, cache_key, 2.0) # Match the grace time + + # First request starts computing + started = await lock.start_computing() + assert started # Ensure we successfully started computing + + # Verify the lock is set + is_computing, remaining_time = await lock.is_computing() + assert is_computing + assert remaining_time is not None + + task1 = asyncio.create_task(slow_operation("key1")) + + # Wait a bit and start second request + await asyncio.sleep(0.1) + + # Second request should still detect computing + is_computing, remaining_time = await lock.is_computing() + assert is_computing # Should still be computing + + # Wait for more than max wait time to ensure second request proceeds + await asyncio.sleep(0.6) + + # Now second request should proceed (after timeout) + task2 = asyncio.create_task(slow_operation("key2")) + + try: + await task1 + await task2 + + # Both operations should have been called + assert len(call_tracker) == 2 + finally: + # Clean up - ensure tasks are properly cancelled if they're still running + if not task1.done(): + task1.cancel() + try: + await task1 + except asyncio.CancelledError: + pass + if not task2.done(): + task2.cancel() + try: + await task2 + except asyncio.CancelledError: + pass + + # Clean up cache + FastAPICache.reset() + + +@pytest.mark.asyncio() +async def test_dogpile_prevention_with_redis_like_backend() -> None: + """Test dogpile prevention with a Redis-like backend.""" + # Simulate a Redis-like backend + class MockRedisBackend(Backend): + def __init__(self) -> None: + self.data: Dict[str, bytes] = {} + self.redis = self # Simulate redis attribute + + async def get(self, key: str) -> Optional[bytes]: + return self.data.get(key) + + async def get_with_ttl(self, key: str) -> Tuple[int, Optional[bytes]]: + return 60, self.data.get(key) + + async def set(self, key: str, value: bytes, expire: Optional[int] = None, **kwargs: Any) -> Any: + # Support both Backend.set signature and Redis.set signature + nx = kwargs.get('nx', False) + if nx and key in self.data: + return False + self.data[key] = value + return True if nx else None + + async def eval(self, script: str, numkeys: int, *args: Any) -> int: + # Simulate the Lua script for atomic check-and-delete + if numkeys == 1: + key, expected_value = args + if self.data.get(key) == expected_value.encode(): + del self.data[key] + return 1 + return 0 + + async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int: + if key and key in self.data: + del self.data[key] + return 1 + return 0 + + backend = MockRedisBackend() + from fastapi_cache.lock import RedisLock + + # Test lock acquisition + lock1 = RedisLock(backend, "test_key", 5.0) + assert await lock1.acquire() is True + + # Second lock should fail + lock2 = RedisLock(backend, "test_key", 5.0) + assert await lock2.acquire() is False + + # Release first lock + assert await lock1.release() is True + + # Now second lock should succeed + assert await lock2.acquire() is True + assert await lock2.release() is True