From e20368287d748f77eb7fd86a8092f0ad512b21ae Mon Sep 17 00:00:00 2001 From: disqualifier Date: Mon, 29 Jun 2026 23:11:11 -0400 Subject: [PATCH] feat: MongoDB class (Mongo kept as alias) + connect()/async with + exists/delete aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit additive only — every existing name preserved, swallow contract unchanged. brings mongo in line with the redis/psql/mysql datastore trio's naming/lifecycle. bump v0.1.3 -> v0.1.4 Signed-off-by: disqualifier --- README.md | 36 +++++++++++++++++++++----- pyproject.toml | 2 +- src/mongo/__init__.py | 6 ++--- src/mongo/mongo.py | 60 ++++++++++++++++++++++++++++++++++++++----- 4 files changed, 87 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ff00f8f..fb08d01 100644 --- a/README.md +++ b/README.md @@ -8,31 +8,39 @@ helpers for the common paths, with a raw escape hatch for everything else. `requirements.txt`: ``` -mongo @ git+ssh://git@git.rethinkstudios.io/rethink-public/mongo.git@v0.1.3 +mongo @ git+ssh://git@git.rethinkstudios.io/rethink-public/mongo.git@v0.1.4 ``` Direct: ```bash -pip install "mongo @ git+ssh://git@git.rethinkstudios.io/rethink-public/mongo.git@v0.1.3" +pip install "mongo @ git+ssh://git@git.rethinkstudios.io/rethink-public/mongo.git@v0.1.4" ``` Requires `motor` and `pymongo` (pulled transitively). -Drop the `@v0.1.3` suffix from the line above to install the latest unpinned. +Drop the `@v0.1.4` suffix from the line above to install the latest unpinned. ## Usage **Object (preferred)** — one client per process: ```python -from mongo import Mongo +from mongo import MongoDB -db = Mongo(conn_string, database) # attach as bot.db / app.db +db = MongoDB(conn_string, database) # attach as bot.db / app.db +await db.connect() # optional: ping to fail-early on a bad URI users = await db.get_documents("users", {"active": True}) db.close() # on shutdown (sync) + +# or with guaranteed cleanup: +async with MongoDB(conn_string, database) as db: + await db.get_documents("users", {"active": True}) ``` +The class is `MongoDB`; **`Mongo` remains a back-compat alias** (`Mongo = MongoDB`), so +existing `Mongo(...)` call sites keep working unchanged. + **Module proxy (back-compat)** — arm once, then call bare: ```python @@ -44,10 +52,26 @@ users = await mongo.get_documents("users", {"active": True}) Both styles share one client. The proxy exists so legacy call sites keep working after a one-line `init()`; new code should use the object. +## Naming consistency with the datastore trio + +mongo predates the `redis`/`psql`/`mysql` trio; this version makes it surface-consistent +**without breaking anything** (all additive, every old name preserved): + +- class is **`MongoDB`** (with `Mongo` kept as an alias) +- **`connect()`** + **`async with`** like the trio (motor connects lazily, so `connect()` + just pings to validate early) +- **`exists()`** aliases `check_document_exists()`; **`delete()`** aliases + `delete_document()` — old names still work, the trio-consistent names are now available + +The one deliberate difference that remains: **mongo swallows** (see below) where the trio +is **fail-loud**. That's intentional — flipping it would break existing consumers' branch- +on-result control flow. + ## Error contract - **Wrapped methods** log-and-swallow exceptions and return a safe default - (`False` / `[]` / `{}` / `0` / `None`). Branch on the result. + (`False` / `[]` / `{}` / `0` / `None`). Branch on the result. (`connect()` is the + exception — it raises on a bad connection so you can fail-early.) - **`db.collection(name)`** (or `db[name]`) returns the raw motor collection: full driver surface, no swallowing, **raises**. Use it for anything not wrapped (`find_one_and_*` beyond what's exposed, change streams, complex bulk ops). diff --git a/pyproject.toml b/pyproject.toml index c1a06ea..14280b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mongo" -version = "0.1.3" +version = "0.1.4" description = "async mongodb wrapper over motor with a raw escape hatch" requires-python = ">=3.10" dependencies = [ diff --git a/src/mongo/__init__.py b/src/mongo/__init__.py index 6000814..1f9d4cd 100644 --- a/src/mongo/__init__.py +++ b/src/mongo/__init__.py @@ -1,6 +1,6 @@ -from .mongo import Mongo, init, instance +from .mongo import MongoDB, Mongo, init, instance -__all__ = ["Mongo", "init", "instance"] +__all__ = ["MongoDB", "Mongo", "init", "instance"] def __getattr__(name: str): @@ -13,6 +13,6 @@ def __getattr__(name: str): can only forward named attributes. on a Mongo instance both `db[name]` and `db.collection(name)` work. """ - if not name.startswith("_") and hasattr(Mongo, name): + if not name.startswith("_") and hasattr(MongoDB, name): return getattr(instance(), name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/mongo/mongo.py b/src/mongo/mongo.py index dbeec7e..2cca812 100644 --- a/src/mongo/mongo.py +++ b/src/mongo/mongo.py @@ -2,11 +2,19 @@ async mongodb wrapper over motor object (preferred), one client per process: - from mongo import Mongo - bot.db = Mongo(conn_string, database) + from mongo import MongoDB + bot.db = MongoDB(conn_string, database) + await bot.db.connect() # optional: ping to fail-early await bot.db.get_documents("users", {"active": True}) bot.db.close() # on shutdown (sync) + # async with (guaranteed cleanup): + async with MongoDB(conn_string, database) as db: + await db.get_documents("users", {}) + +`Mongo` remains a back-compat alias of `MongoDB` — existing `Mongo(...)` call sites keep +working unchanged. + module proxy (back-compat), arm once then call bare: import mongo # not `from mongo import ...` mongo.init(conn_string, database) @@ -16,6 +24,15 @@ errors: wrapped methods log and swallow, returning a safe default (False / [] / {} / 0 / None). .collection(name) / db[name] return the raw motor collection: full driver surface, raises, nothing swallowed. + NOTE: mongo SWALLOWS by design — this is the one deliberate difference from the + newer datastore trio (redis/psql/mysql), which is fail-loud. mongo's contract is + kept as-is so existing consumers' branch-on-result control flow doesn't break. + +naming consistency with the trio (all additive — old names still work): + - class is `MongoDB` (was `Mongo`, kept as alias) + - `connect()` / `async with` like the trio (motor connects lazily, so connect() + just pings to validate early) + - `exists()` aliases `check_document_exists()`; `delete()` aliases `delete_document()` notes: - the proxy needs `import mongo`; `from mongo import func` resolves before init @@ -32,13 +49,29 @@ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection, Asyn log = logging.getLogger(__name__) -class Mongo: +class MongoDB: """async mongodb wrapper; one client per process, attach to bot as bot.db""" def __init__(self, connection_string: str, database: str): self._client = AsyncIOMotorClient(connection_string) self._db = self._client[database] + async def connect(self) -> "MongoDB": + """validate the connection with a ping and return self + + motor connects lazily, so this is optional — call it to fail early on a bad + URI/credentials rather than on the first real op (parallel to the trio's + connect()). raises on a bad connection, unlike the swallowing wrapped methods. + """ + await self._client.admin.command("ping") + return self + + async def __aenter__(self) -> "MongoDB": + return await self.connect() + + async def __aexit__(self, exc_type, exc, tb) -> None: + self.close() + def __getitem__(self, collection: str) -> AsyncIOMotorCollection: """raw collection access via subscript: bot.db['users'].aggregate(...)""" return self._db[collection] @@ -224,6 +257,10 @@ class Mongo: log.exception(f"db.check_document_exists() on {collection}") return False + async def exists(self, collection: str, target: dict) -> bool: + """trio-consistent alias of check_document_exists""" + return await self.check_document_exists(collection, target) + async def count_value_in_array(self, collection: str, array_field: str, value: str) -> int: """count documents whose array field contains value""" try: @@ -463,6 +500,10 @@ class Mongo: log.exception(f"db.delete_document() on {collection}") return 0 + async def delete(self, collection: str, target: dict) -> int: + """trio-consistent alias of delete_document (single-document delete)""" + return await self.delete_document(collection, target) + async def delete_documents(self, collection: str, target: dict) -> int: """delete all matching documents, returning deleted count""" try: @@ -516,6 +557,11 @@ class Mongo: return False +# back-compat alias: the class was historically `Mongo`; existing `Mongo(...)` call +# sites keep working unchanged +Mongo = MongoDB + + # ----------------------------------------------------------------------------- # backwards-compat module proxy # - lets legacy call sites keep using `await mongo.get_documents(...)` @@ -524,17 +570,17 @@ class Mongo: # - does NOT work with `from mongo import func` (resolved at import, # before init runs) — switch those sites to `import mongo` -_default: Optional[Mongo] = None +_default: Optional[MongoDB] = None -def init(connection_string: str, database: str) -> Mongo: +def init(connection_string: str, database: str) -> MongoDB: """arm the module-level default instance and return it""" global _default - _default = Mongo(connection_string, database) + _default = MongoDB(connection_string, database) return _default -def instance() -> Mongo: +def instance() -> MongoDB: """return the default instance, raising if init() has not run""" if _default is None: raise RuntimeError("mongo not initialized; call mongo.init(conn, db) first")