diff --git a/.gitignore b/.gitignore index da436cd090..65d2d993fb 100644 --- a/.gitignore +++ b/.gitignore @@ -207,6 +207,7 @@ temp*/ # AI .claude/ +.kiro/ .omc/ .omx/ WARP.md diff --git a/python/AGENTS.md b/python/AGENTS.md index e4697e18d5..a352ad2486 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -82,6 +82,7 @@ python/ ### Storage & Memory - [mem0](packages/mem0/AGENTS.md) - Mem0 memory integration - [redis](packages/redis/AGENTS.md) - Redis storage +- [valkey](packages/valkey/AGENTS.md) - Valkey storage and vector search ### Infrastructure - [copilotstudio](packages/copilotstudio/AGENTS.md) - Microsoft Copilot Studio diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 661cebe53a..058a3090d3 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -41,6 +41,7 @@ Status is grouped into these buckets: | `agent-framework-orchestrations` | `python/packages/orchestrations` | `beta` | | `agent-framework-purview` | `python/packages/purview` | `beta` | | `agent-framework-redis` | `python/packages/redis` | `beta` | +| `agent-framework-valkey` | `python/packages/valkey` | `alpha` | ## Deprecated / removed packages diff --git a/python/packages/valkey/AGENTS.md b/python/packages/valkey/AGENTS.md new file mode 100644 index 0000000000..5d3a05790d --- /dev/null +++ b/python/packages/valkey/AGENTS.md @@ -0,0 +1,23 @@ +# Valkey Package (agent-framework-valkey) + +Valkey-based storage for agent conversations and context. + +## Main Classes + +- **`ValkeyChatMessageStore`** - Persistent chat history provider using Valkey +- **`ValkeyContextProvider`** - Context provider with Valkey-backed vector search retrieval + +## Usage + +```python +from agent_framework_valkey import ValkeyContextProvider, ValkeyChatMessageStore + +context_provider = ValkeyContextProvider(host="localhost", port=6379, user_id="u1") +message_store = ValkeyChatMessageStore(host="localhost", port=6379) +``` + +## Import Path + +```python +from agent_framework_valkey import ValkeyContextProvider, ValkeyChatMessageStore +``` diff --git a/python/packages/valkey/LICENSE b/python/packages/valkey/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/valkey/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/valkey/README.md b/python/packages/valkey/README.md new file mode 100644 index 0000000000..89cbe532d9 --- /dev/null +++ b/python/packages/valkey/README.md @@ -0,0 +1,115 @@ +# Get Started with Microsoft Agent Framework Valkey + +Please install this package via pip: + +```bash +pip install agent-framework-valkey --pre +``` + +## Server Requirements + +The `ValkeyChatMessageStore` works with any Valkey (or Redis OSS) server — it only uses basic key-value operations. + +The `ValkeyContextProvider` requires the **valkey-search** module (>= 1.2) for its `FT.CREATE` / `FT.SEARCH` commands. This module ships with **valkey-bundle >= 9.1.0** and is also available in managed cloud offerings (AWS ElastiCache for Valkey, GCP Memorystore for Valkey). + +For local development and testing, use `valkey-bundle 9.1.0-rc1`: + +```bash +docker run -d --name valkey -p 6379:6379 valkey/valkey-bundle:9.1.0-rc1 +``` + +## Components + +### Valkey Context Provider + +The `ValkeyContextProvider` enables persistent context and memory capabilities for your agents, +allowing them to remember user preferences and conversation context across sessions and threads. +It uses Valkey's native vector search capabilities (`FT.CREATE` / `FT.SEARCH`) for semantic +retrieval of past conversation context. + +#### Basic Usage + +```python +from agent_framework_valkey import ValkeyContextProvider + +# Text-only search (no embeddings required) +context_provider = ValkeyContextProvider( + host="localhost", + port=6379, + user_id="user-123", +) + +# With vector search (requires an embedding function) +async def my_embed_fn(text: str) -> list[float]: + # Your embedding logic here + ... + +context_provider = ValkeyContextProvider( + host="localhost", + port=6379, + user_id="user-123", + embed_fn=my_embed_fn, + vector_field_name="embedding", + vector_dims=1536, +) +``` + +### Valkey Chat Message Store + +The `ValkeyChatMessageStore` provides persistent conversation storage using Valkey Lists, +enabling chat history to survive application restarts and support distributed applications. + +#### Key Features + +- **Persistent Storage**: Messages survive application restarts +- **Session Isolation**: Each conversation session has its own Valkey key +- **Message Limits**: Configurable automatic trimming of old messages +- **Lightweight**: Uses only basic Valkey key-value operations (no search module required) +- **valkey-glide**: Built on the official Valkey Python client + +#### Basic Usage + +```python +from agent_framework_valkey import ValkeyChatMessageStore + +store = ValkeyChatMessageStore( + host="localhost", + port=6379, + max_messages=100, +) +``` + +## Installing and Running Valkey + +### Option A: Local Valkey with Docker + +For basic chat history storage (no search needed): + +```bash +docker run --name valkey -p 6379:6379 -d valkey/valkey:8.1 +``` + +For full functionality including the `ValkeyContextProvider` (requires valkey-search): + +```bash +docker run --name valkey -p 6379:6379 -d valkey/valkey-bundle:9.1.0-rc1 +``` + +### Option B: AWS ElastiCache for Valkey + +Create a serverless or node-based [ElastiCache for Valkey](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/WhatIs.html) cluster. + +### Option C: Google Cloud Memorystore for Valkey + +Create a [Memorystore for Valkey](https://cloud.google.com/memorystore/docs/valkey) instance. + +## Why Valkey? + +Valkey is an open-source, Linux Foundation project that is protocol-compatible with Redis +for core operations. It provides: + +- **Open governance**: Community-driven development under the Linux Foundation +- **Performance**: Single-digit millisecond latency with high recall for vector search +- **Scaling**: Linear scaling with cluster mode support +- **Cloud support**: Managed services from AWS, GCP, and other providers +- **Migration path**: Drop-in replacement for Redis deployments diff --git a/python/packages/valkey/agent_framework_valkey/__init__.py b/python/packages/valkey/agent_framework_valkey/__init__.py new file mode 100644 index 0000000000..1d53d9b5d0 --- /dev/null +++ b/python/packages/valkey/agent_framework_valkey/__init__.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Valkey integration for Microsoft Agent Framework. + +This module re-exports objects from: +- agent-framework-valkey + +Supported classes: +- ValkeyContextProvider +- ValkeyChatMessageStore +""" + +import importlib.metadata + +from ._chat_message_store import ValkeyChatMessageStore +from ._context_provider import ValkeyContextProvider + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = [ + "ValkeyChatMessageStore", + "ValkeyContextProvider", + "__version__", +] diff --git a/python/packages/valkey/agent_framework_valkey/_chat_message_store.py b/python/packages/valkey/agent_framework_valkey/_chat_message_store.py new file mode 100644 index 0000000000..3d156610df --- /dev/null +++ b/python/packages/valkey/agent_framework_valkey/_chat_message_store.py @@ -0,0 +1,217 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Valkey-backed chat message store using HistoryProvider. + +This module provides ``ValkeyChatMessageStore``, a persistent conversation +history provider built on the :class:`HistoryProvider` hooks pattern, using +valkey-glide (the official Valkey Python client) for basic key-value operations. +""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Sequence +from typing import Any, ClassVar + +from agent_framework import Message +from agent_framework._sessions import HistoryProvider +from glide import GlideClient, GlideClientConfiguration, NodeAddress + +if sys.version_info >= (3, 11): + from typing import Self # pragma: no cover +else: + from typing_extensions import Self # pragma: no cover + + +class ValkeyChatMessageStore(HistoryProvider): + """Valkey-backed history provider using the HistoryProvider hooks pattern. + + Stores conversation history in Valkey Lists, with each session isolated + by a unique key. Uses valkey-glide for all operations. + """ + + DEFAULT_SOURCE_ID: ClassVar[str] = "valkey_memory" + + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + valkey_url: str | None = None, + host: str = "localhost", + port: int = 6379, + *, + use_tls: bool = False, + key_prefix: str = "chat_messages", + max_messages: int | None = None, + load_messages: bool = True, + store_outputs: bool = True, + store_inputs: bool = True, + store_context_messages: bool = False, + store_context_from: set[str] | None = None, + client: GlideClient | None = None, + ) -> None: + """Initialize the Valkey chat message store. + + Args: + source_id: Unique identifier for this provider instance. + valkey_url: Valkey connection URL (e.g., "valkey://localhost:6379"). + If provided, host and port are extracted from the URL. + host: Valkey server hostname. Defaults to "localhost". + port: Valkey server port. Defaults to 6379. + use_tls: Enable TLS for the connection. Defaults to False. + key_prefix: Prefix for Valkey keys. Defaults to "chat_messages". + max_messages: Maximum number of messages to retain per session. + When exceeded, oldest messages are automatically trimmed. + None means unlimited storage. + load_messages: Whether to load messages before invocation. + store_outputs: Whether to store response messages. + store_inputs: Whether to store input messages. + store_context_messages: Whether to store context from other providers. + store_context_from: If set, only store context from these source_ids. + client: A pre-created GlideClient instance. If provided, host/port/url + are ignored and the caller is responsible for the client lifecycle. + """ + super().__init__( + source_id, + load_messages=load_messages, + store_outputs=store_outputs, + store_inputs=store_inputs, + store_context_messages=store_context_messages, + store_context_from=store_context_from, + ) + + self.key_prefix = key_prefix + self.max_messages = max_messages + self.valkey_url = valkey_url + self.host = host + self.port = port + self.use_tls = use_tls + self._client: GlideClient | None = client + self._owns_client = client is None + + # Validate mutually exclusive connection params + if client is None and valkey_url is not None and (host != "localhost" or port != 6379): + raise ValueError("valkey_url and explicit host/port are mutually exclusive.") + + async def _get_client(self) -> GlideClient: + """Get or create the Valkey client.""" + if self._client is None: + if self.valkey_url is not None: + host, port = self._parse_url(self.valkey_url) + else: + host, port = self.host, self.port + config = GlideClientConfiguration( + addresses=[NodeAddress(host=host, port=port)], + use_tls=self.use_tls, + ) + self._client = await GlideClient.create(config) + return self._client + + @staticmethod + def _parse_url(url: str) -> tuple[str, int]: + """Parse a Valkey URL into host and port components. + + Args: + url: A URL like "valkey://host:port" or "redis://host:port". + + Returns: + A tuple of (host, port). + """ + from urllib.parse import urlparse + + parsed = urlparse(url) + host = parsed.hostname or "localhost" + port = parsed.port or 6379 + return host, port + + def _valkey_key(self, session_id: str | None) -> str: + """Get the Valkey key for a given session's messages.""" + return f"{self.key_prefix}:{session_id or 'default'}" + + async def get_messages( + self, + session_id: str | None, + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Message]: + """Retrieve stored messages for this session from Valkey. + + Args: + session_id: The session ID to retrieve messages for. + state: Optional session state. Unused for Valkey-backed history. + **kwargs: Additional arguments (unused). + + Returns: + List of stored Message objects in chronological order. + """ + client = await self._get_client() + key = self._valkey_key(session_id) + raw_messages = await client.lrange(key, 0, -1) + messages: list[Message] = [] + if raw_messages: + for raw in raw_messages: + decoded = raw.decode("utf-8") if isinstance(raw, bytes) else str(raw) + messages.append(Message.from_dict(json.loads(decoded))) + return messages + + async def save_messages( + self, + session_id: str | None, + messages: Sequence[Message], + *, + state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Persist messages for this session to Valkey. + + Args: + session_id: The session ID to store messages for. + messages: The messages to persist. + state: Optional session state. Unused for Valkey-backed history. + **kwargs: Additional arguments (unused). + """ + if not messages: + return + + client = await self._get_client() + key = self._valkey_key(session_id) + serialized_messages = [json.dumps(msg.to_dict()) for msg in messages] + + await client.rpush(key, serialized_messages) # pyright: ignore[reportArgumentType] + + if self.max_messages is not None: + current_count = await client.llen(key) + if current_count > self.max_messages: + await client.ltrim(key, -self.max_messages, -1) + + async def clear(self, session_id: str | None) -> None: + """Clear all messages for a session. + + Args: + session_id: The session ID to clear messages for. + """ + client = await self._get_client() + await client.delete([self._valkey_key(session_id)]) + + async def aclose(self) -> None: + """Close the Valkey connection if owned by this instance.""" + if self._owns_client and self._client is not None: + await self._client.close() + self._client = None + + async def __aenter__(self) -> Self: + """Async context manager entry.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> None: + """Async context manager exit.""" + await self.aclose() + + +__all__ = ["ValkeyChatMessageStore"] diff --git a/python/packages/valkey/agent_framework_valkey/_context_provider.py b/python/packages/valkey/agent_framework_valkey/_context_provider.py new file mode 100644 index 0000000000..e2bdda046f --- /dev/null +++ b/python/packages/valkey/agent_framework_valkey/_context_provider.py @@ -0,0 +1,511 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Valkey context provider using ContextProvider. + +This module provides ``ValkeyContextProvider``, built on the +:class:`ContextProvider` hooks pattern. It uses valkey-glide with Valkey's +native vector search capabilities (FT.CREATE / FT.SEARCH) for semantic +retrieval of past conversation context. +""" + +from __future__ import annotations + +import sys +import uuid +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast + +from agent_framework import Message +from agent_framework._sessions import AgentSession, ContextProvider, SessionContext +from agent_framework.exceptions import IntegrationInvalidRequestException +from glide import GlideClient, GlideClientConfiguration, NodeAddress + +if sys.version_info >= (3, 11): + from typing import Self # pragma: no cover +else: + from typing_extensions import Self # pragma: no cover + +if sys.version_info >= (3, 12): + from typing import override # type: ignore # pragma: no cover +else: + from typing_extensions import override # type: ignore[import] # pragma: no cover + +if TYPE_CHECKING: + from agent_framework._agents import SupportsAgentRun + +EmbedFn = Callable[[str], Awaitable[list[float]]] + + +class ValkeyContextProvider(ContextProvider): + """Valkey context provider using the ContextProvider hooks pattern. + + Stores context in Valkey using HASH keys and retrieves scoped context via + full-text search or optional hybrid vector search using Valkey's native + FT.CREATE / FT.SEARCH commands through valkey-glide. + """ + + DEFAULT_CONTEXT_PROMPT = "## Memories\nConsider the following memories when answering user questions:" + DEFAULT_SOURCE_ID: ClassVar[str] = "valkey" + + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + valkey_url: str | None = None, + host: str = "localhost", + port: int = 6379, + *, + use_tls: bool = False, + index_name: str = "context_idx", + prefix: str = "context:", + vector_dims: int | None = None, + vector_field_name: str | None = None, + vector_algorithm: Literal["FLAT", "HNSW"] = "HNSW", + vector_distance_metric: Literal["COSINE", "IP", "L2"] = "COSINE", + embed_fn: EmbedFn | None = None, + application_id: str | None = None, + agent_id: str | None = None, + user_id: str | None = None, + context_prompt: str | None = None, + client: GlideClient | None = None, + ): + """Create a Valkey Context Provider. + + Args: + source_id: Unique identifier for this provider instance. + valkey_url: Valkey connection URL (e.g., "valkey://localhost:6379"). + If provided, host and port are extracted from the URL. + Mutually exclusive with explicit host/port. + host: Valkey server hostname. Defaults to "localhost". + port: Valkey server port. Defaults to 6379. + use_tls: Enable TLS for the connection. Defaults to False. + index_name: The name of the search index. Defaults to "context_idx". + prefix: The key prefix for stored documents. Defaults to "context:". + vector_dims: Dimensionality of embedding vectors. Required if embed_fn is set. + vector_field_name: The name of the vector field. Required for vector search. + vector_algorithm: Vector index algorithm ("FLAT" or "HNSW"). Defaults to "HNSW". + vector_distance_metric: Distance metric ("COSINE", "IP", or "L2"). Defaults to "COSINE". + embed_fn: An async callable that takes a string and returns a list of floats. + Required for vector search. + application_id: The application ID to scope the context. + agent_id: The agent ID to scope the context. + user_id: The user ID to scope the context. + context_prompt: The context prompt to use for the provider. + client: A pre-created GlideClient instance. If provided, host/port/url + are ignored and the caller is responsible for the client lifecycle. + """ + super().__init__(source_id) + self.valkey_url = valkey_url + self.host = host + self.port = port + self.use_tls = use_tls + self.index_name = index_name + self.prefix = prefix + self.vector_dims = vector_dims + self.vector_field_name = vector_field_name + self.vector_algorithm = vector_algorithm + self.vector_distance_metric = vector_distance_metric + self.embed_fn = embed_fn + self.application_id = application_id + self.agent_id = agent_id + self.user_id = user_id + self.context_prompt = context_prompt or self.DEFAULT_CONTEXT_PROMPT + self._client: GlideClient | None = client + self._owns_client = client is None + self._index_created: bool = False + + # Validate mutually exclusive connection params + if client is None and valkey_url is not None and (host != "localhost" or port != 6379): + raise ValueError("valkey_url and explicit host/port are mutually exclusive.") + + async def _get_client(self) -> GlideClient: + """Get or create the Valkey client.""" + if self._client is None: + if self.valkey_url is not None: + parsed_host, parsed_port = self._parse_url(self.valkey_url) + else: + parsed_host, parsed_port = self.host, self.port + config = GlideClientConfiguration( + addresses=[NodeAddress(host=parsed_host, port=parsed_port)], + use_tls=self.use_tls, + ) + self._client = await GlideClient.create(config) + return self._client + + @staticmethod + def _parse_url(url: str) -> tuple[str, int]: + """Parse a Valkey URL into host and port components. + + Args: + url: A URL like "valkey://host:port" or "redis://host:port". + + Returns: + A tuple of (host, port). + """ + from urllib.parse import urlparse + + parsed = urlparse(url) + host = parsed.hostname or "localhost" + port = parsed.port or 6379 + return host, port + + # -- Hooks pattern --------------------------------------------------------- + + @override + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Retrieve scoped context from Valkey and add to the session context.""" + self._validate_filters() + input_text = "\n".join(msg.text for msg in context.input_messages if msg and msg.text and msg.text.strip()) + if not input_text.strip(): + return + + memories = await self._search(text=input_text) + line_separated_memories = "\n".join( + str(memory.get("content", "")) for memory in memories if memory.get("content") + ) + if line_separated_memories: + context.extend_messages( + self.source_id, + [Message(role="user", contents=[f"{self.context_prompt}\n{line_separated_memories}"])], + ) + + @override + async def after_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Store request/response messages to Valkey for future retrieval.""" + self._validate_filters() + + messages_to_store: list[Message] = list(context.input_messages) + if context.response and context.response.messages: + messages_to_store.extend(context.response.messages) + + docs: list[dict[str, Any]] = [] + for message in messages_to_store: + if message.role in {"user", "assistant", "system"} and message.text and message.text.strip(): + doc: dict[str, Any] = { + "role": message.role, + "content": message.text, + "conversation_id": context.session_id or "", + "message_id": message.message_id or "", + "author_name": message.author_name or "", + "application_id": self.application_id or "", + "agent_id": self.agent_id or "", + "user_id": self.user_id or "", + "thread_id": context.session_id or "", + } + docs.append(doc) + + if docs: + await self._add(data=docs) + + # -- Internal methods ------------------------------------------------------ + + async def _ensure_index(self) -> None: + """Create the search index if it does not already exist.""" + if self._index_created: + return + + client = await self._get_client() + + # Build FT.CREATE arguments + args: list[str] = [ + self.index_name, + "ON", + "HASH", + "PREFIX", + "1", + self.prefix, + "SCHEMA", + "role", + "TAG", + "content", + "TEXT", + "conversation_id", + "TAG", + "message_id", + "TAG", + "author_name", + "TAG", + "application_id", + "TAG", + "agent_id", + "TAG", + "user_id", + "TAG", + "thread_id", + "TAG", + ] + + if self.vector_field_name and self.vector_dims: + args.extend([ + self.vector_field_name, + "VECTOR", + self.vector_algorithm, + "6", + "TYPE", + "FLOAT32", + "DIM", + str(self.vector_dims), + "DISTANCE_METRIC", + self.vector_distance_metric, + ]) + + try: + await client.custom_command(["FT.CREATE", *args]) # pyright: ignore[reportUnknownMemberType] + except Exception as exc: + # Index already exists is not an error + if "Index already exists" in str(exc): + pass + else: + raise IntegrationInvalidRequestException(f"Failed to create Valkey search index: {exc}") from exc + + self._index_created = True + + async def _add(self, *, data: list[dict[str, Any]]) -> None: + """Insert documents into Valkey as HASH keys. + + Partition fields (application_id, agent_id, user_id) are defaulted + from the provider's configuration if not already present in each document. + """ + await self._ensure_index() + client = await self._get_client() + + for doc in data: + doc_id = f"{self.prefix}{uuid.uuid4().hex}" + + # Default partition fields if not already set (defensive, like Redis provider) + doc.setdefault("application_id", self.application_id or "") + doc.setdefault("agent_id", self.agent_id or "") + doc.setdefault("user_id", self.user_id or "") + + field_map: dict[str | bytes, str | bytes] = {} + for key, value in doc.items(): + if key == self.vector_field_name: + continue + field_map[key] = str(value) + + if self.embed_fn is not None and self.vector_field_name and "content" in doc: + import numpy as np + + embedding: list[float] = await self.embed_fn(doc["content"]) + vec_bytes: bytes = np.asarray(embedding, dtype=np.float32).tobytes() + field_map[self.vector_field_name] = vec_bytes + + await client.hset(doc_id, field_map) # pyright: ignore[reportArgumentType] + + async def _search( + self, + text: str, + *, + num_results: int = 10, + ) -> list[dict[str, Any]]: + """Run a text or hybrid vector-text search with scope filters. + + Args: + text: The search query text. + num_results: Maximum number of results to return. + + Returns: + A list of document dicts with at least a "content" field. + """ + await self._ensure_index() + client = await self._get_client() + + q = (text or "").strip() + if not q: + raise IntegrationInvalidRequestException("search requires non-empty text") + + # Build filter expression from scope fields + filter_parts: list[str] = [] + if self.application_id: + filter_parts.append(f"@application_id:{{{self._escape_tag(self.application_id)}}}") + if self.agent_id: + filter_parts.append(f"@agent_id:{{{self._escape_tag(self.agent_id)}}}") + if self.user_id: + filter_parts.append(f"@user_id:{{{self._escape_tag(self.user_id)}}}") + + filter_expr = " ".join(filter_parts) if filter_parts else "*" + + try: + result: Any + if self.embed_fn is not None and self.vector_field_name: + # Hybrid: vector KNN with pre-filter + import numpy as np + + embedding: list[float] = await self.embed_fn(q) + vec_bytes: bytes = np.asarray(embedding, dtype=np.float32).tobytes() + + query_str = f"({filter_expr})=>[KNN {num_results} @{self.vector_field_name} $vec AS score]" + result = await client.custom_command([ # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + "FT.SEARCH", + self.index_name, + query_str, + "PARAMS", + "2", + "vec", + vec_bytes, + "SORTBY", + "score", + "LIMIT", + "0", + str(num_results), + "DIALECT", + "2", + ]) + else: + # Text-only search + escaped_text = self._escape_query(q) + query_str = f"{filter_expr} {escaped_text}" if filter_parts else escaped_text + result = await client.custom_command([ # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + "FT.SEARCH", + self.index_name, + query_str, + "LIMIT", + "0", + str(num_results), + ]) + + return self._parse_search_results(result) + except IntegrationInvalidRequestException: + raise + except Exception as exc: + raise IntegrationInvalidRequestException(f"Valkey search failed: {exc}") from exc + + async def search_all(self, page_size: int = 200) -> list[dict[str, Any]]: + """Return all documents in the index. + + Note: This method is unscoped — it returns documents across all + application_id/agent_id/user_id partitions. Use for debugging, + testing, and administrative tasks only. + + Args: + page_size: Number of documents per page. Defaults to 200. + + Returns: + A list of all document dicts in the index. + """ + await self._ensure_index() + client = await self._get_client() + + all_docs: list[dict[str, Any]] = [] + offset = 0 + while True: + result: Any = await client.custom_command([ # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + "FT.SEARCH", + self.index_name, + "*", + "LIMIT", + str(offset), + str(page_size), + ]) + page = self._parse_search_results(result) + if not page: + break + all_docs.extend(page) + if len(page) < page_size: + break + offset += page_size + return all_docs + + @staticmethod + def _parse_search_results(result: Any) -> list[dict[str, Any]]: + """Parse FT.SEARCH response into a list of document dicts.""" + docs: list[dict[str, Any]] = [] + if not result or not isinstance(result, list): + return docs + + result_list = cast(list[Any], result) + if len(result_list) < 2: + return docs + + # Valkey 9.1+ returns dict format: [total_count, {doc_id: {field: value, ...}, ...}] + if isinstance(result_list[1], dict): + for _doc_id, fields in result_list[1].items(): # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] + if isinstance(fields, dict): + doc: dict[str, Any] = {} + for k, v in fields.items(): # pyright: ignore[reportUnknownVariableType] + key: str = k.decode("utf-8") if isinstance(k, bytes) else str(k) # pyright: ignore[reportUnknownArgumentType] + value: str = v.decode("utf-8") if isinstance(v, bytes) else str(v) # pyright: ignore[reportUnknownArgumentType] + doc[key] = value + docs.append(doc) + return docs + + # Legacy flat list format: [total_count, doc_id, [field, value, ...], ...] + i = 1 + while i < len(result_list): + if i + 1 < len(result_list) and isinstance(result_list[i + 1], list): + fields = cast(list[Any], result_list[i + 1]) + doc = {} + for j in range(0, len(fields), 2): + key = fields[j].decode("utf-8") if isinstance(fields[j], bytes) else str(fields[j]) + value = fields[j + 1].decode("utf-8") if isinstance(fields[j + 1], bytes) else str(fields[j + 1]) + doc[key] = value + docs.append(doc) + i += 2 + else: + i += 1 + + return docs + + @staticmethod + def _escape_tag(value: str) -> str: + """Escape special characters in a TAG filter value.""" + special = r".,<>{}[]\"':;!@#$%^&*()-+=~/ " + escaped: list[str] = [] + for ch in value: + if ch in special: + escaped.append(f"\\{ch}") + else: + escaped.append(ch) + return "".join(escaped) + + @staticmethod + def _escape_query(text: str) -> str: + """Escape special characters in a full-text query.""" + special = r"@!{}()|\-=~[]^\"':*$>+/" + escaped: list[str] = [] + for ch in text: + if ch in special: + escaped.append(f"\\{ch}") + else: + escaped.append(ch) + return "".join(escaped) + + def _validate_filters(self) -> None: + """Validate that at least one scope filter is provided.""" + if not self.agent_id and not self.user_id and not self.application_id: + raise ValueError("At least one of the filters: agent_id, user_id, or application_id is required.") + + async def aclose(self) -> None: + """Close the Valkey connection if owned by this instance.""" + if self._owns_client and self._client is not None: + await self._client.close() + self._client = None + + async def __aenter__(self) -> Self: + """Async context manager entry.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> None: + """Async context manager exit.""" + await self.aclose() + + +__all__ = ["ValkeyContextProvider"] diff --git a/python/packages/valkey/pyproject.toml b/python/packages/valkey/pyproject.toml new file mode 100644 index 0000000000..50794b0f42 --- /dev/null +++ b/python/packages/valkey/pyproject.toml @@ -0,0 +1,103 @@ +[project] +name = "agent-framework-valkey" +description = "Valkey integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0b260421" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "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", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.1.0,<2", + "valkey-glide>=2.3.1,<3", +] + +[project.optional-dependencies] +vector = [ + "numpy>=2.2.6,<3", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [ + "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" +] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_valkey"] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_valkey"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_valkey" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_valkey --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/valkey/tests/test_providers.py b/python/packages/valkey/tests/test_providers.py new file mode 100644 index 0000000000..a61f5e1ffd --- /dev/null +++ b/python/packages/valkey/tests/test_providers.py @@ -0,0 +1,571 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for ValkeyContextProvider and ValkeyChatMessageStore.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock + +import numpy as np +import pytest +from agent_framework import AgentResponse, Message +from agent_framework._sessions import AgentSession, SessionContext +from agent_framework.exceptions import IntegrationInvalidRequestException + +from agent_framework_valkey._chat_message_store import ValkeyChatMessageStore +from agent_framework_valkey._context_provider import ValkeyContextProvider + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_glide_client() -> AsyncMock: + """Create a mock GlideClient for testing.""" + client = AsyncMock() + client.lrange = AsyncMock(return_value=[]) + client.llen = AsyncMock(return_value=0) + client.ltrim = AsyncMock() + client.rpush = AsyncMock() + client.delete = AsyncMock() + client.hset = AsyncMock() + client.custom_command = AsyncMock(return_value=[0]) + client.close = AsyncMock() + return client + + +# =========================================================================== +# ValkeyChatMessageStore tests +# =========================================================================== + + +class TestValkeyChatMessageStoreInit: + def test_basic_construction(self, mock_glide_client: AsyncMock) -> None: + store = ValkeyChatMessageStore(source_id="mem", client=mock_glide_client) + assert store.source_id == "mem" + assert store.key_prefix == "chat_messages" + assert store.max_messages is None + assert store.load_messages is True + assert store.store_outputs is True + assert store.store_inputs is True + + def test_custom_params(self, mock_glide_client: AsyncMock) -> None: + store = ValkeyChatMessageStore( + source_id="mem", + client=mock_glide_client, + key_prefix="custom", + max_messages=50, + load_messages=False, + store_outputs=False, + store_inputs=False, + ) + assert store.key_prefix == "custom" + assert store.max_messages == 50 + assert store.load_messages is False + assert store.store_outputs is False + assert store.store_inputs is False + + def test_default_source_id(self, mock_glide_client: AsyncMock) -> None: + store = ValkeyChatMessageStore(client=mock_glide_client) + assert store.source_id == "valkey_memory" + + def test_mutually_exclusive_url_and_host_raises(self) -> None: + with pytest.raises(ValueError, match="mutually exclusive"): + ValkeyChatMessageStore(valkey_url="valkey://other:6380", host="myhost", port=6380) + + +class TestValkeyChatMessageStoreKey: + def test_key_format(self, mock_glide_client: AsyncMock) -> None: + store = ValkeyChatMessageStore(client=mock_glide_client, key_prefix="msgs") + assert store._valkey_key("session-123") == "msgs:session-123" + assert store._valkey_key(None) == "msgs:default" + + +class TestValkeyChatMessageStoreParseUrl: + def test_parse_valkey_url(self) -> None: + host, port = ValkeyChatMessageStore._parse_url("valkey://myhost:6380") + assert host == "myhost" + assert port == 6380 + + def test_parse_redis_url(self) -> None: + host, port = ValkeyChatMessageStore._parse_url("redis://localhost:6379") + assert host == "localhost" + assert port == 6379 + + def test_parse_url_defaults(self) -> None: + host, port = ValkeyChatMessageStore._parse_url("valkey://") + assert host == "localhost" + assert port == 6379 + + +class TestValkeyChatMessageStoreGetMessages: + async def test_returns_deserialized_messages(self, mock_glide_client: AsyncMock) -> None: + msg1 = Message(role="user", contents=["Hello"]) + msg2 = Message(role="assistant", contents=["Hi!"]) + mock_glide_client.lrange = AsyncMock(return_value=[json.dumps(msg1.to_dict()), json.dumps(msg2.to_dict())]) + store = ValkeyChatMessageStore(client=mock_glide_client) + + messages = await store.get_messages("s1") + assert len(messages) == 2 + assert messages[0].role == "user" + assert messages[0].text == "Hello" + assert messages[1].role == "assistant" + assert messages[1].text == "Hi!" + + async def test_handles_bytes_response(self, mock_glide_client: AsyncMock) -> None: + msg = Message(role="user", contents=["Hello"]) + mock_glide_client.lrange = AsyncMock(return_value=[json.dumps(msg.to_dict()).encode("utf-8")]) + store = ValkeyChatMessageStore(client=mock_glide_client) + + messages = await store.get_messages("s1") + assert len(messages) == 1 + assert messages[0].text == "Hello" + + async def test_empty_returns_empty(self, mock_glide_client: AsyncMock) -> None: + mock_glide_client.lrange = AsyncMock(return_value=[]) + store = ValkeyChatMessageStore(client=mock_glide_client) + + messages = await store.get_messages("s1") + assert messages == [] + + +class TestValkeyChatMessageStoreSaveMessages: + async def test_saves_batched_messages(self, mock_glide_client: AsyncMock) -> None: + store = ValkeyChatMessageStore(client=mock_glide_client) + msgs = [Message(role="user", contents=["Hello"]), Message(role="assistant", contents=["Hi"])] + + await store.save_messages("s1", msgs) + + # Single batched rpush call + mock_glide_client.rpush.assert_called_once() + call_args = mock_glide_client.rpush.call_args[0] + assert call_args[0] == "chat_messages:s1" + assert len(call_args[1]) == 2 + + async def test_empty_messages_noop(self, mock_glide_client: AsyncMock) -> None: + store = ValkeyChatMessageStore(client=mock_glide_client) + + await store.save_messages("s1", []) + mock_glide_client.rpush.assert_not_called() + + async def test_max_messages_trimming(self, mock_glide_client: AsyncMock) -> None: + mock_glide_client.llen = AsyncMock(return_value=15) + store = ValkeyChatMessageStore(client=mock_glide_client, max_messages=10) + + await store.save_messages("s1", [Message(role="user", contents=["msg"])]) + + mock_glide_client.ltrim.assert_called_once_with("chat_messages:s1", -10, -1) + + async def test_no_trim_when_under_limit(self, mock_glide_client: AsyncMock) -> None: + mock_glide_client.llen = AsyncMock(return_value=3) + store = ValkeyChatMessageStore(client=mock_glide_client, max_messages=10) + + await store.save_messages("s1", [Message(role="user", contents=["msg"])]) + + mock_glide_client.ltrim.assert_not_called() + + +class TestValkeyChatMessageStoreClear: + async def test_clear_calls_delete(self, mock_glide_client: AsyncMock) -> None: + store = ValkeyChatMessageStore(client=mock_glide_client) + + await store.clear("session-1") + mock_glide_client.delete.assert_called_once_with(["chat_messages:session-1"]) + + async def test_clear_none_session_uses_default(self, mock_glide_client: AsyncMock) -> None: + store = ValkeyChatMessageStore(client=mock_glide_client) + + await store.clear(None) + mock_glide_client.delete.assert_called_once_with(["chat_messages:default"]) + + +class TestValkeyChatMessageStoreContextManager: + async def test_aenter_returns_self(self, mock_glide_client: AsyncMock) -> None: + store = ValkeyChatMessageStore(client=mock_glide_client) + async with store as s: + assert s is store + + +class TestValkeyChatMessageStoreBeforeAfterRun: + """Test before_run/after_run integration via HistoryProvider defaults.""" + + async def test_before_run_loads_history(self, mock_glide_client: AsyncMock) -> None: + msg = Message(role="user", contents=["old msg"]) + mock_glide_client.lrange = AsyncMock(return_value=[json.dumps(msg.to_dict())]) + store = ValkeyChatMessageStore(client=mock_glide_client) + + session = AgentSession(session_id="test") + ctx = SessionContext(input_messages=[Message(role="user", contents=["new msg"])], session_id="s1") + + await store.before_run( + agent=None, session=session, context=ctx, state=session.state.setdefault(store.source_id, {}) + ) # type: ignore[arg-type] + + assert store.source_id in ctx.context_messages + assert len(ctx.context_messages[store.source_id]) == 1 + assert ctx.context_messages[store.source_id][0].text == "old msg" + + async def test_after_run_stores_input_and_response(self, mock_glide_client: AsyncMock) -> None: + store = ValkeyChatMessageStore(client=mock_glide_client) + + session = AgentSession(session_id="test") + ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hello"])]) + + await store.after_run( + agent=None, session=session, context=ctx, state=session.state.setdefault(store.source_id, {}) + ) # type: ignore[arg-type] + + # Batched into single rpush call + mock_glide_client.rpush.assert_called_once() + + async def test_after_run_skips_when_no_messages(self, mock_glide_client: AsyncMock) -> None: + store = ValkeyChatMessageStore(client=mock_glide_client, store_inputs=False, store_outputs=False) + + session = AgentSession(session_id="test") + ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1") + + await store.after_run( + agent=None, session=session, context=ctx, state=session.state.setdefault(store.source_id, {}) + ) # type: ignore[arg-type] + + mock_glide_client.rpush.assert_not_called() + + +# =========================================================================== +# ValkeyContextProvider tests +# =========================================================================== + + +class TestValkeyContextProviderInit: + def test_basic_construction(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + assert provider.source_id == "ctx" + assert provider.user_id == "u1" + assert provider.host == "localhost" + assert provider.port == 6379 + assert provider.index_name == "context_idx" + assert provider.prefix == "context:" + + def test_custom_params(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider( + source_id="ctx", + host="custom-host", + port=6380, + index_name="my_idx", + prefix="my_prefix:", + application_id="app1", + agent_id="agent1", + user_id="user1", + context_prompt="Custom prompt", + client=mock_glide_client, + ) + assert provider.host == "custom-host" + assert provider.port == 6380 + assert provider.index_name == "my_idx" + assert provider.prefix == "my_prefix:" + assert provider.application_id == "app1" + assert provider.agent_id == "agent1" + assert provider.context_prompt == "Custom prompt" + + def test_default_context_prompt(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + assert "Memories" in provider.context_prompt + + def test_valkey_url_support(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider( + source_id="ctx", user_id="u1", valkey_url="valkey://myhost:6380", client=mock_glide_client + ) + assert provider.valkey_url == "valkey://myhost:6380" + + def test_mutually_exclusive_url_and_host_raises(self) -> None: + with pytest.raises(ValueError, match="mutually exclusive"): + ValkeyContextProvider( + source_id="ctx", user_id="u1", valkey_url="valkey://other:6380", host="myhost", port=6380 + ) + + +class TestValkeyContextProviderValidateFilters: + def test_no_filters_raises(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider(source_id="ctx", client=mock_glide_client) + with pytest.raises(ValueError, match="(?i)at least one"): + provider._validate_filters() + + def test_any_single_filter_ok(self, mock_glide_client: AsyncMock) -> None: + for kwargs in [{"user_id": "u"}, {"agent_id": "a"}, {"application_id": "app"}]: + provider = ValkeyContextProvider(source_id="ctx", client=mock_glide_client, **kwargs) + provider._validate_filters() # should not raise + + +class TestValkeyContextProviderBeforeRun: + async def test_search_results_added_to_context(self, mock_glide_client: AsyncMock) -> None: + mock_glide_client.custom_command = AsyncMock( + return_value=[2, b"doc:1", [b"content", b"Memory A"], b"doc:2", [b"content", b"Memory B"]] + ) + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", contents=["test query"])], session_id="s1") + + await provider.before_run( + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) # type: ignore[arg-type] + + assert "ctx" in ctx.context_messages + msgs = ctx.context_messages["ctx"] + assert len(msgs) == 1 + assert "Memory A" in msgs[0].text + assert "Memory B" in msgs[0].text + + async def test_empty_input_no_search(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", contents=[" "])], session_id="s1") + + await provider.before_run( + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) # type: ignore[arg-type] + + mock_glide_client.custom_command.assert_not_called() + assert "ctx" not in ctx.context_messages + + async def test_empty_results_no_messages(self, mock_glide_client: AsyncMock) -> None: + mock_glide_client.custom_command = AsyncMock(return_value=[0]) + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", contents=["hello"])], session_id="s1") + + await provider.before_run( + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) # type: ignore[arg-type] + + assert "ctx" not in ctx.context_messages + + +class TestValkeyContextProviderAfterRun: + async def test_stores_messages(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + session = AgentSession(session_id="test-session") + response = AgentResponse(messages=[Message(role="assistant", contents=["response text"])]) + ctx = SessionContext(input_messages=[Message(role="user", contents=["user input"])], session_id="s1") + ctx._response = response + + await provider.after_run( + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) # type: ignore[arg-type] + + assert mock_glide_client.hset.call_count == 2 + + async def test_skips_empty_conversations(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", contents=[" "])], session_id="s1") + + await provider.after_run( + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) # type: ignore[arg-type] + + mock_glide_client.hset.assert_not_called() + + async def test_stores_partition_fields(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider( + source_id="ctx", application_id="app", agent_id="ag", user_id="u1", client=mock_glide_client + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", contents=["hello"])], session_id="s1") + + await provider.after_run( + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) # type: ignore[arg-type] + + assert mock_glide_client.hset.call_count == 1 + field_dict = mock_glide_client.hset.call_args[0][1] + assert field_dict["application_id"] == "app" + assert field_dict["agent_id"] == "ag" + assert field_dict["user_id"] == "u1" + + +class TestValkeyContextProviderContextManager: + async def test_aenter_returns_self(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + async with provider as p: + assert p is provider + + async def test_aclose_closes_owned_client(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + # Simulate owned client + provider._owns_client = True + await provider.aclose() + mock_glide_client.close.assert_called_once() + assert provider._client is None + + +class TestValkeyContextProviderEnsureIndex: + async def test_creates_index_on_first_call(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + await provider._ensure_index() + + mock_glide_client.custom_command.assert_called_once() + cmd_args = mock_glide_client.custom_command.call_args[0][0] + assert cmd_args[0] == "FT.CREATE" + assert provider._index_created is True + + async def test_skips_on_subsequent_calls(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + await provider._ensure_index() + await provider._ensure_index() + + assert mock_glide_client.custom_command.call_count == 1 + + async def test_handles_index_already_exists(self, mock_glide_client: AsyncMock) -> None: + mock_glide_client.custom_command = AsyncMock(side_effect=Exception("Index already exists")) + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + + await provider._ensure_index() # should not raise + assert provider._index_created is True + + async def test_includes_vector_field_in_schema(self, mock_glide_client: AsyncMock) -> None: + mock_embed = AsyncMock(return_value=[0.1] * 128) + provider = ValkeyContextProvider( + source_id="ctx", + user_id="u1", + client=mock_glide_client, + embed_fn=mock_embed, + vector_field_name="embedding", + vector_dims=128, + ) + await provider._ensure_index() + + cmd_args = mock_glide_client.custom_command.call_args[0][0] + assert "embedding" in cmd_args + assert "VECTOR" in cmd_args + assert "128" in cmd_args + + +class TestValkeyContextProviderHybridSearch: + """Tests for the vector/hybrid search path (embed_fn provided).""" + + async def test_add_stores_raw_bytes_embedding(self, mock_glide_client: AsyncMock) -> None: + mock_embed = AsyncMock(return_value=[0.1, 0.2, 0.3]) + provider = ValkeyContextProvider( + source_id="ctx", + user_id="u1", + client=mock_glide_client, + embed_fn=mock_embed, + vector_field_name="embedding", + vector_dims=3, + ) + + await provider._add(data=[{"content": "test", "role": "user"}]) + + mock_embed.assert_called_once_with("test") + # Verify the embedding is stored as raw bytes, not hex string + hset_call = mock_glide_client.hset.call_args[0] + field_map = hset_call[1] + stored_embedding = field_map["embedding"] + assert isinstance(stored_embedding, bytes) + expected = np.asarray([0.1, 0.2, 0.3], dtype=np.float32).tobytes() + assert stored_embedding == expected + + async def test_search_passes_raw_bytes_vector(self, mock_glide_client: AsyncMock) -> None: + mock_embed = AsyncMock(return_value=[0.1, 0.2, 0.3]) + mock_glide_client.custom_command = AsyncMock(return_value=[0]) + provider = ValkeyContextProvider( + source_id="ctx", + user_id="u1", + client=mock_glide_client, + embed_fn=mock_embed, + vector_field_name="embedding", + vector_dims=3, + ) + provider._index_created = True + + await provider._search(text="test query") + + # Verify FT.SEARCH was called with raw bytes in PARAMS + search_call = mock_glide_client.custom_command.call_args[0][0] + assert search_call[0] == "FT.SEARCH" + # Find the "vec" param value (follows "vec" in the args) + vec_idx = search_call.index("vec") + 1 + vec_value = search_call[vec_idx] + assert isinstance(vec_value, bytes) + + async def test_hybrid_search_constructs_knn_query(self, mock_glide_client: AsyncMock) -> None: + mock_embed = AsyncMock(return_value=[0.1] * 128) + mock_glide_client.custom_command = AsyncMock(return_value=[1, b"doc:1", [b"content", b"result"]]) + provider = ValkeyContextProvider( + source_id="ctx", + user_id="u1", + client=mock_glide_client, + embed_fn=mock_embed, + vector_field_name="embedding", + vector_dims=128, + ) + provider._index_created = True + + results = await provider._search(text="test") + + search_call = mock_glide_client.custom_command.call_args[0][0] + query_str = search_call[2] + assert "KNN" in query_str + assert "@embedding" in query_str + assert len(results) == 1 + assert results[0]["content"] == "result" + + +class TestValkeyContextProviderSearchErrors: + async def test_empty_text_raises_directly(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + provider._index_created = True + + with pytest.raises(IntegrationInvalidRequestException, match="non-empty text"): + await provider._search(text="") + + async def test_connection_error_wrapped(self, mock_glide_client: AsyncMock) -> None: + # First call succeeds (FT.CREATE), second fails (FT.SEARCH) + mock_glide_client.custom_command = AsyncMock(side_effect=[None, ConnectionError("connection lost")]) + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + + with pytest.raises(IntegrationInvalidRequestException, match="Valkey search failed"): + await provider._search(text="test") + + async def test_direct_integration_exception_not_double_wrapped(self, mock_glide_client: AsyncMock) -> None: + provider = ValkeyContextProvider(source_id="ctx", user_id="u1", client=mock_glide_client) + provider._index_created = True + + # Empty text should raise IntegrationInvalidRequestException directly, not wrapped + with pytest.raises(IntegrationInvalidRequestException, match="non-empty text"): + await provider._search(text=" ") + + +class TestValkeyContextProviderParseResults: + def test_parse_empty_results(self) -> None: + assert ValkeyContextProvider._parse_search_results([0]) == [] + assert ValkeyContextProvider._parse_search_results(None) == [] + assert ValkeyContextProvider._parse_search_results([]) == [] + + def test_parse_results_with_docs(self) -> None: + result = [2, b"doc:1", [b"content", b"Hello"], b"doc:2", [b"content", b"World"]] + docs = ValkeyContextProvider._parse_search_results(result) + assert len(docs) == 2 + assert docs[0]["content"] == "Hello" + assert docs[1]["content"] == "World" + + def test_parse_results_with_string_fields(self) -> None: + result = [1, "doc:1", ["content", "Hello", "role", "user"]] + docs = ValkeyContextProvider._parse_search_results(result) + assert len(docs) == 1 + assert docs[0]["content"] == "Hello" + assert docs[0]["role"] == "user" + + +class TestValkeyContextProviderEscaping: + def test_escape_tag(self) -> None: + assert ValkeyContextProvider._escape_tag("simple") == "simple" + assert "\\" in ValkeyContextProvider._escape_tag("has space") + assert "\\" in ValkeyContextProvider._escape_tag("has@special") + + def test_escape_query(self) -> None: + assert ValkeyContextProvider._escape_query("simple text") == "simple text" + assert "\\" in ValkeyContextProvider._escape_query("@mention") diff --git a/python/pyproject.toml b/python/pyproject.toml index 67853a9ec1..bf9402b327 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -92,6 +92,7 @@ agent-framework-openai = { workspace = true } agent-framework-orchestrations = { workspace = true } agent-framework-purview = { workspace = true } agent-framework-redis = { workspace = true } +agent-framework-valkey = { workspace = true } litellm = { url = "https://files.pythonhosted.org/packages/57/77/0c6eca2cb049793ddf8ce9cdcd5123a35666c4962514788c4fc90edf1d3b/litellm-1.82.1-py3-none-any.whl" } [tool.ruff] @@ -211,6 +212,7 @@ executionEnvironments = [ { root = "packages/orchestrations/tests", reportPrivateUsage = "none" }, { root = "packages/purview/tests", reportPrivateUsage = "none" }, { root = "packages/redis/tests", reportPrivateUsage = "none" }, + { root = "packages/valkey/tests", reportPrivateUsage = "none" }, { root = "tests", reportPrivateUsage = "none" }, ] diff --git a/python/samples/02-agents/context_providers/valkey_sample.py b/python/samples/02-agents/context_providers/valkey_sample.py new file mode 100644 index 0000000000..a60890995f --- /dev/null +++ b/python/samples/02-agents/context_providers/valkey_sample.py @@ -0,0 +1,283 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Valkey Integration Sample — ValkeyChatMessageStore + ValkeyContextProvider + +Demonstrates both Valkey-backed components against a local Valkey instance: + 1. ValkeyChatMessageStore — persistent chat history across sessions + 2. ValkeyContextProvider — long-term memory via text search (no embeddings needed) + +Prerequisites: + - Valkey running locally (9.1+ required for full-text search): + docker run -d --name valkey -p 6379:6379 valkey/valkey-bundle:9.1.0-rc1 + - AWS credentials configured (for Bedrock) + - Install: uv pip install agent-framework-valkey agent-framework-bedrock python-dotenv +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os + +from agent_framework import Agent, AgentSession, tool +from agent_framework_bedrock import BedrockChatClient +from agent_framework_valkey import ValkeyChatMessageStore, ValkeyContextProvider +from dotenv import load_dotenv + +load_dotenv() + +VALKEY_HOST = os.getenv("VALKEY_HOST", "localhost") +VALKEY_PORT = int(os.getenv("VALKEY_PORT", "6379")) +BEDROCK_REGION = os.getenv("BEDROCK_REGION", "us-east-2") +BEDROCK_MODEL = os.getenv("BEDROCK_CHAT_MODEL", "us.anthropic.claude-opus-4-5-20251101-v1:0") + + +@tool +def get_current_time() -> str: + """Get the current date and time.""" + from datetime import datetime + + return datetime.now().isoformat() + + +def make_agent( + name: str, + instructions: str, + context_providers: list, +) -> Agent: + return Agent( + client=BedrockChatClient(region=BEDROCK_REGION, model=BEDROCK_MODEL), + name=name, + instructions=instructions, + context_providers=context_providers, + tools=[get_current_time], + ) + + +# --------------------------------------------------------------------------- +# Part 1: Chat Message Store — persistent history +# --------------------------------------------------------------------------- +async def demo_chat_message_store() -> None: + print("=" * 60) + print("Part 1: ValkeyChatMessageStore — Persistent Chat History") + print("=" * 60) + + history = ValkeyChatMessageStore( + source_id="valkey_demo_history", + host=VALKEY_HOST, + port=VALKEY_PORT, + key_prefix="demo_chat", + max_messages=20, + ) + + agent = make_agent( + name="HistoryBot", + instructions="You are a helpful assistant. Be concise.", + context_providers=[history], + ) + + # --- Session 1: establish facts --- + session = agent.create_session() + session_id = session.session_id + + print(f"\n[Session {session_id[:8]}…] Starting conversation") + + for msg in [ + "Hi! My name is [insert name here] and I work on AI agent frameworks.", + "I really enjoy cycling and live in Seattle.", + ]: + print(f" User: {msg}") + resp = await agent.run(msg, session=session) + print(f" Agent: {resp.text}\n") + + # Serialize session (simulates app restart) + saved = session.to_dict() + + # --- Session 2: resume and verify recall --- + print(f"[Session {session_id[:8]}…] Resuming after 'restart'") + restored = AgentSession.from_dict(saved) + + msg = "What do you remember about me?" + print(f" User: {msg}") + resp = await agent.run(msg, session=restored) + print(f" Agent: {resp.text}\n") + + # Verify data in Valkey + from glide import GlideClient, GlideClientConfiguration, NodeAddress + + client = await GlideClient.create( + GlideClientConfiguration(addresses=[NodeAddress(host=VALKEY_HOST, port=VALKEY_PORT)]) + ) + key = f"demo_chat:{session_id}" + count = await client.llen(key) + print(f" ✓ Valkey key '{key}' has {count} messages stored") + await client.close() + + await history.aclose() + + +# --------------------------------------------------------------------------- +# Part 2: Context Provider — long-term memory via text search +# --------------------------------------------------------------------------- +async def demo_context_provider() -> None: + print("\n" + "=" * 60) + print("Part 2: ValkeyContextProvider — Long-Term Memory (Text Search)") + print("=" * 60) + + context_provider = ValkeyContextProvider( + source_id="valkey_demo_memory", + host=VALKEY_HOST, + port=VALKEY_PORT, + index_name="demo_memory_idx", + prefix="demo_mem:", + agent_id="demo_agent", + ) + + agent = make_agent( + name="MemoryBot", + instructions=( + "You are a helpful assistant with long-term memory. " + "Use the memories provided to personalize your responses. Be concise." + ), + context_providers=[context_provider], + ) + + # --- Conversation 1: teach the agent some facts --- + session1 = agent.create_session() + print(f"\n[Conversation 1 — {session1.session_id[:8]}…] Teaching facts") + + for msg in [ + "I'm building a Valkey integration for the Microsoft Agent Framework.", + "My favorite programming language is Python and I use Bedrock for LLMs.", + ]: + print(f" User: {msg}") + resp = await agent.run(msg, session=session1) + print(f" Agent: {resp.text}\n") + + # --- Conversation 2: new session, agent should recall from Valkey --- + session2 = agent.create_session() + print(f"[Conversation 2 — {session2.session_id[:8]}…] Testing recall") + + msg = "What do you know about my projects and preferences?" + print(f" User: {msg}") + resp = await agent.run(msg, session=session2) + print(f" Agent: {resp.text}\n") + + # Verify data in Valkey + from glide import GlideClient, GlideClientConfiguration, NodeAddress + + client = await GlideClient.create( + GlideClientConfiguration(addresses=[NodeAddress(host=VALKEY_HOST, port=VALKEY_PORT)]) + ) + result = await client.custom_command(["FT.INFO", "demo_memory_idx"]) # noqa: F841 + print(" ✓ Valkey search index 'demo_memory_idx' exists") + + # Count stored documents + scan_result = await client.scan("0", match="demo_mem:*", count=1000) + doc_count = len(scan_result[1]) if scan_result[1] else 0 + print(f" ✓ {doc_count} memory documents stored in Valkey") + await client.close() + + await context_provider.aclose() + + +# --------------------------------------------------------------------------- +# Part 3: Both together +# --------------------------------------------------------------------------- +async def demo_combined() -> None: + print("\n" + "=" * 60) + print("Part 3: Combined — History + Context Provider") + print("=" * 60) + + history = ValkeyChatMessageStore( + source_id="valkey_combined_history", + host=VALKEY_HOST, + port=VALKEY_PORT, + key_prefix="combined_chat", + ) + + context = ValkeyContextProvider( + source_id="valkey_combined_memory", + host=VALKEY_HOST, + port=VALKEY_PORT, + index_name="combined_mem_idx", + prefix="combined_mem:", + agent_id="combined_agent", + ) + + agent = make_agent( + name="FullBot", + instructions=( + "You are a helpful assistant with both conversation history and long-term memory. " + "Be concise." + ), + context_providers=[history, context], + ) + + session = agent.create_session() + print(f"\n[Session {session.session_id[:8]}…]") + + for msg in [ + "I'm evaluating Valkey for our agent infrastructure.", + "What are the key advantages of Valkey?", + ]: + print(f" User: {msg}") + resp = await agent.run(msg, session=session) + print(f" Agent: {resp.text}\n") + + await history.aclose() + await context.aclose() + print(" ✓ Both providers worked together successfully") + + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- +async def cleanup() -> None: + """Remove demo keys from Valkey.""" + from glide import GlideClient, GlideClientConfiguration, NodeAddress + + client = await GlideClient.create( + GlideClientConfiguration(addresses=[NodeAddress(host=VALKEY_HOST, port=VALKEY_PORT)]) + ) + + # Drop indexes + for idx in ["demo_memory_idx", "combined_mem_idx"]: + with contextlib.suppress(Exception): + await client.custom_command(["FT.DROPINDEX", idx]) + + # Delete keys by pattern + for pattern in ["demo_chat:*", "demo_mem:*", "combined_chat:*", "combined_mem:*"]: + cursor: str | bytes = "0" + while True: + result = await client.scan(cursor, match=pattern, count=1000) + cursor = result[0] + keys = result[1] + if keys: + str_keys = [k.decode("utf-8") if isinstance(k, bytes) else str(k) for k in keys] + await client.delete(str_keys) + cursor_str = cursor.decode("utf-8") if isinstance(cursor, bytes) else str(cursor) + if cursor_str == "0": + break + + await client.close() + print("\n✓ Cleanup complete — demo keys removed from Valkey") + + +async def main() -> None: + print("Valkey Integration Sample") + print(f"Prerequisites: Valkey on {VALKEY_HOST}:{VALKEY_PORT}, AWS credentials configured\n") + + try: + await demo_chat_message_store() + await demo_context_provider() + await demo_combined() + finally: + await cleanup() + + print("\n🎉 All demos completed successfully!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/uv.lock b/python/uv.lock index bb422b4478..97082f2fd4 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -54,6 +54,7 @@ members = [ "agent-framework-orchestrations", "agent-framework-purview", "agent-framework-redis", + "agent-framework-valkey", ] constraints = [{ name = "litellm", url = "https://files.pythonhosted.org/packages/57/77/0c6eca2cb049793ddf8ce9cdcd5123a35666c4962514788c4fc90edf1d3b/litellm-1.82.1-py3-none-any.whl" }] @@ -761,6 +762,29 @@ requires-dist = [ { name = "redisvl", specifier = ">=0.11.0,<0.16" }, ] +[[package]] +name = "agent-framework-valkey" +version = "1.0.0b260421" +source = { editable = "packages/valkey" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "valkey-glide", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.optional-dependencies] +vector = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "numpy", marker = "extra == 'vector'", specifier = ">=2.2.6,<3" }, + { name = "valkey-glide", specifier = ">=2.3.1,<3" }, +] +provides-extras = ["vector"] + [[package]] name = "agentlightning" version = "0.2.2" @@ -2558,6 +2582,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -2565,6 +2590,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -2573,6 +2599,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -2581,6 +2608,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -2589,6 +2617,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -2597,6 +2626,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -7252,6 +7282,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, ] +[[package]] +name = "valkey-glide" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/04/92be56c4dd9b5c89f10999e66f4d0e156d07d7b45aed9b0f89273f26aac5/valkey_glide-2.3.1.tar.gz", hash = "sha256:f4bae030c0aa6e55edb2c27dbd55f82cfb5f581904fff1318eec1c062f30d4b3", size = 832671, upload-time = "2026-04-01T17:56:32.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/40/b2ea2da3baa3085cfa93740a431e1054ff2f9c95a23c476618f7859b2083/valkey_glide-2.3.1-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:736a3e58393fa4f0f2fbb10031d46da5f18ebb8e72d2f9428ff24f0f6addeb3f", size = 7379323, upload-time = "2026-04-01T17:55:26.269Z" }, + { url = "https://files.pythonhosted.org/packages/53/ef/ad098d9c8c4385cedb66344316eaba7d8ca613c87dd757ca4f56390f11b9/valkey_glide-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b2cd6f5c4e9b67b78873f34f19b9182bab5b07a9151855cf059303e05dac3b2f", size = 6860556, upload-time = "2026-04-01T17:55:28.255Z" }, + { url = "https://files.pythonhosted.org/packages/7c/14/680b98b22e0af970758a9fe7e16f1f438a0424c6761820e8d5732f6220ea/valkey_glide-2.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ddf70bc7888d565273e4bf858ff6047d5284140ff380a732f807c775be8e108", size = 7133576, upload-time = "2026-04-01T17:55:29.778Z" }, + { url = "https://files.pythonhosted.org/packages/21/a8/4683c403fe26aa9cecc25e557e924f64ea9185c45b31c17aeecd89e00a5f/valkey_glide-2.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f947dd44ba9741eadcab154443f447c19f23dab56de33f56d5f133ee0d597c2", size = 7599098, upload-time = "2026-04-01T17:55:31.594Z" }, + { url = "https://files.pythonhosted.org/packages/0c/19/4cce4fde822f2fa0df7c98e82232af367471996efe89d2c680022350a618/valkey_glide-2.3.1-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:6ddc4c6bee1a9c102f003cddc5d1bad8173a9d90e1c9a0f73a285228ed8625af", size = 7378844, upload-time = "2026-04-01T17:55:33.321Z" }, + { url = "https://files.pythonhosted.org/packages/9d/04/fca4862a885e0f0ef9560f2d4e42f29e0ec6df27e487aa64dc9c0b9a2f6e/valkey_glide-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30590532136e4ea38b6a6389cbcfe4edc554418563c6e4f6357b0749907b2c20", size = 6860870, upload-time = "2026-04-01T17:55:34.885Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/7eb28e04008e247c2fe5c427b3dbbd81b238dd8ed9772e2acfc999008e42/valkey_glide-2.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bbdb7baa7aac12c109aefd97f69f9780a4812429db18786254ef288ecf75f19", size = 7135059, upload-time = "2026-04-01T17:55:36.63Z" }, + { url = "https://files.pythonhosted.org/packages/ec/81/cbb2bfb989efef22b43b66a7e8249aa4afbb1201c2e9a29bb32677460ee9/valkey_glide-2.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fd64d77ae26efd524be58456e22636ce4cb0a6110ad722e89f249a45d098692", size = 7596374, upload-time = "2026-04-01T17:55:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/2e/04/9c492cdf0238aabd2902f4f252dd63ffee64cd0228d06989c8cd2a272291/valkey_glide-2.3.1-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:406b73f5ee080406fbfeda542d37de7e330fb4d83b0aa7212b92707d7b7b82a6", size = 7381382, upload-time = "2026-04-01T17:55:40.431Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/15302dba094927acced9bfccdbe5cf333129ddedf5e8378b94b415a54ccd/valkey_glide-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0940d4069cbc4896dec3a1ab39db7bf86667fb32892df4dbf3b043129d26d6e5", size = 6854103, upload-time = "2026-04-01T17:55:42.166Z" }, + { url = "https://files.pythonhosted.org/packages/ec/54/6b40a104352e44b36558528cd97d1ec7c12585b1fa1019b1794d52d19ab5/valkey_glide-2.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b47de0ec3d5a253c2b37d33266aaeb22503014f9e8f0611ba999e06f9804966a", size = 7134311, upload-time = "2026-04-01T17:55:43.966Z" }, + { url = "https://files.pythonhosted.org/packages/97/79/84de88074bc6780813415afd704e9c827be13b3aa02cc5508122070ae100/valkey_glide-2.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a364210002dd0e7c3362299f61a2a1cacf867594a8a0bbf157a345f3f40d4d94", size = 7597689, upload-time = "2026-04-01T17:55:45.898Z" }, + { url = "https://files.pythonhosted.org/packages/fe/bd/1cdb584687a2d2cd762a53cf111932aee1216186a6b28d00724805679643/valkey_glide-2.3.1-cp313-cp313-macosx_10_7_x86_64.whl", hash = "sha256:86d56756842acd6286601128822c5f1f9dcd61305f0c6a80c3e7fb3a7e0404ef", size = 7384605, upload-time = "2026-04-01T17:55:47.94Z" }, + { url = "https://files.pythonhosted.org/packages/85/76/f8c609597a24a07957c1d0e13d6f083376ad12ee205b21414d6a445c51fa/valkey_glide-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b307795a23473b8e7cff781eb54936cc672a430820f5fa71c6b6fb3748cc1189", size = 6854336, upload-time = "2026-04-01T17:55:50.079Z" }, + { url = "https://files.pythonhosted.org/packages/96/5e/4fc465f880219712c9daff2b38a55008515946dfa5b3b63d3232b75c6bf4/valkey_glide-2.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cb570f5d637ee55300ccdecd39a51cbf25c67ab6e25f2022d42f32a7bec6163", size = 7134155, upload-time = "2026-04-01T17:55:51.566Z" }, + { url = "https://files.pythonhosted.org/packages/00/46/894470eaf297a5d302b63c0900722fa56715a53ccd577528978171481553/valkey_glide-2.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:506c7800eec05caf17136645cc642941a9536578f4d6733845e7d0ed36ed4e3e", size = 7597496, upload-time = "2026-04-01T17:55:53.174Z" }, + { url = "https://files.pythonhosted.org/packages/56/88/eb7f25667c81d16ff55774c685f62d2b622917730b0c822db1d30ff32c11/valkey_glide-2.3.1-cp314-cp314-macosx_10_7_x86_64.whl", hash = "sha256:3d6626e6f9ddfa7f8706023e167b4a2eca8a0f7b7fee1d30f91a83b4811349e4", size = 7383535, upload-time = "2026-04-01T17:55:54.747Z" }, + { url = "https://files.pythonhosted.org/packages/d8/64/5db032850ae1f8ec345ec5e5c4b0f15c50c8cf88e5a67990491964938cab/valkey_glide-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3466a0c113a951d722036704795ff0377eef11a44ab224472f98d99ac2c5ef28", size = 6853286, upload-time = "2026-04-01T17:55:56.96Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/4c835ece50d6e1536e96a74a11fe51a1aef8006c6e38544c324a5d4d5637/valkey_glide-2.3.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe53e4808bdac5b4e6482c66583e1980ecf75666b4e4d0984d89e8b693026543", size = 7135821, upload-time = "2026-04-01T17:55:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/c1341d977d0cd3ae812ae620bf0935e51d95e563af5a00562592c10fcc38/valkey_glide-2.3.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1a9662885ea8f3df97a6d873131dea983d42e4735750af368fe2d47e7e44f0c", size = 7595445, upload-time = "2026-04-01T17:56:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/20/5e/0b9cc70a0852c1423bd4e1609500481ffcdd7d11de88ac799c4b4758d39b/valkey_glide-2.3.1-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:5533a090953fd6af4c07b80bd042231540fbd1ede95fff42614750b435f01184", size = 7379308, upload-time = "2026-04-01T17:56:10.508Z" }, + { url = "https://files.pythonhosted.org/packages/4d/3d/68dcc6010a5cd100c360ff57c15cb1e2ff343e81a1ee2630c7cdb57e91b4/valkey_glide-2.3.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f814ad759e9fdc6c5ced18ddba38cc2a3badb2839ce3555ec9b44beb794096e4", size = 6860135, upload-time = "2026-04-01T17:56:12.698Z" }, + { url = "https://files.pythonhosted.org/packages/63/a4/6e4b8603ab0217f43721641d08740d3c7ce124d1fc7c9bfb30e967ac1830/valkey_glide-2.3.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dc6dea7ce627a8b166d33232aa7bc7f8dd9d224870235a560bc5d1c4ccec8cb", size = 7136201, upload-time = "2026-04-01T17:56:14.287Z" }, + { url = "https://files.pythonhosted.org/packages/04/18/8a5a22e8245e48b0bf83a99ac64f289ff62de84ee44315c53a5db8dff69c/valkey_glide-2.3.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1e135bb43e50b1cd6558d93b3108c40a79ce8dc119de883cebb7458d470f629", size = 7594993, upload-time = "2026-04-01T17:56:16.235Z" }, + { url = "https://files.pythonhosted.org/packages/88/58/a0acca1c36a1481c9f5cf094fc584b1a9f9ad9af927a355400e968cc1f92/valkey_glide-2.3.1-pp311-pypy311_pp73-macosx_10_7_x86_64.whl", hash = "sha256:993c9bffde847fa3d36c6f11e5e50872dd491f245850d7c6ae1bbb8db5bff346", size = 7379554, upload-time = "2026-04-01T17:56:18.12Z" }, + { url = "https://files.pythonhosted.org/packages/1f/84/02e922bfd7201c9bbf3a4464aaf46e1b5b508852ba05974981a215f34d1b/valkey_glide-2.3.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:918ce3b8a2a3602e82d03f254bad5cc5bd1398eb84dec8eef77aefccc039bd5d", size = 6860268, upload-time = "2026-04-01T17:56:19.808Z" }, + { url = "https://files.pythonhosted.org/packages/ae/30/d8e215ab273d9a599ab926a7299e9a1f219120e6248850efb51186107723/valkey_glide-2.3.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28d4cbf00b07db273214488f17d59232baaddd0cc30c26064cf3bf384b03e9cd", size = 7136569, upload-time = "2026-04-01T17:56:21.357Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ac/80d29b75115133c3f97dd0fa725eb9598ebcd4217f0ece22ce63dc7dc8f7/valkey_glide-2.3.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d93ef822a524c8f18c1b750f061373d95e842005116ebcf832d166533bf2bc2", size = 7594844, upload-time = "2026-04-01T17:56:23.528Z" }, +] + [[package]] name = "watchdog" version = "6.0.0"