From 9a0c24880318c639a05add5c5c791ae4ad851dff Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 16 Sep 2025 22:03:04 -0500 Subject: [PATCH 01/15] Add initial implementation of response caching middleware --- src/fastmcp/server/middleware/caching.py | 182 +++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 src/fastmcp/server/middleware/caching.py diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py new file mode 100644 index 0000000000..82021a0427 --- /dev/null +++ b/src/fastmcp/server/middleware/caching.py @@ -0,0 +1,182 @@ +import hashlib +import json +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Any, ClassVar, Protocol + +from pydantic import BaseModel, ConfigDict +from typing_extensions import Self + +from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp.tools.tool import ToolResult + +if TYPE_CHECKING: + import mcp.types as mt + from mcp.types import ContentBlock + + +class CacheEntry(BaseModel): + """A cache entry.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + + key: str + + content: list[ContentBlock] | Any | None + structured_content: str | None + + created_at: datetime + expires_at: datetime + + def is_expired(self) -> bool: + return datetime.now(tz=timezone.utc) > self.expires_at + + def to_tool_result(self) -> ToolResult: + return ToolResult( + content=self.content, + structured_content=json.loads(self.structured_content) + if self.structured_content is not None + else None, + ) + + @classmethod + def from_tool_result(cls, key: str, value: ToolResult, ttl: int) -> Self: + return cls( + key=key, + content=value.content, + structured_content=json.dumps(value.structured_content) + if value.structured_content is not None + else None, + created_at=datetime.now(tz=timezone.utc), + expires_at=datetime.now(tz=timezone.utc) + timedelta(seconds=ttl), + ) + + +class CacheProtocol(Protocol): + """A protocol for a cache client.""" + + async def get(self, key: str) -> ToolResult | None: ... + + async def set(self, key: str, value: ToolResult, ttl: int) -> None: ... + + async def delete(self, key: str) -> None: ... + + async def setup(self) -> None: ... + + async def clear(self) -> None: ... + + +class InMemoryCache(CacheProtocol): + """A simple in-memory cache.""" + + def __init__(self, max_size: int = 1000): + self._cache: dict[str, CacheEntry] = {} + self._max_size = max_size + + async def get(self, key: str) -> ToolResult | None: + cached_entry = self._cache.get(key) + + if cached_entry is None: + return None + + if cached_entry.is_expired(): + self._cache.pop(key, None) + return None + + return ToolResult( + content=cached_entry.content, + structured_content=json.loads(cached_entry.structured_content) + if cached_entry.structured_content is not None + else None, + ) + + async def set(self, key: str, value: Any, ttl: int) -> None: + if len(self._cache) >= self._max_size: + self._cache.pop(next(iter(self._cache))) + + self._cache[key] = CacheEntry.from_tool_result(key=key, value=value, ttl=ttl) + + async def delete(self, key: str) -> None: + self._cache.pop(key, None) + + async def setup(self) -> None: + return None + + async def clear(self) -> None: + self._cache.clear() + + +class CacheStats(BaseModel): + """Stats for the cache.""" + + hits: int + misses: int + + +class ResponseCachingMiddleware(Middleware): + """Caches tool call responses based on method name and params. + + Notes: + - Only caches `tools/call` requests. + - Cache key derived from tool name and arguments. + """ + + _stats: CacheStats + + def __init__( + self, + cache_backend: CacheProtocol, + included_tools: list[str] | None = None, + excluded_tools: list[str] | None = None, + default_ttl: int = 3600, + ): + self._default_ttl = default_ttl + self._backend = cache_backend + self._stats = CacheStats(hits=0, misses=0) + self._included_tools = included_tools + self._excluded_tools = excluded_tools + + async def on_call_tool( + self, + context: MiddlewareContext[mt.CallToolRequestParams], + call_next: CallNext[mt.CallToolRequestParams, Any], + ) -> Any: + if not self._should_cache_tool(context.message.name): + return await call_next(context) + + key = self._make_cache_key(context.message) + + if cached_entry := await self._backend.get(key): + self._stats.hits += 1 + return cached_entry + + # Cache miss: call downstream + self._stats.misses += 1 + result = await call_next(context) + + await self._backend.set(key, result, self._default_ttl) + + return result + + def _should_cache_tool(self, tool_name: str) -> bool: + if self._excluded_tools is not None and tool_name in self._excluded_tools: + return False + if self._included_tools is not None and tool_name not in self._included_tools: + return False + return True + + def _make_cache_key(self, msg: mt.CallToolRequestParams) -> str: + raw = f"{self._get_tool_key(msg)}:{self._get_tool_arguments_str(msg)}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + def _get_tool_key(self, msg: mt.CallToolRequestParams) -> str: + return msg.name + + def _get_tool_arguments_str(self, msg: mt.CallToolRequestParams) -> str: + if msg.arguments is None: + return "null" + + try: + return json.dumps(msg.arguments, sort_keys=True, separators=(",", ":")) + + except TypeError: + return repr(msg.arguments) From daa0eb7aa6593028170626506bc688447f18002c Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 16 Sep 2025 22:23:54 -0500 Subject: [PATCH 02/15] Add tests for caching --- src/fastmcp/server/middleware/caching.py | 17 +- tests/server/middleware/test_caching.py | 466 +++++++++++++++++++++++ 2 files changed, 473 insertions(+), 10 deletions(-) create mode 100644 tests/server/middleware/test_caching.py diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 82021a0427..39b31d7b35 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -1,18 +1,15 @@ import hashlib import json from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, ClassVar, Protocol +from typing import Any, ClassVar, Protocol +from mcp.types import CallToolRequestParams, ContentBlock from pydantic import BaseModel, ConfigDict from typing_extensions import Self from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.tools.tool import ToolResult -if TYPE_CHECKING: - import mcp.types as mt - from mcp.types import ContentBlock - class CacheEntry(BaseModel): """A cache entry.""" @@ -137,8 +134,8 @@ def __init__( async def on_call_tool( self, - context: MiddlewareContext[mt.CallToolRequestParams], - call_next: CallNext[mt.CallToolRequestParams, Any], + context: MiddlewareContext[CallToolRequestParams], + call_next: CallNext[CallToolRequestParams, Any], ) -> Any: if not self._should_cache_tool(context.message.name): return await call_next(context) @@ -164,14 +161,14 @@ def _should_cache_tool(self, tool_name: str) -> bool: return False return True - def _make_cache_key(self, msg: mt.CallToolRequestParams) -> str: + def _make_cache_key(self, msg: CallToolRequestParams) -> str: raw = f"{self._get_tool_key(msg)}:{self._get_tool_arguments_str(msg)}" return hashlib.sha256(raw.encode("utf-8")).hexdigest() - def _get_tool_key(self, msg: mt.CallToolRequestParams) -> str: + def _get_tool_key(self, msg: CallToolRequestParams) -> str: return msg.name - def _get_tool_arguments_str(self, msg: mt.CallToolRequestParams) -> str: + def _get_tool_arguments_str(self, msg: CallToolRequestParams) -> str: if msg.arguments is None: return "null" diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py new file mode 100644 index 0000000000..27def083b3 --- /dev/null +++ b/tests/server/middleware/test_caching.py @@ -0,0 +1,466 @@ +"""Tests for response caching middleware.""" + +from datetime import datetime, timedelta, timezone +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import mcp.types +import pytest +from inline_snapshot import snapshot +from pydantic import BaseModel + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.server.middleware.caching import ( + CacheEntry, + CacheStats, + InMemoryCache, + ResponseCachingMiddleware, +) +from fastmcp.server.middleware.middleware import MiddlewareContext +from fastmcp.tools.tool import Tool, ToolResult + + +class CrazyModel(BaseModel): + a: int + b: int + c: str + d: float + e: bool + f: list[int] + g: dict[str, int] + h: list[dict[str, int]] + i: dict[str, list[int]] + + +def extract_content_for_snapshot(result: ToolResult) -> dict[str, Any]: + return { + "content": [c.model_dump() for c in result.content], + "structured_content": result.structured_content, + } + + +@pytest.fixture +def crazy_model(): + return CrazyModel( + a=5, + b=10, + c="test", + d=1.0, + e=True, + f=[1, 2, 3], + g={"a": 1, "b": 2}, + h=[{"a": 1, "b": 2}], + i={"a": [1, 2]}, + ) + + +class TrackingCalculator: + add_calls: int + multiply_calls: int + crazy_calls: int + + def __init__(self): + self.add_calls = 0 + self.multiply_calls = 0 + self.crazy_calls = 0 + + def add(self, a: int, b: int) -> int: + self.add_calls += 1 + return a + b + + def multiply(self, a: int, b: int) -> int: + self.multiply_calls += 1 + return a * b + + def crazy(self, a: CrazyModel) -> CrazyModel: + self.crazy_calls += 1 + return a + + def add_tools(self, fastmcp: FastMCP): + fastmcp.add_tool(tool=Tool.from_function(fn=self.add)) + fastmcp.add_tool(tool=Tool.from_function(fn=self.multiply)) + fastmcp.add_tool(tool=Tool.from_function(fn=self.crazy)) + + +@pytest.fixture +def tracking_calculator(): + return TrackingCalculator() + + +@pytest.fixture +def mock_context(): + """Create a mock middleware context for tool calls.""" + context = MagicMock(spec=MiddlewareContext[mcp.types.CallToolRequestParams]) + context.message = mcp.types.CallToolRequestParams( + name="test_tool", arguments={"param1": "value1", "param2": 42} + ) + context.method = "tools/call" + return context + + +@pytest.fixture +def mock_call_next(): + """Create a mock call_next function.""" + return AsyncMock( + return_value=ToolResult( + content=[{"type": "text", "text": "test result"}], + structured_content={"result": "success", "value": 123}, + ) + ) + + +@pytest.fixture +def sample_tool_result(): + """Create a sample tool result for testing.""" + return ToolResult( + content=[{"type": "text", "text": "cached result"}], + structured_content={"cached": True, "data": "test"}, + ) + + +class TestCacheEntry: + """Test CacheEntry class functionality.""" + + def test_init_and_expiration(self): + """Test cache entry initialization and expiration logic.""" + now = datetime.now(tz=timezone.utc) + future = now + timedelta(seconds=3600) + past = now - timedelta(seconds=3600) + + # Test valid entry + entry = CacheEntry( + key="test_key", + content=[{"type": "text", "text": "test"}], + structured_content='{"result": "success"}', + created_at=now, + expires_at=future, + ) + + assert entry.key == "test_key" + assert not entry.is_expired() + + # Test expired entry + expired_entry = CacheEntry( + key="expired_key", + content=None, + structured_content=None, + created_at=past, + expires_at=past, + ) + + assert expired_entry.is_expired() + + def test_serialization(self): + """Test cache entry serialization to/from tool result.""" + tool_result = ToolResult( + content=[{"type": "text", "text": "test"}], + structured_content={"result": "success"}, + ) + + # Test round-trip conversion + entry = CacheEntry.from_tool_result("test_key", tool_result, 3600) + result = entry.to_tool_result() + + assert result.content == tool_result.content + assert result.structured_content == tool_result.structured_content + + +class TestInMemoryCache: + """Test InMemoryCache implementation.""" + + async def test_basic_operations(self, sample_tool_result): + """Test basic cache operations.""" + cache = InMemoryCache() + + # Test set and get + await cache.set("test_key", sample_tool_result, 3600) + result = await cache.get("test_key") + + assert result is not None + assert result.content == sample_tool_result.content + + # Test delete + await cache.delete("test_key") + assert await cache.get("test_key") is None + + async def test_expiration_and_cleanup(self, sample_tool_result): + """Test cache expiration and cleanup.""" + cache = InMemoryCache() + + # Create an expired entry + entry = CacheEntry.from_tool_result("expired_key", sample_tool_result, -1) + cache._cache["expired_key"] = entry + + # Should return None and remove expired entry + result = await cache.get("expired_key") + assert result is None + assert "expired_key" not in cache._cache + + async def test_size_limit(self, sample_tool_result): + """Test cache size limit enforcement.""" + cache = InMemoryCache(max_size=2) + + # Fill cache to capacity + await cache.set("key1", sample_tool_result, 3600) + await cache.set("key2", sample_tool_result, 3600) + + # Add one more - should evict the first + await cache.set("key3", sample_tool_result, 3600) + + assert len(cache._cache) == 2 + assert "key1" not in cache._cache + assert "key2" in cache._cache + assert "key3" in cache._cache + + +class TestResponseCachingMiddleware: + """Test ResponseCachingMiddleware functionality.""" + + def test_initialization(self): + """Test middleware initialization.""" + cache = InMemoryCache() + middleware = ResponseCachingMiddleware( + cache_backend=cache, + included_tools=["tool1"], + excluded_tools=["tool2"], + default_ttl=1800, + ) + + assert middleware._backend is cache + assert middleware._default_ttl == 1800 + assert middleware._included_tools == ["tool1"] + assert middleware._excluded_tools == ["tool2"] + assert middleware._stats.hits == 0 + assert middleware._stats.misses == 0 + + def test_tool_filtering(self): + """Test tool filtering logic.""" + cache = InMemoryCache() + + # Test included tools only + middleware1 = ResponseCachingMiddleware( + cache, included_tools=["tool1", "tool2"] + ) + assert middleware1._should_cache_tool("tool1") is True + assert middleware1._should_cache_tool("tool3") is False + + # Test excluded tools + middleware2 = ResponseCachingMiddleware(cache, excluded_tools=["tool1"]) + assert middleware2._should_cache_tool("tool1") is False + assert middleware2._should_cache_tool("tool2") is True + + # Test both (excluded takes precedence) + middleware3 = ResponseCachingMiddleware( + cache, included_tools=["tool1", "tool2"], excluded_tools=["tool2"] + ) + assert middleware3._should_cache_tool("tool1") is True + assert middleware3._should_cache_tool("tool2") is False + + def test_cache_key_generation(self): + """Test cache key generation.""" + cache = InMemoryCache() + middleware = ResponseCachingMiddleware(cache) + + msg = mcp.types.CallToolRequestParams( + name="test_tool", arguments={"param1": "value1", "param2": 42} + ) + + key = middleware._make_cache_key(msg) + + # Should be a SHA256 hash + assert len(key) == 64 + assert all(c in "0123456789abcdef" for c in key) + + async def test_cache_miss_and_hit(self, mock_context, mock_call_next): + """Test cache miss and hit scenarios.""" + cache = InMemoryCache() + middleware = ResponseCachingMiddleware(cache) + + # First call - cache miss + result1 = await middleware.on_call_tool(mock_context, mock_call_next) + assert middleware._stats.misses == 1 + assert middleware._stats.hits == 0 + + # Second call - cache hit + mock_call_next.reset_mock() + result2 = await middleware.on_call_tool(mock_context, mock_call_next) + + assert result1.content == result2.content + assert not mock_call_next.called # Should not call downstream + assert middleware._stats.hits == 1 + assert middleware._stats.misses == 1 + + +class TestResponseCachingMiddlewareIntegration: + """Integration tests with real FastMCP server.""" + + @pytest.fixture + def caching_server(self, tracking_calculator: TrackingCalculator): + """Create a FastMCP server for caching tests.""" + mcp = FastMCP("CachingTestServer") + + mcp.add_middleware( + middleware=ResponseCachingMiddleware(cache_backend=InMemoryCache()) + ) + + tracking_calculator.add_tools(mcp) + + return mcp + + @pytest.fixture + def non_caching_server(self, tracking_calculator: TrackingCalculator): + """Create a FastMCP server for non-caching tests.""" + mcp = FastMCP("NonCachingTestServer") + tracking_calculator.add_tools(mcp) + return mcp + + async def test_caching_works_with_real_server( + self, + caching_server: FastMCP, + tracking_calculator: TrackingCalculator, + crazy_model: CrazyModel, + ): + """Test that caching works with a real FastMCP server.""" + tracking_calculator.add_tools(caching_server) + + async with Client(caching_server) as client: + call_tool_result = await client.call_tool("add", {"a": 5, "b": 3}) + + assert tracking_calculator.add_calls == 1 + assert extract_content_for_snapshot(call_tool_result) == snapshot( + { + "content": [ + {"type": "text", "text": "8", "annotations": None, "meta": None} + ], + "structured_content": {"result": 8}, + } + ) + + call_tool_result = await client.call_tool("add", {"a": 5, "b": 3}) + assert tracking_calculator.add_calls == 1 + assert extract_content_for_snapshot(call_tool_result) == snapshot( + { + "content": [ + {"type": "text", "text": "8", "annotations": None, "meta": None} + ], + "structured_content": {"result": 8}, + } + ) + + call_tool_result = await client.call_tool("crazy", {"a": crazy_model}) + assert tracking_calculator.crazy_calls == 1 + assert extract_content_for_snapshot(call_tool_result) == snapshot( + { + "content": [ + { + "type": "text", + "text": '{"a":5,"b":10,"c":"test","d":1.0,"e":true,"f":[1,2,3],"g":{"a":1,"b":2},"h":[{"a":1,"b":2}],"i":{"a":[1,2]}}', + "annotations": None, + "meta": None, + } + ], + "structured_content": { + "a": 5, + "b": 10, + "c": "test", + "d": 1.0, + "e": True, + "f": [1, 2, 3], + "g": {"a": 1, "b": 2}, + "h": [{"a": 1, "b": 2}], + "i": {"a": [1, 2]}, + }, + } + ) + + call_tool_result = await client.call_tool("crazy", {"a": crazy_model}) + assert tracking_calculator.crazy_calls == 1 + assert extract_content_for_snapshot(call_tool_result) == snapshot( + { + "content": [ + { + "type": "text", + "text": '{"a":5,"b":10,"c":"test","d":1.0,"e":true,"f":[1,2,3],"g":{"a":1,"b":2},"h":[{"a":1,"b":2}],"i":{"a":[1,2]}}', + "annotations": None, + "meta": None, + } + ], + "structured_content": { + "a": 5, + "b": 10, + "c": "test", + "d": 1.0, + "e": True, + "f": [1, 2, 3], + "g": {"a": 1, "b": 2}, + "h": [{"a": 1, "b": 2}], + "i": {"a": [1, 2]}, + }, + } + ) + + async def test_different_arguments_create_different_entries( + self, caching_server: FastMCP, tracking_calculator: TrackingCalculator + ): + """Test that different arguments create different cache entries.""" + + async with Client(caching_server) as client: + result1 = await client.call_tool("add", {"a": 5, "b": 10}) + assert tracking_calculator.add_calls == 1 + result2 = await client.call_tool("add", {"a": 1, "b": 5}) + assert tracking_calculator.add_calls == 2 + + # Results should be different + assert result1.structured_content["result"] == 15 + assert result2.structured_content["result"] == 6 + + async def test_tool_filtering_integration( + self, non_caching_server: FastMCP, tracking_calculator: TrackingCalculator + ): + """Test tool filtering in integration.""" + partial_caching_server = non_caching_server + + partial_caching_server.add_middleware( + ResponseCachingMiddleware( + cache_backend=InMemoryCache(), + included_tools=["add"], # Only cache this tool + ) + ) + + async with Client(partial_caching_server) as client: + # This should be cached + await client.call_tool("add", {"a": 5, "b": 10}) + await client.call_tool("add", {"a": 5, "b": 10}) + assert tracking_calculator.add_calls == 1 + + # This should not be cached + await client.call_tool("multiply", {"a": 1, "b": 5}) + await client.call_tool("multiply", {"a": 1, "b": 5}) + assert tracking_calculator.multiply_calls == 2 + + async def test_cache_stats_tracking(self, non_caching_server: FastMCP): + """Test that cache statistics are properly tracked.""" + middleware = ResponseCachingMiddleware(cache_backend=InMemoryCache()) + non_caching_server.add_middleware(middleware) + + async with Client(non_caching_server) as client: + # First call - cache miss + await client.call_tool("add", {"a": 5, "b": 10}) + assert middleware._stats.misses == 1 + assert middleware._stats.hits == 0 + + # Second call - cache hit + await client.call_tool("add", {"a": 5, "b": 10}) + assert middleware._stats.misses == 1 + assert middleware._stats.hits == 1 + + +class TestCacheStats: + """Test CacheStats functionality.""" + + def test_stats_initialization(self): + """Test cache stats initialization.""" + stats = CacheStats(hits=5, misses=10) + assert stats.hits == 5 + assert stats.misses == 10 From 09369b55555fd7167148ccd1b183ee19a7ce30f1 Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 16 Sep 2025 22:47:49 -0500 Subject: [PATCH 03/15] Add disk cache --- pyproject.toml | 1 + src/fastmcp/server/middleware/caching.py | 63 ++++++++++++++++++++---- tests/server/middleware/test_caching.py | 27 ++++++++-- uv.lock | 11 +++++ 4 files changed, 89 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4b7942dc32..89104173f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pydantic[email]>=2.11.7", "pyperclip>=1.9.0", "openapi-core>=0.19.5", + "diskcache>=5.6.3", ] requires-python = ">=3.10" diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 39b31d7b35..5304b90586 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -3,13 +3,17 @@ from datetime import datetime, timedelta, timezone from typing import Any, ClassVar, Protocol +from diskcache import Cache as DiskCacheClient from mcp.types import CallToolRequestParams, ContentBlock from pydantic import BaseModel, ConfigDict -from typing_extensions import Self +from typing_extensions import Self, overload, runtime_checkable from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.tools.tool import ToolResult +ONE_HOUR_IN_SECONDS = 3600 +ONE_GB_IN_BYTES = 1024 * 1024 * 1024 + class CacheEntry(BaseModel): """A cache entry.""" @@ -48,18 +52,49 @@ def from_tool_result(cls, key: str, value: ToolResult, ttl: int) -> Self: ) +@runtime_checkable class CacheProtocol(Protocol): """A protocol for a cache client.""" - async def get(self, key: str) -> ToolResult | None: ... + async def get(self, key: str) -> ToolResult | None: + """Get a value from the cache.""" + + async def set(self, key: str, value: ToolResult, ttl: int) -> None: + """Set a value in the cache.""" + + async def delete(self, key: str) -> None: + """Delete a value from the cache.""" - async def set(self, key: str, value: ToolResult, ttl: int) -> None: ... - async def delete(self, key: str) -> None: ... +class DiskCache(CacheProtocol): + """A caching client that uses the DiskCache library to cache to disk.""" - async def setup(self) -> None: ... + @overload + def __init__(self, disk_cache: DiskCacheClient): + """Initialize the disk cache with a diskcache client.""" - async def clear(self) -> None: ... + @overload + def __init__(self, path: str, size_limit: int = ONE_GB_IN_BYTES): + """Initialize a 1GB disk cache at the provided path.""" + + def __init__( + self, + disk_cache: DiskCacheClient | None = None, + path: str | None = None, + size_limit: int = ONE_GB_IN_BYTES, + ): + self._cache = disk_cache or DiskCacheClient( + directory=path, size_limit=size_limit + ) + + async def get(self, key: str) -> ToolResult | None: + return self._cache.get(key) + + async def set(self, key: str, value: ToolResult, ttl: int) -> None: + self._cache.set(key, value, expire=ttl) + + async def delete(self, key: str) -> None: + self._cache.delete(key) class InMemoryCache(CacheProtocol): @@ -121,14 +156,24 @@ class ResponseCachingMiddleware(Middleware): def __init__( self, - cache_backend: CacheProtocol, + cache_backend: CacheProtocol | None = None, included_tools: list[str] | None = None, excluded_tools: list[str] | None = None, - default_ttl: int = 3600, + default_ttl: int = ONE_HOUR_IN_SECONDS, ): + """Initialize the response caching middleware. + + Args: + cache_backend: The cache backend to use. If None, an in-memory cache is used. + included_tools: The tools to cache responses from. If None, all tools are cached. + excluded_tools: The tools to not cache responses from. If None, no tools are excluded. + default_ttl: The default TTL for cached responses. Defaults to one hour. + """ self._default_ttl = default_ttl - self._backend = cache_backend + self._backend = cache_backend or InMemoryCache() + self._stats = CacheStats(hits=0, misses=0) + self._included_tools = included_tools self._excluded_tools = excluded_tools diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index 27def083b3..871f2c24ea 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -1,5 +1,6 @@ """Tests for response caching middleware.""" +import tempfile from datetime import datetime, timedelta, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -14,6 +15,7 @@ from fastmcp.server.middleware.caching import ( CacheEntry, CacheStats, + DiskCache, InMemoryCache, ResponseCachingMiddleware, ) @@ -296,13 +298,30 @@ class TestResponseCachingMiddlewareIntegration: """Integration tests with real FastMCP server.""" @pytest.fixture - def caching_server(self, tracking_calculator: TrackingCalculator): + async def disk_cache(self): + with tempfile.TemporaryDirectory() as temp_dir: + yield DiskCache(path=temp_dir) + + @pytest.fixture + async def in_memory_cache(self): + return InMemoryCache() + + @pytest.fixture(params=["memory", "disk"]) + async def caching_server( + self, + tracking_calculator: TrackingCalculator, + request, + disk_cache, + in_memory_cache, + ): """Create a FastMCP server for caching tests.""" mcp = FastMCP("CachingTestServer") - mcp.add_middleware( - middleware=ResponseCachingMiddleware(cache_backend=InMemoryCache()) - ) + cache = disk_cache if request.param == "disk" else in_memory_cache + + response_caching_middleware = ResponseCachingMiddleware(cache_backend=cache) + + mcp.add_middleware(middleware=response_caching_middleware) tracking_calculator.add_tools(mcp) diff --git a/uv.lock b/uv.lock index 9a5ad0c57c..36b3b11814 100644 --- a/uv.lock +++ b/uv.lock @@ -400,6 +400,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/0c/03cc99bf3b6328604b10829de3460f2b2ad3373200c45665c38508e550c6/dirty_equals-0.9.0-py3-none-any.whl", hash = "sha256:ff4d027f5cfa1b69573af00f7ba9043ea652dbdce3fe5cbe828e478c7346db9c", size = 28226, upload-time = "2025-01-11T23:23:37.489Z" }, ] +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -521,6 +530,7 @@ source = { editable = "." } dependencies = [ { name = "authlib" }, { name = "cyclopts" }, + { name = "diskcache" }, { name = "exceptiongroup" }, { name = "httpx" }, { name = "mcp" }, @@ -570,6 +580,7 @@ dev = [ requires-dist = [ { name = "authlib", specifier = ">=1.5.2" }, { name = "cyclopts", specifier = ">=3.0.0" }, + { name = "diskcache", specifier = ">=5.6.3" }, { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", specifier = ">=1.12.4,<2.0.0" }, From 84b3e0ff6093b4bf9c58e6aa080380e074fbb4b5 Mon Sep 17 00:00:00 2001 From: William Easton Date: Wed, 17 Sep 2025 19:10:18 -0500 Subject: [PATCH 04/15] Adding response caching with tests --- pyproject.toml | 4 +- src/fastmcp/server/middleware/caching.py | 680 +++++++++++++++++--- src/fastmcp/server/middleware/middleware.py | 5 +- tests/server/middleware/test_caching.py | 655 ++++++++++++------- uv.lock | 46 +- 5 files changed, 1068 insertions(+), 322 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 89104173f9..c31b62e2af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ dependencies = [ "pydantic[email]>=2.11.7", "pyperclip>=1.9.0", "openapi-core>=0.19.5", - "diskcache>=5.6.3", ] requires-python = ">=3.10" @@ -44,11 +43,13 @@ classifiers = [ [project.optional-dependencies] websockets = ["websockets>=15.0.1"] openai = ["openai>=1.102.0"] +caching = ["diskcache>=5.6.3", "cachetools>=6.2.0"] [dependency-groups] dev = [ "dirty-equals>=0.9.0", "fastmcp[openai]", + "fastmcp[caching]", # add optional dependencies for fastmcp dev "fastapi>=0.115.12", "inline-snapshot[dirty-equals]>=0.27.2", @@ -69,6 +70,7 @@ dev = [ "pytest-xdist>=3.6.1", "ruff", "ty>=0.0.1a19", + "pytest-benchmark>=5.1.0", ] [project.scripts] diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 5304b90586..0b30fcdcf1 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -1,53 +1,106 @@ import hashlib import json +from collections import defaultdict from datetime import datetime, timedelta, timezone -from typing import Any, ClassVar, Protocol +from typing import Any, ClassVar, Generic, Protocol, TypedDict, TypeVar, cast -from diskcache import Cache as DiskCacheClient -from mcp.types import CallToolRequestParams, ContentBlock -from pydantic import BaseModel, ConfigDict -from typing_extensions import Self, overload, runtime_checkable +import mcp.types +from mcp.server.lowlevel.helper_types import ReadResourceContents +from pydantic import BaseModel, ConfigDict, Field +from typing_extensions import NotRequired, Self, overload, runtime_checkable +from fastmcp.prompts.prompt import Prompt +from fastmcp.resources.resource import Resource from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.tool import ToolResult +from fastmcp.tools.tool import Tool, ToolResult +from fastmcp.utilities.logging import get_logger +try: + from cachetools import TLRUCache as MemoryCacheClient + from diskcache import Cache as DiskCacheClient +except ImportError: + raise ImportError( + "fastmcp[caching] is required to use the caching middleware. Please install it with `pip install fastmcp[caching] or `uv add fastmcp[caching]`" + ) + +logger = get_logger(__name__) + +# Constants ONE_HOUR_IN_SECONDS = 3600 +FIVE_MINUTES_IN_SECONDS = 300 + ONE_GB_IN_BYTES = 1024 * 1024 * 1024 +ONE_MB_IN_BYTES = 1024 * 1024 + +GLOBAL_KEY = "__global__" + + +CachableTypes = ( + ToolResult + | list[Tool] + | list[Resource] + | list[Prompt] + | list[ReadResourceContents] + | mcp.types.GetPromptResult +) + +CachableTypeVar = TypeVar("CachableTypeVar", bound=CachableTypes) + + +def make_collection_key(collection: str, key: str) -> str: + return f"{collection}:{key}" + +class CachedPrompt(Prompt): + """A cached prompt.""" -class CacheEntry(BaseModel): + def render( + self, arguments: dict[str, Any] | None = None + ) -> list[mcp.types.PromptMessage]: + raise NotImplementedError( + "Render called on CachedPrompt, this should never happen" + ) + + +class CachedResource(Resource): + """A cached resource.""" + + def read(self) -> str | bytes: + raise NotImplementedError( + "Read called on CachedResource, this should never happen" + ) + + +class CacheEntry(BaseModel, Generic[CachableTypeVar]): """A cache entry.""" - model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True) + model_config: ClassVar[ConfigDict] = ConfigDict( + frozen=True, arbitrary_types_allowed=True + ) key: str - - content: list[ContentBlock] | Any | None - structured_content: str | None + collection: str + value: CachableTypeVar created_at: datetime + + ttl: int + expires_at: datetime def is_expired(self) -> bool: return datetime.now(tz=timezone.utc) > self.expires_at - def to_tool_result(self) -> ToolResult: - return ToolResult( - content=self.content, - structured_content=json.loads(self.structured_content) - if self.structured_content is not None - else None, - ) - @classmethod - def from_tool_result(cls, key: str, value: ToolResult, ttl: int) -> Self: + def from_value( + cls, collection: str, key: str, value: CachableTypeVar, ttl: int + ) -> Self: return cls( + collection=collection, key=key, - content=value.content, - structured_content=json.dumps(value.structured_content) - if value.structured_content is not None - else None, + value=value, created_at=datetime.now(tz=timezone.utc), + ttl=ttl, expires_at=datetime.now(tz=timezone.utc) + timedelta(seconds=ttl), ) @@ -56,79 +109,155 @@ def from_tool_result(cls, key: str, value: ToolResult, ttl: int) -> Self: class CacheProtocol(Protocol): """A protocol for a cache client.""" - async def get(self, key: str) -> ToolResult | None: + async def get_entry( + self, + collection: str, + key: str, + ) -> CacheEntry[CachableTypes] | None: + """Get a cache entry from the cache.""" + + async def get_value( + self, + collection: str, + key: str, + ) -> CachableTypes | None: """Get a value from the cache.""" - async def set(self, key: str, value: ToolResult, ttl: int) -> None: + if not (cache_entry := await self.get_entry(collection=collection, key=key)): + return None + + return cache_entry.value + + async def set_entry( + self, + cache_entry: CacheEntry[CachableTypes], + ) -> None: """Set a value in the cache.""" - async def delete(self, key: str) -> None: + async def set_value( + self, + collection: str, + key: str, + value: CachableTypes, + ttl: int, + ) -> None: + """Set a value in the cache.""" + + await self.set_entry( + cache_entry=CacheEntry.from_value( + collection=collection, key=key, value=value, ttl=ttl + ) + ) + + async def delete( + self, + collection: str, + key: str, + ) -> None: """Delete a value from the cache.""" + def make_collection_key(self, collection: str, key: str) -> str: + return f"{collection}:{key}" + class DiskCache(CacheProtocol): """A caching client that uses the DiskCache library to cache to disk.""" @overload - def __init__(self, disk_cache: DiskCacheClient): + def __init__(self, *, disk_cache: DiskCacheClient): """Initialize the disk cache with a diskcache client.""" @overload - def __init__(self, path: str, size_limit: int = ONE_GB_IN_BYTES): + def __init__(self, path: str, *, size_limit: int = ONE_GB_IN_BYTES): """Initialize a 1GB disk cache at the provided path.""" def __init__( self, - disk_cache: DiskCacheClient | None = None, path: str | None = None, + *, + disk_cache: DiskCacheClient | None = None, size_limit: int = ONE_GB_IN_BYTES, ): self._cache = disk_cache or DiskCacheClient( directory=path, size_limit=size_limit ) - async def get(self, key: str) -> ToolResult | None: - return self._cache.get(key) + async def get_entry( + self, collection: str, key: str + ) -> CacheEntry[CachableTypes] | None: + collection_key = self.make_collection_key(collection=collection, key=key) + + cache_entry = self._cache.get(key=collection_key) + + if cache_entry is None: + return None + + return cache_entry # pyright: ignore[reportReturnType] + + async def set_entry( + self, + cache_entry: CacheEntry[CachableTypes], + ) -> None: + collection_key = self.make_collection_key( + collection=cache_entry.collection, key=cache_entry.key + ) + + self._cache.set(key=collection_key, value=cache_entry, expire=cache_entry.ttl) + + async def delete(self, collection: str, key: str) -> None: + collection_key = self.make_collection_key(collection=collection, key=key) + + self._cache.delete(key=collection_key) + - async def set(self, key: str, value: ToolResult, ttl: int) -> None: - self._cache.set(key, value, expire=ttl) +DEFAULT_MEMORY_CACHE_MAX_ENTRIES = 1000 - async def delete(self, key: str) -> None: - self._cache.delete(key) + +def _memory_cache_ttu(_key: Any, value: CacheEntry[CachableTypes], now: float) -> float: + return now + value.ttl + + +def _memory_cache_getsizeof(value: CacheEntry[CachableTypes]) -> int: + return 1 class InMemoryCache(CacheProtocol): """A simple in-memory cache.""" - def __init__(self, max_size: int = 1000): - self._cache: dict[str, CacheEntry] = {} - self._max_size = max_size + def __init__(self, max_entries: int = DEFAULT_MEMORY_CACHE_MAX_ENTRIES): + """Initialize the in-memory cache. - async def get(self, key: str) -> ToolResult | None: - cached_entry = self._cache.get(key) + Args: + max_entries: The maximum number of entries to store in the cache. Defaults to 1000. + """ + self.max_entries = max_entries + self._cache = MemoryCacheClient( + maxsize=max_entries, + ttu=_memory_cache_ttu, + getsizeof=_memory_cache_getsizeof, + ) - if cached_entry is None: - return None + async def get_entry( + self, collection: str, key: str + ) -> CacheEntry[CachableTypes] | None: + collection_key = self.make_collection_key(collection=collection, key=key) - if cached_entry.is_expired(): - self._cache.pop(key, None) - return None + return self._cache.get(collection_key) - return ToolResult( - content=cached_entry.content, - structured_content=json.loads(cached_entry.structured_content) - if cached_entry.structured_content is not None - else None, + async def set_entry( + self, + cache_entry: CacheEntry[CachableTypes], + ) -> None: + collection_key = self.make_collection_key( + collection=cache_entry.collection, key=cache_entry.key ) - async def set(self, key: str, value: Any, ttl: int) -> None: - if len(self._cache) >= self._max_size: - self._cache.pop(next(iter(self._cache))) + self._cache[collection_key] = cache_entry - self._cache[key] = CacheEntry.from_tool_result(key=key, value=value, ttl=ttl) + async def delete(self, collection: str, key: str) -> None: + collection_key = self.make_collection_key(collection=collection, key=key) - async def delete(self, key: str) -> None: - self._cache.pop(key, None) + self._cache.pop(collection_key, None) async def setup(self) -> None: return None @@ -137,11 +266,97 @@ async def clear(self) -> None: self._cache.clear() +class CacheMethodStats(BaseModel): + """Stats for a cache method.""" + + hits: int = Field(default=0) + misses: int = Field(default=0) + too_big: int = Field(default=0) + + class CacheStats(BaseModel): """Stats for the cache.""" - hits: int - misses: int + collections: dict[str, CacheMethodStats] = Field( + default_factory=lambda: defaultdict[str, CacheMethodStats](CacheMethodStats) + ) + + def get_misses(self, collection: str) -> int: + return self.collections[collection].misses + + def get_hits(self, collection: str) -> int: + return self.collections[collection].hits + + def get_too_big(self, collection: str) -> int: + return self.collections[collection].too_big + + def mark_miss(self, collection: str) -> None: + self.collections[collection].misses += 1 + + def mark_hit(self, collection: str) -> None: + self.collections[collection].hits += 1 + + def mark_too_big(self, collection: str) -> None: + self.collections[collection].too_big += 1 + + +class SharedMethodSettings(TypedDict): + """Shared config for a cache method.""" + + ttl: NotRequired[int] + + +class CallToolSettings(SharedMethodSettings): + """Extra configuration options for Tool-related caching.""" + + included_tools: NotRequired[list[str]] + excluded_tools: NotRequired[list[str]] + + +class MethodSettings(TypedDict): + """Config for the response caching middleware methods.""" + + list_tools: NotRequired[SharedMethodSettings] + call_tool: NotRequired[CallToolSettings] + + list_resources: NotRequired[SharedMethodSettings] + read_resource: NotRequired[SharedMethodSettings] + + list_prompts: NotRequired[SharedMethodSettings] + get_prompt: NotRequired[SharedMethodSettings] + + +MethodSettingsType = TypeVar("MethodSettingsType", bound=SharedMethodSettings) + +MCP_METHOD_TO_METHOD_SETTINGS_KEY = { + "tools/list": "list_tools", + "tools/call": "call_tool", + "resources/list": "list_resources", + "resources/read": "read_resource", + "prompts/list": "list_prompts", + "prompts/get": "get_prompt", +} + +DEFAULT_METHOD_SETTINGS: MethodSettings = MethodSettings( + list_tools=SharedMethodSettings( + ttl=FIVE_MINUTES_IN_SECONDS, + ), + call_tool=CallToolSettings( + ttl=ONE_HOUR_IN_SECONDS, + ), + list_resources=SharedMethodSettings( + ttl=FIVE_MINUTES_IN_SECONDS, + ), + list_prompts=SharedMethodSettings( + ttl=FIVE_MINUTES_IN_SECONDS, + ), + read_resource=SharedMethodSettings( + ttl=ONE_HOUR_IN_SECONDS, + ), + get_prompt=SharedMethodSettings( + ttl=ONE_HOUR_IN_SECONDS, + ), +) class ResponseCachingMiddleware(Middleware): @@ -152,68 +367,339 @@ class ResponseCachingMiddleware(Middleware): - Cache key derived from tool name and arguments. """ - _stats: CacheStats - def __init__( self, cache_backend: CacheProtocol | None = None, - included_tools: list[str] | None = None, - excluded_tools: list[str] | None = None, + method_settings: MethodSettings | None = None, default_ttl: int = ONE_HOUR_IN_SECONDS, + max_item_size: int | None = None, ): """Initialize the response caching middleware. Args: cache_backend: The cache backend to use. If None, an in-memory cache is used. - included_tools: The tools to cache responses from. If None, all tools are cached. - excluded_tools: The tools to not cache responses from. If None, no tools are excluded. + method_settings: The settings for the middleware. If None, the default settings are used. default_ttl: The default TTL for cached responses. Defaults to one hour. + max_item_size: The maximum size of an item to cache. Defaults to no size limit. """ - self._default_ttl = default_ttl - self._backend = cache_backend or InMemoryCache() + self._default_ttl: int = default_ttl + self._backend: CacheProtocol = cache_backend or InMemoryCache() + self._max_item_size: int | None = max_item_size + + self._stats = CacheStats() + + self.method_settings: MethodSettings = ( + method_settings or DEFAULT_METHOD_SETTINGS + ) + + async def on_list_tools( + self, + context: MiddlewareContext[mcp.types.ListToolsRequest], + call_next: CallNext[mcp.types.ListToolsRequest, list[Tool]], + ) -> list[Tool]: + if self._should_bypass_caching(context=context): + return await call_next(context=context) + + if cached_value := await self._get_cache( + context=context, + call_next=call_next, + key=None, + ): + return cached_value + + result: list[Tool] = await call_next(context) + + # Convert tool subclasses to Tool objects + result = [ + Tool( + name=tool.name, + title=tool.title, + description=tool.description, + parameters=tool.parameters, + output_schema=tool.output_schema, + annotations=tool.annotations, + meta=tool.meta, + tags=tool.tags, + ) + for tool in result + ] + + return await self._store_in_cache_and_return( + context=context, + key=None, + value=result, + ) + + async def on_list_resources( + self, + context: MiddlewareContext[mcp.types.ListResourcesRequest], + call_next: CallNext[mcp.types.ListResourcesRequest, list[Resource]], + ) -> list[Resource]: + if self._should_bypass_caching(context=context): + return await call_next(context) + + if cached_value := await self._get_cache( + context=context, + call_next=call_next, + key=None, + ): + return cached_value + + result: list[Resource] = await call_next(context) + + result = [ + CachedResource( + **resource.model_dump(exclude={"fn"}), + ) + for resource in result + ] + + return await self._store_in_cache_and_return( + context=context, + key=None, + value=result, + ) - self._stats = CacheStats(hits=0, misses=0) + async def on_list_prompts( + self, + context: MiddlewareContext[mcp.types.ListPromptsRequest], + call_next: CallNext[mcp.types.ListPromptsRequest, list[Prompt]], + ) -> list[Prompt]: + if self._should_bypass_caching(context=context): + return await call_next(context) - self._included_tools = included_tools - self._excluded_tools = excluded_tools + if cached_value := await self._get_cache( + context=context, + call_next=call_next, + key=None, + ): + return cached_value + + result: list[Prompt] = await call_next(context) + + result = [ + CachedPrompt( + name=prompt.name, + title=prompt.title, + description=prompt.description, + arguments=prompt.arguments, + meta=prompt.meta, + ) + for prompt in result + ] + + return await self._store_in_cache_and_return( + context=context, + key=None, + value=result, + ) async def on_call_tool( self, - context: MiddlewareContext[CallToolRequestParams], - call_next: CallNext[CallToolRequestParams, Any], + context: MiddlewareContext[mcp.types.CallToolRequestParams], + call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult], ) -> Any: - if not self._should_cache_tool(context.message.name): + if self._should_bypass_caching(context=context): + return await call_next(context=context) + + if not self._matches_tool_cache_settings(context=context): + return await call_next(context=context) + + return await self._cached_call_next( + context=context, + call_next=call_next, + key=self._make_cache_key(msg=context.message), + ) + + async def on_read_resource( + self, + context: MiddlewareContext[mcp.types.ReadResourceRequestParams], + call_next: CallNext[ + mcp.types.ReadResourceRequestParams, list[ReadResourceContents] + ], + ) -> list[ReadResourceContents]: + if self._should_bypass_caching(context=context): + return await call_next(context=context) + + return await self._cached_call_next( + context=context, + call_next=call_next, + ) + + async def on_get_prompt( + self, + context: MiddlewareContext[mcp.types.GetPromptRequestParams], + call_next: CallNext[ + mcp.types.GetPromptRequestParams, mcp.types.GetPromptResult + ], + ) -> mcp.types.GetPromptResult: + if self._should_bypass_caching(context=context): return await call_next(context) - key = self._make_cache_key(context.message) + return await self._cached_call_next( + context=context, + call_next=call_next, + key=None, + ) - if cached_entry := await self._backend.get(key): - self._stats.hits += 1 - return cached_entry + async def on_notification( + self, + context: MiddlewareContext[mcp.types.Notification], + call_next: CallNext[mcp.types.Notification, Any], + ) -> Any: + if isinstance(context.message, mcp.types.ToolListChangedNotification): + await self._backend.delete(collection="tools/list", key=GLOBAL_KEY) - # Cache miss: call downstream - self._stats.misses += 1 - result = await call_next(context) + return await call_next(context) - await self._backend.set(key, result, self._default_ttl) + async def _cached_call_next( + self, + context: MiddlewareContext[Any], + call_next: CallNext[Any, CachableTypeVar], + key: str | None = None, + ) -> CachableTypeVar: + if key is None: + key = GLOBAL_KEY + + if cached_value := await self._get_cache( + context=context, + call_next=call_next, + key=key, + ): + return cached_value + + result: CachableTypeVar = await call_next(context) + + return await self._store_in_cache_and_return( + context=context, + key=key, + value=result, + ) + + async def _get_cache( + self, + context: MiddlewareContext[Any], + call_next: CallNext[Any, CachableTypeVar], + key: str | None = None, + ) -> CachableTypeVar | None: + if key is None: + key = GLOBAL_KEY + + if not (collection := context.method): + logger.warning("No method found on context, skipping cache") + return None - return result + if cached_value := await self._backend.get_value( + collection=collection, key=key + ): + self._stats.mark_hit(collection=collection) + return cast(CachableTypeVar, cached_value) + + self._stats.mark_miss(collection=collection) + + return None + + async def _store_in_cache_and_return( + self, + context: MiddlewareContext[Any], + key: str | None, + value: CachableTypeVar, + ) -> CachableTypeVar: + if key is None: + key = GLOBAL_KEY + + if not (collection := context.method): + logger.warning("No method found on context, skipping cache") + return value + + if self._max_item_size is not None: + size = 0 + + for item in dump_if_base_model(value): + size += len(item.encode("utf-8")) + + if size > self._max_item_size: + self._stats.mark_too_big(collection=collection) + return value + + ttl: int = self._get_cache_ttl(context=context) + + await self._backend.set_value( + collection=collection, + key=key, + value=value, + ttl=ttl, + ) + + return value + + def _matches_tool_cache_settings( + self, context: MiddlewareContext[mcp.types.CallToolRequestParams] + ) -> bool: + tool_name = context.message.name + + tool_call_cache_settings: CallToolSettings | None = self._get_cache_settings( + context=context, + settings_type=CallToolSettings, + ) + + if not tool_call_cache_settings: + return True + + if included_tools := tool_call_cache_settings.get("included_tools"): + if tool_name not in included_tools: + return False + + if excluded_tools := tool_call_cache_settings.get("excluded_tools"): + if tool_name in excluded_tools: + return False - def _should_cache_tool(self, tool_name: str) -> bool: - if self._excluded_tools is not None and tool_name in self._excluded_tools: - return False - if self._included_tools is not None and tool_name not in self._included_tools: - return False return True - def _make_cache_key(self, msg: CallToolRequestParams) -> str: + def _get_cache_settings( + self, + context: MiddlewareContext[Any], + settings_type: type[MethodSettingsType] = SharedMethodSettings, + ) -> MethodSettingsType | None: + if not context.method: + return None + + method_settings_key = MCP_METHOD_TO_METHOD_SETTINGS_KEY.get( + context.method, None + ) + + if ( + method_settings_key is None + or method_settings_key not in self.method_settings + ): + return None + + return cast(MethodSettingsType, self.method_settings[method_settings_key]) + + def _get_cache_ttl(self, context: MiddlewareContext[Any]) -> int: + settings: SharedMethodSettings | None = self._get_cache_settings( + context=context + ) + + if not settings or "ttl" not in settings: + return self._default_ttl + + return settings["ttl"] + + def _should_bypass_caching(self, context: MiddlewareContext[Any]) -> bool: + if not self._get_cache_settings(context=context): + return True + + return False + + def _make_cache_key(self, msg: mcp.types.CallToolRequestParams) -> str: raw = f"{self._get_tool_key(msg)}:{self._get_tool_arguments_str(msg)}" return hashlib.sha256(raw.encode("utf-8")).hexdigest() - def _get_tool_key(self, msg: CallToolRequestParams) -> str: + def _get_tool_key(self, msg: mcp.types.CallToolRequestParams) -> str: return msg.name - def _get_tool_arguments_str(self, msg: CallToolRequestParams) -> str: + def _get_tool_arguments_str(self, msg: mcp.types.CallToolRequestParams) -> str: if msg.arguments is None: return "null" @@ -222,3 +708,17 @@ def _get_tool_arguments_str(self, msg: CallToolRequestParams) -> str: except TypeError: return repr(msg.arguments) + + +def dump_if_base_model(value: Any) -> list[str]: + if isinstance(value, BaseModel): + return [value.model_dump_json()] + + if isinstance(value, list): + return [ + item + for sublist in [dump_if_base_model(val) for val in value] + for item in sublist + ] + + return [json.dumps(value, sort_keys=True, separators=(",", ":"))] diff --git a/src/fastmcp/server/middleware/middleware.py b/src/fastmcp/server/middleware/middleware.py index 2eb0894bf2..8b262d4f55 100644 --- a/src/fastmcp/server/middleware/middleware.py +++ b/src/fastmcp/server/middleware/middleware.py @@ -15,6 +15,7 @@ ) import mcp.types as mt +from mcp.server.lowlevel.helper_types import ReadResourceContents from typing_extensions import TypeVar from fastmcp.prompts.prompt import Prompt @@ -154,8 +155,8 @@ async def on_call_tool( async def on_read_resource( self, context: MiddlewareContext[mt.ReadResourceRequestParams], - call_next: CallNext[mt.ReadResourceRequestParams, mt.ReadResourceResult], - ) -> mt.ReadResourceResult: + call_next: CallNext[mt.ReadResourceRequestParams, list[ReadResourceContents]], + ) -> list[ReadResourceContents]: return await call_next(context) async def on_get_prompt( diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index 871f2c24ea..37bd333d57 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -1,6 +1,7 @@ """Tests for response caching middleware.""" import tempfile +from collections.abc import Sequence from datetime import datetime, timedelta, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -8,20 +9,56 @@ import mcp.types import pytest from inline_snapshot import snapshot -from pydantic import BaseModel +from mcp.server.lowlevel.helper_types import ReadResourceContents +from mcp.types import ( + TextContent, + TextResourceContents, +) +from pydantic import AnyUrl, BaseModel from fastmcp import FastMCP from fastmcp.client import Client +from fastmcp.client.client import CallToolResult +from fastmcp.client.transports import FastMCPTransport +from fastmcp.prompts.prompt import FunctionPrompt from fastmcp.server.middleware.caching import ( + CachableTypes, + CachedPrompt, + CachedResource, CacheEntry, + CacheMethodStats, + CacheProtocol, CacheStats, + CallToolSettings, DiskCache, InMemoryCache, + MethodSettings, ResponseCachingMiddleware, ) -from fastmcp.server.middleware.middleware import MiddlewareContext +from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext from fastmcp.tools.tool import Tool, ToolResult +TEST_URI = AnyUrl("https://test_uri") + +SAMPLE_RESOURCE = CachedResource(name="resource", uri=TEST_URI, mime_type="text/plain") +SAMPLE_PROMPT = CachedPrompt(name="prompt") +SAMPLE_READ_RESOURCE_CONTENTS = ReadResourceContents( + content="test_text", + mime_type="text/plain", +) +SAMPLE_GET_PROMPT_RESULT = mcp.types.GetPromptResult( + messages=[ + mcp.types.PromptMessage( + role="user", content=mcp.types.TextContent(type="text", text="test_text") + ) + ] +) +SAMPLE_TOOL = Tool(name="test_tool", parameters={"param1": "value1", "param2": 42}) +SAMPLE_TOOL_RESULT = ToolResult( + content=[TextContent(type="text", text="test_text")], + structured_content={"result": "test_result"}, +) + class CrazyModel(BaseModel): a: int @@ -35,15 +72,43 @@ class CrazyModel(BaseModel): i: dict[str, list[int]] -def extract_content_for_snapshot(result: ToolResult) -> dict[str, Any]: +def extract_content_for_snapshot(result: ToolResult | CallToolResult) -> dict[str, Any]: return { "content": [c.model_dump() for c in result.content], "structured_content": result.structured_content, } +def dump_mcp_type( + model: BaseModel | ToolResult | ReadResourceContents, +) -> dict[str, Any]: + if isinstance(model, ToolResult): + return extract_content_for_snapshot(model) + + if isinstance(model, ReadResourceContents): + return { + "content": model.content, + "mime_type": model.mime_type, + } + + return model.model_dump() + + +def dump_mcp_types( + model: BaseModel + | ToolResult + | Sequence[BaseModel] + | Sequence[ToolResult] + | list[ReadResourceContents], +) -> list[dict[str, Any]]: + if isinstance(model, Sequence): + return [dump_mcp_type(model=m) for m in model] + + return dump_mcp_type(model=model) # type: ignore + + @pytest.fixture -def crazy_model(): +def crazy_model() -> CrazyModel: return CrazyModel( a=5, b=10, @@ -79,19 +144,55 @@ def crazy(self, a: CrazyModel) -> CrazyModel: self.crazy_calls += 1 return a - def add_tools(self, fastmcp: FastMCP): - fastmcp.add_tool(tool=Tool.from_function(fn=self.add)) - fastmcp.add_tool(tool=Tool.from_function(fn=self.multiply)) - fastmcp.add_tool(tool=Tool.from_function(fn=self.crazy)) + def how_to_calculate(self, a: int, b: int) -> str: + return f"To calculate {a} + {b}, you need to add {a} and {b} together." + + def get_add_calls(self) -> int: + return self.add_calls + + def get_multiply_calls(self) -> int: + return self.multiply_calls + + def get_crazy_calls(self) -> int: + return self.crazy_calls + + def add_tools(self, fastmcp: FastMCP, prefix: str = ""): + fastmcp.add_tool(tool=Tool.from_function(fn=self.add, name=f"{prefix}add")) + fastmcp.add_tool( + tool=Tool.from_function(fn=self.multiply, name=f"{prefix}multiply") + ) + fastmcp.add_tool(tool=Tool.from_function(fn=self.crazy, name=f"{prefix}crazy")) + + def add_prompts(self, fastmcp: FastMCP, prefix: str = ""): + fastmcp.add_prompt( + prompt=FunctionPrompt.from_function( + fn=self.how_to_calculate, name=f"{prefix}how_to_calculate" + ) + ) + + def add_resources(self, fastmcp: FastMCP, prefix: str = ""): + fastmcp.add_resource_fn( + fn=self.get_add_calls, uri="resource://add_calls", name=f"{prefix}add_calls" + ) + fastmcp.add_resource_fn( + fn=self.get_multiply_calls, + uri="resource://multiply_calls", + name=f"{prefix}multiply_calls", + ) + fastmcp.add_resource_fn( + fn=self.get_crazy_calls, + uri="resource://crazy_calls", + name=f"{prefix}crazy_calls", + ) @pytest.fixture -def tracking_calculator(): +def tracking_calculator() -> TrackingCalculator: return TrackingCalculator() @pytest.fixture -def mock_context(): +def mock_context() -> MiddlewareContext[mcp.types.CallToolRequestParams]: """Create a mock middleware context for tool calls.""" context = MagicMock(spec=MiddlewareContext[mcp.types.CallToolRequestParams]) context.message = mcp.types.CallToolRequestParams( @@ -102,7 +203,7 @@ def mock_context(): @pytest.fixture -def mock_call_next(): +def mock_call_next() -> CallNext[mcp.types.CallToolRequestParams, ToolResult]: """Create a mock call_next function.""" return AsyncMock( return_value=ToolResult( @@ -113,7 +214,7 @@ def mock_call_next(): @pytest.fixture -def sample_tool_result(): +def sample_tool_result() -> ToolResult: """Create a sample tool result for testing.""" return ToolResult( content=[{"type": "text", "text": "cached result"}], @@ -131,24 +232,32 @@ def test_init_and_expiration(self): past = now - timedelta(seconds=3600) # Test valid entry - entry = CacheEntry( + entry: CacheEntry[ToolResult] = CacheEntry( + collection="test_collection", key="test_key", - content=[{"type": "text", "text": "test"}], - structured_content='{"result": "success"}', + value=ToolResult( + content=[{"type": "text", "text": "success"}], + structured_content={"result": "success"}, + ), created_at=now, expires_at=future, + ttl=3600, ) assert entry.key == "test_key" assert not entry.is_expired() # Test expired entry - expired_entry = CacheEntry( + expired_entry: CacheEntry[ToolResult] = CacheEntry( + collection="test_collection", key="expired_key", - content=None, - structured_content=None, + value=ToolResult( + content=[{"type": "text", "text": "success"}], + structured_content={"result": "success"}, + ), created_at=past, expires_at=past, + ttl=3600, ) assert expired_entry.is_expired() @@ -156,64 +265,144 @@ def test_init_and_expiration(self): def test_serialization(self): """Test cache entry serialization to/from tool result.""" tool_result = ToolResult( - content=[{"type": "text", "text": "test"}], + content=[{"type": "text", "text": "success"}], structured_content={"result": "success"}, ) # Test round-trip conversion - entry = CacheEntry.from_tool_result("test_key", tool_result, 3600) - result = entry.to_tool_result() + entry: CacheEntry[ToolResult] = CacheEntry.from_value( + collection="test_collection", + key="test_key", + value=tool_result, + ttl=3600, + ) + + retrieved_tool_result: ToolResult = entry.value - assert result.content == tool_result.content - assert result.structured_content == tool_result.structured_content + assert retrieved_tool_result.content == tool_result.content + assert ( + retrieved_tool_result.structured_content == tool_result.structured_content + ) -class TestInMemoryCache: + +class TestMemoryCache: """Test InMemoryCache implementation.""" - async def test_basic_operations(self, sample_tool_result): - """Test basic cache operations.""" - cache = InMemoryCache() + async def test_size_limit(self, sample_tool_result): + """Test cache size limit enforcement.""" + cache = InMemoryCache(max_entries=2) + + # Fill cache to capacity + await cache.set_value( + collection="test_collection", key="key1", value=sample_tool_result, ttl=3600 + ) + await cache.set_value( + collection="test_collection", key="key2", value=sample_tool_result, ttl=3600 + ) + + # Add one more - should evict the first + await cache.set_value( + collection="test_collection", key="key3", value=sample_tool_result, ttl=3600 + ) + + assert len(cache._cache) == 2 + assert "test_collection:key1" not in cache._cache + assert "test_collection:key2" in cache._cache + assert "test_collection:key3" in cache._cache + + +class TestCacheImplementations: + """Test InMemoryCache implementation.""" - # Test set and get - await cache.set("test_key", sample_tool_result, 3600) - result = await cache.get("test_key") + @pytest.fixture(params=["memory", "disk"]) + async def cache(self, request): + if request.param == "memory": + return InMemoryCache() + else: + with tempfile.TemporaryDirectory() as temp_dir: + return DiskCache(path=temp_dir) + + async def test_get_none_if_not_set(self, cache: CacheProtocol): + """Test that we get None if a value is not set.""" + assert ( + await cache.get_value(collection="test_collection", key="test_key") is None + ) + + @pytest.mark.parametrize( + "value", + [ + [SAMPLE_TOOL], + SAMPLE_TOOL_RESULT, + [SAMPLE_RESOURCE], + [SAMPLE_READ_RESOURCE_CONTENTS], + [SAMPLE_PROMPT], + SAMPLE_GET_PROMPT_RESULT, + ], + ids=[ + "tool_list", + "tool_result", + "resource", + "read_resource_contents", + "prompt", + "get_prompt_result", + ], + ) + async def test_set_and_get(self, cache: CacheProtocol, value: CachableTypes): + """Test that we can set and then get back a value from the cache.""" + + await cache.set_value( + collection="test_collection", + key="test_key", + value=value, + ttl=3600, + ) + result = await cache.get_value(collection="test_collection", key="test_key") assert result is not None - assert result.content == sample_tool_result.content - # Test delete - await cache.delete("test_key") - assert await cache.get("test_key") is None + assert isinstance(result, type(value)) - async def test_expiration_and_cleanup(self, sample_tool_result): - """Test cache expiration and cleanup.""" - cache = InMemoryCache() + assert dump_mcp_types(model=result) == dump_mcp_types(model=value) - # Create an expired entry - entry = CacheEntry.from_tool_result("expired_key", sample_tool_result, -1) - cache._cache["expired_key"] = entry + async def test_set_get_delete_get_value(self, cache: CacheProtocol): + """Test that we can set, get, delete, and get a value from the cache.""" + await cache.set_value( + collection="test_collection", + key="test_key", + value=SAMPLE_TOOL_RESULT, + ttl=3600, + ) + result = await cache.get_value(collection="test_collection", key="test_key") - # Should return None and remove expired entry - result = await cache.get("expired_key") - assert result is None - assert "expired_key" not in cache._cache + assert result is not None + assert dump_mcp_types(model=result) == dump_mcp_types(model=SAMPLE_TOOL_RESULT) - async def test_size_limit(self, sample_tool_result): - """Test cache size limit enforcement.""" - cache = InMemoryCache(max_size=2) + await cache.delete(collection="test_collection", key="test_key") - # Fill cache to capacity - await cache.set("key1", sample_tool_result, 3600) - await cache.set("key2", sample_tool_result, 3600) + assert ( + await cache.get_value(collection="test_collection", key="test_key") is None + ) - # Add one more - should evict the first - await cache.set("key3", sample_tool_result, 3600) + async def test_expiration_and_cleanup(self, cache: CacheProtocol): + """Test cache expiration and cleanup.""" + # Create an expired entry + await cache.set_value( + collection="test_collection", + key="expired_key", + value=SAMPLE_TOOL_RESULT, + ttl=-1, + ) - assert len(cache._cache) == 2 - assert "key1" not in cache._cache - assert "key2" in cache._cache - assert "key3" in cache._cache + # Should return None and remove expired entry + result = await cache.get_value(collection="test_collection", key="expired_key") + + assert result is None + + assert ( + await cache.get_value(collection="test_collection", key="expired_key") + is None + ) class TestResponseCachingMiddleware: @@ -221,43 +410,66 @@ class TestResponseCachingMiddleware: def test_initialization(self): """Test middleware initialization.""" - cache = InMemoryCache() middleware = ResponseCachingMiddleware( - cache_backend=cache, - included_tools=["tool1"], - excluded_tools=["tool2"], + method_settings=MethodSettings( + call_tool=CallToolSettings( + included_tools=["tool1"], + excluded_tools=["tool2"], + ) + ), default_ttl=1800, ) - assert middleware._backend is cache + assert middleware.method_settings == snapshot( + {"call_tool": {"included_tools": ["tool1"], "excluded_tools": ["tool2"]}} + ) assert middleware._default_ttl == 1800 - assert middleware._included_tools == ["tool1"] - assert middleware._excluded_tools == ["tool2"] - assert middleware._stats.hits == 0 - assert middleware._stats.misses == 0 - - def test_tool_filtering(self): + assert middleware._max_item_size is None + + @pytest.mark.parametrize( + ("tool_name", "included_tools", "excluded_tools", "result"), + [ + ("tool", ["tool", "tool2"], [], True), + ("tool", ["second tool", "third tool"], [], False), + ("tool", [], ["tool"], False), + ("tool", [], ["second tool"], True), + ("tool", ["tool", "second tool"], ["tool"], False), + ("tool", ["tool", "second tool"], ["second tool"], True), + ], + ids=[ + "tool is included", + "tool is not included", + "tool is excluded", + "tool is not excluded", + "tool is included and excluded (excluded takes precedence)", + "tool is included and not excluded", + ], + ) + def test_tool_call_filtering( + self, + tool_name: str, + included_tools: list[str], + excluded_tools: list[str], + result: bool, + ): """Test tool filtering logic.""" - cache = InMemoryCache() - # Test included tools only middleware1 = ResponseCachingMiddleware( - cache, included_tools=["tool1", "tool2"] + method_settings=MethodSettings( + call_tool=CallToolSettings( + included_tools=included_tools, excluded_tools=excluded_tools + ) + ), ) - assert middleware1._should_cache_tool("tool1") is True - assert middleware1._should_cache_tool("tool3") is False - - # Test excluded tools - middleware2 = ResponseCachingMiddleware(cache, excluded_tools=["tool1"]) - assert middleware2._should_cache_tool("tool1") is False - assert middleware2._should_cache_tool("tool2") is True - - # Test both (excluded takes precedence) - middleware3 = ResponseCachingMiddleware( - cache, included_tools=["tool1", "tool2"], excluded_tools=["tool2"] + assert ( + middleware1._matches_tool_cache_settings( + context=MiddlewareContext( + method="tools/call", + message=mcp.types.CallToolRequestParams(name=tool_name), + ) + ) + is result ) - assert middleware3._should_cache_tool("tool1") is True - assert middleware3._should_cache_tool("tool2") is False def test_cache_key_generation(self): """Test cache key generation.""" @@ -274,56 +486,70 @@ def test_cache_key_generation(self): assert len(key) == 64 assert all(c in "0123456789abcdef" for c in key) - async def test_cache_miss_and_hit(self, mock_context, mock_call_next): + async def test_cache_miss_and_hit( + self, + ): """Test cache miss and hit scenarios.""" - cache = InMemoryCache() - middleware = ResponseCachingMiddleware(cache) + middleware = ResponseCachingMiddleware() + + mock_call_next = AsyncMock( + return_value=ToolResult( + content=[{"type": "text", "text": "test result"}], + structured_content={"result": "success", "value": 123}, + ) + ) + + mock_context = MagicMock( + spec=MiddlewareContext[mcp.types.CallToolRequestParams] + ) + mock_context.message = mcp.types.CallToolRequestParams( + name="test_tool", arguments={"param1": "value1", "param2": 42} + ) + mock_context.method = "tools/call" # First call - cache miss - result1 = await middleware.on_call_tool(mock_context, mock_call_next) - assert middleware._stats.misses == 1 - assert middleware._stats.hits == 0 + result1 = await middleware.on_call_tool( + context=mock_context, call_next=mock_call_next + ) + assert middleware._stats.get_misses("tools/call") == 1 + assert middleware._stats.get_hits("tools/call") == 0 # Second call - cache hit mock_call_next.reset_mock() - result2 = await middleware.on_call_tool(mock_context, mock_call_next) + result2 = await middleware.on_call_tool( + context=mock_context, call_next=mock_call_next + ) assert result1.content == result2.content assert not mock_call_next.called # Should not call downstream - assert middleware._stats.hits == 1 - assert middleware._stats.misses == 1 + assert middleware._stats.get_hits("tools/call") == 1 + assert middleware._stats.get_misses("tools/call") == 1 class TestResponseCachingMiddlewareIntegration: """Integration tests with real FastMCP server.""" - @pytest.fixture - async def disk_cache(self): - with tempfile.TemporaryDirectory() as temp_dir: - yield DiskCache(path=temp_dir) - - @pytest.fixture - async def in_memory_cache(self): - return InMemoryCache() - @pytest.fixture(params=["memory", "disk"]) async def caching_server( self, tracking_calculator: TrackingCalculator, request, - disk_cache, - in_memory_cache, ): """Create a FastMCP server for caching tests.""" mcp = FastMCP("CachingTestServer") - cache = disk_cache if request.param == "disk" else in_memory_cache - - response_caching_middleware = ResponseCachingMiddleware(cache_backend=cache) + with tempfile.TemporaryDirectory() as temp_dir: + response_caching_middleware = ResponseCachingMiddleware( + cache_backend=DiskCache(path=temp_dir) + if request.param == "disk" + else InMemoryCache() + ) mcp.add_middleware(middleware=response_caching_middleware) - tracking_calculator.add_tools(mcp) + tracking_calculator.add_tools(fastmcp=mcp) + tracking_calculator.add_resources(fastmcp=mcp) + tracking_calculator.add_prompts(fastmcp=mcp) return mcp @@ -331,148 +557,119 @@ async def caching_server( def non_caching_server(self, tracking_calculator: TrackingCalculator): """Create a FastMCP server for non-caching tests.""" mcp = FastMCP("NonCachingTestServer") - tracking_calculator.add_tools(mcp) + tracking_calculator.add_tools(fastmcp=mcp) return mcp - async def test_caching_works_with_real_server( + async def test_list_tools( + self, caching_server: FastMCP, tracking_calculator: TrackingCalculator + ): + """Test that tool list caching works with a real FastMCP server.""" + + async with Client(caching_server) as client: + pre_tool_list: list[mcp.types.Tool] = await client.list_tools() + assert len(pre_tool_list) == 3 + + # Add a tool and make sure it's missing from the list tool response + caching_server.add_tool( + tool=Tool.from_function(fn=tracking_calculator.add, name="add_2") + ) + + post_tool_list: list[mcp.types.Tool] = await client.list_tools() + assert len(post_tool_list) == 3 + + assert pre_tool_list == post_tool_list + + async def test_call_tool( self, caching_server: FastMCP, tracking_calculator: TrackingCalculator, - crazy_model: CrazyModel, ): """Test that caching works with a real FastMCP server.""" - tracking_calculator.add_tools(caching_server) - - async with Client(caching_server) as client: - call_tool_result = await client.call_tool("add", {"a": 5, "b": 3}) + tracking_calculator.add_tools(fastmcp=caching_server) - assert tracking_calculator.add_calls == 1 - assert extract_content_for_snapshot(call_tool_result) == snapshot( - { - "content": [ - {"type": "text", "text": "8", "annotations": None, "meta": None} - ], - "structured_content": {"result": 8}, - } + async with Client[FastMCPTransport](caching_server) as client: + call_tool_result_one: CallToolResult = await client.call_tool( + "add", {"a": 5, "b": 3} ) - call_tool_result = await client.call_tool("add", {"a": 5, "b": 3}) assert tracking_calculator.add_calls == 1 - assert extract_content_for_snapshot(call_tool_result) == snapshot( - { - "content": [ - {"type": "text", "text": "8", "annotations": None, "meta": None} - ], - "structured_content": {"result": 8}, - } + call_tool_result_two: CallToolResult = await client.call_tool( + "add", {"a": 5, "b": 3} ) + assert call_tool_result_one == call_tool_result_two - call_tool_result = await client.call_tool("crazy", {"a": crazy_model}) - assert tracking_calculator.crazy_calls == 1 - assert extract_content_for_snapshot(call_tool_result) == snapshot( - { - "content": [ - { - "type": "text", - "text": '{"a":5,"b":10,"c":"test","d":1.0,"e":true,"f":[1,2,3],"g":{"a":1,"b":2},"h":[{"a":1,"b":2}],"i":{"a":[1,2]}}', - "annotations": None, - "meta": None, - } - ], - "structured_content": { - "a": 5, - "b": 10, - "c": "test", - "d": 1.0, - "e": True, - "f": [1, 2, 3], - "g": {"a": 1, "b": 2}, - "h": [{"a": 1, "b": 2}], - "i": {"a": [1, 2]}, - }, - } - ) + async def test_list_resources( + self, caching_server: FastMCP, tracking_calculator: TrackingCalculator + ): + """Test that list resources caching works with a real FastMCP server.""" + async with Client[FastMCPTransport](transport=caching_server) as client: + pre_resource_list: list[mcp.types.Resource] = await client.list_resources() - call_tool_result = await client.call_tool("crazy", {"a": crazy_model}) - assert tracking_calculator.crazy_calls == 1 - assert extract_content_for_snapshot(call_tool_result) == snapshot( - { - "content": [ - { - "type": "text", - "text": '{"a":5,"b":10,"c":"test","d":1.0,"e":true,"f":[1,2,3],"g":{"a":1,"b":2},"h":[{"a":1,"b":2}],"i":{"a":[1,2]}}', - "annotations": None, - "meta": None, - } - ], - "structured_content": { - "a": 5, - "b": 10, - "c": "test", - "d": 1.0, - "e": True, - "f": [1, 2, 3], - "g": {"a": 1, "b": 2}, - "h": [{"a": 1, "b": 2}], - "i": {"a": [1, 2]}, - }, - } - ) + assert len(pre_resource_list) == 3 + + tracking_calculator.add_resources(fastmcp=caching_server) + + post_resource_list: list[mcp.types.Resource] = await client.list_resources() + assert len(post_resource_list) == 3 + + assert pre_resource_list == post_resource_list - async def test_different_arguments_create_different_entries( + async def test_read_resource( self, caching_server: FastMCP, tracking_calculator: TrackingCalculator ): - """Test that different arguments create different cache entries.""" + """Test that get resources caching works with a real FastMCP server.""" + async with Client[FastMCPTransport](transport=caching_server) as client: + pre_resource = await client.read_resource(uri="resource://add_calls") + assert isinstance(pre_resource[0], TextResourceContents) + assert pre_resource[0].text == "0" - async with Client(caching_server) as client: - result1 = await client.call_tool("add", {"a": 5, "b": 10}) - assert tracking_calculator.add_calls == 1 - result2 = await client.call_tool("add", {"a": 1, "b": 5}) - assert tracking_calculator.add_calls == 2 + tracking_calculator.add_calls = 1 - # Results should be different - assert result1.structured_content["result"] == 15 - assert result2.structured_content["result"] == 6 + post_resource = await client.read_resource(uri="resource://add_calls") + assert isinstance(post_resource[0], TextResourceContents) + assert post_resource[0].text == "0" + assert pre_resource == post_resource - async def test_tool_filtering_integration( - self, non_caching_server: FastMCP, tracking_calculator: TrackingCalculator + async def test_list_prompts( + self, caching_server: FastMCP, tracking_calculator: TrackingCalculator ): - """Test tool filtering in integration.""" - partial_caching_server = non_caching_server + """Test that list prompts caching works with a real FastMCP server.""" + async with Client[FastMCPTransport](transport=caching_server) as client: + pre_prompt_list: list[mcp.types.Prompt] = await client.list_prompts() - partial_caching_server.add_middleware( - ResponseCachingMiddleware( - cache_backend=InMemoryCache(), - included_tools=["add"], # Only cache this tool - ) - ) + assert len(pre_prompt_list) == 1 - async with Client(partial_caching_server) as client: - # This should be cached - await client.call_tool("add", {"a": 5, "b": 10}) - await client.call_tool("add", {"a": 5, "b": 10}) - assert tracking_calculator.add_calls == 1 + tracking_calculator.add_prompts(fastmcp=caching_server) - # This should not be cached - await client.call_tool("multiply", {"a": 1, "b": 5}) - await client.call_tool("multiply", {"a": 1, "b": 5}) - assert tracking_calculator.multiply_calls == 2 + post_prompt_list: list[mcp.types.Prompt] = await client.list_prompts() - async def test_cache_stats_tracking(self, non_caching_server: FastMCP): - """Test that cache statistics are properly tracked.""" - middleware = ResponseCachingMiddleware(cache_backend=InMemoryCache()) - non_caching_server.add_middleware(middleware) + assert len(post_prompt_list) == 1 - async with Client(non_caching_server) as client: - # First call - cache miss - await client.call_tool("add", {"a": 5, "b": 10}) - assert middleware._stats.misses == 1 - assert middleware._stats.hits == 0 + assert pre_prompt_list == post_prompt_list - # Second call - cache hit - await client.call_tool("add", {"a": 5, "b": 10}) - assert middleware._stats.misses == 1 - assert middleware._stats.hits == 1 + async def test_get_prompts( + self, caching_server: FastMCP, tracking_calculator: TrackingCalculator + ): + """Test that get prompts caching works with a real FastMCP server.""" + async with Client[FastMCPTransport](transport=caching_server) as client: + pre_prompt = await client.get_prompt( + name="how_to_calculate", arguments={"a": 5, "b": 3} + ) + + pre_prompt_content = pre_prompt.messages[0].content + assert isinstance(pre_prompt_content, TextContent) + assert ( + pre_prompt_content.text + == "To calculate 5 + 3, you need to add 5 and 3 together." + ) + + tracking_calculator.add_prompts(fastmcp=caching_server) + + post_prompt = await client.get_prompt( + name="how_to_calculate", arguments={"a": 5, "b": 3} + ) + + assert pre_prompt == post_prompt class TestCacheStats: @@ -480,6 +677,14 @@ class TestCacheStats: def test_stats_initialization(self): """Test cache stats initialization.""" - stats = CacheStats(hits=5, misses=10) - assert stats.hits == 5 - assert stats.misses == 10 + stats = CacheStats( + collections={ + "tools/call": CacheMethodStats(hits=5, misses=10), + "tools/list": CacheMethodStats(hits=0, misses=0), + } + ) + + assert stats.get_hits("tools/call") == 5 + assert stats.get_misses("tools/call") == 10 + assert stats.get_hits("tools/list") == 0 + assert stats.get_misses("tools/list") == 0 diff --git a/uv.lock b/uv.lock index 36b3b11814..fb632a6c4f 100644 --- a/uv.lock +++ b/uv.lock @@ -69,6 +69,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "cachetools" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/61/e4fad8155db4a04bfb4734c7c8ff0882f078f24294d42798b3568eb63bff/cachetools-6.2.0.tar.gz", hash = "sha256:38b328c0889450f05f5e120f56ab68c8abaf424e1275522b138ffc93253f7e32", size = 30988, upload-time = "2025-08-25T18:57:30.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/56/3124f61d37a7a4e7cc96afc5492c78ba0cb551151e530b54669ddd1436ef/cachetools-6.2.0-py3-none-any.whl", hash = "sha256:1c76a8960c0041fcc21097e357f882197c79da0dbff766e7317890a65d7d8ba6", size = 11276, upload-time = "2025-08-25T18:57:29.684Z" }, +] + [[package]] name = "certifi" version = "2025.8.3" @@ -530,7 +539,6 @@ source = { editable = "." } dependencies = [ { name = "authlib" }, { name = "cyclopts" }, - { name = "diskcache" }, { name = "exceptiongroup" }, { name = "httpx" }, { name = "mcp" }, @@ -543,6 +551,10 @@ dependencies = [ ] [package.optional-dependencies] +caching = [ + { name = "cachetools" }, + { name = "diskcache" }, +] openai = [ { name = "openai" }, ] @@ -554,7 +566,7 @@ websockets = [ dev = [ { name = "dirty-equals" }, { name = "fastapi" }, - { name = "fastmcp", extra = ["openai"] }, + { name = "fastmcp", extra = ["caching", "openai"] }, { name = "inline-snapshot", extra = ["dirty-equals"] }, { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -565,6 +577,7 @@ dev = [ { name = "pyperclip" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "pytest-env" }, { name = "pytest-flakefinder" }, @@ -579,8 +592,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "authlib", specifier = ">=1.5.2" }, + { name = "cachetools", marker = "extra == 'caching'", specifier = ">=6.2.0" }, { name = "cyclopts", specifier = ">=3.0.0" }, - { name = "diskcache", specifier = ">=5.6.3" }, + { name = "diskcache", marker = "extra == 'caching'", specifier = ">=5.6.3" }, { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", specifier = ">=1.12.4,<2.0.0" }, @@ -593,12 +607,13 @@ requires-dist = [ { name = "rich", specifier = ">=13.9.4" }, { name = "websockets", marker = "extra == 'websockets'", specifier = ">=15.0.1" }, ] -provides-extras = ["openai", "websockets"] +provides-extras = ["caching", "openai", "websockets"] [package.metadata.requires-dev] dev = [ { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "fastapi", specifier = ">=0.115.12" }, + { name = "fastmcp", extras = ["caching"] }, { name = "fastmcp", extras = ["openai"] }, { name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" }, { name = "ipython", specifier = ">=8.12.3" }, @@ -609,6 +624,7 @@ dev = [ { name = "pyperclip", specifier = ">=1.9.0" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-asyncio", specifier = ">=0.23.5" }, + { name = "pytest-benchmark", specifier = ">=5.1.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "pytest-env", specifier = ">=1.1.5" }, { name = "pytest-flakefinder" }, @@ -1290,6 +1306,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -1540,6 +1565,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/d0/a8bd08d641b393db3be3819b03e2d9bb8760ca8479080a26a5f6e540e99c/pytest-benchmark-5.1.0.tar.gz", hash = "sha256:9ea661cdc292e8231f7cd4c10b0319e56a2118e2c09d9f50e1b3d150d2aca105", size = 337810, upload-time = "2024-10-30T11:51:48.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/d6/b41653199ea09d5969d4e385df9bbfd9a100f28ca7e824ce7c0a016e3053/pytest_benchmark-5.1.0-py3-none-any.whl", hash = "sha256:922de2dfa3033c227c96da942d1878191afa135a29485fb942e85dff1c592c89", size = 44259, upload-time = "2024-10-30T11:51:45.94Z" }, +] + [[package]] name = "pytest-cov" version = "6.2.1" From c600a168997fc91842db022bba1763e86bb51978 Mon Sep 17 00:00:00 2001 From: William Easton Date: Wed, 17 Sep 2025 19:36:14 -0500 Subject: [PATCH 05/15] More progress --- src/fastmcp/server/middleware/caching.py | 118 +++++++++++++++-------- tests/server/middleware/test_caching.py | 94 ++++++++++++++---- 2 files changed, 155 insertions(+), 57 deletions(-) diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 0b30fcdcf1..7cb2853934 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -1,6 +1,7 @@ import hashlib import json from collections import defaultdict +from collections.abc import Sequence from datetime import datetime, timedelta, timezone from typing import Any, ClassVar, Generic, Protocol, TypedDict, TypeVar, cast @@ -37,10 +38,10 @@ CachableTypes = ( ToolResult - | list[Tool] - | list[Resource] - | list[Prompt] - | list[ReadResourceContents] + | Sequence[Tool] + | Sequence[Resource] + | Sequence[Prompt] + | Sequence[ReadResourceContents] | mcp.types.GetPromptResult ) @@ -71,6 +72,13 @@ def read(self) -> str | bytes: ) +class CachedTool(Tool): + """A cached tool.""" + + def run(self, arguments: dict[str, Any]) -> ToolResult: + raise NotImplementedError("Run called on CachedTool, this should never happen") + + class CacheEntry(BaseModel, Generic[CachableTypeVar]): """A cache entry.""" @@ -328,6 +336,12 @@ class MethodSettings(TypedDict): MethodSettingsType = TypeVar("MethodSettingsType", bound=SharedMethodSettings) +NOTIFICATION_TO_COLLECTION = { + mcp.types.ToolListChangedNotification: "tools/list", + mcp.types.ResourceListChangedNotification: "resources/list", + mcp.types.PromptListChangedNotification: "prompts/list", +} + MCP_METHOD_TO_METHOD_SETTINGS_KEY = { "tools/list": "list_tools", "tools/call": "call_tool", @@ -401,9 +415,7 @@ async def on_list_tools( return await call_next(context=context) if cached_value := await self._get_cache( - context=context, - call_next=call_next, - key=None, + context=context, call_next=call_next, key=None ): return cached_value @@ -411,7 +423,7 @@ async def on_list_tools( # Convert tool subclasses to Tool objects result = [ - Tool( + CachedTool( name=tool.name, title=tool.title, description=tool.description, @@ -508,7 +520,7 @@ async def on_call_tool( return await self._cached_call_next( context=context, call_next=call_next, - key=self._make_cache_key(msg=context.message), + key=_make_call_tool_cache_key(msg=context.message), ) async def on_read_resource( @@ -524,6 +536,7 @@ async def on_read_resource( return await self._cached_call_next( context=context, call_next=call_next, + key=_make_read_resource_cache_key(msg=context.message), ) async def on_get_prompt( @@ -539,7 +552,7 @@ async def on_get_prompt( return await self._cached_call_next( context=context, call_next=call_next, - key=None, + key=_make_get_prompt_cache_key(msg=context.message), ) async def on_notification( @@ -547,8 +560,8 @@ async def on_notification( context: MiddlewareContext[mcp.types.Notification], call_next: CallNext[mcp.types.Notification, Any], ) -> Any: - if isinstance(context.message, mcp.types.ToolListChangedNotification): - await self._backend.delete(collection="tools/list", key=GLOBAL_KEY) + if collection := NOTIFICATION_TO_COLLECTION.get(context.message): + await self._backend.delete(collection=collection, key=GLOBAL_KEY) return await call_next(context) @@ -613,12 +626,7 @@ async def _store_in_cache_and_return( return value if self._max_item_size is not None: - size = 0 - - for item in dump_if_base_model(value): - size += len(item.encode("utf-8")) - - if size > self._max_item_size: + if get_size_of_value(value=value) > self._max_item_size: self._stats.mark_too_big(collection=collection) return value @@ -692,33 +700,63 @@ def _should_bypass_caching(self, context: MiddlewareContext[Any]) -> bool: return False - def _make_cache_key(self, msg: mcp.types.CallToolRequestParams) -> str: - raw = f"{self._get_tool_key(msg)}:{self._get_tool_arguments_str(msg)}" - return hashlib.sha256(raw.encode("utf-8")).hexdigest() - def _get_tool_key(self, msg: mcp.types.CallToolRequestParams) -> str: - return msg.name +def _make_call_tool_cache_key(msg: mcp.types.CallToolRequestParams) -> str: + raw = f"{msg.name}:{_get_arguments_str(msg.arguments)}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() - def _get_tool_arguments_str(self, msg: mcp.types.CallToolRequestParams) -> str: - if msg.arguments is None: - return "null" - try: - return json.dumps(msg.arguments, sort_keys=True, separators=(",", ":")) +def _make_read_resource_cache_key(msg: mcp.types.ReadResourceRequestParams) -> str: + raw = f"{msg.uri}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() - except TypeError: - return repr(msg.arguments) +def _make_get_prompt_cache_key(msg: mcp.types.GetPromptRequestParams) -> str: + raw = f"{msg.name}:{_get_arguments_str(msg.arguments)}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() -def dump_if_base_model(value: Any) -> list[str]: - if isinstance(value, BaseModel): - return [value.model_dump_json()] - if isinstance(value, list): - return [ - item - for sublist in [dump_if_base_model(val) for val in value] - for item in sublist - ] +def _get_arguments_str(arguments: dict[str, Any] | None) -> str: + if arguments is None: + return "null" + + try: + return json.dumps(arguments, sort_keys=True, separators=(",", ":")) + + except TypeError: + return repr(arguments) + + +def get_size_of_content_blocks( + value: mcp.types.ContentBlock | Sequence[mcp.types.ContentBlock], +) -> int: + if isinstance(value, mcp.types.ContentBlock): + value = [value] + + return sum([len(item.model_dump_json()) for item in value]) + + +def get_size_of_tool_result(value: ToolResult) -> int: + content_size = get_size_of_content_blocks(value.content) + structured_content_size = len( + json.dumps( + value.structured_content, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + ) + + return content_size + structured_content_size + + +def get_size_of_one_value(value: BaseModel | ToolResult | ReadResourceContents) -> int: + if isinstance(value, ToolResult): + return get_size_of_tool_result(value) + if isinstance(value, ReadResourceContents): + return len(value.content) + return len(value.model_dump_json()) + + +def get_size_of_value(value: CachableTypes) -> int: + if isinstance(value, BaseModel | ToolResult | ReadResourceContents): + return get_size_of_one_value(value) - return [json.dumps(value, sort_keys=True, separators=(",", ":"))] + return sum(get_size_of_one_value(item) for item in value) diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index 37bd333d57..4da0067bc8 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -21,6 +21,7 @@ from fastmcp.client.client import CallToolResult from fastmcp.client.transports import FastMCPTransport from fastmcp.prompts.prompt import FunctionPrompt +from fastmcp.resources.resource import Resource from fastmcp.server.middleware.caching import ( CachableTypes, CachedPrompt, @@ -58,6 +59,10 @@ content=[TextContent(type="text", text="test_text")], structured_content={"result": "test_result"}, ) +SAMPLE_TOOL_RESULT_LARGE = ToolResult( + content=[TextContent(type="text", text="test_text" * 100)], + structured_content={"result": "test_result"}, +) class CrazyModel(BaseModel): @@ -99,7 +104,7 @@ def dump_mcp_types( | ToolResult | Sequence[BaseModel] | Sequence[ToolResult] - | list[ReadResourceContents], + | Sequence[ReadResourceContents], ) -> list[dict[str, Any]]: if isinstance(model, Sequence): return [dump_mcp_type(model=m) for m in model] @@ -171,18 +176,26 @@ def add_prompts(self, fastmcp: FastMCP, prefix: str = ""): ) def add_resources(self, fastmcp: FastMCP, prefix: str = ""): - fastmcp.add_resource_fn( - fn=self.get_add_calls, uri="resource://add_calls", name=f"{prefix}add_calls" + fastmcp.add_resource( + resource=Resource.from_function( + fn=self.get_add_calls, + uri="resource://add_calls", + name=f"{prefix}add_calls", + ) ) - fastmcp.add_resource_fn( - fn=self.get_multiply_calls, - uri="resource://multiply_calls", - name=f"{prefix}multiply_calls", + fastmcp.add_resource( + resource=Resource.from_function( + fn=self.get_multiply_calls, + uri="resource://multiply_calls", + name=f"{prefix}multiply_calls", + ) ) - fastmcp.add_resource_fn( - fn=self.get_crazy_calls, - uri="resource://crazy_calls", - name=f"{prefix}crazy_calls", + fastmcp.add_resource( + resource=Resource.from_function( + fn=self.get_crazy_calls, + uri="resource://crazy_calls", + name=f"{prefix}crazy_calls", + ) ) @@ -357,7 +370,9 @@ async def test_set_and_get(self, cache: CacheProtocol, value: CachableTypes): value=value, ttl=3600, ) - result = await cache.get_value(collection="test_collection", key="test_key") + result: CachableTypes | None = await cache.get_value( + collection="test_collection", key="test_key" + ) assert result is not None @@ -373,7 +388,9 @@ async def test_set_get_delete_get_value(self, cache: CacheProtocol): value=SAMPLE_TOOL_RESULT, ttl=3600, ) - result = await cache.get_value(collection="test_collection", key="test_key") + result: CachableTypes | None = await cache.get_value( + collection="test_collection", key="test_key" + ) assert result is not None assert dump_mcp_types(model=result) == dump_mcp_types(model=SAMPLE_TOOL_RESULT) @@ -471,20 +488,63 @@ def test_tool_call_filtering( is result ) + async def test_large_value(self): + """Test that we can set and get a large value.""" + cache = InMemoryCache() + middleware = ResponseCachingMiddleware(cache, max_item_size=100) + + result = await middleware._store_in_cache_and_return( + context=MiddlewareContext( + method="tools/call", + message=mcp.types.CallToolRequestParams(name="test_tool"), + ), + key="test_key", + value=SAMPLE_TOOL_RESULT_LARGE, + ) + + assert middleware._stats.get_too_big("tools/call") == 1 + def test_cache_key_generation(self): """Test cache key generation.""" - cache = InMemoryCache() - middleware = ResponseCachingMiddleware(cache) + from fastmcp.server.middleware.caching import ( + _make_call_tool_cache_key, + _make_get_prompt_cache_key, + _make_read_resource_cache_key, + ) msg = mcp.types.CallToolRequestParams( name="test_tool", arguments={"param1": "value1", "param2": 42} ) - key = middleware._make_cache_key(msg) + key = _make_call_tool_cache_key(msg) # Should be a SHA256 hash assert len(key) == 64 - assert all(c in "0123456789abcdef" for c in key) + assert key == snapshot( + "7fa3d5c7967a202457eeca0731709fd87ec98546ddaee829ee86ca54b1858c59" + ) + + msg = mcp.types.ReadResourceRequestParams( + uri=AnyUrl("https://test_uri"), + ) + + key = _make_read_resource_cache_key(msg) + + assert len(key) == 64 + assert key == snapshot( + "e34cc47c03ed1ad54f02501d95ecc463b65646568961c97ca4b730cb274e9d42" + ) + + msg = mcp.types.GetPromptRequestParams( + name="test_prompt", arguments={"param1": "value1"} + ) + + key = _make_get_prompt_cache_key(msg) + + assert len(key) == 64 + assert key == snapshot( + "6306ff84fd3ff247a4bd91271e9d727d7f051bba53fb2e3bf80958988c4baf57" + ) async def test_cache_miss_and_hit( self, From d5f01dae157d5ddd06d51aed452a3f8fcfe85afd Mon Sep 17 00:00:00 2001 From: William Easton Date: Wed, 17 Sep 2025 19:46:15 -0500 Subject: [PATCH 06/15] Add docs --- docs/servers/middleware.mdx | 41 ++++++++++++++++++++++++ src/fastmcp/server/middleware/caching.py | 30 ++++++++++++++--- tests/server/middleware/test_caching.py | 2 +- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index 5ef8d8fe14..fd9f88e4cf 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -442,6 +442,47 @@ mcp.add_middleware(DetailedTimingMiddleware()) The built-in versions include custom logger support, proper formatting, and **DetailedTimingMiddleware** provides operation-specific hooks like `on_call_tool` and `on_read_resource` for granular timing. +### Caching Middleware + +Caching middleware is essential for improving performance and reducing server load. FastMCP provides caching middleware at `fastmcp.server.middleware.caching`. + +Here's how to use the full version: + +```python +from fastmcp.server.middleware.caching import ResponseCachingMiddleware + +mcp.add_middleware(ResponseCachingMiddleware()) +``` + +Out of the box, it caches call/list tool, resources, and prompts. Sending a notification of a tool/resource/prompt change will invalidate the cache for the affected method. + +Alternatively, it can be configured to only cache specific methods, for example, only caching list tools and only caching calls to `tool1`: + +```python +from fastmcp.server.middleware.caching import ResponseCachingMiddleware + +mcp.add_middleware(ResponseCachingMiddleware( + method_settings=MethodSettings( + call_tool=CallToolSettings( + included_tools=["tool1"], + ), + list_tools=ListToolsSettings( + ttl=30, + ) + ) +)) +``` + +It can also be configured to cache to disk: + +```python +from fastmcp.server.middleware.caching import ResponseCachingMiddleware, DiskCache + +mcp.add_middleware(ResponseCachingMiddleware( + cache_backend=DiskCache(path="cache"), +)) +``` + ### Logging Middleware Request and response logging is crucial for debugging, monitoring, and understanding usage patterns in your MCP server. FastMCP provides comprehensive logging middleware at `fastmcp.server.middleware.logging`. diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 7cb2853934..03626b7fc1 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -314,6 +314,18 @@ class SharedMethodSettings(TypedDict): ttl: NotRequired[int] +class ListToolsSettings(SharedMethodSettings): + pass + + +class ListResourcesSettings(SharedMethodSettings): + pass + + +class ListPromptsSettings(SharedMethodSettings): + pass + + class CallToolSettings(SharedMethodSettings): """Extra configuration options for Tool-related caching.""" @@ -321,17 +333,25 @@ class CallToolSettings(SharedMethodSettings): excluded_tools: NotRequired[list[str]] +class ReadResourceSettings(SharedMethodSettings): + pass + + +class GetPromptSettings(SharedMethodSettings): + pass + + class MethodSettings(TypedDict): """Config for the response caching middleware methods.""" - list_tools: NotRequired[SharedMethodSettings] + list_tools: NotRequired[ListToolsSettings] call_tool: NotRequired[CallToolSettings] - list_resources: NotRequired[SharedMethodSettings] - read_resource: NotRequired[SharedMethodSettings] + list_resources: NotRequired[ListResourcesSettings] + read_resource: NotRequired[ReadResourceSettings] - list_prompts: NotRequired[SharedMethodSettings] - get_prompt: NotRequired[SharedMethodSettings] + list_prompts: NotRequired[ListPromptsSettings] + get_prompt: NotRequired[GetPromptSettings] MethodSettingsType = TypeVar("MethodSettingsType", bound=SharedMethodSettings) diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index 4da0067bc8..346d9a0612 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -493,7 +493,7 @@ async def test_large_value(self): cache = InMemoryCache() middleware = ResponseCachingMiddleware(cache, max_item_size=100) - result = await middleware._store_in_cache_and_return( + await middleware._store_in_cache_and_return( context=MiddlewareContext( method="tools/call", message=mcp.types.CallToolRequestParams(name="test_tool"), From 00fbc97f9902e49aa842e34b44c6a7f7d7cc7fa7 Mon Sep 17 00:00:00 2001 From: William Easton Date: Thu, 18 Sep 2025 08:20:20 -0500 Subject: [PATCH 07/15] PR Clean-up --- src/fastmcp/server/middleware/caching.py | 127 +++++++++++++++++------ 1 file changed, 97 insertions(+), 30 deletions(-) diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 03626b7fc1..bfd6205bb2 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -1,3 +1,5 @@ +"""A middleware for response caching.""" + import hashlib import json from collections import defaultdict @@ -21,7 +23,7 @@ from diskcache import Cache as DiskCacheClient except ImportError: raise ImportError( - "fastmcp[caching] is required to use the caching middleware. Please install it with `pip install fastmcp[caching] or `uv add fastmcp[caching]`" + "fastmcp[caching] is required to use the caching middleware. Please install it with `pip install fastmcp[caching]` or `uv add fastmcp[caching]`" ) logger = get_logger(__name__) @@ -49,11 +51,12 @@ def make_collection_key(collection: str, key: str) -> str: + """For cache backends that dont support collections, we combine the collection name and key into a single string.""" return f"{collection}:{key}" class CachedPrompt(Prompt): - """A cached prompt.""" + """A no-op prompt that can be cached/pickled and provided during list calls.""" def render( self, arguments: dict[str, Any] | None = None @@ -64,7 +67,7 @@ def render( class CachedResource(Resource): - """A cached resource.""" + """A no-op resource that can be cached/pickled and provided during list calls.""" def read(self) -> str | bytes: raise NotImplementedError( @@ -73,14 +76,14 @@ def read(self) -> str | bytes: class CachedTool(Tool): - """A cached tool.""" + """A no-op tool that can be cached/pickled and provided during list calls.""" def run(self, arguments: dict[str, Any]) -> ToolResult: raise NotImplementedError("Run called on CachedTool, this should never happen") class CacheEntry(BaseModel, Generic[CachableTypeVar]): - """A cache entry.""" + """A cache entry helper that can be stored in a cache backend.""" model_config: ClassVar[ConfigDict] = ConfigDict( frozen=True, arbitrary_types_allowed=True @@ -129,7 +132,7 @@ async def get_value( collection: str, key: str, ) -> CachableTypes | None: - """Get a value from the cache.""" + """Get a value from the cache using the collection and key.""" if not (cache_entry := await self.get_entry(collection=collection, key=key)): return None @@ -140,7 +143,7 @@ async def set_entry( self, cache_entry: CacheEntry[CachableTypes], ) -> None: - """Set a value in the cache.""" + """Set a value in the cache using the collection and key.""" async def set_value( self, @@ -149,7 +152,7 @@ async def set_value( value: CachableTypes, ttl: int, ) -> None: - """Set a value in the cache.""" + """Set a value in the cache using the collection and key.""" await self.set_entry( cache_entry=CacheEntry.from_value( @@ -162,7 +165,7 @@ async def delete( collection: str, key: str, ) -> None: - """Delete a value from the cache.""" + """Delete a value from the cache using the collection and key.""" def make_collection_key(self, collection: str, key: str) -> str: return f"{collection}:{key}" @@ -222,10 +225,12 @@ async def delete(self, collection: str, key: str) -> None: def _memory_cache_ttu(_key: Any, value: CacheEntry[CachableTypes], now: float) -> float: + """TTU function for the memory cache. Determines the TTL of the cache entry.""" return now + value.ttl def _memory_cache_getsizeof(value: CacheEntry[CachableTypes]) -> int: + """Getsizeof function for the memory cache. Currently measures how many entries are in the cache.""" return 1 @@ -277,34 +282,46 @@ async def clear(self) -> None: class CacheMethodStats(BaseModel): """Stats for a cache method.""" - hits: int = Field(default=0) - misses: int = Field(default=0) - too_big: int = Field(default=0) + hits: int = Field(default=0, description="The number of hits for the cache method.") + misses: int = Field( + default=0, description="The number of misses for the cache method." + ) + too_big: int = Field( + default=0, + description="The number of items that exceeded the size limit for cache entries.", + ) class CacheStats(BaseModel): """Stats for the cache.""" collections: dict[str, CacheMethodStats] = Field( - default_factory=lambda: defaultdict[str, CacheMethodStats](CacheMethodStats) + default_factory=lambda: defaultdict(CacheMethodStats), + description="Stats are organized by collection (method).", ) def get_misses(self, collection: str) -> int: + """Get the number of misses for a collection.""" return self.collections[collection].misses def get_hits(self, collection: str) -> int: + """Get the number of hits for a collection.""" return self.collections[collection].hits def get_too_big(self, collection: str) -> int: + """Get the number of items that exceeded the size limit for a collection.""" return self.collections[collection].too_big def mark_miss(self, collection: str) -> None: + """Mark a miss for a collection.""" self.collections[collection].misses += 1 def mark_hit(self, collection: str) -> None: + """Mark a hit for a collection.""" self.collections[collection].hits += 1 def mark_too_big(self, collection: str) -> None: + """Mark a too big for a collection.""" self.collections[collection].too_big += 1 @@ -315,34 +332,34 @@ class SharedMethodSettings(TypedDict): class ListToolsSettings(SharedMethodSettings): - pass + """Configuration options for Tool-related caching.""" class ListResourcesSettings(SharedMethodSettings): - pass + """Configuration options for Resource-related caching.""" class ListPromptsSettings(SharedMethodSettings): - pass + """Configuration options for Prompt-related caching.""" class CallToolSettings(SharedMethodSettings): - """Extra configuration options for Tool-related caching.""" + """Configuration options for Tool-related caching.""" included_tools: NotRequired[list[str]] excluded_tools: NotRequired[list[str]] class ReadResourceSettings(SharedMethodSettings): - pass + """Configuration options for Resource-related caching.""" class GetPromptSettings(SharedMethodSettings): - pass + """Configuration options for Prompt-related caching.""" class MethodSettings(TypedDict): - """Config for the response caching middleware methods.""" + """Configuration options for mcp "methods" in the response caching middleware.""" list_tools: NotRequired[ListToolsSettings] call_tool: NotRequired[CallToolSettings] @@ -356,12 +373,6 @@ class MethodSettings(TypedDict): MethodSettingsType = TypeVar("MethodSettingsType", bound=SharedMethodSettings) -NOTIFICATION_TO_COLLECTION = { - mcp.types.ToolListChangedNotification: "tools/list", - mcp.types.ResourceListChangedNotification: "resources/list", - mcp.types.PromptListChangedNotification: "prompts/list", -} - MCP_METHOD_TO_METHOD_SETTINGS_KEY = { "tools/list": "list_tools", "tools/call": "call_tool", @@ -394,11 +405,16 @@ class MethodSettings(TypedDict): class ResponseCachingMiddleware(Middleware): - """Caches tool call responses based on method name and params. + """The response caching middleware offers a simple way to cache responses to mcp methods. The Middleware + supports cache invalidation via notifications from the server. The Middleware implements TTL-based caching + but cache implementations may offer additional features like LRU eviction, size limits, and more. + + When items are retrieved from the cache they will no longer be the original objects, but rather no-op objects + this means that response caching may not be compatible with other middleware that expects original subclasses. Notes: - - Only caches `tools/call` requests. - - Cache key derived from tool name and arguments. + - Caches `tools/call`, `resources/read`, `prompts/get`, `tools/list`, `resources/list`, and `prompts/list` requests. + - Cache keys are derived from method name and arguments. """ def __init__( @@ -467,6 +483,8 @@ async def on_list_resources( context: MiddlewareContext[mcp.types.ListResourcesRequest], call_next: CallNext[mcp.types.ListResourcesRequest, list[Resource]], ) -> list[Resource]: + """List resources from the cache, if caching is enabled, and the result is in the cache. Otherwise, + otherwise call the next middleware and store the result in the cache if caching is enabled.""" if self._should_bypass_caching(context=context): return await call_next(context) @@ -497,6 +515,8 @@ async def on_list_prompts( context: MiddlewareContext[mcp.types.ListPromptsRequest], call_next: CallNext[mcp.types.ListPromptsRequest, list[Prompt]], ) -> list[Prompt]: + """List prompts from the cache, if caching is enabled, and the result is in the cache. Otherwise, + otherwise call the next middleware and store the result in the cache if caching is enabled.""" if self._should_bypass_caching(context=context): return await call_next(context) @@ -531,6 +551,8 @@ async def on_call_tool( context: MiddlewareContext[mcp.types.CallToolRequestParams], call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult], ) -> Any: + """Call a tool from the cache, if caching is enabled, and the result is in the cache. Otherwise, + otherwise call the next middleware and store the result in the cache if caching is enabled.""" if self._should_bypass_caching(context=context): return await call_next(context=context) @@ -550,6 +572,8 @@ async def on_read_resource( mcp.types.ReadResourceRequestParams, list[ReadResourceContents] ], ) -> list[ReadResourceContents]: + """Read a resource from the cache, if caching is enabled, and the result is in the cache. Otherwise, + otherwise call the next middleware and store the result in the cache if caching is enabled.""" if self._should_bypass_caching(context=context): return await call_next(context=context) @@ -566,6 +590,8 @@ async def on_get_prompt( mcp.types.GetPromptRequestParams, mcp.types.GetPromptResult ], ) -> mcp.types.GetPromptResult: + """Get a prompt from the cache, if caching is enabled, and the result is in the cache. Otherwise, + otherwise call the next middleware and store the result in the cache if caching is enabled.""" if self._should_bypass_caching(context=context): return await call_next(context) @@ -580,7 +606,18 @@ async def on_notification( context: MiddlewareContext[mcp.types.Notification], call_next: CallNext[mcp.types.Notification, Any], ) -> Any: - if collection := NOTIFICATION_TO_COLLECTION.get(context.message): + """Handle a notification from the server. If the notification is a tool/resource/prompt list changed + notification, delete the cache for the affected method.""" + if isinstance(context.message, mcp.types.ToolListChangedNotification): + collection = "tools/list" + elif isinstance(context.message, mcp.types.ResourceListChangedNotification): + collection = "resources/list" + elif isinstance(context.message, mcp.types.PromptListChangedNotification): + collection = "prompts/list" + else: + collection = None + + if collection: await self._backend.delete(collection=collection, key=GLOBAL_KEY) return await call_next(context) @@ -591,6 +628,9 @@ async def _cached_call_next( call_next: CallNext[Any, CachableTypeVar], key: str | None = None, ) -> CachableTypeVar: + """Perform the cached lookup, if the result is not in the cache, call the next middleware and return + the result.""" + if key is None: key = GLOBAL_KEY @@ -615,6 +655,8 @@ async def _get_cache( call_next: CallNext[Any, CachableTypeVar], key: str | None = None, ) -> CachableTypeVar | None: + """Get a value from the cache and update the cache stats.""" + if key is None: key = GLOBAL_KEY @@ -638,6 +680,8 @@ async def _store_in_cache_and_return( key: str | None, value: CachableTypeVar, ) -> CachableTypeVar: + """Store a value in the cache (if it's not too big) with the appropriate TTL.""" + if key is None: key = GLOBAL_KEY @@ -664,6 +708,8 @@ async def _store_in_cache_and_return( def _matches_tool_cache_settings( self, context: MiddlewareContext[mcp.types.CallToolRequestParams] ) -> bool: + """Check if the tool matches the cache settings for tool calls.""" + tool_name = context.message.name tool_call_cache_settings: CallToolSettings | None = self._get_cache_settings( @@ -689,6 +735,8 @@ def _get_cache_settings( context: MiddlewareContext[Any], settings_type: type[MethodSettingsType] = SharedMethodSettings, ) -> MethodSettingsType | None: + """Get the cache settings for a method.""" + if not context.method: return None @@ -705,6 +753,8 @@ def _get_cache_settings( return cast(MethodSettingsType, self.method_settings[method_settings_key]) def _get_cache_ttl(self, context: MiddlewareContext[Any]) -> int: + """Get the cache TTL for a method.""" + settings: SharedMethodSettings | None = self._get_cache_settings( context=context ) @@ -715,6 +765,8 @@ def _get_cache_ttl(self, context: MiddlewareContext[Any]) -> int: return settings["ttl"] def _should_bypass_caching(self, context: MiddlewareContext[Any]) -> bool: + """Check if the method should bypass caching.""" + if not self._get_cache_settings(context=context): return True @@ -722,21 +774,29 @@ def _should_bypass_caching(self, context: MiddlewareContext[Any]) -> bool: def _make_call_tool_cache_key(msg: mcp.types.CallToolRequestParams) -> str: + """Make a cache key for a tool call by hashing the tool name and its arguments.""" + raw = f"{msg.name}:{_get_arguments_str(msg.arguments)}" return hashlib.sha256(raw.encode("utf-8")).hexdigest() def _make_read_resource_cache_key(msg: mcp.types.ReadResourceRequestParams) -> str: + """Make a cache key for a resource read by hashing the resource URI.""" + raw = f"{msg.uri}" return hashlib.sha256(raw.encode("utf-8")).hexdigest() def _make_get_prompt_cache_key(msg: mcp.types.GetPromptRequestParams) -> str: + """Make a cache key for a prompt get by hashing the prompt name and its arguments.""" + raw = f"{msg.name}:{_get_arguments_str(msg.arguments)}" return hashlib.sha256(raw.encode("utf-8")).hexdigest() def _get_arguments_str(arguments: dict[str, Any] | None) -> str: + """Get a string representation of the arguments.""" + if arguments is None: return "null" @@ -750,6 +810,8 @@ def _get_arguments_str(arguments: dict[str, Any] | None) -> str: def get_size_of_content_blocks( value: mcp.types.ContentBlock | Sequence[mcp.types.ContentBlock], ) -> int: + """Get the size of a series of content blocks by summing the size of the JSON representation of each block.""" + if isinstance(value, mcp.types.ContentBlock): value = [value] @@ -757,6 +819,8 @@ def get_size_of_content_blocks( def get_size_of_tool_result(value: ToolResult) -> int: + """Get the size of a tool result by summing the size of the content blocks and the size of the structured content.""" + content_size = get_size_of_content_blocks(value.content) structured_content_size = len( json.dumps( @@ -768,6 +832,8 @@ def get_size_of_tool_result(value: ToolResult) -> int: def get_size_of_one_value(value: BaseModel | ToolResult | ReadResourceContents) -> int: + """Get the size of an mcp type.""" + if isinstance(value, ToolResult): return get_size_of_tool_result(value) if isinstance(value, ReadResourceContents): @@ -776,6 +842,7 @@ def get_size_of_one_value(value: BaseModel | ToolResult | ReadResourceContents) def get_size_of_value(value: CachableTypes) -> int: + """Get the size of a cache entry.""" if isinstance(value, BaseModel | ToolResult | ReadResourceContents): return get_size_of_one_value(value) From a9113d0c9a83ce9639e865d5451ee6ed8e8ace30 Mon Sep 17 00:00:00 2001 From: William Easton Date: Thu, 18 Sep 2025 13:47:42 -0500 Subject: [PATCH 08/15] Refactor cache, add Elasticsearch cache backend as a contrib module with docs --- docs/servers/middleware.mdx | 4 +- pyproject.toml | 2 + src/fastmcp/contrib/middleware/__init__.py | 0 .../contrib/middleware/caching/__init__.py | 0 .../caching/elasticsearch/README.md | 155 +++++ .../caching/elasticsearch/__init__.py | 5 + .../elasticsearch/elasticsearch_cache.py | 166 ++++++ src/fastmcp/server/middleware/caching.py | 402 +++++++------ tests/server/middleware/test_caching.py | 410 +++++++++---- uv.lock | 555 +++++++++++++++++- 10 files changed, 1411 insertions(+), 288 deletions(-) create mode 100644 src/fastmcp/contrib/middleware/__init__.py create mode 100644 src/fastmcp/contrib/middleware/caching/__init__.py create mode 100644 src/fastmcp/contrib/middleware/caching/elasticsearch/README.md create mode 100644 src/fastmcp/contrib/middleware/caching/elasticsearch/__init__.py create mode 100644 src/fastmcp/contrib/middleware/caching/elasticsearch/elasticsearch_cache.py diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index fd9f88e4cf..5c06db0f8f 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -459,7 +459,7 @@ Out of the box, it caches call/list tool, resources, and prompts. Sending a noti Alternatively, it can be configured to only cache specific methods, for example, only caching list tools and only caching calls to `tool1`: ```python -from fastmcp.server.middleware.caching import ResponseCachingMiddleware +from fastmcp.server.middleware.caching import ResponseCachingMiddleware, MethodSettings, CallToolSettings, ListToolsSettings mcp.add_middleware(ResponseCachingMiddleware( method_settings=MethodSettings( @@ -483,6 +483,8 @@ mcp.add_middleware(ResponseCachingMiddleware( )) ``` +See the Contrib modules for caching middleware implementations that support additional features like distributed caching. + ### Logging Middleware Request and response logging is crucial for debugging, monitoring, and understanding usage patterns in your MCP server. FastMCP provides comprehensive logging middleware at `fastmcp.server.middleware.logging`. diff --git a/pyproject.toml b/pyproject.toml index c31b62e2af..845eeebb1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,12 +44,14 @@ classifiers = [ websockets = ["websockets>=15.0.1"] openai = ["openai>=1.102.0"] caching = ["diskcache>=5.6.3", "cachetools>=6.2.0"] +contrib-middleware-elasticsearch_cache = ["fastmcp[caching]", "elasticsearch>=8.15.0", "aiohttp>=3.11.10"] [dependency-groups] dev = [ "dirty-equals>=0.9.0", "fastmcp[openai]", "fastmcp[caching]", + "fastmcp[contrib-middleware-elasticsearch_cache]", # add optional dependencies for fastmcp dev "fastapi>=0.115.12", "inline-snapshot[dirty-equals]>=0.27.2", diff --git a/src/fastmcp/contrib/middleware/__init__.py b/src/fastmcp/contrib/middleware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fastmcp/contrib/middleware/caching/__init__.py b/src/fastmcp/contrib/middleware/caching/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/fastmcp/contrib/middleware/caching/elasticsearch/README.md b/src/fastmcp/contrib/middleware/caching/elasticsearch/README.md new file mode 100644 index 0000000000..badf234936 --- /dev/null +++ b/src/fastmcp/contrib/middleware/caching/elasticsearch/README.md @@ -0,0 +1,155 @@ +# Elasticsearch Cache Backend – Contrib Module for FastMCP + +This backend plugs into `ResponseCachingMiddleware` and stores cache entries in Elasticsearch. + +- **Package**: `fastmcp.contrib.middleware.caching.elasticsearch` +- **Class**: `ElasticsearchCache` +- **Works with**: `fastmcp.server.middleware.caching.ResponseCachingMiddleware` + +--- + +## 📦 Installation + +Install Elasticsearch async client and FastMCP caching extras: + +```bash +uv add elasticsearch fastmcp[contrib-middleware-elasticsearch_cache] +# or +pip install elasticsearch fastmcp[contrib-middleware-elasticsearch_cache] +``` + +--- + +## 🚀 Quick Start + +```python +from elasticsearch import AsyncElasticsearch +from fastmcp import FastMCP +from fastmcp.server.middleware.caching import ResponseCachingMiddleware +from fastmcp.contrib.middleware.caching.elasticsearch import ElasticsearchCache + +mcp = FastMCP(name="Cache Demo") + +es = AsyncElasticsearch(hosts=["http://localhost:9200"]) # configure as needed +cache = ElasticsearchCache(elasticsearch_client=es, index="fastmcp-response-cache") + +mcp.add_middleware(ResponseCachingMiddleware(cache_backend=cache)) + +# ... define tools/resources/prompts ... + +# When using FastMCP.run(), ensure the event loop closes the ES client when done +# await es.close() in your shutdown path if you manage the loop yourself. +``` + +This enables caching for default MCP methods per `ResponseCachingMiddleware`: +- **tools**: `tools/call`, `tools/list` +- **resources**: `resources/read`, `resources/list` +- **prompts**: `prompts/get`, `prompts/list` + +--- + +## ⚙️ Configuration + +Constructor: + +```python +ElasticsearchCache( + elasticsearch_client: AsyncElasticsearch, + index: str | None = None, + mapping: dict[str, object] | None = None, +) +``` + +- **elasticsearch_client**: A live `AsyncElasticsearch` instance +- **index**: Target index name. Default: `"fastmcp-response-cache"` +- **mapping**: Custom index mapping. Default mapping is optimized for cache fields: + +```json +{ + "properties": { + "created_at": {"type": "date"}, + "expires_at": {"type": "date"}, + "ttl": {"type": "integer"}, + "collection": {"type": "keyword"}, + "key": {"type": "keyword"}, + "value": {"type": "keyword", "index": false} + } +} +``` + +Notes: +- Values are stored as JSON strings under `value` (non-indexed) for size and simplicity. +- Each cache entry is keyed by `f"{collection}:{key}"` and saved as the document `_id`. + +--- + +## 🔧 Lifecycle and Maintenance + +The backend lazily creates the index on first use via `setup()`; calls are internally guarded by an async lock. + +Available maintenance helpers: + +```python +# Drop the entire cache index +await cache.clear() + +# Delete only expired entries +await cache.cull() +``` + +- `clear()` deletes the index. +- `cull()` issues a `delete_by_query` filtering `expires_at < now`. + +--- + +## ✅ Compatibility + +- Works with the core cache entry types defined by `ResponseCachingMiddleware`: + - `tools/call` (tool results) + - `resources/read` (resource contents) + - `prompts/get` (prompt result) + - List endpoints for tools, resources, prompts +- Uses Pydantic discriminators to serialize/validate cache entries round-trip. + +--- + +## 🛡️ Production Notes + +- Ensure your ES cluster has sufficient storage. +- Consider a dedicated index per environment or service instance via the `index` parameter. +- Close the `AsyncElasticsearch` client on shutdown to avoid warnings and resource leaks. + +--- + +## 🧪 Manual Cache Lifecycle + +```python +import asyncio +from elasticsearch import AsyncElasticsearch +from fastmcp.server.middleware.caching import ResponseCachingMiddleware +from fastmcp.contrib.middleware.caching.elasticsearch import ElasticsearchCache + +async def main(): + es = AsyncElasticsearch(hosts=["http://localhost:9200"]) + cache = ElasticsearchCache(es) + await cache.setup() # optional; auto-runs on first access + + # Simulate storing an entry through middleware flow + # In practice, the middleware calls set_entry() for you. + + await cache.cull() + await es.close() + +asyncio.run(main()) +``` + +--- + +## 📚 Imports + +```python +from fastmcp.contrib.middleware.caching.elasticsearch import ElasticsearchCache +from fastmcp.server.middleware.caching import ResponseCachingMiddleware +``` + +This module is part of `fastmcp.contrib`. See `docs/servers/middleware.mdx` and `docs/servers/tools.mdx` for response caching details. diff --git a/src/fastmcp/contrib/middleware/caching/elasticsearch/__init__.py b/src/fastmcp/contrib/middleware/caching/elasticsearch/__init__.py new file mode 100644 index 0000000000..1538fd1d6e --- /dev/null +++ b/src/fastmcp/contrib/middleware/caching/elasticsearch/__init__.py @@ -0,0 +1,5 @@ +from .elasticsearch_cache import ElasticsearchCache + +__all__ = [ + "ElasticsearchCache", +] \ No newline at end of file diff --git a/src/fastmcp/contrib/middleware/caching/elasticsearch/elasticsearch_cache.py b/src/fastmcp/contrib/middleware/caching/elasticsearch/elasticsearch_cache.py new file mode 100644 index 0000000000..4267acaf2b --- /dev/null +++ b/src/fastmcp/contrib/middleware/caching/elasticsearch/elasticsearch_cache.py @@ -0,0 +1,166 @@ +import asyncio +import json +from datetime import datetime, timezone +from typing import Annotated, Any + +from elasticsearch import AsyncElasticsearch +from pydantic import Field, TypeAdapter + +from fastmcp.server.middleware.caching import ( + CacheEntryTypes, + CacheProtocol, +) + +DEFAULT_MAPPING = { + "properties": { + "created_at": { + "type": "date", + }, + "expires_at": { + "type": "date", + }, + "ttl": { + "type": "integer", + }, + "collection": { + "type": "keyword", + }, + "key": { + "type": "keyword", + }, + "value": { + "type": "keyword", + "index": False, + }, + }, +} + + +class SetEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, set): + return list(obj) + return json.JSONEncoder.default(self, obj) + + +class ElasticsearchCache(CacheProtocol): + """A cache client that uses Elasticsearch.""" + + setup_called: bool + setup_lock: asyncio.Lock + cached_entry_typeadapter: TypeAdapter[CacheEntryTypes] + + def __init__( + self, + elasticsearch_client: AsyncElasticsearch, + index: str | None = None, + mapping: dict[str, Any] | None = None, + ): + """Initialize the Elasticsearch cache. + + Args: + elasticsearch_client: The Elasticsearch client to use. + index: The index to use for the cache. Defaults to "fastmcp-response-cache". + mapping: The mapping to use for the cache. Defaults to the default mapping. + """ + self.elasticsearch_client = elasticsearch_client + self.index = index or "fastmcp-response-cache" + self.mapping = mapping or DEFAULT_MAPPING + self.setup_called = False + self.setup_lock = asyncio.Lock() + self.cached_entry_typeadapter = TypeAdapter( + Annotated[CacheEntryTypes, Field(discriminator="collection")], + ) + + async def get_entry(self, collection: str, key: str) -> CacheEntryTypes | None: + if not self.setup_called: + await self.setup() + + collection_key = self.make_collection_key(collection=collection, key=key) + + elasticsearch_response = await self.elasticsearch_client.options( + ignore_status=404 + ).get(index=self.index, id=collection_key) + + if ( + elasticsearch_response.body is None + or elasticsearch_response.body.get("error") + or not elasticsearch_response.body.get("found") + ): + return None + + source = elasticsearch_response.body.get("_source") + + source["value"] = json.loads(source["value"]) + + cache_entry = self.cached_entry_typeadapter.validate_python(source) + if cache_entry.is_expired(): + await self.delete(collection=collection, key=key) + return None + + return cache_entry + + async def set_entry( + self, + cache_entry: CacheEntryTypes, + ) -> None: + if not self.setup_called: + await self.setup() + + collection_key = self.make_collection_key( + collection=cache_entry.collection, key=cache_entry.key + ) + + document = json.loads(cache_entry.model_dump_json()) + + document["value"] = json.dumps(document["value"]) + + await self.elasticsearch_client.index( + index=self.index, + id=collection_key, + body=document, + ) + + async def delete(self, collection: str, key: str) -> None: + collection_key = self.make_collection_key(collection=collection, key=key) + + await self.elasticsearch_client.options(ignore_status=404).delete( + index=self.index, id=collection_key + ) + + async def setup(self) -> None: + if self.setup_called: + return + + async with self.setup_lock: + if self.setup_called: + return + + if await self.elasticsearch_client.options( + ignore_status=404 + ).indices.exists(index=self.index): + return + + await self.elasticsearch_client.options(ignore_status=404).indices.create( + index=self.index, + mappings=self.mapping, + ) + + self.setup_called = True + + async def clear(self) -> None: + await self.elasticsearch_client.options(ignore_status=404).indices.delete( + index=self.index, + ) + + async def cull(self) -> None: + await self.elasticsearch_client.options(ignore_status=404).delete_by_query( + index=self.index, + body={ + "query": { + "range": { + "expires_at": {"lt": datetime.now(tz=timezone.utc).timestamp()}, + }, + }, + }, + ) diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index bfd6205bb2..d4a223a587 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -2,14 +2,18 @@ import hashlib import json +from abc import ABC from collections import defaultdict from collections.abc import Sequence from datetime import datetime, timedelta, timezone -from typing import Any, ClassVar, Generic, Protocol, TypedDict, TypeVar, cast +from typing import Any, ClassVar, Literal, Protocol, TypedDict, TypeVar, cast import mcp.types from mcp.server.lowlevel.helper_types import ReadResourceContents -from pydantic import BaseModel, ConfigDict, Field +from mcp.types import GetPromptResult, PromptMessage +from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator +from pydantic.fields import computed_field +from pydantic.type_adapter import TypeAdapter from typing_extensions import NotRequired, Self, overload, runtime_checkable from fastmcp.prompts.prompt import Prompt @@ -17,6 +21,7 @@ from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.tools.tool import Tool, ToolResult from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import get_cached_typeadapter try: from cachetools import TLRUCache as MemoryCacheClient @@ -37,17 +42,16 @@ GLOBAL_KEY = "__global__" - -CachableTypes = ( +CachableValueTypes = ( ToolResult - | Sequence[Tool] - | Sequence[Resource] - | Sequence[Prompt] - | Sequence[ReadResourceContents] - | mcp.types.GetPromptResult + | list[Tool] + | list[Resource] + | list[Prompt] + | list[ReadResourceContents] + | GetPromptResult ) -CachableTypeVar = TypeVar("CachableTypeVar", bound=CachableTypes) +CachableValueTypesVar = TypeVar("CachableValueTypesVar", bound=CachableValueTypes) def make_collection_key(collection: str, key: str) -> str: @@ -55,67 +59,204 @@ def make_collection_key(collection: str, key: str) -> str: return f"{collection}:{key}" -class CachedPrompt(Prompt): - """A no-op prompt that can be cached/pickled and provided during list calls.""" +class BaseCacheEntry(BaseModel, ABC): + model_config: ClassVar[ConfigDict] = ConfigDict( + frozen=True, arbitrary_types_allowed=True + ) + + created_at: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc)) + value: CachableValueTypes - def render( - self, arguments: dict[str, Any] | None = None - ) -> list[mcp.types.PromptMessage]: - raise NotImplementedError( - "Render called on CachedPrompt, this should never happen" + collection: str + key: str + + ttl: int + + def is_expired(self) -> bool: + return datetime.now(tz=timezone.utc) > self.expires_at + + @computed_field + @property + def expires_at(self) -> datetime: + return self.created_at + timedelta(seconds=self.ttl) + + +class ToolResultCacheEntry(BaseCacheEntry): + collection: Literal["tools/call"] = Field(default="tools/call") + value: ToolResult + + @field_validator("value", mode="before") + @classmethod + def validate_value(cls, value: dict[str, Any] | ToolResult) -> ToolResult: + if isinstance(value, ToolResult): + return value + + content_block_type_adapter: TypeAdapter[list[mcp.types.ContentBlock]] = ( + get_cached_typeadapter(list[mcp.types.ContentBlock]) ) + content = content_block_type_adapter.validate_python(value.get("content")) -class CachedResource(Resource): - """A no-op resource that can be cached/pickled and provided during list calls.""" + structured_content = value.get("structured_content") - def read(self) -> str | bytes: - raise NotImplementedError( - "Read called on CachedResource, this should never happen" + return ToolResult( + content=content, + structured_content=structured_content, ) + @field_serializer("value", when_used="always") + def serialize_value(self, value: ToolResult) -> dict[str, Any]: + return { + "content": [item.model_dump() for item in value.content], + "structured_content": value.structured_content, + } -class CachedTool(Tool): - """A no-op tool that can be cached/pickled and provided during list calls.""" - def run(self, arguments: dict[str, Any]) -> ToolResult: - raise NotImplementedError("Run called on CachedTool, this should never happen") +class ListToolsCacheEntry(BaseCacheEntry): + collection: Literal["tools/list"] = Field(default="tools/list") + value: list[Tool] -class CacheEntry(BaseModel, Generic[CachableTypeVar]): - """A cache entry helper that can be stored in a cache backend.""" +class CachablePrompt(Prompt): + async def render( + self, + arguments: dict[str, Any] | None = None, + ) -> list[PromptMessage]: + """Render the prompt with arguments.""" + raise NotImplementedError( + "Prompt.render() is not implemented on cached prompts" + ) - model_config: ClassVar[ConfigDict] = ConfigDict( - frozen=True, arbitrary_types_allowed=True - ) + @classmethod + def from_prompt(cls, prompt: Prompt) -> Self: + return cls( + name=prompt.name, + title=prompt.title, + description=prompt.description, + arguments=prompt.arguments, + meta=prompt.meta, + tags=prompt.tags, + enabled=prompt.enabled, + ) - key: str - collection: str - value: CachableTypeVar - created_at: datetime +class ListPromptsCacheEntry(BaseCacheEntry): + collection: Literal["prompts/list"] = Field(default="prompts/list") + value: Sequence[Prompt] - ttl: int + @field_validator("value", mode="before") + @classmethod + def validate_value( + cls, value: Sequence[dict[str, Any]] | Sequence[Prompt] + ) -> Sequence[CachablePrompt]: + results = [] - expires_at: datetime + for item in value: + if isinstance(item, Prompt): + results.append(CachablePrompt.from_prompt(prompt=item)) + else: + results.append(CachablePrompt.model_validate(item)) - def is_expired(self) -> bool: - return datetime.now(tz=timezone.utc) > self.expires_at + return results + + +class GetPromptCacheEntry(BaseCacheEntry): + collection: Literal["prompts/get"] = Field(default="prompts/get") + value: mcp.types.GetPromptResult + + +class CachableResource(Resource): + async def read(self) -> str | bytes: + """Read the resource content.""" + raise NotImplementedError( + "Resource.read() is not implemented on cached resources" + ) @classmethod - def from_value( - cls, collection: str, key: str, value: CachableTypeVar, ttl: int - ) -> Self: + def from_resource(cls, resource: Resource) -> Self: return cls( - collection=collection, - key=key, - value=value, - created_at=datetime.now(tz=timezone.utc), - ttl=ttl, - expires_at=datetime.now(tz=timezone.utc) + timedelta(seconds=ttl), + name=resource.name, + description=resource.description, + uri=resource.uri, + mime_type=resource.mime_type, + annotations=resource.annotations, + meta=resource.meta, + tags=resource.tags, + enabled=resource.enabled, ) +class ListResourcesCacheEntry(BaseCacheEntry): + collection: Literal["resources/list"] = Field(default="resources/list") + value: Sequence[Resource] + + @field_validator("value", mode="before") + @classmethod + def validate_value( + cls, value: Sequence[dict[str, Any]] | Sequence[Resource] + ) -> Sequence[Resource]: + results = [] + for item in value: + if isinstance(item, Resource): + results.append(CachableResource.from_resource(resource=item)) + else: + results.append(CachableResource.model_validate(item)) + + return results + + +class ReadResourceCacheEntry(BaseCacheEntry): + collection: Literal["resources/read"] = Field(default="resources/read") + value: Sequence[ReadResourceContents] + + @field_validator("value", mode="before") + @classmethod + def validate_value( + cls, value: Sequence[dict[str, Any]] | Sequence[ReadResourceContents] + ) -> Sequence[ReadResourceContents]: + resource_contents: list[ReadResourceContents] = [] + for item in value: + if isinstance(item, ReadResourceContents): + resource_contents.append(item) + continue + + if not isinstance(item, dict): + continue + + if not (content := item.get("content")): + continue + + mime_type = item.get("mime_type") + + resource_contents.append( + ReadResourceContents(content=content, mime_type=mime_type) + ) + + return resource_contents + + @field_serializer("value") + def serialize_value( + self, value: Sequence[ReadResourceContents] + ) -> list[dict[str, Any]]: + return [ + { + "content": item.content, + "mime_type": item.mime_type, + } + for item in value + ] + + +CacheEntryTypes = ( + GetPromptCacheEntry + | ListPromptsCacheEntry + | ReadResourceCacheEntry + | ListResourcesCacheEntry + | ToolResultCacheEntry + | ListToolsCacheEntry +) + + @runtime_checkable class CacheProtocol(Protocol): """A protocol for a cache client.""" @@ -124,42 +265,15 @@ async def get_entry( self, collection: str, key: str, - ) -> CacheEntry[CachableTypes] | None: + ) -> BaseCacheEntry | None: """Get a cache entry from the cache.""" - async def get_value( - self, - collection: str, - key: str, - ) -> CachableTypes | None: - """Get a value from the cache using the collection and key.""" - - if not (cache_entry := await self.get_entry(collection=collection, key=key)): - return None - - return cache_entry.value - async def set_entry( self, - cache_entry: CacheEntry[CachableTypes], + cache_entry: BaseCacheEntry, ) -> None: """Set a value in the cache using the collection and key.""" - async def set_value( - self, - collection: str, - key: str, - value: CachableTypes, - ttl: int, - ) -> None: - """Set a value in the cache using the collection and key.""" - - await self.set_entry( - cache_entry=CacheEntry.from_value( - collection=collection, key=key, value=value, ttl=ttl - ) - ) - async def delete( self, collection: str, @@ -193,10 +307,8 @@ def __init__( directory=path, size_limit=size_limit ) - async def get_entry( - self, collection: str, key: str - ) -> CacheEntry[CachableTypes] | None: - collection_key = self.make_collection_key(collection=collection, key=key) + async def get_entry(self, collection: str, key: str) -> BaseCacheEntry | None: + collection_key: str = self.make_collection_key(collection=collection, key=key) cache_entry = self._cache.get(key=collection_key) @@ -207,9 +319,9 @@ async def get_entry( async def set_entry( self, - cache_entry: CacheEntry[CachableTypes], + cache_entry: BaseCacheEntry, ) -> None: - collection_key = self.make_collection_key( + collection_key: str = self.make_collection_key( collection=cache_entry.collection, key=cache_entry.key ) @@ -224,12 +336,12 @@ async def delete(self, collection: str, key: str) -> None: DEFAULT_MEMORY_CACHE_MAX_ENTRIES = 1000 -def _memory_cache_ttu(_key: Any, value: CacheEntry[CachableTypes], now: float) -> float: +def _memory_cache_ttu(_key: Any, value: BaseCacheEntry, now: float) -> float: """TTU function for the memory cache. Determines the TTL of the cache entry.""" return now + value.ttl -def _memory_cache_getsizeof(value: CacheEntry[CachableTypes]) -> int: +def _memory_cache_getsizeof(value: BaseCacheEntry) -> int: """Getsizeof function for the memory cache. Currently measures how many entries are in the cache.""" return 1 @@ -250,18 +362,16 @@ def __init__(self, max_entries: int = DEFAULT_MEMORY_CACHE_MAX_ENTRIES): getsizeof=_memory_cache_getsizeof, ) - async def get_entry( - self, collection: str, key: str - ) -> CacheEntry[CachableTypes] | None: + async def get_entry(self, collection: str, key: str) -> BaseCacheEntry | None: collection_key = self.make_collection_key(collection=collection, key=key) return self._cache.get(collection_key) async def set_entry( self, - cache_entry: CacheEntry[CachableTypes], + cache_entry: BaseCacheEntry, ) -> None: - collection_key = self.make_collection_key( + collection_key: str = self.make_collection_key( collection=cache_entry.collection, key=cache_entry.key ) @@ -373,6 +483,16 @@ class MethodSettings(TypedDict): MethodSettingsType = TypeVar("MethodSettingsType", bound=SharedMethodSettings) +MCP_METHOD_TO_CACHE_ENTRY_TYPE: dict[str, type[BaseCacheEntry]] = { + "tools/list": ListToolsCacheEntry, + "tools/call": ToolResultCacheEntry, + "resources/list": ListResourcesCacheEntry, + "resources/read": ReadResourceCacheEntry, + "prompts/list": ListPromptsCacheEntry, + "prompts/get": GetPromptCacheEntry, +} + + MCP_METHOD_TO_METHOD_SETTINGS_KEY = { "tools/list": "list_tools", "tools/call": "call_tool", @@ -450,33 +570,7 @@ async def on_list_tools( if self._should_bypass_caching(context=context): return await call_next(context=context) - if cached_value := await self._get_cache( - context=context, call_next=call_next, key=None - ): - return cached_value - - result: list[Tool] = await call_next(context) - - # Convert tool subclasses to Tool objects - result = [ - CachedTool( - name=tool.name, - title=tool.title, - description=tool.description, - parameters=tool.parameters, - output_schema=tool.output_schema, - annotations=tool.annotations, - meta=tool.meta, - tags=tool.tags, - ) - for tool in result - ] - - return await self._store_in_cache_and_return( - context=context, - key=None, - value=result, - ) + return await self._cached_call_next(context=context, call_next=call_next) async def on_list_resources( self, @@ -488,27 +582,7 @@ async def on_list_resources( if self._should_bypass_caching(context=context): return await call_next(context) - if cached_value := await self._get_cache( - context=context, - call_next=call_next, - key=None, - ): - return cached_value - - result: list[Resource] = await call_next(context) - - result = [ - CachedResource( - **resource.model_dump(exclude={"fn"}), - ) - for resource in result - ] - - return await self._store_in_cache_and_return( - context=context, - key=None, - value=result, - ) + return await self._cached_call_next(context=context, call_next=call_next) async def on_list_prompts( self, @@ -520,31 +594,7 @@ async def on_list_prompts( if self._should_bypass_caching(context=context): return await call_next(context) - if cached_value := await self._get_cache( - context=context, - call_next=call_next, - key=None, - ): - return cached_value - - result: list[Prompt] = await call_next(context) - - result = [ - CachedPrompt( - name=prompt.name, - title=prompt.title, - description=prompt.description, - arguments=prompt.arguments, - meta=prompt.meta, - ) - for prompt in result - ] - - return await self._store_in_cache_and_return( - context=context, - key=None, - value=result, - ) + return await self._cached_call_next(context=context, call_next=call_next) async def on_call_tool( self, @@ -625,9 +675,9 @@ async def on_notification( async def _cached_call_next( self, context: MiddlewareContext[Any], - call_next: CallNext[Any, CachableTypeVar], + call_next: CallNext[Any, CachableValueTypesVar], key: str | None = None, - ) -> CachableTypeVar: + ) -> CachableValueTypesVar: """Perform the cached lookup, if the result is not in the cache, call the next middleware and return the result.""" @@ -641,7 +691,7 @@ async def _cached_call_next( ): return cached_value - result: CachableTypeVar = await call_next(context) + result: CachableValueTypesVar = await call_next(context) return await self._store_in_cache_and_return( context=context, @@ -652,9 +702,9 @@ async def _cached_call_next( async def _get_cache( self, context: MiddlewareContext[Any], - call_next: CallNext[Any, CachableTypeVar], + call_next: CallNext[Any, CachableValueTypesVar], key: str | None = None, - ) -> CachableTypeVar | None: + ) -> CachableValueTypesVar | None: """Get a value from the cache and update the cache stats.""" if key is None: @@ -664,11 +714,11 @@ async def _get_cache( logger.warning("No method found on context, skipping cache") return None - if cached_value := await self._backend.get_value( + if cached_entry := await self._backend.get_entry( collection=collection, key=key ): self._stats.mark_hit(collection=collection) - return cast(CachableTypeVar, cached_value) + return cast(CachableValueTypesVar, cached_entry.value) self._stats.mark_miss(collection=collection) @@ -678,8 +728,8 @@ async def _store_in_cache_and_return( self, context: MiddlewareContext[Any], key: str | None, - value: CachableTypeVar, - ) -> CachableTypeVar: + value: CachableValueTypesVar, + ) -> CachableValueTypesVar: """Store a value in the cache (if it's not too big) with the appropriate TTL.""" if key is None: @@ -696,13 +746,17 @@ async def _store_in_cache_and_return( ttl: int = self._get_cache_ttl(context=context) - await self._backend.set_value( + cache_entry: BaseCacheEntry = MCP_METHOD_TO_CACHE_ENTRY_TYPE[collection]( collection=collection, key=key, value=value, ttl=ttl, ) + await self._backend.set_entry( + cache_entry=cache_entry, + ) + return value def _matches_tool_cache_settings( @@ -841,7 +895,7 @@ def get_size_of_one_value(value: BaseModel | ToolResult | ReadResourceContents) return len(value.model_dump_json()) -def get_size_of_value(value: CachableTypes) -> int: +def get_size_of_value(value: CachableValueTypes) -> int: """Get the size of a cache entry.""" if isinstance(value, BaseModel | ToolResult | ReadResourceContents): return get_size_of_one_value(value) diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index 346d9a0612..52cb2b96d2 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -2,7 +2,6 @@ import tempfile from collections.abc import Sequence -from datetime import datetime, timedelta, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -10,43 +9,60 @@ import pytest from inline_snapshot import snapshot from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.types import ( - TextContent, - TextResourceContents, -) +from mcp.types import GetPromptResult, PromptMessage, TextContent, TextResourceContents from pydantic import AnyUrl, BaseModel from fastmcp import FastMCP -from fastmcp.client import Client -from fastmcp.client.client import CallToolResult +from fastmcp.client.client import CallToolResult, Client from fastmcp.client.transports import FastMCPTransport -from fastmcp.prompts.prompt import FunctionPrompt +from fastmcp.prompts.prompt import FunctionPrompt, Prompt from fastmcp.resources.resource import Resource from fastmcp.server.middleware.caching import ( - CachableTypes, - CachedPrompt, - CachedResource, - CacheEntry, + BaseCacheEntry, + CachablePrompt, + CachableResource, CacheMethodStats, CacheProtocol, CacheStats, CallToolSettings, DiskCache, + GetPromptCacheEntry, InMemoryCache, + ListPromptsCacheEntry, + ListResourcesCacheEntry, + ListToolsCacheEntry, MethodSettings, + ReadResourceCacheEntry, ResponseCachingMiddleware, + ToolResultCacheEntry, ) from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext from fastmcp.tools.tool import Tool, ToolResult TEST_URI = AnyUrl("https://test_uri") -SAMPLE_RESOURCE = CachedResource(name="resource", uri=TEST_URI, mime_type="text/plain") -SAMPLE_PROMPT = CachedPrompt(name="prompt") SAMPLE_READ_RESOURCE_CONTENTS = ReadResourceContents( content="test_text", mime_type="text/plain", ) + + +def sample_resource_fn() -> list[ReadResourceContents]: + return [SAMPLE_READ_RESOURCE_CONTENTS] + + +SAMPLE_PROMPT_CONTENTS = TextContent(type="text", text="test_text") + + +def sample_prompt_fn() -> PromptMessage: + return PromptMessage(role="user", content=SAMPLE_PROMPT_CONTENTS) + + +SAMPLE_RESOURCE = Resource.from_function( + fn=sample_resource_fn, uri=TEST_URI, name="test_resource" +) + +SAMPLE_PROMPT = Prompt.from_function(fn=sample_prompt_fn, name="test_prompt") SAMPLE_GET_PROMPT_RESULT = mcp.types.GetPromptResult( messages=[ mcp.types.PromptMessage( @@ -240,20 +256,12 @@ class TestCacheEntry: def test_init_and_expiration(self): """Test cache entry initialization and expiration logic.""" - now = datetime.now(tz=timezone.utc) - future = now + timedelta(seconds=3600) - past = now - timedelta(seconds=3600) - - # Test valid entry - entry: CacheEntry[ToolResult] = CacheEntry( - collection="test_collection", + entry: ToolResultCacheEntry[ToolResult] = ToolResultCacheEntry( key="test_key", value=ToolResult( content=[{"type": "text", "text": "success"}], structured_content={"result": "success"}, ), - created_at=now, - expires_at=future, ttl=3600, ) @@ -261,41 +269,114 @@ def test_init_and_expiration(self): assert not entry.is_expired() # Test expired entry - expired_entry: CacheEntry[ToolResult] = CacheEntry( - collection="test_collection", - key="expired_key", + expired_entry: ToolResultCacheEntry[ToolResult] = ToolResultCacheEntry( + key="test_key", value=ToolResult( content=[{"type": "text", "text": "success"}], structured_content={"result": "success"}, ), - created_at=past, - expires_at=past, - ttl=3600, + ttl=-1, ) assert expired_entry.is_expired() - def test_serialization(self): - """Test cache entry serialization to/from tool result.""" - tool_result = ToolResult( - content=[{"type": "text", "text": "success"}], - structured_content={"result": "success"}, + def test_tool_result_cache_entry_validation(self): + """Test ToolResultCacheEntry class functionality.""" + entry: ToolResultCacheEntry[ToolResult] = ToolResultCacheEntry( + key="test_key", + value=SAMPLE_TOOL_RESULT, + ttl=3600, + ) + + dumped_entry = entry.model_dump() + new_entry = ToolResultCacheEntry.model_validate(dumped_entry) + + assert dump_mcp_types(model=new_entry.value.content) == snapshot( + [{"type": "text", "text": "test_text", "annotations": None, "meta": None}] + ) + assert new_entry.value.structured_content == snapshot({"result": "test_result"}) + + def test_read_resource_cache_entry_validation(self): + """Test ReadResourceCacheEntry class functionality.""" + entry: ReadResourceCacheEntry[list[ReadResourceContents]] = ( + ReadResourceCacheEntry( + key="test_key", + value=[SAMPLE_READ_RESOURCE_CONTENTS], + ttl=3600, + ) ) - # Test round-trip conversion - entry: CacheEntry[ToolResult] = CacheEntry.from_value( - collection="test_collection", + dumped_entry = entry.model_dump() + new_entry = ReadResourceCacheEntry.model_validate(dumped_entry) + + assert new_entry.value == snapshot( + [ReadResourceContents(content="test_text", mime_type="text/plain")] + ) + + def test_get_prompt_cache_entry_validation(self): + """Test GetPromptCacheEntry class functionality.""" + entry: GetPromptCacheEntry = GetPromptCacheEntry( key="test_key", - value=tool_result, + value=SAMPLE_GET_PROMPT_RESULT, ttl=3600, ) - retrieved_tool_result: ToolResult = entry.value + dumped_entry = entry.model_dump() + new_entry = GetPromptCacheEntry.model_validate(dumped_entry) - assert retrieved_tool_result.content == tool_result.content + assert new_entry.value == snapshot( + GetPromptResult( + messages=[ + PromptMessage( + role="user", content=TextContent(type="text", text="test_text") + ) + ] + ) + ) - assert ( - retrieved_tool_result.structured_content == tool_result.structured_content + def test_list_tools_cache_entry_validation(self): + """Test ListToolsCacheEntry class functionality.""" + entry: ListToolsCacheEntry = ListToolsCacheEntry( + key="test_key", + value=[SAMPLE_TOOL], + ttl=3600, + ) + + dumped_entry = entry.model_dump() + new_entry = ListToolsCacheEntry.model_validate(dumped_entry) + + assert new_entry.value == snapshot( + [Tool(name="test_tool", parameters={"param1": "value1", "param2": 42})] + ) + + def test_list_resources_cache_entry_validation(self): + """Test ListResourcesCacheEntry class functionality.""" + entry: ListResourcesCacheEntry = ListResourcesCacheEntry( + key="test_key", + value=[SAMPLE_RESOURCE], + ttl=3600, + ) + + dumped_entry = entry.model_dump() + new_entry = ListResourcesCacheEntry.model_validate(dumped_entry) + + assert new_entry.value == snapshot( + [CachableResource(name="test_resource", uri=AnyUrl("https://test_uri/"))] + ) + + def test_list_prompts_cache_entry_validation(self): + """Test ListPromptsCacheEntry class functionality.""" + entry: ListPromptsCacheEntry = ListPromptsCacheEntry( + key="test_key", + value=[SAMPLE_PROMPT], + ttl=3600, + ) + + dumped_entry = entry.model_dump() + new_entry = ListPromptsCacheEntry.model_validate(dumped_entry) + + assert new_entry.value == snapshot( + [CachablePrompt(name="test_prompt", arguments=[])] ) @@ -307,22 +388,28 @@ async def test_size_limit(self, sample_tool_result): cache = InMemoryCache(max_entries=2) # Fill cache to capacity - await cache.set_value( - collection="test_collection", key="key1", value=sample_tool_result, ttl=3600 + await cache.set_entry( + cache_entry=ToolResultCacheEntry( + key="key1", value=sample_tool_result, ttl=3600 + ) ) - await cache.set_value( - collection="test_collection", key="key2", value=sample_tool_result, ttl=3600 + await cache.set_entry( + cache_entry=ToolResultCacheEntry( + key="key2", value=sample_tool_result, ttl=3600 + ) ) # Add one more - should evict the first - await cache.set_value( - collection="test_collection", key="key3", value=sample_tool_result, ttl=3600 + await cache.set_entry( + cache_entry=ToolResultCacheEntry( + key="key3", value=sample_tool_result, ttl=3600 + ) ) assert len(cache._cache) == 2 - assert "test_collection:key1" not in cache._cache - assert "test_collection:key2" in cache._cache - assert "test_collection:key3" in cache._cache + assert "tools/call:key1" not in cache._cache + assert "tools/call:key2" in cache._cache + assert "tools/call:key3" in cache._cache class TestCacheImplementations: @@ -338,88 +425,189 @@ async def cache(self, request): async def test_get_none_if_not_set(self, cache: CacheProtocol): """Test that we get None if a value is not set.""" - assert ( - await cache.get_value(collection="test_collection", key="test_key") is None + assert await cache.get_entry(collection="tools/call", key="test_key") is None + + async def test_list_tools(self, cache: CacheProtocol): + """Test that we can list tools from the cache.""" + await cache.set_entry( + cache_entry=ListToolsCacheEntry( + key="test_key", value=[SAMPLE_TOOL], ttl=3600 + ) ) + result = await cache.get_entry(collection="tools/list", key="test_key") - @pytest.mark.parametrize( - "value", - [ - [SAMPLE_TOOL], - SAMPLE_TOOL_RESULT, - [SAMPLE_RESOURCE], - [SAMPLE_READ_RESOURCE_CONTENTS], - [SAMPLE_PROMPT], - SAMPLE_GET_PROMPT_RESULT, - ], - ids=[ - "tool_list", - "tool_result", - "resource", - "read_resource_contents", - "prompt", - "get_prompt_result", - ], - ) - async def test_set_and_get(self, cache: CacheProtocol, value: CachableTypes): - """Test that we can set and then get back a value from the cache.""" + assert result is not None + assert dump_mcp_types(model=result.value) == snapshot( + [ + { + "name": "test_tool", + "title": None, + "description": None, + "tags": set(), + "meta": None, + "enabled": True, + "parameters": {"param1": "value1", "param2": 42}, + "output_schema": None, + "annotations": None, + "serializer": None, + } + ] + ) + + async def test_tool_result(self, cache: CacheProtocol): + """Test that we can get a tool result from the cache.""" + await cache.set_entry( + cache_entry=ToolResultCacheEntry( + key="test_key", value=SAMPLE_TOOL_RESULT, ttl=3600 + ) + ) + result = await cache.get_entry(collection="tools/call", key="test_key") - await cache.set_value( - collection="test_collection", - key="test_key", - value=value, - ttl=3600, + assert result is not None + assert dump_mcp_types(model=result.value) == snapshot( + { + "content": [ + { + "type": "text", + "text": "test_text", + "annotations": None, + "meta": None, + } + ], + "structured_content": {"result": "test_result"}, + } + ) + + async def test_list_resources(self, cache: CacheProtocol): + """Test that we can list resources from the cache.""" + await cache.set_entry( + cache_entry=ListResourcesCacheEntry( + key="test_key", value=[SAMPLE_RESOURCE], ttl=3600 + ) ) - result: CachableTypes | None = await cache.get_value( - collection="test_collection", key="test_key" + result = await cache.get_entry(collection="resources/list", key="test_key") + + assert result is not None + assert dump_mcp_types(model=result.value) == snapshot( + [ + { + "name": "test_resource", + "title": None, + "description": None, + "tags": set(), + "meta": None, + "enabled": True, + "uri": AnyUrl("https://test_uri/"), + "mime_type": "text/plain", + "annotations": None, + } + ] + ) + + async def test_read_resource(self, cache: CacheProtocol): + """Test that we can read a resource from the cache.""" + await cache.set_entry( + cache_entry=ReadResourceCacheEntry( + key="test_key", value=[SAMPLE_READ_RESOURCE_CONTENTS], ttl=3600 + ) ) + result = await cache.get_entry(collection="resources/read", key="test_key") assert result is not None + assert dump_mcp_types(model=result.value) == snapshot( + [{"content": "test_text", "mime_type": "text/plain"}] + ) + + async def test_list_prompts(self, cache: CacheProtocol): + """Test that we can list prompts from the cache.""" + await cache.set_entry( + cache_entry=ListPromptsCacheEntry( + key="test_key", value=[SAMPLE_PROMPT], ttl=3600 + ) + ) + result = await cache.get_entry(collection="prompts/list", key="test_key") - assert isinstance(result, type(value)) + assert result is not None + assert dump_mcp_types(model=result.value) == snapshot( + [ + { + "name": "test_prompt", + "title": None, + "description": None, + "tags": set(), + "meta": None, + "enabled": True, + "arguments": [], + } + ] + ) + + async def test_get_prompt(self, cache: CacheProtocol): + """Test that we can get a prompt from the cache.""" + entry = GetPromptCacheEntry( + key="test_key", value=SAMPLE_GET_PROMPT_RESULT, ttl=3600 + ) + await cache.set_entry(cache_entry=entry) + result = await cache.get_entry(collection=entry.collection, key="test_key") - assert dump_mcp_types(model=result) == dump_mcp_types(model=value) + assert result is not None + assert dump_mcp_types(model=result.value) == snapshot( + { + "meta": None, + "description": None, + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "test_text", + "annotations": None, + "meta": None, + }, + } + ], + } + ) async def test_set_get_delete_get_value(self, cache: CacheProtocol): """Test that we can set, get, delete, and get a value from the cache.""" - await cache.set_value( - collection="test_collection", - key="test_key", - value=SAMPLE_TOOL_RESULT, - ttl=3600, + await cache.set_entry( + cache_entry=ToolResultCacheEntry( + key="test_key", + value=SAMPLE_TOOL_RESULT, + ttl=3600, + ) ) - result: CachableTypes | None = await cache.get_value( - collection="test_collection", key="test_key" + result: BaseCacheEntry | None = await cache.get_entry( + collection="tools/call", key="test_key" ) assert result is not None - assert dump_mcp_types(model=result) == dump_mcp_types(model=SAMPLE_TOOL_RESULT) + assert dump_mcp_types(model=result.value) == dump_mcp_types( + model=SAMPLE_TOOL_RESULT + ) - await cache.delete(collection="test_collection", key="test_key") + await cache.delete(collection="tools/call", key="test_key") - assert ( - await cache.get_value(collection="test_collection", key="test_key") is None - ) + assert await cache.get_entry(collection="tools/call", key="test_key") is None async def test_expiration_and_cleanup(self, cache: CacheProtocol): """Test cache expiration and cleanup.""" # Create an expired entry - await cache.set_value( - collection="test_collection", - key="expired_key", - value=SAMPLE_TOOL_RESULT, - ttl=-1, + await cache.set_entry( + cache_entry=ToolResultCacheEntry( + key="expired_key", + value=SAMPLE_TOOL_RESULT, + ttl=-1, + ) ) # Should return None and remove expired entry - result = await cache.get_value(collection="test_collection", key="expired_key") + result = await cache.get_entry(collection="tools/call", key="expired_key") assert result is None - assert ( - await cache.get_value(collection="test_collection", key="expired_key") - is None - ) + assert await cache.get_entry(collection="tools/call", key="expired_key") is None class TestResponseCachingMiddleware: @@ -589,7 +777,7 @@ async def test_cache_miss_and_hit( class TestResponseCachingMiddlewareIntegration: """Integration tests with real FastMCP server.""" - @pytest.fixture(params=["memory", "disk"]) + @pytest.fixture(params=["memory", "disk", "elasticsearch"]) async def caching_server( self, tracking_calculator: TrackingCalculator, @@ -605,13 +793,13 @@ async def caching_server( else InMemoryCache() ) - mcp.add_middleware(middleware=response_caching_middleware) + mcp.add_middleware(middleware=response_caching_middleware) - tracking_calculator.add_tools(fastmcp=mcp) - tracking_calculator.add_resources(fastmcp=mcp) - tracking_calculator.add_prompts(fastmcp=mcp) + tracking_calculator.add_tools(fastmcp=mcp) + tracking_calculator.add_resources(fastmcp=mcp) + tracking_calculator.add_prompts(fastmcp=mcp) - return mcp + yield mcp @pytest.fixture def non_caching_server(self, tracking_calculator: TrackingCalculator): diff --git a/uv.lock b/uv.lock index fb632a6c4f..92afacc396 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,114 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/dc/ef9394bde9080128ad401ac7ede185267ed637df03b51f05d14d1c99ad67/aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc", size = 703921, upload-time = "2025-07-29T05:49:43.584Z" }, + { url = "https://files.pythonhosted.org/packages/8f/42/63fccfc3a7ed97eb6e1a71722396f409c46b60a0552d8a56d7aad74e0df5/aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af", size = 480288, upload-time = "2025-07-29T05:49:47.851Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a2/7b8a020549f66ea2a68129db6960a762d2393248f1994499f8ba9728bbed/aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421", size = 468063, upload-time = "2025-07-29T05:49:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f5/d11e088da9176e2ad8220338ae0000ed5429a15f3c9dfd983f39105399cd/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79", size = 1650122, upload-time = "2025-07-29T05:49:51.874Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6b/b60ce2757e2faed3d70ed45dafee48cee7bfb878785a9423f7e883f0639c/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77", size = 1624176, upload-time = "2025-07-29T05:49:53.805Z" }, + { url = "https://files.pythonhosted.org/packages/dd/de/8c9fde2072a1b72c4fadecf4f7d4be7a85b1d9a4ab333d8245694057b4c6/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c", size = 1696583, upload-time = "2025-07-29T05:49:55.338Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ad/07f863ca3d895a1ad958a54006c6dafb4f9310f8c2fdb5f961b8529029d3/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4", size = 1738896, upload-time = "2025-07-29T05:49:57.045Z" }, + { url = "https://files.pythonhosted.org/packages/20/43/2bd482ebe2b126533e8755a49b128ec4e58f1a3af56879a3abdb7b42c54f/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6", size = 1643561, upload-time = "2025-07-29T05:49:58.762Z" }, + { url = "https://files.pythonhosted.org/packages/23/40/2fa9f514c4cf4cbae8d7911927f81a1901838baf5e09a8b2c299de1acfe5/aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2", size = 1583685, upload-time = "2025-07-29T05:50:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c3/94dc7357bc421f4fb978ca72a201a6c604ee90148f1181790c129396ceeb/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d", size = 1627533, upload-time = "2025-07-29T05:50:02.306Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3f/1f8911fe1844a07001e26593b5c255a685318943864b27b4e0267e840f95/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb", size = 1638319, upload-time = "2025-07-29T05:50:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/4e/46/27bf57a99168c4e145ffee6b63d0458b9c66e58bb70687c23ad3d2f0bd17/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5", size = 1613776, upload-time = "2025-07-29T05:50:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7e/1d2d9061a574584bb4ad3dbdba0da90a27fdc795bc227def3a46186a8bc1/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b", size = 1693359, upload-time = "2025-07-29T05:50:07.563Z" }, + { url = "https://files.pythonhosted.org/packages/08/98/bee429b52233c4a391980a5b3b196b060872a13eadd41c3a34be9b1469ed/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065", size = 1716598, upload-time = "2025-07-29T05:50:09.33Z" }, + { url = "https://files.pythonhosted.org/packages/57/39/b0314c1ea774df3392751b686104a3938c63ece2b7ce0ba1ed7c0b4a934f/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1", size = 1644940, upload-time = "2025-07-29T05:50:11.334Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/3dacb8d3f8f512c8ca43e3fa8a68b20583bd25636ffa4e56ee841ffd79ae/aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a", size = 429239, upload-time = "2025-07-29T05:50:12.803Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f9/470b5daba04d558c9673ca2034f28d067f3202a40e17804425f0c331c89f/aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830", size = 452297, upload-time = "2025-07-29T05:50:14.266Z" }, + { url = "https://files.pythonhosted.org/packages/20/19/9e86722ec8e835959bd97ce8c1efa78cf361fa4531fca372551abcc9cdd6/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117", size = 711246, upload-time = "2025-07-29T05:50:15.937Z" }, + { url = "https://files.pythonhosted.org/packages/71/f9/0a31fcb1a7d4629ac9d8f01f1cb9242e2f9943f47f5d03215af91c3c1a26/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe", size = 483515, upload-time = "2025-07-29T05:50:17.442Z" }, + { url = "https://files.pythonhosted.org/packages/62/6c/94846f576f1d11df0c2e41d3001000527c0fdf63fce7e69b3927a731325d/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9", size = 471776, upload-time = "2025-07-29T05:50:19.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6c/f766d0aaafcee0447fad0328da780d344489c042e25cd58fde566bf40aed/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5", size = 1741977, upload-time = "2025-07-29T05:50:21.665Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/fb779a05ba6ff44d7bc1e9d24c644e876bfff5abe5454f7b854cace1b9cc/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728", size = 1690645, upload-time = "2025-07-29T05:50:23.333Z" }, + { url = "https://files.pythonhosted.org/packages/37/4e/a22e799c2035f5d6a4ad2cf8e7c1d1bd0923192871dd6e367dafb158b14c/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16", size = 1789437, upload-time = "2025-07-29T05:50:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/28/e5/55a33b991f6433569babb56018b2fb8fb9146424f8b3a0c8ecca80556762/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0", size = 1828482, upload-time = "2025-07-29T05:50:26.693Z" }, + { url = "https://files.pythonhosted.org/packages/c6/82/1ddf0ea4f2f3afe79dffed5e8a246737cff6cbe781887a6a170299e33204/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b", size = 1730944, upload-time = "2025-07-29T05:50:28.382Z" }, + { url = "https://files.pythonhosted.org/packages/1b/96/784c785674117b4cb3877522a177ba1b5e4db9ce0fd519430b5de76eec90/aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd", size = 1668020, upload-time = "2025-07-29T05:50:30.032Z" }, + { url = "https://files.pythonhosted.org/packages/12/8a/8b75f203ea7e5c21c0920d84dd24a5c0e971fe1e9b9ebbf29ae7e8e39790/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8", size = 1716292, upload-time = "2025-07-29T05:50:31.983Z" }, + { url = "https://files.pythonhosted.org/packages/47/0b/a1451543475bb6b86a5cfc27861e52b14085ae232896a2654ff1231c0992/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50", size = 1711451, upload-time = "2025-07-29T05:50:33.989Z" }, + { url = "https://files.pythonhosted.org/packages/55/fd/793a23a197cc2f0d29188805cfc93aa613407f07e5f9da5cd1366afd9d7c/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676", size = 1691634, upload-time = "2025-07-29T05:50:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bf/23a335a6670b5f5dfc6d268328e55a22651b440fca341a64fccf1eada0c6/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7", size = 1785238, upload-time = "2025-07-29T05:50:37.597Z" }, + { url = "https://files.pythonhosted.org/packages/57/4f/ed60a591839a9d85d40694aba5cef86dde9ee51ce6cca0bb30d6eb1581e7/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7", size = 1805701, upload-time = "2025-07-29T05:50:39.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/e0/444747a9455c5de188c0f4a0173ee701e2e325d4b2550e9af84abb20cdba/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685", size = 1718758, upload-time = "2025-07-29T05:50:41.292Z" }, + { url = "https://files.pythonhosted.org/packages/36/ab/1006278d1ffd13a698e5dd4bfa01e5878f6bddefc296c8b62649753ff249/aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b", size = 428868, upload-time = "2025-07-29T05:50:43.063Z" }, + { url = "https://files.pythonhosted.org/packages/10/97/ad2b18700708452400278039272032170246a1bf8ec5d832772372c71f1a/aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d", size = 453273, upload-time = "2025-07-29T05:50:44.613Z" }, + { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, + { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, + { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, + { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, + { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, + { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, + { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, + { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, + { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, + { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, + { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, + { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -39,6 +147,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + [[package]] name = "attrs" version = "25.3.0" @@ -463,6 +580,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/57/8db39bc5f98f042e0153b1de9fb88e1a409a33cda4dd7f723c2ed71e01f6/docutils-0.22-py3-none-any.whl", hash = "sha256:4ed966a0e96a0477d852f7af31bdcb3adc049fbb35ccba358c2ea8a03287615e", size = 630709, upload-time = "2025-07-29T15:20:28.335Z" }, ] +[[package]] +name = "elastic-transport" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1f/2d1a1790df2b75e1e1eb90d8a3fe066a47ef95e34430657447e549cc274c/elastic_transport-9.1.0.tar.gz", hash = "sha256:1590e44a25b0fe208107d5e8d7dea15c070525f3ac9baafbe4cb659cd14f073d", size = 76483, upload-time = "2025-07-24T16:41:31.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/5d/dd5a919dd887fe20a91f18faf5b4345ee3a058e483d2aa84cef0f2567e17/elastic_transport-9.1.0-py3-none-any.whl", hash = "sha256:369fa56874c74daae4ea10cbf40636d139f38f42bec0e006b9cd45a168ee7fce", size = 65142, upload-time = "2025-07-24T16:41:29.648Z" }, +] + +[[package]] +name = "elasticsearch" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "elastic-transport" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/6a/5eecef6f1ac8005b04714405cb65971d46031bd897e47c29af86e0f87353/elasticsearch-9.1.1.tar.gz", hash = "sha256:be20acda2a97591a9a6cf4981fc398ee6fca3291cf9e7a9e52b6a9f41a46d393", size = 857802, upload-time = "2025-09-12T13:27:38.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/4c/c0c95d3d881732a5d1b28e12c9be4dea5953ade71810f94565bd5bd2101a/elasticsearch-9.1.1-py3-none-any.whl", hash = "sha256:2a5c27c57ca3dd3365f665c82c9dcd8666ccfb550d5b07c688c21ec636c104e5", size = 937483, upload-time = "2025-09-12T13:27:34.948Z" }, +] + [[package]] name = "email-validator" version = "2.2.0" @@ -555,6 +699,12 @@ caching = [ { name = "cachetools" }, { name = "diskcache" }, ] +contrib-middleware-elasticsearch-cache = [ + { name = "aiohttp" }, + { name = "cachetools" }, + { name = "diskcache" }, + { name = "elasticsearch" }, +] openai = [ { name = "openai" }, ] @@ -566,7 +716,7 @@ websockets = [ dev = [ { name = "dirty-equals" }, { name = "fastapi" }, - { name = "fastmcp", extra = ["caching", "openai"] }, + { name = "fastmcp", extra = ["caching", "contrib-middleware-elasticsearch-cache", "openai"] }, { name = "inline-snapshot", extra = ["dirty-equals"] }, { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -591,10 +741,14 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiohttp", marker = "extra == 'contrib-middleware-elasticsearch-cache'", specifier = ">=3.11.10" }, { name = "authlib", specifier = ">=1.5.2" }, { name = "cachetools", marker = "extra == 'caching'", specifier = ">=6.2.0" }, + { name = "cachetools", marker = "extra == 'contrib-middleware-elasticsearch-cache'", specifier = ">=6.2.0" }, { name = "cyclopts", specifier = ">=3.0.0" }, { name = "diskcache", marker = "extra == 'caching'", specifier = ">=5.6.3" }, + { name = "diskcache", marker = "extra == 'contrib-middleware-elasticsearch-cache'", specifier = ">=5.6.3" }, + { name = "elasticsearch", marker = "extra == 'contrib-middleware-elasticsearch-cache'", specifier = ">=8.15.0" }, { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", specifier = ">=1.12.4,<2.0.0" }, @@ -607,13 +761,14 @@ requires-dist = [ { name = "rich", specifier = ">=13.9.4" }, { name = "websockets", marker = "extra == 'websockets'", specifier = ">=15.0.1" }, ] -provides-extras = ["caching", "openai", "websockets"] +provides-extras = ["caching", "contrib-middleware-elasticsearch-cache", "openai", "websockets"] [package.metadata.requires-dev] dev = [ { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "fastapi", specifier = ">=0.115.12" }, { name = "fastmcp", extras = ["caching"] }, + { name = "fastmcp", extras = ["contrib-middleware-elasticsearch-cache"] }, { name = "fastmcp", extras = ["openai"] }, { name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" }, { name = "ipython", specifier = ">=8.12.3" }, @@ -645,6 +800,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload-time = "2025-06-09T22:59:48.133Z" }, + { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload-time = "2025-06-09T22:59:49.564Z" }, + { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload-time = "2025-06-09T22:59:51.35Z" }, + { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload-time = "2025-06-09T22:59:52.884Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload-time = "2025-06-09T22:59:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload-time = "2025-06-09T22:59:56.187Z" }, + { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload-time = "2025-06-09T22:59:57.604Z" }, + { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload-time = "2025-06-09T22:59:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload-time = "2025-06-09T23:00:01.026Z" }, + { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload-time = "2025-06-09T23:00:03.401Z" }, + { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload-time = "2025-06-09T23:00:05.282Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload-time = "2025-06-09T23:00:07.962Z" }, + { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload-time = "2025-06-09T23:00:09.428Z" }, + { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload-time = "2025-06-09T23:00:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload-time = "2025-06-09T23:00:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload-time = "2025-06-09T23:00:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, + { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, + { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, + { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, + { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, + { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, + { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, + { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1077,6 +1326,108 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" }, ] +[[package]] +name = "multidict" +version = "6.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/6b/86f353088c1358e76fd30b0146947fddecee812703b604ee901e85cd2a80/multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f", size = 77054, upload-time = "2025-08-11T12:06:02.99Z" }, + { url = "https://files.pythonhosted.org/packages/19/5d/c01dc3d3788bb877bd7f5753ea6eb23c1beeca8044902a8f5bfb54430f63/multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb", size = 44914, upload-time = "2025-08-11T12:06:05.264Z" }, + { url = "https://files.pythonhosted.org/packages/46/44/964dae19ea42f7d3e166474d8205f14bb811020e28bc423d46123ddda763/multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495", size = 44601, upload-time = "2025-08-11T12:06:06.627Z" }, + { url = "https://files.pythonhosted.org/packages/31/20/0616348a1dfb36cb2ab33fc9521de1f27235a397bf3f59338e583afadd17/multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8", size = 224821, upload-time = "2025-08-11T12:06:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/14/26/5d8923c69c110ff51861af05bd27ca6783011b96725d59ccae6d9daeb627/multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7", size = 242608, upload-time = "2025-08-11T12:06:09.697Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/e2ad3ba9459aa34fa65cf1f82a5c4a820a2ce615aacfb5143b8817f76504/multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796", size = 222324, upload-time = "2025-08-11T12:06:10.905Z" }, + { url = "https://files.pythonhosted.org/packages/19/db/4ed0f65701afbc2cb0c140d2d02928bb0fe38dd044af76e58ad7c54fd21f/multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db", size = 253234, upload-time = "2025-08-11T12:06:12.658Z" }, + { url = "https://files.pythonhosted.org/packages/94/c1/5160c9813269e39ae14b73debb907bfaaa1beee1762da8c4fb95df4764ed/multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0", size = 251613, upload-time = "2025-08-11T12:06:13.97Z" }, + { url = "https://files.pythonhosted.org/packages/05/a9/48d1bd111fc2f8fb98b2ed7f9a115c55a9355358432a19f53c0b74d8425d/multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877", size = 241649, upload-time = "2025-08-11T12:06:15.204Z" }, + { url = "https://files.pythonhosted.org/packages/85/2a/f7d743df0019408768af8a70d2037546a2be7b81fbb65f040d76caafd4c5/multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace", size = 239238, upload-time = "2025-08-11T12:06:16.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b8/4f4bb13323c2d647323f7919201493cf48ebe7ded971717bfb0f1a79b6bf/multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6", size = 233517, upload-time = "2025-08-11T12:06:18.107Z" }, + { url = "https://files.pythonhosted.org/packages/33/29/4293c26029ebfbba4f574febd2ed01b6f619cfa0d2e344217d53eef34192/multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb", size = 243122, upload-time = "2025-08-11T12:06:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/20/60/a1c53628168aa22447bfde3a8730096ac28086704a0d8c590f3b63388d0c/multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb", size = 248992, upload-time = "2025-08-11T12:06:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3b/55443a0c372f33cae5d9ec37a6a973802884fa0ab3586659b197cf8cc5e9/multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987", size = 243708, upload-time = "2025-08-11T12:06:21.891Z" }, + { url = "https://files.pythonhosted.org/packages/7c/60/a18c6900086769312560b2626b18e8cca22d9e85b1186ba77f4755b11266/multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f", size = 237498, upload-time = "2025-08-11T12:06:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/11/3d/8bdd8bcaff2951ce2affccca107a404925a2beafedd5aef0b5e4a71120a6/multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f", size = 41415, upload-time = "2025-08-11T12:06:24.77Z" }, + { url = "https://files.pythonhosted.org/packages/c0/53/cab1ad80356a4cd1b685a254b680167059b433b573e53872fab245e9fc95/multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0", size = 46046, upload-time = "2025-08-11T12:06:25.893Z" }, + { url = "https://files.pythonhosted.org/packages/cf/9a/874212b6f5c1c2d870d0a7adc5bb4cfe9b0624fa15cdf5cf757c0f5087ae/multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729", size = 43147, upload-time = "2025-08-11T12:06:27.534Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7f/90a7f01e2d005d6653c689039977f6856718c75c5579445effb7e60923d1/multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c", size = 76472, upload-time = "2025-08-11T12:06:29.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a3/bed07bc9e2bb302ce752f1dabc69e884cd6a676da44fb0e501b246031fdd/multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb", size = 44634, upload-time = "2025-08-11T12:06:30.374Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4b/ceeb4f8f33cf81277da464307afeaf164fb0297947642585884f5cad4f28/multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e", size = 44282, upload-time = "2025-08-11T12:06:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/03/35/436a5da8702b06866189b69f655ffdb8f70796252a8772a77815f1812679/multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded", size = 229696, upload-time = "2025-08-11T12:06:33.087Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0e/915160be8fecf1fca35f790c08fb74ca684d752fcba62c11daaf3d92c216/multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683", size = 246665, upload-time = "2025-08-11T12:06:34.448Z" }, + { url = "https://files.pythonhosted.org/packages/08/ee/2f464330acd83f77dcc346f0b1a0eaae10230291450887f96b204b8ac4d3/multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a", size = 225485, upload-time = "2025-08-11T12:06:35.672Z" }, + { url = "https://files.pythonhosted.org/packages/71/cc/9a117f828b4d7fbaec6adeed2204f211e9caf0a012692a1ee32169f846ae/multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9", size = 257318, upload-time = "2025-08-11T12:06:36.98Z" }, + { url = "https://files.pythonhosted.org/packages/25/77/62752d3dbd70e27fdd68e86626c1ae6bccfebe2bb1f84ae226363e112f5a/multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50", size = 254689, upload-time = "2025-08-11T12:06:38.233Z" }, + { url = "https://files.pythonhosted.org/packages/00/6e/fac58b1072a6fc59af5e7acb245e8754d3e1f97f4f808a6559951f72a0d4/multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52", size = 246709, upload-time = "2025-08-11T12:06:39.517Z" }, + { url = "https://files.pythonhosted.org/packages/01/ef/4698d6842ef5e797c6db7744b0081e36fb5de3d00002cc4c58071097fac3/multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6", size = 243185, upload-time = "2025-08-11T12:06:40.796Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c9/d82e95ae1d6e4ef396934e9b0e942dfc428775f9554acf04393cce66b157/multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e", size = 237838, upload-time = "2025-08-11T12:06:42.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/f94af5c36baaa75d44fab9f02e2a6bcfa0cd90acb44d4976a80960759dbc/multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3", size = 246368, upload-time = "2025-08-11T12:06:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/4a/fe/29f23460c3d995f6a4b678cb2e9730e7277231b981f0b234702f0177818a/multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c", size = 253339, upload-time = "2025-08-11T12:06:45.597Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/fd59449204426187b82bf8a75f629310f68c6adc9559dc922d5abe34797b/multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b", size = 246933, upload-time = "2025-08-11T12:06:46.841Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/d5d6b344f176a5ac3606f7a61fb44dc746e04550e1a13834dff722b8d7d6/multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f", size = 242225, upload-time = "2025-08-11T12:06:48.588Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d3/5b2281ed89ff4d5318d82478a2a2450fcdfc3300da48ff15c1778280ad26/multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2", size = 41306, upload-time = "2025-08-11T12:06:49.95Z" }, + { url = "https://files.pythonhosted.org/packages/74/7d/36b045c23a1ab98507aefd44fd8b264ee1dd5e5010543c6fccf82141ccef/multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e", size = 46029, upload-time = "2025-08-11T12:06:51.082Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5e/553d67d24432c5cd52b49047f2d248821843743ee6d29a704594f656d182/multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf", size = 43017, upload-time = "2025-08-11T12:06:52.243Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, + { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, + { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, + { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, + { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, + { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, + { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, + { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, + { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -1273,6 +1624,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, ] +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload-time = "2025-06-09T22:53:41.965Z" }, + { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload-time = "2025-06-09T22:53:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload-time = "2025-06-09T22:53:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload-time = "2025-06-09T22:53:46.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload-time = "2025-06-09T22:53:48.547Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload-time = "2025-06-09T22:53:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload-time = "2025-06-09T22:53:51.438Z" }, + { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload-time = "2025-06-09T22:53:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload-time = "2025-06-09T22:53:54.541Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload-time = "2025-06-09T22:53:56.44Z" }, + { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload-time = "2025-06-09T22:53:57.839Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload-time = "2025-06-09T22:53:59.638Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload-time = "2025-06-09T22:54:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload-time = "2025-06-09T22:54:03.003Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload-time = "2025-06-09T22:54:04.134Z" }, + { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, + { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, + { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, + { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, + { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + [[package]] name = "psutil" version = "7.0.0" @@ -1664,6 +2104,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -2254,3 +2706,102 @@ sdist = { url = "https://files.pythonhosted.org/packages/32/af/d4502dc713b4ccea7 wheels = [ { url = "https://files.pythonhosted.org/packages/ee/ea/c67e1dee1ba208ed22c06d1d547ae5e293374bfc43e0eb0ef5e262b68561/werkzeug-3.1.1-py3-none-any.whl", hash = "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5", size = 224371, upload-time = "2024-11-01T16:40:43.994Z" }, ] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload-time = "2025-06-10T00:42:31.108Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload-time = "2025-06-10T00:42:33.851Z" }, + { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload-time = "2025-06-10T00:42:35.688Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload-time = "2025-06-10T00:42:37.817Z" }, + { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload-time = "2025-06-10T00:42:39.937Z" }, + { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload-time = "2025-06-10T00:42:42.627Z" }, + { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload-time = "2025-06-10T00:42:44.842Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload-time = "2025-06-10T00:42:47.149Z" }, + { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload-time = "2025-06-10T00:42:48.852Z" }, + { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload-time = "2025-06-10T00:42:51.024Z" }, + { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload-time = "2025-06-10T00:42:53.007Z" }, + { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload-time = "2025-06-10T00:42:54.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload-time = "2025-06-10T00:42:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload-time = "2025-06-10T00:42:59.055Z" }, + { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload-time = "2025-06-10T00:43:01.248Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload-time = "2025-06-10T00:43:03.486Z" }, + { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload-time = "2025-06-10T00:43:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, + { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, + { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, + { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, + { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] From e84f9446ae8ffc393531b816c1012f076a69bc78 Mon Sep 17 00:00:00 2001 From: William Easton Date: Sun, 21 Sep 2025 12:35:04 -0500 Subject: [PATCH 09/15] small fix for caching --- .../elasticsearch/elasticsearch_cache.py | 11 ++------ src/fastmcp/server/middleware/caching.py | 28 +++++++++---------- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/fastmcp/contrib/middleware/caching/elasticsearch/elasticsearch_cache.py b/src/fastmcp/contrib/middleware/caching/elasticsearch/elasticsearch_cache.py index 4267acaf2b..973b4fc00d 100644 --- a/src/fastmcp/contrib/middleware/caching/elasticsearch/elasticsearch_cache.py +++ b/src/fastmcp/contrib/middleware/caching/elasticsearch/elasticsearch_cache.py @@ -31,18 +31,13 @@ "value": { "type": "keyword", "index": False, + "doc_values": False, + "ignore_above": 256, }, }, } -class SetEncoder(json.JSONEncoder): - def default(self, obj): - if isinstance(obj, set): - return list(obj) - return json.JSONEncoder.default(self, obj) - - class ElasticsearchCache(CacheProtocol): """A cache client that uses Elasticsearch.""" @@ -111,7 +106,7 @@ async def set_entry( collection=cache_entry.collection, key=cache_entry.key ) - document = json.loads(cache_entry.model_dump_json()) + document = json.loads(cache_entry.model_dump_json(serialize_as_any=True)) document["value"] = json.dumps(document["value"]) diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index d4a223a587..d2cd0b4e63 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -65,9 +65,7 @@ class BaseCacheEntry(BaseModel, ABC): ) created_at: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc)) - value: CachableValueTypes - collection: str key: str ttl: int @@ -265,12 +263,12 @@ async def get_entry( self, collection: str, key: str, - ) -> BaseCacheEntry | None: + ) -> CacheEntryTypes | None: """Get a cache entry from the cache.""" async def set_entry( self, - cache_entry: BaseCacheEntry, + cache_entry: CacheEntryTypes, ) -> None: """Set a value in the cache using the collection and key.""" @@ -307,7 +305,7 @@ def __init__( directory=path, size_limit=size_limit ) - async def get_entry(self, collection: str, key: str) -> BaseCacheEntry | None: + async def get_entry(self, collection: str, key: str) -> CacheEntryTypes | None: collection_key: str = self.make_collection_key(collection=collection, key=key) cache_entry = self._cache.get(key=collection_key) @@ -319,7 +317,7 @@ async def get_entry(self, collection: str, key: str) -> BaseCacheEntry | None: async def set_entry( self, - cache_entry: BaseCacheEntry, + cache_entry: CacheEntryTypes, ) -> None: collection_key: str = self.make_collection_key( collection=cache_entry.collection, key=cache_entry.key @@ -356,20 +354,22 @@ def __init__(self, max_entries: int = DEFAULT_MEMORY_CACHE_MAX_ENTRIES): max_entries: The maximum number of entries to store in the cache. Defaults to 1000. """ self.max_entries = max_entries - self._cache = MemoryCacheClient( + self._cache: MemoryCacheClient[Any, CacheEntryTypes] = MemoryCacheClient[ + Any, CacheEntryTypes + ]( maxsize=max_entries, ttu=_memory_cache_ttu, getsizeof=_memory_cache_getsizeof, ) - async def get_entry(self, collection: str, key: str) -> BaseCacheEntry | None: - collection_key = self.make_collection_key(collection=collection, key=key) + async def get_entry(self, collection: str, key: str) -> CacheEntryTypes | None: + collection_key: str = self.make_collection_key(collection=collection, key=key) return self._cache.get(collection_key) async def set_entry( self, - cache_entry: BaseCacheEntry, + cache_entry: CacheEntryTypes, ) -> None: collection_key: str = self.make_collection_key( collection=cache_entry.collection, key=cache_entry.key @@ -483,7 +483,7 @@ class MethodSettings(TypedDict): MethodSettingsType = TypeVar("MethodSettingsType", bound=SharedMethodSettings) -MCP_METHOD_TO_CACHE_ENTRY_TYPE: dict[str, type[BaseCacheEntry]] = { +MCP_METHOD_TO_CACHE_ENTRY_TYPE: dict[str, type[CacheEntryTypes]] = { "tools/list": ListToolsCacheEntry, "tools/call": ToolResultCacheEntry, "resources/list": ListResourcesCacheEntry, @@ -746,10 +746,10 @@ async def _store_in_cache_and_return( ttl: int = self._get_cache_ttl(context=context) - cache_entry: BaseCacheEntry = MCP_METHOD_TO_CACHE_ENTRY_TYPE[collection]( - collection=collection, + cache_entry: CacheEntryTypes = MCP_METHOD_TO_CACHE_ENTRY_TYPE[collection]( + collection=collection, # pyright: ignore[reportArgumentType] key=key, - value=value, + value=value, # pyright: ignore[reportArgumentType] ttl=ttl, ) From 57cb83a1450a33aae034f2abb153b70b8673f020 Mon Sep 17 00:00:00 2001 From: William Easton Date: Fri, 10 Oct 2025 19:35:38 -0400 Subject: [PATCH 10/15] Update response caching PR --- src/fastmcp/contrib/middleware/__init__.py | 0 .../contrib/middleware/caching/__init__.py | 0 .../caching/elasticsearch/README.md | 155 ---- .../caching/elasticsearch/__init__.py | 5 - .../elasticsearch/elasticsearch_cache.py | 161 ---- src/fastmcp/server/middleware/caching.py | 776 ++++++------------ tests/server/middleware/test_caching.py | 524 ++---------- uv.lock | 2 +- 8 files changed, 348 insertions(+), 1275 deletions(-) delete mode 100644 src/fastmcp/contrib/middleware/__init__.py delete mode 100644 src/fastmcp/contrib/middleware/caching/__init__.py delete mode 100644 src/fastmcp/contrib/middleware/caching/elasticsearch/README.md delete mode 100644 src/fastmcp/contrib/middleware/caching/elasticsearch/__init__.py delete mode 100644 src/fastmcp/contrib/middleware/caching/elasticsearch/elasticsearch_cache.py diff --git a/src/fastmcp/contrib/middleware/__init__.py b/src/fastmcp/contrib/middleware/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/fastmcp/contrib/middleware/caching/__init__.py b/src/fastmcp/contrib/middleware/caching/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/fastmcp/contrib/middleware/caching/elasticsearch/README.md b/src/fastmcp/contrib/middleware/caching/elasticsearch/README.md deleted file mode 100644 index badf234936..0000000000 --- a/src/fastmcp/contrib/middleware/caching/elasticsearch/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# Elasticsearch Cache Backend – Contrib Module for FastMCP - -This backend plugs into `ResponseCachingMiddleware` and stores cache entries in Elasticsearch. - -- **Package**: `fastmcp.contrib.middleware.caching.elasticsearch` -- **Class**: `ElasticsearchCache` -- **Works with**: `fastmcp.server.middleware.caching.ResponseCachingMiddleware` - ---- - -## 📦 Installation - -Install Elasticsearch async client and FastMCP caching extras: - -```bash -uv add elasticsearch fastmcp[contrib-middleware-elasticsearch_cache] -# or -pip install elasticsearch fastmcp[contrib-middleware-elasticsearch_cache] -``` - ---- - -## 🚀 Quick Start - -```python -from elasticsearch import AsyncElasticsearch -from fastmcp import FastMCP -from fastmcp.server.middleware.caching import ResponseCachingMiddleware -from fastmcp.contrib.middleware.caching.elasticsearch import ElasticsearchCache - -mcp = FastMCP(name="Cache Demo") - -es = AsyncElasticsearch(hosts=["http://localhost:9200"]) # configure as needed -cache = ElasticsearchCache(elasticsearch_client=es, index="fastmcp-response-cache") - -mcp.add_middleware(ResponseCachingMiddleware(cache_backend=cache)) - -# ... define tools/resources/prompts ... - -# When using FastMCP.run(), ensure the event loop closes the ES client when done -# await es.close() in your shutdown path if you manage the loop yourself. -``` - -This enables caching for default MCP methods per `ResponseCachingMiddleware`: -- **tools**: `tools/call`, `tools/list` -- **resources**: `resources/read`, `resources/list` -- **prompts**: `prompts/get`, `prompts/list` - ---- - -## ⚙️ Configuration - -Constructor: - -```python -ElasticsearchCache( - elasticsearch_client: AsyncElasticsearch, - index: str | None = None, - mapping: dict[str, object] | None = None, -) -``` - -- **elasticsearch_client**: A live `AsyncElasticsearch` instance -- **index**: Target index name. Default: `"fastmcp-response-cache"` -- **mapping**: Custom index mapping. Default mapping is optimized for cache fields: - -```json -{ - "properties": { - "created_at": {"type": "date"}, - "expires_at": {"type": "date"}, - "ttl": {"type": "integer"}, - "collection": {"type": "keyword"}, - "key": {"type": "keyword"}, - "value": {"type": "keyword", "index": false} - } -} -``` - -Notes: -- Values are stored as JSON strings under `value` (non-indexed) for size and simplicity. -- Each cache entry is keyed by `f"{collection}:{key}"` and saved as the document `_id`. - ---- - -## 🔧 Lifecycle and Maintenance - -The backend lazily creates the index on first use via `setup()`; calls are internally guarded by an async lock. - -Available maintenance helpers: - -```python -# Drop the entire cache index -await cache.clear() - -# Delete only expired entries -await cache.cull() -``` - -- `clear()` deletes the index. -- `cull()` issues a `delete_by_query` filtering `expires_at < now`. - ---- - -## ✅ Compatibility - -- Works with the core cache entry types defined by `ResponseCachingMiddleware`: - - `tools/call` (tool results) - - `resources/read` (resource contents) - - `prompts/get` (prompt result) - - List endpoints for tools, resources, prompts -- Uses Pydantic discriminators to serialize/validate cache entries round-trip. - ---- - -## 🛡️ Production Notes - -- Ensure your ES cluster has sufficient storage. -- Consider a dedicated index per environment or service instance via the `index` parameter. -- Close the `AsyncElasticsearch` client on shutdown to avoid warnings and resource leaks. - ---- - -## 🧪 Manual Cache Lifecycle - -```python -import asyncio -from elasticsearch import AsyncElasticsearch -from fastmcp.server.middleware.caching import ResponseCachingMiddleware -from fastmcp.contrib.middleware.caching.elasticsearch import ElasticsearchCache - -async def main(): - es = AsyncElasticsearch(hosts=["http://localhost:9200"]) - cache = ElasticsearchCache(es) - await cache.setup() # optional; auto-runs on first access - - # Simulate storing an entry through middleware flow - # In practice, the middleware calls set_entry() for you. - - await cache.cull() - await es.close() - -asyncio.run(main()) -``` - ---- - -## 📚 Imports - -```python -from fastmcp.contrib.middleware.caching.elasticsearch import ElasticsearchCache -from fastmcp.server.middleware.caching import ResponseCachingMiddleware -``` - -This module is part of `fastmcp.contrib`. See `docs/servers/middleware.mdx` and `docs/servers/tools.mdx` for response caching details. diff --git a/src/fastmcp/contrib/middleware/caching/elasticsearch/__init__.py b/src/fastmcp/contrib/middleware/caching/elasticsearch/__init__.py deleted file mode 100644 index 1538fd1d6e..0000000000 --- a/src/fastmcp/contrib/middleware/caching/elasticsearch/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .elasticsearch_cache import ElasticsearchCache - -__all__ = [ - "ElasticsearchCache", -] \ No newline at end of file diff --git a/src/fastmcp/contrib/middleware/caching/elasticsearch/elasticsearch_cache.py b/src/fastmcp/contrib/middleware/caching/elasticsearch/elasticsearch_cache.py deleted file mode 100644 index 973b4fc00d..0000000000 --- a/src/fastmcp/contrib/middleware/caching/elasticsearch/elasticsearch_cache.py +++ /dev/null @@ -1,161 +0,0 @@ -import asyncio -import json -from datetime import datetime, timezone -from typing import Annotated, Any - -from elasticsearch import AsyncElasticsearch -from pydantic import Field, TypeAdapter - -from fastmcp.server.middleware.caching import ( - CacheEntryTypes, - CacheProtocol, -) - -DEFAULT_MAPPING = { - "properties": { - "created_at": { - "type": "date", - }, - "expires_at": { - "type": "date", - }, - "ttl": { - "type": "integer", - }, - "collection": { - "type": "keyword", - }, - "key": { - "type": "keyword", - }, - "value": { - "type": "keyword", - "index": False, - "doc_values": False, - "ignore_above": 256, - }, - }, -} - - -class ElasticsearchCache(CacheProtocol): - """A cache client that uses Elasticsearch.""" - - setup_called: bool - setup_lock: asyncio.Lock - cached_entry_typeadapter: TypeAdapter[CacheEntryTypes] - - def __init__( - self, - elasticsearch_client: AsyncElasticsearch, - index: str | None = None, - mapping: dict[str, Any] | None = None, - ): - """Initialize the Elasticsearch cache. - - Args: - elasticsearch_client: The Elasticsearch client to use. - index: The index to use for the cache. Defaults to "fastmcp-response-cache". - mapping: The mapping to use for the cache. Defaults to the default mapping. - """ - self.elasticsearch_client = elasticsearch_client - self.index = index or "fastmcp-response-cache" - self.mapping = mapping or DEFAULT_MAPPING - self.setup_called = False - self.setup_lock = asyncio.Lock() - self.cached_entry_typeadapter = TypeAdapter( - Annotated[CacheEntryTypes, Field(discriminator="collection")], - ) - - async def get_entry(self, collection: str, key: str) -> CacheEntryTypes | None: - if not self.setup_called: - await self.setup() - - collection_key = self.make_collection_key(collection=collection, key=key) - - elasticsearch_response = await self.elasticsearch_client.options( - ignore_status=404 - ).get(index=self.index, id=collection_key) - - if ( - elasticsearch_response.body is None - or elasticsearch_response.body.get("error") - or not elasticsearch_response.body.get("found") - ): - return None - - source = elasticsearch_response.body.get("_source") - - source["value"] = json.loads(source["value"]) - - cache_entry = self.cached_entry_typeadapter.validate_python(source) - if cache_entry.is_expired(): - await self.delete(collection=collection, key=key) - return None - - return cache_entry - - async def set_entry( - self, - cache_entry: CacheEntryTypes, - ) -> None: - if not self.setup_called: - await self.setup() - - collection_key = self.make_collection_key( - collection=cache_entry.collection, key=cache_entry.key - ) - - document = json.loads(cache_entry.model_dump_json(serialize_as_any=True)) - - document["value"] = json.dumps(document["value"]) - - await self.elasticsearch_client.index( - index=self.index, - id=collection_key, - body=document, - ) - - async def delete(self, collection: str, key: str) -> None: - collection_key = self.make_collection_key(collection=collection, key=key) - - await self.elasticsearch_client.options(ignore_status=404).delete( - index=self.index, id=collection_key - ) - - async def setup(self) -> None: - if self.setup_called: - return - - async with self.setup_lock: - if self.setup_called: - return - - if await self.elasticsearch_client.options( - ignore_status=404 - ).indices.exists(index=self.index): - return - - await self.elasticsearch_client.options(ignore_status=404).indices.create( - index=self.index, - mappings=self.mapping, - ) - - self.setup_called = True - - async def clear(self) -> None: - await self.elasticsearch_client.options(ignore_status=404).indices.delete( - index=self.index, - ) - - async def cull(self) -> None: - await self.elasticsearch_client.options(ignore_status=404).delete_by_query( - index=self.index, - body={ - "query": { - "range": { - "expires_at": {"lt": datetime.now(tz=timezone.utc).timestamp()}, - }, - }, - }, - ) diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index d2cd0b4e63..35565de36c 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -2,34 +2,27 @@ import hashlib import json -from abc import ABC -from collections import defaultdict from collections.abc import Sequence -from datetime import datetime, timedelta, timezone -from typing import Any, ClassVar, Literal, Protocol, TypedDict, TypeVar, cast +from typing import Any, TypedDict, TypeVar, cast import mcp.types +from key_value.aio.adapters.pydantic import PydanticAdapter +from key_value.aio.protocols.key_value import AsyncKeyValue +from key_value.aio.stores.memory import MemoryStore +from key_value.aio.wrappers.statistics import StatisticsWrapper +from key_value.aio.wrappers.statistics.wrapper import ( + KVStoreCollectionStatistics, +) from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.types import GetPromptResult, PromptMessage -from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator -from pydantic.fields import computed_field -from pydantic.type_adapter import TypeAdapter -from typing_extensions import NotRequired, Self, overload, runtime_checkable +from mcp.types import PromptMessage +from pydantic import BaseModel, Field +from typing_extensions import NotRequired, Self, override from fastmcp.prompts.prompt import Prompt from fastmcp.resources.resource import Resource from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.tools.tool import Tool, ToolResult from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import get_cached_typeadapter - -try: - from cachetools import TLRUCache as MemoryCacheClient - from diskcache import Cache as DiskCacheClient -except ImportError: - raise ImportError( - "fastmcp[caching] is required to use the caching middleware. Please install it with `pip install fastmcp[caching]` or `uv add fastmcp[caching]`" - ) logger = get_logger(__name__) @@ -42,80 +35,24 @@ GLOBAL_KEY = "__global__" -CachableValueTypes = ( - ToolResult - | list[Tool] - | list[Resource] - | list[Prompt] - | list[ReadResourceContents] - | GetPromptResult -) - -CachableValueTypesVar = TypeVar("CachableValueTypesVar", bound=CachableValueTypes) - - -def make_collection_key(collection: str, key: str) -> str: - """For cache backends that dont support collections, we combine the collection name and key into a single string.""" - return f"{collection}:{key}" - - -class BaseCacheEntry(BaseModel, ABC): - model_config: ClassVar[ConfigDict] = ConfigDict( - frozen=True, arbitrary_types_allowed=True - ) - - created_at: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc)) - - key: str - - ttl: int - - def is_expired(self) -> bool: - return datetime.now(tz=timezone.utc) > self.expires_at - @computed_field - @property - def expires_at(self) -> datetime: - return self.created_at + timedelta(seconds=self.ttl) +class CachableToolResult(BaseModel, ToolResult): + structured_content: dict[str, Any] | None + content: list[mcp.types.ContentBlock] - -class ToolResultCacheEntry(BaseCacheEntry): - collection: Literal["tools/call"] = Field(default="tools/call") - value: ToolResult - - @field_validator("value", mode="before") @classmethod - def validate_value(cls, value: dict[str, Any] | ToolResult) -> ToolResult: - if isinstance(value, ToolResult): - return value - - content_block_type_adapter: TypeAdapter[list[mcp.types.ContentBlock]] = ( - get_cached_typeadapter(list[mcp.types.ContentBlock]) - ) - - content = content_block_type_adapter.validate_python(value.get("content")) - - structured_content = value.get("structured_content") - - return ToolResult( - content=content, - structured_content=structured_content, + def from_tool_result(cls, tool_result: ToolResult) -> Self: + return cls( + structured_content=tool_result.structured_content, + content=tool_result.content, ) - @field_serializer("value", when_used="always") - def serialize_value(self, value: ToolResult) -> dict[str, Any]: - return { - "content": [item.model_dump() for item in value.content], - "structured_content": value.structured_content, - } - - -class ListToolsCacheEntry(BaseCacheEntry): - collection: Literal["tools/list"] = Field(default="tools/list") - value: list[Tool] + def get_size(self) -> int: + return _get_size_of_tool_result(self) class CachablePrompt(Prompt): + @override async def render( self, arguments: dict[str, Any] | None = None, @@ -126,44 +63,28 @@ async def render( ) @classmethod - def from_prompt(cls, prompt: Prompt) -> Self: - return cls( - name=prompt.name, - title=prompt.title, - description=prompt.description, - arguments=prompt.arguments, - meta=prompt.meta, - tags=prompt.tags, - enabled=prompt.enabled, - ) - - -class ListPromptsCacheEntry(BaseCacheEntry): - collection: Literal["prompts/list"] = Field(default="prompts/list") - value: Sequence[Prompt] - - @field_validator("value", mode="before") - @classmethod - def validate_value( - cls, value: Sequence[dict[str, Any]] | Sequence[Prompt] - ) -> Sequence[CachablePrompt]: - results = [] - - for item in value: - if isinstance(item, Prompt): - results.append(CachablePrompt.from_prompt(prompt=item)) - else: - results.append(CachablePrompt.model_validate(item)) - - return results + def from_list_prompts(cls, prompts: list[Prompt]) -> list[Self]: + cachable_prompts: list[Self] = [] + for prompt in prompts: + cachable_prompts.append( + cls( + name=prompt.name, + title=prompt.title, + description=prompt.description, + arguments=prompt.arguments, + meta=prompt.meta, + tags=prompt.tags, + enabled=prompt.enabled, + ) + ) + return cachable_prompts -class GetPromptCacheEntry(BaseCacheEntry): - collection: Literal["prompts/get"] = Field(default="prompts/get") - value: mcp.types.GetPromptResult +class CachablePromptResult(mcp.types.GetPromptResult): ... class CachableResource(Resource): + @override async def read(self) -> str | bytes: """Read the resource content.""" raise NotImplementedError( @@ -171,274 +92,69 @@ async def read(self) -> str | bytes: ) @classmethod - def from_resource(cls, resource: Resource) -> Self: - return cls( - name=resource.name, - description=resource.description, - uri=resource.uri, - mime_type=resource.mime_type, - annotations=resource.annotations, - meta=resource.meta, - tags=resource.tags, - enabled=resource.enabled, - ) - - -class ListResourcesCacheEntry(BaseCacheEntry): - collection: Literal["resources/list"] = Field(default="resources/list") - value: Sequence[Resource] - - @field_validator("value", mode="before") - @classmethod - def validate_value( - cls, value: Sequence[dict[str, Any]] | Sequence[Resource] - ) -> Sequence[Resource]: - results = [] - for item in value: - if isinstance(item, Resource): - results.append(CachableResource.from_resource(resource=item)) - else: - results.append(CachableResource.model_validate(item)) - - return results - + def from_list_resources(cls, resources: list[Resource]) -> list[Self]: + cachable_resources: list[Self] = [] + for resource in resources: + cachable_resources.append( + cls( + name=resource.name, + description=resource.description, + uri=resource.uri, + mime_type=resource.mime_type, + annotations=resource.annotations, + meta=resource.meta, + tags=resource.tags, + enabled=resource.enabled, + ) + ) + return cachable_resources -class ReadResourceCacheEntry(BaseCacheEntry): - collection: Literal["resources/read"] = Field(default="resources/read") - value: Sequence[ReadResourceContents] - @field_validator("value", mode="before") +class CachableTool(Tool): @classmethod - def validate_value( - cls, value: Sequence[dict[str, Any]] | Sequence[ReadResourceContents] - ) -> Sequence[ReadResourceContents]: - resource_contents: list[ReadResourceContents] = [] - for item in value: - if isinstance(item, ReadResourceContents): - resource_contents.append(item) - continue - - if not isinstance(item, dict): - continue - - if not (content := item.get("content")): - continue - - mime_type = item.get("mime_type") - - resource_contents.append( - ReadResourceContents(content=content, mime_type=mime_type) + def from_list_tools(cls, tools: list[Tool]) -> list[Self]: + cachable_tools: list[Self] = [] + for tool in tools: + cachable_tools.append( + cls( + name=tool.name, + description=tool.description, + parameters=tool.parameters, + output_schema=tool.output_schema, + annotations=tool.annotations, + serializer=tool.serializer, + meta=tool.meta, + tags=tool.tags, + enabled=tool.enabled, + ) ) - - return resource_contents - - @field_serializer("value") - def serialize_value( - self, value: Sequence[ReadResourceContents] - ) -> list[dict[str, Any]]: - return [ - { - "content": item.content, - "mime_type": item.mime_type, - } - for item in value - ] + return cachable_tools -CacheEntryTypes = ( - GetPromptCacheEntry - | ListPromptsCacheEntry - | ReadResourceCacheEntry - | ListResourcesCacheEntry - | ToolResultCacheEntry - | ListToolsCacheEntry -) +class CachableReadResourceContents(BaseModel, ReadResourceContents): ... -@runtime_checkable -class CacheProtocol(Protocol): - """A protocol for a cache client.""" +class CachableToolList(BaseModel): + cachable_tools: list[CachableTool] - async def get_entry( - self, - collection: str, - key: str, - ) -> CacheEntryTypes | None: - """Get a cache entry from the cache.""" - async def set_entry( - self, - cache_entry: CacheEntryTypes, - ) -> None: - """Set a value in the cache using the collection and key.""" +class CachableResourceList(BaseModel): + cachable_resources: list[CachableResource] - async def delete( - self, - collection: str, - key: str, - ) -> None: - """Delete a value from the cache using the collection and key.""" - def make_collection_key(self, collection: str, key: str) -> str: - return f"{collection}:{key}" +class CachablePromptList(BaseModel): + cachable_prompts: list[CachablePrompt] -class DiskCache(CacheProtocol): - """A caching client that uses the DiskCache library to cache to disk.""" - - @overload - def __init__(self, *, disk_cache: DiskCacheClient): - """Initialize the disk cache with a diskcache client.""" - - @overload - def __init__(self, path: str, *, size_limit: int = ONE_GB_IN_BYTES): - """Initialize a 1GB disk cache at the provided path.""" - - def __init__( - self, - path: str | None = None, - *, - disk_cache: DiskCacheClient | None = None, - size_limit: int = ONE_GB_IN_BYTES, - ): - self._cache = disk_cache or DiskCacheClient( - directory=path, size_limit=size_limit - ) - - async def get_entry(self, collection: str, key: str) -> CacheEntryTypes | None: - collection_key: str = self.make_collection_key(collection=collection, key=key) - - cache_entry = self._cache.get(key=collection_key) - - if cache_entry is None: - return None - - return cache_entry # pyright: ignore[reportReturnType] - - async def set_entry( - self, - cache_entry: CacheEntryTypes, - ) -> None: - collection_key: str = self.make_collection_key( - collection=cache_entry.collection, key=cache_entry.key - ) - - self._cache.set(key=collection_key, value=cache_entry, expire=cache_entry.ttl) - - async def delete(self, collection: str, key: str) -> None: - collection_key = self.make_collection_key(collection=collection, key=key) - - self._cache.delete(key=collection_key) - - -DEFAULT_MEMORY_CACHE_MAX_ENTRIES = 1000 - - -def _memory_cache_ttu(_key: Any, value: BaseCacheEntry, now: float) -> float: - """TTU function for the memory cache. Determines the TTL of the cache entry.""" - return now + value.ttl - - -def _memory_cache_getsizeof(value: BaseCacheEntry) -> int: - """Getsizeof function for the memory cache. Currently measures how many entries are in the cache.""" - return 1 - - -class InMemoryCache(CacheProtocol): - """A simple in-memory cache.""" - - def __init__(self, max_entries: int = DEFAULT_MEMORY_CACHE_MAX_ENTRIES): - """Initialize the in-memory cache. - - Args: - max_entries: The maximum number of entries to store in the cache. Defaults to 1000. - """ - self.max_entries = max_entries - self._cache: MemoryCacheClient[Any, CacheEntryTypes] = MemoryCacheClient[ - Any, CacheEntryTypes - ]( - maxsize=max_entries, - ttu=_memory_cache_ttu, - getsizeof=_memory_cache_getsizeof, - ) - - async def get_entry(self, collection: str, key: str) -> CacheEntryTypes | None: - collection_key: str = self.make_collection_key(collection=collection, key=key) - - return self._cache.get(collection_key) - - async def set_entry( - self, - cache_entry: CacheEntryTypes, - ) -> None: - collection_key: str = self.make_collection_key( - collection=cache_entry.collection, key=cache_entry.key - ) - - self._cache[collection_key] = cache_entry - - async def delete(self, collection: str, key: str) -> None: - collection_key = self.make_collection_key(collection=collection, key=key) - - self._cache.pop(collection_key, None) - - async def setup(self) -> None: - return None - - async def clear(self) -> None: - self._cache.clear() - - -class CacheMethodStats(BaseModel): - """Stats for a cache method.""" - - hits: int = Field(default=0, description="The number of hits for the cache method.") - misses: int = Field( - default=0, description="The number of misses for the cache method." - ) - too_big: int = Field( - default=0, - description="The number of items that exceeded the size limit for cache entries.", - ) - - -class CacheStats(BaseModel): - """Stats for the cache.""" - - collections: dict[str, CacheMethodStats] = Field( - default_factory=lambda: defaultdict(CacheMethodStats), - description="Stats are organized by collection (method).", - ) - - def get_misses(self, collection: str) -> int: - """Get the number of misses for a collection.""" - return self.collections[collection].misses - - def get_hits(self, collection: str) -> int: - """Get the number of hits for a collection.""" - return self.collections[collection].hits - - def get_too_big(self, collection: str) -> int: - """Get the number of items that exceeded the size limit for a collection.""" - return self.collections[collection].too_big - - def mark_miss(self, collection: str) -> None: - """Mark a miss for a collection.""" - self.collections[collection].misses += 1 - - def mark_hit(self, collection: str) -> None: - """Mark a hit for a collection.""" - self.collections[collection].hits += 1 - - def mark_too_big(self, collection: str) -> None: - """Mark a too big for a collection.""" - self.collections[collection].too_big += 1 +class CachableReadResourceContentsList(BaseModel): + cachable_read_resource_contents: list[CachableReadResourceContents] class SharedMethodSettings(TypedDict): """Shared config for a cache method.""" ttl: NotRequired[int] + enabled: NotRequired[bool] class ListToolsSettings(SharedMethodSettings): @@ -483,15 +199,6 @@ class MethodSettings(TypedDict): MethodSettingsType = TypeVar("MethodSettingsType", bound=SharedMethodSettings) -MCP_METHOD_TO_CACHE_ENTRY_TYPE: dict[str, type[CacheEntryTypes]] = { - "tools/list": ListToolsCacheEntry, - "tools/call": ToolResultCacheEntry, - "resources/list": ListResourcesCacheEntry, - "resources/read": ReadResourceCacheEntry, - "prompts/list": ListPromptsCacheEntry, - "prompts/get": GetPromptCacheEntry, -} - MCP_METHOD_TO_METHOD_SETTINGS_KEY = { "tools/list": "list_tools", @@ -524,6 +231,15 @@ class MethodSettings(TypedDict): ) +class ResponseCachingStatistics(BaseModel): + list_tools: KVStoreCollectionStatistics | None = Field(default=None) + list_resources: KVStoreCollectionStatistics | None = Field(default=None) + list_prompts: KVStoreCollectionStatistics | None = Field(default=None) + read_resource: KVStoreCollectionStatistics | None = Field(default=None) + get_prompt: KVStoreCollectionStatistics | None = Field(default=None) + call_tool: KVStoreCollectionStatistics | None = Field(default=None) + + class ResponseCachingMiddleware(Middleware): """The response caching middleware offers a simple way to cache responses to mcp methods. The Middleware supports cache invalidation via notifications from the server. The Middleware implements TTL-based caching @@ -539,7 +255,7 @@ class ResponseCachingMiddleware(Middleware): def __init__( self, - cache_backend: CacheProtocol | None = None, + cache_store: AsyncKeyValue | None = None, method_settings: MethodSettings | None = None, default_ttl: int = ONE_HOUR_IN_SECONDS, max_item_size: int | None = None, @@ -553,15 +269,56 @@ def __init__( max_item_size: The maximum size of an item to cache. Defaults to no size limit. """ self._default_ttl: int = default_ttl - self._backend: CacheProtocol = cache_backend or InMemoryCache() - self._max_item_size: int | None = max_item_size + self._backend: AsyncKeyValue = cache_store or MemoryStore() + self._stats: StatisticsWrapper = StatisticsWrapper(store=self._backend) - self._stats = CacheStats() + self._max_item_size: int | None = max_item_size self.method_settings: MethodSettings = ( method_settings or DEFAULT_METHOD_SETTINGS ) + self._list_tools_cache: PydanticAdapter[CachableToolList] = PydanticAdapter( + key_value=self._stats, + pydantic_model=CachableToolList, + default_collection="tools/list", + ) + + self._list_resources_cache: PydanticAdapter[CachableResourceList] = ( + PydanticAdapter( + key_value=self._stats, + pydantic_model=CachableResourceList, + default_collection="resources/list", + ) + ) + + self._list_prompts_cache: PydanticAdapter[CachablePromptList] = PydanticAdapter( + key_value=self._stats, + pydantic_model=CachablePromptList, + default_collection="prompts/list", + ) + + self._read_resource_cache: PydanticAdapter[CachableReadResourceContentsList] = ( + PydanticAdapter( + key_value=self._stats, + pydantic_model=CachableReadResourceContentsList, + default_collection="resources/read", + ) + ) + + self._get_prompt_cache: PydanticAdapter[CachablePromptResult] = PydanticAdapter( + key_value=self._stats, + pydantic_model=CachablePromptResult, + default_collection="prompts/get", + ) + + self._call_tool_cache: PydanticAdapter[CachableToolResult] = PydanticAdapter( + key_value=self._stats, + pydantic_model=CachableToolResult, + default_collection="tools/call", + ) + + @override async def on_list_tools( self, context: MiddlewareContext[mcp.types.ListToolsRequest], @@ -570,8 +327,21 @@ async def on_list_tools( if self._should_bypass_caching(context=context): return await call_next(context=context) - return await self._cached_call_next(context=context, call_next=call_next) + if cached_value := await self._list_tools_cache.get(key=GLOBAL_KEY): + return cached_value.cachable_tools + + value: list[Tool] = await call_next(context=context) + cachable_tools: list[CachableTool] = CachableTool.from_list_tools(tools=value) + + await self._list_tools_cache.put( + key=GLOBAL_KEY, + value=CachableToolList(cachable_tools=cachable_tools), + ) + + return cachable_tools + + @override async def on_list_resources( self, context: MiddlewareContext[mcp.types.ListResourcesRequest], @@ -582,8 +352,23 @@ async def on_list_resources( if self._should_bypass_caching(context=context): return await call_next(context) - return await self._cached_call_next(context=context, call_next=call_next) + if cached_value := await self._list_resources_cache.get(key=GLOBAL_KEY): + return cached_value.cachable_resources + + value: list[Resource] = await call_next(context=context) + + cachable_resources: list[CachableResource] = ( + CachableResource.from_list_resources(resources=value) + ) + await self._list_resources_cache.put( + key=GLOBAL_KEY, + value=CachableResourceList(cachable_resources=cachable_resources), + ) + + return cachable_resources + + @override async def on_list_prompts( self, context: MiddlewareContext[mcp.types.ListPromptsRequest], @@ -594,8 +379,23 @@ async def on_list_prompts( if self._should_bypass_caching(context=context): return await call_next(context) - return await self._cached_call_next(context=context, call_next=call_next) + if cached_value := await self._list_prompts_cache.get(key=GLOBAL_KEY): + return cached_value.cachable_prompts + + value: list[Prompt] = await call_next(context=context) + + cachable_prompts: list[CachablePrompt] = CachablePrompt.from_list_prompts( + prompts=value + ) + await self._list_prompts_cache.put( + key=GLOBAL_KEY, + value=CachablePromptList(cachable_prompts=cachable_prompts), + ) + + return cachable_prompts + + @override async def on_call_tool( self, context: MiddlewareContext[mcp.types.CallToolRequestParams], @@ -609,12 +409,28 @@ async def on_call_tool( if not self._matches_tool_cache_settings(context=context): return await call_next(context=context) - return await self._cached_call_next( - context=context, - call_next=call_next, + if cached_value := await self._call_tool_cache.get( + key=_make_call_tool_cache_key(msg=context.message) + ): + return cached_value + + tool_result: ToolResult = await call_next(context=context) + + cachable_value: CachableToolResult = CachableToolResult.from_tool_result( + tool_result=tool_result + ) + + if self._max_item_size and cachable_value.get_size() > self._max_item_size: + return tool_result + + await self._call_tool_cache.put( key=_make_call_tool_cache_key(msg=context.message), + value=cachable_value, ) + return cachable_value + + @override async def on_read_resource( self, context: MiddlewareContext[mcp.types.ReadResourceRequestParams], @@ -627,12 +443,28 @@ async def on_read_resource( if self._should_bypass_caching(context=context): return await call_next(context=context) - return await self._cached_call_next( - context=context, - call_next=call_next, + if cached_value := await self._read_resource_cache.get( + key=_make_read_resource_cache_key(msg=context.message) + ): + return cached_value.cachable_read_resource_contents + + value: list[ReadResourceContents] = await call_next(context=context) + + cachable_read_resource_contents: list[CachableReadResourceContents] = [ + CachableReadResourceContents(content=item.content, mime_type=item.mime_type) + for item in value + ] + + await self._read_resource_cache.put( key=_make_read_resource_cache_key(msg=context.message), + value=CachableReadResourceContentsList( + cachable_read_resource_contents=cachable_read_resource_contents + ), ) + return cachable_read_resource_contents + + @override async def on_get_prompt( self, context: MiddlewareContext[mcp.types.GetPromptRequestParams], @@ -645,119 +477,42 @@ async def on_get_prompt( if self._should_bypass_caching(context=context): return await call_next(context) - return await self._cached_call_next( - context=context, - call_next=call_next, + if cached_value := await self._get_prompt_cache.get( + key=_make_get_prompt_cache_key(msg=context.message) + ): + return cached_value + + value: mcp.types.GetPromptResult = await call_next(context=context) + + cachable_value: CachablePromptResult = CachablePromptResult( + messages=value.messages, description=value.description, _meta=value.meta + ) + + await self._get_prompt_cache.put( key=_make_get_prompt_cache_key(msg=context.message), + value=cachable_value, ) + return cachable_value + + @override async def on_notification( self, - context: MiddlewareContext[mcp.types.Notification], - call_next: CallNext[mcp.types.Notification, Any], + context: MiddlewareContext[mcp.types.Notification[Any, Any]], + call_next: CallNext[mcp.types.Notification[Any, Any], Any], ) -> Any: """Handle a notification from the server. If the notification is a tool/resource/prompt list changed notification, delete the cache for the affected method.""" if isinstance(context.message, mcp.types.ToolListChangedNotification): - collection = "tools/list" + _ = await self._list_tools_cache.delete(key=GLOBAL_KEY) elif isinstance(context.message, mcp.types.ResourceListChangedNotification): - collection = "resources/list" + _ = await self._list_resources_cache.delete(key=GLOBAL_KEY) elif isinstance(context.message, mcp.types.PromptListChangedNotification): - collection = "prompts/list" + _ = await self._list_prompts_cache.delete(key=GLOBAL_KEY) else: - collection = None - - if collection: - await self._backend.delete(collection=collection, key=GLOBAL_KEY) - - return await call_next(context) - - async def _cached_call_next( - self, - context: MiddlewareContext[Any], - call_next: CallNext[Any, CachableValueTypesVar], - key: str | None = None, - ) -> CachableValueTypesVar: - """Perform the cached lookup, if the result is not in the cache, call the next middleware and return - the result.""" - - if key is None: - key = GLOBAL_KEY - - if cached_value := await self._get_cache( - context=context, - call_next=call_next, - key=key, - ): - return cached_value + pass - result: CachableValueTypesVar = await call_next(context) - - return await self._store_in_cache_and_return( - context=context, - key=key, - value=result, - ) - - async def _get_cache( - self, - context: MiddlewareContext[Any], - call_next: CallNext[Any, CachableValueTypesVar], - key: str | None = None, - ) -> CachableValueTypesVar | None: - """Get a value from the cache and update the cache stats.""" - - if key is None: - key = GLOBAL_KEY - - if not (collection := context.method): - logger.warning("No method found on context, skipping cache") - return None - - if cached_entry := await self._backend.get_entry( - collection=collection, key=key - ): - self._stats.mark_hit(collection=collection) - return cast(CachableValueTypesVar, cached_entry.value) - - self._stats.mark_miss(collection=collection) - - return None - - async def _store_in_cache_and_return( - self, - context: MiddlewareContext[Any], - key: str | None, - value: CachableValueTypesVar, - ) -> CachableValueTypesVar: - """Store a value in the cache (if it's not too big) with the appropriate TTL.""" - - if key is None: - key = GLOBAL_KEY - - if not (collection := context.method): - logger.warning("No method found on context, skipping cache") - return value - - if self._max_item_size is not None: - if get_size_of_value(value=value) > self._max_item_size: - self._stats.mark_too_big(collection=collection) - return value - - ttl: int = self._get_cache_ttl(context=context) - - cache_entry: CacheEntryTypes = MCP_METHOD_TO_CACHE_ENTRY_TYPE[collection]( - collection=collection, # pyright: ignore[reportArgumentType] - key=key, - value=value, # pyright: ignore[reportArgumentType] - ttl=ttl, - ) - - await self._backend.set_entry( - cache_entry=cache_entry, - ) - - return value + return await call_next(context=context) def _matches_tool_cache_settings( self, context: MiddlewareContext[mcp.types.CallToolRequestParams] @@ -810,7 +565,7 @@ def _get_cache_ttl(self, context: MiddlewareContext[Any]) -> int: """Get the cache TTL for a method.""" settings: SharedMethodSettings | None = self._get_cache_settings( - context=context + context=context, ) if not settings or "ttl" not in settings: @@ -821,11 +576,24 @@ def _get_cache_ttl(self, context: MiddlewareContext[Any]) -> int: def _should_bypass_caching(self, context: MiddlewareContext[Any]) -> bool: """Check if the method should bypass caching.""" - if not self._get_cache_settings(context=context): + if not (cache_settings := self._get_cache_settings(context=context)): + return True + + if cache_settings.get("enabled") is False: return True return False + def statistics(self) -> ResponseCachingStatistics: + return ResponseCachingStatistics( + list_tools=self._stats.statistics.collections.get("tools/list"), + list_resources=self._stats.statistics.collections.get("resources/list"), + list_prompts=self._stats.statistics.collections.get("prompts/list"), + read_resource=self._stats.statistics.collections.get("resources/read"), + get_prompt=self._stats.statistics.collections.get("prompts/get"), + call_tool=self._stats.statistics.collections.get("tools/call"), + ) + def _make_call_tool_cache_key(msg: mcp.types.CallToolRequestParams) -> str: """Make a cache key for a tool call by hashing the tool name and its arguments.""" @@ -861,7 +629,7 @@ def _get_arguments_str(arguments: dict[str, Any] | None) -> str: return repr(arguments) -def get_size_of_content_blocks( +def _get_size_of_content_blocks( value: mcp.types.ContentBlock | Sequence[mcp.types.ContentBlock], ) -> int: """Get the size of a series of content blocks by summing the size of the JSON representation of each block.""" @@ -872,10 +640,10 @@ def get_size_of_content_blocks( return sum([len(item.model_dump_json()) for item in value]) -def get_size_of_tool_result(value: ToolResult) -> int: +def _get_size_of_tool_result(value: ToolResult) -> int: """Get the size of a tool result by summing the size of the content blocks and the size of the structured content.""" - content_size = get_size_of_content_blocks(value.content) + content_size = _get_size_of_content_blocks(value.content) structured_content_size = len( json.dumps( value.structured_content, sort_keys=True, separators=(",", ":") @@ -885,19 +653,19 @@ def get_size_of_tool_result(value: ToolResult) -> int: return content_size + structured_content_size -def get_size_of_one_value(value: BaseModel | ToolResult | ReadResourceContents) -> int: - """Get the size of an mcp type.""" +# def get_size_of_one_value(value: BaseModel | ToolResult | ReadResourceContents) -> int: +# """Get the size of an mcp type.""" - if isinstance(value, ToolResult): - return get_size_of_tool_result(value) - if isinstance(value, ReadResourceContents): - return len(value.content) - return len(value.model_dump_json()) +# if isinstance(value, ToolResult): +# return get_size_of_tool_result(value) +# if isinstance(value, ReadResourceContents): +# return len(value.content) +# return len(value.model_dump_json()) -def get_size_of_value(value: CachableValueTypes) -> int: - """Get the size of a cache entry.""" - if isinstance(value, BaseModel | ToolResult | ReadResourceContents): - return get_size_of_one_value(value) +# def get_size_of_value(value: CachableListTypes) -> int: +# """Get the size of a cache entry.""" +# if isinstance(value, (BaseModel | ToolResult | ReadResourceContents)): +# return get_size_of_one_value(value) - return sum(get_size_of_one_value(item) for item in value) +# return sum(get_size_of_one_value(item) for item in value) diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index 52cb2b96d2..fd7701ae39 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -8,8 +8,10 @@ import mcp.types import pytest from inline_snapshot import snapshot +from key_value.aio.stores.disk import DiskStore +from key_value.aio.stores.memory import MemoryStore from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.types import GetPromptResult, PromptMessage, TextContent, TextResourceContents +from mcp.types import PromptMessage, TextContent, TextResourceContents from pydantic import AnyUrl, BaseModel from fastmcp import FastMCP @@ -18,23 +20,9 @@ from fastmcp.prompts.prompt import FunctionPrompt, Prompt from fastmcp.resources.resource import Resource from fastmcp.server.middleware.caching import ( - BaseCacheEntry, - CachablePrompt, - CachableResource, - CacheMethodStats, - CacheProtocol, - CacheStats, CallToolSettings, - DiskCache, - GetPromptCacheEntry, - InMemoryCache, - ListPromptsCacheEntry, - ListResourcesCacheEntry, - ListToolsCacheEntry, MethodSettings, - ReadResourceCacheEntry, ResponseCachingMiddleware, - ToolResultCacheEntry, ) from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext from fastmcp.tools.tool import Tool, ToolResult @@ -147,11 +135,13 @@ class TrackingCalculator: add_calls: int multiply_calls: int crazy_calls: int + very_large_response_calls: int def __init__(self): self.add_calls = 0 self.multiply_calls = 0 self.crazy_calls = 0 + self.very_large_response_calls = 0 def add(self, a: int, b: int) -> int: self.add_calls += 1 @@ -161,6 +151,10 @@ def multiply(self, a: int, b: int) -> int: self.multiply_calls += 1 return a * b + def very_large_response(self) -> str: + self.very_large_response_calls += 1 + return "istenchars" * 100000 # 1,000,000 characters, 1mb + def crazy(self, a: CrazyModel) -> CrazyModel: self.crazy_calls += 1 return a @@ -183,6 +177,11 @@ def add_tools(self, fastmcp: FastMCP, prefix: str = ""): tool=Tool.from_function(fn=self.multiply, name=f"{prefix}multiply") ) fastmcp.add_tool(tool=Tool.from_function(fn=self.crazy, name=f"{prefix}crazy")) + fastmcp.add_tool( + tool=Tool.from_function( + fn=self.very_large_response, name=f"{prefix}very_large_response" + ) + ) def add_prompts(self, fastmcp: FastMCP, prefix: str = ""): fastmcp.add_prompt( @@ -251,365 +250,6 @@ def sample_tool_result() -> ToolResult: ) -class TestCacheEntry: - """Test CacheEntry class functionality.""" - - def test_init_and_expiration(self): - """Test cache entry initialization and expiration logic.""" - entry: ToolResultCacheEntry[ToolResult] = ToolResultCacheEntry( - key="test_key", - value=ToolResult( - content=[{"type": "text", "text": "success"}], - structured_content={"result": "success"}, - ), - ttl=3600, - ) - - assert entry.key == "test_key" - assert not entry.is_expired() - - # Test expired entry - expired_entry: ToolResultCacheEntry[ToolResult] = ToolResultCacheEntry( - key="test_key", - value=ToolResult( - content=[{"type": "text", "text": "success"}], - structured_content={"result": "success"}, - ), - ttl=-1, - ) - - assert expired_entry.is_expired() - - def test_tool_result_cache_entry_validation(self): - """Test ToolResultCacheEntry class functionality.""" - entry: ToolResultCacheEntry[ToolResult] = ToolResultCacheEntry( - key="test_key", - value=SAMPLE_TOOL_RESULT, - ttl=3600, - ) - - dumped_entry = entry.model_dump() - new_entry = ToolResultCacheEntry.model_validate(dumped_entry) - - assert dump_mcp_types(model=new_entry.value.content) == snapshot( - [{"type": "text", "text": "test_text", "annotations": None, "meta": None}] - ) - assert new_entry.value.structured_content == snapshot({"result": "test_result"}) - - def test_read_resource_cache_entry_validation(self): - """Test ReadResourceCacheEntry class functionality.""" - entry: ReadResourceCacheEntry[list[ReadResourceContents]] = ( - ReadResourceCacheEntry( - key="test_key", - value=[SAMPLE_READ_RESOURCE_CONTENTS], - ttl=3600, - ) - ) - - dumped_entry = entry.model_dump() - new_entry = ReadResourceCacheEntry.model_validate(dumped_entry) - - assert new_entry.value == snapshot( - [ReadResourceContents(content="test_text", mime_type="text/plain")] - ) - - def test_get_prompt_cache_entry_validation(self): - """Test GetPromptCacheEntry class functionality.""" - entry: GetPromptCacheEntry = GetPromptCacheEntry( - key="test_key", - value=SAMPLE_GET_PROMPT_RESULT, - ttl=3600, - ) - - dumped_entry = entry.model_dump() - new_entry = GetPromptCacheEntry.model_validate(dumped_entry) - - assert new_entry.value == snapshot( - GetPromptResult( - messages=[ - PromptMessage( - role="user", content=TextContent(type="text", text="test_text") - ) - ] - ) - ) - - def test_list_tools_cache_entry_validation(self): - """Test ListToolsCacheEntry class functionality.""" - entry: ListToolsCacheEntry = ListToolsCacheEntry( - key="test_key", - value=[SAMPLE_TOOL], - ttl=3600, - ) - - dumped_entry = entry.model_dump() - new_entry = ListToolsCacheEntry.model_validate(dumped_entry) - - assert new_entry.value == snapshot( - [Tool(name="test_tool", parameters={"param1": "value1", "param2": 42})] - ) - - def test_list_resources_cache_entry_validation(self): - """Test ListResourcesCacheEntry class functionality.""" - entry: ListResourcesCacheEntry = ListResourcesCacheEntry( - key="test_key", - value=[SAMPLE_RESOURCE], - ttl=3600, - ) - - dumped_entry = entry.model_dump() - new_entry = ListResourcesCacheEntry.model_validate(dumped_entry) - - assert new_entry.value == snapshot( - [CachableResource(name="test_resource", uri=AnyUrl("https://test_uri/"))] - ) - - def test_list_prompts_cache_entry_validation(self): - """Test ListPromptsCacheEntry class functionality.""" - entry: ListPromptsCacheEntry = ListPromptsCacheEntry( - key="test_key", - value=[SAMPLE_PROMPT], - ttl=3600, - ) - - dumped_entry = entry.model_dump() - new_entry = ListPromptsCacheEntry.model_validate(dumped_entry) - - assert new_entry.value == snapshot( - [CachablePrompt(name="test_prompt", arguments=[])] - ) - - -class TestMemoryCache: - """Test InMemoryCache implementation.""" - - async def test_size_limit(self, sample_tool_result): - """Test cache size limit enforcement.""" - cache = InMemoryCache(max_entries=2) - - # Fill cache to capacity - await cache.set_entry( - cache_entry=ToolResultCacheEntry( - key="key1", value=sample_tool_result, ttl=3600 - ) - ) - await cache.set_entry( - cache_entry=ToolResultCacheEntry( - key="key2", value=sample_tool_result, ttl=3600 - ) - ) - - # Add one more - should evict the first - await cache.set_entry( - cache_entry=ToolResultCacheEntry( - key="key3", value=sample_tool_result, ttl=3600 - ) - ) - - assert len(cache._cache) == 2 - assert "tools/call:key1" not in cache._cache - assert "tools/call:key2" in cache._cache - assert "tools/call:key3" in cache._cache - - -class TestCacheImplementations: - """Test InMemoryCache implementation.""" - - @pytest.fixture(params=["memory", "disk"]) - async def cache(self, request): - if request.param == "memory": - return InMemoryCache() - else: - with tempfile.TemporaryDirectory() as temp_dir: - return DiskCache(path=temp_dir) - - async def test_get_none_if_not_set(self, cache: CacheProtocol): - """Test that we get None if a value is not set.""" - assert await cache.get_entry(collection="tools/call", key="test_key") is None - - async def test_list_tools(self, cache: CacheProtocol): - """Test that we can list tools from the cache.""" - await cache.set_entry( - cache_entry=ListToolsCacheEntry( - key="test_key", value=[SAMPLE_TOOL], ttl=3600 - ) - ) - result = await cache.get_entry(collection="tools/list", key="test_key") - - assert result is not None - assert dump_mcp_types(model=result.value) == snapshot( - [ - { - "name": "test_tool", - "title": None, - "description": None, - "tags": set(), - "meta": None, - "enabled": True, - "parameters": {"param1": "value1", "param2": 42}, - "output_schema": None, - "annotations": None, - "serializer": None, - } - ] - ) - - async def test_tool_result(self, cache: CacheProtocol): - """Test that we can get a tool result from the cache.""" - await cache.set_entry( - cache_entry=ToolResultCacheEntry( - key="test_key", value=SAMPLE_TOOL_RESULT, ttl=3600 - ) - ) - result = await cache.get_entry(collection="tools/call", key="test_key") - - assert result is not None - assert dump_mcp_types(model=result.value) == snapshot( - { - "content": [ - { - "type": "text", - "text": "test_text", - "annotations": None, - "meta": None, - } - ], - "structured_content": {"result": "test_result"}, - } - ) - - async def test_list_resources(self, cache: CacheProtocol): - """Test that we can list resources from the cache.""" - await cache.set_entry( - cache_entry=ListResourcesCacheEntry( - key="test_key", value=[SAMPLE_RESOURCE], ttl=3600 - ) - ) - result = await cache.get_entry(collection="resources/list", key="test_key") - - assert result is not None - assert dump_mcp_types(model=result.value) == snapshot( - [ - { - "name": "test_resource", - "title": None, - "description": None, - "tags": set(), - "meta": None, - "enabled": True, - "uri": AnyUrl("https://test_uri/"), - "mime_type": "text/plain", - "annotations": None, - } - ] - ) - - async def test_read_resource(self, cache: CacheProtocol): - """Test that we can read a resource from the cache.""" - await cache.set_entry( - cache_entry=ReadResourceCacheEntry( - key="test_key", value=[SAMPLE_READ_RESOURCE_CONTENTS], ttl=3600 - ) - ) - result = await cache.get_entry(collection="resources/read", key="test_key") - - assert result is not None - assert dump_mcp_types(model=result.value) == snapshot( - [{"content": "test_text", "mime_type": "text/plain"}] - ) - - async def test_list_prompts(self, cache: CacheProtocol): - """Test that we can list prompts from the cache.""" - await cache.set_entry( - cache_entry=ListPromptsCacheEntry( - key="test_key", value=[SAMPLE_PROMPT], ttl=3600 - ) - ) - result = await cache.get_entry(collection="prompts/list", key="test_key") - - assert result is not None - assert dump_mcp_types(model=result.value) == snapshot( - [ - { - "name": "test_prompt", - "title": None, - "description": None, - "tags": set(), - "meta": None, - "enabled": True, - "arguments": [], - } - ] - ) - - async def test_get_prompt(self, cache: CacheProtocol): - """Test that we can get a prompt from the cache.""" - entry = GetPromptCacheEntry( - key="test_key", value=SAMPLE_GET_PROMPT_RESULT, ttl=3600 - ) - await cache.set_entry(cache_entry=entry) - result = await cache.get_entry(collection=entry.collection, key="test_key") - - assert result is not None - assert dump_mcp_types(model=result.value) == snapshot( - { - "meta": None, - "description": None, - "messages": [ - { - "role": "user", - "content": { - "type": "text", - "text": "test_text", - "annotations": None, - "meta": None, - }, - } - ], - } - ) - - async def test_set_get_delete_get_value(self, cache: CacheProtocol): - """Test that we can set, get, delete, and get a value from the cache.""" - await cache.set_entry( - cache_entry=ToolResultCacheEntry( - key="test_key", - value=SAMPLE_TOOL_RESULT, - ttl=3600, - ) - ) - result: BaseCacheEntry | None = await cache.get_entry( - collection="tools/call", key="test_key" - ) - - assert result is not None - assert dump_mcp_types(model=result.value) == dump_mcp_types( - model=SAMPLE_TOOL_RESULT - ) - - await cache.delete(collection="tools/call", key="test_key") - - assert await cache.get_entry(collection="tools/call", key="test_key") is None - - async def test_expiration_and_cleanup(self, cache: CacheProtocol): - """Test cache expiration and cleanup.""" - # Create an expired entry - await cache.set_entry( - cache_entry=ToolResultCacheEntry( - key="expired_key", - value=SAMPLE_TOOL_RESULT, - ttl=-1, - ) - ) - - # Should return None and remove expired entry - result = await cache.get_entry(collection="tools/call", key="expired_key") - - assert result is None - - assert await cache.get_entry(collection="tools/call", key="expired_key") is None - - class TestResponseCachingMiddleware: """Test ResponseCachingMiddleware functionality.""" @@ -676,21 +316,42 @@ def test_tool_call_filtering( is result ) - async def test_large_value(self): - """Test that we can set and get a large value.""" - cache = InMemoryCache() - middleware = ResponseCachingMiddleware(cache, max_item_size=100) + def test_method_settings(self): + """Test method TTL.""" + middleware = ResponseCachingMiddleware( + method_settings={ + "list_tools": {"ttl": 100}, + "call_tool": {"enabled": False}, + }, + default_ttl=1000, + ) + + tool_list_settings = middleware._get_cache_settings( + context=MiddlewareContext(method="tools/list", message=MagicMock()) + ) + assert tool_list_settings == {"ttl": 100} - await middleware._store_in_cache_and_return( - context=MiddlewareContext( - method="tools/call", - message=mcp.types.CallToolRequestParams(name="test_tool"), - ), - key="test_key", - value=SAMPLE_TOOL_RESULT_LARGE, + call_tool_settings = middleware._get_cache_settings( + context=MiddlewareContext(method="tools/call", message=MagicMock()) ) + assert call_tool_settings == {"enabled": False} - assert middleware._stats.get_too_big("tools/call") == 1 + other_methods = [ + "resources/list", + "prompts/list", + "resources/read", + "prompts/get", + ] + for method in other_methods: + cache_settings = middleware._get_cache_settings( + context=MiddlewareContext(method=method, message=MagicMock()) + ) + assert cache_settings is None + + should_bypass = middleware._should_bypass_caching( + context=MiddlewareContext(method=method, message=MagicMock()) + ) + assert should_bypass def test_cache_key_generation(self): """Test cache key generation.""" @@ -734,50 +395,11 @@ def test_cache_key_generation(self): "6306ff84fd3ff247a4bd91271e9d727d7f051bba53fb2e3bf80958988c4baf57" ) - async def test_cache_miss_and_hit( - self, - ): - """Test cache miss and hit scenarios.""" - middleware = ResponseCachingMiddleware() - - mock_call_next = AsyncMock( - return_value=ToolResult( - content=[{"type": "text", "text": "test result"}], - structured_content={"result": "success", "value": 123}, - ) - ) - - mock_context = MagicMock( - spec=MiddlewareContext[mcp.types.CallToolRequestParams] - ) - mock_context.message = mcp.types.CallToolRequestParams( - name="test_tool", arguments={"param1": "value1", "param2": 42} - ) - mock_context.method = "tools/call" - - # First call - cache miss - result1 = await middleware.on_call_tool( - context=mock_context, call_next=mock_call_next - ) - assert middleware._stats.get_misses("tools/call") == 1 - assert middleware._stats.get_hits("tools/call") == 0 - - # Second call - cache hit - mock_call_next.reset_mock() - result2 = await middleware.on_call_tool( - context=mock_context, call_next=mock_call_next - ) - - assert result1.content == result2.content - assert not mock_call_next.called # Should not call downstream - assert middleware._stats.get_hits("tools/call") == 1 - assert middleware._stats.get_misses("tools/call") == 1 - class TestResponseCachingMiddlewareIntegration: """Integration tests with real FastMCP server.""" - @pytest.fixture(params=["memory", "disk", "elasticsearch"]) + @pytest.fixture(params=["memory", "disk"]) async def caching_server( self, tracking_calculator: TrackingCalculator, @@ -787,10 +409,10 @@ async def caching_server( mcp = FastMCP("CachingTestServer") with tempfile.TemporaryDirectory() as temp_dir: + disk_store = DiskStore(directory=temp_dir) response_caching_middleware = ResponseCachingMiddleware( - cache_backend=DiskCache(path=temp_dir) - if request.param == "disk" - else InMemoryCache() + cache_store=disk_store if request.param == "disk" else MemoryStore(), + max_item_size=100000, # 100kb ) mcp.add_middleware(middleware=response_caching_middleware) @@ -801,6 +423,8 @@ async def caching_server( yield mcp + await disk_store.close() + @pytest.fixture def non_caching_server(self, tracking_calculator: TrackingCalculator): """Create a FastMCP server for non-caching tests.""" @@ -815,7 +439,7 @@ async def test_list_tools( async with Client(caching_server) as client: pre_tool_list: list[mcp.types.Tool] = await client.list_tools() - assert len(pre_tool_list) == 3 + assert len(pre_tool_list) == 4 # Add a tool and make sure it's missing from the list tool response caching_server.add_tool( @@ -823,7 +447,7 @@ async def test_list_tools( ) post_tool_list: list[mcp.types.Tool] = await client.list_tools() - assert len(post_tool_list) == 3 + assert len(post_tool_list) == 4 assert pre_tool_list == post_tool_list @@ -846,6 +470,26 @@ async def test_call_tool( ) assert call_tool_result_one == call_tool_result_two + async def test_call_tool_very_large_value( + self, + caching_server: FastMCP, + tracking_calculator: TrackingCalculator, + ): + """Test that caching works with a real FastMCP server.""" + tracking_calculator.add_tools(fastmcp=caching_server) + + async with Client[FastMCPTransport](caching_server) as client: + call_tool_result_one: CallToolResult = await client.call_tool( + "very_large_response", {} + ) + + assert tracking_calculator.very_large_response_calls == 1 + call_tool_result_two: CallToolResult = await client.call_tool( + "very_large_response", {} + ) + assert call_tool_result_one == call_tool_result_two + assert tracking_calculator.very_large_response_calls == 2 + async def test_list_resources( self, caching_server: FastMCP, tracking_calculator: TrackingCalculator ): @@ -918,21 +562,3 @@ async def test_get_prompts( ) assert pre_prompt == post_prompt - - -class TestCacheStats: - """Test CacheStats functionality.""" - - def test_stats_initialization(self): - """Test cache stats initialization.""" - stats = CacheStats( - collections={ - "tools/call": CacheMethodStats(hits=5, misses=10), - "tools/list": CacheMethodStats(hits=0, misses=0), - } - ) - - assert stats.get_hits("tools/call") == 5 - assert stats.get_misses("tools/call") == 10 - assert stats.get_hits("tools/list") == 0 - assert stats.get_misses("tools/list") == 0 diff --git a/uv.lock b/uv.lock index bdb524bacb..119144496c 100644 --- a/uv.lock +++ b/uv.lock @@ -762,7 +762,7 @@ requires-dist = [ { name = "rich", specifier = ">=13.9.4" }, { name = "websockets", specifier = ">=15.0.1" }, ] -provides-extras = ["openai"] +provides-extras = ["caching", "contrib-middleware-elasticsearch-cache", "openai"] [package.metadata.requires-dev] dev = [ From af6b55a4ff03ca80141911601cfd6556cac8a4e9 Mon Sep 17 00:00:00 2001 From: William Easton Date: Fri, 10 Oct 2025 19:36:40 -0400 Subject: [PATCH 11/15] Update dependencies --- pyproject.toml | 4 - uv.lock | 562 +------------------------------------------------ 2 files changed, 2 insertions(+), 564 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ec3d6b7a5d..8ec41cf8bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,15 +44,11 @@ classifiers = [ [project.optional-dependencies] openai = ["openai>=1.102.0"] -caching = ["diskcache>=5.6.3", "cachetools>=6.2.0"] -contrib-middleware-elasticsearch_cache = ["fastmcp[caching]", "elasticsearch>=8.15.0", "aiohttp>=3.11.10"] [dependency-groups] dev = [ "dirty-equals>=0.9.0", "fastmcp[openai]", - "fastmcp[caching]", - "fastmcp[contrib-middleware-elasticsearch_cache]", # add optional dependencies for fastmcp dev "fastapi>=0.115.12", "inline-snapshot[dirty-equals]>=0.27.2", diff --git a/uv.lock b/uv.lock index 119144496c..793a40a6eb 100644 --- a/uv.lock +++ b/uv.lock @@ -6,114 +6,6 @@ resolution-markers = [ "python_full_version < '3.11'", ] -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.12.15" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11'" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/dc/ef9394bde9080128ad401ac7ede185267ed637df03b51f05d14d1c99ad67/aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc", size = 703921, upload-time = "2025-07-29T05:49:43.584Z" }, - { url = "https://files.pythonhosted.org/packages/8f/42/63fccfc3a7ed97eb6e1a71722396f409c46b60a0552d8a56d7aad74e0df5/aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af", size = 480288, upload-time = "2025-07-29T05:49:47.851Z" }, - { url = "https://files.pythonhosted.org/packages/9c/a2/7b8a020549f66ea2a68129db6960a762d2393248f1994499f8ba9728bbed/aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421", size = 468063, upload-time = "2025-07-29T05:49:49.789Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f5/d11e088da9176e2ad8220338ae0000ed5429a15f3c9dfd983f39105399cd/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79", size = 1650122, upload-time = "2025-07-29T05:49:51.874Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6b/b60ce2757e2faed3d70ed45dafee48cee7bfb878785a9423f7e883f0639c/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77", size = 1624176, upload-time = "2025-07-29T05:49:53.805Z" }, - { url = "https://files.pythonhosted.org/packages/dd/de/8c9fde2072a1b72c4fadecf4f7d4be7a85b1d9a4ab333d8245694057b4c6/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c", size = 1696583, upload-time = "2025-07-29T05:49:55.338Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ad/07f863ca3d895a1ad958a54006c6dafb4f9310f8c2fdb5f961b8529029d3/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4", size = 1738896, upload-time = "2025-07-29T05:49:57.045Z" }, - { url = "https://files.pythonhosted.org/packages/20/43/2bd482ebe2b126533e8755a49b128ec4e58f1a3af56879a3abdb7b42c54f/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6", size = 1643561, upload-time = "2025-07-29T05:49:58.762Z" }, - { url = "https://files.pythonhosted.org/packages/23/40/2fa9f514c4cf4cbae8d7911927f81a1901838baf5e09a8b2c299de1acfe5/aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2", size = 1583685, upload-time = "2025-07-29T05:50:00.375Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c3/94dc7357bc421f4fb978ca72a201a6c604ee90148f1181790c129396ceeb/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d", size = 1627533, upload-time = "2025-07-29T05:50:02.306Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3f/1f8911fe1844a07001e26593b5c255a685318943864b27b4e0267e840f95/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb", size = 1638319, upload-time = "2025-07-29T05:50:04.282Z" }, - { url = "https://files.pythonhosted.org/packages/4e/46/27bf57a99168c4e145ffee6b63d0458b9c66e58bb70687c23ad3d2f0bd17/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5", size = 1613776, upload-time = "2025-07-29T05:50:05.863Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7e/1d2d9061a574584bb4ad3dbdba0da90a27fdc795bc227def3a46186a8bc1/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b", size = 1693359, upload-time = "2025-07-29T05:50:07.563Z" }, - { url = "https://files.pythonhosted.org/packages/08/98/bee429b52233c4a391980a5b3b196b060872a13eadd41c3a34be9b1469ed/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065", size = 1716598, upload-time = "2025-07-29T05:50:09.33Z" }, - { url = "https://files.pythonhosted.org/packages/57/39/b0314c1ea774df3392751b686104a3938c63ece2b7ce0ba1ed7c0b4a934f/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1", size = 1644940, upload-time = "2025-07-29T05:50:11.334Z" }, - { url = "https://files.pythonhosted.org/packages/1b/83/3dacb8d3f8f512c8ca43e3fa8a68b20583bd25636ffa4e56ee841ffd79ae/aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a", size = 429239, upload-time = "2025-07-29T05:50:12.803Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f9/470b5daba04d558c9673ca2034f28d067f3202a40e17804425f0c331c89f/aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830", size = 452297, upload-time = "2025-07-29T05:50:14.266Z" }, - { url = "https://files.pythonhosted.org/packages/20/19/9e86722ec8e835959bd97ce8c1efa78cf361fa4531fca372551abcc9cdd6/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117", size = 711246, upload-time = "2025-07-29T05:50:15.937Z" }, - { url = "https://files.pythonhosted.org/packages/71/f9/0a31fcb1a7d4629ac9d8f01f1cb9242e2f9943f47f5d03215af91c3c1a26/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe", size = 483515, upload-time = "2025-07-29T05:50:17.442Z" }, - { url = "https://files.pythonhosted.org/packages/62/6c/94846f576f1d11df0c2e41d3001000527c0fdf63fce7e69b3927a731325d/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9", size = 471776, upload-time = "2025-07-29T05:50:19.568Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6c/f766d0aaafcee0447fad0328da780d344489c042e25cd58fde566bf40aed/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5", size = 1741977, upload-time = "2025-07-29T05:50:21.665Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/fb779a05ba6ff44d7bc1e9d24c644e876bfff5abe5454f7b854cace1b9cc/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728", size = 1690645, upload-time = "2025-07-29T05:50:23.333Z" }, - { url = "https://files.pythonhosted.org/packages/37/4e/a22e799c2035f5d6a4ad2cf8e7c1d1bd0923192871dd6e367dafb158b14c/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16", size = 1789437, upload-time = "2025-07-29T05:50:25.007Z" }, - { url = "https://files.pythonhosted.org/packages/28/e5/55a33b991f6433569babb56018b2fb8fb9146424f8b3a0c8ecca80556762/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0", size = 1828482, upload-time = "2025-07-29T05:50:26.693Z" }, - { url = "https://files.pythonhosted.org/packages/c6/82/1ddf0ea4f2f3afe79dffed5e8a246737cff6cbe781887a6a170299e33204/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b", size = 1730944, upload-time = "2025-07-29T05:50:28.382Z" }, - { url = "https://files.pythonhosted.org/packages/1b/96/784c785674117b4cb3877522a177ba1b5e4db9ce0fd519430b5de76eec90/aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd", size = 1668020, upload-time = "2025-07-29T05:50:30.032Z" }, - { url = "https://files.pythonhosted.org/packages/12/8a/8b75f203ea7e5c21c0920d84dd24a5c0e971fe1e9b9ebbf29ae7e8e39790/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8", size = 1716292, upload-time = "2025-07-29T05:50:31.983Z" }, - { url = "https://files.pythonhosted.org/packages/47/0b/a1451543475bb6b86a5cfc27861e52b14085ae232896a2654ff1231c0992/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50", size = 1711451, upload-time = "2025-07-29T05:50:33.989Z" }, - { url = "https://files.pythonhosted.org/packages/55/fd/793a23a197cc2f0d29188805cfc93aa613407f07e5f9da5cd1366afd9d7c/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676", size = 1691634, upload-time = "2025-07-29T05:50:35.846Z" }, - { url = "https://files.pythonhosted.org/packages/ca/bf/23a335a6670b5f5dfc6d268328e55a22651b440fca341a64fccf1eada0c6/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7", size = 1785238, upload-time = "2025-07-29T05:50:37.597Z" }, - { url = "https://files.pythonhosted.org/packages/57/4f/ed60a591839a9d85d40694aba5cef86dde9ee51ce6cca0bb30d6eb1581e7/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7", size = 1805701, upload-time = "2025-07-29T05:50:39.591Z" }, - { url = "https://files.pythonhosted.org/packages/85/e0/444747a9455c5de188c0f4a0173ee701e2e325d4b2550e9af84abb20cdba/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685", size = 1718758, upload-time = "2025-07-29T05:50:41.292Z" }, - { url = "https://files.pythonhosted.org/packages/36/ab/1006278d1ffd13a698e5dd4bfa01e5878f6bddefc296c8b62649753ff249/aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b", size = 428868, upload-time = "2025-07-29T05:50:43.063Z" }, - { url = "https://files.pythonhosted.org/packages/10/97/ad2b18700708452400278039272032170246a1bf8ec5d832772372c71f1a/aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d", size = 453273, upload-time = "2025-07-29T05:50:44.613Z" }, - { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, - { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, - { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, - { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, - { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, - { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, - { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, - { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, - { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, - { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, - { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, - { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, - { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, - { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, - { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, - { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, - { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, - { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, - { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, -] - [[package]] name = "annotated-types" version = "0.7.0" @@ -147,15 +39,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - [[package]] name = "attrs" version = "25.3.0" @@ -580,33 +463,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/57/8db39bc5f98f042e0153b1de9fb88e1a409a33cda4dd7f723c2ed71e01f6/docutils-0.22-py3-none-any.whl", hash = "sha256:4ed966a0e96a0477d852f7af31bdcb3adc049fbb35ccba358c2ea8a03287615e", size = 630709, upload-time = "2025-07-29T15:20:28.335Z" }, ] -[[package]] -name = "elastic-transport" -version = "9.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/1f/2d1a1790df2b75e1e1eb90d8a3fe066a47ef95e34430657447e549cc274c/elastic_transport-9.1.0.tar.gz", hash = "sha256:1590e44a25b0fe208107d5e8d7dea15c070525f3ac9baafbe4cb659cd14f073d", size = 76483, upload-time = "2025-07-24T16:41:31.017Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/5d/dd5a919dd887fe20a91f18faf5b4345ee3a058e483d2aa84cef0f2567e17/elastic_transport-9.1.0-py3-none-any.whl", hash = "sha256:369fa56874c74daae4ea10cbf40636d139f38f42bec0e006b9cd45a168ee7fce", size = 65142, upload-time = "2025-07-24T16:41:29.648Z" }, -] - -[[package]] -name = "elasticsearch" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "elastic-transport" }, - { name = "python-dateutil" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/6a/5eecef6f1ac8005b04714405cb65971d46031bd897e47c29af86e0f87353/elasticsearch-9.1.1.tar.gz", hash = "sha256:be20acda2a97591a9a6cf4981fc398ee6fca3291cf9e7a9e52b6a9f41a46d393", size = 857802, upload-time = "2025-09-12T13:27:38.62Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/4c/c0c95d3d881732a5d1b28e12c9be4dea5953ade71810f94565bd5bd2101a/elasticsearch-9.1.1-py3-none-any.whl", hash = "sha256:2a5c27c57ca3dd3365f665c82c9dcd8666ccfb550d5b07c688c21ec636c104e5", size = 937483, upload-time = "2025-09-12T13:27:34.948Z" }, -] - [[package]] name = "email-validator" version = "2.2.0" @@ -697,16 +553,6 @@ dependencies = [ ] [package.optional-dependencies] -caching = [ - { name = "cachetools" }, - { name = "diskcache" }, -] -contrib-middleware-elasticsearch-cache = [ - { name = "aiohttp" }, - { name = "cachetools" }, - { name = "diskcache" }, - { name = "elasticsearch" }, -] openai = [ { name = "openai" }, ] @@ -715,7 +561,7 @@ openai = [ dev = [ { name = "dirty-equals" }, { name = "fastapi" }, - { name = "fastmcp", extra = ["caching", "contrib-middleware-elasticsearch-cache", "openai"] }, + { name = "fastmcp", extra = ["openai"] }, { name = "inline-snapshot", extra = ["dirty-equals"] }, { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -741,14 +587,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiohttp", marker = "extra == 'contrib-middleware-elasticsearch-cache'", specifier = ">=3.11.10" }, { name = "authlib", specifier = ">=1.5.2" }, - { name = "cachetools", marker = "extra == 'caching'", specifier = ">=6.2.0" }, - { name = "cachetools", marker = "extra == 'contrib-middleware-elasticsearch-cache'", specifier = ">=6.2.0" }, { name = "cyclopts", specifier = ">=3.0.0" }, - { name = "diskcache", marker = "extra == 'caching'", specifier = ">=5.6.3" }, - { name = "diskcache", marker = "extra == 'contrib-middleware-elasticsearch-cache'", specifier = ">=5.6.3" }, - { name = "elasticsearch", marker = "extra == 'contrib-middleware-elasticsearch-cache'", specifier = ">=8.15.0" }, { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", specifier = ">=1.12.4,<2.0.0" }, @@ -762,14 +602,12 @@ requires-dist = [ { name = "rich", specifier = ">=13.9.4" }, { name = "websockets", specifier = ">=15.0.1" }, ] -provides-extras = ["caching", "contrib-middleware-elasticsearch-cache", "openai"] +provides-extras = ["openai"] [package.metadata.requires-dev] dev = [ { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "fastapi", specifier = ">=0.115.12" }, - { name = "fastmcp", extras = ["caching"] }, - { name = "fastmcp", extras = ["contrib-middleware-elasticsearch-cache"] }, { name = "fastmcp", extras = ["openai"] }, { name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" }, { name = "ipython", specifier = ">=8.12.3" }, @@ -802,100 +640,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] -[[package]] -name = "frozenlist" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload-time = "2025-06-09T22:59:48.133Z" }, - { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload-time = "2025-06-09T22:59:49.564Z" }, - { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload-time = "2025-06-09T22:59:51.35Z" }, - { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload-time = "2025-06-09T22:59:52.884Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload-time = "2025-06-09T22:59:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload-time = "2025-06-09T22:59:56.187Z" }, - { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload-time = "2025-06-09T22:59:57.604Z" }, - { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload-time = "2025-06-09T22:59:59.498Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload-time = "2025-06-09T23:00:01.026Z" }, - { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload-time = "2025-06-09T23:00:03.401Z" }, - { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload-time = "2025-06-09T23:00:05.282Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload-time = "2025-06-09T23:00:07.962Z" }, - { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload-time = "2025-06-09T23:00:09.428Z" }, - { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload-time = "2025-06-09T23:00:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload-time = "2025-06-09T23:00:13.526Z" }, - { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload-time = "2025-06-09T23:00:14.98Z" }, - { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, - { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, - { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, - { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, - { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, - { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, - { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, - { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, - { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, - { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, - { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, - { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, - { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, - { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, - { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, - { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, - { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, - { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, - { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, - { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, - { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, - { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, - { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, - { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, - { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, - { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, - { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, - { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, - { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, - { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, - { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, - { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, - { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, - { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, - { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, - { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, - { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, - { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, - { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, - { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, - { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, - { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, - { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, - { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, - { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, - { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, - { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -1328,108 +1072,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" }, ] -[[package]] -name = "multidict" -version = "6.6.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/6b/86f353088c1358e76fd30b0146947fddecee812703b604ee901e85cd2a80/multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f", size = 77054, upload-time = "2025-08-11T12:06:02.99Z" }, - { url = "https://files.pythonhosted.org/packages/19/5d/c01dc3d3788bb877bd7f5753ea6eb23c1beeca8044902a8f5bfb54430f63/multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb", size = 44914, upload-time = "2025-08-11T12:06:05.264Z" }, - { url = "https://files.pythonhosted.org/packages/46/44/964dae19ea42f7d3e166474d8205f14bb811020e28bc423d46123ddda763/multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495", size = 44601, upload-time = "2025-08-11T12:06:06.627Z" }, - { url = "https://files.pythonhosted.org/packages/31/20/0616348a1dfb36cb2ab33fc9521de1f27235a397bf3f59338e583afadd17/multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8", size = 224821, upload-time = "2025-08-11T12:06:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/14/26/5d8923c69c110ff51861af05bd27ca6783011b96725d59ccae6d9daeb627/multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7", size = 242608, upload-time = "2025-08-11T12:06:09.697Z" }, - { url = "https://files.pythonhosted.org/packages/5c/cc/e2ad3ba9459aa34fa65cf1f82a5c4a820a2ce615aacfb5143b8817f76504/multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796", size = 222324, upload-time = "2025-08-11T12:06:10.905Z" }, - { url = "https://files.pythonhosted.org/packages/19/db/4ed0f65701afbc2cb0c140d2d02928bb0fe38dd044af76e58ad7c54fd21f/multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db", size = 253234, upload-time = "2025-08-11T12:06:12.658Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5160c9813269e39ae14b73debb907bfaaa1beee1762da8c4fb95df4764ed/multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0", size = 251613, upload-time = "2025-08-11T12:06:13.97Z" }, - { url = "https://files.pythonhosted.org/packages/05/a9/48d1bd111fc2f8fb98b2ed7f9a115c55a9355358432a19f53c0b74d8425d/multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877", size = 241649, upload-time = "2025-08-11T12:06:15.204Z" }, - { url = "https://files.pythonhosted.org/packages/85/2a/f7d743df0019408768af8a70d2037546a2be7b81fbb65f040d76caafd4c5/multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace", size = 239238, upload-time = "2025-08-11T12:06:16.467Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b8/4f4bb13323c2d647323f7919201493cf48ebe7ded971717bfb0f1a79b6bf/multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6", size = 233517, upload-time = "2025-08-11T12:06:18.107Z" }, - { url = "https://files.pythonhosted.org/packages/33/29/4293c26029ebfbba4f574febd2ed01b6f619cfa0d2e344217d53eef34192/multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb", size = 243122, upload-time = "2025-08-11T12:06:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/20/60/a1c53628168aa22447bfde3a8730096ac28086704a0d8c590f3b63388d0c/multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb", size = 248992, upload-time = "2025-08-11T12:06:20.661Z" }, - { url = "https://files.pythonhosted.org/packages/a3/3b/55443a0c372f33cae5d9ec37a6a973802884fa0ab3586659b197cf8cc5e9/multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987", size = 243708, upload-time = "2025-08-11T12:06:21.891Z" }, - { url = "https://files.pythonhosted.org/packages/7c/60/a18c6900086769312560b2626b18e8cca22d9e85b1186ba77f4755b11266/multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f", size = 237498, upload-time = "2025-08-11T12:06:23.206Z" }, - { url = "https://files.pythonhosted.org/packages/11/3d/8bdd8bcaff2951ce2affccca107a404925a2beafedd5aef0b5e4a71120a6/multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f", size = 41415, upload-time = "2025-08-11T12:06:24.77Z" }, - { url = "https://files.pythonhosted.org/packages/c0/53/cab1ad80356a4cd1b685a254b680167059b433b573e53872fab245e9fc95/multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0", size = 46046, upload-time = "2025-08-11T12:06:25.893Z" }, - { url = "https://files.pythonhosted.org/packages/cf/9a/874212b6f5c1c2d870d0a7adc5bb4cfe9b0624fa15cdf5cf757c0f5087ae/multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729", size = 43147, upload-time = "2025-08-11T12:06:27.534Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7f/90a7f01e2d005d6653c689039977f6856718c75c5579445effb7e60923d1/multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c", size = 76472, upload-time = "2025-08-11T12:06:29.006Z" }, - { url = "https://files.pythonhosted.org/packages/54/a3/bed07bc9e2bb302ce752f1dabc69e884cd6a676da44fb0e501b246031fdd/multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb", size = 44634, upload-time = "2025-08-11T12:06:30.374Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4b/ceeb4f8f33cf81277da464307afeaf164fb0297947642585884f5cad4f28/multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e", size = 44282, upload-time = "2025-08-11T12:06:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/03/35/436a5da8702b06866189b69f655ffdb8f70796252a8772a77815f1812679/multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded", size = 229696, upload-time = "2025-08-11T12:06:33.087Z" }, - { url = "https://files.pythonhosted.org/packages/b6/0e/915160be8fecf1fca35f790c08fb74ca684d752fcba62c11daaf3d92c216/multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683", size = 246665, upload-time = "2025-08-11T12:06:34.448Z" }, - { url = "https://files.pythonhosted.org/packages/08/ee/2f464330acd83f77dcc346f0b1a0eaae10230291450887f96b204b8ac4d3/multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a", size = 225485, upload-time = "2025-08-11T12:06:35.672Z" }, - { url = "https://files.pythonhosted.org/packages/71/cc/9a117f828b4d7fbaec6adeed2204f211e9caf0a012692a1ee32169f846ae/multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9", size = 257318, upload-time = "2025-08-11T12:06:36.98Z" }, - { url = "https://files.pythonhosted.org/packages/25/77/62752d3dbd70e27fdd68e86626c1ae6bccfebe2bb1f84ae226363e112f5a/multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50", size = 254689, upload-time = "2025-08-11T12:06:38.233Z" }, - { url = "https://files.pythonhosted.org/packages/00/6e/fac58b1072a6fc59af5e7acb245e8754d3e1f97f4f808a6559951f72a0d4/multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52", size = 246709, upload-time = "2025-08-11T12:06:39.517Z" }, - { url = "https://files.pythonhosted.org/packages/01/ef/4698d6842ef5e797c6db7744b0081e36fb5de3d00002cc4c58071097fac3/multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6", size = 243185, upload-time = "2025-08-11T12:06:40.796Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c9/d82e95ae1d6e4ef396934e9b0e942dfc428775f9554acf04393cce66b157/multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e", size = 237838, upload-time = "2025-08-11T12:06:42.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/cf/f94af5c36baaa75d44fab9f02e2a6bcfa0cd90acb44d4976a80960759dbc/multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3", size = 246368, upload-time = "2025-08-11T12:06:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/4a/fe/29f23460c3d995f6a4b678cb2e9730e7277231b981f0b234702f0177818a/multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c", size = 253339, upload-time = "2025-08-11T12:06:45.597Z" }, - { url = "https://files.pythonhosted.org/packages/29/b6/fd59449204426187b82bf8a75f629310f68c6adc9559dc922d5abe34797b/multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b", size = 246933, upload-time = "2025-08-11T12:06:46.841Z" }, - { url = "https://files.pythonhosted.org/packages/19/52/d5d6b344f176a5ac3606f7a61fb44dc746e04550e1a13834dff722b8d7d6/multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f", size = 242225, upload-time = "2025-08-11T12:06:48.588Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d3/5b2281ed89ff4d5318d82478a2a2450fcdfc3300da48ff15c1778280ad26/multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2", size = 41306, upload-time = "2025-08-11T12:06:49.95Z" }, - { url = "https://files.pythonhosted.org/packages/74/7d/36b045c23a1ab98507aefd44fd8b264ee1dd5e5010543c6fccf82141ccef/multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e", size = 46029, upload-time = "2025-08-11T12:06:51.082Z" }, - { url = "https://files.pythonhosted.org/packages/0f/5e/553d67d24432c5cd52b49047f2d248821843743ee6d29a704594f656d182/multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf", size = 43017, upload-time = "2025-08-11T12:06:52.243Z" }, - { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, - { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, - { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, - { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, - { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, - { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, - { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, - { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, - { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, - { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, - { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, - { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, - { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, - { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, - { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, - { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, - { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, - { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, - { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, - { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, - { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, - { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, - { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, - { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, - { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, - { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, - { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, - { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, - { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, - { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, - { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, - { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, -] - [[package]] name = "nodeenv" version = "1.9.1" @@ -1635,95 +1277,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, ] -[[package]] -name = "propcache" -version = "0.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload-time = "2025-06-09T22:53:41.965Z" }, - { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload-time = "2025-06-09T22:53:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload-time = "2025-06-09T22:53:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload-time = "2025-06-09T22:53:46.707Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload-time = "2025-06-09T22:53:48.547Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload-time = "2025-06-09T22:53:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload-time = "2025-06-09T22:53:51.438Z" }, - { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload-time = "2025-06-09T22:53:53.229Z" }, - { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload-time = "2025-06-09T22:53:54.541Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload-time = "2025-06-09T22:53:56.44Z" }, - { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload-time = "2025-06-09T22:53:57.839Z" }, - { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload-time = "2025-06-09T22:53:59.638Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload-time = "2025-06-09T22:54:01.071Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload-time = "2025-06-09T22:54:03.003Z" }, - { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload-time = "2025-06-09T22:54:04.134Z" }, - { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, - { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, - { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, - { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, - { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, - { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, - { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, - { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, - { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, - { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, - { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, - { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, - { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, - { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, - { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, - { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, - { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, - { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, - { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, - { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, - { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, - { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, - { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, - { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, - { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, - { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, - { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, - { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, -] - [[package]] name = "psutil" version = "7.0.0" @@ -2160,18 +1713,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-dotenv" version = "1.1.1" @@ -2762,102 +2303,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/32/af/d4502dc713b4ccea7 wheels = [ { url = "https://files.pythonhosted.org/packages/ee/ea/c67e1dee1ba208ed22c06d1d547ae5e293374bfc43e0eb0ef5e262b68561/werkzeug-3.1.1-py3-none-any.whl", hash = "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5", size = 224371, upload-time = "2024-11-01T16:40:43.994Z" }, ] - -[[package]] -name = "yarl" -version = "1.20.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload-time = "2025-06-10T00:42:31.108Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload-time = "2025-06-10T00:42:33.851Z" }, - { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload-time = "2025-06-10T00:42:35.688Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload-time = "2025-06-10T00:42:37.817Z" }, - { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload-time = "2025-06-10T00:42:39.937Z" }, - { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload-time = "2025-06-10T00:42:42.627Z" }, - { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload-time = "2025-06-10T00:42:44.842Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload-time = "2025-06-10T00:42:47.149Z" }, - { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload-time = "2025-06-10T00:42:48.852Z" }, - { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload-time = "2025-06-10T00:42:51.024Z" }, - { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload-time = "2025-06-10T00:42:53.007Z" }, - { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload-time = "2025-06-10T00:42:54.964Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload-time = "2025-06-10T00:42:57.28Z" }, - { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload-time = "2025-06-10T00:42:59.055Z" }, - { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload-time = "2025-06-10T00:43:01.248Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload-time = "2025-06-10T00:43:03.486Z" }, - { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload-time = "2025-06-10T00:43:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, - { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, - { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, - { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, - { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, - { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, - { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, - { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, - { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, - { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, - { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, - { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, - { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, - { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, - { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, - { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, - { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, - { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, - { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, - { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, - { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, - { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, - { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, - { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, - { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, - { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, - { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, - { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, - { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, - { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, - { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, - { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, - { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, - { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, -] From 6ea6351d5792e268b11b8a4899c031f5b3fac824 Mon Sep 17 00:00:00 2001 From: William Easton Date: Mon, 13 Oct 2025 09:47:13 -0500 Subject: [PATCH 12/15] PR Feedback --- src/fastmcp/server/middleware/caching.py | 31 ++++-------------------- tests/server/middleware/test_caching.py | 16 +++--------- 2 files changed, 8 insertions(+), 39 deletions(-) diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 35565de36c..fb61d4f969 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -1,11 +1,11 @@ """A middleware for response caching.""" -import hashlib import json from collections.abc import Sequence from typing import Any, TypedDict, TypeVar, cast import mcp.types +import pydantic_core from key_value.aio.adapters.pydantic import PydanticAdapter from key_value.aio.protocols.key_value import AsyncKeyValue from key_value.aio.stores.memory import MemoryStore @@ -598,22 +598,19 @@ def statistics(self) -> ResponseCachingStatistics: def _make_call_tool_cache_key(msg: mcp.types.CallToolRequestParams) -> str: """Make a cache key for a tool call by hashing the tool name and its arguments.""" - raw = f"{msg.name}:{_get_arguments_str(msg.arguments)}" - return hashlib.sha256(raw.encode("utf-8")).hexdigest() + return f"{msg.name}:{_get_arguments_str(msg.arguments)}" def _make_read_resource_cache_key(msg: mcp.types.ReadResourceRequestParams) -> str: """Make a cache key for a resource read by hashing the resource URI.""" - raw = f"{msg.uri}" - return hashlib.sha256(raw.encode("utf-8")).hexdigest() + return f"{msg.uri}" def _make_get_prompt_cache_key(msg: mcp.types.GetPromptRequestParams) -> str: """Make a cache key for a prompt get by hashing the prompt name and its arguments.""" - raw = f"{msg.name}:{_get_arguments_str(msg.arguments)}" - return hashlib.sha256(raw.encode("utf-8")).hexdigest() + return f"{msg.name}:{_get_arguments_str(msg.arguments)}" def _get_arguments_str(arguments: dict[str, Any] | None) -> str: @@ -623,7 +620,7 @@ def _get_arguments_str(arguments: dict[str, Any] | None) -> str: return "null" try: - return json.dumps(arguments, sort_keys=True, separators=(",", ":")) + return pydantic_core.to_json(value=arguments, fallback=str).decode() except TypeError: return repr(arguments) @@ -651,21 +648,3 @@ def _get_size_of_tool_result(value: ToolResult) -> int: ) return content_size + structured_content_size - - -# def get_size_of_one_value(value: BaseModel | ToolResult | ReadResourceContents) -> int: -# """Get the size of an mcp type.""" - -# if isinstance(value, ToolResult): -# return get_size_of_tool_result(value) -# if isinstance(value, ReadResourceContents): -# return len(value.content) -# return len(value.model_dump_json()) - - -# def get_size_of_value(value: CachableListTypes) -> int: -# """Get the size of a cache entry.""" -# if isinstance(value, (BaseModel | ToolResult | ReadResourceContents)): -# return get_size_of_one_value(value) - -# return sum(get_size_of_one_value(item) for item in value) diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index fd7701ae39..7701f27359 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -367,11 +367,7 @@ def test_cache_key_generation(self): key = _make_call_tool_cache_key(msg) - # Should be a SHA256 hash - assert len(key) == 64 - assert key == snapshot( - "7fa3d5c7967a202457eeca0731709fd87ec98546ddaee829ee86ca54b1858c59" - ) + assert key == snapshot('test_tool:{"param1":"value1","param2":42}') msg = mcp.types.ReadResourceRequestParams( uri=AnyUrl("https://test_uri"), @@ -379,10 +375,7 @@ def test_cache_key_generation(self): key = _make_read_resource_cache_key(msg) - assert len(key) == 64 - assert key == snapshot( - "e34cc47c03ed1ad54f02501d95ecc463b65646568961c97ca4b730cb274e9d42" - ) + assert key == snapshot("https://test_uri/") msg = mcp.types.GetPromptRequestParams( name="test_prompt", arguments={"param1": "value1"} @@ -390,10 +383,7 @@ def test_cache_key_generation(self): key = _make_get_prompt_cache_key(msg) - assert len(key) == 64 - assert key == snapshot( - "6306ff84fd3ff247a4bd91271e9d727d7f051bba53fb2e3bf80958988c4baf57" - ) + assert key == snapshot('test_prompt:{"param1":"value1"}') class TestResponseCachingMiddlewareIntegration: From 5831c4bb60919618cef47d238dc884d1955b34a2 Mon Sep 17 00:00:00 2001 From: William Easton Date: Thu, 16 Oct 2025 21:56:55 -0500 Subject: [PATCH 13/15] PR Clean-up --- docs/servers/middleware.mdx | 26 +- pyproject.toml | 3 +- src/fastmcp/server/middleware/caching.py | 574 ++++++++--------------- tests/server/middleware/test_caching.py | 257 +++++----- 4 files changed, 308 insertions(+), 552 deletions(-) diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index 4919462b55..b38a0a6490 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -461,21 +461,22 @@ from fastmcp.server.middleware.caching import ResponseCachingMiddleware mcp.add_middleware(ResponseCachingMiddleware()) ``` -Out of the box, it caches call/list tool, resources, and prompts. Sending a notification of a tool/resource/prompt change will invalidate the cache for the affected method. +Out of the box, it caches call/list tool, resources, and prompts to an in-memory cache. Sending a notification of a tool/resource/prompt change will invalidate the cache for the affected method. List calls are stored under global keys, if you share a key_value backend across servers, keep this in mind and consider using the PrefixCollectionsWrapper in py-key-value-aio to namespace collections by server. -Alternatively, it can be configured to only cache specific methods, for example, only caching list tools and only caching calls to `tool1`: +Each method can be configured individually, for example, caching list tools for 30 seconds, skipping caching for tools other than `tool1` and not caching and requests to read resources: ```python -from fastmcp.server.middleware.caching import ResponseCachingMiddleware, MethodSettings, CallToolSettings, ListToolsSettings +from fastmcp.server.middleware.caching import ResponseCachingMiddleware, CallToolSettings, ListToolsSettings, ReadResourceSettings mcp.add_middleware(ResponseCachingMiddleware( - method_settings=MethodSettings( - call_tool=CallToolSettings( - included_tools=["tool1"], - ), - list_tools=ListToolsSettings( - ttl=30, - ) + list_tools_settings=ListToolsSettings( + ttl=30, + ), + call_tool_settings=CallToolSettings( + included_tools=["tool1"], + ), + read_resource_settings=ReadResourceSettings( + enabled=False ) )) ``` @@ -483,10 +484,11 @@ mcp.add_middleware(ResponseCachingMiddleware( It can also be configured to cache to disk: ```python -from fastmcp.server.middleware.caching import ResponseCachingMiddleware, DiskCache +from fastmcp.server.middleware.caching import ResponseCachingMiddleware +from key_value.aio.stores.disk import DiskStore mcp.add_middleware(ResponseCachingMiddleware( - cache_backend=DiskCache(path="cache"), + cache_storage=DiskStore(directory="cache"), )) ``` diff --git a/pyproject.toml b/pyproject.toml index bd6563745f..e6c93d38ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "pydantic[email]>=2.11.7", "pyperclip>=1.9.0", "openapi-core>=0.19.5", - "py-key-value-aio[disk,memory]>=0.2.2", + "py-key-value-aio[disk,memory]>=0.2.2,<0.3.0", "websockets>=15.0.1", ] @@ -70,7 +70,6 @@ dev = [ "pytest-xdist>=3.6.1", "ruff", "ty>=0.0.1a19", - "pytest-benchmark>=5.1.0", ] [project.scripts] diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index fb61d4f969..9bc64b1fa7 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -1,20 +1,20 @@ """A middleware for response caching.""" -import json from collections.abc import Sequence -from typing import Any, TypedDict, TypeVar, cast +from logging import Logger +from typing import Any, TypedDict import mcp.types import pydantic_core from key_value.aio.adapters.pydantic import PydanticAdapter from key_value.aio.protocols.key_value import AsyncKeyValue from key_value.aio.stores.memory import MemoryStore +from key_value.aio.wrappers.limit_size import LimitSizeWrapper from key_value.aio.wrappers.statistics import StatisticsWrapper from key_value.aio.wrappers.statistics.wrapper import ( KVStoreCollectionStatistics, ) from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.types import PromptMessage from pydantic import BaseModel, Field from typing_extensions import NotRequired, Self, override @@ -24,130 +24,40 @@ from fastmcp.tools.tool import Tool, ToolResult from fastmcp.utilities.logging import get_logger -logger = get_logger(__name__) +logger: Logger = get_logger(name=__name__) # Constants ONE_HOUR_IN_SECONDS = 3600 FIVE_MINUTES_IN_SECONDS = 300 -ONE_GB_IN_BYTES = 1024 * 1024 * 1024 ONE_MB_IN_BYTES = 1024 * 1024 GLOBAL_KEY = "__global__" -class CachableToolResult(BaseModel, ToolResult): - structured_content: dict[str, Any] | None - content: list[mcp.types.ContentBlock] +class CachableReadResourceContents(BaseModel): + """A wrapper for ReadResourceContents that can be cached.""" - @classmethod - def from_tool_result(cls, tool_result: ToolResult) -> Self: - return cls( - structured_content=tool_result.structured_content, - content=tool_result.content, - ) + content: str | bytes + mime_type: str | None = None def get_size(self) -> int: - return _get_size_of_tool_result(self) - - -class CachablePrompt(Prompt): - @override - async def render( - self, - arguments: dict[str, Any] | None = None, - ) -> list[PromptMessage]: - """Render the prompt with arguments.""" - raise NotImplementedError( - "Prompt.render() is not implemented on cached prompts" - ) + return len(self.model_dump_json()) @classmethod - def from_list_prompts(cls, prompts: list[Prompt]) -> list[Self]: - cachable_prompts: list[Self] = [] - for prompt in prompts: - cachable_prompts.append( - cls( - name=prompt.name, - title=prompt.title, - description=prompt.description, - arguments=prompt.arguments, - meta=prompt.meta, - tags=prompt.tags, - enabled=prompt.enabled, - ) - ) - return cachable_prompts - - -class CachablePromptResult(mcp.types.GetPromptResult): ... - - -class CachableResource(Resource): - @override - async def read(self) -> str | bytes: - """Read the resource content.""" - raise NotImplementedError( - "Resource.read() is not implemented on cached resources" - ) + def get_sizes(cls, values: Sequence[Self]) -> int: + return sum([item.get_size() for item in values]) @classmethod - def from_list_resources(cls, resources: list[Resource]) -> list[Self]: - cachable_resources: list[Self] = [] - for resource in resources: - cachable_resources.append( - cls( - name=resource.name, - description=resource.description, - uri=resource.uri, - mime_type=resource.mime_type, - annotations=resource.annotations, - meta=resource.meta, - tags=resource.tags, - enabled=resource.enabled, - ) - ) - return cachable_resources - + def wrap(cls, values: Sequence[ReadResourceContents]) -> list[Self]: + return [cls(content=item.content, mime_type=item.mime_type) for item in values] -class CachableTool(Tool): @classmethod - def from_list_tools(cls, tools: list[Tool]) -> list[Self]: - cachable_tools: list[Self] = [] - for tool in tools: - cachable_tools.append( - cls( - name=tool.name, - description=tool.description, - parameters=tool.parameters, - output_schema=tool.output_schema, - annotations=tool.annotations, - serializer=tool.serializer, - meta=tool.meta, - tags=tool.tags, - enabled=tool.enabled, - ) - ) - return cachable_tools - - -class CachableReadResourceContents(BaseModel, ReadResourceContents): ... - - -class CachableToolList(BaseModel): - cachable_tools: list[CachableTool] - - -class CachableResourceList(BaseModel): - cachable_resources: list[CachableResource] - - -class CachablePromptList(BaseModel): - cachable_prompts: list[CachablePrompt] - - -class CachableReadResourceContentsList(BaseModel): - cachable_read_resource_contents: list[CachableReadResourceContents] + def unwrap(cls, values: Sequence[Self]) -> list[ReadResourceContents]: + return [ + ReadResourceContents(content=item.content, mime_type=item.mime_type) + for item in values + ] class SharedMethodSettings(TypedDict): @@ -184,53 +94,6 @@ class GetPromptSettings(SharedMethodSettings): """Configuration options for Prompt-related caching.""" -class MethodSettings(TypedDict): - """Configuration options for mcp "methods" in the response caching middleware.""" - - list_tools: NotRequired[ListToolsSettings] - call_tool: NotRequired[CallToolSettings] - - list_resources: NotRequired[ListResourcesSettings] - read_resource: NotRequired[ReadResourceSettings] - - list_prompts: NotRequired[ListPromptsSettings] - get_prompt: NotRequired[GetPromptSettings] - - -MethodSettingsType = TypeVar("MethodSettingsType", bound=SharedMethodSettings) - - -MCP_METHOD_TO_METHOD_SETTINGS_KEY = { - "tools/list": "list_tools", - "tools/call": "call_tool", - "resources/list": "list_resources", - "resources/read": "read_resource", - "prompts/list": "list_prompts", - "prompts/get": "get_prompt", -} - -DEFAULT_METHOD_SETTINGS: MethodSettings = MethodSettings( - list_tools=SharedMethodSettings( - ttl=FIVE_MINUTES_IN_SECONDS, - ), - call_tool=CallToolSettings( - ttl=ONE_HOUR_IN_SECONDS, - ), - list_resources=SharedMethodSettings( - ttl=FIVE_MINUTES_IN_SECONDS, - ), - list_prompts=SharedMethodSettings( - ttl=FIVE_MINUTES_IN_SECONDS, - ), - read_resource=SharedMethodSettings( - ttl=ONE_HOUR_IN_SECONDS, - ), - get_prompt=SharedMethodSettings( - ttl=ONE_HOUR_IN_SECONDS, - ), -) - - class ResponseCachingStatistics(BaseModel): list_tools: KVStoreCollectionStatistics | None = Field(default=None) list_resources: KVStoreCollectionStatistics | None = Field(default=None) @@ -255,66 +118,93 @@ class ResponseCachingMiddleware(Middleware): def __init__( self, - cache_store: AsyncKeyValue | None = None, - method_settings: MethodSettings | None = None, - default_ttl: int = ONE_HOUR_IN_SECONDS, - max_item_size: int | None = None, + cache_storage: AsyncKeyValue | None = None, + list_tools_settings: ListToolsSettings | None = None, + list_resources_settings: ListResourcesSettings | None = None, + list_prompts_settings: ListPromptsSettings | None = None, + read_resource_settings: ReadResourceSettings | None = None, + get_prompt_settings: GetPromptSettings | None = None, + call_tool_settings: CallToolSettings | None = None, + max_item_size: int = ONE_MB_IN_BYTES, ): """Initialize the response caching middleware. Args: - cache_backend: The cache backend to use. If None, an in-memory cache is used. - method_settings: The settings for the middleware. If None, the default settings are used. - default_ttl: The default TTL for cached responses. Defaults to one hour. - max_item_size: The maximum size of an item to cache. Defaults to no size limit. + cache_storage: The cache backend to use. If None, an in-memory cache is used. + list_tools_settings: The settings for the list tools method. If None, the default settings are used (5 minute TTL). + list_resources_settings: The settings for the list resources method. If None, the default settings are used (5 minute TTL). + list_prompts_settings: The settings for the list prompts method. If None, the default settings are used (5 minute TTL). + read_resource_settings: The settings for the read resource method. If None, the default settings are used (1 hour TTL). + get_prompt_settings: The settings for the get prompt method. If None, the default settings are used (1 hour TTL). + call_tool_settings: The settings for the call tool method. If None, the default settings are used (1 hour TTL). + max_item_size: The maximum size of items eligible for caching. Defaults to 1MB. """ - self._default_ttl: int = default_ttl - self._backend: AsyncKeyValue = cache_store or MemoryStore() - self._stats: StatisticsWrapper = StatisticsWrapper(store=self._backend) - self._max_item_size: int | None = max_item_size + self._backend: AsyncKeyValue = cache_storage or MemoryStore() + + # When the size limit is exceeded, the put will silently fail + self._size_limiter: LimitSizeWrapper = LimitSizeWrapper( + key_value=self._backend, max_size=max_item_size, raise_on_too_large=False + ) + self._stats: StatisticsWrapper = StatisticsWrapper(key_value=self._size_limiter) - self.method_settings: MethodSettings = ( - method_settings or DEFAULT_METHOD_SETTINGS + self._list_tools_settings: ListToolsSettings = ( + list_tools_settings or ListToolsSettings() + ) + self._list_resources_settings: ListResourcesSettings = ( + list_resources_settings or ListResourcesSettings() + ) + self._list_prompts_settings: ListPromptsSettings = ( + list_prompts_settings or ListPromptsSettings() ) - self._list_tools_cache: PydanticAdapter[CachableToolList] = PydanticAdapter( + self._read_resource_settings: ReadResourceSettings = ( + read_resource_settings or ReadResourceSettings() + ) + self._get_prompt_settings: GetPromptSettings = ( + get_prompt_settings or GetPromptSettings() + ) + self._call_tool_settings: CallToolSettings = ( + call_tool_settings or CallToolSettings() + ) + + self._list_tools_cache: PydanticAdapter[list[Tool]] = PydanticAdapter( key_value=self._stats, - pydantic_model=CachableToolList, + pydantic_model=list[Tool], default_collection="tools/list", ) - self._list_resources_cache: PydanticAdapter[CachableResourceList] = ( - PydanticAdapter( - key_value=self._stats, - pydantic_model=CachableResourceList, - default_collection="resources/list", - ) + self._list_resources_cache: PydanticAdapter[list[Resource]] = PydanticAdapter( + key_value=self._stats, + pydantic_model=list[Resource], + default_collection="resources/list", ) - self._list_prompts_cache: PydanticAdapter[CachablePromptList] = PydanticAdapter( + self._list_prompts_cache: PydanticAdapter[list[Prompt]] = PydanticAdapter( key_value=self._stats, - pydantic_model=CachablePromptList, + pydantic_model=list[Prompt], default_collection="prompts/list", ) - self._read_resource_cache: PydanticAdapter[CachableReadResourceContentsList] = ( + self._read_resource_cache: PydanticAdapter[ + list[CachableReadResourceContents] + ] = PydanticAdapter( + key_value=self._stats, + pydantic_model=list[CachableReadResourceContents], + default_collection="resources/read", + ) + + self._get_prompt_cache: PydanticAdapter[mcp.types.GetPromptResult] = ( PydanticAdapter( key_value=self._stats, - pydantic_model=CachableReadResourceContentsList, - default_collection="resources/read", + pydantic_model=mcp.types.GetPromptResult, + default_collection="prompts/get", ) ) - self._get_prompt_cache: PydanticAdapter[CachablePromptResult] = PydanticAdapter( - key_value=self._stats, - pydantic_model=CachablePromptResult, - default_collection="prompts/get", - ) - - self._call_tool_cache: PydanticAdapter[CachableToolResult] = PydanticAdapter( + self._call_tool_cache: PydanticAdapter[ToolResult] = PydanticAdapter( key_value=self._stats, - pydantic_model=CachableToolResult, + pydantic_model=ToolResult, default_collection="tools/call", ) @@ -322,21 +212,38 @@ def __init__( async def on_list_tools( self, context: MiddlewareContext[mcp.types.ListToolsRequest], - call_next: CallNext[mcp.types.ListToolsRequest, list[Tool]], - ) -> list[Tool]: - if self._should_bypass_caching(context=context): - return await call_next(context=context) + call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]], + ) -> Sequence[Tool]: + """List tools from the cache, if caching is enabled, and the result is in the cache. Otherwise, + otherwise call the next middleware and store the result in the cache if caching is enabled.""" + if self._list_tools_settings.get("enabled") is False: + return await call_next(context) if cached_value := await self._list_tools_cache.get(key=GLOBAL_KEY): - return cached_value.cachable_tools - - value: list[Tool] = await call_next(context=context) + return cached_value - cachable_tools: list[CachableTool] = CachableTool.from_list_tools(tools=value) + tools: Sequence[Tool] = await call_next(context=context) + + # Turn any subclass of Tool into a Tool + cachable_tools: list[Tool] = [ + Tool( + name=tool.name, + title=tool.title, + description=tool.description, + parameters=tool.parameters, + output_schema=tool.output_schema, + annotations=tool.annotations, + meta=tool.meta, + tags=tool.tags, + enabled=tool.enabled, + ) + for tool in tools + ] await self._list_tools_cache.put( key=GLOBAL_KEY, - value=CachableToolList(cachable_tools=cachable_tools), + value=cachable_tools, + ttl=self._list_tools_settings.get("ttl", FIVE_MINUTES_IN_SECONDS), ) return cachable_tools @@ -345,25 +252,38 @@ async def on_list_tools( async def on_list_resources( self, context: MiddlewareContext[mcp.types.ListResourcesRequest], - call_next: CallNext[mcp.types.ListResourcesRequest, list[Resource]], - ) -> list[Resource]: + call_next: CallNext[mcp.types.ListResourcesRequest, Sequence[Resource]], + ) -> Sequence[Resource]: """List resources from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled.""" - if self._should_bypass_caching(context=context): + if self._list_resources_settings.get("enabled") is False: return await call_next(context) if cached_value := await self._list_resources_cache.get(key=GLOBAL_KEY): - return cached_value.cachable_resources - - value: list[Resource] = await call_next(context=context) + return cached_value - cachable_resources: list[CachableResource] = ( - CachableResource.from_list_resources(resources=value) - ) + resources: Sequence[Resource] = await call_next(context=context) + + # Turn any subclass of Resource into a Resource + cachable_resources: list[Resource] = [ + Resource( + name=resource.name, + title=resource.title, + description=resource.description, + tags=resource.tags, + meta=resource.meta, + mime_type=resource.mime_type, + annotations=resource.annotations, + enabled=resource.enabled, + uri=resource.uri, + ) + for resource in resources + ] await self._list_resources_cache.put( key=GLOBAL_KEY, - value=CachableResourceList(cachable_resources=cachable_resources), + value=cachable_resources, + ttl=self._list_resources_settings.get("ttl", FIVE_MINUTES_IN_SECONDS), ) return cachable_resources @@ -372,25 +292,36 @@ async def on_list_resources( async def on_list_prompts( self, context: MiddlewareContext[mcp.types.ListPromptsRequest], - call_next: CallNext[mcp.types.ListPromptsRequest, list[Prompt]], - ) -> list[Prompt]: + call_next: CallNext[mcp.types.ListPromptsRequest, Sequence[Prompt]], + ) -> Sequence[Prompt]: """List prompts from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled.""" - if self._should_bypass_caching(context=context): + if self._list_prompts_settings.get("enabled") is False: return await call_next(context) if cached_value := await self._list_prompts_cache.get(key=GLOBAL_KEY): - return cached_value.cachable_prompts - - value: list[Prompt] = await call_next(context=context) + return cached_value - cachable_prompts: list[CachablePrompt] = CachablePrompt.from_list_prompts( - prompts=value - ) + prompts: Sequence[Prompt] = await call_next(context=context) + + # Turn any subclass of Prompt into a Prompt + cachable_prompts: list[Prompt] = [ + Prompt( + name=prompt.name, + title=prompt.title, + description=prompt.description, + tags=prompt.tags, + meta=prompt.meta, + enabled=prompt.enabled, + arguments=prompt.arguments, + ) + for prompt in prompts + ] await self._list_prompts_cache.put( key=GLOBAL_KEY, - value=CachablePromptList(cachable_prompts=cachable_prompts), + value=cachable_prompts, + ttl=self._list_prompts_settings.get("ttl", FIVE_MINUTES_IN_SECONDS), ) return cachable_prompts @@ -400,69 +331,60 @@ async def on_call_tool( self, context: MiddlewareContext[mcp.types.CallToolRequestParams], call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult], - ) -> Any: + ) -> ToolResult: """Call a tool from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled.""" - if self._should_bypass_caching(context=context): - return await call_next(context=context) + tool_name = context.message.name - if not self._matches_tool_cache_settings(context=context): + if self._call_tool_settings.get( + "enabled" + ) is False or not self._matches_tool_cache_settings(tool_name=tool_name): return await call_next(context=context) - if cached_value := await self._call_tool_cache.get( - key=_make_call_tool_cache_key(msg=context.message) - ): + cache_key: str = f"{tool_name}:{_get_arguments_str(context.message.arguments)}" + + if cached_value := await self._call_tool_cache.get(key=cache_key): return cached_value tool_result: ToolResult = await call_next(context=context) - cachable_value: CachableToolResult = CachableToolResult.from_tool_result( - tool_result=tool_result - ) - - if self._max_item_size and cachable_value.get_size() > self._max_item_size: - return tool_result - await self._call_tool_cache.put( - key=_make_call_tool_cache_key(msg=context.message), - value=cachable_value, + key=cache_key, + value=tool_result, + ttl=self._call_tool_settings.get("ttl", ONE_HOUR_IN_SECONDS), ) - return cachable_value + return tool_result @override async def on_read_resource( self, context: MiddlewareContext[mcp.types.ReadResourceRequestParams], call_next: CallNext[ - mcp.types.ReadResourceRequestParams, list[ReadResourceContents] + mcp.types.ReadResourceRequestParams, Sequence[ReadResourceContents] ], - ) -> list[ReadResourceContents]: + ) -> Sequence[ReadResourceContents]: """Read a resource from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled.""" - if self._should_bypass_caching(context=context): + if self._read_resource_settings.get("enabled") is False: return await call_next(context=context) - if cached_value := await self._read_resource_cache.get( - key=_make_read_resource_cache_key(msg=context.message) - ): - return cached_value.cachable_read_resource_contents + cache_key: str = str(context.message.uri) + cached_value: list[CachableReadResourceContents] | None - value: list[ReadResourceContents] = await call_next(context=context) + if cached_value := await self._read_resource_cache.get(key=cache_key): + return CachableReadResourceContents.unwrap(values=cached_value) - cachable_read_resource_contents: list[CachableReadResourceContents] = [ - CachableReadResourceContents(content=item.content, mime_type=item.mime_type) - for item in value - ] + value: Sequence[ReadResourceContents] = await call_next(context=context) + cached_value = CachableReadResourceContents.wrap(values=value) await self._read_resource_cache.put( - key=_make_read_resource_cache_key(msg=context.message), - value=CachableReadResourceContentsList( - cachable_read_resource_contents=cachable_read_resource_contents - ), + key=cache_key, + value=cached_value, + ttl=self._read_resource_settings.get("ttl", ONE_HOUR_IN_SECONDS), ) - return cachable_read_resource_contents + return CachableReadResourceContents.unwrap(values=cached_value) @override async def on_get_prompt( @@ -474,117 +396,39 @@ async def on_get_prompt( ) -> mcp.types.GetPromptResult: """Get a prompt from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled.""" - if self._should_bypass_caching(context=context): - return await call_next(context) + if self._get_prompt_settings.get("enabled") is False: + return await call_next(context=context) - if cached_value := await self._get_prompt_cache.get( - key=_make_get_prompt_cache_key(msg=context.message) - ): + cache_key: str = f"{context.message.name}:{_get_arguments_str(arguments=context.message.arguments)}" + + if cached_value := await self._get_prompt_cache.get(key=cache_key): return cached_value value: mcp.types.GetPromptResult = await call_next(context=context) - cachable_value: CachablePromptResult = CachablePromptResult( - messages=value.messages, description=value.description, _meta=value.meta - ) - await self._get_prompt_cache.put( - key=_make_get_prompt_cache_key(msg=context.message), - value=cachable_value, + key=cache_key, + value=value, + ttl=self._get_prompt_settings.get("ttl", ONE_HOUR_IN_SECONDS), ) - return cachable_value + return value - @override - async def on_notification( - self, - context: MiddlewareContext[mcp.types.Notification[Any, Any]], - call_next: CallNext[mcp.types.Notification[Any, Any], Any], - ) -> Any: - """Handle a notification from the server. If the notification is a tool/resource/prompt list changed - notification, delete the cache for the affected method.""" - if isinstance(context.message, mcp.types.ToolListChangedNotification): - _ = await self._list_tools_cache.delete(key=GLOBAL_KEY) - elif isinstance(context.message, mcp.types.ResourceListChangedNotification): - _ = await self._list_resources_cache.delete(key=GLOBAL_KEY) - elif isinstance(context.message, mcp.types.PromptListChangedNotification): - _ = await self._list_prompts_cache.delete(key=GLOBAL_KEY) - else: - pass - - return await call_next(context=context) - - def _matches_tool_cache_settings( - self, context: MiddlewareContext[mcp.types.CallToolRequestParams] - ) -> bool: + def _matches_tool_cache_settings(self, tool_name: str) -> bool: """Check if the tool matches the cache settings for tool calls.""" - tool_name = context.message.name - - tool_call_cache_settings: CallToolSettings | None = self._get_cache_settings( - context=context, - settings_type=CallToolSettings, - ) - - if not tool_call_cache_settings: - return True - - if included_tools := tool_call_cache_settings.get("included_tools"): + if included_tools := self._call_tool_settings.get("included_tools"): if tool_name not in included_tools: return False - if excluded_tools := tool_call_cache_settings.get("excluded_tools"): + if excluded_tools := self._call_tool_settings.get("excluded_tools"): if tool_name in excluded_tools: return False return True - def _get_cache_settings( - self, - context: MiddlewareContext[Any], - settings_type: type[MethodSettingsType] = SharedMethodSettings, - ) -> MethodSettingsType | None: - """Get the cache settings for a method.""" - - if not context.method: - return None - - method_settings_key = MCP_METHOD_TO_METHOD_SETTINGS_KEY.get( - context.method, None - ) - - if ( - method_settings_key is None - or method_settings_key not in self.method_settings - ): - return None - - return cast(MethodSettingsType, self.method_settings[method_settings_key]) - - def _get_cache_ttl(self, context: MiddlewareContext[Any]) -> int: - """Get the cache TTL for a method.""" - - settings: SharedMethodSettings | None = self._get_cache_settings( - context=context, - ) - - if not settings or "ttl" not in settings: - return self._default_ttl - - return settings["ttl"] - - def _should_bypass_caching(self, context: MiddlewareContext[Any]) -> bool: - """Check if the method should bypass caching.""" - - if not (cache_settings := self._get_cache_settings(context=context)): - return True - - if cache_settings.get("enabled") is False: - return True - - return False - def statistics(self) -> ResponseCachingStatistics: + """Get the statistics for the cache.""" return ResponseCachingStatistics( list_tools=self._stats.statistics.collections.get("tools/list"), list_resources=self._stats.statistics.collections.get("resources/list"), @@ -595,24 +439,6 @@ def statistics(self) -> ResponseCachingStatistics: ) -def _make_call_tool_cache_key(msg: mcp.types.CallToolRequestParams) -> str: - """Make a cache key for a tool call by hashing the tool name and its arguments.""" - - return f"{msg.name}:{_get_arguments_str(msg.arguments)}" - - -def _make_read_resource_cache_key(msg: mcp.types.ReadResourceRequestParams) -> str: - """Make a cache key for a resource read by hashing the resource URI.""" - - return f"{msg.uri}" - - -def _make_get_prompt_cache_key(msg: mcp.types.GetPromptRequestParams) -> str: - """Make a cache key for a prompt get by hashing the prompt name and its arguments.""" - - return f"{msg.name}:{_get_arguments_str(msg.arguments)}" - - def _get_arguments_str(arguments: dict[str, Any] | None) -> str: """Get a string representation of the arguments.""" @@ -624,27 +450,3 @@ def _get_arguments_str(arguments: dict[str, Any] | None) -> str: except TypeError: return repr(arguments) - - -def _get_size_of_content_blocks( - value: mcp.types.ContentBlock | Sequence[mcp.types.ContentBlock], -) -> int: - """Get the size of a series of content blocks by summing the size of the JSON representation of each block.""" - - if isinstance(value, mcp.types.ContentBlock): - value = [value] - - return sum([len(item.model_dump_json()) for item in value]) - - -def _get_size_of_tool_result(value: ToolResult) -> int: - """Get the size of a tool result by summing the size of the content blocks and the size of the structured content.""" - - content_size = _get_size_of_content_blocks(value.content) - structured_content_size = len( - json.dumps( - value.structured_content, sort_keys=True, separators=(",", ":") - ).encode("utf-8") - ) - - return content_size + structured_content_size diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index 7701f27359..696933530d 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -1,8 +1,6 @@ """Tests for response caching middleware.""" import tempfile -from collections.abc import Sequence -from typing import Any from unittest.mock import AsyncMock, MagicMock import mcp.types @@ -10,19 +8,24 @@ from inline_snapshot import snapshot from key_value.aio.stores.disk import DiskStore from key_value.aio.stores.memory import MemoryStore +from key_value.aio.wrappers.statistics.wrapper import ( + GetStatistics, + KVStoreCollectionStatistics, + PutStatistics, +) from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.types import PromptMessage, TextContent, TextResourceContents from pydantic import AnyUrl, BaseModel -from fastmcp import FastMCP +from fastmcp import Context, FastMCP from fastmcp.client.client import CallToolResult, Client from fastmcp.client.transports import FastMCPTransport from fastmcp.prompts.prompt import FunctionPrompt, Prompt from fastmcp.resources.resource import Resource from fastmcp.server.middleware.caching import ( CallToolSettings, - MethodSettings, ResponseCachingMiddleware, + ResponseCachingStatistics, ) from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext from fastmcp.tools.tool import Tool, ToolResult @@ -81,41 +84,6 @@ class CrazyModel(BaseModel): i: dict[str, list[int]] -def extract_content_for_snapshot(result: ToolResult | CallToolResult) -> dict[str, Any]: - return { - "content": [c.model_dump() for c in result.content], - "structured_content": result.structured_content, - } - - -def dump_mcp_type( - model: BaseModel | ToolResult | ReadResourceContents, -) -> dict[str, Any]: - if isinstance(model, ToolResult): - return extract_content_for_snapshot(model) - - if isinstance(model, ReadResourceContents): - return { - "content": model.content, - "mime_type": model.mime_type, - } - - return model.model_dump() - - -def dump_mcp_types( - model: BaseModel - | ToolResult - | Sequence[BaseModel] - | Sequence[ToolResult] - | Sequence[ReadResourceContents], -) -> list[dict[str, Any]]: - if isinstance(model, Sequence): - return [dump_mcp_type(model=m) for m in model] - - return dump_mcp_type(model=model) # type: ignore - - @pytest.fixture def crazy_model() -> CrazyModel: return CrazyModel( @@ -171,41 +139,51 @@ def get_multiply_calls(self) -> int: def get_crazy_calls(self) -> int: return self.crazy_calls + async def update_tool_list(self, context: Context): + await context.send_tool_list_changed() + def add_tools(self, fastmcp: FastMCP, prefix: str = ""): - fastmcp.add_tool(tool=Tool.from_function(fn=self.add, name=f"{prefix}add")) - fastmcp.add_tool( + _ = fastmcp.add_tool(tool=Tool.from_function(fn=self.add, name=f"{prefix}add")) + _ = fastmcp.add_tool( tool=Tool.from_function(fn=self.multiply, name=f"{prefix}multiply") ) - fastmcp.add_tool(tool=Tool.from_function(fn=self.crazy, name=f"{prefix}crazy")) - fastmcp.add_tool( + _ = fastmcp.add_tool( + tool=Tool.from_function(fn=self.crazy, name=f"{prefix}crazy") + ) + _ = fastmcp.add_tool( tool=Tool.from_function( fn=self.very_large_response, name=f"{prefix}very_large_response" ) ) + _ = fastmcp.add_tool( + tool=Tool.from_function( + fn=self.update_tool_list, name=f"{prefix}update_tool_list" + ) + ) def add_prompts(self, fastmcp: FastMCP, prefix: str = ""): - fastmcp.add_prompt( + _ = fastmcp.add_prompt( prompt=FunctionPrompt.from_function( fn=self.how_to_calculate, name=f"{prefix}how_to_calculate" ) ) def add_resources(self, fastmcp: FastMCP, prefix: str = ""): - fastmcp.add_resource( + _ = fastmcp.add_resource( resource=Resource.from_function( fn=self.get_add_calls, uri="resource://add_calls", name=f"{prefix}add_calls", ) ) - fastmcp.add_resource( + _ = fastmcp.add_resource( resource=Resource.from_function( fn=self.get_multiply_calls, uri="resource://multiply_calls", name=f"{prefix}multiply_calls", ) ) - fastmcp.add_resource( + _ = fastmcp.add_resource( resource=Resource.from_function( fn=self.get_crazy_calls, uri="resource://crazy_calls", @@ -235,7 +213,7 @@ def mock_call_next() -> CallNext[mcp.types.CallToolRequestParams, ToolResult]: """Create a mock call_next function.""" return AsyncMock( return_value=ToolResult( - content=[{"type": "text", "text": "test result"}], + content=[TextContent(type="text", text="test result")], structured_content={"result": "success", "value": 123}, ) ) @@ -245,7 +223,7 @@ def mock_call_next() -> CallNext[mcp.types.CallToolRequestParams, ToolResult]: def sample_tool_result() -> ToolResult: """Create a sample tool result for testing.""" return ToolResult( - content=[{"type": "text", "text": "cached result"}], + content=[TextContent(type="text", text="cached result")], structured_content={"cached": True, "data": "test"}, ) @@ -255,21 +233,12 @@ class TestResponseCachingMiddleware: def test_initialization(self): """Test middleware initialization.""" - middleware = ResponseCachingMiddleware( - method_settings=MethodSettings( - call_tool=CallToolSettings( - included_tools=["tool1"], - excluded_tools=["tool2"], - ) + assert ResponseCachingMiddleware( + call_tool_settings=CallToolSettings( + included_tools=["tool1"], + excluded_tools=["tool2"], ), - default_ttl=1800, - ) - - assert middleware.method_settings == snapshot( - {"call_tool": {"included_tools": ["tool1"], "excluded_tools": ["tool2"]}} ) - assert middleware._default_ttl == 1800 - assert middleware._max_item_size is None @pytest.mark.parametrize( ("tool_name", "included_tools", "excluded_tools", "result"), @@ -300,90 +269,11 @@ def test_tool_call_filtering( """Test tool filtering logic.""" middleware1 = ResponseCachingMiddleware( - method_settings=MethodSettings( - call_tool=CallToolSettings( - included_tools=included_tools, excluded_tools=excluded_tools - ) + call_tool_settings=CallToolSettings( + included_tools=included_tools, excluded_tools=excluded_tools ), ) - assert ( - middleware1._matches_tool_cache_settings( - context=MiddlewareContext( - method="tools/call", - message=mcp.types.CallToolRequestParams(name=tool_name), - ) - ) - is result - ) - - def test_method_settings(self): - """Test method TTL.""" - middleware = ResponseCachingMiddleware( - method_settings={ - "list_tools": {"ttl": 100}, - "call_tool": {"enabled": False}, - }, - default_ttl=1000, - ) - - tool_list_settings = middleware._get_cache_settings( - context=MiddlewareContext(method="tools/list", message=MagicMock()) - ) - assert tool_list_settings == {"ttl": 100} - - call_tool_settings = middleware._get_cache_settings( - context=MiddlewareContext(method="tools/call", message=MagicMock()) - ) - assert call_tool_settings == {"enabled": False} - - other_methods = [ - "resources/list", - "prompts/list", - "resources/read", - "prompts/get", - ] - for method in other_methods: - cache_settings = middleware._get_cache_settings( - context=MiddlewareContext(method=method, message=MagicMock()) - ) - assert cache_settings is None - - should_bypass = middleware._should_bypass_caching( - context=MiddlewareContext(method=method, message=MagicMock()) - ) - assert should_bypass - - def test_cache_key_generation(self): - """Test cache key generation.""" - from fastmcp.server.middleware.caching import ( - _make_call_tool_cache_key, - _make_get_prompt_cache_key, - _make_read_resource_cache_key, - ) - - msg = mcp.types.CallToolRequestParams( - name="test_tool", arguments={"param1": "value1", "param2": 42} - ) - - key = _make_call_tool_cache_key(msg) - - assert key == snapshot('test_tool:{"param1":"value1","param2":42}') - - msg = mcp.types.ReadResourceRequestParams( - uri=AnyUrl("https://test_uri"), - ) - - key = _make_read_resource_cache_key(msg) - - assert key == snapshot("https://test_uri/") - - msg = mcp.types.GetPromptRequestParams( - name="test_prompt", arguments={"param1": "value1"} - ) - - key = _make_get_prompt_cache_key(msg) - - assert key == snapshot('test_prompt:{"param1":"value1"}') + assert middleware1._matches_tool_cache_settings(tool_name=tool_name) is result class TestResponseCachingMiddlewareIntegration: @@ -393,7 +283,7 @@ class TestResponseCachingMiddlewareIntegration: async def caching_server( self, tracking_calculator: TrackingCalculator, - request, + request: pytest.FixtureRequest, ): """Create a FastMCP server for caching tests.""" mcp = FastMCP("CachingTestServer") @@ -401,8 +291,7 @@ async def caching_server( with tempfile.TemporaryDirectory() as temp_dir: disk_store = DiskStore(directory=temp_dir) response_caching_middleware = ResponseCachingMiddleware( - cache_store=disk_store if request.param == "disk" else MemoryStore(), - max_item_size=100000, # 100kb + cache_storage=disk_store if request.param == "disk" else MemoryStore(), ) mcp.add_middleware(middleware=response_caching_middleware) @@ -429,15 +318,15 @@ async def test_list_tools( async with Client(caching_server) as client: pre_tool_list: list[mcp.types.Tool] = await client.list_tools() - assert len(pre_tool_list) == 4 + assert len(pre_tool_list) == 5 # Add a tool and make sure it's missing from the list tool response - caching_server.add_tool( + _ = caching_server.add_tool( tool=Tool.from_function(fn=tracking_calculator.add, name="add_2") ) post_tool_list: list[mcp.types.Tool] = await client.list_tools() - assert len(post_tool_list) == 4 + assert len(post_tool_list) == 5 assert pre_tool_list == post_tool_list @@ -449,7 +338,7 @@ async def test_call_tool( """Test that caching works with a real FastMCP server.""" tracking_calculator.add_tools(fastmcp=caching_server) - async with Client[FastMCPTransport](caching_server) as client: + async with Client[FastMCPTransport](transport=caching_server) as client: call_tool_result_one: CallToolResult = await client.call_tool( "add", {"a": 5, "b": 3} ) @@ -468,7 +357,7 @@ async def test_call_tool_very_large_value( """Test that caching works with a real FastMCP server.""" tracking_calculator.add_tools(fastmcp=caching_server) - async with Client[FastMCPTransport](caching_server) as client: + async with Client[FastMCPTransport](transport=caching_server) as client: call_tool_result_one: CallToolResult = await client.call_tool( "very_large_response", {} ) @@ -480,6 +369,27 @@ async def test_call_tool_very_large_value( assert call_tool_result_one == call_tool_result_two assert tracking_calculator.very_large_response_calls == 2 + async def test_call_tool_crazy_value( + self, + caching_server: FastMCP, + tracking_calculator: TrackingCalculator, + crazy_model: CrazyModel, + ): + """Test that caching works with a real FastMCP server.""" + tracking_calculator.add_tools(fastmcp=caching_server) + + async with Client[FastMCPTransport](transport=caching_server) as client: + call_tool_result_one: CallToolResult = await client.call_tool( + "crazy", {"a": crazy_model} + ) + + assert tracking_calculator.crazy_calls == 1 + call_tool_result_two: CallToolResult = await client.call_tool( + "crazy", {"a": crazy_model} + ) + assert call_tool_result_one == call_tool_result_two + assert tracking_calculator.crazy_calls == 1 + async def test_list_resources( self, caching_server: FastMCP, tracking_calculator: TrackingCalculator ): @@ -552,3 +462,46 @@ async def test_get_prompts( ) assert pre_prompt == post_prompt + + async def test_statistics( + self, + caching_server: FastMCP, + ): + """Test that statistics are collected correctly.""" + caching_middleware = caching_server.middleware[0] + assert isinstance(caching_middleware, ResponseCachingMiddleware) + + async with Client[FastMCPTransport](transport=caching_server) as client: + statistics = caching_middleware.statistics() + assert statistics == snapshot(ResponseCachingStatistics()) + + _ = await client.call_tool("add", {"a": 5, "b": 3}) + + statistics = caching_middleware.statistics() + assert statistics == snapshot( + ResponseCachingStatistics( + list_tools=KVStoreCollectionStatistics( + get=GetStatistics(count=2, hit=1, miss=1), + put=PutStatistics(count=1), + ), + call_tool=KVStoreCollectionStatistics( + get=GetStatistics(count=1, miss=1), put=PutStatistics(count=1) + ), + ) + ) + + _ = await client.call_tool("add", {"a": 5, "b": 3}) + + statistics = caching_middleware.statistics() + assert statistics == snapshot( + ResponseCachingStatistics( + list_tools=KVStoreCollectionStatistics( + get=GetStatistics(count=2, hit=1, miss=1), + put=PutStatistics(count=1), + ), + call_tool=KVStoreCollectionStatistics( + get=GetStatistics(count=2, hit=1, miss=1), + put=PutStatistics(count=1), + ), + ) + ) From 831a5dd070aaa5c944a974cdbf4dc6017a02e644 Mon Sep 17 00:00:00 2001 From: William Easton Date: Thu, 16 Oct 2025 21:59:55 -0500 Subject: [PATCH 14/15] Unwind tool result changes --- src/fastmcp/server/middleware/caching.py | 27 +++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 9bc64b1fa7..133d6ca958 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -60,6 +60,20 @@ def unwrap(cls, values: Sequence[Self]) -> list[ReadResourceContents]: ] +class CachableToolResult(BaseModel): + content: list[mcp.types.ContentBlock] + structured_content: dict[str, Any] | None + + @classmethod + def wrap(cls, value: ToolResult) -> Self: + return cls(content=value.content, structured_content=value.structured_content) + + def unwrap(self) -> ToolResult: + return ToolResult( + content=self.content, structured_content=self.structured_content + ) + + class SharedMethodSettings(TypedDict): """Shared config for a cache method.""" @@ -202,9 +216,9 @@ def __init__( ) ) - self._call_tool_cache: PydanticAdapter[ToolResult] = PydanticAdapter( + self._call_tool_cache: PydanticAdapter[CachableToolResult] = PydanticAdapter( key_value=self._stats, - pydantic_model=ToolResult, + pydantic_model=CachableToolResult, default_collection="tools/call", ) @@ -344,17 +358,20 @@ async def on_call_tool( cache_key: str = f"{tool_name}:{_get_arguments_str(context.message.arguments)}" if cached_value := await self._call_tool_cache.get(key=cache_key): - return cached_value + return cached_value.unwrap() tool_result: ToolResult = await call_next(context=context) + cachable_tool_result: CachableToolResult = CachableToolResult.wrap( + value=tool_result + ) await self._call_tool_cache.put( key=cache_key, - value=tool_result, + value=cachable_tool_result, ttl=self._call_tool_settings.get("ttl", ONE_HOUR_IN_SECONDS), ) - return tool_result + return cachable_tool_result.unwrap() @override async def on_read_resource( From f5d770e80173cb1f96e5e9ca7331af6ad77f70a5 Mon Sep 17 00:00:00 2001 From: William Easton Date: Thu, 16 Oct 2025 22:00:57 -0500 Subject: [PATCH 15/15] update lock --- uv.lock | 38 +++++++------------------------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/uv.lock b/uv.lock index 53bb0ab6c0..9d140bc210 100644 --- a/uv.lock +++ b/uv.lock @@ -581,7 +581,6 @@ dev = [ { name = "pyperclip" }, { name = "pytest" }, { name = "pytest-asyncio" }, - { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "pytest-env" }, { name = "pytest-flakefinder" }, @@ -604,7 +603,7 @@ requires-dist = [ { name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" }, { name = "openapi-core", specifier = ">=0.19.5" }, { name = "openapi-pydantic", specifier = ">=0.5.1" }, - { name = "py-key-value-aio", extras = ["disk", "memory"], specifier = ">=0.2.2" }, + { name = "py-key-value-aio", extras = ["disk", "memory"], specifier = ">=0.2.2,<0.3.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, @@ -627,7 +626,6 @@ dev = [ { name = "pyperclip", specifier = ">=1.9.0" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-asyncio", specifier = ">=0.23.5" }, - { name = "pytest-benchmark", specifier = ">=5.1.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "pytest-env", specifier = ">=1.1.5" }, { name = "pytest-flakefinder" }, @@ -1319,26 +1317,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] -[[package]] -name = "py-cpuinfo" -version = "9.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, -] - [[package]] name = "py-key-value-aio" -version = "0.2.2" +version = "0.2.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, { name = "py-key-value-shared" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/d0/931ea2ca54eba5b1cf53e6fa29e371a58e53ce327cb84ae0317d1269400e/py_key_value_aio-0.2.2.tar.gz", hash = "sha256:e8e4ea8a9c5c5e7b1c79e019e47cd8595d0d4c2bc5be977e357de734f920c96f", size = 20877, upload-time = "2025-10-14T18:10:09.672Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/47/948cca79fdcdd6177e8852c74cfa3447bcfea1c4a133b3c532933e98eb9e/py_key_value_aio-0.2.5.tar.gz", hash = "sha256:41093d126b98e041d9b10dd38a4c28af8a9aa5ff25857d7a1018d6ee2ce4f66e", size = 29956, upload-time = "2025-10-16T16:56:29.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/eb/bb0b1cb92defee373635fc723af11e093c54b5ed614d825735c03decfc47/py_key_value_aio-0.2.2-py3-none-any.whl", hash = "sha256:59a2858807adc3bfdf24ac6e65c091ef914a871ea89f1293ccd550d48020d1a7", size = 44077, upload-time = "2025-10-14T18:10:08.874Z" }, + { url = "https://files.pythonhosted.org/packages/76/a1/d74a611a4f8b6db30e7eab6c5d5da4241b3f9bcedbb969570b508e0660bb/py_key_value_aio-0.2.5-py3-none-any.whl", hash = "sha256:ae7a3f85a5955ccdfa73fce967b7afe81a0e87ff9692dee93e5999fdbe5d0a11", size = 63327, upload-time = "2025-10-16T16:56:26.717Z" }, ] [package.optional-dependencies] @@ -1352,15 +1341,15 @@ memory = [ [[package]] name = "py-key-value-shared" -version = "0.2.2" +version = "0.2.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/35/c837273b0404ea285da8a881e4dbd47d096b866bcfacaf234ca4bd529c4c/py_key_value_shared-0.2.2.tar.gz", hash = "sha256:7e922efb721d6ba0ef23101a1d96a2a30fa2b55c2dade090f26f32f0edb09ff6", size = 7209, upload-time = "2025-10-14T18:10:10.598Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/a2/f8b1f65afd48b8774453187576a0a3ba776555d7eb8f2d7d251d82130635/py_key_value_shared-0.2.5.tar.gz", hash = "sha256:1484e6cb3a2aefa396d72e938b5acf6609d10a594f589b71bb42a7da0cbf9ebb", size = 8044, upload-time = "2025-10-16T16:56:29.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/9b/c56cc06403305c3cd8c6deb0eae91f59bbe442f0e1b195ebdb822718ea80/py_key_value_shared-0.2.2-py3-none-any.whl", hash = "sha256:5073cce73450471990e3fa01d2e2c158588a47e6324feaf067a29f0b189a7194", size = 12035, upload-time = "2025-10-14T18:10:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/96/eb/5a9caf4204953520206b6e91ac380c0cc524614534c47944fecae6a2faf2/py_key_value_shared-0.2.5-py3-none-any.whl", hash = "sha256:1e439328cb6ce697660100cf0f395e92dd4f43d3405c6eddae5986de78402045", size = 14139, upload-time = "2025-10-16T16:56:27.325Z" }, ] [[package]] @@ -1613,19 +1602,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, ] -[[package]] -name = "pytest-benchmark" -version = "5.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "py-cpuinfo" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/d0/a8bd08d641b393db3be3819b03e2d9bb8760ca8479080a26a5f6e540e99c/pytest-benchmark-5.1.0.tar.gz", hash = "sha256:9ea661cdc292e8231f7cd4c10b0319e56a2118e2c09d9f50e1b3d150d2aca105", size = 337810, upload-time = "2024-10-30T11:51:48.521Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/d6/b41653199ea09d5969d4e385df9bbfd9a100f28ca7e824ce7c0a016e3053/pytest_benchmark-5.1.0-py3-none-any.whl", hash = "sha256:922de2dfa3033c227c96da942d1878191afa135a29485fb942e85dff1c592c89", size = 44259, upload-time = "2024-10-30T11:51:45.94Z" }, -] - [[package]] name = "pytest-cov" version = "6.2.1"