objbase 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
objbase/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """Damn simple object store for Python dicts and Pydantic models across multiple backends."""
2
+
3
+ import importlib
4
+ from importlib.metadata import PackageNotFoundError, version
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from objbase.asyncio.async_file_storage import AsyncDirectoryBasedInventoryStorage, AsyncFileBasedInventoryStorage
8
+ from objbase.asyncio.async_inventory import AsyncInventory
9
+ from objbase.asyncio.async_mongodb_storage import AsyncMongoDBInventoryStorage
10
+ from objbase.asyncio.async_redis_storage import AsyncRedisInventoryStorage
11
+ from objbase.asyncio.async_sqlite_storage import AsyncSQLiteInventoryStorage
12
+ from objbase.asyncio.async_storage import AsyncInventoryStorage
13
+ from objbase.errors import InventoryError, ItemNotFoundError
14
+ from objbase.interface import InventoryStorage, Item
15
+ from objbase.inventory import Inventory
16
+ from objbase.storage.file_storage import DirectoryBasedInventoryStorage, FileBasedInventoryStorage
17
+ from objbase.storage.inmemory_storage import InMemoryInventoryStorage
18
+ from objbase.storage.mongodb_storage import MongoDBInventoryStorage
19
+ from objbase.storage.redis_storage import RedisInventoryStorage
20
+ from objbase.storage.sqlite_storage import SQLiteInventoryStorage
21
+
22
+ if TYPE_CHECKING:
23
+ # Lets type checkers see the real classes; at runtime they are loaded lazily by __getattr__.
24
+ from objbase.pydantic import AsyncPydanticInventory, PydanticInventory
25
+
26
+ try:
27
+ __version__ = version("objbase")
28
+ except PackageNotFoundError: # running from a source tree without installation
29
+ __version__ = "0.0.0"
30
+
31
+ __all__ = [
32
+ "AsyncDirectoryBasedInventoryStorage",
33
+ "AsyncFileBasedInventoryStorage",
34
+ "AsyncInventory",
35
+ "AsyncInventoryStorage",
36
+ "AsyncMongoDBInventoryStorage",
37
+ "AsyncPydanticInventory",
38
+ "AsyncRedisInventoryStorage",
39
+ "AsyncSQLiteInventoryStorage",
40
+ "DirectoryBasedInventoryStorage",
41
+ "FileBasedInventoryStorage",
42
+ "InMemoryInventoryStorage",
43
+ "Inventory",
44
+ "InventoryError",
45
+ "InventoryStorage",
46
+ "Item",
47
+ "ItemNotFoundError",
48
+ "MongoDBInventoryStorage",
49
+ "PydanticInventory",
50
+ "RedisInventoryStorage",
51
+ "SQLiteInventoryStorage",
52
+ ]
53
+
54
+
55
+ def __getattr__(name: str) -> Any:
56
+ # Imported lazily so `import objbase` works without pydantic installed.
57
+ if name in ("PydanticInventory", "AsyncPydanticInventory"):
58
+ return getattr(importlib.import_module("objbase.pydantic"), name)
59
+ raise AttributeError(f"module 'objbase' has no attribute {name!r}")
File without changes
@@ -0,0 +1,40 @@
1
+ import asyncio
2
+
3
+ from objbase.asyncio.threaded_storage import ThreadedAsyncInventoryStorage
4
+ from objbase.storage.file_storage import DirectoryBasedInventoryStorage, FileBasedInventoryStorage
5
+
6
+
7
+ class AsyncFileBasedInventoryStorage(ThreadedAsyncInventoryStorage[FileBasedInventoryStorage]):
8
+ """Async counterpart of ``FileBasedInventoryStorage``, using the same files and locks.
9
+
10
+ Each call runs in a worker thread (``asyncio.to_thread``), so the event loop is never
11
+ blocked by file I/O or while waiting for a file lock. Sync and async adapters on the
12
+ same directory can be used side by side, in one or several processes.
13
+ """
14
+
15
+ def __init__(self, base_dir: str):
16
+ super().__init__(FileBasedInventoryStorage(base_dir))
17
+
18
+ @property
19
+ def inventory_dir(self) -> str:
20
+ return self.sync_storage.inventory_dir
21
+
22
+
23
+ class AsyncDirectoryBasedInventoryStorage(ThreadedAsyncInventoryStorage[DirectoryBasedInventoryStorage]):
24
+ """Async counterpart of ``DirectoryBasedInventoryStorage``, using the same files, index and locks.
25
+
26
+ Each call runs in a worker thread (``asyncio.to_thread``), so the event loop is never
27
+ blocked by file I/O or while waiting for a file lock. Sync and async adapters on the
28
+ same directory can be used side by side, in one or several processes.
29
+ """
30
+
31
+ def __init__(self, base_dir: str):
32
+ super().__init__(DirectoryBasedInventoryStorage(base_dir))
33
+
34
+ @property
35
+ def inventory_dir(self) -> str:
36
+ return self.sync_storage.inventory_dir
37
+
38
+ async def arebuild_index(self, item_type: str) -> None:
39
+ """Async counterpart of ``DirectoryBasedInventoryStorage.rebuild_index``."""
40
+ await asyncio.to_thread(self.sync_storage.rebuild_index, item_type)
@@ -0,0 +1,47 @@
1
+ from objbase.asyncio.async_storage import AsyncInventoryStorage
2
+ from objbase.errors import InventoryError, ItemNotFoundError
3
+ from objbase.interface import Item
4
+ from objbase.inventory import check_patch_data, require_item_id, require_read_back
5
+
6
+
7
+ class AsyncInventory:
8
+ """Async counterpart of ``Inventory``, backed by an ``AsyncInventoryStorage``.
9
+
10
+ Same methods and behaviour as ``Inventory``, but every method is a coroutine.
11
+ """
12
+
13
+ def __init__(self, item_type: str, storage: AsyncInventoryStorage):
14
+ if not isinstance(storage, AsyncInventoryStorage):
15
+ raise TypeError(
16
+ f"{type(storage).__name__} is not an AsyncInventoryStorage; use Inventory for sync storage adapters."
17
+ )
18
+ self.storage = storage
19
+ self.item_type = item_type
20
+
21
+ async def keys(self) -> list[str]:
22
+ return await self.storage.akeys(self.item_type)
23
+
24
+ async def filter(self) -> list[Item]:
25
+ return await self.storage.aitems(self.item_type)
26
+
27
+ async def get(self, id: str) -> Item | None:
28
+ return await self.storage.aread(self.item_type, id)
29
+
30
+ async def save(self, item: Item) -> Item:
31
+ _id = require_item_id(item)
32
+ if not await self.storage.awrite(self.item_type, item):
33
+ raise InventoryError(f"Failed to save item '{_id}'.")
34
+ return require_read_back(await self.storage.aread(self.item_type, _id), self.item_type, _id)
35
+
36
+ async def patch(self, id: str, data: Item) -> Item:
37
+ check_patch_data(id, data)
38
+ item = await self.storage.aread(self.item_type, id)
39
+ if item is None:
40
+ raise ItemNotFoundError(self.item_type, id)
41
+ item.update(data)
42
+ if not await self.storage.awrite(self.item_type, item):
43
+ raise InventoryError(f"Failed to patch item '{id}'.")
44
+ return require_read_back(await self.storage.aread(self.item_type, id), self.item_type, id)
45
+
46
+ async def delete(self, id: str) -> bool:
47
+ return await self.storage.adelete(self.item_type, id)
@@ -0,0 +1,46 @@
1
+ from collections.abc import Mapping
2
+ from typing import TYPE_CHECKING, Any
3
+
4
+ from objbase.asyncio.async_storage import AsyncInventoryStorage
5
+ from objbase.interface import Item
6
+
7
+ if TYPE_CHECKING:
8
+ from pymongo import AsyncMongoClient
9
+ from pymongo.asynchronous.collection import AsyncCollection
10
+
11
+
12
+ class AsyncMongoDBInventoryStorage(AsyncInventoryStorage):
13
+ """Async counterpart of ``MongoDBInventoryStorage``, using the same data layout.
14
+
15
+ Takes an async client such as ``pymongo.AsyncMongoClient``.
16
+ """
17
+
18
+ def __init__(self, mongo_client: "AsyncMongoClient[Item]"):
19
+ self.mongo_client = mongo_client
20
+
21
+ def get_mongo_collection(self, item_type: str) -> "AsyncCollection[Item]":
22
+ db = self.mongo_client["inventory"]
23
+ return db[item_type]
24
+
25
+ async def akeys(self, item_type: str) -> list[str]:
26
+ collection = self.get_mongo_collection(item_type)
27
+ return [doc["id"] async for doc in collection.find({}, {"id": True, "_id": False})]
28
+
29
+ async def aitems(self, item_type: str, query: Mapping[str, Any] | None = None) -> list[Item]:
30
+ """Return all items of a type. ``query`` is a MongoDB-only extension to filter results."""
31
+ collection = self.get_mongo_collection(item_type)
32
+ return [doc async for doc in collection.find(query or {}, {"_id": False})]
33
+
34
+ async def awrite(self, item_type: str, item: Item) -> bool:
35
+ collection = self.get_mongo_collection(item_type)
36
+ await collection.replace_one({"id": item["id"]}, item, upsert=True)
37
+ return True
38
+
39
+ async def aread(self, item_type: str, id: str) -> Item | None:
40
+ collection = self.get_mongo_collection(item_type)
41
+ return await collection.find_one({"id": id}, {"_id": False})
42
+
43
+ async def adelete(self, item_type: str, id: str) -> bool:
44
+ collection = self.get_mongo_collection(item_type)
45
+ result = await collection.delete_one({"id": id})
46
+ return result.deleted_count > 0
@@ -0,0 +1,36 @@
1
+ import json
2
+
3
+ from objbase.asyncio.async_storage import AsyncInventoryStorage
4
+ from objbase.interface import Item
5
+ from objbase.storage.redis_storage import DEFAULT_KEY_PREFIX, RedisHashClient, decode_key, redis_type_key
6
+
7
+
8
+ class AsyncRedisInventoryStorage(AsyncInventoryStorage):
9
+ """Async counterpart of ``RedisInventoryStorage``, using the same data layout.
10
+
11
+ Takes an async client such as ``redis.asyncio.Redis``.
12
+ """
13
+
14
+ def __init__(self, redis_client: RedisHashClient, key_prefix: str = DEFAULT_KEY_PREFIX):
15
+ self.redis_client = redis_client
16
+ self.key_prefix = key_prefix
17
+
18
+ def _key(self, item_type: str) -> str:
19
+ return redis_type_key(self.key_prefix, item_type)
20
+
21
+ async def akeys(self, item_type: str) -> list[str]:
22
+ return [decode_key(key) for key in await self.redis_client.hkeys(self._key(item_type))]
23
+
24
+ async def aitems(self, item_type: str) -> list[Item]:
25
+ return [json.loads(value) for value in await self.redis_client.hvals(self._key(item_type))]
26
+
27
+ async def awrite(self, item_type: str, item: Item) -> bool:
28
+ await self.redis_client.hset(self._key(item_type), item["id"], json.dumps(item))
29
+ return True
30
+
31
+ async def aread(self, item_type: str, id: str) -> Item | None:
32
+ value = await self.redis_client.hget(self._key(item_type), id)
33
+ return json.loads(value) if value is not None else None
34
+
35
+ async def adelete(self, item_type: str, id: str) -> bool:
36
+ return bool(await self.redis_client.hdel(self._key(item_type), id))
@@ -0,0 +1,19 @@
1
+ from objbase.asyncio.threaded_storage import ThreadedAsyncInventoryStorage
2
+ from objbase.storage.sqlite_storage import SQLiteInventoryStorage
3
+
4
+
5
+ class AsyncSQLiteInventoryStorage(ThreadedAsyncInventoryStorage[SQLiteInventoryStorage]):
6
+ """Async counterpart of ``SQLiteInventoryStorage``, using the same data layout.
7
+
8
+ Uses the standard library ``sqlite3`` module, so it needs no extra dependencies.
9
+ Each call runs in a worker thread (``asyncio.to_thread``) with its own connection,
10
+ so the event loop is never blocked by database I/O.
11
+ """
12
+
13
+ def __init__(self, db_path: str):
14
+ # Creates the table if needed; this one-off setup runs synchronously.
15
+ super().__init__(SQLiteInventoryStorage(db_path))
16
+
17
+ @property
18
+ def db_path(self) -> str:
19
+ return self.sync_storage.db_path
@@ -0,0 +1,18 @@
1
+ from typing import Protocol, runtime_checkable
2
+
3
+ from objbase.interface import Item
4
+
5
+
6
+ @runtime_checkable
7
+ class AsyncInventoryStorage(Protocol):
8
+ """Async counterpart of ``InventoryStorage``, with the same contract."""
9
+
10
+ async def akeys(self, item_type: str) -> list[str]: ...
11
+
12
+ async def aitems(self, item_type: str) -> list[Item]: ...
13
+
14
+ async def aread(self, item_type: str, id: str) -> Item | None: ...
15
+
16
+ async def awrite(self, item_type: str, item: Item) -> bool: ...
17
+
18
+ async def adelete(self, item_type: str, id: str) -> bool: ...
@@ -0,0 +1,30 @@
1
+ import asyncio
2
+
3
+ from objbase.asyncio.async_storage import AsyncInventoryStorage
4
+ from objbase.interface import InventoryStorage, Item
5
+
6
+
7
+ class ThreadedAsyncInventoryStorage[S: InventoryStorage](AsyncInventoryStorage):
8
+ """Base for async adapters that run a blocking sync adapter in worker threads.
9
+
10
+ Each call is passed to ``asyncio.to_thread``, so the event loop is never blocked by
11
+ I/O. The sync adapter must be safe to call from several threads at once.
12
+ """
13
+
14
+ def __init__(self, sync_storage: S):
15
+ self.sync_storage = sync_storage
16
+
17
+ async def akeys(self, item_type: str) -> list[str]:
18
+ return await asyncio.to_thread(self.sync_storage.keys, item_type)
19
+
20
+ async def aitems(self, item_type: str) -> list[Item]:
21
+ return await asyncio.to_thread(self.sync_storage.items, item_type)
22
+
23
+ async def aread(self, item_type: str, id: str) -> Item | None:
24
+ return await asyncio.to_thread(self.sync_storage.read, item_type, id)
25
+
26
+ async def awrite(self, item_type: str, item: Item) -> bool:
27
+ return await asyncio.to_thread(self.sync_storage.write, item_type, item)
28
+
29
+ async def adelete(self, item_type: str, id: str) -> bool:
30
+ return await asyncio.to_thread(self.sync_storage.delete, item_type, id)
objbase/errors.py ADDED
@@ -0,0 +1,11 @@
1
+ class InventoryError(Exception):
2
+ """Base class for all objbase errors."""
3
+
4
+
5
+ class ItemNotFoundError(InventoryError, LookupError):
6
+ """Raised when an operation requires an item that does not exist."""
7
+
8
+ def __init__(self, item_type: str, id: str):
9
+ self.item_type = item_type
10
+ self.id = id
11
+ super().__init__(f"Item '{id}' of type '{item_type}' not found.")
objbase/interface.py ADDED
@@ -0,0 +1,27 @@
1
+ from typing import Any, Protocol, runtime_checkable
2
+
3
+ Item = dict[str, Any]
4
+ """A stored item: a JSON-serializable dict with an ``"id"`` key."""
5
+
6
+
7
+ @runtime_checkable
8
+ class InventoryStorage(Protocol):
9
+ """Storage contract shared by all adapters.
10
+
11
+ - ``keys`` returns all item ids of a type, or ``[]`` if there are none.
12
+ - ``items`` returns all items of a type, or ``[]`` if there are none.
13
+ - ``read`` returns the item, or ``None`` if it does not exist.
14
+ - ``write`` inserts the item or replaces an existing item with the same id entirely.
15
+ - ``delete`` returns ``True`` if an item was removed, ``False`` if it did not exist.
16
+ - Returned items are independent copies; mutating them does not change stored data.
17
+ """
18
+
19
+ def keys(self, item_type: str) -> list[str]: ...
20
+
21
+ def items(self, item_type: str) -> list[Item]: ...
22
+
23
+ def read(self, item_type: str, id: str) -> Item | None: ...
24
+
25
+ def write(self, item_type: str, item: Item) -> bool: ...
26
+
27
+ def delete(self, item_type: str, id: str) -> bool: ...
objbase/inventory.py ADDED
@@ -0,0 +1,59 @@
1
+ from typing import Any
2
+
3
+ from objbase.errors import InventoryError, ItemNotFoundError
4
+ from objbase.interface import InventoryStorage, Item
5
+
6
+
7
+ def require_item_id(item: Item) -> Any:
8
+ """Return the item's id, raising ``ValueError`` if it is missing or empty."""
9
+ _id = item.get("id")
10
+ if not _id:
11
+ raise ValueError("Item id is required.")
12
+ return _id
13
+
14
+
15
+ def check_patch_data(id: str, data: Item) -> None:
16
+ """Raise ``ValueError`` if patch data would change the item id."""
17
+ if "id" in data and data["id"] != id:
18
+ raise ValueError("Patch data must not change the item id.")
19
+
20
+
21
+ def require_read_back(item: Item | None, item_type: str, id: str) -> Item:
22
+ """Return an item read back after a successful write, raising if it vanished."""
23
+ if item is None:
24
+ raise InventoryError(f"Item '{id}' of type '{item_type}' could not be read back after writing.")
25
+ return item
26
+
27
+
28
+ class Inventory:
29
+ def __init__(self, item_type: str, storage: InventoryStorage):
30
+ self.storage = storage
31
+ self.item_type = item_type
32
+
33
+ def keys(self) -> list[str]:
34
+ return self.storage.keys(self.item_type)
35
+
36
+ def filter(self) -> list[Item]:
37
+ return self.storage.items(self.item_type)
38
+
39
+ def get(self, id: str) -> Item | None:
40
+ return self.storage.read(self.item_type, id)
41
+
42
+ def save(self, item: Item) -> Item:
43
+ _id = require_item_id(item)
44
+ if not self.storage.write(self.item_type, item):
45
+ raise InventoryError(f"Failed to save item '{_id}'.")
46
+ return require_read_back(self.storage.read(self.item_type, _id), self.item_type, _id)
47
+
48
+ def patch(self, id: str, data: Item) -> Item:
49
+ check_patch_data(id, data)
50
+ item = self.storage.read(self.item_type, id)
51
+ if item is None:
52
+ raise ItemNotFoundError(self.item_type, id)
53
+ item.update(data)
54
+ if not self.storage.write(self.item_type, item):
55
+ raise InventoryError(f"Failed to patch item '{id}'.")
56
+ return require_read_back(self.storage.read(self.item_type, id), self.item_type, id)
57
+
58
+ def delete(self, id: str) -> bool:
59
+ return self.storage.delete(self.item_type, id)
objbase/py.typed ADDED
File without changes
objbase/pydantic.py ADDED
@@ -0,0 +1,123 @@
1
+ import pydantic
2
+
3
+ from objbase.asyncio.async_inventory import AsyncInventory
4
+ from objbase.asyncio.async_storage import AsyncInventoryStorage
5
+ from objbase.errors import ItemNotFoundError
6
+ from objbase.interface import InventoryStorage, Item
7
+ from objbase.inventory import Inventory, check_patch_data
8
+
9
+
10
+ def _dump(model: pydantic.BaseModel) -> Item:
11
+ """Convert a model to the JSON-compatible dict that is stored."""
12
+ return model.model_dump(mode="json")
13
+
14
+
15
+ def _validated[M: pydantic.BaseModel](model_class: type[M], data: Item | M) -> Item:
16
+ """Validate ``data`` against ``model_class`` and return the dict to store.
17
+
18
+ Runs before every write, so invalid data raises ``ValidationError`` without
19
+ being stored. This also catches models made invalid after construction
20
+ (Pydantic does not validate attribute assignment by default).
21
+ """
22
+ # dict(model) takes the raw field values, so a model with invalid values is
23
+ # re-validated (and rejected) rather than serialized as-is.
24
+ raw = dict(data) if isinstance(data, pydantic.BaseModel) else data
25
+ return _dump(model_class.model_validate(raw))
26
+
27
+
28
+ def _patched[M: pydantic.BaseModel](
29
+ model_class: type[M], item_type: str, id: str, current: Item | None, data: Item | M
30
+ ) -> Item:
31
+ """Merge patch ``data`` into the ``current`` stored item and validate the result."""
32
+ patch = dict(data) if isinstance(data, pydantic.BaseModel) else data
33
+ check_patch_data(id, patch)
34
+ if current is None:
35
+ raise ItemNotFoundError(item_type, id)
36
+ return _validated(model_class, {**current, **patch})
37
+
38
+
39
+ class PydanticInventory[M: pydantic.BaseModel]:
40
+ """Inventory that validates items against a Pydantic model.
41
+
42
+ Wraps an ``Inventory``: items are stored as plain dicts and returned as
43
+ ``model_class`` instances. The model type is inferred from ``model_class``,
44
+ so ``PydanticInventory("todo", storage, Todo).get("1")`` is typed ``Todo | None``.
45
+
46
+ ``save`` and ``patch`` validate the complete item before writing it, so data
47
+ that fails validation raises ``pydantic.ValidationError`` and is never stored.
48
+ """
49
+
50
+ def __init__(self, item_type: str, storage: InventoryStorage, model_class: type[M]):
51
+ self.model_class = model_class
52
+ self.inventory = Inventory(item_type, storage)
53
+
54
+ @property
55
+ def item_type(self) -> str:
56
+ return self.inventory.item_type
57
+
58
+ @property
59
+ def storage(self) -> InventoryStorage:
60
+ return self.inventory.storage
61
+
62
+ def keys(self) -> list[str]:
63
+ return self.inventory.keys()
64
+
65
+ def filter(self) -> list[M]:
66
+ return [self.model_class.model_validate(item) for item in self.inventory.filter()]
67
+
68
+ def save(self, model: M) -> M:
69
+ return self.model_class.model_validate(self.inventory.save(_validated(self.model_class, model)))
70
+
71
+ def get(self, id: str) -> M | None:
72
+ item = self.inventory.get(id)
73
+ if item is None:
74
+ return None
75
+ return self.model_class.model_validate(item)
76
+
77
+ def patch(self, id: str, data: Item | M) -> M:
78
+ item = _patched(self.model_class, self.item_type, id, self.inventory.get(id), data)
79
+ return self.model_class.model_validate(self.inventory.save(item))
80
+
81
+ def delete(self, id: str) -> bool:
82
+ return self.inventory.delete(id)
83
+
84
+
85
+ class AsyncPydanticInventory[M: pydantic.BaseModel]:
86
+ """Async counterpart of ``PydanticInventory``, backed by an ``AsyncInventoryStorage``.
87
+
88
+ Same methods and behaviour as ``PydanticInventory``, but every method is a coroutine.
89
+ """
90
+
91
+ def __init__(self, item_type: str, storage: AsyncInventoryStorage, model_class: type[M]):
92
+ self.model_class = model_class
93
+ self.inventory = AsyncInventory(item_type, storage)
94
+
95
+ @property
96
+ def item_type(self) -> str:
97
+ return self.inventory.item_type
98
+
99
+ @property
100
+ def storage(self) -> AsyncInventoryStorage:
101
+ return self.inventory.storage
102
+
103
+ async def keys(self) -> list[str]:
104
+ return await self.inventory.keys()
105
+
106
+ async def filter(self) -> list[M]:
107
+ return [self.model_class.model_validate(item) for item in await self.inventory.filter()]
108
+
109
+ async def save(self, model: M) -> M:
110
+ return self.model_class.model_validate(await self.inventory.save(_validated(self.model_class, model)))
111
+
112
+ async def get(self, id: str) -> M | None:
113
+ item = await self.inventory.get(id)
114
+ if item is None:
115
+ return None
116
+ return self.model_class.model_validate(item)
117
+
118
+ async def patch(self, id: str, data: Item | M) -> M:
119
+ item = _patched(self.model_class, self.item_type, id, await self.inventory.get(id), data)
120
+ return self.model_class.model_validate(await self.inventory.save(item))
121
+
122
+ async def delete(self, id: str) -> bool:
123
+ return await self.inventory.delete(id)
File without changes