From c1f8776ed6a903d013d508beb3b353345332dd1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:47:38 +0000 Subject: [PATCH 1/3] Initial plan From a39d6c85935b6de84897d265a7b9a928f674ed43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:56:06 +0000 Subject: [PATCH 2/3] Implement complete KV Store adapter with memory, disk, and Redis implementations Co-authored-by: strawgate <6384545+strawgate@users.noreply.github.com> --- README.md | 303 +++++++++++++++++++++++- examples/demo.py | 107 +++++++++ pyproject.toml | 33 +++ src/kv_store_adapter/__init__.py | 12 + src/kv_store_adapter/disk/__init__.py | 7 + src/kv_store_adapter/disk/store.py | 254 ++++++++++++++++++++ src/kv_store_adapter/exceptions.py | 23 ++ src/kv_store_adapter/memory/__init__.py | 7 + src/kv_store_adapter/memory/store.py | 183 ++++++++++++++ src/kv_store_adapter/protocol.py | 128 ++++++++++ src/kv_store_adapter/redis/__init__.py | 7 + src/kv_store_adapter/redis/store.py | 164 +++++++++++++ tests/conftest.py | 36 +++ tests/test_disk_store.py | 80 +++++++ tests/test_kv_stores.py | 227 ++++++++++++++++++ tests/test_memory_store.py | 53 +++++ 16 files changed, 1622 insertions(+), 2 deletions(-) create mode 100644 examples/demo.py create mode 100644 pyproject.toml create mode 100644 src/kv_store_adapter/__init__.py create mode 100644 src/kv_store_adapter/disk/__init__.py create mode 100644 src/kv_store_adapter/disk/store.py create mode 100644 src/kv_store_adapter/exceptions.py create mode 100644 src/kv_store_adapter/memory/__init__.py create mode 100644 src/kv_store_adapter/memory/store.py create mode 100644 src/kv_store_adapter/protocol.py create mode 100644 src/kv_store_adapter/redis/__init__.py create mode 100644 src/kv_store_adapter/redis/store.py create mode 100644 tests/conftest.py create mode 100644 tests/test_disk_store.py create mode 100644 tests/test_kv_stores.py create mode 100644 tests/test_memory_store.py diff --git a/README.md b/README.md index 0c98c087..37ec8f85 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,301 @@ -# py-kv-store-adapter -A pluggable interface for KV Stores +# KV Store Adapter + +A pluggable interface for Key-Value stores with multiple backend implementations. + +## Overview + +This package provides a common protocol for Key-Value store operations with support for: +- **Basic operations**: get, set, delete +- **TTL (Time To Live)**: Automatic expiration of keys +- **Namespaces/Collections**: Organize data into separate collections +- **Pattern matching**: Find keys using wildcard patterns +- **Multiple backends**: In-memory, disk-based, and Redis implementations + +## Features + +### Supported Operations +- `get(key, namespace=None)` - Retrieve a value +- `set(key, value, namespace=None, ttl=None)` - Store a value with optional TTL +- `delete(key, namespace=None)` - Delete a key +- `exists(key, namespace=None)` - Check if a key exists +- `ttl(key, namespace=None)` - Get remaining time-to-live +- `keys(namespace=None, pattern="*")` - List keys with pattern matching +- `clear_namespace(namespace)` - Clear all keys in a namespace +- `list_namespaces()` - List all available namespaces + +### Backend Implementations + +#### 1. Memory Store (`MemoryKVStore`) +- Fast in-memory storage using Python dictionaries +- Thread-safe with proper locking +- Data lost when process ends +- Perfect for caching and temporary storage + +#### 2. Disk Store (`DiskKVStore`) +- Persistent storage using the filesystem +- Each namespace is a directory +- Uses pickle for serialization and JSON for metadata +- Survives process restarts + +#### 3. Redis Store (`RedisKVStore`) +- Uses Redis as the backend +- Leverages Redis's native TTL support +- Requires `redis` package and Redis server +- Scalable and production-ready + +## Installation + +```bash +# Basic installation +pip install kv-store-adapter + +# With Redis support +pip install kv-store-adapter[redis] + +# Development installation +pip install kv-store-adapter[dev] +``` + +## Quick Start + +```python +from kv_store_adapter.memory import MemoryKVStore +from kv_store_adapter.disk import DiskKVStore +from kv_store_adapter.redis import RedisKVStore +from datetime import timedelta + +# Memory store +store = MemoryKVStore() + +# Disk store +store = DiskKVStore("/path/to/data") + +# Redis store (requires Redis server) +store = RedisKVStore(host="localhost", port=6379) + +# Basic operations +store.set("user:1", {"name": "Alice", "age": 30}) +user = store.get("user:1") +print(user) # {'name': 'Alice', 'age': 30} + +# With TTL +store.set("session:abc", {"user_id": 1}, ttl=timedelta(hours=1)) + +# Using namespaces +store.set("config", "production", namespace="app") +store.set("config", "debug", namespace="test") + +config = store.get("config", namespace="app") # "production" + +# Pattern matching +store.set("user:1", "Alice") +store.set("user:2", "Bob") +store.set("admin:1", "Charlie") + +users = store.keys(pattern="user:*") # ["user:1", "user:2"] +``` + +## Detailed Examples + +### Working with Namespaces + +```python +from kv_store_adapter.memory import MemoryKVStore + +store = MemoryKVStore() + +# Store data in different namespaces +store.set("settings", {"theme": "dark"}, namespace="user:1") +store.set("settings", {"theme": "light"}, namespace="user:2") +store.set("cache", "some_value", namespace="temp") + +# Retrieve from specific namespace +user1_settings = store.get("settings", namespace="user:1") + +# List keys in a namespace +temp_keys = store.keys(namespace="temp") + +# List all namespaces +namespaces = store.list_namespaces() +print(namespaces) # ['user:1', 'user:2', 'temp'] + +# Clear a namespace +cleared_count = store.clear_namespace("temp") +``` + +### TTL and Expiration + +```python +import time +from datetime import timedelta + +# Set with TTL in seconds +store.set("session", {"user": "alice"}, ttl=30) + +# Set with timedelta +store.set("cache", "data", ttl=timedelta(minutes=15)) + +# Check remaining TTL +remaining = store.ttl("session") +print(f"Session expires in {remaining:.2f} seconds") + +# Key automatically expires +time.sleep(31) +exists = store.exists("session") # False +``` + +### Error Handling + +```python +from kv_store_adapter.exceptions import KeyNotFoundError + +try: + value = store.get("nonexistent_key") +except KeyNotFoundError as e: + print(f"Key not found: {e}") + +# Safe existence check +if store.exists("key"): + value = store.get("key") +else: + print("Key does not exist") +``` + +### Disk Store Persistence + +```python +from kv_store_adapter.disk import DiskKVStore + +# Create store with custom path +store1 = DiskKVStore("/my/data/path") +store1.set("persistent_key", "persistent_value") + +# Data persists across instances +store2 = DiskKVStore("/my/data/path") +value = store2.get("persistent_key") # "persistent_value" +``` + +### Redis Store Configuration + +```python +from kv_store_adapter.redis import RedisKVStore + +# Basic connection +store = RedisKVStore() + +# Custom configuration +store = RedisKVStore( + host="redis.example.com", + port=6379, + db=1, + password="secret", + socket_timeout=10 +) + +# All Redis connection parameters are supported +``` + +## Protocol Interface + +All implementations follow the `KVStoreProtocol` interface: + +```python +from abc import ABC, abstractmethod +from typing import Any, Optional, Union, List +from datetime import timedelta + +class KVStoreProtocol(ABC): + @abstractmethod + def get(self, key: str, namespace: Optional[str] = None) -> Any: ... + + @abstractmethod + def set(self, key: str, value: Any, namespace: Optional[str] = None, + ttl: Optional[Union[int, float, timedelta]] = None) -> None: ... + + @abstractmethod + def delete(self, key: str, namespace: Optional[str] = None) -> bool: ... + + @abstractmethod + def ttl(self, key: str, namespace: Optional[str] = None) -> Optional[float]: ... + + @abstractmethod + def exists(self, key: str, namespace: Optional[str] = None) -> bool: ... + + @abstractmethod + def keys(self, namespace: Optional[str] = None, pattern: str = "*") -> List[str]: ... + + @abstractmethod + def clear_namespace(self, namespace: str) -> int: ... + + @abstractmethod + def list_namespaces(self) -> List[str]: ... +``` + +## Thread Safety + +- **MemoryKVStore**: Thread-safe using `threading.RLock` +- **DiskKVStore**: Thread-safe using `threading.RLock` +- **RedisKVStore**: Thread-safe (Redis handles concurrency) + +## Performance Characteristics + +| Implementation | Speed | Persistence | Memory Usage | Scalability | +|----------------|-------|-------------|--------------|-------------| +| Memory | Fastest | No | High | Single process | +| Disk | Medium | Yes | Low | Single process | +| Redis | Fast | Yes | Medium | Multi-process | + +## Testing + +```bash +# Run all tests +pytest tests/ + +# Run specific implementation tests +pytest tests/test_memory_store.py +pytest tests/test_disk_store.py + +# Run with coverage +pytest tests/ --cov=kv_store_adapter +``` + +## Development + +```bash +# Install in development mode +pip install -e . + +# Install with development dependencies +pip install -e .[dev] + +# Run tests +pytest + +# Run example +python examples/demo.py +``` + +## Requirements + +- Python >= 3.8 +- `redis` package (optional, for Redis implementation) + +## License + +MIT License - see LICENSE file for details. + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Add tests for new functionality +4. Ensure all tests pass +5. Submit a pull request + +## Changelog + +### v0.1.0 +- Initial release +- Memory, Disk, and Redis implementations +- Full protocol support with TTL and namespaces +- Comprehensive test suite diff --git a/examples/demo.py b/examples/demo.py new file mode 100644 index 00000000..c8dcf263 --- /dev/null +++ b/examples/demo.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Example usage of the KV Store adapter implementations. + +This script demonstrates the basic functionality of all three implementations: +- In-memory store +- Disk-based store +- Redis store (if Redis is available) +""" + +import tempfile +import shutil +from datetime import timedelta + +from kv_store_adapter.memory import MemoryKVStore +from kv_store_adapter.disk import DiskKVStore + + +def demo_store(store, store_name): + """Demonstrate basic functionality of a KV store.""" + print(f"\n=== {store_name} Demo ===") + + # Basic set/get operations + store.set("user:1", {"name": "Alice", "age": 30}) + store.set("user:2", {"name": "Bob", "age": 25}) + + print(f"Retrieved user:1: {store.get('user:1')}") + print(f"Retrieved user:2: {store.get('user:2')}") + + # Namespace operations + store.set("settings", "production", namespace="config") + store.set("settings", "debug", namespace="test") + + print(f"Config settings: {store.get('settings', namespace='config')}") + print(f"Test settings: {store.get('settings', namespace='test')}") + + # TTL operations + store.set("session:abc123", {"user_id": 1}, ttl=timedelta(seconds=5)) + ttl_remaining = store.ttl("session:abc123") + print(f"Session TTL remaining: {ttl_remaining:.2f} seconds") + + # Key listing + print(f"All keys in default namespace: {store.keys()}") + print(f"User keys: {store.keys(pattern='user:*')}") + print(f"Config namespace keys: {store.keys(namespace='config')}") + + # Namespace listing + print(f"Available namespaces: {store.list_namespaces()}") + + # Existence checks + print(f"user:1 exists: {store.exists('user:1')}") + print(f"user:3 exists: {store.exists('user:3')}") + + # Deletion + deleted = store.delete("user:2") + print(f"Deleted user:2: {deleted}") + print(f"Keys after deletion: {store.keys()}") + + +def main(): + """Main demonstration function.""" + print("KV Store Adapter Demo") + print("=====================") + + # Demo memory store + memory_store = MemoryKVStore() + demo_store(memory_store, "Memory Store") + + # Demo disk store + temp_dir = tempfile.mkdtemp() + try: + disk_store = DiskKVStore(temp_dir) + demo_store(disk_store, "Disk Store") + + print(f"\nDisk store data saved to: {temp_dir}") + print("Files created:") + import os + for root, dirs, files in os.walk(temp_dir): + level = root.replace(temp_dir, '').count(os.sep) + indent = ' ' * 2 * level + print(f"{indent}{os.path.basename(root)}/") + subindent = ' ' * 2 * (level + 1) + for file in files: + print(f"{subindent}{file}") + + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + # Demo Redis store (if available) + try: + from kv_store_adapter.redis import RedisKVStore + + # Try to create Redis store (will fail if Redis is not available) + try: + redis_store = RedisKVStore() + demo_store(redis_store, "Redis Store") + except (ImportError, ConnectionError) as e: + print(f"\nRedis Store Demo skipped: {e}") + + except ImportError: + print("\nRedis Store Demo skipped: redis package not installed") + + print("\nDemo completed!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a715daf3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "kv-store-adapter" +version = "0.1.0" +description = "A pluggable interface for KV Stores" +readme = "README.md" +requires-python = ">=3.8" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [] + +[project.optional-dependencies] +redis = ["redis>=4.0.0"] +test = ["pytest>=6.0", "pytest-asyncio"] +dev = ["pytest>=6.0", "pytest-asyncio", "black", "flake8", "mypy"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-dir] +"" = "src" \ No newline at end of file diff --git a/src/kv_store_adapter/__init__.py b/src/kv_store_adapter/__init__.py new file mode 100644 index 00000000..120c9e6e --- /dev/null +++ b/src/kv_store_adapter/__init__.py @@ -0,0 +1,12 @@ +""" +KV Store Adapter - A pluggable interface for Key-Value stores. + +This package provides a common interface for various KV store implementations +including in-memory, disk-based, and Redis-based stores. +""" + +from .protocol import KVStoreProtocol +from .exceptions import KVStoreError, KeyNotFoundError, TTLError + +__version__ = "0.1.0" +__all__ = ["KVStoreProtocol", "KVStoreError", "KeyNotFoundError", "TTLError"] \ No newline at end of file diff --git a/src/kv_store_adapter/disk/__init__.py b/src/kv_store_adapter/disk/__init__.py new file mode 100644 index 00000000..a4d8f773 --- /dev/null +++ b/src/kv_store_adapter/disk/__init__.py @@ -0,0 +1,7 @@ +""" +Disk-based KV store implementation package. +""" + +from .store import DiskKVStore + +__all__ = ["DiskKVStore"] \ No newline at end of file diff --git a/src/kv_store_adapter/disk/store.py b/src/kv_store_adapter/disk/store.py new file mode 100644 index 00000000..50ac4115 --- /dev/null +++ b/src/kv_store_adapter/disk/store.py @@ -0,0 +1,254 @@ +""" +Disk-based implementation of the KV Store protocol. +""" + +import os +import json +import time +import threading +import fnmatch +import pickle +from pathlib import Path +from typing import Any, Optional, Union, List, Dict +from datetime import datetime, timedelta + +from ..protocol import KVStoreProtocol +from ..exceptions import KeyNotFoundError + + +class DiskKVStore(KVStoreProtocol): + """ + Disk-based implementation of KV Store protocol. + + This implementation stores data in the filesystem using JSON files for metadata + and pickle files for the actual data. Each namespace is a directory, and each + key is a file within that directory. + """ + + def __init__(self, base_path: Union[str, Path] = "./kv_store_data"): + self.base_path = Path(base_path) + self.base_path.mkdir(exist_ok=True) + self._lock = threading.RLock() + + def _get_namespace_path(self, namespace: Optional[str]) -> Path: + """Get the directory path for a namespace.""" + namespace_name = namespace or "default" + return self.base_path / namespace_name + + def _get_key_paths(self, key: str, namespace: Optional[str]) -> tuple[Path, Path]: + """Get the file paths for a key's data and metadata.""" + namespace_path = self._get_namespace_path(namespace) + data_file = namespace_path / f"{key}.data" + meta_file = namespace_path / f"{key}.meta" + return data_file, meta_file + + def _is_expired(self, meta_file: Path) -> bool: + """Check if a key is expired based on its metadata file.""" + if not meta_file.exists(): + return False + + try: + with open(meta_file, 'r') as f: + metadata = json.load(f) + + if 'expires_at' in metadata: + return time.time() > metadata['expires_at'] + except (json.JSONDecodeError, KeyError, IOError): + # If we can't read metadata, consider it not expired + pass + + return False + + def _cleanup_expired_key(self, data_file: Path, meta_file: Path) -> None: + """Remove expired key files.""" + try: + if data_file.exists(): + data_file.unlink() + if meta_file.exists(): + meta_file.unlink() + except OSError: + pass # Ignore file deletion errors + + def _cleanup_expired_namespace(self, namespace_path: Path) -> None: + """Clean up expired keys in a namespace.""" + if not namespace_path.exists(): + return + + for meta_file in namespace_path.glob("*.meta"): + if self._is_expired(meta_file): + key_name = meta_file.stem + data_file = namespace_path / f"{key_name}.data" + self._cleanup_expired_key(data_file, meta_file) + + def get(self, key: str, namespace: Optional[str] = None) -> Any: + """Retrieve a value by key from the specified namespace.""" + with self._lock: + data_file, meta_file = self._get_key_paths(key, namespace) + + # Check if expired and clean up + if self._is_expired(meta_file): + self._cleanup_expired_key(data_file, meta_file) + raise KeyNotFoundError(f"Key '{key}' not found in namespace '{namespace or 'default'}'") + + if not data_file.exists(): + raise KeyNotFoundError(f"Key '{key}' not found in namespace '{namespace or 'default'}'") + + try: + with open(data_file, 'rb') as f: + return pickle.load(f) + except (IOError, pickle.PickleError) as e: + raise KeyNotFoundError(f"Error reading key '{key}': {e}") + + def set(self, key: str, value: Any, namespace: Optional[str] = None, + ttl: Optional[Union[int, float, timedelta]] = None) -> None: + """Store a key-value pair in the specified namespace.""" + with self._lock: + namespace_path = self._get_namespace_path(namespace) + namespace_path.mkdir(exist_ok=True) + + data_file, meta_file = self._get_key_paths(key, namespace) + + # Store the value + try: + with open(data_file, 'wb') as f: + pickle.dump(value, f) + except (IOError, pickle.PickleError) as e: + raise Exception(f"Error storing key '{key}': {e}") + + # Store metadata + metadata = { + 'created_at': time.time(), + 'updated_at': time.time() + } + + if ttl is not None: + if isinstance(ttl, timedelta): + ttl_seconds = ttl.total_seconds() + else: + ttl_seconds = float(ttl) + + metadata['expires_at'] = time.time() + ttl_seconds + + try: + with open(meta_file, 'w') as f: + json.dump(metadata, f) + except (IOError, json.JSONEncodeError) as e: + # If metadata write fails, clean up the data file + if data_file.exists(): + data_file.unlink() + raise Exception(f"Error storing metadata for key '{key}': {e}") + + def delete(self, key: str, namespace: Optional[str] = None) -> bool: + """Delete a key from the specified namespace.""" + with self._lock: + data_file, meta_file = self._get_key_paths(key, namespace) + + exists = data_file.exists() or meta_file.exists() + + # Remove both files if they exist + try: + if data_file.exists(): + data_file.unlink() + if meta_file.exists(): + meta_file.unlink() + except OSError: + pass # Ignore deletion errors + + return exists + + def ttl(self, key: str, namespace: Optional[str] = None) -> Optional[float]: + """Get the time-to-live for a key in seconds.""" + with self._lock: + if not self.exists(key, namespace): + return None + + _, meta_file = self._get_key_paths(key, namespace) + + if not meta_file.exists(): + return None # No TTL set + + try: + with open(meta_file, 'r') as f: + metadata = json.load(f) + + if 'expires_at' not in metadata: + return None # No TTL set + + remaining = metadata['expires_at'] - time.time() + return max(0.0, remaining) + except (json.JSONDecodeError, KeyError, IOError): + return None + + def exists(self, key: str, namespace: Optional[str] = None) -> bool: + """Check if a key exists in the specified namespace.""" + with self._lock: + data_file, meta_file = self._get_key_paths(key, namespace) + + # Check if expired + if self._is_expired(meta_file): + self._cleanup_expired_key(data_file, meta_file) + return False + + return data_file.exists() + + def keys(self, namespace: Optional[str] = None, pattern: str = "*") -> List[str]: + """List keys in the specified namespace matching the pattern.""" + with self._lock: + namespace_path = self._get_namespace_path(namespace) + + if not namespace_path.exists(): + return [] + + # Clean up expired keys first + self._cleanup_expired_namespace(namespace_path) + + # Get all data files + all_keys = [f.stem for f in namespace_path.glob("*.data")] + + if pattern == "*": + return all_keys + + return [key for key in all_keys if fnmatch.fnmatch(key, pattern)] + + def clear_namespace(self, namespace: str) -> int: + """Clear all keys in a namespace.""" + with self._lock: + namespace_path = self._get_namespace_path(namespace) + + if not namespace_path.exists(): + return 0 + + count = 0 + # Remove all .data and .meta files + for file_path in namespace_path.glob("*"): + if file_path.is_file() and file_path.suffix in ['.data', '.meta']: + try: + file_path.unlink() + if file_path.suffix == '.data': + count += 1 + except OSError: + pass # Ignore deletion errors + + # Try to remove the directory if it's empty + try: + namespace_path.rmdir() + except OSError: + pass # Directory not empty or other error + + return count + + def list_namespaces(self) -> List[str]: + """List all available namespaces.""" + with self._lock: + namespaces = [] + + for item in self.base_path.iterdir(): + if item.is_dir(): + # Clean up expired keys in the namespace + self._cleanup_expired_namespace(item) + + # Check if namespace has any data files + if any(item.glob("*.data")): + namespaces.append(item.name) + + return namespaces \ No newline at end of file diff --git a/src/kv_store_adapter/exceptions.py b/src/kv_store_adapter/exceptions.py new file mode 100644 index 00000000..fac35e19 --- /dev/null +++ b/src/kv_store_adapter/exceptions.py @@ -0,0 +1,23 @@ +""" +Custom exceptions for KV Store operations. +""" + + +class KVStoreError(Exception): + """Base exception for all KV store operations.""" + pass + + +class KeyNotFoundError(KVStoreError): + """Raised when a key is not found in the store.""" + pass + + +class TTLError(KVStoreError): + """Raised when there's an error with TTL operations.""" + pass + + +class NamespaceError(KVStoreError): + """Raised when there's an error with namespace operations.""" + pass \ No newline at end of file diff --git a/src/kv_store_adapter/memory/__init__.py b/src/kv_store_adapter/memory/__init__.py new file mode 100644 index 00000000..e2fca2c0 --- /dev/null +++ b/src/kv_store_adapter/memory/__init__.py @@ -0,0 +1,7 @@ +""" +In-memory KV store implementation package. +""" + +from .store import MemoryKVStore + +__all__ = ["MemoryKVStore"] \ No newline at end of file diff --git a/src/kv_store_adapter/memory/store.py b/src/kv_store_adapter/memory/store.py new file mode 100644 index 00000000..c82ea854 --- /dev/null +++ b/src/kv_store_adapter/memory/store.py @@ -0,0 +1,183 @@ +""" +In-memory implementation of the KV Store protocol. +""" + +import time +import threading +import fnmatch +from typing import Any, Optional, Union, List, Dict, Tuple +from datetime import datetime, timedelta + +from ..protocol import KVStoreProtocol +from ..exceptions import KeyNotFoundError + + +class MemoryKVStore(KVStoreProtocol): + """ + In-memory implementation of KV Store protocol. + + This implementation stores all data in memory using Python dictionaries. + TTL is implemented using expiration timestamps. + Thread-safe operations are ensured using locks. + """ + + def __init__(self): + self._data: Dict[str, Dict[str, Any]] = {} # namespace -> key -> value + self._ttl: Dict[str, Dict[str, float]] = {} # namespace -> key -> expiration_time + self._lock = threading.RLock() + + def _get_namespace_key(self, namespace: Optional[str]) -> str: + """Get the internal namespace key, defaulting to 'default'.""" + return namespace or "default" + + def _cleanup_expired(self, namespace_key: str) -> None: + """Remove expired keys from a namespace.""" + if namespace_key not in self._ttl: + return + + current_time = time.time() + expired_keys = [ + key for key, exp_time in self._ttl[namespace_key].items() + if exp_time <= current_time + ] + + for key in expired_keys: + if namespace_key in self._data and key in self._data[namespace_key]: + del self._data[namespace_key][key] + del self._ttl[namespace_key][key] + + def _is_expired(self, key: str, namespace_key: str) -> bool: + """Check if a key is expired.""" + if namespace_key not in self._ttl or key not in self._ttl[namespace_key]: + return False + return self._ttl[namespace_key][key] <= time.time() + + def get(self, key: str, namespace: Optional[str] = None) -> Any: + """Retrieve a value by key from the specified namespace.""" + namespace_key = self._get_namespace_key(namespace) + + with self._lock: + self._cleanup_expired(namespace_key) + + if (namespace_key not in self._data or + key not in self._data[namespace_key] or + self._is_expired(key, namespace_key)): + raise KeyNotFoundError(f"Key '{key}' not found in namespace '{namespace_key}'") + + return self._data[namespace_key][key] + + def set(self, key: str, value: Any, namespace: Optional[str] = None, + ttl: Optional[Union[int, float, timedelta]] = None) -> None: + """Store a key-value pair in the specified namespace.""" + namespace_key = self._get_namespace_key(namespace) + + with self._lock: + # Initialize namespace if it doesn't exist + if namespace_key not in self._data: + self._data[namespace_key] = {} + if namespace_key not in self._ttl: + self._ttl[namespace_key] = {} + + # Store the value + self._data[namespace_key][key] = value + + # Handle TTL + if ttl is not None: + if isinstance(ttl, timedelta): + ttl_seconds = ttl.total_seconds() + else: + ttl_seconds = float(ttl) + + self._ttl[namespace_key][key] = time.time() + ttl_seconds + else: + # Remove any existing TTL + if key in self._ttl[namespace_key]: + del self._ttl[namespace_key][key] + + def delete(self, key: str, namespace: Optional[str] = None) -> bool: + """Delete a key from the specified namespace.""" + namespace_key = self._get_namespace_key(namespace) + + with self._lock: + self._cleanup_expired(namespace_key) + + if (namespace_key not in self._data or + key not in self._data[namespace_key]): + return False + + del self._data[namespace_key][key] + + # Remove TTL if it exists + if namespace_key in self._ttl and key in self._ttl[namespace_key]: + del self._ttl[namespace_key][key] + + return True + + def ttl(self, key: str, namespace: Optional[str] = None) -> Optional[float]: + """Get the time-to-live for a key in seconds.""" + namespace_key = self._get_namespace_key(namespace) + + with self._lock: + if not self.exists(key, namespace): + return None + + if (namespace_key not in self._ttl or + key not in self._ttl[namespace_key]): + return None # No TTL set + + remaining = self._ttl[namespace_key][key] - time.time() + return max(0.0, remaining) + + def exists(self, key: str, namespace: Optional[str] = None) -> bool: + """Check if a key exists in the specified namespace.""" + namespace_key = self._get_namespace_key(namespace) + + with self._lock: + self._cleanup_expired(namespace_key) + + return (namespace_key in self._data and + key in self._data[namespace_key] and + not self._is_expired(key, namespace_key)) + + def keys(self, namespace: Optional[str] = None, pattern: str = "*") -> List[str]: + """List keys in the specified namespace matching the pattern.""" + namespace_key = self._get_namespace_key(namespace) + + with self._lock: + self._cleanup_expired(namespace_key) + + if namespace_key not in self._data: + return [] + + all_keys = list(self._data[namespace_key].keys()) + + if pattern == "*": + return all_keys + + return [key for key in all_keys if fnmatch.fnmatch(key, pattern)] + + def clear_namespace(self, namespace: str) -> int: + """Clear all keys in a namespace.""" + namespace_key = self._get_namespace_key(namespace) + + with self._lock: + if namespace_key not in self._data: + return 0 + + count = len(self._data[namespace_key]) + self._data[namespace_key].clear() + + if namespace_key in self._ttl: + self._ttl[namespace_key].clear() + + return count + + def list_namespaces(self) -> List[str]: + """List all available namespaces.""" + with self._lock: + # Clean up expired keys first + for ns in list(self._data.keys()): + self._cleanup_expired(ns) + + # Return namespaces that have data + return [ns for ns in self._data.keys() if self._data[ns]] \ No newline at end of file diff --git a/src/kv_store_adapter/protocol.py b/src/kv_store_adapter/protocol.py new file mode 100644 index 00000000..4b4c36b9 --- /dev/null +++ b/src/kv_store_adapter/protocol.py @@ -0,0 +1,128 @@ +""" +Protocol definition for KV Store implementations. +""" + +from abc import ABC, abstractmethod +from typing import Any, Optional, Union, List +from datetime import datetime, timedelta + + +class KVStoreProtocol(ABC): + """ + Abstract base class defining the interface for Key-Value store implementations. + + This protocol supports: + - Basic operations: get, set, delete + - TTL (Time To Live) functionality + - Namespace/collection support for data organization + """ + + @abstractmethod + def get(self, key: str, namespace: Optional[str] = None) -> Any: + """ + Retrieve a value by key from the specified namespace. + + Args: + key: The key to retrieve + namespace: Optional namespace/collection name + + Returns: + The value associated with the key + + Raises: + KeyNotFoundError: If the key is not found + """ + pass + + @abstractmethod + def set(self, key: str, value: Any, namespace: Optional[str] = None, + ttl: Optional[Union[int, float, timedelta]] = None) -> None: + """ + Store a key-value pair in the specified namespace. + + Args: + key: The key to store + value: The value to store + namespace: Optional namespace/collection name + ttl: Time to live in seconds (int/float) or timedelta object + """ + pass + + @abstractmethod + def delete(self, key: str, namespace: Optional[str] = None) -> bool: + """ + Delete a key from the specified namespace. + + Args: + key: The key to delete + namespace: Optional namespace/collection name + + Returns: + True if the key was deleted, False if it didn't exist + """ + pass + + @abstractmethod + def ttl(self, key: str, namespace: Optional[str] = None) -> Optional[float]: + """ + Get the time-to-live for a key in seconds. + + Args: + key: The key to check + namespace: Optional namespace/collection name + + Returns: + Remaining TTL in seconds, None if no TTL is set, or if key doesn't exist + """ + pass + + @abstractmethod + def exists(self, key: str, namespace: Optional[str] = None) -> bool: + """ + Check if a key exists in the specified namespace. + + Args: + key: The key to check + namespace: Optional namespace/collection name + + Returns: + True if the key exists, False otherwise + """ + pass + + @abstractmethod + def keys(self, namespace: Optional[str] = None, pattern: str = "*") -> List[str]: + """ + List keys in the specified namespace matching the pattern. + + Args: + namespace: Optional namespace/collection name + pattern: Pattern to match keys against (supports * wildcard) + + Returns: + List of matching keys + """ + pass + + @abstractmethod + def clear_namespace(self, namespace: str) -> int: + """ + Clear all keys in a namespace. + + Args: + namespace: The namespace to clear + + Returns: + Number of keys deleted + """ + pass + + @abstractmethod + def list_namespaces(self) -> List[str]: + """ + List all available namespaces. + + Returns: + List of namespace names + """ + pass \ No newline at end of file diff --git a/src/kv_store_adapter/redis/__init__.py b/src/kv_store_adapter/redis/__init__.py new file mode 100644 index 00000000..0731f9cb --- /dev/null +++ b/src/kv_store_adapter/redis/__init__.py @@ -0,0 +1,7 @@ +""" +Redis-based KV store implementation package. +""" + +from .store import RedisKVStore + +__all__ = ["RedisKVStore"] \ No newline at end of file diff --git a/src/kv_store_adapter/redis/store.py b/src/kv_store_adapter/redis/store.py new file mode 100644 index 00000000..89c96358 --- /dev/null +++ b/src/kv_store_adapter/redis/store.py @@ -0,0 +1,164 @@ +""" +Redis-based implementation of the KV Store protocol. +""" + +import pickle +import fnmatch +from typing import Any, Optional, Union, List +from datetime import timedelta + +from ..protocol import KVStoreProtocol +from ..exceptions import KeyNotFoundError + +try: + import redis +except ImportError: + redis = None + + +class RedisKVStore(KVStoreProtocol): + """ + Redis-based implementation of KV Store protocol. + + This implementation uses Redis as the backend storage. + Namespaces are implemented using key prefixes. + """ + + def __init__(self, host: str = "localhost", port: int = 6379, db: int = 0, + password: Optional[str] = None, **redis_kwargs): + if redis is None: + raise ImportError("redis package is required for RedisKVStore. Install with: pip install redis") + + self.redis_client = redis.Redis( + host=host, + port=port, + db=db, + password=password, + decode_responses=False, # We'll handle encoding ourselves + **redis_kwargs + ) + + # Test connection + try: + self.redis_client.ping() + except redis.ConnectionError as e: + raise ConnectionError(f"Failed to connect to Redis: {e}") + + def _get_redis_key(self, key: str, namespace: Optional[str]) -> str: + """Get the Redis key with namespace prefix.""" + namespace_prefix = f"{namespace or 'default'}:" + return f"{namespace_prefix}{key}" + + def _get_namespace_prefix(self, namespace: Optional[str]) -> str: + """Get the namespace prefix for pattern matching.""" + return f"{namespace or 'default'}:*" + + def _strip_namespace_from_key(self, redis_key: str, namespace: Optional[str]) -> str: + """Remove namespace prefix from Redis key to get the original key.""" + namespace_prefix = f"{namespace or 'default'}:" + if redis_key.startswith(namespace_prefix): + return redis_key[len(namespace_prefix):] + return redis_key + + def _serialize_value(self, value: Any) -> bytes: + """Serialize a value for storage in Redis.""" + return pickle.dumps(value) + + def _deserialize_value(self, data: bytes) -> Any: + """Deserialize a value from Redis storage.""" + return pickle.loads(data) + + def get(self, key: str, namespace: Optional[str] = None) -> Any: + """Retrieve a value by key from the specified namespace.""" + redis_key = self._get_redis_key(key, namespace) + + data = self.redis_client.get(redis_key) + if data is None: + raise KeyNotFoundError(f"Key '{key}' not found in namespace '{namespace or 'default'}'") + + return self._deserialize_value(data) + + def set(self, key: str, value: Any, namespace: Optional[str] = None, + ttl: Optional[Union[int, float, timedelta]] = None) -> None: + """Store a key-value pair in the specified namespace.""" + redis_key = self._get_redis_key(key, namespace) + serialized_value = self._serialize_value(value) + + if ttl is not None: + if isinstance(ttl, timedelta): + ttl_seconds = ttl.total_seconds() + else: + ttl_seconds = float(ttl) + + # Redis expects integer seconds for ex parameter + self.redis_client.setex(redis_key, int(ttl_seconds), serialized_value) + else: + self.redis_client.set(redis_key, serialized_value) + + def delete(self, key: str, namespace: Optional[str] = None) -> bool: + """Delete a key from the specified namespace.""" + redis_key = self._get_redis_key(key, namespace) + result = self.redis_client.delete(redis_key) + return result > 0 + + def ttl(self, key: str, namespace: Optional[str] = None) -> Optional[float]: + """Get the time-to-live for a key in seconds.""" + redis_key = self._get_redis_key(key, namespace) + + if not self.redis_client.exists(redis_key): + return None + + ttl_seconds = self.redis_client.ttl(redis_key) + + if ttl_seconds == -1: + return None # No TTL set + elif ttl_seconds == -2: + return None # Key doesn't exist (shouldn't happen due to exists check) + else: + return float(ttl_seconds) + + def exists(self, key: str, namespace: Optional[str] = None) -> bool: + """Check if a key exists in the specified namespace.""" + redis_key = self._get_redis_key(key, namespace) + return bool(self.redis_client.exists(redis_key)) + + def keys(self, namespace: Optional[str] = None, pattern: str = "*") -> List[str]: + """List keys in the specified namespace matching the pattern.""" + # Get all keys in the namespace + namespace_pattern = self._get_namespace_prefix(namespace) + redis_keys = self.redis_client.keys(namespace_pattern) + + # Strip namespace prefix and filter by pattern + original_keys = [ + self._strip_namespace_from_key(key.decode('utf-8'), namespace) + for key in redis_keys + ] + + if pattern == "*": + return original_keys + + return [key for key in original_keys if fnmatch.fnmatch(key, pattern)] + + def clear_namespace(self, namespace: str) -> int: + """Clear all keys in a namespace.""" + namespace_pattern = self._get_namespace_prefix(namespace) + keys_to_delete = self.redis_client.keys(namespace_pattern) + + if not keys_to_delete: + return 0 + + return self.redis_client.delete(*keys_to_delete) + + def list_namespaces(self) -> List[str]: + """List all available namespaces.""" + # Get all keys and extract unique namespace prefixes + all_keys = self.redis_client.keys("*") + namespaces = set() + + for key in all_keys: + key_str = key.decode('utf-8') + if ':' in key_str: + namespace = key_str.split(':', 1)[0] + namespaces.add(namespace) + + return list(namespaces) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..e18018e4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,36 @@ +""" +Test configuration and fixtures. +""" + +import pytest +import tempfile +import shutil +from pathlib import Path + +from kv_store_adapter.memory import MemoryKVStore +from kv_store_adapter.disk import DiskKVStore + + +@pytest.fixture +def memory_store(): + """Create a fresh in-memory KV store for testing.""" + return MemoryKVStore() + + +@pytest.fixture +def disk_store(): + """Create a fresh disk-based KV store for testing.""" + temp_dir = tempfile.mkdtemp() + store = DiskKVStore(temp_dir) + yield store + # Cleanup after test + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.fixture(params=["memory", "disk"]) +def kv_store(request, memory_store, disk_store): + """Parameterized fixture that tests both memory and disk implementations.""" + if request.param == "memory": + return memory_store + elif request.param == "disk": + return disk_store \ No newline at end of file diff --git a/tests/test_disk_store.py b/tests/test_disk_store.py new file mode 100644 index 00000000..374de1ca --- /dev/null +++ b/tests/test_disk_store.py @@ -0,0 +1,80 @@ +""" +Tests specific to the disk implementation. +""" + +import pytest +import tempfile +import shutil +from pathlib import Path + +from kv_store_adapter.disk import DiskKVStore + + +class TestDiskKVStoreSpecific: + """Tests specific to disk implementation.""" + + def test_persistence_across_instances(self): + """Test that data persists across different store instances.""" + temp_dir = tempfile.mkdtemp() + + try: + # Create first store instance and add data + store1 = DiskKVStore(temp_dir) + store1.set("persistent_key", "persistent_value") + store1.set("ns_key", "ns_value", namespace="test_ns") + + # Create second store instance with same directory + store2 = DiskKVStore(temp_dir) + + # Data should be available in second instance + assert store2.get("persistent_key") == "persistent_value" + assert store2.get("ns_key", namespace="test_ns") == "ns_value" + + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + def test_file_structure(self): + """Test that the correct file structure is created.""" + temp_dir = tempfile.mkdtemp() + + try: + store = DiskKVStore(temp_dir) + store.set("test_key", "test_value") + store.set("ns_key", "ns_value", namespace="test_ns") + + base_path = Path(temp_dir) + + # Check default namespace files + default_ns = base_path / "default" + assert default_ns.exists() + assert (default_ns / "test_key.data").exists() + assert (default_ns / "test_key.meta").exists() + + # Check custom namespace files + test_ns = base_path / "test_ns" + assert test_ns.exists() + assert (test_ns / "ns_key.data").exists() + assert (test_ns / "ns_key.meta").exists() + + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + def test_corrupted_files_handling(self): + """Test handling of corrupted data files.""" + temp_dir = tempfile.mkdtemp() + + try: + store = DiskKVStore(temp_dir) + store.set("test_key", "test_value") + + # Corrupt the data file + data_file = Path(temp_dir) / "default" / "test_key.data" + with open(data_file, 'w') as f: + f.write("corrupted data") + + # Should raise KeyNotFoundError due to pickle error + with pytest.raises(Exception): # Could be KeyNotFoundError or pickle error + store.get("test_key") + + finally: + shutil.rmtree(temp_dir, ignore_errors=True) \ No newline at end of file diff --git a/tests/test_kv_stores.py b/tests/test_kv_stores.py new file mode 100644 index 00000000..1a916bf5 --- /dev/null +++ b/tests/test_kv_stores.py @@ -0,0 +1,227 @@ +""" +Basic tests for KV Store implementations. +""" + +import time +import pytest +from datetime import timedelta + +from kv_store_adapter.exceptions import KeyNotFoundError + + +class TestKVStoreBasics: + """Test basic KV store operations.""" + + def test_set_and_get(self, kv_store): + """Test setting and getting values.""" + kv_store.set("test_key", "test_value") + assert kv_store.get("test_key") == "test_value" + + def test_get_nonexistent_key(self, kv_store): + """Test getting a key that doesn't exist.""" + with pytest.raises(KeyNotFoundError): + kv_store.get("nonexistent_key") + + def test_delete_existing_key(self, kv_store): + """Test deleting an existing key.""" + kv_store.set("test_key", "test_value") + assert kv_store.delete("test_key") is True + + with pytest.raises(KeyNotFoundError): + kv_store.get("test_key") + + def test_delete_nonexistent_key(self, kv_store): + """Test deleting a key that doesn't exist.""" + assert kv_store.delete("nonexistent_key") is False + + def test_exists(self, kv_store): + """Test checking if keys exist.""" + assert kv_store.exists("test_key") is False + + kv_store.set("test_key", "test_value") + assert kv_store.exists("test_key") is True + + kv_store.delete("test_key") + assert kv_store.exists("test_key") is False + + def test_keys_listing(self, kv_store): + """Test listing keys.""" + # Initially empty + assert kv_store.keys() == [] + + # Add some keys + kv_store.set("key1", "value1") + kv_store.set("key2", "value2") + kv_store.set("test_key", "test_value") + + keys = kv_store.keys() + assert len(keys) == 3 + assert "key1" in keys + assert "key2" in keys + assert "test_key" in keys + + def test_keys_pattern_matching(self, kv_store): + """Test listing keys with patterns.""" + kv_store.set("user_1", "data1") + kv_store.set("user_2", "data2") + kv_store.set("admin_1", "admin_data") + + user_keys = kv_store.keys(pattern="user_*") + assert len(user_keys) == 2 + assert "user_1" in user_keys + assert "user_2" in user_keys + assert "admin_1" not in user_keys + + +class TestKVStoreNamespaces: + """Test namespace/collection functionality.""" + + def test_namespace_isolation(self, kv_store): + """Test that namespaces isolate data.""" + kv_store.set("key1", "default_value") + kv_store.set("key1", "ns1_value", namespace="namespace1") + kv_store.set("key1", "ns2_value", namespace="namespace2") + + assert kv_store.get("key1") == "default_value" + assert kv_store.get("key1", namespace="namespace1") == "ns1_value" + assert kv_store.get("key1", namespace="namespace2") == "ns2_value" + + def test_namespace_keys_listing(self, kv_store): + """Test listing keys within namespaces.""" + kv_store.set("key1", "value1") + kv_store.set("key2", "value2") + kv_store.set("key1", "ns_value1", namespace="test_ns") + kv_store.set("key3", "ns_value3", namespace="test_ns") + + default_keys = kv_store.keys() + assert len(default_keys) == 2 + assert "key1" in default_keys + assert "key2" in default_keys + + ns_keys = kv_store.keys(namespace="test_ns") + assert len(ns_keys) == 2 + assert "key1" in ns_keys + assert "key3" in ns_keys + + def test_clear_namespace(self, kv_store): + """Test clearing all keys in a namespace.""" + kv_store.set("key1", "value1") + kv_store.set("key2", "value2") + kv_store.set("key1", "ns_value1", namespace="test_ns") + kv_store.set("key3", "ns_value3", namespace="test_ns") + + # Clear the test namespace + cleared_count = kv_store.clear_namespace("test_ns") + assert cleared_count == 2 + + # Default namespace should be unchanged + assert len(kv_store.keys()) == 2 + + # Test namespace should be empty + assert len(kv_store.keys(namespace="test_ns")) == 0 + + def test_list_namespaces(self, kv_store): + """Test listing available namespaces.""" + # Initially no namespaces (or just default) + namespaces = kv_store.list_namespaces() + + # Add data to different namespaces + kv_store.set("key1", "value1") # default namespace + kv_store.set("key1", "value1", namespace="ns1") + kv_store.set("key1", "value1", namespace="ns2") + + namespaces = kv_store.list_namespaces() + assert "default" in namespaces + assert "ns1" in namespaces + assert "ns2" in namespaces + + +class TestKVStoreTTL: + """Test TTL (Time To Live) functionality.""" + + def test_ttl_with_seconds(self, kv_store): + """Test TTL with seconds as integer.""" + kv_store.set("ttl_key", "ttl_value", ttl=2) + + # Key should exist initially + assert kv_store.exists("ttl_key") is True + assert kv_store.get("ttl_key") == "ttl_value" + + # TTL should be approximately 2 seconds + ttl_remaining = kv_store.ttl("ttl_key") + assert ttl_remaining is not None + assert 1.0 <= ttl_remaining <= 2.0 + + def test_ttl_with_timedelta(self, kv_store): + """Test TTL with timedelta object.""" + kv_store.set("ttl_key", "ttl_value", ttl=timedelta(seconds=3)) + + assert kv_store.exists("ttl_key") is True + ttl_remaining = kv_store.ttl("ttl_key") + assert ttl_remaining is not None + assert 2.0 <= ttl_remaining <= 3.0 + + def test_ttl_expiration(self, kv_store): + """Test that keys expire after TTL.""" + kv_store.set("expire_key", "expire_value", ttl=0.1) # 100ms + + # Key should exist initially + assert kv_store.exists("expire_key") is True + + # Wait for expiration + time.sleep(0.2) + + # Key should be gone + assert kv_store.exists("expire_key") is False + with pytest.raises(KeyNotFoundError): + kv_store.get("expire_key") + + def test_ttl_no_expiration(self, kv_store): + """Test keys without TTL don't expire.""" + kv_store.set("persistent_key", "persistent_value") + + assert kv_store.ttl("persistent_key") is None + + # Wait a bit and verify key still exists + time.sleep(0.1) + assert kv_store.exists("persistent_key") is True + + def test_ttl_nonexistent_key(self, kv_store): + """Test TTL for non-existent key.""" + assert kv_store.ttl("nonexistent_key") is None + + +class TestKVStoreDataTypes: + """Test storing different data types.""" + + def test_string_values(self, kv_store): + """Test storing string values.""" + kv_store.set("string_key", "string_value") + assert kv_store.get("string_key") == "string_value" + + def test_integer_values(self, kv_store): + """Test storing integer values.""" + kv_store.set("int_key", 42) + assert kv_store.get("int_key") == 42 + + def test_float_values(self, kv_store): + """Test storing float values.""" + kv_store.set("float_key", 3.14159) + assert kv_store.get("float_key") == 3.14159 + + def test_list_values(self, kv_store): + """Test storing list values.""" + test_list = [1, 2, 3, "four", 5.0] + kv_store.set("list_key", test_list) + assert kv_store.get("list_key") == test_list + + def test_dict_values(self, kv_store): + """Test storing dictionary values.""" + test_dict = {"name": "test", "value": 123, "nested": {"key": "value"}} + kv_store.set("dict_key", test_dict) + assert kv_store.get("dict_key") == test_dict + + def test_none_values(self, kv_store): + """Test storing None values.""" + kv_store.set("none_key", None) + assert kv_store.get("none_key") is None \ No newline at end of file diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py new file mode 100644 index 00000000..b18c5277 --- /dev/null +++ b/tests/test_memory_store.py @@ -0,0 +1,53 @@ +""" +Tests specific to the memory implementation. +""" + +import pytest +import threading +import time + +from kv_store_adapter.memory import MemoryKVStore + + +class TestMemoryKVStoreSpecific: + """Tests specific to memory implementation.""" + + def test_thread_safety(self): + """Test that memory store is thread-safe.""" + store = MemoryKVStore() + results = [] + + def worker(thread_id): + """Worker function for threading test.""" + for i in range(100): + key = f"thread_{thread_id}_key_{i}" + value = f"thread_{thread_id}_value_{i}" + store.set(key, value) + retrieved = store.get(key) + results.append(retrieved == value) + + # Create multiple threads + threads = [] + for i in range(5): + thread = threading.Thread(target=worker, args=(i,)) + threads.append(thread) + thread.start() + + # Wait for all threads to complete + for thread in threads: + thread.join() + + # All operations should have succeeded + assert all(results) + assert len(results) == 500 # 5 threads * 100 operations + + def test_memory_isolation_between_instances(self): + """Test that different memory store instances are isolated.""" + store1 = MemoryKVStore() + store2 = MemoryKVStore() + + store1.set("shared_key", "store1_value") + store2.set("shared_key", "store2_value") + + assert store1.get("shared_key") == "store1_value" + assert store2.get("shared_key") == "store2_value" \ No newline at end of file From 596d7c6b1852e8a16d77eeabeba101bce33d8844 Mon Sep 17 00:00:00 2001 From: William Easton Date: Wed, 24 Sep 2025 11:44:36 -0500 Subject: [PATCH 3/3] Initial Commit --- .../workflows/publish-py-kv-store-adapter.yml | 39 + .github/workflows/test_pull_request.yml | 41 + .vscode/launch.json | 31 + .vscode/settings.json | 7 + DEVELOPING.md | 388 ++++++ README.md | 370 ++--- examples/demo.py | 107 -- pyproject.toml | 129 +- src/kv_store_adapter/__init__.py | 11 - src/kv_store_adapter/adapters/pydantic.py | 58 + .../adapters/single_collection.py | 30 + src/kv_store_adapter/disk/__init__.py | 7 - src/kv_store_adapter/disk/store.py | 254 ---- src/kv_store_adapter/errors.py | 50 + src/kv_store_adapter/exceptions.py | 23 - src/kv_store_adapter/memory/__init__.py | 7 - src/kv_store_adapter/memory/store.py | 183 --- src/kv_store_adapter/protocol.py | 128 -- src/kv_store_adapter/redis/__init__.py | 7 - src/kv_store_adapter/redis/store.py | 164 --- src/kv_store_adapter/stores/__init__.py | 1 + src/kv_store_adapter/stores/base/__init__.py | 0 src/kv_store_adapter/stores/base/managed.py | 121 ++ src/kv_store_adapter/stores/base/unmanaged.py | 75 ++ src/kv_store_adapter/stores/disk/__init__.py | 3 + src/kv_store_adapter/stores/disk/store.py | 92 ++ .../stores/elasticsearch/__init__.py | 3 + .../stores/elasticsearch/store.py | 265 ++++ .../stores/elasticsearch/utils.py | 107 ++ .../stores/memory/__init__.py | 3 + src/kv_store_adapter/stores/memory/store.py | 109 ++ src/kv_store_adapter/stores/null/__init__.py | 3 + src/kv_store_adapter/stores/null/store.py | 53 + src/kv_store_adapter/stores/redis/__init__.py | 3 + src/kv_store_adapter/stores/redis/store.py | 158 +++ .../stores/simple/__init__.py | 4 + .../stores/simple/json_store.py | 69 + src/kv_store_adapter/stores/simple/store.py | 166 +++ src/kv_store_adapter/stores/utils/compound.py | 69 + .../stores/utils/managed_entry.py | 85 ++ .../stores/utils/time_to_live.py | 10 + .../stores/wrappers/__init__.py | 6 + .../stores/wrappers/clamp_ttl.py | 69 + .../stores/wrappers/passthrough_cache.py | 81 ++ .../stores/wrappers/prefix_collection.py | 76 ++ .../stores/wrappers/prefix_key.py | 69 + .../stores/wrappers/single_collection.py | 68 + .../stores/wrappers/statistics.py | 197 +++ src/kv_store_adapter/types.py | 43 + tests/__init__.py | 0 tests/adapters/__init__.py | 0 tests/adapters/test_pydantic.py | 72 + tests/adapters/test_single_collection.py | 39 + tests/cases.py | 40 + tests/conftest.py | 36 - tests/stores/__init__.py | 0 tests/stores/base/__init__.py | 0 tests/stores/base/test_kv_json_store.py | 39 + tests/stores/conftest.py | 188 +++ tests/stores/disk/__init__.py | 0 tests/stores/disk/test_disk.py | 18 + tests/stores/elasticsearch/__init__.py | 0 .../elasticsearch/test_elasticsearch.py | 49 + tests/stores/memory/__init__.py | 0 tests/stores/memory/test_memory.py | 12 + tests/stores/redis/__init__.py | 0 tests/stores/redis/test_redis.py | 83 ++ tests/stores/simple/__init__.py | 0 tests/stores/simple/test_json_store.py | 12 + tests/stores/simple/test_store.py | 17 + tests/stores/wrappers/__init__.py | 0 tests/stores/wrappers/test_clamp_ttl.py | 71 + tests/stores/wrappers/test_passthrough.py | 28 + .../stores/wrappers/test_prefix_collection.py | 14 + tests/stores/wrappers/test_prefix_key.py | 14 + .../stores/wrappers/test_single_collection.py | 31 + tests/test_disk_store.py | 80 -- tests/test_kv_stores.py | 227 ---- tests/test_memory_store.py | 53 - tests/test_types.py | 38 + uv.lock | 1185 +++++++++++++++++ 81 files changed, 4863 insertions(+), 1525 deletions(-) create mode 100644 .github/workflows/publish-py-kv-store-adapter.yml create mode 100644 .github/workflows/test_pull_request.yml create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 DEVELOPING.md delete mode 100644 examples/demo.py create mode 100644 src/kv_store_adapter/adapters/pydantic.py create mode 100644 src/kv_store_adapter/adapters/single_collection.py delete mode 100644 src/kv_store_adapter/disk/__init__.py delete mode 100644 src/kv_store_adapter/disk/store.py create mode 100644 src/kv_store_adapter/errors.py delete mode 100644 src/kv_store_adapter/exceptions.py delete mode 100644 src/kv_store_adapter/memory/__init__.py delete mode 100644 src/kv_store_adapter/memory/store.py delete mode 100644 src/kv_store_adapter/protocol.py delete mode 100644 src/kv_store_adapter/redis/__init__.py delete mode 100644 src/kv_store_adapter/redis/store.py create mode 100644 src/kv_store_adapter/stores/__init__.py create mode 100644 src/kv_store_adapter/stores/base/__init__.py create mode 100644 src/kv_store_adapter/stores/base/managed.py create mode 100644 src/kv_store_adapter/stores/base/unmanaged.py create mode 100644 src/kv_store_adapter/stores/disk/__init__.py create mode 100644 src/kv_store_adapter/stores/disk/store.py create mode 100644 src/kv_store_adapter/stores/elasticsearch/__init__.py create mode 100644 src/kv_store_adapter/stores/elasticsearch/store.py create mode 100644 src/kv_store_adapter/stores/elasticsearch/utils.py create mode 100644 src/kv_store_adapter/stores/memory/__init__.py create mode 100644 src/kv_store_adapter/stores/memory/store.py create mode 100644 src/kv_store_adapter/stores/null/__init__.py create mode 100644 src/kv_store_adapter/stores/null/store.py create mode 100644 src/kv_store_adapter/stores/redis/__init__.py create mode 100644 src/kv_store_adapter/stores/redis/store.py create mode 100644 src/kv_store_adapter/stores/simple/__init__.py create mode 100644 src/kv_store_adapter/stores/simple/json_store.py create mode 100644 src/kv_store_adapter/stores/simple/store.py create mode 100644 src/kv_store_adapter/stores/utils/compound.py create mode 100644 src/kv_store_adapter/stores/utils/managed_entry.py create mode 100644 src/kv_store_adapter/stores/utils/time_to_live.py create mode 100644 src/kv_store_adapter/stores/wrappers/__init__.py create mode 100644 src/kv_store_adapter/stores/wrappers/clamp_ttl.py create mode 100644 src/kv_store_adapter/stores/wrappers/passthrough_cache.py create mode 100644 src/kv_store_adapter/stores/wrappers/prefix_collection.py create mode 100644 src/kv_store_adapter/stores/wrappers/prefix_key.py create mode 100644 src/kv_store_adapter/stores/wrappers/single_collection.py create mode 100644 src/kv_store_adapter/stores/wrappers/statistics.py create mode 100644 src/kv_store_adapter/types.py create mode 100644 tests/__init__.py create mode 100644 tests/adapters/__init__.py create mode 100644 tests/adapters/test_pydantic.py create mode 100644 tests/adapters/test_single_collection.py create mode 100644 tests/cases.py create mode 100644 tests/stores/__init__.py create mode 100644 tests/stores/base/__init__.py create mode 100644 tests/stores/base/test_kv_json_store.py create mode 100644 tests/stores/conftest.py create mode 100644 tests/stores/disk/__init__.py create mode 100644 tests/stores/disk/test_disk.py create mode 100644 tests/stores/elasticsearch/__init__.py create mode 100644 tests/stores/elasticsearch/test_elasticsearch.py create mode 100644 tests/stores/memory/__init__.py create mode 100644 tests/stores/memory/test_memory.py create mode 100644 tests/stores/redis/__init__.py create mode 100644 tests/stores/redis/test_redis.py create mode 100644 tests/stores/simple/__init__.py create mode 100644 tests/stores/simple/test_json_store.py create mode 100644 tests/stores/simple/test_store.py create mode 100644 tests/stores/wrappers/__init__.py create mode 100644 tests/stores/wrappers/test_clamp_ttl.py create mode 100644 tests/stores/wrappers/test_passthrough.py create mode 100644 tests/stores/wrappers/test_prefix_collection.py create mode 100644 tests/stores/wrappers/test_prefix_key.py create mode 100644 tests/stores/wrappers/test_single_collection.py delete mode 100644 tests/test_disk_store.py delete mode 100644 tests/test_kv_stores.py delete mode 100644 tests/test_memory_store.py create mode 100644 tests/test_types.py create mode 100644 uv.lock diff --git a/.github/workflows/publish-py-kv-store-adapter.yml b/.github/workflows/publish-py-kv-store-adapter.yml new file mode 100644 index 00000000..69d634ef --- /dev/null +++ b/.github/workflows/publish-py-kv-store-adapter.yml @@ -0,0 +1,39 @@ +name: Publish Strawgate ESQL Tools MCP to PyPI + +on: + release: + types: [created] + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write + environment: pypi + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: "Install uv" + uses: astral-sh/setup-uv@v6 + + - name: "Install" + run: uv sync --locked --group dev + + - name: "Test" + run: uv run pytest tests + + - name: "Build" + run: uv build + + - name: "Publish to PyPi" + if: github.event_name == 'release' && github.event.action == 'created' + run: uv publish -v dist/* \ No newline at end of file diff --git a/.github/workflows/test_pull_request.yml b/.github/workflows/test_pull_request.yml new file mode 100644 index 00000000..c58b33da --- /dev/null +++ b/.github/workflows/test_pull_request.yml @@ -0,0 +1,41 @@ +name: Publish Strawgate ESQL Tools MCP to PyPI + +on: + release: + types: [created] + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write + environment: pypi + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: "Install uv" + uses: astral-sh/setup-uv@v6 + + - name: "Install" + run: uv sync --locked --group dev + + - name: "Lint" + run: uv run ruff check --exit-non-zero-on-fix --fix + + - name: "Type Check" + run: uv run basedpyright + + - name: "Format" + run: uv run ruff format + + - name: "Test" + run: uv run pytest tests + + - name: "Build" + run: uv build diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..1b0dc51a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,31 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + },{ + "name": "Python: Debug Tests", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "purpose": [ + "debug-test" + ], + "args": [ + "-vv", + "-s" // Disable Captures + ], + "console": "integratedTerminal", + "justMyCode": false, + "envFile": "${workspaceFolder}/.env" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..9b388533 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/DEVELOPING.md b/DEVELOPING.md new file mode 100644 index 00000000..1db0721e --- /dev/null +++ b/DEVELOPING.md @@ -0,0 +1,388 @@ +# Development Guide + +This guide covers development setup, testing, and contribution guidelines for the KV Store Adapter project. + +## Development Setup + +### Prerequisites + +- Python 3.10 or higher +- [uv](https://docs.astral.sh/uv/) for dependency management +- Docker and Docker Compose (for integration tests) + +### Initial Setup + +1. **Clone the repository:** + ```bash + git clone + cd py-kv-store-adapter + ``` + +2. **Install dependencies:** + ```bash + uv sync --group dev + ``` + +3. **Activate the virtual environment:** + ```bash + source .venv/bin/activate # Linux/macOS + # or + .venv\Scripts\activate # Windows + ``` + +4. **Install pre-commit hooks (optional but recommended):** + ```bash + pre-commit install + ``` + +## Project Structure + +``` +src/kv_store_adapter/ +├── __init__.py # Main package exports +├── types.py # Core types and protocols +├── errors.py # Exception hierarchy +├── stores/ # Store implementations +│ ├── base/ # Abstract base classes +│ ├── redis/ # Redis implementation +│ ├── memory/ # In-memory TLRU cache +│ ├── disk/ # Disk-based storage +│ ├── elasticsearch/ # Elasticsearch implementation +│ ├── simple/ # Simple dict-based stores +│ ├── null/ # Null object pattern store +│ ├── utils/ # Utility functions +│ │ ├── compound_keys.py # Key composition utilities +│ │ ├── managed_entry.py # ManagedEntry dataclass +│ │ └── time_to_live.py # TTL calculation +│ └── wrappers/ # Wrappers implementations + +tests/ +├── conftest.py # Test configuration +├── cases.py # Common test cases +├── test_types.py # Type tests +└── stores/ # Store-specific tests +``` + +## Store Configuration + +All stores implement the `KVStoreProtocol` interface. Here are detailed configuration options: + +### Redis Store +High-performance store with native TTL support: + +```python +from kv_store_adapter import RedisStore + +# Connection options +store = RedisStore(host="localhost", port=6379, db=0, password="secret") +store = RedisStore(url="redis://localhost:6379/0") +store = RedisStore(client=existing_redis_client) +``` + +### Memory Store +In-memory TLRU (Time-aware Least Recently Used) cache: + +```python +from kv_store_adapter import MemoryStore + +store = MemoryStore(max_entries=1000) # Default: 1000 entries +``` + +### Disk Store +Persistent disk-based storage using diskcache: + +```python +from kv_store_adapter import DiskStore + +store = DiskStore(path="/path/to/cache", size_limit=1024*1024*1024) # 1GB +store = DiskStore(cache=existing_cache_instance) +``` + +### Elasticsearch Store +Full-text searchable storage with Elasticsearch: + +```python +from kv_store_adapter import ElasticsearchStore + +store = ElasticsearchStore( + url="https://localhost:9200", + api_key="your-api-key", + index="kv-store" +) +store = ElasticsearchStore(client=existing_client, index="custom-index") +``` + +### Simple Stores +Dictionary-based stores for testing and development: + +```python +from kv_store_adapter import SimpleStore, SimpleManagedStore, SimpleJSONStore + +# Basic dictionary store +store = SimpleStore(max_entries=1000) + +# Managed store with automatic entry wrapping +managed_store = SimpleManagedStore(max_entries=1000) + +# JSON-serialized storage +json_store = SimpleJSONStore(max_entries=1000) +``` + +### Null Store +Null object pattern store for testing: + +```python +from kv_store_adapter import NullStore + +store = NullStore() # Accepts all operations but stores nothing +``` + +## Architecture + +### Store Types + +The project supports two main store architectures: + +1. **Unmanaged Stores (`BaseKVStore`)** + - Handle their own TTL management + - Directly store user values + - Examples: `SimpleStore`, `NullStore` + +2. **Managed Stores (`BaseManagedKVStore`)** + - Use `ManagedEntry` wrapper objects + - Automatic TTL handling and expiration checking + - Examples: `RedisStore`, `MemoryStore`, `DiskStore`, `ElasticsearchStore` + +### Key Concepts + +- **Collections**: Logical namespaces for organizing keys +- **Compound Keys**: Internal key format `collection::key` for flat stores +- **TTL Management**: Automatic expiration handling with timezone-aware timestamps +- **Wrappers**: Wrapper pattern for adding functionality (statistics, logging, etc.) + +## Testing + +### Running Tests + +```bash +# Run all tests +uv run pytest + +# Run tests with coverage +uv run pytest --cov=src/kv_store_adapter --cov-report=html + +# Run specific test file +uv run pytest tests/stores/redis/test_redis.py + +# Run tests with specific markers +uv run pytest -m "not skip_on_ci" +``` + +### Test Environment Setup + +Some tests require external services. Use Docker Compose to start them: + +```bash +# Start all services +docker-compose up -d + +# Start specific services +docker-compose up -d redis elasticsearch + +# Stop services +docker-compose down +``` + +### Environment Variables + +Create a `.env` file for test configuration: + +```bash +# Redis +REDIS_URL=redis://localhost:6379/0 + +# Elasticsearch +ELASTICSEARCH_URL=https://localhost:9200 +ELASTICSEARCH_API_KEY=your-api-key-here +ELASTICSEARCH_INDEX=test-kv-store + +# Test settings +SKIP_INTEGRATION_TESTS=false +``` + +### Writing Tests + +#### Test Structure + +Tests are organized by store type and use common test cases: + +```python +# tests/stores/mystore/test_mystore.py +import pytest +from kv_store_adapter.stores.mystore import MyStore +from tests.cases import BaseKVStoreTestCase + +class TestMyStore(BaseKVStoreTestCase): + @pytest.fixture + async def store(self): + """Provide store instance for testing.""" + store = MyStore() + yield store + # Cleanup if needed + await store.clear_collection("test") +``` + +#### Common Test Cases + +Use the provided base test cases for consistency: + +```python +from tests.cases import BaseKVStoreTestCase, BaseManagedKVStoreTestCase + +class TestMyUnmanagedStore(BaseKVStoreTestCase): + # Inherits all standard KV store tests + pass + +class TestMyManagedStore(BaseManagedKVStoreTestCase): + # Inherits managed store specific tests + pass +``` + +#### Custom Test Methods + +Add store-specific tests as needed: + +```python +class TestRedisStore(BaseManagedKVStoreTestCase): + async def test_redis_specific_feature(self, store): + """Test Redis-specific functionality.""" + # Your test implementation + pass +``` + +### Test Markers + +- `skip_on_ci`: Skip tests that require external services on CI +- `slow`: Mark slow-running tests +- `integration`: Mark integration tests + +## Code Quality + +### Linting and Formatting + +The project uses Ruff for linting and formatting: + +```bash +# Check code style +uv run ruff check + +# Fix auto-fixable issues +uv run ruff check --fix + +# Format code +uv run ruff format +``` + +### Type Checking + +Use Pyright for type checking: + +```bash +# Check types +pyright + +# Check specific file +pyright src/kv_store_adapter/stores/redis/store.py +``` + +## Adding New Store Implementations + +### 1. Choose Base Class + +Decide between `BaseKVStore` (unmanaged) or `BaseManagedKVStore` (managed): + +```python +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +# or +from kv_store_adapter.stores.base.managed import BaseManagedKVStore +``` + +### 2. Create Store Class + +```python +# src/kv_store_adapter/stores/mystore/store.py +from typing import Any +from kv_store_adapter.stores.base.managed import BaseManagedKVStore +from kv_store_adapter.stores.utils.managed_entry import ManagedEntry + +class MyStore(BaseManagedKVStore): + """My custom key-value store implementation.""" + + def __init__(self, **kwargs): + """Initialize store with custom parameters.""" + super().__init__() + # Your initialization code + + async def setup(self) -> None: + """Initialize store (called once before first use).""" + # Setup code (connect to database, etc.) + pass + + async def get_entry(self, collection: str, key: str) -> ManagedEntry | None: + """Retrieve a managed entry by key from the specified collection.""" + # Your implementation + pass + + async def put_entry( + self, + collection: str, + key: str, + cache_entry: ManagedEntry, + *, + ttl: float | None = None + ) -> None: + """Store a managed entry by key in the specified collection.""" + # Your implementation + pass + + # Implement other required methods... +``` + +### 3. Create Package Structure + +``` +src/kv_store_adapter/stores/mystore/ +├── __init__.py # Export store class +└── store.py # Store implementation +``` + +```python +# src/kv_store_adapter/stores/mystore/__init__.py +from .store import MyStore + +__all__ = ["MyStore"] +``` + +### 4. Add Tests + +```python +# tests/stores/mystore/test_mystore.py +import pytest +from kv_store_adapter.stores.mystore import MyStore +from tests.cases import BaseManagedKVStoreTestCase + +class TestMyStore(BaseManagedKVStoreTestCase): + @pytest.fixture + async def store(self): + store = MyStore() + yield store + # Cleanup +``` + +### 5. Add Optional Dependencies + +```toml +# pyproject.toml +[project.optional-dependencies] +mystore = ["my-store-dependency>=1.0.0"] +``` diff --git a/README.md b/README.md index 37ec8f85..43acddd3 100644 --- a/README.md +++ b/README.md @@ -1,301 +1,225 @@ # KV Store Adapter -A pluggable interface for Key-Value stores with multiple backend implementations. - -## Overview - -This package provides a common protocol for Key-Value store operations with support for: -- **Basic operations**: get, set, delete -- **TTL (Time To Live)**: Automatic expiration of keys -- **Namespaces/Collections**: Organize data into separate collections -- **Pattern matching**: Find keys using wildcard patterns -- **Multiple backends**: In-memory, disk-based, and Redis implementations +A pluggable, async-first key-value store interface for Python applications with support for multiple backends and TTL (Time To Live) functionality. ## Features -### Supported Operations -- `get(key, namespace=None)` - Retrieve a value -- `set(key, value, namespace=None, ttl=None)` - Store a value with optional TTL -- `delete(key, namespace=None)` - Delete a key -- `exists(key, namespace=None)` - Check if a key exists -- `ttl(key, namespace=None)` - Get remaining time-to-live -- `keys(namespace=None, pattern="*")` - List keys with pattern matching -- `clear_namespace(namespace)` - Clear all keys in a namespace -- `list_namespaces()` - List all available namespaces - -### Backend Implementations - -#### 1. Memory Store (`MemoryKVStore`) -- Fast in-memory storage using Python dictionaries -- Thread-safe with proper locking -- Data lost when process ends -- Perfect for caching and temporary storage - -#### 2. Disk Store (`DiskKVStore`) -- Persistent storage using the filesystem -- Each namespace is a directory -- Uses pickle for serialization and JSON for metadata -- Survives process restarts - -#### 3. Redis Store (`RedisKVStore`) -- Uses Redis as the backend -- Leverages Redis's native TTL support -- Requires `redis` package and Redis server -- Scalable and production-ready - -## Installation +- **Async-first**: Built from the ground up with `async`/`await` support +- **Multiple backends**: Redis, Elasticsearch, In-memory, Disk, and more +- **TTL support**: Automatic expiration handling across all store types +- **Type-safe**: Full type hints with Protocol-based interfaces +- **Adapters**: Pydantic, Single Collection, and more +- **Wrappers**: Statistics tracking and extensible wrapper system +- **Collection-based**: Organize keys into logical collections/namespaces +- **Pluggable architecture**: Easy to add custom store implementations + +## Quick Start ```bash -# Basic installation pip install kv-store-adapter -# With Redis support +# With specific backend support pip install kv-store-adapter[redis] +pip install kv-store-adapter[elasticsearch] +pip install kv-store-adapter[memory] +pip install kv-store-adapter[disk] -# Development installation -pip install kv-store-adapter[dev] +# With all backends +pip install kv-store-adapter[memory,disk,redis,elasticsearch] ``` -## Quick Start +# The KV Store Protocol + +The simplest way to get started is to use the `KVStoreProtocol` interface, which allows you to write code that works with any supported KV Store: ```python -from kv_store_adapter.memory import MemoryKVStore -from kv_store_adapter.disk import DiskKVStore -from kv_store_adapter.redis import RedisKVStore -from datetime import timedelta +from kv_store_adapter.types import KVStoreProtocol +from typing import Any -# Memory store -store = MemoryKVStore() +async def cache_user_data(store: KVStoreProtocol, user_id: str, data: dict[str, Any]) -> None: + """Cache user data with 1-hour TTL.""" + await store.put("users", f"user:{user_id}", data, ttl=3600) -# Disk store -store = DiskKVStore("/path/to/data") +async def get_cached_user(store: KVStoreProtocol, user_id: str) -> dict[str, Any] | None: + """Retrieve cached user data.""" + return await store.get("users", f"user:{user_id}") -# Redis store (requires Redis server) -store = RedisKVStore(host="localhost", port=6379) +# Works with any store implementation +from kv_store_adapter import RedisStore, MemoryStore -# Basic operations -store.set("user:1", {"name": "Alice", "age": 30}) -user = store.get("user:1") -print(user) # {'name': 'Alice', 'age': 30} +redis_store = RedisStore(url="redis://localhost:6379") +memory_store = MemoryStore(max_entries=1000) -# With TTL -store.set("session:abc", {"user_id": 1}, ttl=timedelta(hours=1)) +# Same code works with both stores +await cache_user_data(redis_store, "123", {"name": "Alice"}) +await cache_user_data(memory_store, "456", {"name": "Bob"}) +``` -# Using namespaces -store.set("config", "production", namespace="app") -store.set("config", "debug", namespace="test") +## Store Implementations -config = store.get("config", namespace="app") # "production" +Choose the store that best fits your needs. All stores implement the same `KVStoreProtocol` interface: -# Pattern matching -store.set("user:1", "Alice") -store.set("user:2", "Bob") -store.set("admin:1", "Charlie") +### Production Stores -users = store.keys(pattern="user:*") # ["user:1", "user:2"] -``` +- **RedisStore**: `RedisStore(url="redis://localhost:6379/0")` +- **ElasticsearchStore**: `ElasticsearchStore(url="https://localhost:9200", api_key="your-api-key")` +- **DiskStore**: A sqlite-based store for local persistence `DiskStore(path="./cache")` +- **MemoryStore**: A fast in-memory cache `MemoryStore()` -## Detailed Examples +### Development/Testing Stores -### Working with Namespaces +- **SimpleStore**: In-memory and inspectable for testing `SimpleStore()` +- **NullStore**: No-op store for testing `NullStore()` -```python -from kv_store_adapter.memory import MemoryKVStore +For detailed configuration options and all available stores, see [DEVELOPING.md](DEVELOPING.md). -store = MemoryKVStore() +## Atomicity / Consistency -# Store data in different namespaces -store.set("settings", {"theme": "dark"}, namespace="user:1") -store.set("settings", {"theme": "light"}, namespace="user:2") -store.set("cache", "some_value", namespace="temp") +We strive to support atomicity and consistency across all stores and operations in the KVStoreProtocol. That being said, +there are operations available via the BaseKVStore class which are management operations like listing keys, listing collections, clearing collections, +culling expired entries, etc. These operations may not be atomic or may be eventually consistent across stores. -# Retrieve from specific namespace -user1_settings = store.get("settings", namespace="user:1") +### TTL (Time To Live) -# List keys in a namespace -temp_keys = store.keys(namespace="temp") +All stores support automatic expiration. Use TTL for session management, caching, and temporary data: -# List all namespaces -namespaces = store.list_namespaces() -print(namespaces) # ['user:1', 'user:2', 'temp'] +```python +from kv_store_adapter.types import KVStoreProtocol -# Clear a namespace -cleared_count = store.clear_namespace("temp") +async def session_example(store: KVStoreProtocol): + # Store session with 1-hour expiration + session_data = {"user_id": 123, "role": "admin"} + await store.put("sessions", "session:abc123", session_data, ttl=3600) + + # Data automatically expires after 1 hour + session = await store.get("sessions", "session:abc123") + if session: + print(f"Active session for user {session['user_id']}") + else: + print("Session expired or not found") ``` -### TTL and Expiration +### Collections -```python -import time -from datetime import timedelta +Organize your data into logical namespaces: -# Set with TTL in seconds -store.set("session", {"user": "alice"}, ttl=30) +```python +from kv_store_adapter.types import KVStoreProtocol -# Set with timedelta -store.set("cache", "data", ttl=timedelta(minutes=15)) +async def organize_data(store: KVStoreProtocol): + # Same key in different collections - no conflicts + await store.put("users", "123", {"name": "Alice", "email": "alice@example.com"}) + await store.put("products", "123", {"name": "Widget", "price": 29.99}) + await store.put("orders", "123", {"user_id": 456, "total": 99.99}) + + # Work with specific collections + user = await store.get("users", "123") + product = await store.get("products", "123") + + # List all keys in a collection + user_keys = await store.keys("users") + print(f"User keys: {user_keys}") +``` -# Check remaining TTL -remaining = store.ttl("session") -print(f"Session expires in {remaining:.2f} seconds") +## Adapters -# Key automatically expires -time.sleep(31) -exists = store.exists("session") # False -``` +The library provides an adapter pattern simplifying the user of the protocol/store. Adapters themselves do not implement the `KVStoreProtocol` interface and cannot be nested. Adapters can be used with wrappers and stores interchangeably. -### Error Handling +The following adapters are available: -```python -from kv_store_adapter.exceptions import KeyNotFoundError - -try: - value = store.get("nonexistent_key") -except KeyNotFoundError as e: - print(f"Key not found: {e}") - -# Safe existence check -if store.exists("key"): - value = store.get("key") -else: - print("Key does not exist") -``` +- **PydanticAdapter**: Converts data to and from a store using Pydantic models. +- **SingleCollectionAdapter**: Provides KV operations that do not require a collection parameter. -### Disk Store Persistence +For example, the PydanticAdapter can be used to provide type-safe interactions with a store: ```python -from kv_store_adapter.disk import DiskKVStore +from kv_store_adapter import PydanticAdapter, MemoryStore +from pydantic import BaseModel -# Create store with custom path -store1 = DiskKVStore("/my/data/path") -store1.set("persistent_key", "persistent_value") +class User(BaseModel): + name: str + email: str -# Data persists across instances -store2 = DiskKVStore("/my/data/path") -value = store2.get("persistent_key") # "persistent_value" -``` +memory_store = MemoryStore() -### Redis Store Configuration +user_adapter = PydanticAdapter(memory_store, User) -```python -from kv_store_adapter.redis import RedisKVStore - -# Basic connection -store = RedisKVStore() +await user_adapter.put("users", "123", User(name="John Doe", email="john.doe@example.com")) +user: User | None = await user_adapter.get("users", "123") +``` -# Custom configuration -store = RedisKVStore( - host="redis.example.com", - port=6379, - db=1, - password="secret", - socket_timeout=10 -) +## Wrappers -# All Redis connection parameters are supported -``` +The library provides a wrapper pattern for adding functionality to a store. Wrappers themselves implement the `KVStoreProtocol` interface meaning that you can wrap any +store with any wrapper, and chain wrappers together as needed. -## Protocol Interface +### Statistics Tracking -All implementations follow the `KVStoreProtocol` interface: +Track operation statistics for any store: ```python -from abc import ABC, abstractmethod -from typing import Any, Optional, Union, List -from datetime import timedelta - -class KVStoreProtocol(ABC): - @abstractmethod - def get(self, key: str, namespace: Optional[str] = None) -> Any: ... - - @abstractmethod - def set(self, key: str, value: Any, namespace: Optional[str] = None, - ttl: Optional[Union[int, float, timedelta]] = None) -> None: ... - - @abstractmethod - def delete(self, key: str, namespace: Optional[str] = None) -> bool: ... - - @abstractmethod - def ttl(self, key: str, namespace: Optional[str] = None) -> Optional[float]: ... - - @abstractmethod - def exists(self, key: str, namespace: Optional[str] = None) -> bool: ... - - @abstractmethod - def keys(self, namespace: Optional[str] = None, pattern: str = "*") -> List[str]: ... - - @abstractmethod - def clear_namespace(self, namespace: str) -> int: ... - - @abstractmethod - def list_namespaces(self) -> List[str]: ... +from kv_store_adapter import StatisticsWrapper, MemoryStore + +memory_store = MemoryStore() +store = StatisticsWrapper(memory_store) + +# Use store normally - statistics are tracked automatically +await store.put("users", "123", {"name": "Alice"}) +await store.get("users", "123") +await store.get("users", "456") # Cache miss + +# Access statistics +stats = store.statistics +user_stats = stats.get_collection("users") +print(f"Total gets: {user_stats.get.count}") +print(f"Cache hits: {user_stats.get.hit}") +print(f"Cache misses: {user_stats.get.miss}") ``` -## Thread Safety - -- **MemoryKVStore**: Thread-safe using `threading.RLock` -- **DiskKVStore**: Thread-safe using `threading.RLock` -- **RedisKVStore**: Thread-safe (Redis handles concurrency) - -## Performance Characteristics +Other wrappers that are available include: -| Implementation | Speed | Persistence | Memory Usage | Scalability | -|----------------|-------|-------------|--------------|-------------| -| Memory | Fastest | No | High | Single process | -| Disk | Medium | Yes | Low | Single process | -| Redis | Fast | Yes | Medium | Multi-process | +- **TTLClampWrapper**: Wraps a store and clamps the TTL to a given range. +- **PassthroughWrapper**: Wraps two stores, using the primary store as a write-through cache for the secondary store. For example, you could use a RedisStore as a distributed primary store and a MemoryStore as the cache store. +- **PrefixCollectionWrapper**: Wraps a store and prefixes all collections with a given prefix. +- **PrefixKeyWrapper**: Wraps a store and prefixes all keys with a given prefix. +- **SingleCollectionWrapper**: Wraps a store and forces all requests into a single collection. -## Testing - -```bash -# Run all tests -pytest tests/ +See [DEVELOPING.md](DEVELOPING.md) for more information on how to create your own wrappers. -# Run specific implementation tests -pytest tests/test_memory_store.py -pytest tests/test_disk_store.py +## Chaining Wrappers, Adapters, and Stores -# Run with coverage -pytest tests/ --cov=kv_store_adapter -``` +Imagine you have a service where you want to cache 3 pydantic models in a single collection. You can do this by wrapping the store in a PydanticAdapter and a SingleCollectionAdapter: -## Development +```python +from kv_store_adapter import PydanticAdapter, SingleCollectionAdapter, MemoryStore +from pydantic import BaseModel -```bash -# Install in development mode -pip install -e . +class User(BaseModel): + name: str + email: str -# Install with development dependencies -pip install -e .[dev] +store = MemoryStore() -# Run tests -pytest +users_store = PydanticAdapter(SingleCollectionWrapper(store, "users"), User) +products_store = PydanticAdapter(SingleCollectionWrapper(store, "products"), Product) +orders_store = PydanticAdapter(SingleCollectionWrapper(store, "orders"), Order) -# Run example -python examples/demo.py +await users_store.put("123", User(name="John Doe", email="john.doe@example.com")) +user: User | None = await users_store.get("123") ``` -## Requirements +## Development -- Python >= 3.8 -- `redis` package (optional, for Redis implementation) +See [DEVELOPING.md](DEVELOPING.md) for development setup, testing, and contribution guidelines. ## License -MIT License - see LICENSE file for details. +This project is licensed under the MIT License - see the LICENSE file for details. ## Contributing -1. Fork the repository -2. Create a feature branch -3. Add tests for new functionality -4. Ensure all tests pass -5. Submit a pull request +Contributions are welcome! Please read [DEVELOPING.md](DEVELOPING.md) for development setup and contribution guidelines. ## Changelog -### v0.1.0 -- Initial release -- Memory, Disk, and Redis implementations -- Full protocol support with TTL and namespaces -- Comprehensive test suite +See [CHANGELOG.md](CHANGELOG.md) for version history and changes. diff --git a/examples/demo.py b/examples/demo.py deleted file mode 100644 index c8dcf263..00000000 --- a/examples/demo.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -""" -Example usage of the KV Store adapter implementations. - -This script demonstrates the basic functionality of all three implementations: -- In-memory store -- Disk-based store -- Redis store (if Redis is available) -""" - -import tempfile -import shutil -from datetime import timedelta - -from kv_store_adapter.memory import MemoryKVStore -from kv_store_adapter.disk import DiskKVStore - - -def demo_store(store, store_name): - """Demonstrate basic functionality of a KV store.""" - print(f"\n=== {store_name} Demo ===") - - # Basic set/get operations - store.set("user:1", {"name": "Alice", "age": 30}) - store.set("user:2", {"name": "Bob", "age": 25}) - - print(f"Retrieved user:1: {store.get('user:1')}") - print(f"Retrieved user:2: {store.get('user:2')}") - - # Namespace operations - store.set("settings", "production", namespace="config") - store.set("settings", "debug", namespace="test") - - print(f"Config settings: {store.get('settings', namespace='config')}") - print(f"Test settings: {store.get('settings', namespace='test')}") - - # TTL operations - store.set("session:abc123", {"user_id": 1}, ttl=timedelta(seconds=5)) - ttl_remaining = store.ttl("session:abc123") - print(f"Session TTL remaining: {ttl_remaining:.2f} seconds") - - # Key listing - print(f"All keys in default namespace: {store.keys()}") - print(f"User keys: {store.keys(pattern='user:*')}") - print(f"Config namespace keys: {store.keys(namespace='config')}") - - # Namespace listing - print(f"Available namespaces: {store.list_namespaces()}") - - # Existence checks - print(f"user:1 exists: {store.exists('user:1')}") - print(f"user:3 exists: {store.exists('user:3')}") - - # Deletion - deleted = store.delete("user:2") - print(f"Deleted user:2: {deleted}") - print(f"Keys after deletion: {store.keys()}") - - -def main(): - """Main demonstration function.""" - print("KV Store Adapter Demo") - print("=====================") - - # Demo memory store - memory_store = MemoryKVStore() - demo_store(memory_store, "Memory Store") - - # Demo disk store - temp_dir = tempfile.mkdtemp() - try: - disk_store = DiskKVStore(temp_dir) - demo_store(disk_store, "Disk Store") - - print(f"\nDisk store data saved to: {temp_dir}") - print("Files created:") - import os - for root, dirs, files in os.walk(temp_dir): - level = root.replace(temp_dir, '').count(os.sep) - indent = ' ' * 2 * level - print(f"{indent}{os.path.basename(root)}/") - subindent = ' ' * 2 * (level + 1) - for file in files: - print(f"{subindent}{file}") - - finally: - shutil.rmtree(temp_dir, ignore_errors=True) - - # Demo Redis store (if available) - try: - from kv_store_adapter.redis import RedisKVStore - - # Try to create Redis store (will fail if Redis is not available) - try: - redis_store = RedisKVStore() - demo_store(redis_store, "Redis Store") - except (ImportError, ConnectionError) as e: - print(f"\nRedis Store Demo skipped: {e}") - - except ImportError: - print("\nRedis Store Demo skipped: redis package not installed") - - print("\nDemo completed!") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a715daf3..ebc09da6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,132 @@ -[build-system] -requires = ["setuptools>=61.0", "wheel"] -build-backend = "setuptools.build_meta" - [project] name = "kv-store-adapter" version = "0.1.0" description = "A pluggable interface for KV Stores" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ ] -dependencies = [] + +[build-system] +requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"] +build-backend = "hatchling.build" [project.optional-dependencies] -redis = ["redis>=4.0.0"] -test = ["pytest>=6.0", "pytest-asyncio"] -dev = ["pytest>=6.0", "pytest-asyncio", "black", "flake8", "mypy"] +memory = ["cachetools>=6.0.0"] +disk = ["diskcache>=5.6.0"] +redis = ["redis>=6.0.0"] +elasticsearch = ["elasticsearch>=9.0.0", "aiohttp>=3.12"] +pydantic = ["pydantic>=2.11.9"] -[tool.setuptools.packages.find] -where = ["src"] +[tool.pytest.ini_options] +asyncio_mode = "auto" +addopts = ["--inline-snapshot=create,fix","-vv","-s"] +markers = [ + "skip_on_ci: Skip running the test when running on CI", +] + +env_files = [".env"] -[tool.setuptools.package-dir] -"" = "src" \ No newline at end of file +[dependency-groups] +dev = [ + "kv-store-adapter[memory,disk,redis,elasticsearch]", + "kv-store-adapter[pydantic]", + "pytest", + "pytest-mock", + "pytest-asyncio", + "ruff", + "diskcache-stubs>=5.6.3.6.20240818", + "pytest-dotenv>=0.5.2", + "dirty-equals>=0.10.0", + "inline-snapshot>=0.29.0", + "pytest-redis>=3.1.3", + "basedpyright>=1.31.5", +] +lint = [ + "ruff" +] + +[tool.ruff] +target-version = "py310" +lint.fixable = ["ALL"] +lint.ignore = [ + "COM812", + "PLR0913", # Too many arguments, MCP Servers have a lot of arguments, OKAY?! +] +lint.extend-select = [ + "A", + "ARG", + "B", + "C4", + "COM", + "DTZ", + "E", + "EM", + "F", + "FURB", + "I", + "LOG", + "N", + "PERF", + "PIE", + "PLR", + "PLW", + "PT", + "PTH", + "Q", + "RET", + "RSE", + "RUF", + "S", + "SIM", + "TC", + "TID", + "TRY", + "UP", + "W", +] + +line-length = 140 + +[tool.ruff.lint.extend-per-file-ignores] +"**/tests/*.py" = [ + "S101", # Ignore asserts + "DTZ005", # Ignore datetime.UTC + "PLR2004", # Ignore magic values + "E501", # Ignore line length + +] +"**/references/*" = ["ALL"] +"template/*" = ["ALL"] +"**/vendored/**" = ["ALL"] + +[tool.pyright] +pythonVersion = "3.10" +typeCheckingMode = "recommended" +extraPaths = ["src/"] +include = ["src/"] +exclude = [ + "**/archive/**", + "**/node_modules/**", + "**/__pycache__/**", + "**/.venv/**", + ".venv", + "**/.pytest_cache/**", + "**/.ruff_cache/**", + "**/uv/python/**", + "**/clients/graphql/**", +] +reportMissingTypeStubs = false +reportExplicitAny = false +reportMissingModuleSource = false diff --git a/src/kv_store_adapter/__init__.py b/src/kv_store_adapter/__init__.py index 120c9e6e..8b137891 100644 --- a/src/kv_store_adapter/__init__.py +++ b/src/kv_store_adapter/__init__.py @@ -1,12 +1 @@ -""" -KV Store Adapter - A pluggable interface for Key-Value stores. -This package provides a common interface for various KV store implementations -including in-memory, disk-based, and Redis-based stores. -""" - -from .protocol import KVStoreProtocol -from .exceptions import KVStoreError, KeyNotFoundError, TTLError - -__version__ = "0.1.0" -__all__ = ["KVStoreProtocol", "KVStoreError", "KeyNotFoundError", "TTLError"] \ No newline at end of file diff --git a/src/kv_store_adapter/adapters/pydantic.py b/src/kv_store_adapter/adapters/pydantic.py new file mode 100644 index 00000000..17bc524c --- /dev/null +++ b/src/kv_store_adapter/adapters/pydantic.py @@ -0,0 +1,58 @@ +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, ValidationError +from pydantic_core import PydanticSerializationError + +from kv_store_adapter.errors import DeserializationError, SerializationError +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.types import TTLInfo + +T = TypeVar("T", bound=BaseModel) + + +class PydanticAdapter(Generic[T]): + """Adapter around a KV Store that allows type-safe persistence of Pydantic models.""" + + def __init__(self, store: BaseKVStore, pydantic_model: type[T]) -> None: + self.store: BaseKVStore = store + self.pydantic_model: type[T] = pydantic_model + + async def get(self, collection: str, key: str) -> T | None: + if value := await self.store.get(collection=collection, key=key): + try: + return self.pydantic_model.model_validate(obj=value) + except ValidationError as e: + msg = f"Invalid Pydantic model: {e}" + raise DeserializationError(msg) from e + + return None + + async def put(self, collection: str, key: str, value: T, *, ttl: float | None = None) -> None: + try: + value_dict: dict[str, Any] = value.model_dump() + except PydanticSerializationError as e: + msg = f"Invalid Pydantic model: {e}" + raise SerializationError(msg) from e + + await self.store.put(collection=collection, key=key, value=value_dict, ttl=ttl) + + async def delete(self, collection: str, key: str) -> bool: + return await self.store.delete(collection=collection, key=key) + + async def exists(self, collection: str, key: str) -> bool: + return await self.store.exists(collection=collection, key=key) + + async def keys(self, collection: str) -> list[str]: + return await self.store.keys(collection=collection) + + async def clear_collection(self, collection: str) -> int: + return await self.store.clear_collection(collection=collection) + + async def ttl(self, collection: str, key: str) -> TTLInfo | None: + return await self.store.ttl(collection=collection, key=key) + + async def list_collections(self) -> list[str]: + return await self.store.list_collections() + + async def cull(self) -> None: + await self.store.cull() diff --git a/src/kv_store_adapter/adapters/single_collection.py b/src/kv_store_adapter/adapters/single_collection.py new file mode 100644 index 00000000..c12e3904 --- /dev/null +++ b/src/kv_store_adapter/adapters/single_collection.py @@ -0,0 +1,30 @@ +from typing import Any + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.types import TTLInfo + + +class SingleCollectionAdapter: + """Adapter around a KV Store that only allows one collection.""" + + def __init__(self, store: BaseKVStore, collection: str) -> None: + self.store: BaseKVStore = store + self.collection: str = collection + + async def get(self, key: str) -> dict[str, Any] | None: + return await self.store.get(collection=self.collection, key=key) + + async def put(self, key: str, value: dict[str, Any], *, ttl: float | None = None) -> None: + await self.store.put(collection=self.collection, key=key, value=value, ttl=ttl) + + async def delete(self, key: str) -> bool: + return await self.store.delete(collection=self.collection, key=key) + + async def exists(self, key: str) -> bool: + return await self.store.exists(collection=self.collection, key=key) + + async def keys(self) -> list[str]: + return await self.store.keys(collection=self.collection) + + async def ttl(self, key: str) -> TTLInfo | None: + return await self.store.ttl(collection=self.collection, key=key) diff --git a/src/kv_store_adapter/disk/__init__.py b/src/kv_store_adapter/disk/__init__.py deleted file mode 100644 index a4d8f773..00000000 --- a/src/kv_store_adapter/disk/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Disk-based KV store implementation package. -""" - -from .store import DiskKVStore - -__all__ = ["DiskKVStore"] \ No newline at end of file diff --git a/src/kv_store_adapter/disk/store.py b/src/kv_store_adapter/disk/store.py deleted file mode 100644 index 50ac4115..00000000 --- a/src/kv_store_adapter/disk/store.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Disk-based implementation of the KV Store protocol. -""" - -import os -import json -import time -import threading -import fnmatch -import pickle -from pathlib import Path -from typing import Any, Optional, Union, List, Dict -from datetime import datetime, timedelta - -from ..protocol import KVStoreProtocol -from ..exceptions import KeyNotFoundError - - -class DiskKVStore(KVStoreProtocol): - """ - Disk-based implementation of KV Store protocol. - - This implementation stores data in the filesystem using JSON files for metadata - and pickle files for the actual data. Each namespace is a directory, and each - key is a file within that directory. - """ - - def __init__(self, base_path: Union[str, Path] = "./kv_store_data"): - self.base_path = Path(base_path) - self.base_path.mkdir(exist_ok=True) - self._lock = threading.RLock() - - def _get_namespace_path(self, namespace: Optional[str]) -> Path: - """Get the directory path for a namespace.""" - namespace_name = namespace or "default" - return self.base_path / namespace_name - - def _get_key_paths(self, key: str, namespace: Optional[str]) -> tuple[Path, Path]: - """Get the file paths for a key's data and metadata.""" - namespace_path = self._get_namespace_path(namespace) - data_file = namespace_path / f"{key}.data" - meta_file = namespace_path / f"{key}.meta" - return data_file, meta_file - - def _is_expired(self, meta_file: Path) -> bool: - """Check if a key is expired based on its metadata file.""" - if not meta_file.exists(): - return False - - try: - with open(meta_file, 'r') as f: - metadata = json.load(f) - - if 'expires_at' in metadata: - return time.time() > metadata['expires_at'] - except (json.JSONDecodeError, KeyError, IOError): - # If we can't read metadata, consider it not expired - pass - - return False - - def _cleanup_expired_key(self, data_file: Path, meta_file: Path) -> None: - """Remove expired key files.""" - try: - if data_file.exists(): - data_file.unlink() - if meta_file.exists(): - meta_file.unlink() - except OSError: - pass # Ignore file deletion errors - - def _cleanup_expired_namespace(self, namespace_path: Path) -> None: - """Clean up expired keys in a namespace.""" - if not namespace_path.exists(): - return - - for meta_file in namespace_path.glob("*.meta"): - if self._is_expired(meta_file): - key_name = meta_file.stem - data_file = namespace_path / f"{key_name}.data" - self._cleanup_expired_key(data_file, meta_file) - - def get(self, key: str, namespace: Optional[str] = None) -> Any: - """Retrieve a value by key from the specified namespace.""" - with self._lock: - data_file, meta_file = self._get_key_paths(key, namespace) - - # Check if expired and clean up - if self._is_expired(meta_file): - self._cleanup_expired_key(data_file, meta_file) - raise KeyNotFoundError(f"Key '{key}' not found in namespace '{namespace or 'default'}'") - - if not data_file.exists(): - raise KeyNotFoundError(f"Key '{key}' not found in namespace '{namespace or 'default'}'") - - try: - with open(data_file, 'rb') as f: - return pickle.load(f) - except (IOError, pickle.PickleError) as e: - raise KeyNotFoundError(f"Error reading key '{key}': {e}") - - def set(self, key: str, value: Any, namespace: Optional[str] = None, - ttl: Optional[Union[int, float, timedelta]] = None) -> None: - """Store a key-value pair in the specified namespace.""" - with self._lock: - namespace_path = self._get_namespace_path(namespace) - namespace_path.mkdir(exist_ok=True) - - data_file, meta_file = self._get_key_paths(key, namespace) - - # Store the value - try: - with open(data_file, 'wb') as f: - pickle.dump(value, f) - except (IOError, pickle.PickleError) as e: - raise Exception(f"Error storing key '{key}': {e}") - - # Store metadata - metadata = { - 'created_at': time.time(), - 'updated_at': time.time() - } - - if ttl is not None: - if isinstance(ttl, timedelta): - ttl_seconds = ttl.total_seconds() - else: - ttl_seconds = float(ttl) - - metadata['expires_at'] = time.time() + ttl_seconds - - try: - with open(meta_file, 'w') as f: - json.dump(metadata, f) - except (IOError, json.JSONEncodeError) as e: - # If metadata write fails, clean up the data file - if data_file.exists(): - data_file.unlink() - raise Exception(f"Error storing metadata for key '{key}': {e}") - - def delete(self, key: str, namespace: Optional[str] = None) -> bool: - """Delete a key from the specified namespace.""" - with self._lock: - data_file, meta_file = self._get_key_paths(key, namespace) - - exists = data_file.exists() or meta_file.exists() - - # Remove both files if they exist - try: - if data_file.exists(): - data_file.unlink() - if meta_file.exists(): - meta_file.unlink() - except OSError: - pass # Ignore deletion errors - - return exists - - def ttl(self, key: str, namespace: Optional[str] = None) -> Optional[float]: - """Get the time-to-live for a key in seconds.""" - with self._lock: - if not self.exists(key, namespace): - return None - - _, meta_file = self._get_key_paths(key, namespace) - - if not meta_file.exists(): - return None # No TTL set - - try: - with open(meta_file, 'r') as f: - metadata = json.load(f) - - if 'expires_at' not in metadata: - return None # No TTL set - - remaining = metadata['expires_at'] - time.time() - return max(0.0, remaining) - except (json.JSONDecodeError, KeyError, IOError): - return None - - def exists(self, key: str, namespace: Optional[str] = None) -> bool: - """Check if a key exists in the specified namespace.""" - with self._lock: - data_file, meta_file = self._get_key_paths(key, namespace) - - # Check if expired - if self._is_expired(meta_file): - self._cleanup_expired_key(data_file, meta_file) - return False - - return data_file.exists() - - def keys(self, namespace: Optional[str] = None, pattern: str = "*") -> List[str]: - """List keys in the specified namespace matching the pattern.""" - with self._lock: - namespace_path = self._get_namespace_path(namespace) - - if not namespace_path.exists(): - return [] - - # Clean up expired keys first - self._cleanup_expired_namespace(namespace_path) - - # Get all data files - all_keys = [f.stem for f in namespace_path.glob("*.data")] - - if pattern == "*": - return all_keys - - return [key for key in all_keys if fnmatch.fnmatch(key, pattern)] - - def clear_namespace(self, namespace: str) -> int: - """Clear all keys in a namespace.""" - with self._lock: - namespace_path = self._get_namespace_path(namespace) - - if not namespace_path.exists(): - return 0 - - count = 0 - # Remove all .data and .meta files - for file_path in namespace_path.glob("*"): - if file_path.is_file() and file_path.suffix in ['.data', '.meta']: - try: - file_path.unlink() - if file_path.suffix == '.data': - count += 1 - except OSError: - pass # Ignore deletion errors - - # Try to remove the directory if it's empty - try: - namespace_path.rmdir() - except OSError: - pass # Directory not empty or other error - - return count - - def list_namespaces(self) -> List[str]: - """List all available namespaces.""" - with self._lock: - namespaces = [] - - for item in self.base_path.iterdir(): - if item.is_dir(): - # Clean up expired keys in the namespace - self._cleanup_expired_namespace(item) - - # Check if namespace has any data files - if any(item.glob("*.data")): - namespaces.append(item.name) - - return namespaces \ No newline at end of file diff --git a/src/kv_store_adapter/errors.py b/src/kv_store_adapter/errors.py new file mode 100644 index 00000000..67f73af1 --- /dev/null +++ b/src/kv_store_adapter/errors.py @@ -0,0 +1,50 @@ +from typing import Any + +ExtraInfoType = dict[str, Any] + + +class KVStoreAdapterError(Exception): + """Base exception for all KV Store Adapter errors.""" + + def __init__(self, message: str | None = None, extra_info: ExtraInfoType | None = None): + message_parts: list[str] = [] + + if message: + message_parts.append(message) + + if extra_info: + extra_info_str = ";".join(f"{k}: {v}" for k, v in extra_info.items()) # pyright: ignore[reportAny] + if message: + extra_info_str = "(" + extra_info_str + ")" + + message_parts.append(extra_info_str) + + super().__init__(": ".join(message_parts)) + + +class SetupError(KVStoreAdapterError): + """Raised when a store setup fails.""" + + +class UnknownError(KVStoreAdapterError): + """Raised when an unexpected or unidentifiable error occurs.""" + + +class StoreConnectionError(KVStoreAdapterError): + """Raised when unable to connect to or communicate with the underlying store.""" + + +class KVStoreAdapterOperationError(KVStoreAdapterError): + """Raised when a store operation fails due to operational issues.""" + + +class SerializationError(KVStoreAdapterOperationError): + """Raised when data cannot be serialized for storage.""" + + +class DeserializationError(KVStoreAdapterOperationError): + """Raised when stored data cannot be deserialized back to its original form.""" + + +class ConfigurationError(KVStoreAdapterError): + """Raised when store configuration is invalid or incomplete.""" diff --git a/src/kv_store_adapter/exceptions.py b/src/kv_store_adapter/exceptions.py deleted file mode 100644 index fac35e19..00000000 --- a/src/kv_store_adapter/exceptions.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Custom exceptions for KV Store operations. -""" - - -class KVStoreError(Exception): - """Base exception for all KV store operations.""" - pass - - -class KeyNotFoundError(KVStoreError): - """Raised when a key is not found in the store.""" - pass - - -class TTLError(KVStoreError): - """Raised when there's an error with TTL operations.""" - pass - - -class NamespaceError(KVStoreError): - """Raised when there's an error with namespace operations.""" - pass \ No newline at end of file diff --git a/src/kv_store_adapter/memory/__init__.py b/src/kv_store_adapter/memory/__init__.py deleted file mode 100644 index e2fca2c0..00000000 --- a/src/kv_store_adapter/memory/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -In-memory KV store implementation package. -""" - -from .store import MemoryKVStore - -__all__ = ["MemoryKVStore"] \ No newline at end of file diff --git a/src/kv_store_adapter/memory/store.py b/src/kv_store_adapter/memory/store.py deleted file mode 100644 index c82ea854..00000000 --- a/src/kv_store_adapter/memory/store.py +++ /dev/null @@ -1,183 +0,0 @@ -""" -In-memory implementation of the KV Store protocol. -""" - -import time -import threading -import fnmatch -from typing import Any, Optional, Union, List, Dict, Tuple -from datetime import datetime, timedelta - -from ..protocol import KVStoreProtocol -from ..exceptions import KeyNotFoundError - - -class MemoryKVStore(KVStoreProtocol): - """ - In-memory implementation of KV Store protocol. - - This implementation stores all data in memory using Python dictionaries. - TTL is implemented using expiration timestamps. - Thread-safe operations are ensured using locks. - """ - - def __init__(self): - self._data: Dict[str, Dict[str, Any]] = {} # namespace -> key -> value - self._ttl: Dict[str, Dict[str, float]] = {} # namespace -> key -> expiration_time - self._lock = threading.RLock() - - def _get_namespace_key(self, namespace: Optional[str]) -> str: - """Get the internal namespace key, defaulting to 'default'.""" - return namespace or "default" - - def _cleanup_expired(self, namespace_key: str) -> None: - """Remove expired keys from a namespace.""" - if namespace_key not in self._ttl: - return - - current_time = time.time() - expired_keys = [ - key for key, exp_time in self._ttl[namespace_key].items() - if exp_time <= current_time - ] - - for key in expired_keys: - if namespace_key in self._data and key in self._data[namespace_key]: - del self._data[namespace_key][key] - del self._ttl[namespace_key][key] - - def _is_expired(self, key: str, namespace_key: str) -> bool: - """Check if a key is expired.""" - if namespace_key not in self._ttl or key not in self._ttl[namespace_key]: - return False - return self._ttl[namespace_key][key] <= time.time() - - def get(self, key: str, namespace: Optional[str] = None) -> Any: - """Retrieve a value by key from the specified namespace.""" - namespace_key = self._get_namespace_key(namespace) - - with self._lock: - self._cleanup_expired(namespace_key) - - if (namespace_key not in self._data or - key not in self._data[namespace_key] or - self._is_expired(key, namespace_key)): - raise KeyNotFoundError(f"Key '{key}' not found in namespace '{namespace_key}'") - - return self._data[namespace_key][key] - - def set(self, key: str, value: Any, namespace: Optional[str] = None, - ttl: Optional[Union[int, float, timedelta]] = None) -> None: - """Store a key-value pair in the specified namespace.""" - namespace_key = self._get_namespace_key(namespace) - - with self._lock: - # Initialize namespace if it doesn't exist - if namespace_key not in self._data: - self._data[namespace_key] = {} - if namespace_key not in self._ttl: - self._ttl[namespace_key] = {} - - # Store the value - self._data[namespace_key][key] = value - - # Handle TTL - if ttl is not None: - if isinstance(ttl, timedelta): - ttl_seconds = ttl.total_seconds() - else: - ttl_seconds = float(ttl) - - self._ttl[namespace_key][key] = time.time() + ttl_seconds - else: - # Remove any existing TTL - if key in self._ttl[namespace_key]: - del self._ttl[namespace_key][key] - - def delete(self, key: str, namespace: Optional[str] = None) -> bool: - """Delete a key from the specified namespace.""" - namespace_key = self._get_namespace_key(namespace) - - with self._lock: - self._cleanup_expired(namespace_key) - - if (namespace_key not in self._data or - key not in self._data[namespace_key]): - return False - - del self._data[namespace_key][key] - - # Remove TTL if it exists - if namespace_key in self._ttl and key in self._ttl[namespace_key]: - del self._ttl[namespace_key][key] - - return True - - def ttl(self, key: str, namespace: Optional[str] = None) -> Optional[float]: - """Get the time-to-live for a key in seconds.""" - namespace_key = self._get_namespace_key(namespace) - - with self._lock: - if not self.exists(key, namespace): - return None - - if (namespace_key not in self._ttl or - key not in self._ttl[namespace_key]): - return None # No TTL set - - remaining = self._ttl[namespace_key][key] - time.time() - return max(0.0, remaining) - - def exists(self, key: str, namespace: Optional[str] = None) -> bool: - """Check if a key exists in the specified namespace.""" - namespace_key = self._get_namespace_key(namespace) - - with self._lock: - self._cleanup_expired(namespace_key) - - return (namespace_key in self._data and - key in self._data[namespace_key] and - not self._is_expired(key, namespace_key)) - - def keys(self, namespace: Optional[str] = None, pattern: str = "*") -> List[str]: - """List keys in the specified namespace matching the pattern.""" - namespace_key = self._get_namespace_key(namespace) - - with self._lock: - self._cleanup_expired(namespace_key) - - if namespace_key not in self._data: - return [] - - all_keys = list(self._data[namespace_key].keys()) - - if pattern == "*": - return all_keys - - return [key for key in all_keys if fnmatch.fnmatch(key, pattern)] - - def clear_namespace(self, namespace: str) -> int: - """Clear all keys in a namespace.""" - namespace_key = self._get_namespace_key(namespace) - - with self._lock: - if namespace_key not in self._data: - return 0 - - count = len(self._data[namespace_key]) - self._data[namespace_key].clear() - - if namespace_key in self._ttl: - self._ttl[namespace_key].clear() - - return count - - def list_namespaces(self) -> List[str]: - """List all available namespaces.""" - with self._lock: - # Clean up expired keys first - for ns in list(self._data.keys()): - self._cleanup_expired(ns) - - # Return namespaces that have data - return [ns for ns in self._data.keys() if self._data[ns]] \ No newline at end of file diff --git a/src/kv_store_adapter/protocol.py b/src/kv_store_adapter/protocol.py deleted file mode 100644 index 4b4c36b9..00000000 --- a/src/kv_store_adapter/protocol.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -Protocol definition for KV Store implementations. -""" - -from abc import ABC, abstractmethod -from typing import Any, Optional, Union, List -from datetime import datetime, timedelta - - -class KVStoreProtocol(ABC): - """ - Abstract base class defining the interface for Key-Value store implementations. - - This protocol supports: - - Basic operations: get, set, delete - - TTL (Time To Live) functionality - - Namespace/collection support for data organization - """ - - @abstractmethod - def get(self, key: str, namespace: Optional[str] = None) -> Any: - """ - Retrieve a value by key from the specified namespace. - - Args: - key: The key to retrieve - namespace: Optional namespace/collection name - - Returns: - The value associated with the key - - Raises: - KeyNotFoundError: If the key is not found - """ - pass - - @abstractmethod - def set(self, key: str, value: Any, namespace: Optional[str] = None, - ttl: Optional[Union[int, float, timedelta]] = None) -> None: - """ - Store a key-value pair in the specified namespace. - - Args: - key: The key to store - value: The value to store - namespace: Optional namespace/collection name - ttl: Time to live in seconds (int/float) or timedelta object - """ - pass - - @abstractmethod - def delete(self, key: str, namespace: Optional[str] = None) -> bool: - """ - Delete a key from the specified namespace. - - Args: - key: The key to delete - namespace: Optional namespace/collection name - - Returns: - True if the key was deleted, False if it didn't exist - """ - pass - - @abstractmethod - def ttl(self, key: str, namespace: Optional[str] = None) -> Optional[float]: - """ - Get the time-to-live for a key in seconds. - - Args: - key: The key to check - namespace: Optional namespace/collection name - - Returns: - Remaining TTL in seconds, None if no TTL is set, or if key doesn't exist - """ - pass - - @abstractmethod - def exists(self, key: str, namespace: Optional[str] = None) -> bool: - """ - Check if a key exists in the specified namespace. - - Args: - key: The key to check - namespace: Optional namespace/collection name - - Returns: - True if the key exists, False otherwise - """ - pass - - @abstractmethod - def keys(self, namespace: Optional[str] = None, pattern: str = "*") -> List[str]: - """ - List keys in the specified namespace matching the pattern. - - Args: - namespace: Optional namespace/collection name - pattern: Pattern to match keys against (supports * wildcard) - - Returns: - List of matching keys - """ - pass - - @abstractmethod - def clear_namespace(self, namespace: str) -> int: - """ - Clear all keys in a namespace. - - Args: - namespace: The namespace to clear - - Returns: - Number of keys deleted - """ - pass - - @abstractmethod - def list_namespaces(self) -> List[str]: - """ - List all available namespaces. - - Returns: - List of namespace names - """ - pass \ No newline at end of file diff --git a/src/kv_store_adapter/redis/__init__.py b/src/kv_store_adapter/redis/__init__.py deleted file mode 100644 index 0731f9cb..00000000 --- a/src/kv_store_adapter/redis/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Redis-based KV store implementation package. -""" - -from .store import RedisKVStore - -__all__ = ["RedisKVStore"] \ No newline at end of file diff --git a/src/kv_store_adapter/redis/store.py b/src/kv_store_adapter/redis/store.py deleted file mode 100644 index 89c96358..00000000 --- a/src/kv_store_adapter/redis/store.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -Redis-based implementation of the KV Store protocol. -""" - -import pickle -import fnmatch -from typing import Any, Optional, Union, List -from datetime import timedelta - -from ..protocol import KVStoreProtocol -from ..exceptions import KeyNotFoundError - -try: - import redis -except ImportError: - redis = None - - -class RedisKVStore(KVStoreProtocol): - """ - Redis-based implementation of KV Store protocol. - - This implementation uses Redis as the backend storage. - Namespaces are implemented using key prefixes. - """ - - def __init__(self, host: str = "localhost", port: int = 6379, db: int = 0, - password: Optional[str] = None, **redis_kwargs): - if redis is None: - raise ImportError("redis package is required for RedisKVStore. Install with: pip install redis") - - self.redis_client = redis.Redis( - host=host, - port=port, - db=db, - password=password, - decode_responses=False, # We'll handle encoding ourselves - **redis_kwargs - ) - - # Test connection - try: - self.redis_client.ping() - except redis.ConnectionError as e: - raise ConnectionError(f"Failed to connect to Redis: {e}") - - def _get_redis_key(self, key: str, namespace: Optional[str]) -> str: - """Get the Redis key with namespace prefix.""" - namespace_prefix = f"{namespace or 'default'}:" - return f"{namespace_prefix}{key}" - - def _get_namespace_prefix(self, namespace: Optional[str]) -> str: - """Get the namespace prefix for pattern matching.""" - return f"{namespace or 'default'}:*" - - def _strip_namespace_from_key(self, redis_key: str, namespace: Optional[str]) -> str: - """Remove namespace prefix from Redis key to get the original key.""" - namespace_prefix = f"{namespace or 'default'}:" - if redis_key.startswith(namespace_prefix): - return redis_key[len(namespace_prefix):] - return redis_key - - def _serialize_value(self, value: Any) -> bytes: - """Serialize a value for storage in Redis.""" - return pickle.dumps(value) - - def _deserialize_value(self, data: bytes) -> Any: - """Deserialize a value from Redis storage.""" - return pickle.loads(data) - - def get(self, key: str, namespace: Optional[str] = None) -> Any: - """Retrieve a value by key from the specified namespace.""" - redis_key = self._get_redis_key(key, namespace) - - data = self.redis_client.get(redis_key) - if data is None: - raise KeyNotFoundError(f"Key '{key}' not found in namespace '{namespace or 'default'}'") - - return self._deserialize_value(data) - - def set(self, key: str, value: Any, namespace: Optional[str] = None, - ttl: Optional[Union[int, float, timedelta]] = None) -> None: - """Store a key-value pair in the specified namespace.""" - redis_key = self._get_redis_key(key, namespace) - serialized_value = self._serialize_value(value) - - if ttl is not None: - if isinstance(ttl, timedelta): - ttl_seconds = ttl.total_seconds() - else: - ttl_seconds = float(ttl) - - # Redis expects integer seconds for ex parameter - self.redis_client.setex(redis_key, int(ttl_seconds), serialized_value) - else: - self.redis_client.set(redis_key, serialized_value) - - def delete(self, key: str, namespace: Optional[str] = None) -> bool: - """Delete a key from the specified namespace.""" - redis_key = self._get_redis_key(key, namespace) - result = self.redis_client.delete(redis_key) - return result > 0 - - def ttl(self, key: str, namespace: Optional[str] = None) -> Optional[float]: - """Get the time-to-live for a key in seconds.""" - redis_key = self._get_redis_key(key, namespace) - - if not self.redis_client.exists(redis_key): - return None - - ttl_seconds = self.redis_client.ttl(redis_key) - - if ttl_seconds == -1: - return None # No TTL set - elif ttl_seconds == -2: - return None # Key doesn't exist (shouldn't happen due to exists check) - else: - return float(ttl_seconds) - - def exists(self, key: str, namespace: Optional[str] = None) -> bool: - """Check if a key exists in the specified namespace.""" - redis_key = self._get_redis_key(key, namespace) - return bool(self.redis_client.exists(redis_key)) - - def keys(self, namespace: Optional[str] = None, pattern: str = "*") -> List[str]: - """List keys in the specified namespace matching the pattern.""" - # Get all keys in the namespace - namespace_pattern = self._get_namespace_prefix(namespace) - redis_keys = self.redis_client.keys(namespace_pattern) - - # Strip namespace prefix and filter by pattern - original_keys = [ - self._strip_namespace_from_key(key.decode('utf-8'), namespace) - for key in redis_keys - ] - - if pattern == "*": - return original_keys - - return [key for key in original_keys if fnmatch.fnmatch(key, pattern)] - - def clear_namespace(self, namespace: str) -> int: - """Clear all keys in a namespace.""" - namespace_pattern = self._get_namespace_prefix(namespace) - keys_to_delete = self.redis_client.keys(namespace_pattern) - - if not keys_to_delete: - return 0 - - return self.redis_client.delete(*keys_to_delete) - - def list_namespaces(self) -> List[str]: - """List all available namespaces.""" - # Get all keys and extract unique namespace prefixes - all_keys = self.redis_client.keys("*") - namespaces = set() - - for key in all_keys: - key_str = key.decode('utf-8') - if ':' in key_str: - namespace = key_str.split(':', 1)[0] - namespaces.add(namespace) - - return list(namespaces) \ No newline at end of file diff --git a/src/kv_store_adapter/stores/__init__.py b/src/kv_store_adapter/stores/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/kv_store_adapter/stores/__init__.py @@ -0,0 +1 @@ + diff --git a/src/kv_store_adapter/stores/base/__init__.py b/src/kv_store_adapter/stores/base/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/kv_store_adapter/stores/base/managed.py b/src/kv_store_adapter/stores/base/managed.py new file mode 100644 index 00000000..16bf3f4c --- /dev/null +++ b/src/kv_store_adapter/stores/base/managed.py @@ -0,0 +1,121 @@ +""" +Base abstract class for managed key-value store implementations. +""" + +import asyncio +from abc import ABC, abstractmethod +from asyncio.locks import Lock +from collections import defaultdict +from datetime import datetime, timezone +from typing import Any + +from typing_extensions import override + +from kv_store_adapter.errors import SetupError +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.stores.utils.managed_entry import ManagedEntry +from kv_store_adapter.stores.utils.time_to_live import calculate_expires_at +from kv_store_adapter.types import TTLInfo + + +class BaseManagedKVStore(BaseKVStore, ABC): + """An opinionated Abstract base class for managed key-value stores using ManagedEntry objects. + + This class handles TTL management, expiration checking, and entry wrapping automatically. + Implementations only need to handle storage and retrieval of ManagedEntry objects and culling of expired entries. + """ + + _setup_complete: bool + _setup_lock: asyncio.Lock + + _setup_collection_locks: defaultdict[str, Lock] + _setup_collection_complete: defaultdict[str, bool] + + def __init__(self) -> None: + self._setup_complete = False + self._setup_lock = asyncio.Lock() + self._setup_collection_locks = defaultdict[str, asyncio.Lock](asyncio.Lock) + self._setup_collection_complete = defaultdict[str, bool](bool) + + async def setup(self) -> None: + """Initialize the store (called once before first use).""" + + async def setup_collection(self, collection: str) -> None: # pyright: ignore[reportUnusedParameter] + """Initialize the collection (called once before first use of the collection).""" + + async def setup_collection_once(self, collection: str) -> None: + await self.setup_once() + + if not self._setup_collection_complete[collection]: + async with self._setup_collection_locks[collection]: + if not self._setup_collection_complete[collection]: + try: + await self.setup_collection(collection=collection) + except Exception as e: + raise SetupError(message=f"Failed to setup collection: {e}", extra_info={"collection": collection}) from e + self._setup_collection_complete[collection] = True + + async def setup_once(self) -> None: + if not self._setup_complete: + async with self._setup_lock: + if not self._setup_complete: + try: + await self.setup() + except Exception as e: + raise SetupError(message=f"Failed to setup store: {e}", extra_info={"store": self.__class__.__name__}) from e + self._setup_complete = True + + @override + async def get(self, collection: str, key: str) -> dict[str, Any] | None: + """Retrieve a non-expired value by key from the specified collection.""" + await self.setup_collection_once(collection=collection) + + if cache_entry := await self.get_entry(collection=collection, key=key): + if cache_entry.is_expired: + # _ = await self.delete(collection=collection, key=key) + return None + + return cache_entry.value + return None + + @override + async def ttl(self, collection: str, key: str) -> TTLInfo | None: + await self.setup_collection_once(collection=collection) + + if cache_entry := await self.get_entry(collection=collection, key=key): + return cache_entry.to_ttl_info() + + return None + + @abstractmethod + async def get_entry(self, collection: str, key: str) -> ManagedEntry | None: + """Retrieve a cache entry by key from the specified collection.""" + + @override + async def put(self, collection: str, key: str, value: dict[str, Any], *, ttl: float | None = None) -> None: + """Store a key-value pair in the specified collection with optional TTL.""" + await self.setup_collection_once(collection=collection) + + created_at: datetime = datetime.now(tz=timezone.utc) + + cache_entry: ManagedEntry = ManagedEntry( + created_at=created_at, + expires_at=calculate_expires_at(created_at=created_at, ttl=ttl), + ttl=ttl, + collection=collection, + key=key, + value=value, + ) + + await self.put_entry(collection=collection, key=key, cache_entry=cache_entry, ttl=ttl) + + @abstractmethod + async def put_entry(self, collection: str, key: str, cache_entry: ManagedEntry, *, ttl: float | None = None) -> None: + """Store a managed entry by key in the specified collection.""" + ... + + @override + async def exists(self, collection: str, key: str) -> bool: + await self.setup_collection_once(collection=collection) + + return await super().exists(collection=collection, key=key) diff --git a/src/kv_store_adapter/stores/base/unmanaged.py b/src/kv_store_adapter/stores/base/unmanaged.py new file mode 100644 index 00000000..c9a157da --- /dev/null +++ b/src/kv_store_adapter/stores/base/unmanaged.py @@ -0,0 +1,75 @@ +""" +Base abstract class for unmanaged key-value store implementations. +""" + +from abc import ABC, abstractmethod +from typing import Any + +from kv_store_adapter.types import TTLInfo + + +class BaseKVStore(ABC): + """Abstract base class for key-value store implementations. + + The "value" passed to the implementation will be a dictionary of the value to store. + + When using this ABC, your implementation will: + 1. Implement `get` and `set` to get and save values + 2. Self-manage Expiration + 3. Self-manage Collections + 4. Self-manage Expired Entry Culling + """ + + @abstractmethod + async def get(self, collection: str, key: str) -> dict[str, Any] | None: + """Retrieve a non-expired value by key from the specified collection.""" + ... + + @abstractmethod + async def put( + self, + collection: str, + key: str, + value: dict[str, Any], + *, + ttl: float | None = None, + ) -> None: + """Store a key-value pair in the specified collection with optional TTL.""" + ... + + @abstractmethod + async def delete(self, collection: str, key: str) -> bool: + """Delete a key from the specified collection, returning True if it existed.""" + ... + + @abstractmethod + async def ttl(self, collection: str, key: str) -> TTLInfo | None: + """Get TTL information for a key, or None if the key doesn't exist.""" + ... + + @abstractmethod + async def exists(self, collection: str, key: str) -> bool: + """Check if a key exists in the specified collection.""" + + return await self.get(collection=collection, key=key) is not None + + @abstractmethod + async def keys(self, collection: str) -> list[str]: + """List all keys in the specified collection.""" + + ... + + @abstractmethod + async def clear_collection(self, collection: str) -> int: + """Clear all keys in a collection, returning the number of keys deleted.""" + ... + + @abstractmethod + async def list_collections(self) -> list[str]: + """List all available collection names (may include empty collections).""" + ... + + @abstractmethod + async def cull(self) -> None: + """Remove all expired entries from the store.""" + ... diff --git a/src/kv_store_adapter/stores/disk/__init__.py b/src/kv_store_adapter/stores/disk/__init__.py new file mode 100644 index 00000000..795e2cdd --- /dev/null +++ b/src/kv_store_adapter/stores/disk/__init__.py @@ -0,0 +1,3 @@ +from .store import DiskStore + +__all__ = ["DiskStore"] diff --git a/src/kv_store_adapter/stores/disk/store.py b/src/kv_store_adapter/stores/disk/store.py new file mode 100644 index 00000000..e84a31ed --- /dev/null +++ b/src/kv_store_adapter/stores/disk/store.py @@ -0,0 +1,92 @@ +from typing import Any, overload + +from diskcache import Cache +from typing_extensions import override + +from kv_store_adapter.stores.base.managed import BaseManagedKVStore +from kv_store_adapter.stores.utils.compound import compound_key, get_collections_from_compound_keys, get_keys_from_compound_keys +from kv_store_adapter.stores.utils.managed_entry import ManagedEntry + +DEFAULT_DISK_STORE_SIZE_LIMIT = 1 * 1024 * 1024 * 1024 # 1GB + + +class DiskStore(BaseManagedKVStore): + """A disk-based store that uses the diskcache library to store data. The diskcache library is a syncronous implementation of an LRU + cache and may not be suitable for high-traffic applications.""" + + _cache: Cache + + @overload + def __init__(self, *, cache: Cache) -> None: ... + + @overload + def __init__(self, *, path: str, size_limit: int | None = None) -> None: ... + + def __init__(self, *, cache: Cache | None = None, path: str | None = None, size_limit: int | None = None) -> None: + """Initialize the in-memory cache. + + Args: + disk_cache: The disk cache to use. + size_limit: The maximum size of the disk cache. Defaults to 1GB. + """ + self._cache = cache or Cache(directory=path, size_limit=size_limit or DEFAULT_DISK_STORE_SIZE_LIMIT) + + super().__init__() + + @override + async def setup(self) -> None: + pass + + @override + async def get_entry(self, collection: str, key: str) -> ManagedEntry | None: + combo_key: str = compound_key(collection=collection, key=key) + + cache_entry: Any = self._cache.get(combo_key) # pyright: ignore[reportAny] + + if not isinstance(cache_entry, str): + return None + + return ManagedEntry.from_json(json_str=cache_entry) + + @override + async def put_entry( + self, + collection: str, + key: str, + cache_entry: ManagedEntry, + *, + ttl: float | None = None, + ) -> None: + combo_key: str = compound_key(collection=collection, key=key) + + _ = self._cache.set(key=combo_key, value=cache_entry.to_json(), expire=ttl) + + @override + async def delete(self, collection: str, key: str) -> bool: + combo_key: str = compound_key(collection=collection, key=key) + return self._cache.delete(key=combo_key) + + @override + async def keys(self, collection: str) -> list[str]: + compound_strings: list[str] = list(self._cache.iterkeys()) + + return get_keys_from_compound_keys(compound_keys=compound_strings, collection=collection) + + @override + async def clear_collection(self, collection: str) -> int: + cleared_count: int = 0 + + for key in await self.keys(collection=collection): + _ = await self.delete(collection=collection, key=key) + cleared_count += 1 + + return cleared_count + + @override + async def list_collections(self) -> list[str]: + compound_strings: list[str] = list(self._cache.iterkeys()) + return get_collections_from_compound_keys(compound_keys=compound_strings) + + @override + async def cull(self) -> None: + _ = self._cache.cull() diff --git a/src/kv_store_adapter/stores/elasticsearch/__init__.py b/src/kv_store_adapter/stores/elasticsearch/__init__.py new file mode 100644 index 00000000..3e9dbcb2 --- /dev/null +++ b/src/kv_store_adapter/stores/elasticsearch/__init__.py @@ -0,0 +1,3 @@ +from .store import ElasticsearchStore + +__all__ = ["ElasticsearchStore"] diff --git a/src/kv_store_adapter/stores/elasticsearch/store.py b/src/kv_store_adapter/stores/elasticsearch/store.py new file mode 100644 index 00000000..535d6f28 --- /dev/null +++ b/src/kv_store_adapter/stores/elasticsearch/store.py @@ -0,0 +1,265 @@ +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, overload + +from elasticsearch import AsyncElasticsearch +from typing_extensions import override + +from kv_store_adapter.stores.base.managed import BaseManagedKVStore +from kv_store_adapter.stores.elasticsearch.utils import ( + get_aggregations_from_body, + get_body_from_response, + get_first_value_from_field_in_hit, + get_hits_from_response, + get_source_from_body, +) +from kv_store_adapter.stores.utils.compound import compound_key +from kv_store_adapter.stores.utils.managed_entry import ManagedEntry, dump_to_json, load_from_json + +if TYPE_CHECKING: + from elastic_transport import ObjectApiResponse + +DEFAULT_DISK_STORE_SIZE_LIMIT = 1 * 1024 * 1024 * 1024 # 1GB + +ELASTICSEARCH_CLIENT_DEFAULTS = { + "http_compress": True, + "timeout": 10, + "retry_on_timeout": True, + "max_retries": 3, +} + +DEFAULT_INDEX = "kv-store" + +DEFAULT_MAPPING = { + "properties": { + "created_at": { + "type": "date", + }, + "expires_at": { + "type": "date", + }, + "ttl": { + "type": "float", + }, + "collection": { + "type": "keyword", + }, + "key": { + "type": "keyword", + }, + "value": { + "type": "keyword", + "index": False, + "doc_values": False, + "ignore_above": 256, + }, + }, +} + + +class ElasticsearchStore(BaseManagedKVStore): + """A elasticsearch-based store.""" + + _client: AsyncElasticsearch + + _index: str + + @overload + def __init__(self, *, client: AsyncElasticsearch, index: str) -> None: ... + + @overload + def __init__(self, *, url: str, api_key: str, index: str) -> None: ... + + def __init__(self, *, client: AsyncElasticsearch | None = None, url: str | None = None, api_key: str | None = None, index: str) -> None: + """Initialize the elasticsearch store. + + Args: + client: The elasticsearch client to use. + url: The url of the elasticsearch cluster. + api_key: The api key to use. + index: The index to use. Defaults to "kv-store". + """ + self._client = client or AsyncElasticsearch(hosts=[url], api_key=api_key) # pyright: ignore[reportArgumentType] + self._index = index or DEFAULT_INDEX + super().__init__() + + @override + async def setup(self) -> None: + if await self._client.options(ignore_status=404).indices.exists(index=self._index): + return + + _ = await self._client.options(ignore_status=404).indices.create( + index=self._index, + mappings=DEFAULT_MAPPING, + ) + + @override + async def setup_collection(self, collection: str) -> None: + pass + + @override + async def get_entry(self, collection: str, key: str) -> ManagedEntry | None: + combo_key: str = compound_key(collection=collection, key=key) + + elasticsearch_response = await self._client.options(ignore_status=404).get(index=self._index, id=combo_key) + + body: dict[str, Any] = get_body_from_response(response=elasticsearch_response) + + if not (source := get_source_from_body(body=body)): + return None + + if not (value_str := source.get("value")) or not isinstance(value_str, str): + return None + + if not (created_at := source.get("created_at")) or not isinstance(created_at, str): + return None + + ttl: Any | float | int | None = source.get("ttl") + expires_at: Any | str | None = source.get("expires_at") + + if not isinstance(ttl, float | int | None): + return None + + if not isinstance(expires_at, str | None): + return None + + return ManagedEntry( + collection=collection, + key=key, + value=load_from_json(value_str), + created_at=datetime.fromisoformat(created_at), + ttl=float(ttl) if ttl else None, + expires_at=datetime.fromisoformat(expires_at) if expires_at else None, + ) + + @override + async def put_entry( + self, + collection: str, + key: str, + cache_entry: ManagedEntry, + *, + ttl: float | None = None, + ) -> None: + combo_key: str = compound_key(collection=collection, key=key) + + _ = await self._client.index( + index=self._index, + id=combo_key, + body={ + "collection": collection, + "key": key, + "value": dump_to_json(cache_entry.value), + "created_at": cache_entry.created_at.isoformat() if cache_entry.created_at else None, + "expires_at": cache_entry.expires_at.isoformat() if cache_entry.expires_at else None, + "ttl": cache_entry.ttl, + }, + ) + + @override + async def delete(self, collection: str, key: str) -> bool: + await self.setup_collection_once(collection=collection) + + combo_key: str = compound_key(collection=collection, key=key) + elasticsearch_response: ObjectApiResponse[Any] = await self._client.options(ignore_status=404).delete( + index=self._index, id=combo_key + ) + + body: dict[str, Any] = get_body_from_response(response=elasticsearch_response) + + if not (result := body.get("result")) or not isinstance(result, str): + return False + + return result == "deleted" + + @override + async def keys(self, collection: str) -> list[str]: + """Get up to 10,000 keys in the specified collection (eventually consistent).""" + await self.setup_collection_once(collection=collection) + + result: ObjectApiResponse[Any] = await self._client.options(ignore_status=404).search( + index=self._index, + fields=["key"], # pyright: ignore[reportArgumentType] + body={ + "query": { + "term": { + "collection": collection, + }, + }, + }, + source_includes=[], + size=10000, + ) + + if not (hits := get_hits_from_response(response=result)): + return [] + + all_keys: list[str] = [] + + for hit in hits: + if not (key := get_first_value_from_field_in_hit(hit=hit, field="key", value_type=str)): + continue + + all_keys.append(key) + + return all_keys + + @override + async def clear_collection(self, collection: str) -> int: + await self.setup_collection_once(collection=collection) + + result: ObjectApiResponse[Any] = await self._client.options(ignore_status=404).delete_by_query( + index=self._index, + body={ + "query": { + "term": { + "collection": collection, + }, + }, + }, + ) + + body: dict[str, Any] = get_body_from_response(response=result) + + if not (deleted := body.get("deleted")) or not isinstance(deleted, int): + return 0 + + return deleted + + @override + async def list_collections(self) -> list[str]: + """List up to 10,000 collections in the elasticsearch store (eventually consistent).""" + await self.setup_once() + + result: ObjectApiResponse[Any] = await self._client.options(ignore_status=404).search( + index=self._index, + aggregations={ + "collections": { + "terms": { + "field": "collection", + }, + }, + }, + size=10000, + ) + + body: dict[str, Any] = get_body_from_response(response=result) + aggregations: dict[str, Any] = get_aggregations_from_body(body=body) + + buckets: list[Any] = aggregations["collections"]["buckets"] # pyright: ignore[reportAny] + + return [bucket["key"] for bucket in buckets] # pyright: ignore[reportAny] + + @override + async def cull(self) -> None: + await self.setup_once() + + _ = await self._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/kv_store_adapter/stores/elasticsearch/utils.py b/src/kv_store_adapter/stores/elasticsearch/utils.py new file mode 100644 index 00000000..f931d8a4 --- /dev/null +++ b/src/kv_store_adapter/stores/elasticsearch/utils.py @@ -0,0 +1,107 @@ +from typing import Any, TypeVar, cast + +from elastic_transport import ObjectApiResponse + + +def get_body_from_response(response: ObjectApiResponse[Any]) -> dict[str, Any]: + if not (body := response.body): # pyright: ignore[reportAny] + return {} + + if not isinstance(body, dict) or not all(isinstance(key, str) for key in body): # pyright: ignore[reportUnknownVariableType] + return {} + + return cast(typ="dict[str, Any]", val=body) + + +def get_source_from_body(body: dict[str, Any]) -> dict[str, Any]: + if not (source := body.get("_source")): + return {} + + if not isinstance(source, dict) or not all(isinstance(key, str) for key in source): # pyright: ignore[reportUnknownVariableType] + return {} + + return cast(typ="dict[str, Any]", val=source) + + +def get_aggregations_from_body(body: dict[str, Any]) -> dict[str, Any]: + if not (aggregations := body.get("aggregations")): + return {} + + if not isinstance(aggregations, dict) or not all(isinstance(key, str) for key in aggregations): # pyright: ignore[reportUnknownVariableType] + return {} + + return cast(typ="dict[str, Any]", val=aggregations) + + +def get_hits_from_response(response: ObjectApiResponse[Any]) -> list[dict[str, Any]]: + if not (body := response.body): # pyright: ignore[reportAny] + return [] + + if not isinstance(body, dict) or not all(isinstance(key, str) for key in body): # pyright: ignore[reportUnknownVariableType] + return [] + + body_dict: dict[str, Any] = cast(typ="dict[str, Any]", val=body) + + if not (hits := body_dict.get("hits")): + return [] + + hits_dict: dict[str, Any] = cast(typ="dict[str, Any]", val=hits) + + if not (hits_list := hits_dict.get("hits")): + return [] + + if not all(isinstance(hit, dict) for hit in hits_list): # pyright: ignore[reportAny] + return [] + + hits_list_dict: list[dict[str, Any]] = cast(typ="list[dict[str, Any]]", val=hits_list) + + return hits_list_dict + + +T = TypeVar("T") + + +def get_fields_from_hit(hit: dict[str, Any]) -> dict[str, list[Any]]: + if not (fields := hit.get("fields")): + return {} + + if not isinstance(fields, dict) or not all(isinstance(key, str) for key in fields): # pyright: ignore[reportUnknownVariableType] + msg = f"Fields in hit {hit} is not a dict" + raise TypeError(msg) + + if not all(isinstance(value, list) for value in fields.values()): # pyright: ignore[reportUnknownVariableType] + msg = f"Fields in hit {hit} is not a dict of lists" + raise TypeError(msg) + + return cast(typ="dict[str, list[Any]]", val=fields) + + +def get_field_from_hit(hit: dict[str, Any], field: str) -> list[Any]: + if not (fields := get_fields_from_hit(hit=hit)): + return [] + + if not (value := fields.get(field)): + msg = f"Field {field} is not in hit {hit}" + raise TypeError(msg) + + return value + + +def get_values_from_field_in_hit(hit: dict[str, Any], field: str, value_type: type[T]) -> list[T]: + if not (value := get_field_from_hit(hit=hit, field=field)): + msg = f"Field {field} is not in hit {hit}" + raise TypeError(msg) + + if not all(isinstance(item, value_type) for item in value): # pyright: ignore[reportAny] + msg = f"Field {field} in hit {hit} is not a list of {value_type}" + raise TypeError(msg) + + return cast(typ="list[T]", val=value) + + +def get_first_value_from_field_in_hit(hit: dict[str, Any], field: str, value_type: type[T]) -> T: + values: list[T] = get_values_from_field_in_hit(hit=hit, field=field, value_type=value_type) + if len(values) != 1: + msg: str = f"Field {field} in hit {hit} is not a single value" + raise TypeError(msg) + return values[0] diff --git a/src/kv_store_adapter/stores/memory/__init__.py b/src/kv_store_adapter/stores/memory/__init__.py new file mode 100644 index 00000000..7bcd5ca4 --- /dev/null +++ b/src/kv_store_adapter/stores/memory/__init__.py @@ -0,0 +1,3 @@ +from .store import MemoryStore + +__all__ = ["MemoryStore"] diff --git a/src/kv_store_adapter/stores/memory/store.py b/src/kv_store_adapter/stores/memory/store.py new file mode 100644 index 00000000..7f0a371a --- /dev/null +++ b/src/kv_store_adapter/stores/memory/store.py @@ -0,0 +1,109 @@ +import sys +from typing import Any + +from cachetools import TLRUCache +from typing_extensions import override + +from kv_store_adapter.stores.base.managed import BaseManagedKVStore +from kv_store_adapter.stores.utils.compound import compound_key, uncompound_key +from kv_store_adapter.stores.utils.managed_entry import ManagedEntry + + +def _memory_cache_ttu(_key: Any, value: ManagedEntry, now: float) -> float: # pyright: ignore[reportAny] + """Calculate time-to-use for cache entries based on their TTL.""" + return now + value.ttl if value.ttl else sys.maxsize + + +def _memory_cache_getsizeof(value: ManagedEntry) -> int: # pyright: ignore[reportUnusedParameter] # noqa: ARG001 + """Return size of cache entry (always 1 for entry counting).""" + return 1 + + +DEFAULT_MEMORY_CACHE_LIMIT = 1000 + + +class MemoryStore(BaseManagedKVStore): + """In-memory key-value store using TLRU (Time-aware Least Recently Used) cache.""" + + max_entries: int + _cache: TLRUCache[str, ManagedEntry] + + def __init__(self, max_entries: int = DEFAULT_MEMORY_CACHE_LIMIT): + """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 = TLRUCache[str, ManagedEntry]( + maxsize=max_entries, + ttu=_memory_cache_ttu, + getsizeof=_memory_cache_getsizeof, + ) + + super().__init__() + + @override + async def setup(self) -> None: + pass + + @override + async def get_entry(self, collection: str, key: str) -> ManagedEntry | None: + combo_key: str = compound_key(collection=collection, key=key) + + if cache_entry := self._cache.get(combo_key): + return cache_entry + + return None + + @override + async def put_entry( + self, + collection: str, + key: str, + cache_entry: ManagedEntry, + *, + ttl: float | None = None, + ) -> None: + combo_key: str = compound_key(collection=collection, key=key) + self._cache[combo_key] = cache_entry + + @override + async def delete(self, collection: str, key: str) -> bool: + combo_key: str = compound_key(collection=collection, key=key) + return self._cache.pop(combo_key, None) is not None + + @override + async def keys(self, collection: str) -> list[str]: + keys: list[str] = [] + + for key in self._cache: + entry_collection, entry_key = uncompound_key(key=key) + if entry_collection == collection: + keys.append(entry_key) + + return keys + + @override + async def clear_collection(self, collection: str) -> int: + cleared_count: int = 0 + + for key in await self.keys(collection=collection): + _ = await self.delete(collection=collection, key=key) + cleared_count += 1 + + return cleared_count + + @override + async def list_collections(self) -> list[str]: + collections: set[str] = set() + for key in self._cache: + entry_collection, _ = uncompound_key(key=key) + collections.add(entry_collection) + return list(collections) + + @override + async def cull(self) -> None: + for collection in await self.list_collections(): + for key in await self.keys(collection=collection): + _ = await self.get_entry(collection=collection, key=key) diff --git a/src/kv_store_adapter/stores/null/__init__.py b/src/kv_store_adapter/stores/null/__init__.py new file mode 100644 index 00000000..7cbca0b5 --- /dev/null +++ b/src/kv_store_adapter/stores/null/__init__.py @@ -0,0 +1,3 @@ +from .store import NullStore + +__all__ = ["NullStore"] diff --git a/src/kv_store_adapter/stores/null/store.py b/src/kv_store_adapter/stores/null/store.py new file mode 100644 index 00000000..8e1d0e98 --- /dev/null +++ b/src/kv_store_adapter/stores/null/store.py @@ -0,0 +1,53 @@ +from typing import Any + +from typing_extensions import override + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.types import TTLInfo + + +class NullStore(BaseKVStore): + """Null object pattern store that accepts all operations but stores nothing.""" + + @override + async def get(self, collection: str, key: str) -> dict[str, Any] | None: + return None + + @override + async def put( + self, + collection: str, + key: str, + value: dict[str, Any], + *, + ttl: float | None = None, + ) -> None: + pass + + @override + async def delete(self, collection: str, key: str) -> bool: + return False + + @override + async def ttl(self, collection: str, key: str) -> TTLInfo | None: + return None + + @override + async def keys(self, collection: str) -> list[str]: + return [] + + @override + async def clear_collection(self, collection: str) -> int: + return 0 + + @override + async def list_collections(self) -> list[str]: + return [] + + @override + async def cull(self) -> None: + pass + + @override + async def exists(self, collection: str, key: str) -> bool: + return False diff --git a/src/kv_store_adapter/stores/redis/__init__.py b/src/kv_store_adapter/stores/redis/__init__.py new file mode 100644 index 00000000..600d165b --- /dev/null +++ b/src/kv_store_adapter/stores/redis/__init__.py @@ -0,0 +1,3 @@ +from .store import RedisStore + +__all__ = ["RedisStore"] diff --git a/src/kv_store_adapter/stores/redis/store.py b/src/kv_store_adapter/stores/redis/store.py new file mode 100644 index 00000000..875ef27c --- /dev/null +++ b/src/kv_store_adapter/stores/redis/store.py @@ -0,0 +1,158 @@ +from typing import Any, overload +from urllib.parse import urlparse + +from redis.asyncio import Redis +from typing_extensions import override + +from kv_store_adapter.errors import StoreConnectionError +from kv_store_adapter.stores.base.managed import BaseManagedKVStore +from kv_store_adapter.stores.utils.compound import compound_key, get_keys_from_compound_keys, uncompound_key +from kv_store_adapter.stores.utils.managed_entry import ManagedEntry + + +class RedisStore(BaseManagedKVStore): + """Redis-based key-value store.""" + + _client: Redis + + @overload + def __init__(self, *, client: Redis) -> None: ... + + @overload + def __init__(self, *, url: str) -> None: ... + + @overload + def __init__(self, *, host: str = "localhost", port: int = 6379, db: int = 0, password: str | None = None) -> None: ... + + def __init__( + self, + *, + client: Redis | None = None, + url: str | None = None, + host: str = "localhost", + port: int = 6379, + db: int = 0, + password: str | None = None, + ) -> None: + """Initialize the Redis store. + + Args: + client: An existing Redis client to use. + url: Redis URL (e.g., redis://localhost:6379/0). + host: Redis host. Defaults to localhost. + port: Redis port. Defaults to 6379. + db: Redis database number. Defaults to 0. + password: Redis password. Defaults to None. + """ + if client: + self._client = client + elif url: + parsed_url = urlparse(url) + self._client = Redis( + host=parsed_url.hostname or "localhost", + port=parsed_url.port or 6379, + db=int(parsed_url.path.lstrip("/")) if parsed_url.path and parsed_url.path != "/" else 0, + password=parsed_url.password or password, + decode_responses=True, + ) + else: + self._client = Redis( + host=host, + port=port, + db=db, + password=password, + decode_responses=True, + ) + + super().__init__() + + @override + async def setup(self) -> None: + if not await self._client.ping(): # pyright: ignore[reportUnknownMemberType] + raise StoreConnectionError(message="Failed to connect to Redis") + + @override + async def get_entry(self, collection: str, key: str) -> ManagedEntry | None: + combo_key: str = compound_key(collection=collection, key=key) + + cache_entry: Any = await self._client.get(name=combo_key) # pyright: ignore[reportAny] + + if cache_entry is None: + return None + + if not isinstance(cache_entry, str): + return None + + return ManagedEntry.from_json(json_str=cache_entry) + + @override + async def put_entry( + self, + collection: str, + key: str, + cache_entry: ManagedEntry, + *, + ttl: float | None = None, + ) -> None: + combo_key: str = compound_key(collection=collection, key=key) + + json_value: str = cache_entry.to_json() + + if ttl is not None: + # Redis does not support <= 0 TTLs + ttl = max(int(ttl), 1) + + _ = await self._client.setex(name=combo_key, time=ttl, value=json_value) # pyright: ignore[reportAny] + else: + _ = await self._client.set(name=combo_key, value=json_value) # pyright: ignore[reportAny] + + @override + async def delete(self, collection: str, key: str) -> bool: + await self.setup_collection_once(collection=collection) + + combo_key: str = compound_key(collection=collection, key=key) + return await self._client.delete(combo_key) != 0 # pyright: ignore[reportAny] + + @override + async def keys(self, collection: str) -> list[str]: + await self.setup_collection_once(collection=collection) + + pattern = compound_key(collection=collection, key="*") + compound_keys: list[str] = await self._client.keys(pattern) # pyright: ignore[reportUnknownMemberType, reportAny] + + return get_keys_from_compound_keys(compound_keys=compound_keys, collection=collection) + + @override + async def clear_collection(self, collection: str) -> int: + await self.setup_collection_once(collection=collection) + + pattern = compound_key(collection=collection, key="*") + + deleted_count: int = 0 + + async for key in self._client.scan_iter(name=pattern): # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + if not isinstance(key, str): + continue + + deleted_count += await self._client.delete(key) # pyright: ignore[reportAny] + + return deleted_count + + @override + async def list_collections(self) -> list[str]: + await self.setup_once() + + pattern: str = compound_key(collection="*", key="*") + + collections: set[str] = set() + + async for key in self._client.scan_iter(name=pattern): # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + if not isinstance(key, str): + continue + + collections.add(uncompound_key(key=key)[0]) + + return list[str](collections) + + @override + async def cull(self) -> None: ... diff --git a/src/kv_store_adapter/stores/simple/__init__.py b/src/kv_store_adapter/stores/simple/__init__.py new file mode 100644 index 00000000..dbbcf5af --- /dev/null +++ b/src/kv_store_adapter/stores/simple/__init__.py @@ -0,0 +1,4 @@ +from .json_store import SimpleJSONStore +from .store import SimpleStore + +__all__ = ["SimpleJSONStore", "SimpleStore"] diff --git a/src/kv_store_adapter/stores/simple/json_store.py b/src/kv_store_adapter/stores/simple/json_store.py new file mode 100644 index 00000000..858ec362 --- /dev/null +++ b/src/kv_store_adapter/stores/simple/json_store.py @@ -0,0 +1,69 @@ +from typing_extensions import override + +from kv_store_adapter.stores.base.managed import BaseManagedKVStore +from kv_store_adapter.stores.utils.compound import compound_key, get_collections_from_compound_keys, get_keys_from_compound_keys +from kv_store_adapter.stores.utils.managed_entry import ManagedEntry + +DEFAULT_SIMPLE_JSON_STORE_MAX_ENTRIES = 1000 + + +class SimpleJSONStore(BaseManagedKVStore): + """Simple JSON-serialized dictionary-based key-value store for testing.""" + + max_entries: int + _data: dict[str, str] + + def __init__(self, max_entries: int = DEFAULT_SIMPLE_JSON_STORE_MAX_ENTRIES): + super().__init__() + self.max_entries = max_entries + self._data = {} + + @override + async def setup(self) -> None: + pass + + @override + async def get_entry(self, collection: str, key: str) -> ManagedEntry | None: + combo_key: str = compound_key(collection=collection, key=key) + + if not (data := self._data.get(combo_key)): + return None + + return ManagedEntry.from_json(json_str=data) + + @override + async def put_entry(self, collection: str, key: str, cache_entry: ManagedEntry, *, ttl: float | None = None) -> None: + combo_key: str = compound_key(collection=collection, key=key) + + if len(self._data) >= self.max_entries: + _ = self._data.pop(next(iter(self._data))) + + self._data[combo_key] = cache_entry.to_json() + + @override + async def delete(self, collection: str, key: str) -> bool: + combo_key: str = compound_key(collection=collection, key=key) + return self._data.pop(combo_key, None) is not None + + @override + async def keys(self, collection: str) -> list[str]: + return get_keys_from_compound_keys(compound_keys=list(self._data.keys()), collection=collection) + + @override + async def clear_collection(self, collection: str) -> int: + keys: list[str] = get_keys_from_compound_keys(compound_keys=list(self._data.keys()), collection=collection) + + for key in keys: + _ = self._data.pop(key) + + return len(keys) + + @override + async def list_collections(self) -> list[str]: + return get_collections_from_compound_keys(compound_keys=list(self._data.keys())) + + @override + async def cull(self) -> None: + for collection in await self.list_collections(): + for key in get_keys_from_compound_keys(compound_keys=list(self._data.keys()), collection=collection): + _ = await self.get_entry(collection=collection, key=key) diff --git a/src/kv_store_adapter/stores/simple/store.py b/src/kv_store_adapter/stores/simple/store.py new file mode 100644 index 00000000..a3859d4f --- /dev/null +++ b/src/kv_store_adapter/stores/simple/store.py @@ -0,0 +1,166 @@ +from collections import defaultdict +from datetime import datetime, timezone +from typing import Any + +from typing_extensions import override + +from kv_store_adapter.stores.base.managed import BaseManagedKVStore +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.stores.utils.compound import compound_key, get_collections_from_compound_keys, get_keys_from_compound_keys +from kv_store_adapter.stores.utils.managed_entry import ManagedEntry +from kv_store_adapter.stores.utils.time_to_live import calculate_expires_at +from kv_store_adapter.types import TTLInfo + +DEFAULT_SIMPLE_MANAGED_STORE_MAX_ENTRIES = 1000 +DEFAULT_SIMPLE_STORE_MAX_ENTRIES = 1000 + + +class SimpleStore(BaseKVStore): + """Simple dictionary-based key-value store for testing and development.""" + + max_entries: int + _data: dict[str, dict[str, Any]] + _expirations: dict[str, datetime] + + def __init__(self, max_entries: int = DEFAULT_SIMPLE_STORE_MAX_ENTRIES): + super().__init__() + self.max_entries = max_entries + self._data = defaultdict[str, dict[str, Any]](dict) + self._expirations = defaultdict[str, datetime]() + + async def setup(self) -> None: + pass + + @override + async def get(self, collection: str, key: str) -> dict[str, Any] | None: + combo_key: str = compound_key(collection=collection, key=key) + + if not (data := self._data.get(combo_key)): + return None + + if not (expiration := self._expirations.get(combo_key)): + return data + + if expiration <= datetime.now(tz=timezone.utc): + del self._data[combo_key] + del self._expirations[combo_key] + return None + + return data + + @override + async def exists(self, collection: str, key: str) -> bool: + return await self.get(collection=collection, key=key) is not None + + @override + async def put(self, collection: str, key: str, value: dict[str, Any], *, ttl: float | None = None) -> None: + combo_key: str = compound_key(collection=collection, key=key) + + if len(self._data) >= self.max_entries: + _ = self._data.pop(next(iter(self._data))) + + _ = self._data[combo_key] = value + + if expires_at := calculate_expires_at(ttl=ttl): + _ = self._expirations[combo_key] = expires_at + + @override + async def delete(self, collection: str, key: str) -> bool: + combo_key: str = compound_key(collection=collection, key=key) + return self._data.pop(combo_key, None) is not None + + @override + async def ttl(self, collection: str, key: str) -> TTLInfo | None: + combo_key: str = compound_key(collection=collection, key=key) + + if not (expiration := self._expirations.get(combo_key)): + return None + + return TTLInfo(collection=collection, key=key, created_at=None, ttl=None, expires_at=expiration) + + @override + async def keys(self, collection: str) -> list[str]: + return get_keys_from_compound_keys(compound_keys=list(self._data.keys()), collection=collection) + + @override + async def clear_collection(self, collection: str) -> int: + keys: list[str] = get_keys_from_compound_keys(compound_keys=list(self._data.keys()), collection=collection) + + for key in keys: + _ = self._data.pop(key) + _ = self._expirations.pop(key) + + return len(keys) + + @override + async def list_collections(self) -> list[str]: + return get_collections_from_compound_keys(compound_keys=list(self._data.keys())) + + @override + async def cull(self) -> None: + for collection in await self.list_collections(): + for key in get_keys_from_compound_keys(compound_keys=list(self._data.keys()), collection=collection): + if not (expiration := self._expirations.get(key)): + continue + + if expiration <= datetime.now(tz=timezone.utc): + _ = self._data.pop(key) + _ = self._expirations.pop(key) + + +class SimpleManagedStore(BaseManagedKVStore): + """Simple managed dictionary-based key-value store for testing and development.""" + + max_entries: int + _data: dict[str, ManagedEntry] + + def __init__(self, max_entries: int = DEFAULT_SIMPLE_MANAGED_STORE_MAX_ENTRIES): + super().__init__() + self.max_entries = max_entries + self._data = defaultdict[str, ManagedEntry]() + + @override + async def setup(self) -> None: + pass + + @override + async def get_entry(self, collection: str, key: str) -> ManagedEntry | None: + combo_key: str = compound_key(collection=collection, key=key) + return self._data.get(combo_key) + + @override + async def put_entry(self, collection: str, key: str, cache_entry: ManagedEntry, *, ttl: float | None = None) -> None: + combo_key: str = compound_key(collection=collection, key=key) + + if len(self._data) >= self.max_entries: + _ = self._data.pop(next(iter(self._data))) + + self._data[combo_key] = cache_entry + + @override + async def delete(self, collection: str, key: str) -> bool: + combo_key: str = compound_key(collection=collection, key=key) + return self._data.pop(combo_key, None) is not None + + @override + async def keys(self, collection: str) -> list[str]: + return get_keys_from_compound_keys(compound_keys=list(self._data.keys()), collection=collection) + + @override + async def clear_collection(self, collection: str) -> int: + keys: list[str] = get_keys_from_compound_keys(compound_keys=list(self._data.keys()), collection=collection) + + for key in keys: + _ = self._data.pop(key) + + return len(keys) + + @override + async def list_collections(self) -> list[str]: + return get_collections_from_compound_keys(compound_keys=list(self._data.keys())) + + @override + async def cull(self) -> None: + for collection in await self.list_collections(): + for key in get_keys_from_compound_keys(compound_keys=list(self._data.keys()), collection=collection): + _ = await self.get_entry(collection=collection, key=key) diff --git a/src/kv_store_adapter/stores/utils/compound.py b/src/kv_store_adapter/stores/utils/compound.py new file mode 100644 index 00000000..c7231621 --- /dev/null +++ b/src/kv_store_adapter/stores/utils/compound.py @@ -0,0 +1,69 @@ +DEFAULT_COMPOUND_SEPARATOR = "::" +DEFAULT_PREFIX_SEPARATOR = "__" + + +def compound_string(first: str, second: str, separator: str | None = None) -> str: + separator = separator or DEFAULT_COMPOUND_SEPARATOR + return f"{first}{separator}{second}" + + +def uncompound_string(string: str, separator: str | None = None) -> tuple[str, str]: + separator = separator or DEFAULT_COMPOUND_SEPARATOR + if separator not in string: + msg: str = f"String {string} is not a compound identifier" + raise TypeError(msg) from None + + split_key: list[str] = string.split(separator, 1) + + if len(split_key) != 2: # noqa: PLR2004 + msg = f"String {string} is not a compound identifier" + raise TypeError(msg) from None + + return split_key[0], split_key[1] + + +def uncompound_strings(strings: list[str], separator: str | None = None) -> list[tuple[str, str]]: + separator = separator or DEFAULT_COMPOUND_SEPARATOR + return [uncompound_string(string=string, separator=separator) for string in strings] + + +def compound_key(collection: str, key: str, separator: str | None = None) -> str: + separator = separator or DEFAULT_COMPOUND_SEPARATOR + return compound_string(first=collection, second=key, separator=separator) + + +def uncompound_key(key: str, separator: str | None = None) -> tuple[str, str]: + separator = separator or DEFAULT_COMPOUND_SEPARATOR + return uncompound_string(string=key, separator=separator) + + +def prefix_key(key: str, prefix: str, separator: str | None = None) -> str: + separator = separator or DEFAULT_PREFIX_SEPARATOR + return compound_string(first=prefix, second=key, separator=separator) + + +def unprefix_key(key: str, separator: str | None = None) -> str: + separator = separator or DEFAULT_PREFIX_SEPARATOR + return uncompound_string(string=key, separator=separator)[1] + + +def prefix_collection(collection: str, prefix: str, separator: str | None = None) -> str: + separator = separator or DEFAULT_PREFIX_SEPARATOR + return compound_string(first=prefix, second=collection, separator=separator) + + +def unprefix_collection(collection: str, separator: str | None = None) -> str: + separator = separator or DEFAULT_PREFIX_SEPARATOR + return uncompound_string(string=collection, separator=separator)[1] + + +def get_collections_from_compound_keys(compound_keys: list[str], separator: str | None = None) -> list[str]: + """Returns a unique list of collections from a list of compound keys.""" + separator = separator or DEFAULT_COMPOUND_SEPARATOR + return list({key_collection for key_collection, _ in uncompound_strings(strings=compound_keys)}) + + +def get_keys_from_compound_keys(compound_keys: list[str], collection: str, separator: str | None = None) -> list[str]: + """Returns a list of keys from a list of compound keys for a given collection.""" + separator = separator or DEFAULT_COMPOUND_SEPARATOR + return [key for key_collection, key in uncompound_strings(strings=compound_keys) if key_collection == collection] diff --git a/src/kv_store_adapter/stores/utils/managed_entry.py b/src/kv_store_adapter/stores/utils/managed_entry.py new file mode 100644 index 00000000..170a11ae --- /dev/null +++ b/src/kv_store_adapter/stores/utils/managed_entry.py @@ -0,0 +1,85 @@ +import json +from dataclasses import dataclass +from datetime import datetime +from typing import Any, cast + +from typing_extensions import Self + +from kv_store_adapter.errors import DeserializationError, SerializationError +from kv_store_adapter.types import TTLInfo + + +@dataclass +class ManagedEntry: + """A managed cache entry containing value data and TTL metadata.""" + + collection: str + key: str + + value: dict[str, Any] + + created_at: datetime | None + ttl: float | None + expires_at: datetime | None + + @property + def is_expired(self) -> bool: + return self.to_ttl_info().is_expired + + def to_ttl_info(self) -> TTLInfo: + return TTLInfo(collection=self.collection, key=self.key, created_at=self.created_at, ttl=self.ttl, expires_at=self.expires_at) + + def to_json(self) -> str: + return dump_to_json( + obj={ + "created_at": self.created_at.isoformat() if self.created_at else None, + "ttl": self.ttl, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + "collection": self.collection, + "key": self.key, + "value": self.value, + } + ) + + @classmethod + def from_json(cls, json_str: str) -> Self: + data: dict[str, Any] = load_from_json(json_str=json_str) + created_at: str | None = data.get("created_at") + expires_at: str | None = data.get("expires_at") + ttl: float | None = data.get("ttl") + + return cls( + created_at=datetime.fromisoformat(created_at) if created_at else None, + ttl=ttl, + expires_at=datetime.fromisoformat(expires_at) if expires_at else None, + collection=data["collection"], # pyright: ignore[reportAny] + key=data["key"], # pyright: ignore[reportAny] + value=data["value"], # pyright: ignore[reportAny] + ) + + +def dump_to_json(obj: dict[str, Any]) -> str: + try: + return json.dumps(obj) + except json.JSONDecodeError as e: + msg: str = f"Failed to serialize object to JSON: {e}" + raise SerializationError(msg) from e + + +def load_from_json(json_str: str) -> dict[str, Any]: + try: + deserialized_obj: Any = json.loads(json_str) # pyright: ignore[reportAny] + + except (json.JSONDecodeError, TypeError) as e: + msg: str = f"Failed to deserialize JSON string: {e}" + raise DeserializationError(msg) from e + + if not isinstance(deserialized_obj, dict): + msg = "Deserialized object is not a dictionary" + raise DeserializationError(msg) + + if not all(isinstance(key, str) for key in deserialized_obj): # pyright: ignore[reportUnknownVariableType] + msg = "Deserialized object contains non-string keys" + raise DeserializationError(msg) + + return cast(typ="dict[str, Any]", val=deserialized_obj) diff --git a/src/kv_store_adapter/stores/utils/time_to_live.py b/src/kv_store_adapter/stores/utils/time_to_live.py new file mode 100644 index 00000000..4eee4d80 --- /dev/null +++ b/src/kv_store_adapter/stores/utils/time_to_live.py @@ -0,0 +1,10 @@ +from datetime import datetime, timedelta, timezone + + +def calculate_expires_at(created_at: datetime | None = None, ttl: float | None = None) -> datetime | None: + """Calculate expiration timestamp from creation time and TTL seconds.""" + if ttl is None: + return None + + expires_at: datetime = (created_at or datetime.now(tz=timezone.utc)) + timedelta(seconds=ttl) + return expires_at diff --git a/src/kv_store_adapter/stores/wrappers/__init__.py b/src/kv_store_adapter/stores/wrappers/__init__.py new file mode 100644 index 00000000..b7e7f975 --- /dev/null +++ b/src/kv_store_adapter/stores/wrappers/__init__.py @@ -0,0 +1,6 @@ +from .prefix_collection import PrefixCollectionWrapper +from .prefix_key import PrefixKeyWrapper +from .single_collection import SingleCollectionWrapper +from .statistics import StatisticsWrapper + +__all__ = ["PrefixCollectionWrapper", "PrefixKeyWrapper", "SingleCollectionWrapper", "StatisticsWrapper"] diff --git a/src/kv_store_adapter/stores/wrappers/clamp_ttl.py b/src/kv_store_adapter/stores/wrappers/clamp_ttl.py new file mode 100644 index 00000000..59ec24b4 --- /dev/null +++ b/src/kv_store_adapter/stores/wrappers/clamp_ttl.py @@ -0,0 +1,69 @@ +from typing import Any + +from typing_extensions import override + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.types import TTLInfo + + +class TTLClampWrapper(BaseKVStore): + """Wrapper that enforces a maximum TTL for puts into the store.""" + + def __init__(self, store: BaseKVStore, min_ttl: float, max_ttl: float, missing_ttl: float | None = None) -> None: + """Initialize the TTL clamp wrapper. + + Args: + store: The store to wrap. + min_ttl: The minimum TTL for puts into the store. + max_ttl: The maximum TTL for puts into the store. + missing_ttl: The TTL to use for entries that do not have a TTL. Defaults to None. + """ + self.store: BaseKVStore = store + self.min_ttl: float = min_ttl + self.max_ttl: float = max_ttl + self.missing_ttl: float | None = missing_ttl + + @override + async def get(self, collection: str, key: str) -> dict[str, Any] | None: + return await self.store.get(collection=collection, key=key) + + @override + async def put(self, collection: str, key: str, value: dict[str, Any], *, ttl: float | None = None) -> None: + if ttl is None and self.missing_ttl: + ttl = self.missing_ttl + + if ttl and ttl < self.min_ttl: + ttl = self.min_ttl + + if ttl and ttl > self.max_ttl: + ttl = self.max_ttl + + await self.store.put(collection=collection, key=key, value=value, ttl=ttl) + + @override + async def delete(self, collection: str, key: str) -> bool: + return await self.store.delete(collection=collection, key=key) + + @override + async def exists(self, collection: str, key: str) -> bool: + return await self.store.exists(collection=collection, key=key) + + @override + async def keys(self, collection: str) -> list[str]: + return await self.store.keys(collection=collection) + + @override + async def clear_collection(self, collection: str) -> int: + return await self.store.clear_collection(collection=collection) + + @override + async def ttl(self, collection: str, key: str) -> TTLInfo | None: + return await self.store.ttl(collection=collection, key=key) + + @override + async def list_collections(self) -> list[str]: + return await self.store.list_collections() + + @override + async def cull(self) -> None: + await self.store.cull() diff --git a/src/kv_store_adapter/stores/wrappers/passthrough_cache.py b/src/kv_store_adapter/stores/wrappers/passthrough_cache.py new file mode 100644 index 00000000..713bf037 --- /dev/null +++ b/src/kv_store_adapter/stores/wrappers/passthrough_cache.py @@ -0,0 +1,81 @@ +from typing import Any + +from typing_extensions import override + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.types import TTLInfo + + +class PassthroughCacheWrapper(BaseKVStore): + """Wrapper that users two stores, ideal for combining a local and distributed store.""" + + def __init__(self, primary_store: BaseKVStore, cache_store: BaseKVStore) -> None: + """Initialize the passthrough cache wrapper. Items are first checked in the primary store and if not found, are + checked in the secondary store. Operations are performed on both stores but are not atomic. + + Operations like expiry culling against the primary store will not be reflected in the cache store. This may + lead to stale data in the cache store. One way to combat this is to use a TTLClampWrapper on the cache store to + enforce a lower TTL on the cache store than the primary store. + + Args: + primary_store: The primary store the data will live in. + cache_store: The write-through (likely ephemeral) cache to use. + """ + self.cache_store: BaseKVStore = cache_store + self.primary_store: BaseKVStore = primary_store + + @override + async def get(self, collection: str, key: str) -> dict[str, Any] | None: + if cache_store_value := await self.cache_store.get(collection=collection, key=key): + return cache_store_value + + if primary_store_value := await self.primary_store.get(collection=collection, key=key): + ttl_info: TTLInfo | None = await self.primary_store.ttl(collection=collection, key=key) + + await self.cache_store.put(collection=collection, key=key, value=primary_store_value, ttl=ttl_info.ttl if ttl_info else None) + + return primary_store_value + return None + + @override + async def put(self, collection: str, key: str, value: dict[str, Any], *, ttl: float | None = None) -> None: + _ = await self.cache_store.delete(collection=collection, key=key) + await self.primary_store.put(collection=collection, key=key, value=value, ttl=ttl) + + @override + async def delete(self, collection: str, key: str) -> bool: + deleted = await self.primary_store.delete(collection=collection, key=key) + _ = await self.cache_store.delete(collection=collection, key=key) + return deleted + + @override + async def exists(self, collection: str, key: str) -> bool: + return await self.get(collection=collection, key=key) is not None + + @override + async def keys(self, collection: str) -> list[str]: + return await self.primary_store.keys(collection=collection) + + @override + async def clear_collection(self, collection: str) -> int: + removed: int = await self.primary_store.clear_collection(collection=collection) + _ = await self.cache_store.clear_collection(collection=collection) + return removed + + @override + async def ttl(self, collection: str, key: str) -> TTLInfo | None: + if ttl_info := await self.cache_store.ttl(collection=collection, key=key): + return ttl_info + + return await self.primary_store.ttl(collection=collection, key=key) + + @override + async def list_collections(self) -> list[str]: + collections: list[str] = await self.primary_store.list_collections() + + return collections + + @override + async def cull(self) -> None: + await self.primary_store.cull() + await self.cache_store.cull() diff --git a/src/kv_store_adapter/stores/wrappers/prefix_collection.py b/src/kv_store_adapter/stores/wrappers/prefix_collection.py new file mode 100644 index 00000000..6488e611 --- /dev/null +++ b/src/kv_store_adapter/stores/wrappers/prefix_collection.py @@ -0,0 +1,76 @@ +from typing import Any + +from typing_extensions import override + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.stores.utils.compound import DEFAULT_PREFIX_SEPARATOR, prefix_collection, unprefix_collection +from kv_store_adapter.types import TTLInfo + + +class PrefixCollectionWrapper(BaseKVStore): + """Wrapper that prefixes all collections with a given prefix.""" + + def __init__(self, store: BaseKVStore, prefix: str, separator: str | None = None) -> None: + """Initialize the prefix collection wrapper. + + Args: + store: The store to wrap. + prefix: The prefix to add to all collections. + separator: The separator to use between the prefix and the collection. Defaults to "__". + """ + self.store: BaseKVStore = store + self.prefix: str = prefix + self.separator: str = separator or DEFAULT_PREFIX_SEPARATOR + + @override + async def get(self, collection: str, key: str) -> dict[str, Any] | None: + prefixed_collection: str = prefix_collection(collection=collection, prefix=self.prefix, separator=self.separator) + return await self.store.get(collection=prefixed_collection, key=key) + + @override + async def put(self, collection: str, key: str, value: dict[str, Any], *, ttl: float | None = None) -> None: + prefixed_collection: str = prefix_collection(collection=collection, prefix=self.prefix, separator=self.separator) + await self.store.put(collection=prefixed_collection, key=key, value=value, ttl=ttl) + + @override + async def delete(self, collection: str, key: str) -> bool: + prefixed_collection: str = prefix_collection(collection=collection, prefix=self.prefix, separator=self.separator) + return await self.store.delete(collection=prefixed_collection, key=key) + + @override + async def exists(self, collection: str, key: str) -> bool: + prefixed_collection: str = prefix_collection(collection=collection, prefix=self.prefix, separator=self.separator) + return await self.store.exists(collection=prefixed_collection, key=key) + + @override + async def keys(self, collection: str) -> list[str]: + prefixed_collection: str = prefix_collection(collection=collection, prefix=self.prefix, separator=self.separator) + return await self.store.keys(collection=prefixed_collection) + + @override + async def clear_collection(self, collection: str) -> int: + prefixed_collection: str = prefix_collection(collection=collection, prefix=self.prefix, separator=self.separator) + return await self.store.clear_collection(collection=prefixed_collection) + + @override + async def ttl(self, collection: str, key: str) -> TTLInfo | None: + prefixed_collection: str = prefix_collection(collection=collection, prefix=self.prefix, separator=self.separator) + ttl_info: TTLInfo | None = await self.store.ttl(collection=prefixed_collection, key=key) + if ttl_info: + ttl_info.collection = collection + ttl_info.key = key + return ttl_info + + @override + async def list_collections(self) -> list[str]: + collections: list[str] = await self.store.list_collections() + + return [ + unprefix_collection(collection=collection, separator=self.separator) + for collection in collections + if collection.startswith(self.prefix) + ] + + @override + async def cull(self) -> None: + await self.store.cull() diff --git a/src/kv_store_adapter/stores/wrappers/prefix_key.py b/src/kv_store_adapter/stores/wrappers/prefix_key.py new file mode 100644 index 00000000..a7c43fe2 --- /dev/null +++ b/src/kv_store_adapter/stores/wrappers/prefix_key.py @@ -0,0 +1,69 @@ +from typing import Any + +from typing_extensions import override + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.stores.utils.compound import DEFAULT_PREFIX_SEPARATOR, prefix_key, unprefix_key +from kv_store_adapter.types import TTLInfo + + +class PrefixKeyWrapper(BaseKVStore): + """Wrapper that prefixes all keys with a given prefix.""" + + def __init__(self, store: BaseKVStore, prefix: str, separator: str | None = None) -> None: + """Initialize the prefix key wrapper. + + Args: + store: The store to wrap. + prefix: The prefix to add to all keys. + separator: The separator to use between the prefix and the key. Defaults to "__". + """ + self.store: BaseKVStore = store + self.prefix: str = prefix + self.separator: str = separator or DEFAULT_PREFIX_SEPARATOR + + @override + async def get(self, collection: str, key: str) -> dict[str, Any] | None: + prefixed_key: str = prefix_key(key=key, prefix=self.prefix, separator=self.separator) + return await self.store.get(collection=collection, key=prefixed_key) + + @override + async def put(self, collection: str, key: str, value: dict[str, Any], *, ttl: float | None = None) -> None: + prefixed_key: str = prefix_key(key=key, prefix=self.prefix, separator=self.separator) + await self.store.put(collection=collection, key=prefixed_key, value=value, ttl=ttl) + + @override + async def delete(self, collection: str, key: str) -> bool: + prefixed_key: str = prefix_key(key=key, prefix=self.prefix, separator=self.separator) + return await self.store.delete(collection=collection, key=prefixed_key) + + @override + async def exists(self, collection: str, key: str) -> bool: + prefixed_key: str = prefix_key(key=key, prefix=self.prefix, separator=self.separator) + return await self.store.exists(collection=collection, key=prefixed_key) + + @override + async def keys(self, collection: str) -> list[str]: + keys: list[str] = await self.store.keys(collection=collection) + return [unprefix_key(key=key, separator=self.separator) for key in keys] + + @override + async def clear_collection(self, collection: str) -> int: + return await self.store.clear_collection(collection=collection) + + @override + async def ttl(self, collection: str, key: str) -> TTLInfo | None: + prefixed_key: str = prefix_key(key=key, prefix=self.prefix, separator=self.separator) + ttl_info: TTLInfo | None = await self.store.ttl(collection=collection, key=prefixed_key) + if ttl_info: + ttl_info.collection = collection + ttl_info.key = key + return ttl_info + + @override + async def list_collections(self) -> list[str]: + return await self.store.list_collections() + + @override + async def cull(self) -> None: + await self.store.cull() diff --git a/src/kv_store_adapter/stores/wrappers/single_collection.py b/src/kv_store_adapter/stores/wrappers/single_collection.py new file mode 100644 index 00000000..6806a6cc --- /dev/null +++ b/src/kv_store_adapter/stores/wrappers/single_collection.py @@ -0,0 +1,68 @@ +from typing import Any + +from typing_extensions import override + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.stores.utils.compound import DEFAULT_PREFIX_SEPARATOR, prefix_key, unprefix_key +from kv_store_adapter.types import TTLInfo + + +class SingleCollectionWrapper(BaseKVStore): + """Wrapper that forces all requests into a single collection, prefixes the keys with the original collection name. + + The single collection wrapper does not support collection operations.""" + + def __init__(self, store: BaseKVStore, collection: str, prefix_separator: str | None = None) -> None: + self.collection: str = collection + self.prefix_separator: str = prefix_separator or DEFAULT_PREFIX_SEPARATOR + self.store: BaseKVStore = store + + @override + async def get(self, collection: str, key: str) -> dict[str, Any] | None: + prefixed_key: str = prefix_key(key=key, prefix=collection, separator=self.prefix_separator) + return await self.store.get(collection=self.collection, key=prefixed_key) + + @override + async def put(self, collection: str, key: str, value: dict[str, Any], *, ttl: float | None = None) -> None: + prefixed_key: str = prefix_key(key=key, prefix=collection, separator=self.prefix_separator) + await self.store.put(collection=self.collection, key=prefixed_key, value=value, ttl=ttl) + + @override + async def delete(self, collection: str, key: str) -> bool: + prefixed_key: str = prefix_key(key=key, prefix=collection, separator=self.prefix_separator) + return await self.store.delete(collection=self.collection, key=prefixed_key) + + @override + async def exists(self, collection: str, key: str) -> bool: + prefixed_key: str = prefix_key(key=key, prefix=collection, separator=self.prefix_separator) + return await self.store.exists(collection=self.collection, key=prefixed_key) + + @override + async def keys(self, collection: str) -> list[str]: + keys: list[str] = await self.store.keys(collection=collection) + return [unprefix_key(key=key, separator=self.prefix_separator) for key in keys] + + @override + async def clear_collection(self, collection: str) -> int: + msg = "Clearing a collection is not supported for SingleCollectionWrapper" + raise NotImplementedError(msg) + + # return await self.store.clear_collection(collection=self.collection) + + @override + async def ttl(self, collection: str, key: str) -> TTLInfo | None: + prefixed_key: str = prefix_key(key=key, prefix=collection, separator=self.prefix_separator) + ttl: TTLInfo | None = await self.store.ttl(collection=self.collection, key=prefixed_key) + if ttl: + ttl.collection = collection + ttl.key = key + return ttl + + @override + async def list_collections(self) -> list[str]: + msg = "Listing collections is not supported for SingleCollectionWrapper" + raise NotImplementedError(msg) + + @override + async def cull(self) -> None: + await self.store.cull() diff --git a/src/kv_store_adapter/stores/wrappers/statistics.py b/src/kv_store_adapter/stores/wrappers/statistics.py new file mode 100644 index 00000000..5163808f --- /dev/null +++ b/src/kv_store_adapter/stores/wrappers/statistics.py @@ -0,0 +1,197 @@ +from dataclasses import dataclass, field +from typing import Any + +from typing_extensions import override + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.types import TTLInfo + + +@dataclass +class BaseStatistics: + """Base statistics container with operation counting.""" + + count: int = field(default=0) + """The number of operations.""" + + def increment(self) -> None: + self.count += 1 + + +@dataclass +class BaseHitMissStatistics(BaseStatistics): + """Statistics container with hit/miss tracking for cache-like operations.""" + + hit: int = field(default=0) + """The number of hits.""" + miss: int = field(default=0) + """The number of misses.""" + + def increment_hit(self) -> None: + self.increment() + self.hit += 1 + + def increment_miss(self) -> None: + self.increment() + self.miss += 1 + + +@dataclass +class GetStatistics(BaseHitMissStatistics): + """A class for statistics about a KV Store collection.""" + + +@dataclass +class SetStatistics(BaseStatistics): + """A class for statistics about a KV Store collection.""" + + +@dataclass +class DeleteStatistics(BaseHitMissStatistics): + """A class for statistics about a KV Store collection.""" + + +@dataclass +class ExistsStatistics(BaseHitMissStatistics): + """A class for statistics about a KV Store collection.""" + + +@dataclass +class KeysStatistics(BaseStatistics): + """A class for statistics about a KV Store collection.""" + + +@dataclass +class ClearCollectionStatistics(BaseHitMissStatistics): + """A class for statistics about a KV Store collection.""" + + +@dataclass +class ListCollectionsStatistics(BaseStatistics): + """A class for statistics about a KV Store collection.""" + + +@dataclass +class KVStoreCollectionStatistics(BaseStatistics): + """A class for statistics about a KV Store collection.""" + + get: GetStatistics = field(default_factory=GetStatistics) + """The statistics for the get operation.""" + + set: SetStatistics = field(default_factory=SetStatistics) + """The statistics for the set operation.""" + + delete: DeleteStatistics = field(default_factory=DeleteStatistics) + """The statistics for the delete operation.""" + + exists: ExistsStatistics = field(default_factory=ExistsStatistics) + """The statistics for the exists operation.""" + + keys: KeysStatistics = field(default_factory=KeysStatistics) + """The statistics for the keys operation.""" + + clear_collection: ClearCollectionStatistics = field(default_factory=ClearCollectionStatistics) + """The statistics for the clear collection operation.""" + + list_collections: ListCollectionsStatistics = field(default_factory=ListCollectionsStatistics) + """The statistics for the list collections operation.""" + + +@dataclass +class KVStoreStatistics: + """Statistics container for a KV Store.""" + + collections: dict[str, KVStoreCollectionStatistics] = field(default_factory=dict) + + def get_collection(self, collection: str) -> KVStoreCollectionStatistics: + if collection not in self.collections: + self.collections[collection] = KVStoreCollectionStatistics() + return self.collections[collection] + + +class StatisticsWrapper(BaseKVStore): + """Statistics wrapper around a KV Store that tracks operation statistics.""" + + def __init__(self, store: BaseKVStore, track_statistics: bool = True) -> None: + self.store: BaseKVStore = store + self._statistics: KVStoreStatistics | None = KVStoreStatistics() if track_statistics else None + + @property + def statistics(self) -> KVStoreStatistics | None: + return self._statistics + + @override + async def get(self, collection: str, key: str) -> dict[str, Any] | None: + if value := await self.store.get(collection=collection, key=key): + if self.statistics: + self.statistics.get_collection(collection).get.increment_hit() + return value + + if self.statistics: + self.statistics.get_collection(collection).get.increment_miss() + + return None + + @override + async def put(self, collection: str, key: str, value: dict[str, Any], *, ttl: float | None = None) -> None: + await self.store.put(collection=collection, key=key, value=value, ttl=ttl) + + if self.statistics: + self.statistics.get_collection(collection).set.increment() + + @override + async def delete(self, collection: str, key: str) -> bool: + if await self.store.delete(collection=collection, key=key): + if self.statistics: + self.statistics.get_collection(collection).delete.increment_hit() + return True + + if self.statistics: + self.statistics.get_collection(collection).delete.increment_miss() + + return False + + @override + async def exists(self, collection: str, key: str) -> bool: + if await self.store.exists(collection=collection, key=key): + if self.statistics: + self.statistics.get_collection(collection).exists.increment_hit() + return True + + if self.statistics: + self.statistics.get_collection(collection).exists.increment_miss() + + return False + + @override + async def keys(self, collection: str) -> list[str]: + keys: list[str] = await self.store.keys(collection) + + if self.statistics: + self.statistics.get_collection(collection).keys.increment() + + return keys + + @override + async def clear_collection(self, collection: str) -> int: + if count := await self.store.clear_collection(collection): + if self.statistics: + self.statistics.get_collection(collection).clear_collection.increment_hit() + return count + + if self.statistics: + self.statistics.get_collection(collection).clear_collection.increment_miss() + + return 0 + + @override + async def ttl(self, collection: str, key: str) -> TTLInfo | None: + return await self.store.ttl(collection=collection, key=key) + + @override + async def list_collections(self) -> list[str]: + return await self.store.list_collections() + + @override + async def cull(self) -> None: + await self.store.cull() diff --git a/src/kv_store_adapter/types.py b/src/kv_store_adapter/types.py new file mode 100644 index 00000000..7aeedbfd --- /dev/null +++ b/src/kv_store_adapter/types.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Protocol + + +@dataclass +class TTLInfo: + """TTL (Time To Live) information for a key-value pair in a collection.""" + + collection: str + key: str + created_at: datetime | None + + ttl: float | None + expires_at: datetime | None + + @property + def is_expired(self) -> bool: + """Check if the key-value pair has expired based on its TTL.""" + if self.expires_at is None: + return False + + return self.expires_at <= datetime.now(tz=timezone.utc) + + +class KVStoreProtocol(Protocol): + """Protocol defining the interface for key-value store implementations.""" + + async def get(self, collection: str, key: str) -> dict[str, Any] | None: + """Retrieve a value by key from the specified collection.""" + ... + + async def put(self, collection: str, key: str, value: dict[str, Any], *, ttl: float | None = None) -> None: + """Store a key-value pair in the specified collection with optional TTL.""" + ... + + async def delete(self, collection: str, key: str) -> bool: + """Delete a key-value pair from the specified collection.""" + ... + + async def exists(self, collection: str, key: str) -> bool: + """Check if a key exists in the specified collection.""" + ... diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/adapters/__init__.py b/tests/adapters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/adapters/test_pydantic.py b/tests/adapters/test_pydantic.py new file mode 100644 index 00000000..912c71b1 --- /dev/null +++ b/tests/adapters/test_pydantic.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone + +import pytest +from pydantic import BaseModel + +from kv_store_adapter.adapters.pydantic import PydanticAdapter +from kv_store_adapter.stores.memory.store import MemoryStore + + +class User(BaseModel): + name: str + age: int + email: str + + +class Product(BaseModel): + name: str + price: float + quantity: int + + +class Order(BaseModel): + created_at: datetime + updated_at: datetime + user: User + product: Product + paid: bool + + +FIXED_CREATED_AT: datetime = datetime(year=2021, month=1, day=1, hour=12, minute=0, second=0, tzinfo=timezone.utc) +FIXED_UPDATED_AT: datetime = datetime(year=2021, month=1, day=1, hour=15, minute=0, second=0, tzinfo=timezone.utc) + +SAMPLE_USER: User = User(name="John Doe", email="john.doe@example.com", age=30) +SAMPLE_PRODUCT: Product = Product(name="Widget", price=29.99, quantity=10) +SAMPLE_ORDER: Order = Order(created_at=datetime.now(), updated_at=datetime.now(), user=SAMPLE_USER, product=SAMPLE_PRODUCT, paid=False) + + +class TestPydanticAdapter: + @pytest.fixture + async def store(self) -> MemoryStore: + return MemoryStore() + + @pytest.fixture + async def user_adapter(self, store: MemoryStore) -> PydanticAdapter[User]: + return PydanticAdapter[User](store=store, pydantic_model=User) + + @pytest.fixture + async def product_adapter(self, store: MemoryStore) -> PydanticAdapter[Product]: + return PydanticAdapter[Product](store=store, pydantic_model=Product) + + @pytest.fixture + async def order_adapter(self, store: MemoryStore) -> PydanticAdapter[Order]: + return PydanticAdapter[Order](store=store, pydantic_model=Order) + + async def test_simple_adapter(self, user_adapter: PydanticAdapter[User]): + await user_adapter.put(collection="test", key="test", value=SAMPLE_USER) + cached_user: User | None = await user_adapter.get(collection="test", key="test") + assert cached_user == SAMPLE_USER + + assert await user_adapter.delete(collection="test", key="test") + + cached_user = await user_adapter.get(collection="test", key="test") + assert cached_user is None + + async def test_complex_adapter(self, order_adapter: PydanticAdapter[Order]): + await order_adapter.put(collection="test", key="test", value=SAMPLE_ORDER, ttl=10) + cached_order: Order | None = await order_adapter.get(collection="test", key="test") + assert cached_order == SAMPLE_ORDER + + assert await order_adapter.delete(collection="test", key="test") + cached_order = await order_adapter.get(collection="test", key="test") + assert cached_order is None diff --git a/tests/adapters/test_single_collection.py b/tests/adapters/test_single_collection.py new file mode 100644 index 00000000..c13b35ab --- /dev/null +++ b/tests/adapters/test_single_collection.py @@ -0,0 +1,39 @@ +import pytest + +from kv_store_adapter.adapters.single_collection import SingleCollectionAdapter +from kv_store_adapter.stores.memory.store import MemoryStore + + +class TestSingleCollectionAdapter: + @pytest.fixture + async def adapter(self) -> SingleCollectionAdapter: + memory_store: MemoryStore = MemoryStore() + return SingleCollectionAdapter(store=memory_store, collection="test") + + async def test_get(self, adapter: SingleCollectionAdapter): + assert await adapter.get(key="test") is None + + async def test_put_get(self, adapter: SingleCollectionAdapter): + await adapter.put(key="test", value={"test": "test"}) + assert await adapter.get(key="test") == {"test": "test"} + + async def test_delete_get(self, adapter: SingleCollectionAdapter): + _ = await adapter.delete(key="test") + assert await adapter.get(key="test") is None + + async def test_put_exists_delete_exists(self, adapter: SingleCollectionAdapter): + await adapter.put(key="test", value={"test": "test"}) + assert await adapter.exists(key="test") + assert await adapter.delete(key="test") + assert await adapter.exists(key="test") is False + + async def test_put_keys(self, adapter: SingleCollectionAdapter): + await adapter.put(key="test", value={"test": "test"}) + assert await adapter.keys() == ["test"] + + async def test_put_keys_delete_keys_get(self, adapter: SingleCollectionAdapter): + await adapter.put(key="test", value={"test": "test"}) + assert await adapter.keys() == ["test"] + assert await adapter.delete(key="test") + assert await adapter.keys() == [] + assert await adapter.get(key="test") is None diff --git a/tests/cases.py b/tests/cases.py new file mode 100644 index 00000000..54c041a0 --- /dev/null +++ b/tests/cases.py @@ -0,0 +1,40 @@ +from typing import Any + +SIMPLE_CASE: dict[str, Any] = { + "key_1": "value_1", + "key_2": 1, + "key_3": 1.0, + "key_4": [1, 2, 3], + "key_5": {"nested": "value"}, + "key_6": True, + "key_7": False, + "key_8": None, +} + +SIMPLE_CASE_JSON: str = '{"key_1": "value_1", "key_2": 1, "key_3": 1.0, "key_4": [1, 2, 3], "key_5": {"nested": "value"}, "key_6": true, "key_7": false, "key_8": null}' + +DICTIONARY_TO_JSON_TEST_CASES: list[tuple[dict[str, Any], str]] = [ + ({"key": "value"}, '{"key": "value"}'), + ({"key": 1}, '{"key": 1}'), + ({"key": 1.0}, '{"key": 1.0}'), + ({"key": [1, 2, 3]}, '{"key": [1, 2, 3]}'), + ({"key": {"nested": "value"}}, '{"key": {"nested": "value"}}'), + ({"key": True}, '{"key": true}'), + ({"key": False}, '{"key": false}'), + ({"key": None}, '{"key": null}'), +] + +DICTIONARY_TO_JSON_TEST_CASES_NAMES: list[str] = [ + "string", + "int", + "float", + "list", + "dict", + "bool-false", + "bool-true", + "null", +] + +OBJECT_TEST_CASES: list[dict[str, Any]] = [test_case[0] for test_case in DICTIONARY_TO_JSON_TEST_CASES] + +JSON_TEST_CASES: list[str] = [test_case[1] for test_case in DICTIONARY_TO_JSON_TEST_CASES] diff --git a/tests/conftest.py b/tests/conftest.py index e18018e4..e69de29b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,36 +0,0 @@ -""" -Test configuration and fixtures. -""" - -import pytest -import tempfile -import shutil -from pathlib import Path - -from kv_store_adapter.memory import MemoryKVStore -from kv_store_adapter.disk import DiskKVStore - - -@pytest.fixture -def memory_store(): - """Create a fresh in-memory KV store for testing.""" - return MemoryKVStore() - - -@pytest.fixture -def disk_store(): - """Create a fresh disk-based KV store for testing.""" - temp_dir = tempfile.mkdtemp() - store = DiskKVStore(temp_dir) - yield store - # Cleanup after test - shutil.rmtree(temp_dir, ignore_errors=True) - - -@pytest.fixture(params=["memory", "disk"]) -def kv_store(request, memory_store, disk_store): - """Parameterized fixture that tests both memory and disk implementations.""" - if request.param == "memory": - return memory_store - elif request.param == "disk": - return disk_store \ No newline at end of file diff --git a/tests/stores/__init__.py b/tests/stores/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/stores/base/__init__.py b/tests/stores/base/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/stores/base/test_kv_json_store.py b/tests/stores/base/test_kv_json_store.py new file mode 100644 index 00000000..8d0fb26d --- /dev/null +++ b/tests/stores/base/test_kv_json_store.py @@ -0,0 +1,39 @@ +from datetime import datetime, timezone +from typing import Any + +import pytest + +from kv_store_adapter.stores.utils.managed_entry import dump_to_json, load_from_json +from tests.cases import DICTIONARY_TO_JSON_TEST_CASES, DICTIONARY_TO_JSON_TEST_CASES_NAMES + +FIXED_DATETIME = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc) +FIXED_DATETIME_STRING = FIXED_DATETIME.isoformat() + + +@pytest.mark.parametrize( + argnames=("obj", "expected"), + argvalues=DICTIONARY_TO_JSON_TEST_CASES, + ids=DICTIONARY_TO_JSON_TEST_CASES_NAMES, +) +def test_dump_to_json(obj: dict[str, Any], expected: str): + assert dump_to_json(obj) == expected + + +@pytest.mark.parametrize( + argnames=("obj", "expected"), + argvalues=DICTIONARY_TO_JSON_TEST_CASES, + ids=DICTIONARY_TO_JSON_TEST_CASES_NAMES, +) +def test_load_from_json(obj: dict[str, Any], expected: str): + assert load_from_json(expected) == obj + + +@pytest.mark.parametrize( + argnames=("obj", "expected"), + argvalues=DICTIONARY_TO_JSON_TEST_CASES, + ids=DICTIONARY_TO_JSON_TEST_CASES_NAMES, +) +def test_roundtrip_json(obj: dict[str, Any], expected: str): + dumped_json: str = dump_to_json(obj) + assert dumped_json == expected + assert load_from_json(dumped_json) == obj diff --git a/tests/stores/conftest.py b/tests/stores/conftest.py new file mode 100644 index 00000000..498e210c --- /dev/null +++ b/tests/stores/conftest.py @@ -0,0 +1,188 @@ +import asyncio +import hashlib +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING + +import pytest +from dirty_equals import IsDatetime, IsList + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore + +if TYPE_CHECKING: + from kv_store_adapter.types import TTLInfo + + +def now() -> datetime: + return datetime.now(tz=timezone.utc) + + +def now_plus(seconds: int) -> datetime: + return now() + timedelta(seconds=seconds) + + +class BaseStoreTests(ABC): + async def eventually_consistent(self) -> None: # noqa: B027 + """Subclasses can override this to wait for eventually consistent operations.""" + + @pytest.fixture + @abstractmethod + async def store(self) -> BaseKVStore | AsyncGenerator[BaseKVStore, None]: ... + + async def test_empty_get(self, store: BaseKVStore): + """Tests that the get method returns None from an empty store.""" + assert await store.get(collection="test", key="test") is None + + async def test_empty_set(self, store: BaseKVStore): + """Tests that the set method does not raise an exception when called on a new store.""" + await store.put(collection="test", key="test", value={"test": "test"}) + + async def test_empty_exists(self, store: BaseKVStore): + """Tests that the exists method returns False from an empty store.""" + assert await store.exists(collection="test", key="test") is False + + async def test_empty_ttl(self, store: BaseKVStore): + """Tests that the ttl method returns None from an empty store.""" + assert await store.ttl(collection="test", key="test") is None + + async def test_empty_keys(self, store: BaseKVStore): + """Tests that the keys method returns an empty list from an empty store.""" + assert await store.keys(collection="test") == [] + + async def test_empty_clear_collection(self, store: BaseKVStore): + """Tests that the clear collection method returns 0 from an empty store.""" + assert await store.clear_collection(collection="test") == 0 + + async def test_empty_list_collections(self, store: BaseKVStore): + """Tests that the list collections method returns an empty list from an empty store.""" + assert await store.list_collections() == [] + + async def test_empty_cull(self, store: BaseKVStore): + """Tests that the cull method does not raise an exception when called on an empty store.""" + await store.cull() + + async def test_get_set_get(self, store: BaseKVStore): + assert await store.get(collection="test", key="test") is None + await store.put(collection="test", key="test", value={"test": "test"}) + assert await store.get(collection="test", key="test") == {"test": "test"} + + async def test_set_exists_delete_exists(self, store: BaseKVStore): + await store.put(collection="test", key="test", value={"test": "test"}) + assert await store.exists(collection="test", key="test") + assert await store.delete(collection="test", key="test") + assert await store.exists(collection="test", key="test") is False + + async def test_get_set_get_delete_get(self, store: BaseKVStore): + """Tests that the get, set, delete, and get methods work together to store and retrieve a value from an empty store.""" + + assert await store.ttl(collection="test", key="test") is None + + await store.put(collection="test", key="test", value={"test": "test"}) + + assert await store.get(collection="test", key="test") == {"test": "test"} + + assert await store.delete(collection="test", key="test") + + assert await store.get(collection="test", key="test") is None + + async def test_get_set_keys_delete_keys_get(self, store: BaseKVStore): + """Tests that the get, set, keys, delete, keys, clear, and get methods work together to store and retrieve a value from an empty store.""" + + await store.put(collection="test", key="test", value={"test": "test"}) + assert await store.get(collection="test", key="test") == {"test": "test"} + assert await store.keys(collection="test") == ["test"] + + assert await store.delete(collection="test", key="test") + + await self.eventually_consistent() + assert await store.keys(collection="test") == [] + + assert await store.get(collection="test", key="test") is None + + async def test_get_set_get_set_delete_get(self, store: BaseKVStore): + """Tests that the get, set, get, set, delete, and get methods work together to store and retrieve a value from an empty store.""" + await store.put(collection="test", key="test", value={"test": "test"}) + assert await store.get(collection="test", key="test") == {"test": "test"} + + await store.put(collection="test", key="test", value={"test": "test_2"}) + + assert await store.get(collection="test", key="test") == {"test": "test_2"} + assert await store.delete(collection="test", key="test") + assert await store.get(collection="test", key="test") is None + + async def test_set_ttl_get_ttl(self, store: BaseKVStore): + """Tests that the set and get ttl methods work together to store and retrieve a ttl from an empty store.""" + await store.put(collection="test", key="test", value={"test": "test"}, ttl=100) + ttl_info: TTLInfo | None = await store.ttl(collection="test", key="test") + assert ttl_info is not None + assert ttl_info.ttl == 100 + + assert ttl_info.created_at is not None + assert ttl_info.created_at == IsDatetime(approx=now()) + assert ttl_info.expires_at is not None + assert ttl_info.expires_at == IsDatetime(approx=now_plus(seconds=100)) + + assert ttl_info.collection == "test" + assert ttl_info.key == "test" + + async def test_list_collections(self, store: BaseKVStore): + """Tests that the list collections method returns an empty list from an empty store.""" + assert await store.list_collections() == [] + + async def test_cull(self, store: BaseKVStore): + """Tests that the cull method does not raise an exception when called on an empty store.""" + await store.cull() + + async def test_set_set_list_collections(self, store: BaseKVStore): + """Tests that a list collections call after adding keys to two distinct collections returns the correct collections.""" + await store.put(collection="test_one", key="test_one", value={"test": "test"}) + await self.eventually_consistent() + assert await store.list_collections() == IsList("test_one", check_order=False) + + assert await store.get(collection="test_one", key="test_one") == {"test": "test"} + await self.eventually_consistent() + assert await store.list_collections() == IsList("test_one", check_order=False) + + await store.put(collection="test_two", key="test_two", value={"test": "test"}) + await self.eventually_consistent() + assert await store.list_collections() == IsList("test_one", "test_two", check_order=False) + + assert await store.get(collection="test_two", key="test_two") == {"test": "test"} + await self.eventually_consistent() + assert await store.list_collections() == IsList("test_one", "test_two", check_order=False) + + async def test_set_expired_get_none(self, store: BaseKVStore): + """Tests that a set call with a negative ttl will return None when getting the key.""" + await store.put(collection="test_collection", key="test_key", value={"test": "test"}, ttl=-100) + assert await store.get(collection="test_collection", key="test_key") is None + + async def test_not_unbounded(self, store: BaseKVStore): + """Tests that the store is not unbounded.""" + + for i in range(5000): + value = hashlib.sha256(f"test_{i}".encode()).hexdigest() + await store.put(collection="test_collection", key=f"test_key_{i}", value={"test": value}) + + assert await store.get(collection="test_collection", key="test_key_0") is None + assert await store.get(collection="test_collection", key="test_key_4999") is not None + + async def test_concurrent_operations(self, store: BaseKVStore): + """Tests that the store can handle concurrent operations.""" + + async def worker(store: BaseKVStore, worker_id: int): + for i in range(100): + assert await store.get(collection="test_collection", key=f"test_{worker_id}_{i}") is None + + await store.put(collection="test_collection", key=f"test_{worker_id}_{i}", value={"test": f"test_{i}"}) + assert await store.get(collection="test_collection", key=f"test_{worker_id}_{i}") == {"test": f"test_{i}"} + + await store.put(collection="test_collection", key=f"test_{worker_id}_{i}", value={"test": f"test_{i}_2"}) + assert await store.get(collection="test_collection", key=f"test_{worker_id}_{i}") == {"test": f"test_{i}_2"} + + assert await store.delete(collection="test_collection", key=f"test_{worker_id}_{i}") + assert await store.get(collection="test_collection", key=f"test_{worker_id}_{i}") is None + + _ = await asyncio.gather(*[worker(store, worker_id) for worker_id in range(1)]) + + assert await store.keys(collection="test_collection") == [] diff --git a/tests/stores/disk/__init__.py b/tests/stores/disk/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/stores/disk/test_disk.py b/tests/stores/disk/test_disk.py new file mode 100644 index 00000000..1bb4e144 --- /dev/null +++ b/tests/stores/disk/test_disk.py @@ -0,0 +1,18 @@ +import tempfile +from collections.abc import AsyncGenerator + +import pytest +from typing_extensions import override + +from kv_store_adapter.stores.disk import DiskStore +from tests.stores.conftest import BaseStoreTests + +TEST_SIZE_LIMIT = 1 * 1024 * 1024 # 1MB + + +class TestMemoryStore(BaseStoreTests): + @override + @pytest.fixture + async def store(self) -> AsyncGenerator[DiskStore, None]: + with tempfile.TemporaryDirectory() as temp_dir: + yield DiskStore(path=temp_dir, size_limit=TEST_SIZE_LIMIT) diff --git a/tests/stores/elasticsearch/__init__.py b/tests/stores/elasticsearch/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/stores/elasticsearch/test_elasticsearch.py b/tests/stores/elasticsearch/test_elasticsearch.py new file mode 100644 index 00000000..5a6e53c3 --- /dev/null +++ b/tests/stores/elasticsearch/test_elasticsearch.py @@ -0,0 +1,49 @@ +import asyncio +import os +from collections.abc import AsyncGenerator + +import pytest +from elasticsearch import AsyncElasticsearch +from typing_extensions import override + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.stores.elasticsearch import ElasticsearchStore +from tests.stores.conftest import BaseStoreTests + +TEST_SIZE_LIMIT = 1 * 1024 * 1024 # 1MB + + +@pytest.fixture +async def elasticsearch_client() -> AsyncGenerator[AsyncElasticsearch, None]: + es_url = os.getenv("ES_URL") + es_api_key = os.getenv("ES_API_KEY") + + assert isinstance(es_url, str) + + assert isinstance(es_api_key, str) + + client = AsyncElasticsearch(hosts=[es_url], api_key=es_api_key) + + async with client: + yield client + + +@pytest.mark.skipif(os.getenv("ES_URL") is None, reason="Elasticsearch is not configured") +class TestElasticsearchStore(BaseStoreTests): + @override + async def eventually_consistent(self) -> None: + await asyncio.sleep(5) + + @override + @pytest.fixture + async def store(self, elasticsearch_client: AsyncElasticsearch) -> ElasticsearchStore: + _ = await elasticsearch_client.options(ignore_status=404).indices.delete(index="kv-store-e2e-test") + return ElasticsearchStore(client=elasticsearch_client, index="kv-store-e2e-test") + + @pytest.mark.skip(reason="Distributed Caches are unbounded") + @override + async def test_not_unbounded(self, store: BaseKVStore): ... + + @pytest.mark.skip(reason="Skip concurrent tests on distributed caches") + @override + async def test_concurrent_operations(self, store: BaseKVStore): ... diff --git a/tests/stores/memory/__init__.py b/tests/stores/memory/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/stores/memory/test_memory.py b/tests/stores/memory/test_memory.py new file mode 100644 index 00000000..63a7bcc5 --- /dev/null +++ b/tests/stores/memory/test_memory.py @@ -0,0 +1,12 @@ +import pytest +from typing_extensions import override + +from kv_store_adapter.stores.memory.store import MemoryStore +from tests.stores.conftest import BaseStoreTests + + +class TestMemoryStore(BaseStoreTests): + @override + @pytest.fixture + async def store(self) -> MemoryStore: + return MemoryStore() diff --git a/tests/stores/redis/__init__.py b/tests/stores/redis/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/stores/redis/test_redis.py b/tests/stores/redis/test_redis.py new file mode 100644 index 00000000..a58f31be --- /dev/null +++ b/tests/stores/redis/test_redis.py @@ -0,0 +1,83 @@ +import asyncio +from collections.abc import AsyncGenerator + +import pytest +from redis.asyncio import Redis +from typing_extensions import override + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.stores.redis import RedisStore +from tests.stores.conftest import BaseStoreTests + +# Redis test configuration +REDIS_HOST = "localhost" +REDIS_PORT = 6379 +REDIS_DB = 15 # Use a separate database for tests + + +async def ping_redis() -> bool: + client = Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, decode_responses=True) + return await client.ping() # pyright: ignore[reportUnknownMemberType, reportAny] + + +async def wait_redis() -> bool: + # with a timeout of 10 seconds + for _ in range(10): + if await ping_redis(): + return True + await asyncio.sleep(delay=1) + + return False + + +class RedisFailedToStartError(Exception): + pass + + +class TestRedisStore(BaseStoreTests): + @pytest.fixture(autouse=True, scope="session") + async def setup_redis(self) -> AsyncGenerator[None, None]: + _ = await asyncio.create_subprocess_exec("docker", "stop", "redis-test") + _ = await asyncio.create_subprocess_exec("docker", "rm", "-f", "redis-test") + + process = await asyncio.create_subprocess_exec("docker", "run", "-d", "--name", "redis-test", "-p", "6379:6379", "redis") + _ = await process.wait() + if not await wait_redis(): + msg = "Redis failed to start" + raise RedisFailedToStartError(msg) + try: + yield + finally: + _ = await asyncio.create_subprocess_exec("docker", "rm", "-f", "redis-test") + + @override + @pytest.fixture + async def store(self, setup_redis: RedisStore) -> RedisStore: + """Create a Redis store for testing.""" + # Create the store with test database + redis_store = RedisStore(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB) + _ = await redis_store._client.flushdb() # pyright: ignore[reportPrivateUsage, reportUnknownMemberType] + return redis_store + + async def test_redis_url_connection(self): + """Test Redis store creation with URL.""" + redis_url = f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" + store = RedisStore(url=redis_url) + _ = await store._client.flushdb() # pyright: ignore[reportPrivateUsage, reportUnknownMemberType] + await store.put(collection="test", key="url_test", value={"test": "value"}) + result = await store.get(collection="test", key="url_test") + assert result == {"test": "value"} + + async def test_redis_client_connection(self): + """Test Redis store creation with existing client.""" + client = Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, decode_responses=True) + store = RedisStore(client=client) + + _ = await store._client.flushdb() # pyright: ignore[reportPrivateUsage, reportUnknownMemberType] + await store.put(collection="test", key="client_test", value={"test": "value"}) + result = await store.get(collection="test", key="client_test") + assert result == {"test": "value"} + + @pytest.mark.skip(reason="Distributed Caches are unbounded") + @override + async def test_not_unbounded(self, store: BaseKVStore): ... diff --git a/tests/stores/simple/__init__.py b/tests/stores/simple/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/stores/simple/test_json_store.py b/tests/stores/simple/test_json_store.py new file mode 100644 index 00000000..dd40967d --- /dev/null +++ b/tests/stores/simple/test_json_store.py @@ -0,0 +1,12 @@ +import pytest +from typing_extensions import override + +from kv_store_adapter.stores.simple.json_store import SimpleJSONStore +from tests.stores.conftest import BaseStoreTests + + +class TestSimpleJSONStore(BaseStoreTests): + @override + @pytest.fixture + async def store(self) -> SimpleJSONStore: + return SimpleJSONStore() diff --git a/tests/stores/simple/test_store.py b/tests/stores/simple/test_store.py new file mode 100644 index 00000000..91af7305 --- /dev/null +++ b/tests/stores/simple/test_store.py @@ -0,0 +1,17 @@ +import pytest +from typing_extensions import override + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.stores.simple.store import SimpleStore +from tests.stores.conftest import BaseStoreTests + + +class TestSimpleStore(BaseStoreTests): + @override + @pytest.fixture + async def store(self) -> SimpleStore: + return SimpleStore() + + @pytest.mark.skip(reason="SimpleStore does not track TTL explicitly") + @override + async def test_set_ttl_get_ttl(self, store: BaseKVStore): ... diff --git a/tests/stores/wrappers/__init__.py b/tests/stores/wrappers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/stores/wrappers/test_clamp_ttl.py b/tests/stores/wrappers/test_clamp_ttl.py new file mode 100644 index 00000000..65c4c8bc --- /dev/null +++ b/tests/stores/wrappers/test_clamp_ttl.py @@ -0,0 +1,71 @@ +from typing import TYPE_CHECKING + +import pytest +from dirty_equals import IsDatetime +from typing_extensions import override + +from kv_store_adapter.stores.memory.store import MemoryStore +from kv_store_adapter.stores.wrappers.clamp_ttl import TTLClampWrapper +from tests.stores.conftest import BaseStoreTests, now, now_plus + +if TYPE_CHECKING: + from kv_store_adapter.types import TTLInfo + + +class TestTTLClampWrapper(BaseStoreTests): + @pytest.fixture + async def memory_store(self) -> MemoryStore: + return MemoryStore() + + @override + @pytest.fixture + async def store(self, memory_store: MemoryStore) -> TTLClampWrapper: + return TTLClampWrapper(store=memory_store, min_ttl=0, max_ttl=100) + + async def test_put_below_min_ttl(self, memory_store: MemoryStore): + ttl_clamp_store: TTLClampWrapper = TTLClampWrapper(store=memory_store, min_ttl=50, max_ttl=100) + + await ttl_clamp_store.put(collection="test", key="test", value={"test": "test"}, ttl=5) + assert await ttl_clamp_store.get(collection="test", key="test") is not None + + ttl_info: TTLInfo | None = await ttl_clamp_store.ttl(collection="test", key="test") + assert ttl_info is not None + assert ttl_info.ttl == 50 + + assert ttl_info.created_at is not None + assert ttl_info.created_at == IsDatetime(approx=now()) + + assert ttl_info.expires_at is not None + assert ttl_info.expires_at == IsDatetime(approx=now_plus(seconds=50)) + + async def test_put_above_max_ttl(self, memory_store: MemoryStore): + ttl_clamp_store: TTLClampWrapper = TTLClampWrapper(store=memory_store, min_ttl=0, max_ttl=100) + + await ttl_clamp_store.put(collection="test", key="test", value={"test": "test"}, ttl=1000) + assert await ttl_clamp_store.get(collection="test", key="test") is not None + + ttl_info: TTLInfo | None = await ttl_clamp_store.ttl(collection="test", key="test") + assert ttl_info is not None + assert ttl_info.ttl == 100 + + assert ttl_info.created_at is not None + assert ttl_info.created_at == IsDatetime(approx=now()) + + assert ttl_info.expires_at is not None + assert ttl_info.expires_at == IsDatetime(approx=now_plus(seconds=100)) + + async def test_put_missing_ttl(self, memory_store: MemoryStore): + ttl_clamp_store: TTLClampWrapper = TTLClampWrapper(store=memory_store, min_ttl=0, max_ttl=100, missing_ttl=50) + + await ttl_clamp_store.put(collection="test", key="test", value={"test": "test"}, ttl=None) + assert await ttl_clamp_store.get(collection="test", key="test") is not None + + ttl_info: TTLInfo | None = await ttl_clamp_store.ttl(collection="test", key="test") + assert ttl_info is not None + assert ttl_info.ttl == 50 + + assert ttl_info.expires_at is not None + assert ttl_info.expires_at == IsDatetime(approx=now_plus(seconds=50)) + + assert ttl_info.created_at is not None + assert ttl_info.created_at == IsDatetime(approx=now()) diff --git a/tests/stores/wrappers/test_passthrough.py b/tests/stores/wrappers/test_passthrough.py new file mode 100644 index 00000000..051fa859 --- /dev/null +++ b/tests/stores/wrappers/test_passthrough.py @@ -0,0 +1,28 @@ +import tempfile +from collections.abc import AsyncGenerator + +import pytest +from typing_extensions import override + +from kv_store_adapter.stores.disk.store import DiskStore +from kv_store_adapter.stores.memory.store import MemoryStore +from kv_store_adapter.stores.wrappers.passthrough_cache import PassthroughCacheWrapper +from tests.stores.conftest import BaseStoreTests + +DISK_STORE_SIZE_LIMIT = 1 * 1024 * 1024 # 1MB + + +class TestPrefixCollectionWrapper(BaseStoreTests): + @pytest.fixture + async def primary_store(self) -> AsyncGenerator[DiskStore, None]: + with tempfile.TemporaryDirectory() as temp_dir: + yield DiskStore(path=temp_dir, size_limit=DISK_STORE_SIZE_LIMIT) + + @pytest.fixture + async def cache_store(self) -> MemoryStore: + return MemoryStore() + + @override + @pytest.fixture + async def store(self, primary_store: DiskStore, cache_store: MemoryStore) -> PassthroughCacheWrapper: + return PassthroughCacheWrapper(primary_store=primary_store, cache_store=cache_store) diff --git a/tests/stores/wrappers/test_prefix_collection.py b/tests/stores/wrappers/test_prefix_collection.py new file mode 100644 index 00000000..15fc19f3 --- /dev/null +++ b/tests/stores/wrappers/test_prefix_collection.py @@ -0,0 +1,14 @@ +import pytest +from typing_extensions import override + +from kv_store_adapter.stores.memory.store import MemoryStore +from kv_store_adapter.stores.wrappers.prefix_collection import PrefixCollectionWrapper +from tests.stores.conftest import BaseStoreTests + + +class TestPrefixCollectionWrapper(BaseStoreTests): + @override + @pytest.fixture + async def store(self) -> PrefixCollectionWrapper: + memory_store: MemoryStore = MemoryStore() + return PrefixCollectionWrapper(store=memory_store, prefix="collection_prefix") diff --git a/tests/stores/wrappers/test_prefix_key.py b/tests/stores/wrappers/test_prefix_key.py new file mode 100644 index 00000000..3868e420 --- /dev/null +++ b/tests/stores/wrappers/test_prefix_key.py @@ -0,0 +1,14 @@ +import pytest +from typing_extensions import override + +from kv_store_adapter.stores.memory.store import MemoryStore +from kv_store_adapter.stores.wrappers.prefix_key import PrefixKeyWrapper +from tests.stores.conftest import BaseStoreTests + + +class TestPrefixKeyWrapper(BaseStoreTests): + @override + @pytest.fixture + async def store(self) -> PrefixKeyWrapper: + memory_store: MemoryStore = MemoryStore() + return PrefixKeyWrapper(store=memory_store, prefix="key_prefix") diff --git a/tests/stores/wrappers/test_single_collection.py b/tests/stores/wrappers/test_single_collection.py new file mode 100644 index 00000000..963a4f29 --- /dev/null +++ b/tests/stores/wrappers/test_single_collection.py @@ -0,0 +1,31 @@ +import pytest +from typing_extensions import override + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.stores.memory.store import MemoryStore +from kv_store_adapter.stores.wrappers.single_collection import SingleCollectionWrapper +from tests.stores.conftest import BaseStoreTests + + +class TestSingleCollectionWrapper(BaseStoreTests): + @override + @pytest.fixture + async def store(self) -> SingleCollectionWrapper: + memory_store: MemoryStore = MemoryStore() + return SingleCollectionWrapper(store=memory_store, collection="test") + + @pytest.mark.skip(reason="SingleCollectionWrapper does not support collection operations") + @override + async def test_empty_clear_collection(self, store: BaseKVStore): ... + + @pytest.mark.skip(reason="SingleCollectionWrapper does not support collection operations") + @override + async def test_empty_list_collections(self, store: BaseKVStore): ... + + @pytest.mark.skip(reason="SingleCollectionWrapper does not support collection operations") + @override + async def test_list_collections(self, store: BaseKVStore): ... + + @pytest.mark.skip(reason="SingleCollectionWrapper does not support collection operations") + @override + async def test_set_set_list_collections(self, store: BaseKVStore): ... diff --git a/tests/test_disk_store.py b/tests/test_disk_store.py deleted file mode 100644 index 374de1ca..00000000 --- a/tests/test_disk_store.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Tests specific to the disk implementation. -""" - -import pytest -import tempfile -import shutil -from pathlib import Path - -from kv_store_adapter.disk import DiskKVStore - - -class TestDiskKVStoreSpecific: - """Tests specific to disk implementation.""" - - def test_persistence_across_instances(self): - """Test that data persists across different store instances.""" - temp_dir = tempfile.mkdtemp() - - try: - # Create first store instance and add data - store1 = DiskKVStore(temp_dir) - store1.set("persistent_key", "persistent_value") - store1.set("ns_key", "ns_value", namespace="test_ns") - - # Create second store instance with same directory - store2 = DiskKVStore(temp_dir) - - # Data should be available in second instance - assert store2.get("persistent_key") == "persistent_value" - assert store2.get("ns_key", namespace="test_ns") == "ns_value" - - finally: - shutil.rmtree(temp_dir, ignore_errors=True) - - def test_file_structure(self): - """Test that the correct file structure is created.""" - temp_dir = tempfile.mkdtemp() - - try: - store = DiskKVStore(temp_dir) - store.set("test_key", "test_value") - store.set("ns_key", "ns_value", namespace="test_ns") - - base_path = Path(temp_dir) - - # Check default namespace files - default_ns = base_path / "default" - assert default_ns.exists() - assert (default_ns / "test_key.data").exists() - assert (default_ns / "test_key.meta").exists() - - # Check custom namespace files - test_ns = base_path / "test_ns" - assert test_ns.exists() - assert (test_ns / "ns_key.data").exists() - assert (test_ns / "ns_key.meta").exists() - - finally: - shutil.rmtree(temp_dir, ignore_errors=True) - - def test_corrupted_files_handling(self): - """Test handling of corrupted data files.""" - temp_dir = tempfile.mkdtemp() - - try: - store = DiskKVStore(temp_dir) - store.set("test_key", "test_value") - - # Corrupt the data file - data_file = Path(temp_dir) / "default" / "test_key.data" - with open(data_file, 'w') as f: - f.write("corrupted data") - - # Should raise KeyNotFoundError due to pickle error - with pytest.raises(Exception): # Could be KeyNotFoundError or pickle error - store.get("test_key") - - finally: - shutil.rmtree(temp_dir, ignore_errors=True) \ No newline at end of file diff --git a/tests/test_kv_stores.py b/tests/test_kv_stores.py deleted file mode 100644 index 1a916bf5..00000000 --- a/tests/test_kv_stores.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Basic tests for KV Store implementations. -""" - -import time -import pytest -from datetime import timedelta - -from kv_store_adapter.exceptions import KeyNotFoundError - - -class TestKVStoreBasics: - """Test basic KV store operations.""" - - def test_set_and_get(self, kv_store): - """Test setting and getting values.""" - kv_store.set("test_key", "test_value") - assert kv_store.get("test_key") == "test_value" - - def test_get_nonexistent_key(self, kv_store): - """Test getting a key that doesn't exist.""" - with pytest.raises(KeyNotFoundError): - kv_store.get("nonexistent_key") - - def test_delete_existing_key(self, kv_store): - """Test deleting an existing key.""" - kv_store.set("test_key", "test_value") - assert kv_store.delete("test_key") is True - - with pytest.raises(KeyNotFoundError): - kv_store.get("test_key") - - def test_delete_nonexistent_key(self, kv_store): - """Test deleting a key that doesn't exist.""" - assert kv_store.delete("nonexistent_key") is False - - def test_exists(self, kv_store): - """Test checking if keys exist.""" - assert kv_store.exists("test_key") is False - - kv_store.set("test_key", "test_value") - assert kv_store.exists("test_key") is True - - kv_store.delete("test_key") - assert kv_store.exists("test_key") is False - - def test_keys_listing(self, kv_store): - """Test listing keys.""" - # Initially empty - assert kv_store.keys() == [] - - # Add some keys - kv_store.set("key1", "value1") - kv_store.set("key2", "value2") - kv_store.set("test_key", "test_value") - - keys = kv_store.keys() - assert len(keys) == 3 - assert "key1" in keys - assert "key2" in keys - assert "test_key" in keys - - def test_keys_pattern_matching(self, kv_store): - """Test listing keys with patterns.""" - kv_store.set("user_1", "data1") - kv_store.set("user_2", "data2") - kv_store.set("admin_1", "admin_data") - - user_keys = kv_store.keys(pattern="user_*") - assert len(user_keys) == 2 - assert "user_1" in user_keys - assert "user_2" in user_keys - assert "admin_1" not in user_keys - - -class TestKVStoreNamespaces: - """Test namespace/collection functionality.""" - - def test_namespace_isolation(self, kv_store): - """Test that namespaces isolate data.""" - kv_store.set("key1", "default_value") - kv_store.set("key1", "ns1_value", namespace="namespace1") - kv_store.set("key1", "ns2_value", namespace="namespace2") - - assert kv_store.get("key1") == "default_value" - assert kv_store.get("key1", namespace="namespace1") == "ns1_value" - assert kv_store.get("key1", namespace="namespace2") == "ns2_value" - - def test_namespace_keys_listing(self, kv_store): - """Test listing keys within namespaces.""" - kv_store.set("key1", "value1") - kv_store.set("key2", "value2") - kv_store.set("key1", "ns_value1", namespace="test_ns") - kv_store.set("key3", "ns_value3", namespace="test_ns") - - default_keys = kv_store.keys() - assert len(default_keys) == 2 - assert "key1" in default_keys - assert "key2" in default_keys - - ns_keys = kv_store.keys(namespace="test_ns") - assert len(ns_keys) == 2 - assert "key1" in ns_keys - assert "key3" in ns_keys - - def test_clear_namespace(self, kv_store): - """Test clearing all keys in a namespace.""" - kv_store.set("key1", "value1") - kv_store.set("key2", "value2") - kv_store.set("key1", "ns_value1", namespace="test_ns") - kv_store.set("key3", "ns_value3", namespace="test_ns") - - # Clear the test namespace - cleared_count = kv_store.clear_namespace("test_ns") - assert cleared_count == 2 - - # Default namespace should be unchanged - assert len(kv_store.keys()) == 2 - - # Test namespace should be empty - assert len(kv_store.keys(namespace="test_ns")) == 0 - - def test_list_namespaces(self, kv_store): - """Test listing available namespaces.""" - # Initially no namespaces (or just default) - namespaces = kv_store.list_namespaces() - - # Add data to different namespaces - kv_store.set("key1", "value1") # default namespace - kv_store.set("key1", "value1", namespace="ns1") - kv_store.set("key1", "value1", namespace="ns2") - - namespaces = kv_store.list_namespaces() - assert "default" in namespaces - assert "ns1" in namespaces - assert "ns2" in namespaces - - -class TestKVStoreTTL: - """Test TTL (Time To Live) functionality.""" - - def test_ttl_with_seconds(self, kv_store): - """Test TTL with seconds as integer.""" - kv_store.set("ttl_key", "ttl_value", ttl=2) - - # Key should exist initially - assert kv_store.exists("ttl_key") is True - assert kv_store.get("ttl_key") == "ttl_value" - - # TTL should be approximately 2 seconds - ttl_remaining = kv_store.ttl("ttl_key") - assert ttl_remaining is not None - assert 1.0 <= ttl_remaining <= 2.0 - - def test_ttl_with_timedelta(self, kv_store): - """Test TTL with timedelta object.""" - kv_store.set("ttl_key", "ttl_value", ttl=timedelta(seconds=3)) - - assert kv_store.exists("ttl_key") is True - ttl_remaining = kv_store.ttl("ttl_key") - assert ttl_remaining is not None - assert 2.0 <= ttl_remaining <= 3.0 - - def test_ttl_expiration(self, kv_store): - """Test that keys expire after TTL.""" - kv_store.set("expire_key", "expire_value", ttl=0.1) # 100ms - - # Key should exist initially - assert kv_store.exists("expire_key") is True - - # Wait for expiration - time.sleep(0.2) - - # Key should be gone - assert kv_store.exists("expire_key") is False - with pytest.raises(KeyNotFoundError): - kv_store.get("expire_key") - - def test_ttl_no_expiration(self, kv_store): - """Test keys without TTL don't expire.""" - kv_store.set("persistent_key", "persistent_value") - - assert kv_store.ttl("persistent_key") is None - - # Wait a bit and verify key still exists - time.sleep(0.1) - assert kv_store.exists("persistent_key") is True - - def test_ttl_nonexistent_key(self, kv_store): - """Test TTL for non-existent key.""" - assert kv_store.ttl("nonexistent_key") is None - - -class TestKVStoreDataTypes: - """Test storing different data types.""" - - def test_string_values(self, kv_store): - """Test storing string values.""" - kv_store.set("string_key", "string_value") - assert kv_store.get("string_key") == "string_value" - - def test_integer_values(self, kv_store): - """Test storing integer values.""" - kv_store.set("int_key", 42) - assert kv_store.get("int_key") == 42 - - def test_float_values(self, kv_store): - """Test storing float values.""" - kv_store.set("float_key", 3.14159) - assert kv_store.get("float_key") == 3.14159 - - def test_list_values(self, kv_store): - """Test storing list values.""" - test_list = [1, 2, 3, "four", 5.0] - kv_store.set("list_key", test_list) - assert kv_store.get("list_key") == test_list - - def test_dict_values(self, kv_store): - """Test storing dictionary values.""" - test_dict = {"name": "test", "value": 123, "nested": {"key": "value"}} - kv_store.set("dict_key", test_dict) - assert kv_store.get("dict_key") == test_dict - - def test_none_values(self, kv_store): - """Test storing None values.""" - kv_store.set("none_key", None) - assert kv_store.get("none_key") is None \ No newline at end of file diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py deleted file mode 100644 index b18c5277..00000000 --- a/tests/test_memory_store.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -Tests specific to the memory implementation. -""" - -import pytest -import threading -import time - -from kv_store_adapter.memory import MemoryKVStore - - -class TestMemoryKVStoreSpecific: - """Tests specific to memory implementation.""" - - def test_thread_safety(self): - """Test that memory store is thread-safe.""" - store = MemoryKVStore() - results = [] - - def worker(thread_id): - """Worker function for threading test.""" - for i in range(100): - key = f"thread_{thread_id}_key_{i}" - value = f"thread_{thread_id}_value_{i}" - store.set(key, value) - retrieved = store.get(key) - results.append(retrieved == value) - - # Create multiple threads - threads = [] - for i in range(5): - thread = threading.Thread(target=worker, args=(i,)) - threads.append(thread) - thread.start() - - # Wait for all threads to complete - for thread in threads: - thread.join() - - # All operations should have succeeded - assert all(results) - assert len(results) == 500 # 5 threads * 100 operations - - def test_memory_isolation_between_instances(self): - """Test that different memory store instances are isolated.""" - store1 = MemoryKVStore() - store2 = MemoryKVStore() - - store1.set("shared_key", "store1_value") - store2.set("shared_key", "store2_value") - - assert store1.get("shared_key") == "store1_value" - assert store2.get("shared_key") == "store2_value" \ No newline at end of file diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 00000000..b60ab1c7 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,38 @@ +from datetime import datetime, timedelta, timezone + +from kv_store_adapter.stores.memory import MemoryStore +from kv_store_adapter.types import KVStoreProtocol, TTLInfo + + +def test_ttl_info(): + created_at = datetime.now(tz=timezone.utc) + expires_at = datetime.now(tz=timezone.utc) + timedelta(seconds=100) + ttl_info = TTLInfo(collection="test", key="test", created_at=created_at, ttl=100, expires_at=expires_at) + + assert ttl_info.expires_at is not None + assert ttl_info.expires_at > datetime.now(tz=timezone.utc) + assert ttl_info.expires_at < datetime.now(tz=timezone.utc) + timedelta(seconds=100) + + assert ttl_info.created_at is not None + assert ttl_info.created_at < datetime.now(tz=timezone.utc) + assert ttl_info.created_at > datetime.now(tz=timezone.utc) - timedelta(seconds=5) + + assert ttl_info.collection == "test" + assert ttl_info.key == "test" + + assert ttl_info.is_expired is False + + +async def test_kv_store_protocol(): + async def test_kv_store_protocol(kv_store: KVStoreProtocol): + assert await kv_store.get(collection="test", key="test") is None + await kv_store.put(collection="test", key="test", value={"test": "test"}) + assert await kv_store.delete(collection="test", key="test") + await kv_store.put(collection="test", key="test_2", value={"test": "test"}) + + memory_store = MemoryStore() + + await test_kv_store_protocol(kv_store=memory_store) + + assert await memory_store.get(collection="test", key="test") is None + assert await memory_store.get(collection="test", key="test_2") == {"test": "test"} diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..092b2ccb --- /dev/null +++ b/uv.lock @@ -0,0 +1,1185 @@ +version = 1 +revision = 2 +requires-python = ">=3.10" + +[[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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" } +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +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 = "basedpyright" +version = "1.31.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/c1/f50d6dfc35f87c75b109b414d48cdc55097b21d2f4b5fb7f74702ef0d26c/basedpyright-1.31.5.tar.gz", hash = "sha256:11bbc4af6107de1b1aa1eb76647f0c194095251dfff3c5f7aaf50376690142f5", size = 22511888, upload-time = "2025-09-24T12:53:13.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b3/42b736baf91f5289f8907937575a1ea1a91484a8bf0985d03ed4251c5e4f/basedpyright-1.31.5-py3-none-any.whl", hash = "sha256:a345aae09fa6c12694f401514baf87fdb66ddade36a45bf978f1920a40b08df6", size = 11738983, upload-time = "2025-09-24T12:53:09.624Z" }, +] + +[[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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dirty-equals" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/69/f8a63f97166565dbf01e6a3fdf4665313719a6781125f105e4ffde82c5cd/dirty_equals-0.10.0.tar.gz", hash = "sha256:623d7a07c5ba437f1a834c6246d1e3eb97238ca70331c61a499d9aabd757b899", size = 125778, upload-time = "2025-09-19T16:05:31.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/87/0fc6e51f9db3a3b3de88fb0c9cf6414d4572d565f4ba4d166023cbd4354d/dirty_equals-0.10.0-py3-none-any.whl", hash = "sha256:bbf4a4eaafd56e371dafe2edf2265315ebd71a441b142ed801511aa33e4c3438", size = 28014, upload-time = "2025-09-19T16:05:29.953Z" }, +] + +[[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 = "diskcache-stubs" +version = "5.6.3.6.20240818" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/d6/b741a916707520349a3853a3f436aaf5df6e06a2c1499636072b1b3ce45d/diskcache_stubs-5.6.3.6.20240818.tar.gz", hash = "sha256:b6eb43899e906b3167a20ac09a9a226f30267a306a96542ea720ebbfc3282796", size = 13524, upload-time = "2024-08-18T07:50:11.943Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/76/288d91c284ac1787f01c8260af5ea89dcfa6c0abc9acd601d01cf6f72f86/diskcache_stubs-5.6.3.6.20240818-py3-none-any.whl", hash = "sha256:e1db90940b344140730976abe79f57f5b43ca296cbb43fa95da0c69b12d5de4f", size = 18391, upload-time = "2024-08-18T07:50:10.723Z" }, +] + +[[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 = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[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 = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "inline-snapshot" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pytest" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/4d/8e3b89f00df7925942acb091809ca32395373dc579517abacec5e242e8bd/inline_snapshot-0.29.0.tar.gz", hash = "sha256:8bac016fc8ff4638a6cdebca96d7042fecde471f0574d360de11f552ba77d6b5", size = 349586, upload-time = "2025-09-15T07:03:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/eb/5ab8628a3289fab7ab28ccd59ef6d3ef4b28706c3065388df9f975ed29b6/inline_snapshot-0.29.0-py3-none-any.whl", hash = "sha256:aaea04480f1b5ec741b9025da45c00cb166d8791f01bed0f5ea7eabd1f9784cd", size = 70235, upload-time = "2025-09-15T07:03:03.616Z" }, +] + +[[package]] +name = "kv-store-adapter" +version = "0.1.0" +source = { editable = "." } + +[package.optional-dependencies] +disk = [ + { name = "diskcache" }, +] +elasticsearch = [ + { name = "aiohttp" }, + { name = "elasticsearch" }, +] +memory = [ + { name = "cachetools" }, +] +pydantic = [ + { name = "pydantic" }, +] +redis = [ + { name = "redis" }, +] + +[package.dev-dependencies] +dev = [ + { name = "basedpyright" }, + { name = "dirty-equals" }, + { name = "diskcache-stubs" }, + { name = "inline-snapshot" }, + { name = "kv-store-adapter", extra = ["disk", "elasticsearch", "memory", "pydantic", "redis"] }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-dotenv" }, + { name = "pytest-mock" }, + { name = "pytest-redis" }, + { name = "ruff" }, +] +lint = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", marker = "extra == 'elasticsearch'", specifier = ">=3.12" }, + { name = "cachetools", marker = "extra == 'memory'", specifier = ">=6.0.0" }, + { name = "diskcache", marker = "extra == 'disk'", specifier = ">=5.6.0" }, + { name = "elasticsearch", marker = "extra == 'elasticsearch'", specifier = ">=9.0.0" }, + { name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.11.9" }, + { name = "redis", marker = "extra == 'redis'", specifier = ">=6.0.0" }, +] +provides-extras = ["memory", "disk", "redis", "elasticsearch", "pydantic"] + +[package.metadata.requires-dev] +dev = [ + { name = "basedpyright", specifier = ">=1.31.5" }, + { name = "dirty-equals", specifier = ">=0.10.0" }, + { name = "diskcache-stubs", specifier = ">=5.6.3.6.20240818" }, + { name = "inline-snapshot", specifier = ">=0.29.0" }, + { name = "kv-store-adapter", extras = ["memory", "disk", "redis", "elasticsearch"] }, + { name = "kv-store-adapter", extras = ["pydantic"] }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-dotenv", specifier = ">=0.5.2" }, + { name = "pytest-mock" }, + { name = "pytest-redis", specifier = ">=3.1.3" }, + { name = "ruff" }, +] +lint = [{ name = "ruff" }] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mirakuru" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "psutil", marker = "sys_platform != 'cygwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/57/bfa1e5b904b18f669e03b7c6981bb92fb473b7da9c3b082a875e25bfaa8c/mirakuru-2.6.1.tar.gz", hash = "sha256:95d4f5a5ad406a625e9ca418f20f8e09386a35dad1ea30fd9073e0ae93f712c7", size = 26889, upload-time = "2025-07-02T07:18:41.234Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/ce/139df7074328119869a1041ce91c082d78287541cf867f9c4c85097c5d8b/mirakuru-2.6.1-py3-none-any.whl", hash = "sha256:4be0bfd270744454fa0c0466b8127b66bd55f4decaf05bbee9b071f2acbd9473", size = 26202, upload-time = "2025-07-02T07:18:39.951Z" }, +] + +[[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 = "nodejs-wheel-binaries" +version = "22.19.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/ca/6033f80b7aebc23cb31ed8b09608b6308c5273c3522aedd043e8a0644d83/nodejs_wheel_binaries-22.19.0.tar.gz", hash = "sha256:e69b97ef443d36a72602f7ed356c6a36323873230f894799f4270a853932fdb3", size = 8060, upload-time = "2025-09-12T10:33:46.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/a2/0d055fd1d8c9a7a971c4db10cf42f3bba57c964beb6cf383ca053f2cdd20/nodejs_wheel_binaries-22.19.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:43eca1526455a1fb4cb777095198f7ebe5111a4444749c87f5c2b84645aaa72a", size = 50902454, upload-time = "2025-09-12T10:33:18.3Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f5/446f7b3c5be1d2f5145ffa3c9aac3496e06cdf0f436adeb21a1f95dd79a7/nodejs_wheel_binaries-22.19.0-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:feb06709e1320790d34babdf71d841ec7f28e4c73217d733e7f5023060a86bfc", size = 51837860, upload-time = "2025-09-12T10:33:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/d0a036f04fd0f5dc3ae505430657044b8d9853c33be6b2d122bb171aaca3/nodejs_wheel_binaries-22.19.0-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db9f5777292491430457c99228d3a267decf12a09d31246f0692391e3513285e", size = 57841528, upload-time = "2025-09-12T10:33:25.433Z" }, + { url = "https://files.pythonhosted.org/packages/e2/11/4811d27819f229cc129925c170db20c12d4f01ad366a0066f06d6eb833cf/nodejs_wheel_binaries-22.19.0-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1392896f1a05a88a8a89b26e182d90fdf3020b4598a047807b91b65731e24c00", size = 58368815, upload-time = "2025-09-12T10:33:29.083Z" }, + { url = "https://files.pythonhosted.org/packages/6e/94/df41416856b980e38a7ff280cfb59f142a77955ccdbec7cc4260d8ab2e78/nodejs_wheel_binaries-22.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9164c876644f949cad665e3ada00f75023e18f381e78a1d7b60ccbbfb4086e73", size = 59690937, upload-time = "2025-09-12T10:33:32.771Z" }, + { url = "https://files.pythonhosted.org/packages/d1/39/8d0d5f84b7616bdc4eca725f5d64a1cfcac3d90cf3f30cae17d12f8e987f/nodejs_wheel_binaries-22.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6b4b75166134010bc9cfebd30dc57047796a27049fef3fc22316216d76bc0af7", size = 60751996, upload-time = "2025-09-12T10:33:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/41/93/2d66b5b60055dd1de6e37e35bef563c15e4cafa5cfe3a6990e0ab358e515/nodejs_wheel_binaries-22.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:3f271f5abfc71b052a6b074225eca8c1223a0f7216863439b86feaca814f6e5a", size = 40026140, upload-time = "2025-09-12T10:33:40.33Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/c9cf7ff7e3c71f07ca8331c939afd09b6e59fc85a2944ea9411e8b29ce50/nodejs_wheel_binaries-22.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:666a355fe0c9bde44a9221cd543599b029045643c8196b8eedb44f28dc192e06", size = 38804500, upload-time = "2025-09-12T10:33:43.302Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "port-for" +version = "0.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/84/ad5114c85217426d7a5170a74a6f9d6b724df117c2f3b75e41fc9d6c6811/port_for-0.7.4.tar.gz", hash = "sha256:fc7713e7b22f89442f335ce12536653656e8f35146739eccaeff43d28436028d", size = 25077, upload-time = "2024-10-09T12:28:38.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/a2/579dcefbb0285b31f8d65b537f8a9932ed51319e0a3694e01b5bbc271f92/port_for-0.7.4-py3-none-any.whl", hash = "sha256:08404aa072651a53dcefe8d7a598ee8a1dca320d9ac44ac464da16ccf2a02c4a", size = 21369, upload-time = "2024-10-09T12:28:37.853Z" }, +] + +[[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.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2", size = 497660, upload-time = "2025-09-17T20:14:52.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13", size = 245242, upload-time = "2025-09-17T20:14:56.126Z" }, + { url = "https://files.pythonhosted.org/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5", size = 246682, upload-time = "2025-09-17T20:14:58.25Z" }, + { url = "https://files.pythonhosted.org/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3", size = 287994, upload-time = "2025-09-17T20:14:59.901Z" }, + { url = "https://files.pythonhosted.org/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3", size = 291163, upload-time = "2025-09-17T20:15:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d", size = 293625, upload-time = "2025-09-17T20:15:04.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca", size = 244812, upload-time = "2025-09-17T20:15:07.462Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d", size = 247965, upload-time = "2025-09-17T20:15:09.673Z" }, + { url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971, upload-time = "2025-09-17T20:15:12.262Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-dotenv" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/b0/cafee9c627c1bae228eb07c9977f679b3a7cb111b488307ab9594ba9e4da/pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732", size = 3782, upload-time = "2020-06-16T12:38:03.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993, upload-time = "2020-06-16T12:38:01.139Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-redis" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mirakuru" }, + { name = "port-for" }, + { name = "pytest" }, + { name = "redis" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/d4/4d37bbe92ce7e991175115a30335dd591dfc9a086b10b5ed58133b286a17/pytest_redis-3.1.3.tar.gz", hash = "sha256:8bb76be4a749f1907c8b4f04213df40b679949cc2ffe39657e222ccb912aecd9", size = 38202, upload-time = "2024-11-27T08:42:22.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/5f/d9e617368aeee75609e43c66ff22e9d216c761f5b4290d56927d493ec618/pytest_redis-3.1.3-py3-none-any.whl", hash = "sha256:7fd6eb54ed0878590b857e1011b031c38aa3e230a53771739e845d3fc6b05d79", size = 32856, upload-time = "2024-11-27T08:42:19.837Z" }, +] + +[[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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "ruff" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/33/c8e89216845615d14d2d42ba2bee404e7206a8db782f33400754f3799f05/ruff-0.13.1.tar.gz", hash = "sha256:88074c3849087f153d4bb22e92243ad4c1b366d7055f98726bc19aa08dc12d51", size = 5397987, upload-time = "2025-09-18T19:52:44.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/41/ca37e340938f45cfb8557a97a5c347e718ef34702546b174e5300dbb1f28/ruff-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:b2abff595cc3cbfa55e509d89439b5a09a6ee3c252d92020bd2de240836cf45b", size = 12304308, upload-time = "2025-09-18T19:51:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/ba378ef4129415066c3e1c80d84e539a0d52feb250685091f874804f28af/ruff-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4ee9f4249bf7f8bb3984c41bfaf6a658162cdb1b22e3103eabc7dd1dc5579334", size = 12937258, upload-time = "2025-09-18T19:52:00.184Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b6/ec5e4559ae0ad955515c176910d6d7c93edcbc0ed1a3195a41179c58431d/ruff-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c5da4af5f6418c07d75e6f3224e08147441f5d1eac2e6ce10dcce5e616a3bae", size = 12214554, upload-time = "2025-09-18T19:52:02.753Z" }, + { url = "https://files.pythonhosted.org/packages/70/d6/cb3e3b4f03b9b0c4d4d8f06126d34b3394f6b4d764912fe80a1300696ef6/ruff-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80524f84a01355a59a93cef98d804e2137639823bcee2931f5028e71134a954e", size = 12448181, upload-time = "2025-09-18T19:52:05.279Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ea/bf60cb46d7ade706a246cd3fb99e4cfe854efa3dfbe530d049c684da24ff/ruff-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff7f5ce8d7988767dd46a148192a14d0f48d1baea733f055d9064875c7d50389", size = 12104599, upload-time = "2025-09-18T19:52:07.497Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3e/05f72f4c3d3a69e65d55a13e1dd1ade76c106d8546e7e54501d31f1dc54a/ruff-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c55d84715061f8b05469cdc9a446aa6c7294cd4bd55e86a89e572dba14374f8c", size = 13791178, upload-time = "2025-09-18T19:52:10.189Z" }, + { url = "https://files.pythonhosted.org/packages/81/e7/01b1fc403dd45d6cfe600725270ecc6a8f8a48a55bc6521ad820ed3ceaf8/ruff-0.13.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac57fed932d90fa1624c946dc67a0a3388d65a7edc7d2d8e4ca7bddaa789b3b0", size = 14814474, upload-time = "2025-09-18T19:52:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/fa/92/d9e183d4ed6185a8df2ce9faa3f22e80e95b5f88d9cc3d86a6d94331da3f/ruff-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c366a71d5b4f41f86a008694f7a0d75fe409ec298685ff72dc882f882d532e36", size = 14217531, upload-time = "2025-09-18T19:52:15.245Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4a/6ddb1b11d60888be224d721e01bdd2d81faaf1720592858ab8bac3600466/ruff-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4ea9d1b5ad3e7a83ee8ebb1229c33e5fe771e833d6d3dcfca7b77d95b060d38", size = 13265267, upload-time = "2025-09-18T19:52:17.649Z" }, + { url = "https://files.pythonhosted.org/packages/81/98/3f1d18a8d9ea33ef2ad508f0417fcb182c99b23258ec5e53d15db8289809/ruff-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f70202996055b555d3d74b626406476cc692f37b13bac8828acff058c9966a", size = 13243120, upload-time = "2025-09-18T19:52:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/8d/86/b6ce62ce9c12765fa6c65078d1938d2490b2b1d9273d0de384952b43c490/ruff-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f8cff7a105dad631085d9505b491db33848007d6b487c3c1979dd8d9b2963783", size = 13443084, upload-time = "2025-09-18T19:52:23.032Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/af7943466a41338d04503fb5a81b2fd07251bd272f546622e5b1599a7976/ruff-0.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9761e84255443316a258dd7dfbd9bfb59c756e52237ed42494917b2577697c6a", size = 12295105, upload-time = "2025-09-18T19:52:25.263Z" }, + { url = "https://files.pythonhosted.org/packages/3f/97/0249b9a24f0f3ebd12f007e81c87cec6d311de566885e9309fcbac5b24cc/ruff-0.13.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3d376a88c3102ef228b102211ef4a6d13df330cb0f5ca56fdac04ccec2a99700", size = 12072284, upload-time = "2025-09-18T19:52:27.478Z" }, + { url = "https://files.pythonhosted.org/packages/f6/85/0b64693b2c99d62ae65236ef74508ba39c3febd01466ef7f354885e5050c/ruff-0.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cbefd60082b517a82c6ec8836989775ac05f8991715d228b3c1d86ccc7df7dae", size = 12970314, upload-time = "2025-09-18T19:52:30.212Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/342e9f28179915d28b3747b7654f932ca472afbf7090fc0c4011e802f494/ruff-0.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd16b9a5a499fe73f3c2ef09a7885cb1d97058614d601809d37c422ed1525317", size = 13422360, upload-time = "2025-09-18T19:52:32.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/54/6177a0dc10bce6f43e392a2192e6018755473283d0cf43cc7e6afc182aea/ruff-0.13.1-py3-none-win32.whl", hash = "sha256:55e9efa692d7cb18580279f1fbb525146adc401f40735edf0aaeabd93099f9a0", size = 12178448, upload-time = "2025-09-18T19:52:35.545Z" }, + { url = "https://files.pythonhosted.org/packages/64/51/c6a3a33d9938007b8bdc8ca852ecc8d810a407fb513ab08e34af12dc7c24/ruff-0.13.1-py3-none-win_amd64.whl", hash = "sha256:3a3fb595287ee556de947183489f636b9f76a72f0fa9c028bdcabf5bab2cc5e5", size = 13286458, upload-time = "2025-09-18T19:52:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/04/afc078a12cf68592345b1e2d6ecdff837d286bac023d7a22c54c7a698c5b/ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a", size = 12437893, upload-time = "2025-09-18T19:52:41.283Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[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" }, +]