diff --git a/docs/ref/extensions/memory/valkey_session.md b/docs/ref/extensions/memory/valkey_session.md new file mode 100644 index 0000000000..00514beec6 --- /dev/null +++ b/docs/ref/extensions/memory/valkey_session.md @@ -0,0 +1,3 @@ +# `ValkeySession` + +::: agents.extensions.memory.valkey_session.ValkeySession diff --git a/docs/sessions/index.md b/docs/sessions/index.md index da420fa667..e21295e11b 100644 --- a/docs/sessions/index.md +++ b/docs/sessions/index.md @@ -205,6 +205,7 @@ Use this table to pick a starting point before reading the detailed examples bel | `SQLiteSession` | Local development and simple apps | Built-in, lightweight, file-backed or in-memory | | `AsyncSQLiteSession` | Async SQLite with `aiosqlite` | Extension backend with async driver support | | `RedisSession` | Shared memory across workers/services | Good for low-latency distributed deployments | +| `ValkeySession` | Shared memory with Valkey (Redis-compatible) | For teams using Valkey; uses the high-performance valkey-glide client | | `SQLAlchemySession` | Production apps with existing databases | Works with SQLAlchemy-supported databases | | `DaprSession` | Cloud-native deployments with Dapr sidecars | Supports multiple state stores plus TTL and consistency controls | | `OpenAIConversationsSession` | Server-managed storage in OpenAI | OpenAI Conversations API-backed history | @@ -362,6 +363,48 @@ session = RedisSession.from_url( result = await Runner.run(agent, "Hello", session=session) ``` +### Valkey sessions + +Use `ValkeySession` for shared session memory backed by [Valkey](https://valkey.io/), the open-source Redis-compatible data store. It uses the [valkey-glide](https://github.com/valkey-io/valkey-glide) client for high-performance async access. + +```bash +pip install openai-agents[valkey] +``` + +```python +from agents import Agent, Runner +from agents.extensions.memory import ValkeySession + +agent = Agent(name="Assistant") +session = await ValkeySession.from_url( + "user_123", + url="valkey://localhost:6379/0", +) +result = await Runner.run(agent, "Hello", session=session) +``` + +You can also inject an existing `GlideClient` if your application already manages one: + +```python +from glide import GlideClient, GlideClientConfiguration, NodeAddress +from agents.extensions.memory import ValkeySession + +config = GlideClientConfiguration(addresses=[NodeAddress("localhost", 6379)]) +client = await GlideClient.create(config) + +session = ValkeySession( + session_id="user_123", + valkey_client=client, + ttl=3600, # optional: expire session data after 1 hour +) +``` + +Notes: + +- `from_url(...)` is `async` because `GlideClient.create()` is async. It creates and owns the client; `close()` will shut it down. +- When you pass your own `valkey_client`, the session does not close it — your code manages the client lifecycle. +- `from_url` accepts `valkey://`, `valkeys://` (TLS), and `redis://`, `rediss://` schemes for compatibility. + ### SQLAlchemy sessions Production-ready Agents SDK session persistence using any SQLAlchemy-supported database: @@ -487,6 +530,7 @@ Use meaningful session IDs that help you organize conversations: - Use file-based SQLite (`SQLiteSession("session_id", "path/to/db.sqlite")`) for persistent conversations - Use async SQLite (`AsyncSQLiteSession("session_id", db_path="...")`) when you need an `aiosqlite`-based implementation - Use Redis-backed sessions (`RedisSession.from_url("session_id", url="redis://...")`) for shared, low-latency session memory +- Use Valkey-backed sessions (`await ValkeySession.from_url("session_id", url="valkey://...")`) for shared session memory with the open-source Valkey data store - Use SQLAlchemy-powered sessions (`SQLAlchemySession("session_id", engine=engine, create_tables=True)`) for production systems with existing databases supported by SQLAlchemy - Use Dapr state store sessions (`DaprSession.from_address("session_id", state_store_name="statestore", dapr_address="localhost:50001")`) for production cloud-native deployments with support for 30+ database backends with built-in telemetry, tracing, and data isolation - Use OpenAI-hosted storage (`OpenAIConversationsSession()`) when you prefer to store history in the OpenAI Conversations API @@ -666,6 +710,7 @@ For detailed API documentation, see: - [`SQLiteSession`][agents.memory.sqlite_session.SQLiteSession] - Basic SQLite implementation - [`AsyncSQLiteSession`][agents.extensions.memory.async_sqlite_session.AsyncSQLiteSession] - Async SQLite implementation based on `aiosqlite` - [`RedisSession`][agents.extensions.memory.redis_session.RedisSession] - Redis-backed session implementation +- [`ValkeySession`][agents.extensions.memory.valkey_session.ValkeySession] - Valkey-backed session implementation using valkey-glide - [`SQLAlchemySession`][agents.extensions.memory.sqlalchemy_session.SQLAlchemySession] - SQLAlchemy-powered implementation - [`DaprSession`][agents.extensions.memory.dapr_session.DaprSession] - Dapr state store implementation - [`AdvancedSQLiteSession`][agents.extensions.memory.advanced_sqlite_session.AdvancedSQLiteSession] - Enhanced SQLite with branching and analytics diff --git a/mkdocs.yml b/mkdocs.yml index 5697cbd40d..05f04644b9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -190,6 +190,7 @@ plugins: - SQLAlchemySession: ref/extensions/memory/sqlalchemy_session.md - Async SQLite session: ref/extensions/memory/async_sqlite_session.md - RedisSession: ref/extensions/memory/redis_session.md + - ValkeySession: ref/extensions/memory/valkey_session.md - DaprSession: ref/extensions/memory/dapr_session.md - EncryptedSession: ref/extensions/memory/encrypt_session.md - AdvancedSQLiteSession: ref/extensions/memory/advanced_sqlite_session.md diff --git a/pyproject.toml b/pyproject.toml index 180a41857c..3d6ec3d5b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ realtime = ["websockets>=15.0, <17"] sqlalchemy = ["SQLAlchemy>=2.0", "asyncpg>=0.29.0"] encrypt = ["cryptography>=45.0, <46"] redis = ["redis>=7"] +valkey = ["valkey-glide>=2.1"] dapr = ["dapr>=1.16.0", "grpcio>=1.60.0"] mongodb = ["pymongo>=4.14"] docker = ["docker>=6.1"] @@ -164,6 +165,10 @@ ignore_missing_imports = true module = ["vercel", "vercel.*"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["glide", "glide.*"] +ignore_missing_imports = true + [tool.coverage.run] source = ["src/agents"] omit = [ diff --git a/src/agents/extensions/memory/__init__.py b/src/agents/extensions/memory/__init__.py index 7d0437fa00..a5ef900749 100644 --- a/src/agents/extensions/memory/__init__.py +++ b/src/agents/extensions/memory/__init__.py @@ -22,6 +22,7 @@ from .mongodb_session import MongoDBSession from .redis_session import RedisSession from .sqlalchemy_session import SQLAlchemySession + from .valkey_session import ValkeySession __all__: list[str] = [ "AdvancedSQLiteSession", @@ -33,6 +34,7 @@ "MongoDBSession", "RedisSession", "SQLAlchemySession", + "ValkeySession", ] @@ -130,4 +132,15 @@ def __getattr__(name: str) -> Any: "Install it with: pip install openai-agents[mongodb]" ) from e + if name == "ValkeySession": + try: + from .valkey_session import ValkeySession # noqa: F401 + + return ValkeySession + except ModuleNotFoundError as e: + raise ImportError( + "ValkeySession requires the 'valkey' extra. " + "Install it with: pip install openai-agents[valkey]" + ) from e + raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/src/agents/extensions/memory/valkey_session.py b/src/agents/extensions/memory/valkey_session.py new file mode 100644 index 0000000000..dd1bfac2a4 --- /dev/null +++ b/src/agents/extensions/memory/valkey_session.py @@ -0,0 +1,361 @@ +"""Valkey-powered Session backend. + +Usage:: + + from agents.extensions.memory import ValkeySession + + # Create from Valkey URL + session = await ValkeySession.from_url( + session_id="user-123", + url="valkey://localhost:6379/0", + ) + + # Or pass an existing GlideClient that your application already manages + session = ValkeySession( + session_id="user-123", + valkey_client=my_glide_client, + ) + + await Runner.run(agent, "Hello", session=session) +""" + +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any +from urllib.parse import ParseResult, urlparse + +try: + from glide import ( + Batch, + GlideClient, + GlideClientConfiguration, + NodeAddress, + ServerCredentials, + ) +except ImportError as e: + raise ImportError( + "ValkeySession requires the 'valkey-glide' package. " + "Install it with: pip install openai-agents[valkey]" + ) from e + +from ...items import TResponseInputItem +from ...memory.session import SessionABC +from ...memory.session_settings import SessionSettings, resolve_session_limit + +_VALID_SCHEMES = {"valkey", "valkeys", "redis", "rediss"} + + +def _parse_valkey_url(url: str) -> dict[str, Any]: + """Parse a Valkey/Redis-style URL into connection parameters. + + Supports schemes: valkey://, valkeys://, redis://, rediss://. + The 's' suffix indicates TLS. + + Args: + url: Connection URL string. + + Returns: + Dictionary with host, port, db, password, username, and use_tls keys. + + Raises: + ValueError: If the URL scheme is not one of the supported schemes. + """ + parsed: ParseResult = urlparse(url) + scheme = parsed.scheme.lower() + + if scheme not in _VALID_SCHEMES: + raise ValueError( + f"Unsupported URL scheme '{scheme}'. Use one of: {', '.join(sorted(_VALID_SCHEMES))}" + ) + + use_tls = scheme in ("valkeys", "rediss") + + host = parsed.hostname or "localhost" + port = parsed.port or 6379 + + # Extract database number from the path (e.g., /0, /15). + db: int = 0 + if parsed.path and parsed.path.strip("/"): + try: + db = int(parsed.path.strip("/")) + except ValueError: + db = 0 + + password: str | None = parsed.password + username: str | None = parsed.username + + return { + "host": host, + "port": port, + "db": db, + "password": password, + "username": username, + "use_tls": use_tls, + } + + +class ValkeySession(SessionABC): + """Valkey implementation of :pyclass:`agents.memory.session.Session`.""" + + session_settings: SessionSettings | None = None + + def __init__( + self, + session_id: str, + *, + valkey_client: GlideClient, + key_prefix: str = "agents:session", + ttl: int | None = None, + session_settings: SessionSettings | None = None, + ): + """Initialise a new ValkeySession. + + Args: + session_id (str): Unique identifier for the conversation. + valkey_client (GlideClient): A pre-configured Valkey GLIDE client. + key_prefix (str, optional): Prefix for Valkey keys to avoid collisions. + Defaults to "agents:session". + ttl (int | None, optional): Time-to-live in seconds for session data. + If None, data persists indefinitely. Defaults to None. + session_settings (SessionSettings | None): Session configuration settings including + default limit for retrieving items. If None, uses default SessionSettings(). + """ + self.session_id = session_id + self.session_settings = session_settings or SessionSettings() + self._client = valkey_client + self._key_prefix = key_prefix + self._ttl = ttl + self._lock = asyncio.Lock() + self._owns_client = False # Track if we own the Valkey client. + + # Valkey key patterns. + self._session_key = f"{self._key_prefix}:{self.session_id}" + self._messages_key = f"{self._session_key}:messages" + self._counter_key = f"{self._session_key}:counter" + + @classmethod + async def from_url( + cls, + session_id: str, + *, + url: str, + session_settings: SessionSettings | None = None, + **kwargs: Any, + ) -> ValkeySession: + """Create a session from a Valkey URL string. + + Args: + session_id (str): Conversation ID. + url (str): Valkey URL, e.g. "valkey://localhost:6379/0" or "valkeys://host:6380". + Also accepts "redis://" and "rediss://" schemes for compatibility. + Note: GlideClient does not support the ``SELECT`` command, so a non-zero + database number in the path (e.g. ``/5``) will raise ``ValueError``. + session_settings (SessionSettings | None): Session configuration settings including + default limit for retrieving items. If None, uses default SessionSettings(). + **kwargs: Additional keyword arguments forwarded to the main constructor + (e.g., key_prefix, ttl, etc.). + + Returns: + ValkeySession: An instance of ValkeySession connected to the specified Valkey server. + + Raises: + ValueError: If the URL contains a non-zero database number or an unsupported scheme. + """ + params = _parse_valkey_url(url) + + if params["db"] != 0: + raise ValueError( + f"GlideClient does not support database selection. " + f"URL specifies database {params['db']}, but only database 0 is supported. " + f"Remove the database path from the URL." + ) + + addresses = [NodeAddress(params["host"], params["port"])] + + # Build credentials when a password or username is present in the URL. + credentials = None + if params["password"]: + cred_kwargs: dict[str, Any] = {"password": params["password"]} + if params.get("username"): + cred_kwargs["username"] = params["username"] + credentials = ServerCredentials(**cred_kwargs) + + config = GlideClientConfiguration( + addresses=addresses, + use_tls=params["use_tls"], + credentials=credentials, + ) + + client = await GlideClient.create(config) + session = cls( + session_id, + valkey_client=client, + session_settings=session_settings, + **kwargs, + ) + session._owns_client = True # We created the client, so we own it. + return session + + async def _serialize_item(self, item: TResponseInputItem) -> str: + """Serialize an item to JSON string. Can be overridden by subclasses.""" + return json.dumps(item, separators=(",", ":")) + + async def _deserialize_item(self, item: str) -> TResponseInputItem: + """Deserialize a JSON string to an item. Can be overridden by subclasses.""" + return json.loads(item) # type: ignore[no-any-return] # json.loads returns Any but we know the structure + + async def _get_next_id(self) -> int: + """Get the next message ID using Valkey INCR for atomic increment.""" + result = await self._client.incr(self._counter_key) + return int(result) + + async def _set_ttl_if_configured(self, *keys: str) -> None: + """Set TTL on keys if configured, using a pipeline for efficiency.""" + if self._ttl is not None: + pipe = Batch(is_atomic=False) + for key in keys: + pipe.expire(key, self._ttl) + await self._client.exec(pipe, raise_on_error=True) + + # ------------------------------------------------------------------ + # Session protocol implementation + # ------------------------------------------------------------------ + + async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: + """Retrieve the conversation history for this session. + + Args: + limit: Maximum number of items to retrieve. If None, uses session_settings.limit. + When specified, returns the latest N items in chronological order. + + Returns: + List of input items representing the conversation history. + """ + session_limit = resolve_session_limit(limit, self.session_settings) + + async with self._lock: + if session_limit is None: + # Get all messages in chronological order. + raw_messages = await self._client.lrange(self._messages_key, 0, -1) + else: + if session_limit <= 0: + return [] + # Get the latest N messages using negative indices. + raw_messages = await self._client.lrange(self._messages_key, -session_limit, -1) + + items: list[TResponseInputItem] = [] + for raw_msg in raw_messages: + try: + # Handle both bytes (default) and str responses from the client. + if isinstance(raw_msg, bytes): + msg_str = raw_msg.decode("utf-8") + else: + msg_str = str(raw_msg) + item = await self._deserialize_item(msg_str) + items.append(item) + except (json.JSONDecodeError, UnicodeDecodeError): + # Skip corrupted messages. + continue + + return items + + async def add_items(self, items: list[TResponseInputItem]) -> None: + """Add new items to the conversation history. + + Args: + items: List of input items to add to the history. + """ + if not items: + return + + async with self._lock: + # Serialize all items. + serialized_items: list[str] = [] + for item in items: + serialized = await self._serialize_item(item) + serialized_items.append(serialized) + + now = str(int(time.time())) + + # Build a pipeline so all mutations go in a single round-trip. + pipe = Batch(is_atomic=False) + + # Set session metadata — always update updated_at, set created_at + # only when the key does not yet exist (via a separate HSETNX-style + # approach is not available in Batch, so we set both and accept that + # created_at is overwritten — same behaviour as RedisSession). + pipe.hset( + self._session_key, + { + "session_id": self.session_id, + "created_at": now, + "updated_at": now, + }, + ) + + # Add all items to the messages list. + if serialized_items: + pipe.rpush(self._messages_key, serialized_items) + + # Set TTL if configured. + if self._ttl is not None: + pipe.expire(self._session_key, self._ttl) + pipe.expire(self._messages_key, self._ttl) + pipe.expire(self._counter_key, self._ttl) + + await self._client.exec(pipe, raise_on_error=True) + + async def pop_item(self) -> TResponseInputItem | None: + """Remove and return the most recent item from the session. + + Returns: + The most recent item if it exists, None if the session is empty. + """ + async with self._lock: + # Use RPOP to atomically remove and return the rightmost (most recent) item. + raw_msg = await self._client.rpop(self._messages_key) + + if raw_msg is None: + return None + + try: + # Handle both bytes (default) and str responses from the client. + if isinstance(raw_msg, bytes): + msg_str = raw_msg.decode("utf-8") + else: + msg_str = str(raw_msg) + return await self._deserialize_item(msg_str) + except (json.JSONDecodeError, UnicodeDecodeError): + # Return None for corrupted messages (already removed). + return None + + async def clear_session(self) -> None: + """Clear all items for this session.""" + async with self._lock: + # Delete all keys associated with this session. + await self._client.delete([self._session_key, self._messages_key, self._counter_key]) + + async def close(self) -> None: + """Close the Valkey connection. + + Only closes the connection if this session owns the Valkey client + (i.e., created via from_url). If the client was injected externally, + the caller is responsible for managing its lifecycle. + """ + if self._owns_client: + await self._client.close() + + async def ping(self) -> bool: + """Test Valkey connectivity. + + Returns: + True if Valkey is reachable, False otherwise. + """ + try: + await self._client.custom_command(["PING"]) + return True + except Exception: + return False diff --git a/tests/extensions/memory/test_valkey_integration.py b/tests/extensions/memory/test_valkey_integration.py new file mode 100644 index 0000000000..c72224efa3 --- /dev/null +++ b/tests/extensions/memory/test_valkey_integration.py @@ -0,0 +1,620 @@ +"""Integration tests for ValkeySession with a real Valkey server using testcontainers. + +These tests use a ``valkey/valkey-bundle`` Docker container to exercise every +public method of ``ValkeySession`` against a live server. They are +automatically **skipped** when Docker is not available or when ``valkey-glide`` +/ ``testcontainers`` are not installed, so they never break ``make tests`` on +machines without Docker. + +Run explicitly with:: + + pytest tests/extensions/memory/test_valkey_integration.py -v +""" + +from __future__ import annotations + +import asyncio +import shutil +import sys +from collections.abc import AsyncGenerator + +import pytest +import pytest_asyncio + +pytest.importorskip("glide") # Skip when valkey-glide is not installed. +pytest.importorskip("testcontainers") # Skip when testcontainers is not installed. + +if sys.platform == "win32": + pytest.skip( + "Valkey Docker integration tests are not supported on Windows", + allow_module_level=True, + ) +if shutil.which("docker") is None: + pytest.skip( + "Docker executable is not available; skipping Valkey integration tests", + allow_module_level=True, + ) + +import docker as docker_lib # type: ignore[import-untyped] +from docker.errors import DockerException # type: ignore[import-untyped] + +try: + _client = docker_lib.from_env() + _client.ping() +except DockerException: + pytest.skip( + "Docker daemon is not available; skipping Valkey integration tests", + allow_module_level=True, + ) +else: + _client.close() + +from glide import GlideClient, GlideClientConfiguration, NodeAddress +from testcontainers.core.container import DockerContainer # type: ignore[import-untyped] +from testcontainers.core.waiting_utils import wait_for_logs # type: ignore[import-untyped] + +from agents import Agent, RunConfig, Runner, TResponseInputItem +from agents.extensions.memory.valkey_session import ValkeySession +from agents.memory import SessionSettings +from tests.fake_model import FakeModel +from tests.test_responses import get_text_message + +# Docker-backed integration tests should stay on the serial test path. +pytestmark = [pytest.mark.asyncio, pytest.mark.serial] + +VALKEY_IMAGE = "docker.io/valkey/valkey-bundle:9.1.0-rc1" +VALKEY_PORT = 6379 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def valkey_container(): + """Start a Valkey container for the whole module and tear it down after.""" + container = DockerContainer(VALKEY_IMAGE).with_exposed_ports(VALKEY_PORT) + container.start() + wait_for_logs(container, "Ready to accept connections", timeout=30) + try: + yield container + finally: + container.stop() + + +@pytest.fixture(scope="module") +def valkey_server(valkey_container) -> tuple[str, int]: + """Return (host, port) reachable from the test process.""" + host = valkey_container.get_container_host_ip() + port = int(valkey_container.get_exposed_port(VALKEY_PORT)) + return host, port + + +@pytest_asyncio.fixture(loop_scope="function") +async def glide_client(valkey_server: tuple[str, int]) -> AsyncGenerator[GlideClient]: + """Create a GlideClient connected to the Valkey container. + + Uses ``loop_scope="function"`` because GlideClient's internal Rust event + loop does not work correctly on the shared session-scoped loop that + pyproject.toml configures as the default. + """ + host, port = valkey_server + config = GlideClientConfiguration(addresses=[NodeAddress(host, port)]) + client = await GlideClient.create(config) + try: + yield client + finally: + try: + await client.close() + except Exception: + pass + + +@pytest_asyncio.fixture(loop_scope="function") +async def session(glide_client: GlideClient) -> AsyncGenerator[ValkeySession]: + """Provide a clean ValkeySession. Clears data before and after each test.""" + s = ValkeySession( + session_id="integration_test", + valkey_client=glide_client, + key_prefix="test:", + ) + await s.clear_session() + try: + yield s + finally: + await s.clear_session() + + +@pytest.fixture +def agent() -> Agent: + return Agent(name="test", model=FakeModel()) + + +# =================================================================== +# ping / connectivity +# =================================================================== + + +async def test_ping(session: ValkeySession): + """Verify that ping() returns True against a live server.""" + assert await session.ping() is True + + +# =================================================================== +# add_items / get_items +# =================================================================== + + +async def test_add_and_get_items(session: ValkeySession): + items: list[TResponseInputItem] = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + ] + await session.add_items(items) + + got = await session.get_items() + assert len(got) == 2 + assert got[0].get("content") == "Hello" + assert got[1].get("content") == "Hi there!" + + +async def test_add_empty_list_is_noop(session: ValkeySession): + await session.add_items([]) + assert await session.get_items() == [] + + +async def test_add_items_multiple_batches(session: ValkeySession): + """Items added in separate calls appear in chronological order.""" + await session.add_items([{"role": "user", "content": "first"}]) + await session.add_items([{"role": "assistant", "content": "second"}]) + await session.add_items([{"role": "user", "content": "third"}]) + + got = await session.get_items() + assert [it.get("content") for it in got] == ["first", "second", "third"] + + +# =================================================================== +# get_items with limit +# =================================================================== + + +async def test_get_items_with_limit(session: ValkeySession): + await session.add_items([{"role": "user", "content": str(i)} for i in range(6)]) + + last2 = await session.get_items(limit=2) + assert len(last2) == 2 + assert last2[0].get("content") == "4" + assert last2[1].get("content") == "5" + + +async def test_get_items_limit_zero(session: ValkeySession): + await session.add_items([{"role": "user", "content": "x"}]) + assert await session.get_items(limit=0) == [] + + +async def test_get_items_limit_exceeds_count(session: ValkeySession): + await session.add_items([{"role": "user", "content": str(i)} for i in range(3)]) + got = await session.get_items(limit=100) + assert len(got) == 3 + + +# =================================================================== +# pop_item +# =================================================================== + + +async def test_pop_item(session: ValkeySession): + await session.add_items( + [ + {"role": "user", "content": "a"}, + {"role": "assistant", "content": "b"}, + ] + ) + + popped = await session.pop_item() + assert popped is not None and popped.get("content") == "b" + + remaining = await session.get_items() + assert len(remaining) == 1 + assert remaining[0].get("content") == "a" + + +async def test_pop_from_empty_session(session: ValkeySession): + assert await session.pop_item() is None + + +async def test_pop_all_items(session: ValkeySession): + """Popping every item leaves the session empty.""" + await session.add_items( + [ + {"role": "user", "content": "x"}, + {"role": "assistant", "content": "y"}, + ] + ) + assert await session.pop_item() is not None + assert await session.pop_item() is not None + assert await session.pop_item() is None + assert await session.get_items() == [] + + +# =================================================================== +# clear_session +# =================================================================== + + +async def test_clear_session(session: ValkeySession): + await session.add_items([{"role": "user", "content": "gone"}]) + await session.clear_session() + assert await session.get_items() == [] + + +async def test_clear_empty_session(session: ValkeySession): + """Clearing an already-empty session must not raise.""" + await session.clear_session() + assert await session.get_items() == [] + + +# =================================================================== +# TTL +# =================================================================== + + +async def test_ttl_sets_expiry(glide_client: GlideClient): + """Keys should have a TTL after add_items when ttl is configured.""" + s = ValkeySession( + session_id="ttl_test", + valkey_client=glide_client, + key_prefix="test:", + ttl=60, + ) + try: + await s.clear_session() + await s.add_items([{"role": "user", "content": "expires"}]) + + ttl_val = await glide_client.ttl(s._messages_key) + assert ttl_val is not None and ttl_val > 0 + + session_ttl = await glide_client.ttl(s._session_key) + assert session_ttl is not None and session_ttl > 0 + finally: + await s.clear_session() + + +async def test_no_ttl_means_persistent(glide_client: GlideClient): + """Without ttl, keys should have no expiry (TTL == -1).""" + s = ValkeySession( + session_id="no_ttl_test", + valkey_client=glide_client, + key_prefix="test:", + ttl=None, + ) + try: + await s.clear_session() + await s.add_items([{"role": "user", "content": "forever"}]) + + ttl_val = await glide_client.ttl(s._messages_key) + assert ttl_val == -1 # -1 means no expiry. + finally: + await s.clear_session() + + +# =================================================================== +# Session isolation +# =================================================================== + + +async def test_session_id_isolation(glide_client: GlideClient): + s1 = ValkeySession(session_id="iso_a", valkey_client=glide_client, key_prefix="test:") + s2 = ValkeySession(session_id="iso_b", valkey_client=glide_client, key_prefix="test:") + try: + await s1.clear_session() + await s2.clear_session() + + await s1.add_items([{"role": "user", "content": "from a"}]) + await s2.add_items([{"role": "user", "content": "from b"}]) + + assert (await s1.get_items())[0].get("content") == "from a" + assert (await s2.get_items())[0].get("content") == "from b" + finally: + await s1.clear_session() + await s2.clear_session() + + +async def test_key_prefix_isolation(glide_client: GlideClient): + s1 = ValkeySession(session_id="same", valkey_client=glide_client, key_prefix="app1:") + s2 = ValkeySession(session_id="same", valkey_client=glide_client, key_prefix="app2:") + try: + await s1.clear_session() + await s2.clear_session() + + await s1.add_items([{"role": "user", "content": "app1"}]) + await s2.add_items([{"role": "user", "content": "app2"}]) + + assert (await s1.get_items())[0].get("content") == "app1" + assert (await s2.get_items())[0].get("content") == "app2" + finally: + await s1.clear_session() + await s2.clear_session() + + +# =================================================================== +# Data integrity — unicode, special characters +# =================================================================== + + +async def test_unicode_roundtrip(session: ValkeySession): + await session.add_items( + [ + {"role": "user", "content": "こんにちは"}, + {"role": "assistant", "content": "😊👍"}, + {"role": "user", "content": "Привет"}, + ] + ) + got = await session.get_items() + assert got[0].get("content") == "こんにちは" + assert got[1].get("content") == "😊👍" + assert got[2].get("content") == "Привет" + + +async def test_special_characters(session: ValkeySession): + payloads = [ + "O'Reilly", + '{"nested": "json"}', + 'Quote: "Hello world"', + "Line1\nLine2\tTabbed", + "Robert'); DROP TABLE students;--", + "\\n\\t\\r literal backslashes", + ] + items: list[TResponseInputItem] = [{"role": "user", "content": p} for p in payloads] + await session.add_items(items) + + got = await session.get_items() + for expected, actual in zip(payloads, got, strict=False): + assert actual.get("content") == expected + + +# =================================================================== +# Corrupted data (injected directly into Valkey) +# =================================================================== + + +async def test_get_items_skips_corrupted_data(glide_client: GlideClient): + """Corrupted entries in the list are silently skipped by get_items.""" + s = ValkeySession( + session_id="corrupt_get", + valkey_client=glide_client, + key_prefix="test:", + ) + try: + await s.clear_session() + await s.add_items([{"role": "user", "content": "valid"}]) + + # Inject garbage directly into the Valkey list. + await glide_client.rpush(s._messages_key, ["not-json", "{broken"]) + + got = await s.get_items() + assert len(got) == 1 + assert got[0].get("content") == "valid" + finally: + await s.clear_session() + + +async def test_pop_returns_none_for_corrupted_data(glide_client: GlideClient): + """pop_item returns None (not an exception) when the popped entry is garbage.""" + s = ValkeySession( + session_id="corrupt_pop", + valkey_client=glide_client, + key_prefix="test:", + ) + try: + await s.clear_session() + await glide_client.rpush(s._messages_key, ["%%%invalid%%%"]) + + assert await s.pop_item() is None + # The garbage was consumed; list should be empty now. + assert await s.get_items() == [] + finally: + await s.clear_session() + + +# =================================================================== +# Concurrent access +# =================================================================== + + +async def test_concurrent_add_items(glide_client: GlideClient): + s = ValkeySession( + session_id="concurrent", + valkey_client=glide_client, + key_prefix="test:", + ) + try: + await s.clear_session() + + async def _add(start: int, n: int) -> None: + await s.add_items([{"role": "user", "content": f"m{start + i}"} for i in range(n)]) + + await asyncio.gather(_add(0, 5), _add(5, 5), _add(10, 5)) + + got = await s.get_items() + assert len(got) == 15 + contents = {str(it.get("content")) for it in got} + assert contents == {f"m{i}" for i in range(15)} + finally: + await s.clear_session() + + +# =================================================================== +# from_url +# =================================================================== + + +async def test_from_url(valkey_server: tuple[str, int]): + """from_url should create a working session against the real server.""" + host, port = valkey_server + url = f"valkey://{host}:{port}/0" + + s = await ValkeySession.from_url("from_url_test", url=url, key_prefix="test:") + try: + assert s._owns_client is True + assert await s.ping() is True + + await s.clear_session() + await s.add_items([{"role": "user", "content": "via url"}]) + got = await s.get_items() + assert len(got) == 1 + assert got[0].get("content") == "via url" + finally: + await s.clear_session() + await s.close() + + +async def test_from_url_with_session_settings(valkey_server: tuple[str, int]): + host, port = valkey_server + url = f"valkey://{host}:{port}/0" + + s = await ValkeySession.from_url( + "from_url_ss", + url=url, + key_prefix="test:", + session_settings=SessionSettings(limit=2), + ) + try: + await s.clear_session() + await s.add_items([{"role": "user", "content": str(i)} for i in range(5)]) + + got = await s.get_items() # Should use limit=2 from settings. + assert len(got) == 2 + assert got[0].get("content") == "3" + assert got[1].get("content") == "4" + finally: + await s.clear_session() + await s.close() + + +async def test_from_url_with_ttl(valkey_server: tuple[str, int]): + host, port = valkey_server + url = f"valkey://{host}:{port}/0" + + s = await ValkeySession.from_url("from_url_ttl", url=url, key_prefix="test:", ttl=120) + try: + await s.clear_session() + await s.add_items([{"role": "user", "content": "ttl via url"}]) + + got = await s.get_items() + assert len(got) == 1 + finally: + await s.clear_session() + await s.close() + + +# =================================================================== +# Client ownership +# =================================================================== + + +async def test_close_does_not_close_external_client(glide_client: GlideClient): + """Closing a session with an external client must leave the client usable.""" + s = ValkeySession( + session_id="ext_close", + valkey_client=glide_client, + key_prefix="test:", + ) + await s.close() + + # The shared client should still work. + pong = await glide_client.custom_command(["PING"]) + assert pong == b"PONG" + + +# =================================================================== +# Runner integration (FakeModel, real Valkey) +# =================================================================== + + +async def test_runner_integration(session: ValkeySession, agent: Agent): + model = agent.model + assert isinstance(model, FakeModel) + + model.set_next_output([get_text_message("San Francisco")]) + r1 = await Runner.run(agent, "Where is the Golden Gate Bridge?", session=session) + assert r1.final_output == "San Francisco" + + model.set_next_output([get_text_message("California")]) + r2 = await Runner.run(agent, "What state?", session=session) + assert r2.final_output == "California" + + last_input = model.last_turn_args["input"] + assert any("Golden Gate Bridge" in str(it.get("content", "")) for it in last_input) + + +async def test_runner_session_settings_override(glide_client: GlideClient): + s = ValkeySession( + session_id="runner_ss", + valkey_client=glide_client, + key_prefix="test:", + session_settings=SessionSettings(limit=100), + ) + try: + await s.clear_session() + await s.add_items([{"role": "user", "content": f"Turn {i}"} for i in range(10)]) + + model = FakeModel() + ag = Agent(name="test", model=model) + model.set_next_output([get_text_message("Got it")]) + + await Runner.run( + ag, + "New question", + session=s, + run_config=RunConfig(session_settings=SessionSettings(limit=2)), + ) + + history = [ + it for it in model.last_turn_args["input"] if it.get("content") != "New question" + ] + assert len(history) == 2 + finally: + await s.clear_session() + + +# =================================================================== +# SessionSettings with real Valkey +# =================================================================== + + +async def test_session_settings_limit(glide_client: GlideClient): + s = ValkeySession( + session_id="ss_limit", + valkey_client=glide_client, + key_prefix="test:", + session_settings=SessionSettings(limit=3), + ) + try: + await s.clear_session() + await s.add_items([{"role": "user", "content": str(i)} for i in range(5)]) + + got = await s.get_items() + assert len(got) == 3 + assert got[0].get("content") == "2" + finally: + await s.clear_session() + + +async def test_explicit_limit_overrides_settings(glide_client: GlideClient): + s = ValkeySession( + session_id="ss_override", + valkey_client=glide_client, + key_prefix="test:", + session_settings=SessionSettings(limit=10), + ) + try: + await s.clear_session() + await s.add_items([{"role": "user", "content": str(i)} for i in range(10)]) + + got = await s.get_items(limit=2) + assert len(got) == 2 + assert got[0].get("content") == "8" + finally: + await s.clear_session() diff --git a/tests/extensions/memory/test_valkey_session.py b/tests/extensions/memory/test_valkey_session.py new file mode 100644 index 0000000000..a0d1acd7e2 --- /dev/null +++ b/tests/extensions/memory/test_valkey_session.py @@ -0,0 +1,596 @@ +"""Unit tests for ValkeySession. + +These tests verify the ValkeySession logic using a lightweight mock client +that stores data in plain Python dicts. They do NOT test the real +valkey-glide wire protocol — see ``test_valkey_integration.py`` for that. + +The mock is intentionally thin: each Valkey command is backed by a simple +in-memory implementation so that the *session* logic (serialisation, +key layout, TTL calls, ownership tracking, etc.) is exercised against +realistic return values without over-mocking individual call sites. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +pytest.importorskip("glide") # Skip the whole module when valkey-glide is absent. + +from agents import Agent, Runner, TResponseInputItem +from agents.extensions.memory.valkey_session import ValkeySession, _parse_valkey_url +from agents.memory import SessionSettings +from tests.fake_model import FakeModel +from tests.test_responses import get_text_message + +# Serial marker keeps these off the xdist parallel workers. +pytestmark = [pytest.mark.asyncio, pytest.mark.serial] + + +# --------------------------------------------------------------------------- +# Lightweight in-memory Valkey stub +# --------------------------------------------------------------------------- + + +def _make_stub_client() -> AsyncMock: + """Return an ``AsyncMock`` that behaves like a minimal ``GlideClient``. + + Every Valkey command used by ``ValkeySession`` is backed by a trivial + Python implementation so that the session's own logic is the thing + under test — not the mock wiring. + + Pipeline (``Batch``) execution is handled by replaying the batch's + recorded commands against the same in-memory store. The opcode + mapping is intentionally minimal — only the commands that + ``ValkeySession`` actually pipelines are supported. + """ + client = AsyncMock() + store: dict[str, list[bytes]] = {} + hashes: dict[str, dict[str, str]] = {} + + def _do_rpush(key: str, values: list[str]) -> int: + store.setdefault(key, []) + for v in values: + store[key].append(v.encode()) + return len(store[key]) + + def _do_hset(key: str, mapping: dict[str, str]) -> int: + hashes.setdefault(key, {}).update(mapping) + return len(mapping) + + def _do_expire(_key: str, _seconds: int) -> bool: + return True + + # -- async wrappers for commands called outside of pipelines ------ + + async def _lrange(key: str, start: int, end: int) -> list[bytes]: + lst = store.get(key, []) + if not lst: + return [] + n = len(lst) + if start < 0: + start = max(n + start, 0) + if end < 0: + end = n + end + return lst[start : end + 1] + + async def _rpop(key: str) -> bytes | None: + lst = store.get(key, []) + return lst.pop() if lst else None + + async def _delete(keys: list[str]) -> int: + removed = 0 + for k in keys: + removed += int(k in store) + int(k in hashes) + store.pop(k, None) + hashes.pop(k, None) + return removed + + async def _incr(key: str) -> int: + tag = f"__ctr__{key}" + store.setdefault(tag, [b"0"]) + val = int(store[tag][0]) + 1 + store[tag] = [str(val).encode()] + return val + + async def _custom_command(args: list[str]) -> str: + return "PONG" if args and args[0].upper() == "PING" else "OK" + + # -- batch (pipeline) replay -------------------------------------- + # Batch.commands is a list of (opcode, [arg, ...]) tuples. + # We only map the three opcodes ValkeySession actually pipelines. + + from glide import Batch + + _sample = Batch(is_atomic=False) + _sample.hset("_", {"_": "_"}) + _OP_HSET = _sample.commands[0][0] + _sample.clear() + _sample.rpush("_", ["_"]) + _OP_RPUSH = _sample.commands[0][0] + _sample.clear() + _sample.expire("_", 1) + _OP_EXPIRE = _sample.commands[0][0] + del _sample + + async def _exec(batch: Any, raise_on_error: bool = True) -> list[Any]: + results: list[Any] = [] + for opcode, args in batch.commands: + if opcode == _OP_HSET: + key, fields = args[0], dict(zip(args[1::2], args[2::2], strict=False)) + results.append(_do_hset(key, fields)) + elif opcode == _OP_RPUSH: + results.append(_do_rpush(args[0], list(args[1:]))) + elif opcode == _OP_EXPIRE: + results.append(_do_expire(args[0], int(args[1]))) + else: + results.append(None) + return results + + client.lrange = AsyncMock(side_effect=_lrange) + client.rpop = AsyncMock(side_effect=_rpop) + client.delete = AsyncMock(side_effect=_delete) + client.incr = AsyncMock(side_effect=_incr) + client.custom_command = AsyncMock(side_effect=_custom_command) + client.exec = AsyncMock(side_effect=_exec) + client.close = AsyncMock() + + # Expose internals so tests can inject corrupted data. + client._store = store + client._hashes = hashes + return client + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def stub() -> AsyncMock: + """Fresh stub client for every test.""" + return _make_stub_client() + + +@pytest.fixture +def agent() -> Agent: + return Agent(name="test", model=FakeModel()) + + +def _session( + session_id: str, + client: AsyncMock, + *, + key_prefix: str = "test:", + ttl: int | None = None, + session_settings: SessionSettings | None = None, +) -> ValkeySession: + """Shorthand factory — no async needed for the constructor path.""" + return ValkeySession( + session_id=session_id, + valkey_client=client, + key_prefix=key_prefix, + ttl=ttl, + session_settings=session_settings, + ) + + +# =================================================================== +# Core CRUD +# =================================================================== + + +async def test_add_get_pop_clear(stub: AsyncMock): + """Full lifecycle: add → get → pop → clear.""" + s = _session("crud", stub) + + items: list[TResponseInputItem] = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + ] + await s.add_items(items) + + got = await s.get_items() + assert len(got) == 2 + assert got[0].get("content") == "Hello" + assert got[1].get("content") == "Hi there!" + + popped = await s.pop_item() + assert popped is not None and popped.get("content") == "Hi there!" + assert len(await s.get_items()) == 1 + + await s.clear_session() + assert await s.get_items() == [] + + +async def test_add_empty_list_is_noop(stub: AsyncMock): + """Adding an empty list must not touch Valkey at all.""" + s = _session("noop", stub) + await s.add_items([]) + # No pipeline should have been executed. + stub.exec.assert_not_called() + + +async def test_pop_from_empty_returns_none(stub: AsyncMock): + s = _session("empty", stub) + assert await s.pop_item() is None + + +# =================================================================== +# Limit / SessionSettings +# =================================================================== + + +async def test_get_items_with_explicit_limit(stub: AsyncMock): + s = _session("limit", stub) + await s.add_items([{"role": "user", "content": str(i)} for i in range(6)]) + + last3 = await s.get_items(limit=3) + assert [it.get("content") for it in last3] == ["3", "4", "5"] + + all_items = await s.get_items() + assert len(all_items) == 6 + + assert await s.get_items(limit=0) == [] + assert len(await s.get_items(limit=100)) == 6 # More than available. + + +async def test_session_settings_limit_used_as_default(stub: AsyncMock): + s = _session("ss", stub, session_settings=SessionSettings(limit=2)) + await s.add_items([{"role": "user", "content": str(i)} for i in range(5)]) + + got = await s.get_items() # No explicit limit → uses settings. + assert len(got) == 2 + assert got[0].get("content") == "3" + assert got[1].get("content") == "4" + + +async def test_explicit_limit_overrides_session_settings(stub: AsyncMock): + s = _session("override", stub, session_settings=SessionSettings(limit=10)) + await s.add_items([{"role": "user", "content": str(i)} for i in range(10)]) + + got = await s.get_items(limit=2) + assert len(got) == 2 + assert got[0].get("content") == "8" + + +# =================================================================== +# TTL +# =================================================================== + + +async def test_ttl_session_uses_pipeline(stub: AsyncMock): + """When ttl is set, add_items must call exec (pipeline) and the data must land.""" + s = _session("ttl", stub, ttl=300) + await s.add_items([{"role": "user", "content": "hi"}]) + + # The pipeline was executed. + stub.exec.assert_called() + # And the data actually landed in the store. + got = await s.get_items() + assert len(got) == 1 + assert got[0].get("content") == "hi" + + +async def test_no_ttl_session_still_works(stub: AsyncMock): + """Without ttl the pipeline still executes and data lands correctly.""" + s = _session("nottl", stub, ttl=None) + await s.add_items([{"role": "user", "content": "hi"}]) + + got = await s.get_items() + assert len(got) == 1 + + +# =================================================================== +# Key isolation +# =================================================================== + + +async def test_different_session_ids_are_isolated(stub: AsyncMock): + s1 = _session("a", stub) + s2 = _session("b", stub) + + await s1.add_items([{"role": "user", "content": "from a"}]) + await s2.add_items([{"role": "user", "content": "from b"}]) + + assert len(await s1.get_items()) == 1 + assert (await s1.get_items())[0].get("content") == "from a" + assert (await s2.get_items())[0].get("content") == "from b" + + +async def test_different_key_prefixes_are_isolated(stub: AsyncMock): + s1 = _session("same", stub, key_prefix="app1") + s2 = _session("same", stub, key_prefix="app2") + + await s1.add_items([{"role": "user", "content": "app1"}]) + await s2.add_items([{"role": "user", "content": "app2"}]) + + assert (await s1.get_items())[0].get("content") == "app1" + assert (await s2.get_items())[0].get("content") == "app2" + + +# =================================================================== +# Client ownership & close() +# =================================================================== + + +async def test_external_client_not_closed(stub: AsyncMock): + """When the caller provides the client, close() must not shut it down.""" + s = _session("ext", stub) + assert s._owns_client is False + await s.close() + stub.close.assert_not_called() + + +async def test_owned_client_closed(): + """Clients created via from_url must be closed when the session is closed.""" + with ( + patch("agents.extensions.memory.valkey_session.GlideClient") as MockGlide, + patch("agents.extensions.memory.valkey_session.ServerCredentials"), + ): + mock = _make_stub_client() + MockGlide.create = AsyncMock(return_value=mock) + + s = await ValkeySession.from_url("owned", url="valkey://localhost:6379/0") + assert s._owns_client is True + await s.close() + mock.close.assert_called_once() + + +# =================================================================== +# ping() +# =================================================================== + + +async def test_ping_success(stub: AsyncMock): + s = _session("ping", stub) + assert await s.ping() is True + + +async def test_ping_failure(stub: AsyncMock): + s = _session("ping_fail", stub) + stub.custom_command = AsyncMock(side_effect=ConnectionError("gone")) + assert await s.ping() is False + + +# =================================================================== +# Corrupted data handling +# =================================================================== + + +async def test_get_items_skips_corrupted_json(stub: AsyncMock): + s = _session("corrupt_get", stub) + await s.add_items([{"role": "user", "content": "ok"}]) + + # Inject garbage directly into the backing store. + stub._store[s._messages_key].append(b"not json") + stub._store[s._messages_key].append(b"{broken") + + items = await s.get_items() + assert len(items) == 1 + assert items[0].get("content") == "ok" + + +async def test_pop_returns_none_for_corrupted_item(stub: AsyncMock): + s = _session("corrupt_pop", stub) + # Push raw garbage — pop should return None, not raise. + stub._store.setdefault(s._messages_key, []).append(b"%%%") + assert await s.pop_item() is None + + +# =================================================================== +# Data integrity — unicode, special chars +# =================================================================== + + +async def test_unicode_roundtrip(stub: AsyncMock): + s = _session("uni", stub) + await s.add_items( + [ + {"role": "user", "content": "こんにちは"}, + {"role": "assistant", "content": "😊👍"}, + {"role": "user", "content": "Привет"}, + ] + ) + got = await s.get_items() + assert got[0].get("content") == "こんにちは" + assert got[1].get("content") == "😊👍" + assert got[2].get("content") == "Привет" + + +async def test_special_characters_roundtrip(stub: AsyncMock): + payloads = [ + "O'Reilly", + '{"nested": "json"}', + 'Quote: "Hello world"', + "Line1\nLine2\tTabbed", + "Robert'); DROP TABLE students;--", + "\\n\\t\\r literal backslashes", + ] + s = _session("special", stub) + items: list[TResponseInputItem] = [{"role": "user", "content": p} for p in payloads] + await s.add_items(items) + + got = await s.get_items() + for expected, actual in zip(payloads, got, strict=False): + assert actual.get("content") == expected + + +# =================================================================== +# Concurrent access +# =================================================================== + + +async def test_concurrent_add_items(stub: AsyncMock): + import asyncio + + s = _session("conc", stub) + + async def _add(start: int, n: int) -> None: + await s.add_items([{"role": "user", "content": f"m{start + i}"} for i in range(n)]) + + await asyncio.gather(_add(0, 5), _add(5, 5), _add(10, 5)) + + got = await s.get_items() + assert len(got) == 15 + contents = {str(it.get("content")) for it in got} + assert contents == {f"m{i}" for i in range(15)} + + +# =================================================================== +# Runner integration (with FakeModel, no real LLM) +# =================================================================== + + +async def test_runner_preserves_history(stub: AsyncMock, agent: Agent): + s = _session("runner", stub) + model = agent.model + assert isinstance(model, FakeModel) + + model.set_next_output([get_text_message("San Francisco")]) + r1 = await Runner.run(agent, "Where is the Golden Gate Bridge?", session=s) + assert r1.final_output == "San Francisco" + + model.set_next_output([get_text_message("California")]) + r2 = await Runner.run(agent, "What state?", session=s) + assert r2.final_output == "California" + + # The second turn should have received the first turn's history. + last_input = model.last_turn_args["input"] + assert any("Golden Gate Bridge" in str(it.get("content", "")) for it in last_input) + + +async def test_runner_session_settings_override(stub: AsyncMock): + from agents import RunConfig + + s = _session("run_ss", stub, session_settings=SessionSettings(limit=100)) + await s.add_items([{"role": "user", "content": f"Turn {i}"} for i in range(10)]) + + model = FakeModel() + ag = Agent(name="test", model=model) + model.set_next_output([get_text_message("Got it")]) + + await Runner.run( + ag, + "New question", + session=s, + run_config=RunConfig(session_settings=SessionSettings(limit=2)), + ) + + history = [it for it in model.last_turn_args["input"] if it.get("content") != "New question"] + assert len(history) == 2 + + +# =================================================================== +# from_url / URL parsing +# =================================================================== + + +async def test_from_url_creates_session(): + with ( + patch("agents.extensions.memory.valkey_session.GlideClient") as MockGlide, + patch("agents.extensions.memory.valkey_session.ServerCredentials"), + ): + MockGlide.create = AsyncMock(return_value=_make_stub_client()) + s = await ValkeySession.from_url("u", url="valkey://localhost:6379/0") + assert s.session_id == "u" + assert s._owns_client is True + await s.close() + + +async def test_from_url_forwards_session_settings(): + with ( + patch("agents.extensions.memory.valkey_session.GlideClient") as MockGlide, + patch("agents.extensions.memory.valkey_session.ServerCredentials"), + ): + MockGlide.create = AsyncMock(return_value=_make_stub_client()) + s = await ValkeySession.from_url( + "ss", url="valkey://h:6379/0", session_settings=SessionSettings(limit=7) + ) + assert s.session_settings is not None and s.session_settings.limit == 7 + await s.close() + + +async def test_from_url_with_username(): + """from_url should forward the username to ServerCredentials for ACL auth.""" + with ( + patch("agents.extensions.memory.valkey_session.GlideClient") as MockGlide, + patch("agents.extensions.memory.valkey_session.ServerCredentials") as MockCreds, + ): + MockGlide.create = AsyncMock(return_value=_make_stub_client()) + s = await ValkeySession.from_url("u", url="valkey://alice:secret@localhost:6379/0") + MockCreds.assert_called_once_with(password="secret", username="alice") + assert s._owns_client is True + await s.close() + + +async def test_from_url_rejects_nonzero_db(): + """from_url should raise ValueError when the URL specifies a non-zero database.""" + with pytest.raises(ValueError, match="does not support database selection"): + await ValkeySession.from_url("u", url="valkey://localhost:6379/5") + + +def test_parse_url_basic(): + r = _parse_valkey_url("valkey://localhost:6379/0") + assert (r["host"], r["port"], r["db"]) == ("localhost", 6379, 0) + assert r["password"] is None and r["use_tls"] is False + assert r["username"] is None + + +def test_parse_url_password_and_db(): + r = _parse_valkey_url("valkey://:secret@host:6380/5") + assert r["password"] == "secret" + assert r["host"] == "host" + assert r["port"] == 6380 + assert r["db"] == 5 + + +def test_parse_url_username_and_password(): + r = _parse_valkey_url("valkey://alice:secret@host:6379/0") + assert r["username"] == "alice" + assert r["password"] == "secret" + assert r["host"] == "host" + + +def test_parse_url_tls_schemes(): + assert _parse_valkey_url("valkeys://h:6379/0")["use_tls"] is True + assert _parse_valkey_url("rediss://h:6379/0")["use_tls"] is True + assert _parse_valkey_url("redis://h:6379/0")["use_tls"] is False + + +def test_parse_url_defaults(): + r = _parse_valkey_url("valkey://myhost") + assert (r["host"], r["port"], r["db"]) == ("myhost", 6379, 0) + + +def test_parse_url_invalid_scheme(): + with pytest.raises(ValueError, match="Unsupported URL scheme"): + _parse_valkey_url("http://localhost:6379/0") + with pytest.raises(ValueError, match="Unsupported URL scheme"): + _parse_valkey_url("ftp://localhost:6379/0") + + +# =================================================================== +# _get_next_id (internal helper) +# =================================================================== + + +async def test_get_next_id_sequential(stub: AsyncMock): + s = _session("ctr", stub) + assert await s._get_next_id() == 1 + assert await s._get_next_id() == 2 + assert await s._get_next_id() == 3 + + +# =================================================================== +# SessionSettings.resolve (pure logic, no Valkey) +# =================================================================== + + +def test_session_settings_resolve(): + base = SessionSettings(limit=100) + assert base.resolve(SessionSettings(limit=50)).limit == 50 + assert base.resolve(None).limit == 100 + assert base.limit == 100 # Original unchanged. diff --git a/uv.lock b/uv.lock index bca6f19282..d57a06d979 100644 --- a/uv.lock +++ b/uv.lock @@ -1350,16 +1350,16 @@ wheels = [ [[package]] name = "grpcio-status" -version = "1.67.1" +version = "1.76.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/c7/fe0e79a80ac6346e0c6c0a24e9e3cbc3ae1c2a009acffb59eab484a6f69b/grpcio_status-1.67.1.tar.gz", hash = "sha256:2bf38395e028ceeecfd8866b081f61628114b384da7d51ae064ddc8d766a5d11", size = 13673, upload-time = "2024-10-29T06:30:21.787Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/46/e9f19d5be65e8423f886813a2a9d0056ba94757b0c5007aa59aed1a961fa/grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd", size = 13679, upload-time = "2025-10-21T16:28:52.545Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/18/56999a1da3577d8ccc8698a575d6638e15fe25650cc88b2ce0a087f180b9/grpcio_status-1.67.1-py3-none-any.whl", hash = "sha256:16e6c085950bdacac97c779e6a502ea671232385e6e37f258884d6883392c2bd", size = 14427, upload-time = "2024-10-29T06:27:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cc/27ba60ad5a5f2067963e6a858743500df408eb5855e98be778eaef8c9b02/grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18", size = 14425, upload-time = "2025-10-21T16:28:40.853Z" }, ] [[package]] @@ -2502,6 +2502,9 @@ temporal = [ { name = "temporalio" }, { name = "textual" }, ] +valkey = [ + { name = "valkey-glide" }, +] vercel = [ { name = "vercel" }, ] @@ -2580,12 +2583,13 @@ requires-dist = [ { name = "textual", marker = "extra == 'temporal'", specifier = ">=8.2.3,<8.3" }, { name = "types-requests", specifier = ">=2.0,<3" }, { name = "typing-extensions", specifier = ">=4.12.2,<5" }, + { name = "valkey-glide", marker = "extra == 'valkey'", specifier = ">=2.1" }, { name = "vercel", marker = "extra == 'vercel'", specifier = ">=0.5.6,<0.6" }, { name = "websockets", specifier = ">=15.0,<17" }, { name = "websockets", marker = "extra == 'realtime'", specifier = ">=15.0,<17" }, { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<17" }, ] -provides-extras = ["voice", "viz", "litellm", "any-llm", "realtime", "sqlalchemy", "encrypt", "redis", "dapr", "mongodb", "docker", "blaxel", "daytona", "cloudflare", "e2b", "modal", "runloop", "vercel", "s3", "temporal"] +provides-extras = ["voice", "viz", "litellm", "any-llm", "realtime", "sqlalchemy", "encrypt", "redis", "valkey", "dapr", "mongodb", "docker", "blaxel", "daytona", "cloudflare", "e2b", "modal", "runloop", "vercel", "s3", "temporal"] [package.metadata.requires-dev] dev = [ @@ -2910,16 +2914,17 @@ wheels = [ [[package]] name = "protobuf" -version = "5.29.5" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/29/d09e70352e4e88c9c7a198d5645d7277811448d76c23b00345670f7c8a38/protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84", size = 425226, upload-time = "2025-05-28T23:51:59.82Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/11/6e40e9fc5bba02988a214c07cf324595789ca7820160bfd1f8be96e48539/protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079", size = 422963, upload-time = "2025-05-28T23:51:41.204Z" }, - { url = "https://files.pythonhosted.org/packages/81/7f/73cefb093e1a2a7c3ffd839e6f9fcafb7a427d300c7f8aef9c64405d8ac6/protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc", size = 434818, upload-time = "2025-05-28T23:51:44.297Z" }, - { url = "https://files.pythonhosted.org/packages/dd/73/10e1661c21f139f2c6ad9b23040ff36fee624310dc28fba20d33fdae124c/protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671", size = 418091, upload-time = "2025-05-28T23:51:45.907Z" }, - { url = "https://files.pythonhosted.org/packages/6c/04/98f6f8cf5b07ab1294c13f34b4e69b3722bb609c5b701d6c169828f9f8aa/protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015", size = 319824, upload-time = "2025-05-28T23:51:47.545Z" }, - { url = "https://files.pythonhosted.org/packages/85/e4/07c80521879c2d15f321465ac24c70efe2381378c00bf5e56a0f4fbac8cd/protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61", size = 319942, upload-time = "2025-05-28T23:51:49.11Z" }, - { url = "https://files.pythonhosted.org/packages/7e/cc/7e77861000a0691aeea8f4566e5d3aa716f2b1dece4a24439437e41d3d25/protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5", size = 172823, upload-time = "2025-05-28T23:51:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] @@ -4291,6 +4296,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, ] +[[package]] +name = "valkey-glide" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "protobuf" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +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 = "vercel" version = "0.5.6"