diff --git a/pyproject.toml b/pyproject.toml index b016977639..4541991a0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ sqlalchemy = ["SQLAlchemy>=2.0", "asyncpg>=0.29.0"] encrypt = ["cryptography>=45.0, <46"] redis = ["redis>=7"] dapr = ["dapr>=1.16.0", "grpcio>=1.60.0"] +mongodb = ["pymongo>=4.14"] docker = ["docker>=6.1"] blaxel = ["blaxel>=0.2.50", "aiohttp>=3.12,<4"] daytona = ["daytona>=0.155.0"] @@ -90,6 +91,7 @@ dev = [ "grpcio>=1.60.0", "testcontainers==4.12.0", # pinned to 4.12.0 because 4.13.0 has a warning bug in wait_for_logs, see https://github.com/testcontainers/testcontainers-python/issues/874 "pyright==1.1.408", + "pymongo>=4.14", ] [tool.uv.workspace] diff --git a/src/agents/extensions/memory/__init__.py b/src/agents/extensions/memory/__init__.py index 2c7d268a76..7d0437fa00 100644 --- a/src/agents/extensions/memory/__init__.py +++ b/src/agents/extensions/memory/__init__.py @@ -19,6 +19,7 @@ DaprSession, ) from .encrypt_session import EncryptedSession + from .mongodb_session import MongoDBSession from .redis_session import RedisSession from .sqlalchemy_session import SQLAlchemySession @@ -29,6 +30,7 @@ "DAPR_CONSISTENCY_STRONG", "DaprSession", "EncryptedSession", + "MongoDBSession", "RedisSession", "SQLAlchemySession", ] @@ -117,4 +119,15 @@ def __getattr__(name: str) -> Any: "Install it with: pip install openai-agents[dapr]" ) from e + if name == "MongoDBSession": + try: + from .mongodb_session import MongoDBSession # noqa: F401 + + return MongoDBSession + except ModuleNotFoundError as e: + raise ImportError( + "MongoDBSession requires the 'mongodb' extra. " + "Install it with: pip install openai-agents[mongodb]" + ) from e + raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/src/agents/extensions/memory/mongodb_session.py b/src/agents/extensions/memory/mongodb_session.py new file mode 100644 index 0000000000..20c7c5f030 --- /dev/null +++ b/src/agents/extensions/memory/mongodb_session.py @@ -0,0 +1,373 @@ +"""MongoDB-powered Session backend. + +Requires ``pymongo>=4.14``, which ships the native async API +(``AsyncMongoClient``). Install it with:: + + pip install openai-agents[mongodb] + +Usage:: + + from agents.extensions.memory import MongoDBSession + + # Create from MongoDB URI + session = MongoDBSession.from_uri( + session_id="user-123", + uri="mongodb://localhost:27017", + database="agents", + ) + + # Or pass an existing AsyncMongoClient that your application already manages + from pymongo.asynchronous.mongo_client import AsyncMongoClient + + client = AsyncMongoClient("mongodb://localhost:27017") + session = MongoDBSession( + session_id="user-123", + client=client, + database="agents", + ) + + await Runner.run(agent, "Hello", session=session) +""" + +from __future__ import annotations + +import json +import threading +import weakref +from typing import Any + +try: + from importlib.metadata import version as _get_version + + _VERSION: str | None = _get_version("openai-agents") +except Exception: + _VERSION = None + +try: + from pymongo.asynchronous.collection import AsyncCollection + from pymongo.asynchronous.mongo_client import AsyncMongoClient + from pymongo.driver_info import DriverInfo +except ImportError as e: + raise ImportError( + "MongoDBSession requires the 'pymongo' package (>=4.14). " + "Install it with: pip install openai-agents[mongodb]" + ) from e + +from ...items import TResponseInputItem +from ...memory.session import SessionABC +from ...memory.session_settings import SessionSettings, resolve_session_limit + +# Identifies this library in the MongoDB handshake for server-side telemetry. +_DRIVER_INFO = DriverInfo(name="openai-agents", version=_VERSION) + + +class MongoDBSession(SessionABC): + """MongoDB implementation of :pyclass:`agents.memory.session.Session`. + + Conversation items are stored as individual documents in a ``messages`` + collection. A lightweight ``sessions`` collection tracks metadata + (creation time, last-updated time) for each session. + + Indexes are created once per ``(client, database, sessions_collection, + messages_collection)`` combination on the first call to any of the + session protocol methods. Subsequent calls skip the setup entirely. + + Each message document carries a ``seq`` field — an integer assigned by + atomically incrementing a counter on the session metadata document. This + guarantees a strictly monotonic insertion order that is safe across + multiple writers and processes, unlike sorting by ``_id`` / ObjectId which + is only second-level accurate and non-monotonic across machines. + """ + + # Class-level registry so index creation runs only once per unique + # (client, database, sessions_collection, messages_collection) combination. + # + # Design notes: + # - Keyed on id(client) so two distinct AsyncMongoClient objects that happen + # to compare equal (same host/port) never share a cache entry. A + # weakref.finalize callback removes the entry when the client is GC'd, + # preventing stale id() values from being reused by a future client. + # - Only a threading.Lock (never an asyncio.Lock) touches the registry. + # asyncio.Lock is bound to the event loop that first acquires it; reusing + # one across loops raises RuntimeError. create_index is idempotent, so + # we only need the threading lock to guard the boolean done flag — no + # async coordination is required. + _init_state: dict[int, dict[tuple[str, str, str], bool]] = {} + _init_guard: threading.Lock = threading.Lock() + + session_settings: SessionSettings | None = None + + def __init__( + self, + session_id: str, + *, + client: AsyncMongoClient[Any], + database: str = "agents", + sessions_collection: str = "agent_sessions", + messages_collection: str = "agent_messages", + session_settings: SessionSettings | None = None, + ): + """Initialize a new MongoDBSession. + + Args: + session_id: Unique identifier for the conversation. + client: A pre-configured ``AsyncMongoClient`` instance. + database: Name of the MongoDB database to use. + Defaults to ``"agents"``. + sessions_collection: Name of the collection that stores session + metadata. Defaults to ``"agent_sessions"``. + messages_collection: Name of the collection that stores individual + conversation items. Defaults to ``"agent_messages"``. + session_settings: Optional session configuration. When ``None`` a + default :class:`~agents.memory.session_settings.SessionSettings` + is used (no item limit). + """ + self.session_id = session_id + self.session_settings = session_settings or SessionSettings() + self._client = client + self._owns_client = False + + client.append_metadata(_DRIVER_INFO) + + db = client[database] + self._sessions: AsyncCollection[Any] = db[sessions_collection] + self._messages: AsyncCollection[Any] = db[messages_collection] + + self._client_id = id(client) + self._init_sub_key = (database, sessions_collection, messages_collection) + + # ------------------------------------------------------------------ + # Convenience constructors + # ------------------------------------------------------------------ + + @classmethod + def from_uri( + cls, + session_id: str, + *, + uri: str, + database: str = "agents", + client_kwargs: dict[str, Any] | None = None, + session_settings: SessionSettings | None = None, + **kwargs: Any, + ) -> MongoDBSession: + """Create a session from a MongoDB URI string. + + Args: + session_id: Conversation ID. + uri: MongoDB connection URI, + e.g. ``"mongodb://localhost:27017"`` or + ``"mongodb+srv://user:pass@cluster.example.com"``. + database: Name of the MongoDB database to use. + client_kwargs: Additional keyword arguments forwarded to + :class:`pymongo.asynchronous.mongo_client.AsyncMongoClient`. + session_settings: Optional session configuration settings. + **kwargs: Additional keyword arguments forwarded to the main + constructor (e.g. ``sessions_collection``, + ``messages_collection``). + + Returns: + A :class:`MongoDBSession` connected to the specified MongoDB server. + """ + client_kwargs = client_kwargs or {} + client_kwargs.setdefault("driver", _DRIVER_INFO) + client: AsyncMongoClient[Any] = AsyncMongoClient(uri, **client_kwargs) + session = cls( + session_id, + client=client, + database=database, + session_settings=session_settings, + **kwargs, + ) + session._owns_client = True + return session + + # ------------------------------------------------------------------ + # Index initialisation + # ------------------------------------------------------------------ + + def _is_init_done(self) -> bool: + """Return True if indexes have already been created for this (client, sub_key).""" + with self._init_guard: + per_client = self._init_state.get(self._client_id) + return per_client is not None and per_client.get(self._init_sub_key, False) + + def _mark_init_done(self) -> None: + """Record that index creation is complete for this (client, sub_key).""" + with self._init_guard: + per_client = self._init_state.get(self._client_id) + if per_client is None: + per_client = {} + self._init_state[self._client_id] = per_client + # Register the cleanup finalizer exactly once per client identity, + # not once per session, to avoid unbounded growth when many + # sessions share a single long-lived client. + weakref.finalize(self._client, self._init_state.pop, self._client_id, None) + per_client[self._init_sub_key] = True + + async def _ensure_indexes(self) -> None: + """Create required indexes the first time this (client, sub_key) is accessed. + + ``create_index`` is idempotent on the server side, so concurrent calls + from different coroutines or event loops are safe — at most a redundant + round-trip is issued. The threading-lock-guarded boolean prevents that + extra round-trip after the first call completes. + """ + if self._is_init_done(): + return + + # sessions: unique index on session_id. + await self._sessions.create_index("session_id", unique=True) + + # messages: compound index for efficient per-session retrieval and + # sorting by the explicit seq counter. + await self._messages.create_index([("session_id", 1), ("seq", 1)]) + + self._mark_init_done() + + # ------------------------------------------------------------------ + # Serialization helpers + # ------------------------------------------------------------------ + + async def _serialize_item(self, item: TResponseInputItem) -> str: + """Serialize an item to a JSON string. Can be overridden by subclasses.""" + return json.dumps(item, separators=(",", ":")) + + async def _deserialize_item(self, raw: str) -> TResponseInputItem: + """Deserialize a JSON string to an item. Can be overridden by subclasses.""" + return json.loads(raw) # type: ignore[no-any-return] + + # ------------------------------------------------------------------ + # 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. When ``None``, the + effective limit is taken from :attr:`session_settings`. + If that is also ``None``, all items are returned. + The returned list is always in chronological (oldest-first) + order. + + Returns: + List of input items representing the conversation history. + """ + await self._ensure_indexes() + + session_limit = resolve_session_limit(limit, self.session_settings) + + if session_limit is not None and session_limit <= 0: + return [] + + query = {"session_id": self.session_id} + + if session_limit is None: + cursor = self._messages.find(query).sort("seq", 1) + docs = await cursor.to_list() + else: + # Fetch the latest N documents in reverse order, then reverse the + # list to restore chronological order. + cursor = self._messages.find(query).sort("seq", -1).limit(session_limit) + docs = await cursor.to_list() + docs.reverse() + + items: list[TResponseInputItem] = [] + for doc in docs: + try: + items.append(await self._deserialize_item(doc["message_data"])) + except (json.JSONDecodeError, KeyError, TypeError): + # Skip corrupted or malformed documents (including non-string BSON values). + 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 append to the session. + """ + if not items: + return + + await self._ensure_indexes() + + # Atomically reserve a block of sequence numbers for this batch. + # $inc returns the new value, so subtract len(items) to get the first + # number in the block. + result = await self._sessions.find_one_and_update( + {"session_id": self.session_id}, + { + "$setOnInsert": {"session_id": self.session_id}, + "$inc": {"_seq": len(items)}, + }, + upsert=True, + return_document=True, + ) + next_seq: int = (result["_seq"] if result else len(items)) - len(items) + + payload = [ + { + "session_id": self.session_id, + "seq": next_seq + i, + "message_data": await self._serialize_item(item), + } + for i, item in enumerate(items) + ] + + await self._messages.insert_many(payload, ordered=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. + """ + await self._ensure_indexes() + + doc = await self._messages.find_one_and_delete( + {"session_id": self.session_id}, + sort=[("seq", -1)], + ) + + if doc is None: + return None + + try: + return await self._deserialize_item(doc["message_data"]) + except (json.JSONDecodeError, KeyError, TypeError): + return None + + async def clear_session(self) -> None: + """Clear all items for this session.""" + await self._ensure_indexes() + await self._messages.delete_many({"session_id": self.session_id}) + await self._sessions.delete_one({"session_id": self.session_id}) + + # ------------------------------------------------------------------ + # Lifecycle helpers + # ------------------------------------------------------------------ + + async def close(self) -> None: + """Close the underlying MongoDB connection. + + Only closes the client if this session owns it (i.e. it was created + via :meth:`from_uri`). 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 MongoDB connectivity. + + Returns: + ``True`` if the server is reachable, ``False`` otherwise. + """ + try: + await self._client.admin.command("ping") + return True + except Exception: + return False diff --git a/tests/extensions/memory/test_mongodb_session.py b/tests/extensions/memory/test_mongodb_session.py new file mode 100644 index 0000000000..2d2c024e30 --- /dev/null +++ b/tests/extensions/memory/test_mongodb_session.py @@ -0,0 +1,762 @@ +"""Tests for MongoDBSession using in-process mock objects. + +All tests run without a real MongoDB server — or even the ``pymongo`` +package — by injecting lightweight fake classes into ``sys.modules`` +before the module under test is imported. This keeps the suite fast and +dependency-free while exercising the full session logic. +""" + +from __future__ import annotations + +import sys +import types +from collections import defaultdict +from typing import Any +from unittest.mock import patch + +import pytest + +from agents import Agent, Runner, TResponseInputItem +from agents.memory.session_settings import SessionSettings +from tests.fake_model import FakeModel +from tests.test_responses import get_text_message + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# In-memory fake pymongo async types +# --------------------------------------------------------------------------- + + +class FakeObjectId: + """Minimal ObjectId stand-in with a monotonic counter for sort order.""" + + _counter = 0 + + def __init__(self) -> None: + FakeObjectId._counter += 1 + self._value = FakeObjectId._counter + + def __lt__(self, other: FakeObjectId) -> bool: + return self._value < other._value + + def __repr__(self) -> str: + return f"FakeObjectId({self._value})" + + +class FakeCursor: + """Minimal async cursor returned by ``find()``.""" + + def __init__(self, docs: list[dict[str, Any]]) -> None: + self._docs = docs + + def sort( + self, + key: str | list[tuple[str, int]], + direction: int | None = None, + ) -> FakeCursor: + if isinstance(key, list): + pairs = key + else: + direction = direction if direction is not None else 1 + pairs = [(key, direction)] + + docs = list(self._docs) + for field, dir_ in reversed(pairs): + docs.sort(key=lambda d: d.get(field, 0), reverse=(dir_ == -1)) + self._docs = docs + return self + + def limit(self, n: int) -> FakeCursor: + self._docs = self._docs[:n] + return self + + async def to_list(self) -> list[dict[str, Any]]: + return list(self._docs) + + +class FakeAsyncCollection: + """In-memory substitute for pymongo AsyncCollection.""" + + def __init__(self) -> None: + self._docs: dict[Any, dict[str, Any]] = {} + + async def create_index(self, keys: Any, **kwargs: Any) -> str: + return "fake_index" + + def find(self, query: dict[str, Any] | None = None) -> FakeCursor: + query = query or {} + results = [doc for doc in self._docs.values() if self._matches(doc, query)] + return FakeCursor(results) + + async def find_one_and_delete( + self, + query: dict[str, Any], + sort: list[tuple[str, int]] | None = None, + ) -> dict[str, Any] | None: + matches = [doc for doc in self._docs.values() if self._matches(doc, query)] + if not matches: + return None + if sort: + field, dir_ = sort[0] + matches.sort(key=lambda d: d.get(field, 0), reverse=(dir_ == -1)) + doc = matches[0] + self._docs.pop(id(doc["_id"])) + return doc + + async def insert_many( + self, + documents: list[dict[str, Any]], + ordered: bool = True, + ) -> Any: + for doc in documents: + if "_id" not in doc: + doc["_id"] = FakeObjectId() + self._docs[id(doc["_id"])] = dict(doc) + + async def find_one_and_update( + self, + query: dict[str, Any], + update: dict[str, Any], + upsert: bool = False, + return_document: bool = False, + ) -> dict[str, Any] | None: + for doc in self._docs.values(): + if self._matches(doc, query): + # Apply $inc fields. + for field, delta in update.get("$inc", {}).items(): + doc[field] = doc.get(field, 0) + delta + return dict(doc) if return_document else None + if upsert: + new_doc: dict[str, Any] = {"_id": FakeObjectId()} + new_doc.update(update.get("$setOnInsert", {})) + for field, delta in update.get("$inc", {}).items(): + new_doc[field] = new_doc.get(field, 0) + delta + self._docs[id(new_doc["_id"])] = new_doc + return dict(new_doc) if return_document else None + return None + + async def update_one( + self, + query: dict[str, Any], + update: dict[str, Any], + upsert: bool = False, + ) -> None: + for doc in self._docs.values(): + if self._matches(doc, query): + return # Exists — $setOnInsert is a no-op on existing docs. + if upsert: + new_doc2: dict[str, Any] = {"_id": FakeObjectId()} + new_doc2.update(update.get("$setOnInsert", {})) + self._docs[id(new_doc2["_id"])] = new_doc2 + + async def delete_many(self, query: dict[str, Any]) -> None: + to_remove = [k for k, d in self._docs.items() if self._matches(d, query)] + for key in to_remove: + del self._docs[key] + + async def delete_one(self, query: dict[str, Any]) -> None: + for key, doc in list(self._docs.items()): + if self._matches(doc, query): + del self._docs[key] + return + + @staticmethod + def _matches(doc: dict[str, Any], query: dict[str, Any]) -> bool: + return all(doc.get(k) == v for k, v in query.items()) + + +class FakeAsyncDatabase: + """In-memory substitute for a pymongo async Database.""" + + def __init__(self) -> None: + self._collections: dict[str, FakeAsyncCollection] = defaultdict(FakeAsyncCollection) + + def __getitem__(self, name: str) -> FakeAsyncCollection: + return self._collections[name] + + +class FakeAdminDatabase: + """Minimal admin database used by ping().""" + + def __init__(self) -> None: + self._closed = False + + async def command(self, cmd: str) -> dict[str, Any]: + if self._closed: + raise ConnectionError("Client is closed.") + return {"ok": 1} + + +class FakeDriverInfo: + """Minimal stand-in for pymongo.driver_info.DriverInfo.""" + + def __init__(self, name: str, version: str | None = None) -> None: + self.name = name + self.version = version + + +class FakeAsyncMongoClient: + """In-memory substitute for pymongo AsyncMongoClient.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._databases: dict[str, FakeAsyncDatabase] = defaultdict(FakeAsyncDatabase) + self._closed = False + self.admin = FakeAdminDatabase() + self._metadata_calls: list[FakeDriverInfo] = [] + + def __getitem__(self, name: str) -> FakeAsyncDatabase: + return self._databases[name] + + def append_metadata(self, driver_info: FakeDriverInfo) -> None: + """Record append_metadata calls for test assertions.""" + self._metadata_calls.append(driver_info) + + async def close(self) -> None: + """Async close — matches PyMongo's AsyncMongoClient.close() signature.""" + self._closed = True + self.admin._closed = True + + +# --------------------------------------------------------------------------- +# Inject fake pymongo into sys.modules before importing the module under test +# --------------------------------------------------------------------------- + + +def _make_fake_pymongo_modules() -> None: + """Populate sys.modules with stub pymongo async modules.""" + pymongo_mod = sys.modules.get("pymongo") or types.ModuleType("pymongo") + + async_pkg = types.ModuleType("pymongo.asynchronous") + collection_mod = types.ModuleType("pymongo.asynchronous.collection") + client_mod = types.ModuleType("pymongo.asynchronous.mongo_client") + driver_info_mod = types.ModuleType("pymongo.driver_info") + + collection_mod.AsyncCollection = FakeAsyncCollection # type: ignore[attr-defined] + client_mod.AsyncMongoClient = FakeAsyncMongoClient # type: ignore[attr-defined] + driver_info_mod.DriverInfo = FakeDriverInfo # type: ignore[attr-defined] + + sys.modules["pymongo"] = pymongo_mod + sys.modules["pymongo.asynchronous"] = async_pkg + sys.modules["pymongo.asynchronous.collection"] = collection_mod + sys.modules["pymongo.asynchronous.mongo_client"] = client_mod + sys.modules["pymongo.driver_info"] = driver_info_mod + + +_make_fake_pymongo_modules() + +# Now it's safe to import the module under test. +from agents.extensions.memory.mongodb_session import MongoDBSession # noqa: E402 + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + + +def _make_session(session_id: str = "test-session", **kwargs: Any) -> MongoDBSession: + """Create a MongoDBSession backed by a FakeAsyncMongoClient.""" + client = FakeAsyncMongoClient() + MongoDBSession._init_state.clear() + return MongoDBSession( + session_id, + client=client, # type: ignore[arg-type] + database="agents_test", + **kwargs, + ) + + +@pytest.fixture +def session() -> MongoDBSession: + return _make_session() + + +@pytest.fixture +def agent() -> Agent: + return Agent(name="test", model=FakeModel()) + + +# --------------------------------------------------------------------------- +# Core CRUD tests +# --------------------------------------------------------------------------- + + +async def test_add_and_get_items(session: MongoDBSession) -> None: + """Items added to the session are retrievable in insertion order.""" + items: list[TResponseInputItem] = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + ] + await session.add_items(items) + + retrieved = await session.get_items() + assert len(retrieved) == 2 + assert retrieved[0].get("content") == "Hello" + assert retrieved[1].get("content") == "Hi there!" + + +async def test_add_empty_list_is_noop(session: MongoDBSession) -> None: + """Adding an empty list must not create any documents.""" + await session.add_items([]) + assert await session.get_items() == [] + + +async def test_get_items_empty_session(session: MongoDBSession) -> None: + """Retrieving items from a brand-new session returns an empty list.""" + assert await session.get_items() == [] + + +async def test_pop_item_returns_last(session: MongoDBSession) -> None: + """pop_item must return and remove the most recently added item.""" + items: list[TResponseInputItem] = [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "second"}, + ] + await session.add_items(items) + + popped = await session.pop_item() + assert popped is not None + assert popped.get("content") == "second" + + remaining = await session.get_items() + assert len(remaining) == 1 + assert remaining[0].get("content") == "first" + + +async def test_pop_item_empty_session(session: MongoDBSession) -> None: + """pop_item on an empty session must return None.""" + assert await session.pop_item() is None + + +async def test_clear_session(session: MongoDBSession) -> None: + """clear_session must remove all items and session metadata.""" + await session.add_items([{"role": "user", "content": "x"}]) + await session.clear_session() + assert await session.get_items() == [] + + +async def test_multiple_add_calls_accumulate(session: MongoDBSession) -> None: + """Items from separate add_items calls all appear in get_items.""" + await session.add_items([{"role": "user", "content": "a"}]) + await session.add_items([{"role": "assistant", "content": "b"}]) + await session.add_items([{"role": "user", "content": "c"}]) + + items = await session.get_items() + assert [i.get("content") for i in items] == ["a", "b", "c"] + + +# --------------------------------------------------------------------------- +# Limit / SessionSettings tests +# --------------------------------------------------------------------------- + + +async def test_get_items_with_explicit_limit(session: MongoDBSession) -> None: + """Explicit limit returns the N most recent items in chronological order.""" + await session.add_items([{"role": "user", "content": str(i)} for i in range(6)]) + + result = await session.get_items(limit=3) + assert len(result) == 3 + assert [r.get("content") for r in result] == ["3", "4", "5"] + + +async def test_get_items_limit_zero(session: MongoDBSession) -> None: + """A limit of 0 must return an empty list immediately.""" + await session.add_items([{"role": "user", "content": "x"}]) + assert await session.get_items(limit=0) == [] + + +async def test_get_items_limit_exceeds_count(session: MongoDBSession) -> None: + """Requesting more items than exist returns all items without error.""" + await session.add_items([{"role": "user", "content": "only"}]) + result = await session.get_items(limit=100) + assert len(result) == 1 + + +async def test_session_settings_limit_used_as_default() -> None: + """session_settings.limit is applied when no explicit limit is given.""" + MongoDBSession._init_state.clear() + s = MongoDBSession( + "ls-test", + client=FakeAsyncMongoClient(), # type: ignore[arg-type] + database="agents_test", + session_settings=SessionSettings(limit=2), + ) + await s.add_items([{"role": "user", "content": str(i)} for i in range(5)]) + + result = await s.get_items() + assert len(result) == 2 + assert result[0].get("content") == "3" + assert result[1].get("content") == "4" + + +async def test_explicit_limit_overrides_session_settings() -> None: + """An explicit limit passed to get_items must override session_settings.limit.""" + MongoDBSession._init_state.clear() + s = MongoDBSession( + "override-test", + client=FakeAsyncMongoClient(), # type: ignore[arg-type] + database="agents_test", + session_settings=SessionSettings(limit=10), + ) + await s.add_items([{"role": "user", "content": str(i)} for i in range(8)]) + + result = await s.get_items(limit=2) + assert len(result) == 2 + assert result[0].get("content") == "6" + assert result[1].get("content") == "7" + + +# --------------------------------------------------------------------------- +# Session isolation +# --------------------------------------------------------------------------- + + +async def test_sessions_are_isolated() -> None: + """Two sessions with different IDs must not share data.""" + MongoDBSession._init_state.clear() + client = FakeAsyncMongoClient() + s1 = MongoDBSession("alice", client=client, database="agents_test") # type: ignore[arg-type] + s2 = MongoDBSession("bob", client=client, database="agents_test") # type: ignore[arg-type] + + await s1.add_items([{"role": "user", "content": "alice msg"}]) + await s2.add_items([{"role": "user", "content": "bob msg"}]) + + assert [i.get("content") for i in await s1.get_items()] == ["alice msg"] + assert [i.get("content") for i in await s2.get_items()] == ["bob msg"] + + +async def test_clear_does_not_affect_other_sessions() -> None: + """Clearing one session must leave sibling sessions untouched.""" + MongoDBSession._init_state.clear() + client = FakeAsyncMongoClient() + s1 = MongoDBSession("s1", client=client, database="agents_test") # type: ignore[arg-type] + s2 = MongoDBSession("s2", client=client, database="agents_test") # type: ignore[arg-type] + + await s1.add_items([{"role": "user", "content": "keep"}]) + await s2.add_items([{"role": "user", "content": "delete"}]) + + await s2.clear_session() + + assert len(await s1.get_items()) == 1 + assert await s2.get_items() == [] + + +# --------------------------------------------------------------------------- +# Serialisation / unicode safety +# --------------------------------------------------------------------------- + + +async def test_unicode_content_roundtrip(session: MongoDBSession) -> None: + """Unicode and emoji content must survive the serialisation round-trip.""" + items: list[TResponseInputItem] = [ + {"role": "user", "content": "こんにちは"}, + {"role": "assistant", "content": "😊👍"}, + {"role": "user", "content": "Привет"}, + ] + await session.add_items(items) + result = await session.get_items() + assert result[0].get("content") == "こんにちは" + assert result[1].get("content") == "😊👍" + assert result[2].get("content") == "Привет" + + +async def test_json_special_characters(session: MongoDBSession) -> None: + """Items containing JSON-special strings must be stored without corruption.""" + items: list[TResponseInputItem] = [ + {"role": "user", "content": '{"nested": "value"}'}, + {"role": "assistant", "content": 'Quote: "Hello"'}, + {"role": "user", "content": "Line1\nLine2\tTabbed"}, + ] + await session.add_items(items) + result = await session.get_items() + assert result[0].get("content") == '{"nested": "value"}' + assert result[1].get("content") == 'Quote: "Hello"' + assert result[2].get("content") == "Line1\nLine2\tTabbed" + + +async def test_corrupted_document_is_skipped(session: MongoDBSession) -> None: + """Documents with invalid JSON in message_data are silently skipped.""" + await session.add_items([{"role": "user", "content": "valid"}]) + + # Inject a corrupted document directly into the fake collection. + bad_doc = { + "_id": FakeObjectId(), + "session_id": session.session_id, + "message_data": "not valid json {{{", + } + session._messages._docs[id(bad_doc["_id"])] = bad_doc + + items = await session.get_items() + assert len(items) == 1 + assert items[0].get("content") == "valid" + + +async def test_missing_message_data_field_is_skipped(session: MongoDBSession) -> None: + """Documents without a message_data field are silently skipped.""" + await session.add_items([{"role": "user", "content": "valid"}]) + + bad_doc = {"_id": FakeObjectId(), "session_id": session.session_id} + session._messages._docs[id(bad_doc["_id"])] = bad_doc + + items = await session.get_items() + assert len(items) == 1 + + +async def test_non_string_message_data_is_skipped(session: MongoDBSession) -> None: + """Documents whose message_data is a non-string BSON type are silently skipped.""" + await session.add_items([{"role": "user", "content": "valid"}]) + + # Inject a document where message_data is an integer — json.loads raises TypeError. + bad_doc = {"_id": FakeObjectId(), "session_id": session.session_id, "message_data": 42} + session._messages._docs[id(bad_doc["_id"])] = bad_doc + + items = await session.get_items() + assert len(items) == 1 + assert items[0].get("content") == "valid" + + +# --------------------------------------------------------------------------- +# Index initialisation (idempotency) +# --------------------------------------------------------------------------- + + +async def test_index_creation_runs_only_once(session: MongoDBSession) -> None: + """_ensure_indexes must call create_index only on the very first call.""" + call_count = 0 + original_messages = session._messages.create_index + original_sessions = session._sessions.create_index + + async def counting(*args: Any, **kwargs: Any) -> str: + nonlocal call_count + call_count += 1 + return "fake_index" + + session._messages.create_index = counting # type: ignore[method-assign] + session._sessions.create_index = counting # type: ignore[method-assign] + + await session._ensure_indexes() + await session._ensure_indexes() # Second call must be a no-op. + + # Exactly one call per collection (sessions + messages). + assert call_count == 2 + + session._messages.create_index = original_messages # type: ignore[method-assign] + session._sessions.create_index = original_sessions # type: ignore[method-assign] + + +async def test_different_clients_each_run_index_init() -> None: + """Each distinct AsyncMongoClient gets its own index-creation pass.""" + MongoDBSession._init_state.clear() + + client_a = FakeAsyncMongoClient() + client_b = FakeAsyncMongoClient() + + call_counts: dict[str, int] = {"a": 0, "b": 0} + + async def counting_a(*args: Any, **kwargs: Any) -> str: + call_counts["a"] += 1 + return "fake_index" + + async def counting_b(*args: Any, **kwargs: Any) -> str: + call_counts["b"] += 1 + return "fake_index" + + s_a = MongoDBSession("x", client=client_a, database="agents_test") # type: ignore[arg-type] + s_b = MongoDBSession("x", client=client_b, database="agents_test") # type: ignore[arg-type] + + s_a._messages.create_index = counting_a # type: ignore[method-assign] + s_a._sessions.create_index = counting_a # type: ignore[method-assign] + s_b._messages.create_index = counting_b # type: ignore[method-assign] + s_b._sessions.create_index = counting_b # type: ignore[method-assign] + + await s_a._ensure_indexes() + await s_b._ensure_indexes() + + # Each client must trigger its own index creation (2 calls = sessions + messages). + assert call_counts["a"] == 2 + assert call_counts["b"] == 2 + + +# --------------------------------------------------------------------------- +# Connectivity and lifecycle +# --------------------------------------------------------------------------- + + +async def test_ping_success(session: MongoDBSession) -> None: + """ping() must return True when the client responds normally.""" + assert await session.ping() is True + + +async def test_ping_failure(session: MongoDBSession) -> None: + """ping() must return False when the server raises an exception.""" + original = session._client.admin.command + + async def _fail(*args: Any, **kwargs: Any) -> dict[str, Any]: + raise ConnectionError("unreachable") + + session._client.admin.command = _fail # type: ignore[method-assign, assignment] + assert await session.ping() is False + session._client.admin.command = original # type: ignore[method-assign] + + +async def test_close_external_client_not_closed() -> None: + """close() must NOT close a client that was injected externally.""" + MongoDBSession._init_state.clear() + client = FakeAsyncMongoClient() + s = MongoDBSession("x", client=client, database="agents_test") # type: ignore[arg-type] + assert s._owns_client is False + + await s.close() + assert not client._closed + + +async def test_close_owned_client_is_closed() -> None: + """close() must close a client created by from_uri.""" + MongoDBSession._init_state.clear() + fake_client = FakeAsyncMongoClient() + with patch( + "agents.extensions.memory.mongodb_session.AsyncMongoClient", + return_value=fake_client, + ): + s = MongoDBSession.from_uri("owned", uri="mongodb://localhost:27017", database="t") + assert s._owns_client is True + + await s.close() + assert fake_client._closed + + +# --------------------------------------------------------------------------- +# Runner integration +# --------------------------------------------------------------------------- + + +async def test_runner_integration(agent: Agent) -> None: + """MongoDBSession must supply conversation history to the Runner.""" + session = _make_session("runner-test") + + assert isinstance(agent.model, FakeModel) + agent.model.set_next_output([get_text_message("San Francisco")]) + result1 = await Runner.run(agent, "Where is the Golden Gate Bridge?", session=session) + assert result1.final_output == "San Francisco" + + agent.model.set_next_output([get_text_message("California")]) + result2 = await Runner.run(agent, "What state is it in?", session=session) + assert result2.final_output == "California" + + last_input = agent.model.last_turn_args["input"] + assert len(last_input) > 1 + assert any("Golden Gate Bridge" in str(item.get("content", "")) for item in last_input) + + +async def test_runner_session_isolation(agent: Agent) -> None: + """Two independent sessions must not bleed history into each other.""" + MongoDBSession._init_state.clear() + client = FakeAsyncMongoClient() + s1 = MongoDBSession("user-a", client=client, database="agents_test") # type: ignore[arg-type] + s2 = MongoDBSession("user-b", client=client, database="agents_test") # type: ignore[arg-type] + + assert isinstance(agent.model, FakeModel) + agent.model.set_next_output([get_text_message("I like cats.")]) + await Runner.run(agent, "I like cats.", session=s1) + + agent.model.set_next_output([get_text_message("I like dogs.")]) + await Runner.run(agent, "I like dogs.", session=s2) + + agent.model.set_next_output([get_text_message("You said you like cats.")]) + result = await Runner.run(agent, "What animal did I mention?", session=s1) + assert "cats" in result.final_output.lower() + assert "dogs" not in result.final_output.lower() + + +async def test_runner_with_session_settings_limit(agent: Agent) -> None: + """RunConfig.session_settings.limit must cap the history sent to the model.""" + from agents import RunConfig + + MongoDBSession._init_state.clear() + session = MongoDBSession( + "limit-test", + client=FakeAsyncMongoClient(), # type: ignore[arg-type] + database="agents_test", + session_settings=SessionSettings(limit=100), + ) + + history: list[TResponseInputItem] = [ + {"role": "user", "content": f"Turn {i}"} for i in range(10) + ] + await session.add_items(history) + + assert isinstance(agent.model, FakeModel) + agent.model.set_next_output([get_text_message("Got it")]) + await Runner.run( + agent, + "New question", + session=session, + run_config=RunConfig(session_settings=SessionSettings(limit=2)), + ) + + last_input = agent.model.last_turn_args["input"] + history_items = [i for i in last_input if i.get("content") != "New question"] + assert len(history_items) == 2 + + +# --------------------------------------------------------------------------- +# Client metadata (driver handshake) +# --------------------------------------------------------------------------- + + +async def test_injected_client_receives_append_metadata() -> None: + """Append_metadata is called on a caller-supplied client.""" + MongoDBSession._init_state.clear() + client = FakeAsyncMongoClient() + + MongoDBSession("meta-test", client=client, database="agents_test") # type: ignore[arg-type] + + assert len(client._metadata_calls) == 1 + info = client._metadata_calls[0] + assert info.name == "openai-agents" + + +async def test_from_uri_passes_driver_info_to_constructor() -> None: + """driver=_DRIVER_INFO is forwarded to AsyncMongoClient via from_uri.""" + MongoDBSession._init_state.clear() + + captured_kwargs: dict[str, Any] = {} + + def _fake_client(uri: str, **kwargs: Any) -> FakeAsyncMongoClient: + captured_kwargs.update(kwargs) + return FakeAsyncMongoClient() + + with patch( + "agents.extensions.memory.mongodb_session.AsyncMongoClient", + side_effect=_fake_client, + ): + MongoDBSession.from_uri("uri-test", uri="mongodb://localhost:27017", database="t") + + assert "driver" in captured_kwargs + assert captured_kwargs["driver"].name == "openai-agents" + + +async def test_caller_supplied_driver_info_is_not_overwritten() -> None: + """A caller-supplied driver kwarg must not be silently replaced.""" + MongoDBSession._init_state.clear() + + captured_kwargs: dict[str, Any] = {} + custom_info = FakeDriverInfo(name="MyApp") + + def _fake_client(uri: str, **kwargs: Any) -> FakeAsyncMongoClient: + captured_kwargs.update(kwargs) + return FakeAsyncMongoClient() + + with patch( + "agents.extensions.memory.mongodb_session.AsyncMongoClient", + side_effect=_fake_client, + ): + MongoDBSession.from_uri( + "uri-test", + uri="mongodb://localhost:27017", + database="t", + client_kwargs={"driver": custom_info}, + ) + + # The caller's value must be preserved — setdefault must not overwrite it. + assert captured_kwargs["driver"] is custom_info diff --git a/uv.lock b/uv.lock index bf401d354a..68b1866c5b 100644 --- a/uv.lock +++ b/uv.lock @@ -851,6 +851,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "docker" version = "7.1.0" @@ -2466,6 +2475,9 @@ litellm = [ modal = [ { name = "modal" }, ] +mongodb = [ + { name = "pymongo" }, +] realtime = [ { name = "websockets" }, ] @@ -2516,6 +2528,7 @@ dev = [ { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, { name = "playwright" }, + { name = "pymongo" }, { name = "pynput" }, { name = "pyright" }, { name = "pytest" }, @@ -2554,6 +2567,7 @@ requires-dist = [ { name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" }, { name = "openai", specifier = ">=2.26.0,<3" }, { name = "pydantic", specifier = ">=2.12.2,<3" }, + { name = "pymongo", marker = "extra == 'mongodb'", specifier = ">=4.13" }, { name = "redis", marker = "extra == 'redis'", specifier = ">=7" }, { name = "requests", specifier = ">=2.0,<3" }, { name = "runloop-api-client", marker = "extra == 'runloop'", specifier = ">=1.16.0,<2.0.0" }, @@ -2567,7 +2581,7 @@ requires-dist = [ { name = "websockets", marker = "extra == 'realtime'", specifier = ">=15.0,<16" }, { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<16" }, ] -provides-extras = ["voice", "viz", "litellm", "any-llm", "realtime", "sqlalchemy", "encrypt", "redis", "dapr", "docker", "blaxel", "daytona", "cloudflare", "e2b", "modal", "runloop", "vercel", "s3", "temporal"] +provides-extras = ["voice", "viz", "litellm", "any-llm", "realtime", "sqlalchemy", "encrypt", "redis", "dapr", "mongodb", "docker", "blaxel", "daytona", "cloudflare", "e2b", "modal", "runloop", "vercel", "s3", "temporal"] [package.metadata.requires-dev] dev = [ @@ -2588,6 +2602,7 @@ dev = [ { name = "mkdocstrings", extras = ["python"], specifier = ">=0.28.0" }, { name = "mypy" }, { name = "playwright", specifier = "==1.50.0" }, + { name = "pymongo", specifier = ">=4.13" }, { name = "pynput" }, { name = "pyright", specifier = "==1.1.408" }, { name = "pytest" }, @@ -3103,6 +3118,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" }, ] +[[package]] +name = "pymongo" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/9c/a4895c4b785fc9865a84a56e14b5bd21ca75aadc3dab79c14187cdca189b/pymongo-4.16.0.tar.gz", hash = "sha256:8ba8405065f6e258a6f872fe62d797a28f383a12178c7153c01ed04e845c600c", size = 2495323, upload-time = "2026-01-07T18:05:48.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/93/c36c0998dd91ad8b5031d2e77a903d5cd705b5ba05ca92bcc8731a2c3a8d/pymongo-4.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ed162b2227f98d5b270ecbe1d53be56c8c81db08a1a8f5f02d89c7bb4d19591d", size = 807993, upload-time = "2026-01-07T18:03:40.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/96/d2117d792fa9fedb2f6ccf0608db31f851e8382706d7c3c88c6ac92cc958/pymongo-4.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a9390dce61d705a88218f0d7b54d7e1fa1b421da8129fc7c009e029a9a6b81e", size = 808355, upload-time = "2026-01-07T18:03:42.13Z" }, + { url = "https://files.pythonhosted.org/packages/ae/2e/e79b7b86c0dd6323d0985c201583c7921d67b842b502aae3f3327cbe3935/pymongo-4.16.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:92a232af9927710de08a6c16a9710cc1b175fb9179c0d946cd4e213b92b2a69a", size = 1182337, upload-time = "2026-01-07T18:03:44.126Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/07ec9966381c57d941fddc52637e9c9653e63773be410bd8605f74683084/pymongo-4.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d79aa147ce86aef03079096d83239580006ffb684eead593917186aee407767", size = 1200928, upload-time = "2026-01-07T18:03:45.52Z" }, + { url = "https://files.pythonhosted.org/packages/44/15/9d45e3cc6fa428b0a3600b0c1c86b310f28c91251c41493460695ab40b6b/pymongo-4.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19a1c96e7f39c7a59a9cfd4d17920cf9382f6f684faeff4649bf587dc59f8edc", size = 1239418, upload-time = "2026-01-07T18:03:47.03Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b3/f35ee51e2a3f05f673ad4f5e803ae1284c42f4413e8d121c4958f1af4eb9/pymongo-4.16.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efe020c46ce3c3a89af6baec6569635812129df6fb6cf76d4943af3ba6ee2069", size = 1229045, upload-time = "2026-01-07T18:03:48.377Z" }, + { url = "https://files.pythonhosted.org/packages/18/2d/1688b88d7c0a5c01da8c703dea831419435d9ce67c6ddbb0ac629c9c72d2/pymongo-4.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dc2c00bed568732b89e211b6adca389053d5e6d2d5a8979e80b813c3ec4d1f9", size = 1196517, upload-time = "2026-01-07T18:03:50.205Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/e89db0f23bd20757b627a5d8c73a609ffd6741887b9004ab229208a79764/pymongo-4.16.0-cp310-cp310-win32.whl", hash = "sha256:5b9c6d689bbe5beb156374508133218610e14f8c81e35bc17d7a14e30ab593e6", size = 794911, upload-time = "2026-01-07T18:03:52.701Z" }, + { url = "https://files.pythonhosted.org/packages/37/54/e00a5e517153f310a33132375159e42dceb12bee45b51b35aa0df14f1866/pymongo-4.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:2290909275c9b8f637b0a92eb9b89281e18a72922749ebb903403ab6cc7da914", size = 804801, upload-time = "2026-01-07T18:03:57.671Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0a/2572faf89195a944c99c6d756227019c8c5f4b5658ecc261c303645dfe69/pymongo-4.16.0-cp310-cp310-win_arm64.whl", hash = "sha256:6af1aaa26f0835175d2200e62205b78e7ec3ffa430682e322cc91aaa1a0dbf28", size = 797579, upload-time = "2026-01-07T18:03:59.1Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/907414a763c4270b581ad6d960d0c6221b74a70eda216a1fdd8fa82ba89f/pymongo-4.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f2077ec24e2f1248f9cac7b9a2dfb894e50cc7939fcebfb1759f99304caabef", size = 862561, upload-time = "2026-01-07T18:04:00.628Z" }, + { url = "https://files.pythonhosted.org/packages/8c/58/787d8225dd65cb2383c447346ea5e200ecfde89962d531111521e3b53018/pymongo-4.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d4f7ba040f72a9f43a44059872af5a8c8c660aa5d7f90d5344f2ed1c3c02721", size = 862923, upload-time = "2026-01-07T18:04:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a7/cc2865aae32bc77ade7b35f957a58df52680d7f8506f93c6edbf458e5738/pymongo-4.16.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8a0f73af1ea56c422b2dcfc0437459148a799ef4231c6aee189d2d4c59d6728f", size = 1426779, upload-time = "2026-01-07T18:04:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/81/25/3e96eb7998eec05382174da2fefc58d28613f46bbdf821045539d0ed60ab/pymongo-4.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa30cd16ddd2f216d07ba01d9635c873e97ddb041c61cf0847254edc37d1c60e", size = 1454207, upload-time = "2026-01-07T18:04:05.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/7b/8e817a7df8c5d565d39dd4ca417a5e0ef46cc5cc19aea9405f403fec6449/pymongo-4.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d638b0b1b294d95d0fdc73688a3b61e05cc4188872818cd240d51460ccabcb5", size = 1511654, upload-time = "2026-01-07T18:04:08.458Z" }, + { url = "https://files.pythonhosted.org/packages/39/7a/50c4d075ccefcd281cdcfccc5494caa5665b096b85e65a5d6afabb80e09e/pymongo-4.16.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:21d02cc10a158daa20cb040985e280e7e439832fc6b7857bff3d53ef6914ad50", size = 1496794, upload-time = "2026-01-07T18:04:10.355Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/ebdc1aaca5deeaf47310c369ef4083e8550e04e7bf7e3752cfb7d95fcdb8/pymongo-4.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fbb8d3552c2ad99d9e236003c0b5f96d5f05e29386ba7abae73949bfebc13dd", size = 1448371, upload-time = "2026-01-07T18:04:11.76Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c9/50fdd78c37f68ea49d590c027c96919fbccfd98f3a4cb39f84f79970bd37/pymongo-4.16.0-cp311-cp311-win32.whl", hash = "sha256:be1099a8295b1a722d03fb7b48be895d30f4301419a583dcf50e9045968a041c", size = 841024, upload-time = "2026-01-07T18:04:13.522Z" }, + { url = "https://files.pythonhosted.org/packages/4a/dd/a3aa1ade0cf9980744db703570afac70a62c85b432c391dea0577f6da7bb/pymongo-4.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:61567f712bda04c7545a037e3284b4367cad8d29b3dec84b4bf3b2147020a75b", size = 855838, upload-time = "2026-01-07T18:04:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/bf/10/9ad82593ccb895e8722e4884bad4c5ce5e8ff6683b740d7823a6c2bcfacf/pymongo-4.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:c53338613043038005bf2e41a2fafa08d29cdbc0ce80891b5366c819456c1ae9", size = 845007, upload-time = "2026-01-07T18:04:17.099Z" }, + { url = "https://files.pythonhosted.org/packages/6a/03/6dd7c53cbde98de469a3e6fb893af896dca644c476beb0f0c6342bcc368b/pymongo-4.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bd4911c40a43a821dfd93038ac824b756b6e703e26e951718522d29f6eb166a8", size = 917619, upload-time = "2026-01-07T18:04:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/e1/328915f2734ea1f355dc9b0e98505ff670f5fab8be5e951d6ed70971c6aa/pymongo-4.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25a6b03a68f9907ea6ec8bc7cf4c58a1b51a18e23394f962a6402f8e46d41211", size = 917364, upload-time = "2026-01-07T18:04:20.861Z" }, + { url = "https://files.pythonhosted.org/packages/41/fe/4769874dd9812a1bc2880a9785e61eba5340da966af888dd430392790ae0/pymongo-4.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:91ac0cb0fe2bf17616c2039dac88d7c9a5088f5cb5829b27c9d250e053664d31", size = 1686901, upload-time = "2026-01-07T18:04:22.219Z" }, + { url = "https://files.pythonhosted.org/packages/fa/8d/15707b9669fdc517bbc552ac60da7124dafe7ac1552819b51e97ed4038b4/pymongo-4.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf0ec79e8ca7077f455d14d915d629385153b6a11abc0b93283ed73a8013e376", size = 1723034, upload-time = "2026-01-07T18:04:24.055Z" }, + { url = "https://files.pythonhosted.org/packages/5b/af/3d5d16ff11d447d40c1472da1b366a31c7380d7ea2922a449c7f7f495567/pymongo-4.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d0082631a7510318befc2b4fdab140481eb4b9dd62d9245e042157085da2a70", size = 1797161, upload-time = "2026-01-07T18:04:25.964Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/725ab8664eeec73ec125b5a873448d80f5d8cf2750aaaf804cbc538a50a5/pymongo-4.16.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85dc2f3444c346ea019a371e321ac868a4fab513b7a55fe368f0cc78de8177cc", size = 1780938, upload-time = "2026-01-07T18:04:28.745Z" }, + { url = "https://files.pythonhosted.org/packages/22/50/dd7e9095e1ca35f93c3c844c92eb6eb0bc491caeb2c9bff3b32fe3c9b18f/pymongo-4.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dabbf3c14de75a20cc3c30bf0c6527157224a93dfb605838eabb1a2ee3be008d", size = 1714342, upload-time = "2026-01-07T18:04:30.331Z" }, + { url = "https://files.pythonhosted.org/packages/03/c9/542776987d5c31ae8e93e92680ea2b6e5a2295f398b25756234cabf38a39/pymongo-4.16.0-cp312-cp312-win32.whl", hash = "sha256:60307bb91e0ab44e560fe3a211087748b2b5f3e31f403baf41f5b7b0a70bd104", size = 887868, upload-time = "2026-01-07T18:04:32.124Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/b4045a7ccc5680fb496d01edf749c7a9367cc8762fbdf7516cf807ef679b/pymongo-4.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:f513b2c6c0d5c491f478422f6b5b5c27ac1af06a54c93ef8631806f7231bd92e", size = 907554, upload-time = "2026-01-07T18:04:33.685Z" }, + { url = "https://files.pythonhosted.org/packages/60/4c/33f75713d50d5247f2258405142c0318ff32c6f8976171c4fcae87a9dbdf/pymongo-4.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:dfc320f08ea9a7ec5b2403dc4e8150636f0d6150f4b9792faaae539c88e7db3b", size = 892971, upload-time = "2026-01-07T18:04:35.594Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/148d8b5da8260f4679d6665196ae04ab14ffdf06f5fe670b0ab11942951f/pymongo-4.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d15f060bc6d0964a8bb70aba8f0cb6d11ae99715438f640cff11bbcf172eb0e8", size = 972009, upload-time = "2026-01-07T18:04:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/9f3a8daf583d0adaaa033a3e3e58194d2282737dc164014ff33c7a081103/pymongo-4.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a19ea46a0fe71248965305a020bc076a163311aefbaa1d83e47d06fa30ac747", size = 971784, upload-time = "2026-01-07T18:04:39.669Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f2/b6c24361fcde24946198573c0176406bfd5f7b8538335f3d939487055322/pymongo-4.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:311d4549d6bf1f8c61d025965aebb5ba29d1481dc6471693ab91610aaffbc0eb", size = 1947174, upload-time = "2026-01-07T18:04:41.368Z" }, + { url = "https://files.pythonhosted.org/packages/47/1a/8634192f98cf740b3d174e1018dd0350018607d5bd8ac35a666dc49c732b/pymongo-4.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46ffb728d92dd5b09fc034ed91acf5595657c7ca17d4cf3751322cd554153c17", size = 1991727, upload-time = "2026-01-07T18:04:42.965Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2f/0c47ac84572b28e23028a23a3798a1f725e1c23b0cf1c1424678d16aff42/pymongo-4.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:acda193f440dd88c2023cb00aa8bd7b93a9df59978306d14d87a8b12fe426b05", size = 2082497, upload-time = "2026-01-07T18:04:44.652Z" }, + { url = "https://files.pythonhosted.org/packages/ba/57/9f46ef9c862b2f0cf5ce798f3541c201c574128d31ded407ba4b3918d7b6/pymongo-4.16.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d9fdb386cf958e6ef6ff537d6149be7edb76c3268cd6833e6c36aa447e4443f", size = 2064947, upload-time = "2026-01-07T18:04:46.228Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/5421c0998f38e32288100a07f6cb2f5f9f352522157c901910cb2927e211/pymongo-4.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91899dd7fb9a8c50f09c3c1cf0cb73bfbe2737f511f641f19b9650deb61c00ca", size = 1980478, upload-time = "2026-01-07T18:04:48.017Z" }, + { url = "https://files.pythonhosted.org/packages/92/93/bfc448d025e12313a937d6e1e0101b50cc9751636b4b170e600fe3203063/pymongo-4.16.0-cp313-cp313-win32.whl", hash = "sha256:2cd60cd1e05de7f01927f8e25ca26b3ea2c09de8723241e5d3bcfdc70eaff76b", size = 934672, upload-time = "2026-01-07T18:04:49.538Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/12710a5e01218d50c3dd165fd72c5ed2699285f77348a3b1a119a191d826/pymongo-4.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3ead8a0050c53eaa55935895d6919d393d0328ec24b2b9115bdbe881aa222673", size = 959237, upload-time = "2026-01-07T18:04:51.382Z" }, + { url = "https://files.pythonhosted.org/packages/0c/56/d288bcd1d05bc17ec69df1d0b1d67bc710c7c5dbef86033a5a4d2e2b08e6/pymongo-4.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:dbbc5b254c36c37d10abb50e899bc3939bbb7ab1e7c659614409af99bd3e7675", size = 940909, upload-time = "2026-01-07T18:04:52.904Z" }, + { url = "https://files.pythonhosted.org/packages/30/9e/4d343f8d0512002fce17915a89477b9f916bda1205729e042d8f23acf194/pymongo-4.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a254d49a9ffe9d7f888e3c677eed3729b14ce85abb08cd74732cead6ccc3c66", size = 1026634, upload-time = "2026-01-07T18:04:54.359Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e3/341f88c5535df40c0450fda915f582757bb7d988cdfc92990a5e27c4c324/pymongo-4.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a1bf44e13cf2d44d2ea2e928a8140d5d667304abe1a61c4d55b4906f389fbe64", size = 1026252, upload-time = "2026-01-07T18:04:56.642Z" }, + { url = "https://files.pythonhosted.org/packages/af/64/9471b22eb98f0a2ca0b8e09393de048502111b2b5b14ab1bd9e39708aab5/pymongo-4.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f1c5f1f818b669875d191323a48912d3fcd2e4906410e8297bb09ac50c4d5ccc", size = 2207399, upload-time = "2026-01-07T18:04:58.255Z" }, + { url = "https://files.pythonhosted.org/packages/87/ac/47c4d50b25a02f21764f140295a2efaa583ee7f17992a5e5fa542b3a690f/pymongo-4.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77cfd37a43a53b02b7bd930457c7994c924ad8bbe8dff91817904bcbf291b371", size = 2260595, upload-time = "2026-01-07T18:04:59.788Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1b/0ce1ce9dd036417646b2fe6f63b58127acff3cf96eeb630c34ec9cd675ff/pymongo-4.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:36ef2fee50eee669587d742fb456e349634b4fcf8926208766078b089054b24b", size = 2366958, upload-time = "2026-01-07T18:05:01.942Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3c/a5a17c0d413aa9d6c17bc35c2b472e9e79cda8068ba8e93433b5f43028e9/pymongo-4.16.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55f8d5a6fe2fa0b823674db2293f92d74cd5f970bc0360f409a1fc21003862d3", size = 2346081, upload-time = "2026-01-07T18:05:03.576Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/f815533d1a88fb8a3b6c6e895bb085ffdae68ccb1e6ed7102202a307f8e2/pymongo-4.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9caacac0dd105e2555521002e2d17afc08665187017b466b5753e84c016628e6", size = 2246053, upload-time = "2026-01-07T18:05:05.459Z" }, + { url = "https://files.pythonhosted.org/packages/c6/88/4be3ec78828dc64b212c123114bd6ae8db5b7676085a7b43cc75d0131bd2/pymongo-4.16.0-cp314-cp314-win32.whl", hash = "sha256:c789236366525c3ee3cd6e4e450a9ff629a7d1f4d88b8e18a0aea0615fd7ecf8", size = 989461, upload-time = "2026-01-07T18:05:07.018Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/ab8d5af76421b34db483c9c8ebc3a2199fb80ae63dc7e18f4cf1df46306a/pymongo-4.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b0714d7764efb29bf9d3c51c964aed7c4c7237b341f9346f15ceaf8321fdb35", size = 1017803, upload-time = "2026-01-07T18:05:08.499Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/98d68020728ac6423cf02d17cfd8226bf6cce5690b163d30d3f705e8297e/pymongo-4.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:12762e7cc0f8374a8cae3b9f9ed8dabb5d438c7b33329232dd9b7de783454033", size = 997184, upload-time = "2026-01-07T18:05:09.944Z" }, + { url = "https://files.pythonhosted.org/packages/50/00/dc3a271daf06401825b9c1f4f76f018182c7738281ea54b9762aea0560c1/pymongo-4.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1c01e8a7cd0ea66baf64a118005535ab5bf9f9eb63a1b50ac3935dccf9a54abe", size = 1083303, upload-time = "2026-01-07T18:05:11.702Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4b/b5375ee21d12eababe46215011ebc63801c0d2c5ffdf203849d0d79f9852/pymongo-4.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4c4872299ebe315a79f7f922051061634a64fda95b6b17677ba57ef00b2ba2a4", size = 1083233, upload-time = "2026-01-07T18:05:13.182Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e3/52efa3ca900622c7dcb56c5e70f15c906816d98905c22d2ee1f84d9a7b60/pymongo-4.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:78037d02389745e247fe5ab0bcad5d1ab30726eaac3ad79219c7d6bbb07eec53", size = 2527438, upload-time = "2026-01-07T18:05:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/cb/96/43b1be151c734e7766c725444bcbfa1de6b60cc66bfb406203746839dd25/pymongo-4.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c126fb72be2518395cc0465d4bae03125119136462e1945aea19840e45d89cfc", size = 2600399, upload-time = "2026-01-07T18:05:16.794Z" }, + { url = "https://files.pythonhosted.org/packages/e7/62/fa64a5045dfe3a1cd9217232c848256e7bc0136cffb7da4735c5e0d30e40/pymongo-4.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3867dc225d9423c245a51eaac2cfcd53dde8e0a8d8090bb6aed6e31bd6c2d4f", size = 2720960, upload-time = "2026-01-07T18:05:18.498Z" }, + { url = "https://files.pythonhosted.org/packages/54/7b/01577eb97e605502821273a5bc16ce0fb0be5c978fe03acdbff471471202/pymongo-4.16.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f25001a955073b80510c0c3db0e043dbbc36904fd69e511c74e3d8640b8a5111", size = 2699344, upload-time = "2026-01-07T18:05:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/55/68/6ef6372d516f703479c3b6cbbc45a5afd307173b1cbaccd724e23919bb1a/pymongo-4.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d9885aad05f82fd7ea0c9ca505d60939746b39263fa273d0125170da8f59098", size = 2577133, upload-time = "2026-01-07T18:05:22.052Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/b5337093bb01da852f945802328665f85f8109dbe91d81ea2afe5ff059b9/pymongo-4.16.0-cp314-cp314t-win32.whl", hash = "sha256:948152b30eddeae8355495f9943a3bf66b708295c0b9b6f467de1c620f215487", size = 1040560, upload-time = "2026-01-07T18:05:23.888Z" }, + { url = "https://files.pythonhosted.org/packages/96/8c/5b448cd1b103f3889d5713dda37304c81020ff88e38a826e8a75ddff4610/pymongo-4.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f6e42c1bc985d9beee884780ae6048790eb4cd565c46251932906bdb1630034a", size = 1075081, upload-time = "2026-01-07T18:05:26.874Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/ddc794cdc8500f6f28c119c624252fb6dfb19481c6d7ed150f13cf468a6d/pymongo-4.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6b2a20edb5452ac8daa395890eeb076c570790dfce6b7a44d788af74c2f8cf96", size = 1047725, upload-time = "2026-01-07T18:05:28.47Z" }, +] + [[package]] name = "pynput" version = "1.8.1"