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.
Files changed (60) hide show
  1. {objbase-0.5.1 → objbase-0.5.2}/PKG-INFO +81 -2
  2. {objbase-0.5.1 → objbase-0.5.2}/README.md +80 -1
  3. {objbase-0.5.1 → objbase-0.5.2}/pyproject.toml +1 -1
  4. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/__init__.py +7 -1
  5. objbase-0.5.2/src/objbase/actions.py +55 -0
  6. objbase-0.5.2/src/objbase/asyncio/collection.py +93 -0
  7. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/collection.py +35 -1
  8. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/errors.py +9 -0
  9. objbase-0.5.2/tests/test_actions.py +229 -0
  10. {objbase-0.5.1 → objbase-0.5.2}/uv.lock +1 -1
  11. objbase-0.5.1/src/objbase/asyncio/collection.py +0 -46
  12. {objbase-0.5.1 → objbase-0.5.2}/.github/dependabot.yml +0 -0
  13. {objbase-0.5.1 → objbase-0.5.2}/.github/workflows/ci.yml +0 -0
  14. {objbase-0.5.1 → objbase-0.5.2}/.github/workflows/release.yml +0 -0
  15. {objbase-0.5.1 → objbase-0.5.2}/.gitignore +0 -0
  16. {objbase-0.5.1 → objbase-0.5.2}/DEVELOPER.md +0 -0
  17. {objbase-0.5.1 → objbase-0.5.2}/LICENSE +0 -0
  18. {objbase-0.5.1 → objbase-0.5.2}/examples/async_directory_example.py +0 -0
  19. {objbase-0.5.1 → objbase-0.5.2}/examples/async_example.py +0 -0
  20. {objbase-0.5.1 → objbase-0.5.2}/examples/async_file_example.py +0 -0
  21. {objbase-0.5.1 → objbase-0.5.2}/examples/async_mongodb_example.py +0 -0
  22. {objbase-0.5.1 → objbase-0.5.2}/examples/async_pydantic_example.py +0 -0
  23. {objbase-0.5.1 → objbase-0.5.2}/examples/async_sqlite_example.py +0 -0
  24. {objbase-0.5.1 → objbase-0.5.2}/examples/dict_example.py +0 -0
  25. {objbase-0.5.1 → objbase-0.5.2}/examples/mongodb_example.py +0 -0
  26. {objbase-0.5.1 → objbase-0.5.2}/examples/pydantic_example.py +0 -0
  27. {objbase-0.5.1 → objbase-0.5.2}/release.sh +0 -0
  28. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/__init__.py +0 -0
  29. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/storage/__init__.py +0 -0
  30. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/storage/local.py +0 -0
  31. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/storage/mongodb.py +0 -0
  32. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/storage/redis.py +0 -0
  33. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/storage/sqlite.py +0 -0
  34. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/asyncio/storage/threaded.py +0 -0
  35. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/interface.py +0 -0
  36. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/py.typed +0 -0
  37. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/pydantic.py +0 -0
  38. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/storage/__init__.py +0 -0
  39. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/storage/inmemory.py +0 -0
  40. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/storage/local.py +0 -0
  41. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/storage/mongodb.py +0 -0
  42. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/storage/redis.py +0 -0
  43. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/storage/sqlite.py +0 -0
  44. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/util/__init__.py +0 -0
  45. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/util/file_util.py +0 -0
  46. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/util/mongodb_util.py +0 -0
  47. {objbase-0.5.1 → objbase-0.5.2}/src/objbase/util/redis_util.py +0 -0
  48. {objbase-0.5.1 → objbase-0.5.2}/tests/test_async_collection.py +0 -0
  49. {objbase-0.5.1 → objbase-0.5.2}/tests/test_async_file_storage.py +0 -0
  50. {objbase-0.5.1 → objbase-0.5.2}/tests/test_async_mongodb_storage.py +0 -0
  51. {objbase-0.5.1 → objbase-0.5.2}/tests/test_async_redis_storage.py +0 -0
  52. {objbase-0.5.1 → objbase-0.5.2}/tests/test_async_sqlite_storage.py +0 -0
  53. {objbase-0.5.1 → objbase-0.5.2}/tests/test_collection.py +0 -0
  54. {objbase-0.5.1 → objbase-0.5.2}/tests/test_file_storage.py +0 -0
  55. {objbase-0.5.1 → objbase-0.5.2}/tests/test_inmemory_storage.py +0 -0
  56. {objbase-0.5.1 → objbase-0.5.2}/tests/test_mongodb_storage.py +0 -0
  57. {objbase-0.5.1 → objbase-0.5.2}/tests/test_package.py +0 -0
  58. {objbase-0.5.1 → objbase-0.5.2}/tests/test_redis_storage.py +0 -0
  59. {objbase-0.5.1 → objbase-0.5.2}/tests/test_sqlite_storage.py +0 -0
  60. {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.1
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/):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "objbase"
3
- version = "0.5.1"
3
+ version = "0.5.2"
4
4
  description = "Damn simple object store for Python dicts and Pydantic models across multiple backends"
5
5
  requires-python = ">=3.13"
6
6
  dependencies = []
@@ -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.errors import CollectionError, ItemNotFoundError
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")
@@ -414,7 +414,7 @@ wheels = [
414
414
 
415
415
  [[package]]
416
416
  name = "objbase"
417
- version = "0.5.1"
417
+ version = "0.5.2"
418
418
  source = { editable = "." }
419
419
 
420
420
  [package.optional-dependencies]
@@ -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