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 +59 -0
- objbase/asyncio/__init__.py +0 -0
- objbase/asyncio/async_file_storage.py +40 -0
- objbase/asyncio/async_inventory.py +47 -0
- objbase/asyncio/async_mongodb_storage.py +46 -0
- objbase/asyncio/async_redis_storage.py +36 -0
- objbase/asyncio/async_sqlite_storage.py +19 -0
- objbase/asyncio/async_storage.py +18 -0
- objbase/asyncio/threaded_storage.py +30 -0
- objbase/errors.py +11 -0
- objbase/interface.py +27 -0
- objbase/inventory.py +59 -0
- objbase/py.typed +0 -0
- objbase/pydantic.py +123 -0
- objbase/storage/__init__.py +0 -0
- objbase/storage/file_storage.py +268 -0
- objbase/storage/inmemory_storage.py +51 -0
- objbase/storage/mongodb_storage.py +42 -0
- objbase/storage/redis_storage.py +68 -0
- objbase/storage/sqlite_storage.py +79 -0
- objbase/util/__init__.py +0 -0
- objbase/util/file_util.py +102 -0
- objbase/util/mongodb_util.py +45 -0
- objbase/util/redis_util.py +35 -0
- objbase-0.3.0.dist-info/METADATA +780 -0
- objbase-0.3.0.dist-info/RECORD +28 -0
- objbase-0.3.0.dist-info/WHEEL +4 -0
- objbase-0.3.0.dist-info/licenses/LICENSE +21 -0
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
|