diff --git a/DEVELOPING.md b/DEVELOPING.md index 1db0721e..6389af66 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -112,6 +112,27 @@ store = ElasticsearchStore( store = ElasticsearchStore(client=existing_client, index="custom-index") ``` +### MongoDB Store +Document-based storage with MongoDB: + +```python +from kv_store_adapter.stores.mongodb import MongoStore + +# Connection options +store = MongoStore(host="localhost", port=27017, database="kvstore") +store = MongoStore(connection_string="mongodb://localhost:27017/kvstore") +store = MongoStore(client=existing_motor_client, database="custom_db") + +# With authentication +store = MongoStore( + host="localhost", + port=27017, + username="user", + password="pass", + database="secure_db" +) +``` + ### Simple Stores Dictionary-based stores for testing and development: diff --git a/README.md b/README.md index 78f7f767..edeb33cf 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A pluggable, async-first key-value store interface for Python applications with ## Features - **Async-first**: Built from the ground up with `async`/`await` support -- **Multiple backends**: Redis, Elasticsearch, In-memory, Disk, and more +- **Multiple backends**: Redis, Elasticsearch, MongoDB, In-memory, Disk, and more - **TTL support**: Automatic expiration handling across all store types - **Type-safe**: Full type hints with Protocol-based interfaces - **Adapters**: Pydantic, Single Collection, and more @@ -21,11 +21,12 @@ pip install kv-store-adapter # With specific backend support pip install kv-store-adapter[redis] pip install kv-store-adapter[elasticsearch] +pip install kv-store-adapter[mongodb] pip install kv-store-adapter[memory] pip install kv-store-adapter[disk] # With all backends -pip install kv-store-adapter[memory,disk,redis,elasticsearch] +pip install kv-store-adapter[memory,disk,redis,elasticsearch,mongodb] ``` # The KV Store Protocol diff --git a/pyproject.toml b/pyproject.toml index 92b4117d..89b8d0d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ memory = ["cachetools>=6.0.0"] disk = ["diskcache>=5.6.0"] redis = ["redis>=6.0.0"] elasticsearch = ["elasticsearch>=9.0.0", "aiohttp>=3.12"] +mongodb = ["motor>=3.0.0"] pydantic = ["pydantic>=2.11.9"] [tool.pytest.ini_options] @@ -40,7 +41,7 @@ env_files = [".env"] [dependency-groups] dev = [ - "kv-store-adapter[memory,disk,redis,elasticsearch]", + "kv-store-adapter[memory,disk,redis,elasticsearch,mongodb]", "kv-store-adapter[pydantic]", "pytest", "pytest-mock", diff --git a/src/kv_store_adapter/stores/mongodb/__init__.py b/src/kv_store_adapter/stores/mongodb/__init__.py new file mode 100644 index 00000000..46df0dbf --- /dev/null +++ b/src/kv_store_adapter/stores/mongodb/__init__.py @@ -0,0 +1,3 @@ +from .store import MongoStore + +__all__ = ["MongoStore"] diff --git a/src/kv_store_adapter/stores/mongodb/store.py b/src/kv_store_adapter/stores/mongodb/store.py new file mode 100644 index 00000000..53a6ff4e --- /dev/null +++ b/src/kv_store_adapter/stores/mongodb/store.py @@ -0,0 +1,184 @@ +from typing import overload +from urllib.parse import urlparse + +from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection, AsyncIOMotorDatabase +from typing_extensions import override + +from kv_store_adapter.errors import StoreConnectionError +from kv_store_adapter.stores.base.managed import BaseManagedKVStore +from kv_store_adapter.stores.utils.managed_entry import ManagedEntry + + +class MongoStore(BaseManagedKVStore): + """MongoDB-based key-value store.""" + + _client: AsyncIOMotorClient + _database: AsyncIOMotorDatabase + _collection_name: str + + @overload + def __init__(self, *, client: AsyncIOMotorClient, database: str = "kvstore", collection: str = "entries") -> None: ... + + @overload + def __init__(self, *, connection_string: str, database: str = "kvstore", collection: str = "entries") -> None: ... + + @overload + def __init__( + self, + *, + host: str = "localhost", + port: int = 27017, + username: str | None = None, + password: str | None = None, + database: str = "kvstore", + collection: str = "entries", + ) -> None: ... + + def __init__( + self, + *, + client: AsyncIOMotorClient | None = None, + connection_string: str | None = None, + host: str = "localhost", + port: int = 27017, + username: str | None = None, + password: str | None = None, + database: str = "kvstore", + collection: str = "entries", + ) -> None: + """Initialize the MongoDB store. + + Args: + client: An existing AsyncIOMotorClient to use. + connection_string: MongoDB connection string (e.g., mongodb://localhost:27017/kvstore). + host: MongoDB host. Defaults to localhost. + port: MongoDB port. Defaults to 27017. + username: MongoDB username. Defaults to None. + password: MongoDB password. Defaults to None. + database: Database name to use. Defaults to kvstore. + collection: Collection name to use. Defaults to entries. + """ + if client: + self._client = client + elif connection_string: + self._client = AsyncIOMotorClient(connection_string) + # Extract database name from connection string if not in path + parsed = urlparse(connection_string) + if parsed.path and parsed.path != "/" and database != "kvstore": + pass # Keep provided database name + elif parsed.path and parsed.path != "/": + database = parsed.path.lstrip("/") + else: + # Build connection string from individual parameters + auth_str = f"{username}:{password}@" if username and password else "" + connection_str = f"mongodb://{auth_str}{host}:{port}" + self._client = AsyncIOMotorClient(connection_str) + + self._database = self._client[database] + self._collection_name = collection + super().__init__() + + @property + def _collection(self) -> AsyncIOMotorCollection: + """Get the collection for storing entries.""" + return self._database[self._collection_name] + + @override + async def setup(self) -> None: + """Initialize the MongoDB store by testing connectivity and creating indexes.""" + try: + # Test connection + await self._client.admin.command("ping") + + # Create compound index on collection+key for efficient lookups + await self._collection.create_index([("collection", 1), ("key", 1)], unique=True) + + # Create TTL index for automatic expiration + await self._collection.create_index("expires_at", expireAfterSeconds=0) + except Exception as e: + raise StoreConnectionError(message=f"Failed to connect to MongoDB: {e}") from e + + @override + async def setup_collection(self, collection: str) -> None: + """Setup collection-specific resources (no-op for MongoDB).""" + # MongoDB collections are created automatically when first document is inserted + + @override + async def get_entry(self, collection: str, key: str) -> ManagedEntry | None: + doc = await self._collection.find_one({"collection": collection, "key": key}) + + if doc is None: + return None + + # Convert MongoDB document to ManagedEntry + return ManagedEntry( + collection=doc["collection"], + key=doc["key"], + value=doc["value"], + created_at=doc.get("created_at"), + ttl=doc.get("ttl"), + expires_at=doc.get("expires_at"), + ) + + @override + async def put_entry( + self, + collection: str, + key: str, + cache_entry: ManagedEntry, + *, + ttl: float | None = None, + ) -> None: + doc = { + "collection": collection, + "key": key, + "value": cache_entry.value, + "created_at": cache_entry.created_at, + "ttl": cache_entry.ttl, + "expires_at": cache_entry.expires_at, + } + + # Use upsert to replace existing entries + await self._collection.replace_one( + {"collection": collection, "key": key}, + doc, + upsert=True, + ) + + @override + async def delete(self, collection: str, key: str) -> bool: + await self.setup_collection_once(collection=collection) + + result = await self._collection.delete_one({"collection": collection, "key": key}) + return result.deleted_count > 0 + + @override + async def keys(self, collection: str) -> list[str]: + await self.setup_collection_once(collection=collection) + + cursor = self._collection.find({"collection": collection}, {"key": 1}) + return [doc["key"] async for doc in cursor] + + @override + async def clear_collection(self, collection: str) -> int: + await self.setup_collection_once(collection=collection) + + result = await self._collection.delete_many({"collection": collection}) + return result.deleted_count + + @override + async def list_collections(self) -> list[str]: + await self.setup_once() + + pipeline = [ + {"$group": {"_id": "$collection"}}, + {"$project": {"collection": "$_id", "_id": 0}}, + ] + + cursor = self._collection.aggregate(pipeline) + return [doc["collection"] async for doc in cursor] + + @override + async def cull(self) -> None: + """MongoDB handles TTL automatically, so this is a no-op.""" + # MongoDB's TTL indexes handle expiration automatically diff --git a/tests/stores/mongodb/__init__.py b/tests/stores/mongodb/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/stores/mongodb/test_mongodb.py b/tests/stores/mongodb/test_mongodb.py new file mode 100644 index 00000000..e2af73c7 --- /dev/null +++ b/tests/stores/mongodb/test_mongodb.py @@ -0,0 +1,89 @@ +import asyncio +from collections.abc import AsyncGenerator + +import pytest +from motor.motor_asyncio import AsyncIOMotorClient +from typing_extensions import override + +from kv_store_adapter.stores.base.unmanaged import BaseKVStore +from kv_store_adapter.stores.mongodb import MongoStore +from tests.stores.conftest import BaseStoreTests + +# MongoDB test configuration +MONGO_HOST = "localhost" +MONGO_PORT = 27017 +MONGO_DB = "kvstore_test" + +WAIT_FOR_MONGO_TIMEOUT = 30 + + +async def ping_mongo() -> bool: + client = AsyncIOMotorClient(f"mongodb://{MONGO_HOST}:{MONGO_PORT}") + try: + await client.admin.command("ping") + except Exception: + return False + else: + return True + finally: + client.close() + + +async def wait_mongo() -> bool: + # with a timeout of 30 seconds + for _ in range(WAIT_FOR_MONGO_TIMEOUT): + if await ping_mongo(): + return True + await asyncio.sleep(delay=1) + + return False + + +class MongoFailedToStartError(Exception): + pass + + +class TestMongoStore(BaseStoreTests): + @pytest.fixture(autouse=True, scope="session") + async def setup_mongo(self) -> AsyncGenerator[None, None]: + # Try to connect to existing MongoDB or skip tests if not available + if not await ping_mongo(): + pytest.skip("MongoDB not available at localhost:27017") + + return + + @override + @pytest.fixture + async def store(self, setup_mongo: None) -> MongoStore: # pyright: ignore[reportUnusedParameter] + """Create a MongoDB store for testing.""" + # Create the store with test database + mongo_store = MongoStore(host=MONGO_HOST, port=MONGO_PORT, database=MONGO_DB) + + # Clear the test database + await mongo_store._database.drop_collection(mongo_store._collection_name) + + return mongo_store + + async def test_mongo_connection_string(self) -> None: + """Test MongoDB store creation with connection string.""" + connection_string = f"mongodb://{MONGO_HOST}:{MONGO_PORT}/{MONGO_DB}" + store = MongoStore(connection_string=connection_string) + + await store._database.drop_collection(store._collection_name) + await store.put(collection="test", key="conn_test", value={"test": "value"}) + result = await store.get(collection="test", key="conn_test") + assert result == {"test": "value"} + + async def test_mongo_client_connection(self) -> None: + """Test MongoDB store creation with existing client.""" + client = AsyncIOMotorClient(f"mongodb://{MONGO_HOST}:{MONGO_PORT}") + store = MongoStore(client=client, database=MONGO_DB) + + await store._database.drop_collection(store._collection_name) + await store.put(collection="test", key="client_test", value={"test": "value"}) + result = await store.get(collection="test", key="client_test") + assert result == {"test": "value"} + + @pytest.mark.skip(reason="Distributed Caches are unbounded") + @override + async def test_not_unbounded(self, store: BaseKVStore): ... diff --git a/uv.lock b/uv.lock index 7504c8d1..881a18f7 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -224,6 +224,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/76/288d91c284ac1787f01c8260af5ea89dcfa6c0abc9acd601d01cf6f72f86/diskcache_stubs-5.6.3.6.20240818-py3-none-any.whl", hash = "sha256:e1db90940b344140730976abe79f57f5b43ca296cbb43fa95da0c69b12d5de4f", size = 18391, upload-time = "2024-08-18T07:50:10.723Z" }, ] +[[package]] +name = "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 = "elastic-transport" version = "9.1.0" @@ -416,6 +425,9 @@ elasticsearch = [ memory = [ { name = "cachetools" }, ] +mongodb = [ + { name = "motor" }, +] pydantic = [ { name = "pydantic" }, ] @@ -429,7 +441,7 @@ dev = [ { name = "dirty-equals" }, { name = "diskcache-stubs" }, { name = "inline-snapshot" }, - { name = "kv-store-adapter", extra = ["disk", "elasticsearch", "memory", "pydantic", "redis"] }, + { name = "kv-store-adapter", extra = ["disk", "elasticsearch", "memory", "mongodb", "pydantic", "redis"] }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-dotenv" }, @@ -447,10 +459,11 @@ requires-dist = [ { name = "cachetools", marker = "extra == 'memory'", specifier = ">=6.0.0" }, { name = "diskcache", marker = "extra == 'disk'", specifier = ">=5.6.0" }, { name = "elasticsearch", marker = "extra == 'elasticsearch'", specifier = ">=9.0.0" }, + { name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.0.0" }, { name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.11.9" }, { name = "redis", marker = "extra == 'redis'", specifier = ">=6.0.0" }, ] -provides-extras = ["memory", "disk", "redis", "elasticsearch", "pydantic"] +provides-extras = ["memory", "disk", "redis", "elasticsearch", "mongodb", "pydantic"] [package.metadata.requires-dev] dev = [ @@ -458,7 +471,7 @@ dev = [ { name = "dirty-equals", specifier = ">=0.10.0" }, { name = "diskcache-stubs", specifier = ">=5.6.3.6.20240818" }, { name = "inline-snapshot", specifier = ">=0.29.0" }, - { name = "kv-store-adapter", extras = ["memory", "disk", "redis", "elasticsearch"] }, + { name = "kv-store-adapter", extras = ["memory", "disk", "redis", "elasticsearch", "mongodb"] }, { name = "kv-store-adapter", extras = ["pydantic"] }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -502,6 +515,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/ce/139df7074328119869a1041ce91c082d78287541cf867f9c4c85097c5d8b/mirakuru-2.6.1-py3-none-any.whl", hash = "sha256:4be0bfd270744454fa0c0466b8127b66bd55f4decaf05bbee9b071f2acbd9473", size = 26202, upload-time = "2025-07-02T07:18:39.951Z" }, ] +[[package]] +name = "motor" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pymongo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/ae/96b88362d6a84cb372f7977750ac2a8aed7b2053eed260615df08d5c84f4/motor-3.7.1.tar.gz", hash = "sha256:27b4d46625c87928f331a6ca9d7c51c2f518ba0e270939d395bc1ddc89d64526", size = 280997, upload-time = "2025-05-14T18:56:33.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/9a/35e053d4f442addf751ed20e0e922476508ee580786546d699b0567c4c67/motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298", size = 74996, upload-time = "2025-05-14T18:56:31.665Z" }, +] + [[package]] name = "multidict" version = "6.6.4" @@ -863,6 +888,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pymongo" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/c0c6732fbd358b75a07e17d7e588fd23d481b9812ca96ceeff90bbf879fc/pymongo-4.15.1.tar.gz", hash = "sha256:b9f379a4333dc3779a6bf7adfd077d4387404ed1561472743486a9c58286f705", size = 2470613, upload-time = "2025-09-16T16:39:47.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/19/2de6086e3974f3a95a1fc41fd082bc4a58dc9b70268cbfd7c84067d184f2/pymongo-4.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97ccf8222abd5b79daa29811f64ef8b6bb678b9c9a1c1a2cfa0a277f89facd1d", size = 811020, upload-time = "2025-09-16T16:37:57.329Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/a340dde32818dd5c95b1c373bc4a27cef5863009faa328388ddc899527fe/pymongo-4.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f130b3d7540749a8788a254ceb199a03ede4ee080061bfa5e20e28237c87f2d7", size = 811313, upload-time = "2025-09-16T16:37:59.312Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d9/7d64fdc9e87ec38bd36395bc730848ef56e1cd4bd29ab065d53c27559ace/pymongo-4.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fbe6a044a306ed974bd1788f3ceffc2f5e13f81fdb786a28c948c047f4cea38", size = 1188666, upload-time = "2025-09-16T16:38:00.896Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d9/47cc69d3b22c9d971b1486e3a80d6a5d0bbf2dec6c9c4d5e39a129ee8125/pymongo-4.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b96768741e0e03451ef7b07c4857490cc43999e01c7f8da704fe00b3fe5d4d3", size = 1222891, upload-time = "2025-09-16T16:38:02.574Z" }, + { url = "https://files.pythonhosted.org/packages/a9/73/a57594c956bf276069a438056330a346871b2f5e3cae4e3bcc257cffc788/pymongo-4.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d50b18ad6e4a55a75c30f0e669bd15ed1ceb18f9994d6835b4f5d5218592b4a0", size = 1205824, upload-time = "2025-09-16T16:38:04.277Z" }, + { url = "https://files.pythonhosted.org/packages/37/d5/1ae77ddcc376ebce0139614d51ec1fd0ba666d7cc1f198ec88272cfdac36/pymongo-4.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e8e2a33613b2880d516d9c8616b64d27957c488de2f8e591945cf12094336a5", size = 1191838, upload-time = "2025-09-16T16:38:05.728Z" }, + { url = "https://files.pythonhosted.org/packages/35/07/ae3fc20a809066b35bbf470bda79d34a72948603d9f29a425bf1d0ef2cb7/pymongo-4.15.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a2a439395f3d4c9d3dc33ba4575d52b6dd285d57db54e32062ae8ef557cab10", size = 1170996, upload-time = "2025-09-16T16:38:09.084Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0f/eb654cea7586588704151ac4894cd3fb2582c0db458cd615cad1c7fe4c59/pymongo-4.15.1-cp310-cp310-win32.whl", hash = "sha256:142abf2fbd4667a3c8f4ce2e30fdbd287c015f52a838f4845d7476a45340208d", size = 798249, upload-time = "2025-09-16T16:38:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/38184382c32695f914a5474d8de0c9f3714b7d8f4c66f090b3836d70273d/pymongo-4.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:8baf46384c97f774bc84178662e1fc6e32a2755fbc8e259f424780c2a11a3566", size = 807990, upload-time = "2025-09-16T16:38:12.525Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/77a4d37b2a0673c010dd97b9911438f17bb05f407235cc9f02074175855d/pymongo-4.15.1-cp310-cp310-win_arm64.whl", hash = "sha256:b5b837df8e414e2a173722395107da981d178ba7e648f612fa49b7ab4e240852", size = 800875, upload-time = "2025-09-16T16:38:14.532Z" }, + { url = "https://files.pythonhosted.org/packages/c9/da/89066930a70b4299844f1155fc23baaa7e30e77c8a0cbf62a2ae06ee34a5/pymongo-4.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:363445cc0e899b9e55ac9904a868c8a16a6c81f71c48dbadfd78c98e0b54de27", size = 865410, upload-time = "2025-09-16T16:38:16.279Z" }, + { url = "https://files.pythonhosted.org/packages/99/8f/a1d0402d52e5ebd14283718abefdc0c16f308cf10bee56cdff04b1f5119b/pymongo-4.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:da0a13f345f4b101776dbab92cec66f0b75015df0b007b47bd73bfd0305cc56a", size = 865695, upload-time = "2025-09-16T16:38:18.015Z" }, + { url = "https://files.pythonhosted.org/packages/53/38/d1ef69028923f86fd00638d9eb16400d4e60a89eabd2011fe631fd3186cf/pymongo-4.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9481a492851e432122a83755d4e69c06aeb087bbf8370bac9f96d112ac1303fd", size = 1434758, upload-time = "2025-09-16T16:38:20.141Z" }, + { url = "https://files.pythonhosted.org/packages/b0/eb/a8d5dff748a2dd333610b2e4c8120b623e38ea2b5e30ad190d0ce2803840/pymongo-4.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:625dec3e9cd7c3d336285a20728c01bfc56d37230a99ec537a6a8625af783a43", size = 1485716, upload-time = "2025-09-16T16:38:21.607Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d4/17ba457a828b733182ddc01a202872fef3006eed6b54450b20dc95a2f77d/pymongo-4.15.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26a31af455bffcc64537a7f67e2f84833a57855a82d05a085a1030c471138990", size = 1460160, upload-time = "2025-09-16T16:38:23.509Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/42b8662c09f5ca9c81d18d160f48e58842e0fa4c314ea02613c5e5d54542/pymongo-4.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea4415970d2a074d5890696af10e174d84cb735f1fa7673020c7538431e1cb6e", size = 1439284, upload-time = "2025-09-16T16:38:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bb/46b9d978161828eb91973bd441a3f05f73c789203e976332a8de2832d5db/pymongo-4.15.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51ee050a2e026e2b224d2ed382830194be20a81c78e1ef98f467e469071df3ac", size = 1407933, upload-time = "2025-09-16T16:38:27.045Z" }, + { url = "https://files.pythonhosted.org/packages/4b/55/bd5af98f675001f4b06f7314b3918e45809424a7ad3510f823f6703cd8f2/pymongo-4.15.1-cp311-cp311-win32.whl", hash = "sha256:9aef07d33839f6429dc24f2ef36e4ec906979cb4f628c57a1c2676cc66625711", size = 844328, upload-time = "2025-09-16T16:38:28.513Z" }, + { url = "https://files.pythonhosted.org/packages/c3/78/90989a290dd458ed43a8a04fa561ac9c7b3391f395cdacd42e21f0f22ce4/pymongo-4.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ea6e5ff4d6747e7b64966629a964db3089e9c1e0206d8f9cc8720c90f5a7af1", size = 858951, upload-time = "2025-09-16T16:38:30.074Z" }, + { url = "https://files.pythonhosted.org/packages/de/bb/d4d23f06e166cd773f2324cff73841a62d78a1ad16fb799cf7c5490ce32c/pymongo-4.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:bb783d9001b464a6ef3ee76c30ebbb6f977caee7bbc3a9bb1bd2ff596e818c46", size = 848290, upload-time = "2025-09-16T16:38:31.741Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/bc4525312083706a59fffe6e8de868054472308230fdee8db0c452c2b831/pymongo-4.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bab357c5ff36ba2340dfc94f3338ef399032089d35c3d257ce0c48630b7848b2", size = 920261, upload-time = "2025-09-16T16:38:33.614Z" }, + { url = "https://files.pythonhosted.org/packages/ae/55/4d99aec625494f21151b8b31e12e06b8ccd3b9dcff609b0dd1acf9bbbc0e/pymongo-4.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46d1af3eb2c274f07815372b5a68f99ecd48750e8ab54d5c3ff36a280fb41c8e", size = 919956, upload-time = "2025-09-16T16:38:35.121Z" }, + { url = "https://files.pythonhosted.org/packages/be/60/8f1afa41521df950e13f6490ecdef48155fc63b78f926e7649045e07afd1/pymongo-4.15.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7dc31357379318881186213dc5fc49b62601c955504f65c8e72032b5048950a1", size = 1698596, upload-time = "2025-09-16T16:38:36.586Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3f/e48d50ee8d6aa0a4cda7889dd73076ec2ab79a232716a5eb0b9df070ffcf/pymongo-4.15.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12140d29da1ecbaefee2a9e65433ef15d6c2c38f97bc6dab0ff246a96f9d20cd", size = 1762833, upload-time = "2025-09-16T16:38:38.09Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/db976859efc617f608754e051e1468459d9a818fe1ad5d0862e8af57720b/pymongo-4.15.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf193d2dcd91fa1d1dfa1fd036a3b54f792915a4842d323c0548d23d30461b59", size = 1731875, upload-time = "2025-09-16T16:38:39.742Z" }, + { url = "https://files.pythonhosted.org/packages/18/59/3643ad52a5064ad3ef8c32910de6da28eb658234c25f2db5366f16bffbfb/pymongo-4.15.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2c0bdcf4d57e4861ed323ba430b585ad98c010a83e46cb8aa3b29c248a82be1", size = 1701853, upload-time = "2025-09-16T16:38:41.333Z" }, + { url = "https://files.pythonhosted.org/packages/d8/96/441c190823f855fc6445ea574b39dca41156acf723c5e6a69ee718421700/pymongo-4.15.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43fcfc19446e0706bbfe86f683a477d1e699b02369dd9c114ec17c7182d1fe2b", size = 1660978, upload-time = "2025-09-16T16:38:42.877Z" }, + { url = "https://files.pythonhosted.org/packages/47/49/bd7e783fb78aaf9bdaa3f88cc238449be5bc5546e930ec98845ef235f809/pymongo-4.15.1-cp312-cp312-win32.whl", hash = "sha256:e5fedea0e7b3747da836cd5f88b0fa3e2ec5a394371f9b6a6b15927cfeb5455d", size = 891175, upload-time = "2025-09-16T16:38:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/2e/28/7de5858bdeaa07ea4b277f9eb06123ea358003659fe55e72e4e7c898b321/pymongo-4.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:330a17c1c89e2c3bf03ed391108f928d5881298c17692199d3e0cdf097a20082", size = 910619, upload-time = "2025-09-16T16:38:46.124Z" }, + { url = "https://files.pythonhosted.org/packages/17/87/c39f4f8415e7c65f8b66413f53a9272211ff7dfe78a5128b27027bf88864/pymongo-4.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:756b7a2a80ec3dd5b89cd62e9d13c573afd456452a53d05663e8ad0c5ff6632b", size = 896229, upload-time = "2025-09-16T16:38:48.563Z" }, + { url = "https://files.pythonhosted.org/packages/a6/22/02ac885d8accb4c86ae92e99681a09f3fd310c431843fc850e141b42ab17/pymongo-4.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:622957eed757e44d9605c43b576ef90affb61176d9e8be7356c1a2948812cb84", size = 974492, upload-time = "2025-09-16T16:38:50.437Z" }, + { url = "https://files.pythonhosted.org/packages/56/bf/71685b6b2d085dbaadf029b1ea4a1bc7a1bc483452513dea283b47a5f7c0/pymongo-4.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c5283dffcf601b793a57bb86819a467473bbb1bf21cd170c0b9648f933f22131", size = 974191, upload-time = "2025-09-16T16:38:52.725Z" }, + { url = "https://files.pythonhosted.org/packages/df/98/141edc92fa97af96b4c691e10a7225ac3e552914e88b7a8d439bd6bc9fcc/pymongo-4.15.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:def51dea1f8e336aed807eb5d2f2a416c5613e97ec64f07479681d05044c217c", size = 1962311, upload-time = "2025-09-16T16:38:54.319Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a9/601b91607af1dec8035b46ba67a5a023c819ccedd40d6f6232e15bf76030/pymongo-4.15.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24171b2015052b2f0a3f8cbfa38b973fa87f6474e88236a4dfeb735983f9f49e", size = 2039667, upload-time = "2025-09-16T16:38:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/4f/71/02e9a5248e0a9dfc371fd7350f8b11eac03d9eb3662328978f37613d319a/pymongo-4.15.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64b60ed7220c52f8c78c7af8d2c58f7e415732e21b3ff7e642169efa6e0b11e7", size = 2003579, upload-time = "2025-09-16T16:38:57.576Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d1/b1a9520b33e022ed1c0d2d43e8805ba18d3d686fc9c9d89a507593f6dd86/pymongo-4.15.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58236ce5ba3a79748c1813221b07b411847fd8849ff34c2891ba56f807cce3e5", size = 1964307, upload-time = "2025-09-16T16:38:59.219Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d1/1d205a762020f056c05899a912364c48bac0f3438502b36d057aa1da3ca5/pymongo-4.15.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7461e777b3da96568c1f077b1fbf9e0c15667ac4d8b9a1cf90d80a69fe3be609", size = 1913879, upload-time = "2025-09-16T16:39:01.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d1/0a3ab2440ea00b6423f33c84e6433022fd51f3561dede9346f54f39cf4dd/pymongo-4.15.1-cp313-cp313-win32.whl", hash = "sha256:45f0a2fb09704ca5e0df08a794076d21cbe5521d3a8ceb8ad6d51cef12f5f4e7", size = 938007, upload-time = "2025-09-16T16:39:03.427Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/e9ea839af2caadfde91774549a6f72450b72efdc92117995e7117d4b1270/pymongo-4.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:b70201a6dbe19d0d10a886989d3ba4b857ea6ef402a22a61c8ca387b937cc065", size = 962236, upload-time = "2025-09-16T16:39:05.018Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f8/0a92a72993b2e1c110ee532650624ca7ae15c5e45906dbae4f063a2fd32a/pymongo-4.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:6892ebf8b2bc345cacfe1301724195d87162f02d01c417175e9f27d276a2f198", size = 944138, upload-time = "2025-09-16T16:39:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/e5/eb/2ba257482844bb2e3c82c6b266d6e811bc610fa80408133e352cc1afb3c9/pymongo-4.15.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:db439288516514713c8ee09c9baaf66bc4b0188fbe4cd578ef3433ee27699aab", size = 1030987, upload-time = "2025-09-16T16:39:08.914Z" }, + { url = "https://files.pythonhosted.org/packages/0d/86/8c6eab3767251ba77a3604d3b6b0826d0af246bd04b2d16aced3a54f08b0/pymongo-4.15.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:234c80a5f21c8854cc5d6c2f5541ff17dd645b99643587c5e7ed1e21d42003b6", size = 1030996, upload-time = "2025-09-16T16:39:10.429Z" }, + { url = "https://files.pythonhosted.org/packages/5b/26/c1bc0bcb64f39b9891b8b537f21cc37d668edd8b93f47ed930af7f95649c/pymongo-4.15.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b570dc8179dcab980259b885116b14462bcf39170e30d8cbcce6f17f28a2ac5b", size = 2290670, upload-time = "2025-09-16T16:39:12.348Z" }, + { url = "https://files.pythonhosted.org/packages/82/af/f5e8b6c404a3678a99bf0b704f7b19fa14a71edb42d724eb09147aa1d3be/pymongo-4.15.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb6321bde02308d4d313b487d19bfae62ea4d37749fc2325b1c12388e05e4c31", size = 2377711, upload-time = "2025-09-16T16:39:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/af/f4/63bcc1760bf3e0925cb6cb91b2b3ba756c113b1674a14b41efe7e3738b8d/pymongo-4.15.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc808588289f693aba80fae8272af4582a7d6edc4e95fb8fbf65fe6f634116ce", size = 2337097, upload-time = "2025-09-16T16:39:15.717Z" }, + { url = "https://files.pythonhosted.org/packages/d0/dc/0cfada0426556b4b04144fb00ce6a1e7535ab49623d4d9dd052d27ea46c0/pymongo-4.15.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99236fd0e0cf6b048a4370d0df6820963dc94f935ad55a2e29af752272abd6c9", size = 2288295, upload-time = "2025-09-16T16:39:17.385Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a8/081a80f60042d2b8cd6a1c091ecaa186f1ef216b587d06acd0743e1016c6/pymongo-4.15.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2277548bb093424742325b2a88861d913d8990f358fc71fd26004d1b87029bb8", size = 2227616, upload-time = "2025-09-16T16:39:19.025Z" }, + { url = "https://files.pythonhosted.org/packages/56/d0/a6007e0c3c5727391ac5ea40e93a1e7d14146c65ac4ca731c0680962eb48/pymongo-4.15.1-cp313-cp313t-win32.whl", hash = "sha256:754a5d75c33d49691e2b09a4e0dc75959e271a38cbfd92c6b36f7e4eafc4608e", size = 987225, upload-time = "2025-09-16T16:39:20.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/c9bf6dcd647a8cf7abbad5814dfb7d8a16e6ab92a3e56343b3bcb454a6d3/pymongo-4.15.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8d62e68ad21661e536555d0683087a14bf5c74b242a4446c602d16080eb9e293", size = 1017521, upload-time = "2025-09-16T16:39:22.319Z" }, + { url = "https://files.pythonhosted.org/packages/31/ea/102f7c9477302fa05e5303dd504781ac82400e01aab91bfba9c290253bd6/pymongo-4.15.1-cp313-cp313t-win_arm64.whl", hash = "sha256:56bbfb79b51e95f4b1324a5a7665f3629f4d27c18e2002cfaa60c907cc5369d9", size = 992963, upload-time = "2025-09-16T16:39:23.957Z" }, +] + [[package]] name = "pytest" version = "8.4.2"