patx/mongokv

Ditch motor for PyMongo. Ditch pickleDB style dual method for two seperate clients one sync one async. Update readme.

Commit e9417d0 · patx · 2025-12-11T00:01:43-05:00

Changeset
e9417d08bff716eb3b3598164df020679b159f11
Parents
508054fabf96454d1ae788a9f1cfd1b9faa0896e

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 0640ecc..81de3c5 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,21 @@
-**[mkvDB](https://github.com/patx/mkvdb)** is a fast, easy to use, Python key-value store with first-class
-asynchronous support. It stands on the shoulders of [motor](https://motor.readthedocs.io/)
-and is safe across multiple processes and workers thanks to MongoDB’s concurrency model.
+mkvDB is a unified sync + async key-value store backed by MongoDB. It gives you a 
+dead-simple Redis-like API (set, get, remove, etc.) while letting MongoDB handle all 
+the heavy concurrency lifting — meaning it’s safe across threads, processes, and 
+ASGI workers out of the box. Sync usage uses `MongoClient` (thread-safe). Async usage
+uses `AsyncMongoClient` (non-blocking, ASGI/uvicorn safe). Same API either way.
+[Read the docs here.](https://thoughts.harrisonerd.com/post/693a32cee5c413ce5f061c19).
 
-Is it a little pointless if you already know Mongo? Yeah. But if ya just want to
-`set` and `get` without thinking about collections or schemas, it’s perfect. I made this
-because [pickleDB](https://patx.github.io/pickledb) doesn't play nice with ASGI but I
-like its dumb API anyway :) [Read a little more about mkvDB in this blog post.](https://thoughts.harrisonerd.com/post/693a32cee5c413ce5f061c19)
-
-```python
+```
 from mkvdb import Mkv
 
 db = Mkv("mongodb://localhost:27017")
 
+# Sync
 db.set("key", "value")
-db.get("key")  # returns "value"
+db.get("key")
+
+# Async
+await db.set("a", 123)
+val = await db.get("a")
 ```
 
-Note: You can also `await db.set` or `db.whatever` for async usage. 
diff --git a/mkvdb.py b/mkvdb.py
index c92516e..8f882f8 100644
--- a/mkvdb.py
+++ b/mkvdb.py
@@ -7,11 +7,11 @@ Licensed - BSD 3 Clause (see LICENSE)
 import asyncio
 from typing import Any, Optional, List
 
-import motor.motor_asyncio
+from pymongo import MongoClient, AsyncMongoClient
 
 
 def in_async() -> bool:
-    """Check if running inside an active event loop."""
+    """Return True if we're currently running inside an event loop."""
     try:
         asyncio.get_running_loop()
         return True
@@ -19,73 +19,93 @@ def in_async() -> bool:
         return False
 
 
-def dualmethod(func):
-    """Allows async methods to also be called synchronously."""
-    def wrapper(self, *args, **kwargs):
-        coro = func(self, *args, **kwargs)
-        if in_async():
-            return coro
-        return asyncio.run(coro)
-    return wrapper
-
-
 class Mkv:
     """
-    A unified async/sync key-value store backed by MongoDB (Motor).
+    A unified async/sync key-value store backed by MongoDB (PyMongo).
+    AsyncMongoClient for async paths - MongoClient for sync paths
     Each key is stored as a document:
         { "_id": <str(key)>, "value": <BSON-serializable Python object> }
     """
 
     def __init__(self, mongo_uri: str, db_name: str = "mkv",
-                 collection_name: str = "kv"):
-        self.client = motor.motor_asyncio.AsyncIOMotorClient(mongo_uri)
-        self.db = self.client[db_name]
+                 collection_name: str = "kv") -> None:
+        self._async_client = AsyncMongoClient(mongo_uri)
+        self.db = self._async_client[db_name]
         self.collection = self.db[collection_name]
+        self._sync_client = MongoClient(mongo_uri)
+        self._sync_db = self._sync_client[db_name]
+        self._sync_collection = self._sync_db[collection_name]
 
-    @dualmethod
-    async def set(self, key: Any, value: Any) -> bool:
+    def set(self, key: Any, value: Any) -> Any:
         """Set a key-value pair. Overwrites if the key already exists."""
-        await self.collection.update_one(
-            {"_id": str(key)}, {"$set": {"value": value}}, upsert=True)
+        if in_async():
+            async def _aset() -> bool:
+                await self.collection.update_one({"_id": str(key)},
+                    {"$set": {"value": value}}, upsert=True,)
+                return True
+            return _aset()
+        self._sync_collection.update_one({"_id": str(key)},
+            {"$set": {"value": value}}, upsert=True,)
         return True
 
-    @dualmethod
-    async def get(self, key: Any, default: Optional[Any] = None) -> Any:
-        """
-        Get the value for a key. Returns `default` if the key does not exist.
-        """
-        key_str = str(key)
-        doc = await self.collection.find_one({"_id": key_str})
+    def get(self, key: Any, default: Optional[Any] = None) -> Any:
+        """Get the value for a key. """
+        if in_async():
+            async def _aget() -> Any:
+                doc = await self.collection.find_one({"_id": str(key)})
+                if doc is None:
+                    return default
+                return doc.get("value", default)
+            return _aget()
+        doc = self._sync_collection.find_one({"_id": str(key)})
         if doc is None:
             return default
         return doc.get("value", default)
 
-    @dualmethod
-    async def remove(self, key: Any) -> bool:
-        """
-        Remove a key-value pair. Returns True if a document was 
-        deleted, False otherwise.
+    def remove(self, key: Any) -> Any:
         """
-        result = await self.collection.delete_one({"_id": str(key)})
+        Remove a key-value pair."""
+        if in_async():
+            async def _aremove() -> bool:
+                result = await self.collection.delete_one({"_id": str(key)})
+                return result.deleted_count > 0
+            return _aremove()
+        result = self._sync_collection.delete_one({"_id": str(key)})
         return result.deleted_count > 0
 
-    @dualmethod
-    async def all(self) -> List[str]:
+    def all(self) -> Any:
         """Return a list of all keys in the database."""
+        if in_async():
+            async def _aall() -> List[str]:
+                keys: List[str] = []
+                cursor = self.collection.find({}, {"_id": 1})
+                async for doc in cursor:
+                    keys.append(doc["_id"])
+                return keys
+            return _aall()
         keys: List[str] = []
-        cursor = self.collection.find({}, {"_id": 1})
-        async for doc in cursor:
+        for doc in self._sync_collection.find({}, {"_id": 1}):
             keys.append(doc["_id"])
         return keys
 
-    @dualmethod
-    async def purge(self) -> bool:
+    def purge(self) -> Any:
         """Remove all key-value pairs from the database."""
-        await self.collection.delete_many({})
+        if in_async():
+            async def _apurge() -> bool:
+                await self.collection.delete_many({})
+                return True
+            return _apurge()
+        self._sync_collection.delete_many({})
         return True
 
-    @dualmethod
-    async def close(self) -> None:
-        """Close the underlying MongoDB client."""
-        self.client.close()
+    def close(self) -> Any:
+        """Close the underlying MongoDB clients."""
+        if in_async():
+            async def _aclose() -> None:
+                await self._async_client.close()
+                self._sync_client.close()
+            return _aclose()
+        asyncio.run(self._async_client.close())
+        self._sync_client.close()
+        return None
 
diff --git a/pyproject.toml b/pyproject.toml
index ae3bcc0..89ed60d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
 
 [project]
 name = "mkvdb"
-version = "0.1"
+version = "0.2"
 description = "MongoDB-backed async/sync key–value store with a super tiny API."
 keywords = [
     "key-value",
@@ -19,9 +19,7 @@ readme = "README.md"
 authors = [{ name = "Harrison Erd", email = "[email protected]" }]
 license = { file = "LICENSE" }
 requires-python = ">=3.10"
-dependencies = [
-    "motor>=3.6,<4",
-]
+dependencies = ["pymongo>=4.15.5"]
 
 classifiers = [
     "Framework :: AsyncIO",
@@ -37,6 +35,5 @@ classifiers = [
 ]
 
 [project.urls]
-Homepage = "https://patx.github.io/mkvdb"
 Repository = "https://github.com/patx/mkvdb"
 
diff --git a/test_mkvdb.py b/test_mkvdb.py
new file mode 100644
index 0000000..198441d
--- /dev/null
+++ b/test_mkvdb.py
@@ -0,0 +1,207 @@
+# tests/test_mkvdb.py
+
+import os
+import uuid
+
+import pytest
+import pytest_asyncio
+
+from mkvdb import Mkv
+
+MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017")
+
+
+# ---------- Fixtures ----------
+
+@pytest_asyncio.fixture
+async def mkv():
+    collection_name = f"test_kv_{uuid.uuid4().hex}"
+    db = Mkv(MONGO_URI, db_name="test_mkvdb", collection_name=collection_name)
+
+    try:
+        await db.db.command("ping")
+    except Exception:
+        pytest.skip(f"MongoDB not available at {MONGO_URI}")
+
+    await db.purge()
+
+    try:
+        yield db
+    finally:
+        # Best-effort cleanup; don't let errors fail the test
+        try:
+            await db.purge()
+        except Exception:
+            pass
+        try:
+            await db.close()
+        except Exception:
+            pass
+
+
[email protected]
+def mkv_sync():
+    collection_name = f"test_kv_sync_{uuid.uuid4().hex}"
+    db = Mkv(MONGO_URI, db_name="test_mkvdb", collection_name=collection_name)
+
+    try:
+        db.purge()
+    except Exception:
+        pass
+
+    try:
+        yield db
+    finally:
+        try:
+            db.purge()
+        except Exception:
+            pass
+        try:
+            db.close()
+        except Exception:
+            pass
+
+
+# ---------- Async tests ----------
+
[email protected]
+async def test_set_and_get_value_async(mkv: Mkv):
+    await mkv.set("foo", "bar")
+    value = await mkv.get("foo")
+    assert value == "bar"
+
+
[email protected]
+async def test_get_missing_returns_default_async(mkv: Mkv):
+    value = await mkv.get("missing", default=123)
+    assert value == 123
+
+
[email protected]
+async def test_get_missing_default_none_async(mkv: Mkv):
+    value = await mkv.get("missing")
+    assert value is None
+
+
[email protected]
+async def test_overwrite_existing_key_async(mkv: Mkv):
+    await mkv.set("counter", 1)
+    await mkv.set("counter", 2)
+    value = await mkv.get("counter")
+    assert value == 2
+
+
[email protected]
+async def test_remove_existing_key_async(mkv: Mkv):
+    await mkv.set("temp", "value")
+    removed = await mkv.remove("temp")
+    assert removed is True
+
+    # Confirm it’s gone
+    value = await mkv.get("temp")
+    assert value is None
+
+
[email protected]
+async def test_remove_missing_key_returns_false_async(mkv: Mkv):
+    removed = await mkv.remove("does-not-exist")
+    assert removed is False
+
+
[email protected]
+async def test_all_returns_all_keys_async(mkv: Mkv):
+    await mkv.set("k1", "v1")
+    await mkv.set("k2", "v2")
+    await mkv.set("k3", "v3")
+
+    keys = await mkv.all()
+    # Order is not guaranteed, so assert as a set
+    assert set(keys) == {"k1", "k2", "k3"}
+
+
[email protected]
+async def test_all_on_empty_db_returns_empty_list_async(mkv: Mkv):
+    keys = await mkv.all()
+    assert keys == []
+
+
[email protected]
+async def test_purge_clears_all_keys_async(mkv: Mkv):
+    await mkv.set("a", 1)
+    await mkv.set("b", 2)
+
+    keys_before = await mkv.all()
+    assert set(keys_before) == {"a", "b"}
+
+    result = await mkv.purge()
+    assert result is True
+
+    keys_after = await mkv.all()
+    assert keys_after == []
+
+
[email protected]
+async def test_non_string_keys_are_cast_to_str_async(mkv: Mkv):
+    await mkv.set(123, "number")
+    await mkv.set(("tuple", 1), "tuple-value")
+
+    # We expect them to be stored under str(key)
+    keys = await mkv.all()
+    assert set(keys) == {"123", "('tuple', 1)"}
+
+    assert await mkv.get(123) == "number"
+    assert await mkv.get(("tuple", 1)) == "tuple-value"
+
+
[email protected]
+async def test_close_does_not_throw_async(mkv: Mkv):
+    # The fixture already closes in teardown, but we can call it early too.
+    await mkv.set("k", "v")
+    await mkv.close()  # Should not raise
+    # Don't assert behavior after close (Motor generally allows it but it's not required)
+
+
+# ---------- Sync tests (dualmethod behavior) ----------
+
+def test_sync_set_and_get(mkv_sync: Mkv):
+    mkv_sync.set("foo", "bar")
+    value = mkv_sync.get("foo")
+    assert value == "bar"
+
+
+def test_sync_get_missing_returns_default(mkv_sync: Mkv):
+    value = mkv_sync.get("nope", default="fallback")
+    assert value == "fallback"
+
+
+def test_sync_remove_and_purge(mkv_sync: Mkv):
+    mkv_sync.set("a", 1)
+    mkv_sync.set("b", 2)
+
+    # Remove one key
+    removed = mkv_sync.remove("a")
+    assert removed is True
+    assert mkv_sync.get("a") is None
+    assert mkv_sync.get("b") == 2
+
+    # Purge everything
+    purged = mkv_sync.purge()
+    assert purged is True
+    assert mkv_sync.all() == []
+
+
+def test_sync_non_string_keys_cast_to_str(mkv_sync: Mkv):
+    mkv_sync.set(10, "ten")
+    mkv_sync.set(("x", "y"), {"ok": True})
+
+    keys = mkv_sync.all()
+    assert set(keys) == {"10", "('x', 'y')"}
+
+    assert mkv_sync.get(10) == "ten"
+    assert mkv_sync.get(("x", "y")) == {"ok": True}
+
+
+def test_sync_close_does_not_raise(mkv_sync: Mkv):
+    mkv_sync.set("k", "v")
+    mkv_sync.close()  # Should not raise
+