From c9ad454fb69528bd937c33bfa1ced16c279f4e66 Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Thu, 11 Jun 2026 08:53:48 +0530 Subject: [PATCH 1/2] feat(document): add optional sync client via get_mongodb_sync --- CLAUDE.md | 70 +++++++++ README.md | 19 +++ cloudrift/__init__.py | 3 +- cloudrift/document/__init__.py | 47 +++++- cloudrift/document/cosmos_sync.py | 88 ++++++++++++ cloudrift/document/documentdb_sync.py | 110 ++++++++++++++ pyproject.toml | 3 + tests/test_document_sync.py | 197 ++++++++++++++++++++++++++ 8 files changed, 535 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 cloudrift/document/cosmos_sync.py create mode 100644 cloudrift/document/documentdb_sync.py create mode 100644 tests/test_document_sync.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..747418d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`lyzr-cloudrift` is a cloud-agnostic abstraction layer for Lyzr microservices, covering six categories: **storage**, **messaging**, **document DB**, **cache**, **secrets**, and **pub/sub**. Each category exposes the same interface across AWS, Azure, and (for cache) self-hosted backends, so a service swaps providers by changing a single string. Everything is **async-first** — public methods are `async def`, backed by native-async SDK clients (`aioboto3`, `azure.*.aio`, `motor`, `redis.asyncio`); there is no thread-pool wrapping. The one deliberate exception: the document category also exposes an **optional sync factory** (`get_mongodb_sync`, returning a raw `pymongo.MongoClient`) for services that don't run an event loop. + +## Commands + +The project uses `uv`. Optional-dependency extras gate which providers install. + +```bash +uv sync --extra dev # install with test/lint tooling (matches CI) +uv run pytest tests/ -v # run the full test suite +uv run pytest tests/test_cache.py # single file +uv run pytest tests/test_cache.py::test_set_and_get # single test +uv run ruff check . # lint (line-length 100, target py311) +uv run ruff format . # format +``` + +Tests run against in-process mocks (`fakeredis`, `moto`/`ThreadedMotoServer`, and recording stand-ins for the Mongo clients), so **no real cloud credentials are ever needed**. `asyncio_mode = "auto"` is set, so `async def test_*` functions need no `@pytest.mark.asyncio` decorator. + +CI: pushes to `develop` test + publish to TestPyPI; pushes to `main` test + publish to PyPI (`.github/workflows/`). + +## Architecture + +Five of the six categories (all but document) are self-contained packages under `cloudrift/` following an identical three-part shape: + +1. **`base.py`** — an `ABC` defining the provider-neutral interface (e.g. `StorageBackend`, `CacheBackend`, `MessagingBackend`). All `@abstractmethod`s are async. Concrete, non-abstract helpers (`__aenter__`/`__aexit__`, `health_check`, default `pipeline`) live here too. +2. **Per-provider modules** — e.g. `s3.py` + `azure_blob.py`, `redis_standalone.py` + `redis_elasticache.py` + `redis_azure.py`. Each subclasses the ABC and is constructed **only** via `from_*` classmethods (`from_iam_role`, `from_access_key`, `from_connection_string`, `from_managed_identity`, etc.) — never a bare `__init__` with credentials. +3. **`__init__.py`** — a `get_*` factory function that selects the provider and routes to the right `from_*` constructor. + +Provider SDKs are imported **lazily inside the factory branch**, not at module top level, so a service installing only `cloudrift[aws]` never imports Azure packages. + +### Document DB is different — no wrappers + +The document category deliberately has **no `base.py` ABC and no backend wrapper classes** (they were removed in the v0.2.0 refactor — don't reintroduce them). Both providers speak the MongoDB wire protocol, so `get_mongodb` returns a raw `motor` `AsyncIOMotorClient` and the caller uses Motor's native API directly. `documentdb.py` and `cosmos.py` contain only plain `connect_*` factory functions (`connect_uri`, `connect_credentials`, `connect_tls_cert`; `connect_connection_string`, `connect_account_key`) that build the URI, translate construction failures to `DocumentConnectionError`, and return the client. The sync variant mirrors this exactly: `get_mongodb_sync` returns a raw `pymongo.MongoClient` via identical `connect_*` functions in `documentdb_sync.py`/`cosmos_sync.py`. Cosmos here is the **MongoDB API** (keys-only auth — AAD tokens don't work at the wire-protocol layer), not the SQL/Core API. + +### Two factory-dispatch styles — don't conflate them + +- **Cache** uses an explicit auth-method argument: `get_cache(provider, auth_method, **kwargs)` where `auth_method` is the literal `from_*` method name (e.g. `get_cache("redis", "from_url", url=...)`). +- **All five other categories** infer the constructor from **which credential keys are present** in `**kwargs`: `get_storage(provider, **kwargs)` calls `from_access_key` if `aws_access_key_id` is present, `from_connection_string` if `connection_string` is present, and falls through to the managed-identity/IAM-role default. When adding an auth method here, add both the constructor (a `from_*` classmethod, or a `connect_*` function for document) and a routing branch in the factory — for document, in **both** `get_mongodb` and `get_mongodb_sync`. + +### Redis cache specifics + +The three Redis backends (`redis`, `elasticache`, `azure_redis`) share **all** their operation logic through `_RedisMixin` in `cache/base.py` — the per-provider modules contain *only* the `from_*` constructors that build the `aioredis.Redis` client with the right auth (URL, IAM SigV4 auto-refresh, access key, managed identity). A new Redis command is implemented **once** in `_RedisMixin` and added as an `@abstractmethod` on `CacheBackend`; never reimplement it per provider. `CacheBackend.pipeline()` ships a no-atomicity `_SequentialPipeline` fallback that the Redis mixin overrides with a real server-side transactional pipeline. + +### Errors + +All backends raise from one hierarchy in `cloudrift/core/exceptions.py`, rooted at `CloudRiftError` with a per-category base (`StorageError`, `CacheError`, `MessagingError`, `DocumentError`, `SecretError`, `PubSubError`) and specific subclasses. Provider-native exceptions (`botocore.ClientError`, `RedisError`, `azure.core.exceptions.*`) are **caught and translated to this hierarchy at the backend boundary** — callers should only ever see cloudrift exceptions. + +### Lifecycle + +Backends hold one long-lived, connection-pooled async client meant to be constructed **once at service startup** and reused — never per-request. Always release sockets with `await backend.close()` or `async with backend:` (the ABCs implement the async-context-manager protocol). + +## Provider/category matrix + +| Category | Factory | AWS | Azure | Self-hosted | +|---|---|---|---|---| +| Storage | `get_storage` | `s3` | `azure_blob` | — | +| Messaging | `get_queue` | `sqs` | `azure_bus` | — | +| Document DB | `get_mongodb` | `documentdb` | `cosmos` | — | +| Cache | `get_cache` | `elasticache` | `azure_redis` | `redis` | +| Secrets | `get_secrets` | `aws_secrets_manager` | `azure_keyvault` | — | +| Pub/Sub | `get_pubsub` | `sns` | `azure_eventgrid` | — | + +## Known abstraction gaps + +The interface is uniform but not every operation maps cleanly to every provider. When a provider can't honor a method, it raises `NotImplementedError` rather than silently differing — e.g. Azure Service Bus `delete(receipt_handle)` (Service Bus acks via the receiver's lock token, not a handle). Preserve this fail-loud convention; document the gap in the method docstring as the existing code does. diff --git a/README.md b/README.md index f73e2c0..ad160b6 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,25 @@ client.close() > factories for Cosmos that called the SQL API; those have been removed > in favour of a single Motor-based path. +### Optional sync client + +For services that don't run an event loop, `get_mongodb_sync(...)` returns a +blocking [PyMongo](https://pymongo.readthedocs.io/) `MongoClient` — the sync +driver Motor wraps — with identical provider and auth routing: + +```python +from cloudrift.document import get_mongodb_sync + +client = get_mongodb_sync("documentdb", uri="mongodb://...") +client = get_mongodb_sync("cosmos", account="myacct", account_key="...") + +users = client["lyzr"]["users"] +users.insert_one({"name": "Alice"}) +doc = users.find_one({"name": "Alice"}) + +client.close() +``` + --- ## Cache diff --git a/cloudrift/__init__.py b/cloudrift/__init__.py index 194df63..ec1fc68 100644 --- a/cloudrift/__init__.py +++ b/cloudrift/__init__.py @@ -1,6 +1,6 @@ from cloudrift.storage import get_storage from cloudrift.messaging import get_queue -from cloudrift.document import get_mongodb +from cloudrift.document import get_mongodb, get_mongodb_sync from cloudrift.cache import get_cache from cloudrift.secrets import get_secrets from cloudrift.pubsub import get_pubsub @@ -11,6 +11,7 @@ "get_storage", "get_queue", "get_mongodb", + "get_mongodb_sync", "get_cache", "get_secrets", "get_pubsub", diff --git a/cloudrift/document/__init__.py b/cloudrift/document/__init__.py index 579eecb..a908e31 100644 --- a/cloudrift/document/__init__.py +++ b/cloudrift/document/__init__.py @@ -9,8 +9,13 @@ client = get_mongodb("documentdb", uri="mongodb://...") await client["mydb"]["users"].insert_one({"name": "Alice"}) client.close() + +An optional synchronous variant, :func:`get_mongodb_sync`, returns a +:class:`pymongo.MongoClient` with identical provider/auth routing for services +that don't run an event loop. """ from motor.motor_asyncio import AsyncIOMotorClient +from pymongo import MongoClient def get_mongodb(provider: str, **kwargs) -> AsyncIOMotorClient: @@ -52,4 +57,44 @@ def get_mongodb(provider: str, **kwargs) -> AsyncIOMotorClient: ) -__all__ = ["get_mongodb"] +def get_mongodb_sync(provider: str, **kwargs) -> MongoClient: + """Factory to build a *synchronous* MongoDB client for the given provider. + + Same providers and ``connect_*`` routing as :func:`get_mongodb`, but returns + a blocking :class:`pymongo.MongoClient` — for services that don't run an + event loop. + + Args: + provider: ``"documentdb"`` or ``"cosmos"``. + **kwargs: Provider-specific config. Routed to the appropriate + ``connect_*`` function based on which keys are present. + + Examples: + get_mongodb_sync("documentdb", uri="mongodb://...") + get_mongodb_sync("documentdb", host="...", port=27017, + username="u", password="p") + get_mongodb_sync("cosmos", connection_string="mongodb://...") + get_mongodb_sync("cosmos", account="myacct", account_key="...") + """ + if provider == "documentdb": + from cloudrift.document import documentdb_sync + + if "uri" in kwargs: + return documentdb_sync.connect_uri(**kwargs) + if "tls_cert_key_file" in kwargs: + return documentdb_sync.connect_tls_cert(**kwargs) + return documentdb_sync.connect_credentials(**kwargs) + + if provider == "cosmos": + from cloudrift.document import cosmos_sync + + if "connection_string" in kwargs: + return cosmos_sync.connect_connection_string(**kwargs) + return cosmos_sync.connect_account_key(**kwargs) + + raise ValueError( + f"Unknown document DB provider: {provider!r}. Choose 'documentdb' or 'cosmos'." + ) + + +__all__ = ["get_mongodb", "get_mongodb_sync"] diff --git a/cloudrift/document/cosmos_sync.py b/cloudrift/document/cosmos_sync.py new file mode 100644 index 0000000..a7444f9 --- /dev/null +++ b/cloudrift/document/cosmos_sync.py @@ -0,0 +1,88 @@ +"""Azure Cosmos DB (MongoDB API) connection factory (synchronous). + +Returns a configured :class:`pymongo.MongoClient` — the blocking counterpart of +:mod:`cloudrift.document.cosmos` for services that don't run an event loop. +Identical in shape to :mod:`cloudrift.document.documentdb_sync`. + +Only key-based auth is supported here: Cosmos for MongoDB (RU) does not accept +Azure AD tokens at the Mongo wire-protocol layer. Use a connection string from +the portal or build one from the account name + key. + +Lifecycle is caller-managed: call ``client.close()`` at shutdown. +""" +from urllib.parse import quote_plus + +from pymongo import MongoClient + +from cloudrift.core.exceptions import DocumentConnectionError + + +def connect_connection_string( + connection_string: str, + *, + max_pool_size: int = 100, + min_pool_size: int = 0, +) -> MongoClient: + """Connect using a Cosmos MongoDB-API connection string from the Azure portal. + + The portal exposes this under *Connection strings* on a Cosmos account + configured for the MongoDB API. + + Args: + connection_string: Mongo-format URI from the Cosmos portal. + max_pool_size: Max connection pool size. Overrides any ``maxPoolSize`` + in the connection string. + min_pool_size: Min connection pool size. Overrides any ``minPoolSize`` + in the connection string. + """ + try: + return MongoClient( + connection_string, + maxPoolSize=max_pool_size, + minPoolSize=min_pool_size, + ) + except Exception as e: + raise DocumentConnectionError(f"Failed to connect to Cosmos DB: {e}") from e + + +def connect_account_key( + account: str, + account_key: str, + *, + port: int = 10255, + app_name: str | None = None, + max_pool_size: int = 100, + min_pool_size: int = 0, +) -> MongoClient: + """Build a Cosmos MongoDB-API URI from the account name and key. + + Args: + account: Cosmos account name (the leftmost label of the host, i.e. + ``.mongo.cosmos.azure.com``). + account_key: Primary or secondary account key. + port: Mongo-API port (default ``10255``). + app_name: Optional ``appName`` URI parameter (Cosmos uses it for + telemetry and routing). Defaults to ``@@``. + max_pool_size: Max connection pool size. + min_pool_size: Min connection pool size. + """ + user = quote_plus(account) + pwd = quote_plus(account_key) + host = f"{account}.mongo.cosmos.azure.com" + app = app_name if app_name is not None else f"@{account}@" + query = ( + "ssl=true" + "&replicaSet=globaldb" + "&retryWrites=false" + "&maxIdleTimeMS=120000" + f"&appName={quote_plus(app)}" + ) + uri = f"mongodb://{user}:{pwd}@{host}:{port}/?{query}" + try: + return MongoClient( + uri, + maxPoolSize=max_pool_size, + minPoolSize=min_pool_size, + ) + except Exception as e: + raise DocumentConnectionError(f"Failed to connect to Cosmos DB: {e}") from e diff --git a/cloudrift/document/documentdb_sync.py b/cloudrift/document/documentdb_sync.py new file mode 100644 index 0000000..dcf9a96 --- /dev/null +++ b/cloudrift/document/documentdb_sync.py @@ -0,0 +1,110 @@ +"""AWS DocumentDB connection factory (synchronous). + +Returns a configured :class:`pymongo.MongoClient` — the blocking counterpart of +:mod:`cloudrift.document.documentdb` for services that don't run an event loop. +The caller selects database and collection (``client[db][collection]``) and +uses PyMongo's native API directly. + +Lifecycle is caller-managed: call ``client.close()`` at shutdown. +""" +from urllib.parse import quote_plus + +from pymongo import MongoClient + +from cloudrift.core.exceptions import DocumentConnectionError + + +def connect_uri( + uri: str, + *, + tls_ca_file: str | None = None, + max_pool_size: int = 100, + min_pool_size: int = 0, + **client_kwargs, +) -> MongoClient: + """Connect using a full MongoDB-compatible URI.""" + kwargs: dict = {"maxPoolSize": max_pool_size, "minPoolSize": min_pool_size} + if tls_ca_file: + kwargs["tlsCAFile"] = tls_ca_file + kwargs.update(client_kwargs) + try: + return MongoClient(uri, **kwargs) + except Exception as e: + raise DocumentConnectionError(f"Failed to connect to DocumentDB: {e}") from e + + +def connect_credentials( + host: str, + port: int, + username: str, + password: str, + *, + tls: bool = True, + tls_ca_file: str | None = None, + max_pool_size: int = 100, + min_pool_size: int = 0, +) -> MongoClient: + """Connect using explicit host, port, username, and password. + + Args: + host: DocumentDB cluster endpoint hostname. + port: Port number (DocumentDB default: 27017). + username: Database username. + password: Database password. + tls: Enable TLS (default ``True``; required for AWS DocumentDB). + tls_ca_file: Optional path to the CA certificate bundle (PEM). + max_pool_size: Max connection pool size. + min_pool_size: Min connection pool size. + """ + uri = f"mongodb://{quote_plus(username)}:{quote_plus(password)}@{host}:{port}/" + kwargs: dict = { + "tls": tls, + "maxPoolSize": max_pool_size, + "minPoolSize": min_pool_size, + } + if tls_ca_file: + kwargs["tlsCAFile"] = tls_ca_file + try: + return MongoClient(uri, **kwargs) + except Exception as e: + raise DocumentConnectionError(f"Failed to connect to DocumentDB: {e}") from e + + +def connect_tls_cert( + host: str, + port: int, + username: str, + password: str, + *, + tls_cert_key_file: str, + tls_ca_file: str | None = None, + max_pool_size: int = 100, + min_pool_size: int = 0, +) -> MongoClient: + """Connect using mutual TLS (mTLS) with a client certificate. + + Args: + host: DocumentDB cluster endpoint hostname. + port: Port number (DocumentDB default: 27017). + username: Database username. + password: Database password. + tls_cert_key_file: Path to a PEM file containing the client private key + followed by the client certificate (combined, as required by + pymongo). + tls_ca_file: Optional path to the CA certificate bundle (PEM). + max_pool_size: Max connection pool size. + min_pool_size: Min connection pool size. + """ + uri = f"mongodb://{quote_plus(username)}:{quote_plus(password)}@{host}:{port}/" + kwargs: dict = { + "tls": True, + "tlsCertificateKeyFile": tls_cert_key_file, + "maxPoolSize": max_pool_size, + "minPoolSize": min_pool_size, + } + if tls_ca_file: + kwargs["tlsCAFile"] = tls_ca_file + try: + return MongoClient(uri, **kwargs) + except Exception as e: + raise DocumentConnectionError(f"Failed to connect to DocumentDB: {e}") from e diff --git a/pyproject.toml b/pyproject.toml index 01e1005..d304c5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ Documentation = "https://github.com/LYZR-OSS/cloudrift#readme" aws = [ "aioboto3>=13.0.0", # native async S3 / SQS / SNS / Secrets Manager / SES "motor>=3.3.0", # async MongoDB driver for DocumentDB + "pymongo>=4.5.0", # sync MongoDB driver (optional sync client) "redis[hiredis]>=5.0.0", # ElastiCache (also used by standalone) ] azure = [ @@ -46,6 +47,7 @@ azure = [ "azure-eventgrid>=4.9.0", "azure-communication-email>=1.0.0", "motor>=3.3.0", # Cosmos DB via MongoDB API + "pymongo>=4.5.0", # sync MongoDB driver (optional sync client) "redis[hiredis]>=5.0.0", # Azure Cache for Redis ] cache = [ @@ -57,6 +59,7 @@ email = [ all = [ "aioboto3>=13.0.0", "motor>=3.3.0", + "pymongo>=4.5.0", "azure-storage-blob>=12.19.0", "azure-servicebus>=7.11.0", "azure-identity>=1.15.0", diff --git a/tests/test_document_sync.py b/tests/test_document_sync.py new file mode 100644 index 0000000..26d140e --- /dev/null +++ b/tests/test_document_sync.py @@ -0,0 +1,197 @@ +import pytest +from pymongo import MongoClient + +from cloudrift.core.exceptions import DocumentConnectionError +from cloudrift.document import get_mongodb_sync + + +class _RecordingClient: + """Stand-in for MongoClient that records constructor args.""" + + instances: list["_RecordingClient"] = [] + + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + _RecordingClient.instances.append(self) + + def close(self): + pass + + +@pytest.fixture +def recorder(monkeypatch): + _RecordingClient.instances.clear() + import cloudrift.document.cosmos_sync as cosmos_mod + import cloudrift.document.documentdb_sync as docdb_mod + + monkeypatch.setattr(docdb_mod, "MongoClient", _RecordingClient) + monkeypatch.setattr(cosmos_mod, "MongoClient", _RecordingClient) + return _RecordingClient + + +def test_documentdb_uri_returns_pymongo_client(): + # Real PyMongo client (no recorder) — verifies the dispatch returns the right type. + client = get_mongodb_sync( + "documentdb", + uri="mongodb://localhost:27017/", + max_pool_size=50, + min_pool_size=5, + ) + assert isinstance(client, MongoClient) + client.close() + + +def test_documentdb_uri_passes_pool_kwargs(recorder): + get_mongodb_sync( + "documentdb", + uri="mongodb://h:27017/", + max_pool_size=200, + min_pool_size=10, + tls_ca_file="/etc/ssl/ca.pem", + ) + inst = recorder.instances[-1] + assert inst.args == ("mongodb://h:27017/",) + assert inst.kwargs["maxPoolSize"] == 200 + assert inst.kwargs["minPoolSize"] == 10 + assert inst.kwargs["tlsCAFile"] == "/etc/ssl/ca.pem" + + +def test_documentdb_credentials_url_encodes_password(recorder): + get_mongodb_sync( + "documentdb", + host="cluster.docdb.amazonaws.com", + port=27017, + username="admin", + password="p@ss/word", + tls=True, + ) + inst = recorder.instances[-1] + uri = inst.args[0] + # raw '@' / '/' in the password would corrupt the URI; quote_plus encodes them + assert "p%40ss%2Fword" in uri + assert uri.startswith("mongodb://admin:") + assert "cluster.docdb.amazonaws.com:27017" in uri + assert inst.kwargs["tls"] is True + + +def test_documentdb_tls_cert_passes_cert_path(recorder): + get_mongodb_sync( + "documentdb", + host="cluster.docdb.amazonaws.com", + port=27017, + username="admin", + password="pw", + tls_cert_key_file="/secrets/client.pem", + tls_ca_file="/secrets/ca.pem", + ) + inst = recorder.instances[-1] + assert inst.kwargs["tls"] is True + assert inst.kwargs["tlsCertificateKeyFile"] == "/secrets/client.pem" + assert inst.kwargs["tlsCAFile"] == "/secrets/ca.pem" + + +def test_cosmos_account_key_builds_mongo_uri(recorder): + get_mongodb_sync("cosmos", account="myacct", account_key="raw+key/with=special") + inst = recorder.instances[-1] + uri = inst.args[0] + assert uri.startswith("mongodb://myacct:") + assert "myacct.mongo.cosmos.azure.com:10255" in uri + assert "ssl=true" in uri + assert "replicaSet=globaldb" in uri + assert "retryWrites=false" in uri + # the '+' / '/' / '=' in the key must be URL-encoded + assert "raw%2Bkey%2Fwith%3Dspecial" in uri + + +def test_cosmos_connection_string_passed_through(recorder): + cs = "mongodb://acct:key@acct.mongo.cosmos.azure.com:10255/?ssl=true" + get_mongodb_sync("cosmos", connection_string=cs) + inst = recorder.instances[-1] + assert inst.args == (cs,) + + +@pytest.mark.parametrize( + "kwargs,expected_max,expected_min", + [ + # defaults + ({"uri": "mongodb://h/"}, 100, 0), + ({"host": "h", "port": 27017, "username": "u", "password": "p"}, 100, 0), + ( + { + "host": "h", "port": 27017, "username": "u", "password": "p", + "tls_cert_key_file": "/c.pem", + }, + 100, 0, + ), + # explicit + ({"uri": "mongodb://h/", "max_pool_size": 250, "min_pool_size": 25}, 250, 25), + ( + {"host": "h", "port": 27017, "username": "u", "password": "p", + "max_pool_size": 250, "min_pool_size": 25}, + 250, 25, + ), + ( + {"host": "h", "port": 27017, "username": "u", "password": "p", + "tls_cert_key_file": "/c.pem", + "max_pool_size": 250, "min_pool_size": 25}, + 250, 25, + ), + ], +) +def test_documentdb_pool_kwargs_standardized(recorder, kwargs, expected_max, expected_min): + get_mongodb_sync("documentdb", **kwargs) + inst = recorder.instances[-1] + assert inst.kwargs["maxPoolSize"] == expected_max + assert inst.kwargs["minPoolSize"] == expected_min + + +@pytest.mark.parametrize( + "kwargs,expected_max,expected_min", + [ + ({"connection_string": "mongodb://h/"}, 100, 0), + ({"account": "a", "account_key": "k"}, 100, 0), + ( + {"connection_string": "mongodb://h/", + "max_pool_size": 250, "min_pool_size": 25}, + 250, 25, + ), + ( + {"account": "a", "account_key": "k", + "max_pool_size": 250, "min_pool_size": 25}, + 250, 25, + ), + ], +) +def test_cosmos_pool_kwargs_standardized(recorder, kwargs, expected_max, expected_min): + get_mongodb_sync("cosmos", **kwargs) + inst = recorder.instances[-1] + assert inst.kwargs["maxPoolSize"] == expected_max + assert inst.kwargs["minPoolSize"] == expected_min + + +def test_invalid_provider(): + with pytest.raises(ValueError, match="Unknown document DB provider"): + get_mongodb_sync("dynamodb", uri="x") + + +def test_documentdb_connect_failure_wrapped(monkeypatch): + import cloudrift.document.documentdb_sync as mod + + def boom(*args, **kwargs): + raise RuntimeError("bad uri") + + monkeypatch.setattr(mod, "MongoClient", boom) + with pytest.raises(DocumentConnectionError, match="Failed to connect to DocumentDB"): + get_mongodb_sync("documentdb", uri="mongodb://broken") + + +def test_cosmos_connect_failure_wrapped(monkeypatch): + import cloudrift.document.cosmos_sync as mod + + def boom(*args, **kwargs): + raise RuntimeError("bad key") + + monkeypatch.setattr(mod, "MongoClient", boom) + with pytest.raises(DocumentConnectionError, match="Failed to connect to Cosmos DB"): + get_mongodb_sync("cosmos", account="a", account_key="k") From e257a68b533c02e783147aaecf92b5e84dc46687 Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Thu, 11 Jun 2026 15:42:19 +0530 Subject: [PATCH 2/2] fix(deps): pin pymongo>=4.6.3 and add it to the dev extra --- pyproject.toml | 7 ++++--- uv.lock | 8 ++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d304c5e..aa40960 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ Documentation = "https://github.com/LYZR-OSS/cloudrift#readme" aws = [ "aioboto3>=13.0.0", # native async S3 / SQS / SNS / Secrets Manager / SES "motor>=3.3.0", # async MongoDB driver for DocumentDB - "pymongo>=4.5.0", # sync MongoDB driver (optional sync client) + "pymongo>=4.6.3", # sync MongoDB driver (optional sync client) "redis[hiredis]>=5.0.0", # ElastiCache (also used by standalone) ] azure = [ @@ -47,7 +47,7 @@ azure = [ "azure-eventgrid>=4.9.0", "azure-communication-email>=1.0.0", "motor>=3.3.0", # Cosmos DB via MongoDB API - "pymongo>=4.5.0", # sync MongoDB driver (optional sync client) + "pymongo>=4.6.3", # sync MongoDB driver (optional sync client) "redis[hiredis]>=5.0.0", # Azure Cache for Redis ] cache = [ @@ -59,7 +59,7 @@ email = [ all = [ "aioboto3>=13.0.0", "motor>=3.3.0", - "pymongo>=4.5.0", + "pymongo>=4.6.3", "azure-storage-blob>=12.19.0", "azure-servicebus>=7.11.0", "azure-identity>=1.15.0", @@ -75,6 +75,7 @@ dev = [ "moto[s3,sqs,sns,ses,secretsmanager,server]>=5.0", "aioboto3>=13.0.0", "motor>=3.3.0", + "pymongo>=4.6.3", "fakeredis>=2.20.0", "httpx>=0.25.0", "ruff>=0.4.0", diff --git a/uv.lock b/uv.lock index ce44ec6..772889d 100644 --- a/uv.lock +++ b/uv.lock @@ -1154,11 +1154,13 @@ all = [ { name = "azure-servicebus" }, { name = "azure-storage-blob" }, { name = "motor" }, + { name = "pymongo" }, { name = "redis", extra = ["hiredis"] }, ] aws = [ { name = "aioboto3" }, { name = "motor" }, + { name = "pymongo" }, { name = "redis", extra = ["hiredis"] }, ] azure = [ @@ -1169,6 +1171,7 @@ azure = [ { name = "azure-servicebus" }, { name = "azure-storage-blob" }, { name = "motor" }, + { name = "pymongo" }, { name = "redis", extra = ["hiredis"] }, ] cache = [ @@ -1183,6 +1186,7 @@ dev = [ { name = "httpx" }, { name = "moto", extra = ["s3", "server"] }, { name = "motor" }, + { name = "pymongo" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -1225,6 +1229,10 @@ requires-dist = [ { name = "motor", marker = "extra == 'aws'", specifier = ">=3.3.0" }, { name = "motor", marker = "extra == 'azure'", specifier = ">=3.3.0" }, { name = "motor", marker = "extra == 'dev'", specifier = ">=3.3.0" }, + { name = "pymongo", marker = "extra == 'all'", specifier = ">=4.6.3" }, + { name = "pymongo", marker = "extra == 'aws'", specifier = ">=4.6.3" }, + { name = "pymongo", marker = "extra == 'azure'", specifier = ">=4.6.3" }, + { name = "pymongo", marker = "extra == 'dev'", specifier = ">=4.6.3" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "redis", extras = ["hiredis"], marker = "extra == 'all'", specifier = ">=5.0.0" },