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
Comments
No comments yet.
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
+