objbase 0.5.1__tar.gz → 0.5.2__tar.gz
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-0.5.1 → objbase-0.5.2}/PKG-INFO +81 -2
- {objbase-0.5.1 → objbase-0.5.2}/README.md +80 -1
- {objbase-0.5.1 → objbase-0.5.2}/pyproject.toml +1 -1
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/__init__.py +7 -1
- objbase-0.5.2/src/objbase/actions.py +55 -0
- objbase-0.5.2/src/objbase/asyncio/collection.py +93 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/collection.py +35 -1
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/errors.py +9 -0
- objbase-0.5.2/tests/test_actions.py +229 -0
- {objbase-0.5.1 → objbase-0.5.2}/uv.lock +1 -1
- objbase-0.5.1/src/objbase/asyncio/collection.py +0 -46
- {objbase-0.5.1 → objbase-0.5.2}/.github/dependabot.yml +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/.github/workflows/ci.yml +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/.github/workflows/release.yml +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/.gitignore +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/DEVELOPER.md +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/LICENSE +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/examples/async_directory_example.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/examples/async_example.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/examples/async_file_example.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/examples/async_mongodb_example.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/examples/async_pydantic_example.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/examples/async_sqlite_example.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/examples/dict_example.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/examples/mongodb_example.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/examples/pydantic_example.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/release.sh +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/__init__.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/storage/__init__.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/storage/local.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/storage/mongodb.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/storage/redis.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/storage/sqlite.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/storage/threaded.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/interface.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/py.typed +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/pydantic.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/storage/__init__.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/storage/inmemory.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/storage/local.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/storage/mongodb.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/storage/redis.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/storage/sqlite.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/util/__init__.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/util/file_util.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/util/mongodb_util.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/src/objbase/util/redis_util.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_async_collection.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_async_file_storage.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_async_mongodb_storage.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_async_redis_storage.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_async_sqlite_storage.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_collection.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_file_storage.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_inmemory_storage.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_mongodb_storage.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_package.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_redis_storage.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_sqlite_storage.py +0 -0
- {objbase-0.5.1 → objbase-0.5.2}/tests/test_storage_contract.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: objbase
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Damn simple object store for Python dicts and Pydantic models across multiple backends
|
|
5
5
|
Project-URL: Homepage, https://github.com/fm-labs/objbase
|
|
6
6
|
Project-URL: Issues, https://github.com/fm-labs/objbase/issues
|
|
@@ -98,7 +98,8 @@ or from their submodules as in the examples below:
|
|
|
98
98
|
|---|---|
|
|
99
99
|
| `objbase.interface` | `Storage`, `AsyncStorage` protocols and the `Item` type |
|
|
100
100
|
| `objbase.collection` | `Collection` |
|
|
101
|
-
| `objbase.errors` | `CollectionError`, `ItemNotFoundError` |
|
|
101
|
+
| `objbase.errors` | `CollectionError`, `ItemNotFoundError`, `ActionNotFoundError` |
|
|
102
|
+
| `objbase.actions` | `ActionHandler`, `AsyncActionHandler`, `ActionParams`, `load_action_handler` |
|
|
102
103
|
| `objbase.pydantic` | `PydanticCollection`, `AsyncPydanticCollection` (needs `objbase[pydantic]`) |
|
|
103
104
|
| `objbase.storage.{inmemory,local,sqlite,redis,mongodb}` | Sync storage adapters |
|
|
104
105
|
| `objbase.asyncio.collection` | `AsyncCollection` |
|
|
@@ -125,6 +126,7 @@ Errors are raised, not returned:
|
|
|
125
126
|
| `patch` with data that changes the item's `id` | `ValueError` |
|
|
126
127
|
| `patch` a missing item | `objbase.ItemNotFoundError` (a `CollectionError` and a `LookupError`) |
|
|
127
128
|
| The storage backend reports a failed write | `objbase.CollectionError` |
|
|
129
|
+
| `run_action` with an action that isn't registered | `objbase.ActionNotFoundError` (a `CollectionError` and a `LookupError`) |
|
|
128
130
|
|
|
129
131
|
`CollectionError` is the base class of all objbase errors.
|
|
130
132
|
|
|
@@ -385,6 +387,83 @@ can also be called directly on the storage.
|
|
|
385
387
|
|
|
386
388
|
---
|
|
387
389
|
|
|
390
|
+
## Actions
|
|
391
|
+
|
|
392
|
+
An action is a named operation on a single item. Register a handler on a
|
|
393
|
+
collection, then run it by item id:
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
from objbase import Collection, InMemoryStorage
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def set_status(item, params):
|
|
400
|
+
return {**item, "status": params["status"]}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
todos = Collection(item_type="todo", storage=InMemoryStorage())
|
|
404
|
+
todos.register_action("set_status", set_status)
|
|
405
|
+
|
|
406
|
+
todos.save({"id": "1", "title": "Buy milk", "status": "pending"})
|
|
407
|
+
todos.run_action("1", "set_status", {"status": "done"}) # → {"id": "1", ..., "status": "done"}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
A handler is called as `handler(item, params)` with the stored item and the
|
|
411
|
+
params (`{}` if none are given):
|
|
412
|
+
|
|
413
|
+
- If it returns an item, that item **replaces** the stored one (like `save`) and is
|
|
414
|
+
returned. It must keep the item's `id`, otherwise `ValueError` is raised and nothing is saved.
|
|
415
|
+
- If it returns `None`, nothing is saved and the stored item is returned.
|
|
416
|
+
- Exceptions raised by the handler propagate unchanged.
|
|
417
|
+
|
|
418
|
+
`run_action` raises `ActionNotFoundError` for an unregistered action and
|
|
419
|
+
`ItemNotFoundError` for a missing item. Actions are registered per collection
|
|
420
|
+
instance; registering a name again replaces its handler. Like `patch`, an action
|
|
421
|
+
reads, changes and writes the item, so it is not atomic across concurrent writers.
|
|
422
|
+
|
|
423
|
+
On `AsyncCollection`, `run_action` is a coroutine and handlers may be sync or
|
|
424
|
+
async: async handlers are awaited, sync handlers run in a worker thread
|
|
425
|
+
(`asyncio.to_thread`). `Collection` accepts only sync handlers and raises
|
|
426
|
+
`TypeError` for an async one.
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
async def set_status(item, params):
|
|
430
|
+
return {**item, "status": params["status"]}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
todos = AsyncCollection(item_type="todo", storage=storage)
|
|
434
|
+
todos.register_action("set_status", set_status)
|
|
435
|
+
await todos.run_action("1", "set_status", {"status": "done"})
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Loading handlers from a module
|
|
439
|
+
|
|
440
|
+
`load_action_handler(module_name, action_name)` imports a module and returns
|
|
441
|
+
the handler from its `actions` mapping (pass `attr_name=` to use another name):
|
|
442
|
+
|
|
443
|
+
```python
|
|
444
|
+
# myapp/todo_actions.py
|
|
445
|
+
def set_status(item, params):
|
|
446
|
+
return {**item, "status": params["status"]}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
actions = {"set_status": set_status}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
from objbase import load_action_handler
|
|
454
|
+
|
|
455
|
+
todos.register_action("set_status", load_action_handler("myapp.todo_actions", "set_status"))
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
It raises `ActionNotFoundError` if the module has no such mapping or action, and
|
|
459
|
+
`TypeError` if the entry isn't callable. Errors from importing the module itself
|
|
460
|
+
propagate unchanged. Only pass module names you trust: importing a module runs its code.
|
|
461
|
+
|
|
462
|
+
Runs are logged at `DEBUG` level on the `objbase.collection` and
|
|
463
|
+
`objbase.asyncio.collection` loggers.
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
388
467
|
## Examples
|
|
389
468
|
|
|
390
469
|
Runnable scripts are in [`examples/`](examples/):
|
|
@@ -66,7 +66,8 @@ or from their submodules as in the examples below:
|
|
|
66
66
|
|---|---|
|
|
67
67
|
| `objbase.interface` | `Storage`, `AsyncStorage` protocols and the `Item` type |
|
|
68
68
|
| `objbase.collection` | `Collection` |
|
|
69
|
-
| `objbase.errors` | `CollectionError`, `ItemNotFoundError` |
|
|
69
|
+
| `objbase.errors` | `CollectionError`, `ItemNotFoundError`, `ActionNotFoundError` |
|
|
70
|
+
| `objbase.actions` | `ActionHandler`, `AsyncActionHandler`, `ActionParams`, `load_action_handler` |
|
|
70
71
|
| `objbase.pydantic` | `PydanticCollection`, `AsyncPydanticCollection` (needs `objbase[pydantic]`) |
|
|
71
72
|
| `objbase.storage.{inmemory,local,sqlite,redis,mongodb}` | Sync storage adapters |
|
|
72
73
|
| `objbase.asyncio.collection` | `AsyncCollection` |
|
|
@@ -93,6 +94,7 @@ Errors are raised, not returned:
|
|
|
93
94
|
| `patch` with data that changes the item's `id` | `ValueError` |
|
|
94
95
|
| `patch` a missing item | `objbase.ItemNotFoundError` (a `CollectionError` and a `LookupError`) |
|
|
95
96
|
| The storage backend reports a failed write | `objbase.CollectionError` |
|
|
97
|
+
| `run_action` with an action that isn't registered | `objbase.ActionNotFoundError` (a `CollectionError` and a `LookupError`) |
|
|
96
98
|
|
|
97
99
|
`CollectionError` is the base class of all objbase errors.
|
|
98
100
|
|
|
@@ -353,6 +355,83 @@ can also be called directly on the storage.
|
|
|
353
355
|
|
|
354
356
|
---
|
|
355
357
|
|
|
358
|
+
## Actions
|
|
359
|
+
|
|
360
|
+
An action is a named operation on a single item. Register a handler on a
|
|
361
|
+
collection, then run it by item id:
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
from objbase import Collection, InMemoryStorage
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def set_status(item, params):
|
|
368
|
+
return {**item, "status": params["status"]}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
todos = Collection(item_type="todo", storage=InMemoryStorage())
|
|
372
|
+
todos.register_action("set_status", set_status)
|
|
373
|
+
|
|
374
|
+
todos.save({"id": "1", "title": "Buy milk", "status": "pending"})
|
|
375
|
+
todos.run_action("1", "set_status", {"status": "done"}) # → {"id": "1", ..., "status": "done"}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
A handler is called as `handler(item, params)` with the stored item and the
|
|
379
|
+
params (`{}` if none are given):
|
|
380
|
+
|
|
381
|
+
- If it returns an item, that item **replaces** the stored one (like `save`) and is
|
|
382
|
+
returned. It must keep the item's `id`, otherwise `ValueError` is raised and nothing is saved.
|
|
383
|
+
- If it returns `None`, nothing is saved and the stored item is returned.
|
|
384
|
+
- Exceptions raised by the handler propagate unchanged.
|
|
385
|
+
|
|
386
|
+
`run_action` raises `ActionNotFoundError` for an unregistered action and
|
|
387
|
+
`ItemNotFoundError` for a missing item. Actions are registered per collection
|
|
388
|
+
instance; registering a name again replaces its handler. Like `patch`, an action
|
|
389
|
+
reads, changes and writes the item, so it is not atomic across concurrent writers.
|
|
390
|
+
|
|
391
|
+
On `AsyncCollection`, `run_action` is a coroutine and handlers may be sync or
|
|
392
|
+
async: async handlers are awaited, sync handlers run in a worker thread
|
|
393
|
+
(`asyncio.to_thread`). `Collection` accepts only sync handlers and raises
|
|
394
|
+
`TypeError` for an async one.
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
async def set_status(item, params):
|
|
398
|
+
return {**item, "status": params["status"]}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
todos = AsyncCollection(item_type="todo", storage=storage)
|
|
402
|
+
todos.register_action("set_status", set_status)
|
|
403
|
+
await todos.run_action("1", "set_status", {"status": "done"})
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Loading handlers from a module
|
|
407
|
+
|
|
408
|
+
`load_action_handler(module_name, action_name)` imports a module and returns
|
|
409
|
+
the handler from its `actions` mapping (pass `attr_name=` to use another name):
|
|
410
|
+
|
|
411
|
+
```python
|
|
412
|
+
# myapp/todo_actions.py
|
|
413
|
+
def set_status(item, params):
|
|
414
|
+
return {**item, "status": params["status"]}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
actions = {"set_status": set_status}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
from objbase import load_action_handler
|
|
422
|
+
|
|
423
|
+
todos.register_action("set_status", load_action_handler("myapp.todo_actions", "set_status"))
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
It raises `ActionNotFoundError` if the module has no such mapping or action, and
|
|
427
|
+
`TypeError` if the entry isn't callable. Errors from importing the module itself
|
|
428
|
+
propagate unchanged. Only pass module names you trust: importing a module runs its code.
|
|
429
|
+
|
|
430
|
+
Runs are logged at `DEBUG` level on the `objbase.collection` and
|
|
431
|
+
`objbase.asyncio.collection` loggers.
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
356
435
|
## Examples
|
|
357
436
|
|
|
358
437
|
Runnable scripts are in [`examples/`](examples/):
|
|
@@ -4,13 +4,14 @@ import importlib
|
|
|
4
4
|
from importlib.metadata import PackageNotFoundError, version
|
|
5
5
|
from typing import TYPE_CHECKING, Any
|
|
6
6
|
|
|
7
|
+
from objbase.actions import ActionHandler, ActionParams, AsyncActionHandler, load_action_handler
|
|
7
8
|
from objbase.asyncio.collection import AsyncCollection
|
|
8
9
|
from objbase.asyncio.storage.local import AsyncLocalDirectoryStorage, AsyncLocalFileStorage
|
|
9
10
|
from objbase.asyncio.storage.mongodb import AsyncMongoDBStorage
|
|
10
11
|
from objbase.asyncio.storage.redis import AsyncRedisStorage
|
|
11
12
|
from objbase.asyncio.storage.sqlite import AsyncSQLiteStorage
|
|
12
13
|
from objbase.collection import Collection
|
|
13
|
-
from objbase.errors import CollectionError, ItemNotFoundError
|
|
14
|
+
from objbase.errors import ActionNotFoundError, CollectionError, ItemNotFoundError
|
|
14
15
|
from objbase.interface import AsyncStorage, Item, Storage
|
|
15
16
|
from objbase.storage.inmemory import InMemoryStorage
|
|
16
17
|
from objbase.storage.local import LocalDirectoryStorage, LocalFileStorage
|
|
@@ -28,6 +29,10 @@ except PackageNotFoundError: # running from a source tree without installation
|
|
|
28
29
|
__version__ = "0.0.0"
|
|
29
30
|
|
|
30
31
|
__all__ = [
|
|
32
|
+
"ActionHandler",
|
|
33
|
+
"ActionNotFoundError",
|
|
34
|
+
"ActionParams",
|
|
35
|
+
"AsyncActionHandler",
|
|
31
36
|
"AsyncLocalDirectoryStorage",
|
|
32
37
|
"AsyncLocalFileStorage",
|
|
33
38
|
"AsyncCollection",
|
|
@@ -48,6 +53,7 @@ __all__ = [
|
|
|
48
53
|
"PydanticCollection",
|
|
49
54
|
"RedisStorage",
|
|
50
55
|
"SQLiteStorage",
|
|
56
|
+
"load_action_handler",
|
|
51
57
|
]
|
|
52
58
|
|
|
53
59
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Action handlers: named operations registered on a collection and run against one item.
|
|
2
|
+
|
|
3
|
+
A handler takes the stored item and the action parameters, and returns either the
|
|
4
|
+
updated item, which the collection saves, or ``None`` to leave the item unchanged.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from objbase.errors import ActionNotFoundError
|
|
12
|
+
from objbase.interface import Item
|
|
13
|
+
|
|
14
|
+
ActionParams = dict[str, Any]
|
|
15
|
+
"""Parameters passed to an action handler."""
|
|
16
|
+
|
|
17
|
+
ActionHandler = Callable[[Item, ActionParams], Item | None]
|
|
18
|
+
"""A sync action handler: ``handler(item, params) -> updated item | None``."""
|
|
19
|
+
|
|
20
|
+
AsyncActionHandler = Callable[[Item, ActionParams], Awaitable[Item | None]]
|
|
21
|
+
"""An async action handler: ``async handler(item, params) -> updated item | None``."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_action_name(name: str) -> None:
|
|
25
|
+
"""Raise ``ValueError`` if ``name`` is not a usable action name."""
|
|
26
|
+
if not name or not isinstance(name, str):
|
|
27
|
+
raise ValueError("Action name must be a non-empty string.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_action_result(id: str, result: Item) -> None:
|
|
31
|
+
"""Raise ``ValueError`` if a handler's returned item does not keep the item id."""
|
|
32
|
+
if result.get("id") != id:
|
|
33
|
+
raise ValueError("Action handler must return the item with its id unchanged.")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_action_handler(module_name: str, action_name: str, attr_name: str = "actions") -> Callable[..., Any]:
|
|
37
|
+
"""Import ``module_name`` and return ``getattr(module, attr_name)[action_name]``.
|
|
38
|
+
|
|
39
|
+
The module must define a mapping of action names to handlers (``actions`` by default).
|
|
40
|
+
The returned handler can be passed to ``register_action``.
|
|
41
|
+
|
|
42
|
+
Errors raised while importing the module (``ImportError`` or any error in the
|
|
43
|
+
module itself) propagate unchanged.
|
|
44
|
+
|
|
45
|
+
:raises ActionNotFoundError: If the module has no such mapping or the mapping has no such action.
|
|
46
|
+
:raises TypeError: If the mapping entry is not callable.
|
|
47
|
+
"""
|
|
48
|
+
module = importlib.import_module(module_name)
|
|
49
|
+
actions = getattr(module, attr_name, None)
|
|
50
|
+
if actions is None or action_name not in actions:
|
|
51
|
+
raise ActionNotFoundError(action_name, f"module '{module_name}' ({attr_name})")
|
|
52
|
+
handler = actions[action_name]
|
|
53
|
+
if not callable(handler):
|
|
54
|
+
raise TypeError(f"Action '{action_name}' in module '{module_name}' is not callable.")
|
|
55
|
+
return handler # type: ignore[no-any-return]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
from objbase.actions import (
|
|
7
|
+
ActionHandler,
|
|
8
|
+
ActionParams,
|
|
9
|
+
AsyncActionHandler,
|
|
10
|
+
check_action_name,
|
|
11
|
+
check_action_result,
|
|
12
|
+
)
|
|
13
|
+
from objbase.collection import check_patch_data, require_item_id, require_read_back
|
|
14
|
+
from objbase.errors import ActionNotFoundError, CollectionError, ItemNotFoundError
|
|
15
|
+
from objbase.interface import AsyncStorage, Item
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AsyncCollection:
|
|
21
|
+
"""Async counterpart of ``Collection``, backed by an ``AsyncStorage``.
|
|
22
|
+
|
|
23
|
+
Same methods and behaviour as ``Collection``, but every method is a coroutine.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, item_type: str, storage: AsyncStorage):
|
|
27
|
+
if not isinstance(storage, AsyncStorage):
|
|
28
|
+
raise TypeError(
|
|
29
|
+
f"{type(storage).__name__} is not an AsyncStorage; use Collection for sync storage adapters."
|
|
30
|
+
)
|
|
31
|
+
self.storage = storage
|
|
32
|
+
self.item_type = item_type
|
|
33
|
+
self.actions: dict[str, ActionHandler | AsyncActionHandler] = {}
|
|
34
|
+
|
|
35
|
+
async def keys(self) -> list[str]:
|
|
36
|
+
return await self.storage.akeys(self.item_type)
|
|
37
|
+
|
|
38
|
+
async def items(self) -> list[Item]:
|
|
39
|
+
return await self.storage.aitems(self.item_type)
|
|
40
|
+
|
|
41
|
+
async def get(self, id: str) -> Item | None:
|
|
42
|
+
return await self.storage.aread(self.item_type, id)
|
|
43
|
+
|
|
44
|
+
async def save(self, item: Item) -> Item:
|
|
45
|
+
_id = require_item_id(item)
|
|
46
|
+
if not await self.storage.awrite(self.item_type, item):
|
|
47
|
+
raise CollectionError(f"Failed to save item '{_id}'.")
|
|
48
|
+
return require_read_back(await self.storage.aread(self.item_type, _id), self.item_type, _id)
|
|
49
|
+
|
|
50
|
+
async def patch(self, id: str, data: Item) -> Item:
|
|
51
|
+
check_patch_data(id, data)
|
|
52
|
+
item = await self.storage.aread(self.item_type, id)
|
|
53
|
+
if item is None:
|
|
54
|
+
raise ItemNotFoundError(self.item_type, id)
|
|
55
|
+
item.update(data)
|
|
56
|
+
if not await self.storage.awrite(self.item_type, item):
|
|
57
|
+
raise CollectionError(f"Failed to patch item '{id}'.")
|
|
58
|
+
return require_read_back(await self.storage.aread(self.item_type, id), self.item_type, id)
|
|
59
|
+
|
|
60
|
+
async def delete(self, id: str) -> bool:
|
|
61
|
+
return await self.storage.adelete(self.item_type, id)
|
|
62
|
+
|
|
63
|
+
def register_action(self, name: str, handler: ActionHandler | AsyncActionHandler) -> None:
|
|
64
|
+
"""Register ``handler`` as action ``name``, replacing any handler already registered under that name.
|
|
65
|
+
|
|
66
|
+
Async handlers are awaited; sync handlers run in a worker thread (``asyncio.to_thread``).
|
|
67
|
+
"""
|
|
68
|
+
check_action_name(name)
|
|
69
|
+
self.actions[name] = handler
|
|
70
|
+
|
|
71
|
+
async def run_action(self, id: str, name: str, params: ActionParams | None = None) -> Item:
|
|
72
|
+
"""Run action ``name`` on item ``id`` and return the resulting item.
|
|
73
|
+
|
|
74
|
+
If the handler returns an item, it is saved (replacing the stored item) and
|
|
75
|
+
returned; if it returns ``None``, the stored item is returned unchanged.
|
|
76
|
+
"""
|
|
77
|
+
handler = self.actions.get(name)
|
|
78
|
+
if handler is None:
|
|
79
|
+
raise ActionNotFoundError(name, f"item type '{self.item_type}'")
|
|
80
|
+
item = await self.storage.aread(self.item_type, id)
|
|
81
|
+
if item is None:
|
|
82
|
+
raise ItemNotFoundError(self.item_type, id)
|
|
83
|
+
logger.debug("Running action %r on %s item %r", name, self.item_type, id)
|
|
84
|
+
if inspect.iscoroutinefunction(handler):
|
|
85
|
+
result = await cast(AsyncActionHandler, handler)(item, params or {})
|
|
86
|
+
else:
|
|
87
|
+
result = await asyncio.to_thread(cast(ActionHandler, handler), item, params or {})
|
|
88
|
+
if result is None:
|
|
89
|
+
logger.debug("Action %r on %s item %r left the item unchanged", name, self.item_type, id)
|
|
90
|
+
return item
|
|
91
|
+
check_action_result(id, result)
|
|
92
|
+
logger.debug("Action %r on %s item %r returned an updated item; saving", name, self.item_type, id)
|
|
93
|
+
return await self.save(result)
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
1
3
|
from typing import Any
|
|
2
4
|
|
|
3
|
-
from objbase.
|
|
5
|
+
from objbase.actions import ActionHandler, ActionParams, check_action_name, check_action_result
|
|
6
|
+
from objbase.errors import ActionNotFoundError, CollectionError, ItemNotFoundError
|
|
4
7
|
from objbase.interface import Item, Storage
|
|
5
8
|
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
6
11
|
|
|
7
12
|
def require_item_id(item: Item) -> Any:
|
|
8
13
|
"""Return the item's id, raising ``ValueError`` if it is missing or empty."""
|
|
@@ -29,6 +34,7 @@ class Collection:
|
|
|
29
34
|
def __init__(self, item_type: str, storage: Storage):
|
|
30
35
|
self.storage = storage
|
|
31
36
|
self.item_type = item_type
|
|
37
|
+
self.actions: dict[str, ActionHandler] = {}
|
|
32
38
|
|
|
33
39
|
def keys(self) -> list[str]:
|
|
34
40
|
return self.storage.keys(self.item_type)
|
|
@@ -57,3 +63,31 @@ class Collection:
|
|
|
57
63
|
|
|
58
64
|
def delete(self, id: str) -> bool:
|
|
59
65
|
return self.storage.delete(self.item_type, id)
|
|
66
|
+
|
|
67
|
+
def register_action(self, name: str, handler: ActionHandler) -> None:
|
|
68
|
+
"""Register ``handler`` as action ``name``, replacing any handler already registered under that name."""
|
|
69
|
+
check_action_name(name)
|
|
70
|
+
if inspect.iscoroutinefunction(handler):
|
|
71
|
+
raise TypeError(f"Action '{name}' has an async handler; register it on an AsyncCollection instead.")
|
|
72
|
+
self.actions[name] = handler
|
|
73
|
+
|
|
74
|
+
def run_action(self, id: str, name: str, params: ActionParams | None = None) -> Item:
|
|
75
|
+
"""Run action ``name`` on item ``id`` and return the resulting item.
|
|
76
|
+
|
|
77
|
+
If the handler returns an item, it is saved (replacing the stored item) and
|
|
78
|
+
returned; if it returns ``None``, the stored item is returned unchanged.
|
|
79
|
+
"""
|
|
80
|
+
handler = self.actions.get(name)
|
|
81
|
+
if handler is None:
|
|
82
|
+
raise ActionNotFoundError(name, f"item type '{self.item_type}'")
|
|
83
|
+
item = self.storage.read(self.item_type, id)
|
|
84
|
+
if item is None:
|
|
85
|
+
raise ItemNotFoundError(self.item_type, id)
|
|
86
|
+
logger.debug("Running action %r on %s item %r", name, self.item_type, id)
|
|
87
|
+
result = handler(item, params or {})
|
|
88
|
+
if result is None:
|
|
89
|
+
logger.debug("Action %r on %s item %r left the item unchanged", name, self.item_type, id)
|
|
90
|
+
return item
|
|
91
|
+
check_action_result(id, result)
|
|
92
|
+
logger.debug("Action %r on %s item %r returned an updated item; saving", name, self.item_type, id)
|
|
93
|
+
return self.save(result)
|
|
@@ -9,3 +9,12 @@ class ItemNotFoundError(CollectionError, LookupError):
|
|
|
9
9
|
self.item_type = item_type
|
|
10
10
|
self.id = id
|
|
11
11
|
super().__init__(f"Item '{id}' of type '{item_type}' not found.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ActionNotFoundError(CollectionError, LookupError):
|
|
15
|
+
"""Raised when an action is not registered on a collection or not found in a module."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, action: str, source: str):
|
|
18
|
+
self.action = action
|
|
19
|
+
self.source = source
|
|
20
|
+
super().__init__(f"Action '{action}' not found for {source}.")
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Tests for action handlers on Collection and AsyncCollection, and load_action_handler."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from objbase.actions import load_action_handler
|
|
9
|
+
from objbase.asyncio.collection import AsyncCollection
|
|
10
|
+
from objbase.collection import Collection
|
|
11
|
+
from objbase.errors import ActionNotFoundError, CollectionError, ItemNotFoundError
|
|
12
|
+
from objbase.storage.inmemory import InMemoryStorage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_status(item, params):
|
|
16
|
+
return {**item, "status": params["status"]}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def no_change(item, params):
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def async_set_status(item, params):
|
|
24
|
+
return {**item, "status": params["status"]}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture()
|
|
28
|
+
def storage() -> InMemoryStorage:
|
|
29
|
+
return InMemoryStorage()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture()
|
|
33
|
+
def todos(storage) -> Collection:
|
|
34
|
+
todos = Collection(item_type="todo", storage=storage)
|
|
35
|
+
todos.save({"id": "1", "status": "pending"})
|
|
36
|
+
return todos
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture()
|
|
40
|
+
def async_todos(storage) -> AsyncCollection:
|
|
41
|
+
storage.write("todo", {"id": "1", "status": "pending"})
|
|
42
|
+
return AsyncCollection(item_type="todo", storage=storage)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ===========================================================================
|
|
46
|
+
# Collection
|
|
47
|
+
# ===========================================================================
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestCollectionRegisterAction:
|
|
51
|
+
def test_registers_handler(self, todos):
|
|
52
|
+
todos.register_action("set_status", set_status)
|
|
53
|
+
assert todos.actions == {"set_status": set_status}
|
|
54
|
+
|
|
55
|
+
def test_registering_again_replaces_handler(self, todos):
|
|
56
|
+
todos.register_action("act", set_status)
|
|
57
|
+
todos.register_action("act", no_change)
|
|
58
|
+
assert todos.actions["act"] is no_change
|
|
59
|
+
|
|
60
|
+
def test_actions_are_per_collection(self, storage, todos):
|
|
61
|
+
todos.register_action("set_status", set_status)
|
|
62
|
+
assert Collection(item_type="note", storage=storage).actions == {}
|
|
63
|
+
|
|
64
|
+
def test_empty_name_raises(self, todos):
|
|
65
|
+
with pytest.raises(ValueError):
|
|
66
|
+
todos.register_action("", set_status)
|
|
67
|
+
|
|
68
|
+
def test_async_handler_raises_type_error(self, todos):
|
|
69
|
+
with pytest.raises(TypeError, match="AsyncCollection"):
|
|
70
|
+
todos.register_action("set_status", async_set_status)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestCollectionRunAction:
|
|
74
|
+
def test_returned_item_is_saved_and_returned(self, todos):
|
|
75
|
+
todos.register_action("set_status", set_status)
|
|
76
|
+
assert todos.run_action("1", "set_status", {"status": "done"}) == {"id": "1", "status": "done"}
|
|
77
|
+
assert todos.get("1") == {"id": "1", "status": "done"}
|
|
78
|
+
|
|
79
|
+
def test_none_leaves_item_unchanged(self, todos):
|
|
80
|
+
todos.register_action("noop", no_change)
|
|
81
|
+
assert todos.run_action("1", "noop") == {"id": "1", "status": "pending"}
|
|
82
|
+
assert todos.get("1") == {"id": "1", "status": "pending"}
|
|
83
|
+
|
|
84
|
+
def test_params_default_to_empty_dict(self, todos):
|
|
85
|
+
received = []
|
|
86
|
+
todos.register_action("record", lambda item, params: received.append(params))
|
|
87
|
+
todos.run_action("1", "record")
|
|
88
|
+
assert received == [{}]
|
|
89
|
+
|
|
90
|
+
def test_handler_receives_stored_item(self, todos):
|
|
91
|
+
received = []
|
|
92
|
+
todos.register_action("record", lambda item, params: received.append(item))
|
|
93
|
+
todos.run_action("1", "record")
|
|
94
|
+
assert received == [{"id": "1", "status": "pending"}]
|
|
95
|
+
|
|
96
|
+
def test_unknown_action_raises(self, todos):
|
|
97
|
+
with pytest.raises(ActionNotFoundError) as exc_info:
|
|
98
|
+
todos.run_action("1", "missing")
|
|
99
|
+
assert exc_info.value.action == "missing"
|
|
100
|
+
assert isinstance(exc_info.value, CollectionError)
|
|
101
|
+
assert isinstance(exc_info.value, LookupError)
|
|
102
|
+
|
|
103
|
+
def test_unknown_action_checked_before_item(self, todos):
|
|
104
|
+
with pytest.raises(ActionNotFoundError):
|
|
105
|
+
todos.run_action("nonexistent", "missing")
|
|
106
|
+
|
|
107
|
+
def test_missing_item_raises(self, todos):
|
|
108
|
+
todos.register_action("set_status", set_status)
|
|
109
|
+
with pytest.raises(ItemNotFoundError):
|
|
110
|
+
todos.run_action("nonexistent", "set_status", {"status": "done"})
|
|
111
|
+
|
|
112
|
+
@pytest.mark.parametrize("result", [{"id": "2", "status": "done"}, {"status": "done"}])
|
|
113
|
+
def test_changing_or_dropping_id_raises(self, todos, result):
|
|
114
|
+
todos.register_action("bad", lambda item, params: result)
|
|
115
|
+
with pytest.raises(ValueError):
|
|
116
|
+
todos.run_action("1", "bad")
|
|
117
|
+
assert todos.get("1") == {"id": "1", "status": "pending"}
|
|
118
|
+
assert todos.get("2") is None
|
|
119
|
+
|
|
120
|
+
def test_handler_errors_propagate(self, todos):
|
|
121
|
+
todos.register_action("set_status", set_status)
|
|
122
|
+
with pytest.raises(KeyError):
|
|
123
|
+
todos.run_action("1", "set_status", {})
|
|
124
|
+
assert todos.get("1") == {"id": "1", "status": "pending"}
|
|
125
|
+
|
|
126
|
+
def test_logs_at_debug_level(self, todos, caplog):
|
|
127
|
+
todos.register_action("set_status", set_status)
|
|
128
|
+
with caplog.at_level(logging.DEBUG, logger="objbase"):
|
|
129
|
+
todos.run_action("1", "set_status", {"status": "done"})
|
|
130
|
+
assert any("set_status" in record.getMessage() for record in caplog.records)
|
|
131
|
+
assert all(record.levelno == logging.DEBUG for record in caplog.records)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ===========================================================================
|
|
135
|
+
# AsyncCollection
|
|
136
|
+
# ===========================================================================
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestAsyncCollectionActions:
|
|
140
|
+
async def test_async_handler_result_is_saved(self, async_todos):
|
|
141
|
+
async_todos.register_action("set_status", async_set_status)
|
|
142
|
+
assert await async_todos.run_action("1", "set_status", {"status": "done"}) == {"id": "1", "status": "done"}
|
|
143
|
+
assert await async_todos.get("1") == {"id": "1", "status": "done"}
|
|
144
|
+
|
|
145
|
+
async def test_sync_handler_runs_in_worker_thread(self, async_todos):
|
|
146
|
+
threads = []
|
|
147
|
+
|
|
148
|
+
def handler(item, params):
|
|
149
|
+
threads.append(threading.current_thread())
|
|
150
|
+
return set_status(item, params)
|
|
151
|
+
|
|
152
|
+
async_todos.register_action("set_status", handler)
|
|
153
|
+
assert await async_todos.run_action("1", "set_status", {"status": "done"}) == {"id": "1", "status": "done"}
|
|
154
|
+
assert threads and threads[0] is not threading.main_thread()
|
|
155
|
+
|
|
156
|
+
async def test_none_leaves_item_unchanged(self, async_todos):
|
|
157
|
+
async_todos.register_action("noop", no_change)
|
|
158
|
+
assert await async_todos.run_action("1", "noop") == {"id": "1", "status": "pending"}
|
|
159
|
+
|
|
160
|
+
async def test_unknown_action_raises(self, async_todos):
|
|
161
|
+
with pytest.raises(ActionNotFoundError):
|
|
162
|
+
await async_todos.run_action("1", "missing")
|
|
163
|
+
|
|
164
|
+
async def test_missing_item_raises(self, async_todos):
|
|
165
|
+
async_todos.register_action("set_status", async_set_status)
|
|
166
|
+
with pytest.raises(ItemNotFoundError):
|
|
167
|
+
await async_todos.run_action("nonexistent", "set_status", {"status": "done"})
|
|
168
|
+
|
|
169
|
+
async def test_changing_id_raises(self, async_todos):
|
|
170
|
+
async def bad(item, params):
|
|
171
|
+
return {"id": "2"}
|
|
172
|
+
|
|
173
|
+
async_todos.register_action("bad", bad)
|
|
174
|
+
with pytest.raises(ValueError):
|
|
175
|
+
await async_todos.run_action("1", "bad")
|
|
176
|
+
assert await async_todos.get("2") is None
|
|
177
|
+
|
|
178
|
+
def test_empty_name_raises(self, async_todos):
|
|
179
|
+
with pytest.raises(ValueError):
|
|
180
|
+
async_todos.register_action("", set_status)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ===========================================================================
|
|
184
|
+
# load_action_handler
|
|
185
|
+
# ===========================================================================
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.fixture()
|
|
189
|
+
def actions_module(tmp_path, monkeypatch):
|
|
190
|
+
(tmp_path / "my_todo_actions.py").write_text(
|
|
191
|
+
"def set_status(item, params):\n"
|
|
192
|
+
" return {**item, 'status': params['status']}\n"
|
|
193
|
+
"\n"
|
|
194
|
+
"actions = {'set_status': set_status, 'broken': 42}\n"
|
|
195
|
+
"handlers = {'other': set_status}\n"
|
|
196
|
+
)
|
|
197
|
+
(tmp_path / "my_failing_actions.py").write_text("import does_not_exist_anywhere\n")
|
|
198
|
+
monkeypatch.syspath_prepend(str(tmp_path))
|
|
199
|
+
return "my_todo_actions"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TestLoadActionHandler:
|
|
203
|
+
def test_loads_handler_from_actions_mapping(self, actions_module, todos):
|
|
204
|
+
handler = load_action_handler(actions_module, "set_status")
|
|
205
|
+
todos.register_action("set_status", handler)
|
|
206
|
+
assert todos.run_action("1", "set_status", {"status": "done"})["status"] == "done"
|
|
207
|
+
|
|
208
|
+
def test_custom_attr_name(self, actions_module):
|
|
209
|
+
assert callable(load_action_handler(actions_module, "other", attr_name="handlers"))
|
|
210
|
+
|
|
211
|
+
def test_unknown_action_raises(self, actions_module):
|
|
212
|
+
with pytest.raises(ActionNotFoundError, match="missing"):
|
|
213
|
+
load_action_handler(actions_module, "missing")
|
|
214
|
+
|
|
215
|
+
def test_missing_mapping_raises(self, actions_module):
|
|
216
|
+
with pytest.raises(ActionNotFoundError):
|
|
217
|
+
load_action_handler(actions_module, "set_status", attr_name="nope")
|
|
218
|
+
|
|
219
|
+
def test_non_callable_raises(self, actions_module):
|
|
220
|
+
with pytest.raises(TypeError):
|
|
221
|
+
load_action_handler(actions_module, "broken")
|
|
222
|
+
|
|
223
|
+
def test_missing_module_raises_import_error(self):
|
|
224
|
+
with pytest.raises(ModuleNotFoundError):
|
|
225
|
+
load_action_handler("objbase_no_such_module", "set_status")
|
|
226
|
+
|
|
227
|
+
def test_import_errors_inside_module_propagate(self, actions_module):
|
|
228
|
+
with pytest.raises(ModuleNotFoundError, match="does_not_exist_anywhere"):
|
|
229
|
+
load_action_handler("my_failing_actions", "set_status")
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
from objbase.collection import check_patch_data, require_item_id, require_read_back
|
|
2
|
-
from objbase.errors import CollectionError, ItemNotFoundError
|
|
3
|
-
from objbase.interface import AsyncStorage, Item
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class AsyncCollection:
|
|
7
|
-
"""Async counterpart of ``Collection``, backed by an ``AsyncStorage``.
|
|
8
|
-
|
|
9
|
-
Same methods and behaviour as ``Collection``, but every method is a coroutine.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
def __init__(self, item_type: str, storage: AsyncStorage):
|
|
13
|
-
if not isinstance(storage, AsyncStorage):
|
|
14
|
-
raise TypeError(
|
|
15
|
-
f"{type(storage).__name__} is not an AsyncStorage; use Collection for sync storage adapters."
|
|
16
|
-
)
|
|
17
|
-
self.storage = storage
|
|
18
|
-
self.item_type = item_type
|
|
19
|
-
|
|
20
|
-
async def keys(self) -> list[str]:
|
|
21
|
-
return await self.storage.akeys(self.item_type)
|
|
22
|
-
|
|
23
|
-
async def items(self) -> list[Item]:
|
|
24
|
-
return await self.storage.aitems(self.item_type)
|
|
25
|
-
|
|
26
|
-
async def get(self, id: str) -> Item | None:
|
|
27
|
-
return await self.storage.aread(self.item_type, id)
|
|
28
|
-
|
|
29
|
-
async def save(self, item: Item) -> Item:
|
|
30
|
-
_id = require_item_id(item)
|
|
31
|
-
if not await self.storage.awrite(self.item_type, item):
|
|
32
|
-
raise CollectionError(f"Failed to save item '{_id}'.")
|
|
33
|
-
return require_read_back(await self.storage.aread(self.item_type, _id), self.item_type, _id)
|
|
34
|
-
|
|
35
|
-
async def patch(self, id: str, data: Item) -> Item:
|
|
36
|
-
check_patch_data(id, data)
|
|
37
|
-
item = await self.storage.aread(self.item_type, id)
|
|
38
|
-
if item is None:
|
|
39
|
-
raise ItemNotFoundError(self.item_type, id)
|
|
40
|
-
item.update(data)
|
|
41
|
-
if not await self.storage.awrite(self.item_type, item):
|
|
42
|
-
raise CollectionError(f"Failed to patch item '{id}'.")
|
|
43
|
-
return require_read_back(await self.storage.aread(self.item_type, id), self.item_type, id)
|
|
44
|
-
|
|
45
|
-
async def delete(self, id: str) -> bool:
|
|
46
|
-
return await self.storage.adelete(self.item_type, id)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|