objbase 0.5.0__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 (61) hide show
  1. {objbase-0.5.0 → objbase-0.5.2}/PKG-INFO +97 -17
  2. {objbase-0.5.0 → objbase-0.5.2}/README.md +96 -16
  3. {objbase-0.5.0 → objbase-0.5.2}/examples/async_directory_example.py +9 -9
  4. {objbase-0.5.0 → objbase-0.5.2}/examples/async_example.py +5 -5
  5. objbase-0.5.2/examples/async_file_example.py +30 -0
  6. {objbase-0.5.0 → objbase-0.5.2}/examples/async_mongodb_example.py +7 -7
  7. {objbase-0.5.0 → objbase-0.5.2}/examples/async_pydantic_example.py +7 -7
  8. {objbase-0.5.0 → objbase-0.5.2}/examples/async_sqlite_example.py +7 -7
  9. {objbase-0.5.0 → objbase-0.5.2}/examples/dict_example.py +5 -5
  10. {objbase-0.5.0 → objbase-0.5.2}/examples/mongodb_example.py +7 -7
  11. {objbase-0.5.0 → objbase-0.5.2}/examples/pydantic_example.py +5 -5
  12. {objbase-0.5.0 → objbase-0.5.2}/pyproject.toml +1 -1
  13. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/__init__.py +7 -1
  14. objbase-0.5.2/src/objbase/actions.py +55 -0
  15. objbase-0.5.2/src/objbase/asyncio/collection.py +93 -0
  16. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/asyncio/storage/local.py +4 -4
  17. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/asyncio/storage/mongodb.py +4 -2
  18. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/collection.py +35 -1
  19. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/errors.py +9 -0
  20. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/pydantic.py +22 -22
  21. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/storage/inmemory.py +1 -1
  22. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/storage/local.py +8 -8
  23. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/storage/mongodb.py +9 -3
  24. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/storage/redis.py +1 -1
  25. objbase-0.5.2/tests/test_actions.py +229 -0
  26. {objbase-0.5.0 → objbase-0.5.2}/tests/test_async_collection.py +8 -8
  27. {objbase-0.5.0 → objbase-0.5.2}/tests/test_async_file_storage.py +3 -3
  28. {objbase-0.5.0 → objbase-0.5.2}/tests/test_async_mongodb_storage.py +11 -2
  29. {objbase-0.5.0 → objbase-0.5.2}/tests/test_async_redis_storage.py +2 -2
  30. {objbase-0.5.0 → objbase-0.5.2}/tests/test_async_sqlite_storage.py +1 -1
  31. {objbase-0.5.0 → objbase-0.5.2}/tests/test_collection.py +6 -6
  32. {objbase-0.5.0 → objbase-0.5.2}/tests/test_file_storage.py +2 -2
  33. {objbase-0.5.0 → objbase-0.5.2}/tests/test_mongodb_storage.py +30 -3
  34. {objbase-0.5.0 → objbase-0.5.2}/tests/test_redis_storage.py +3 -3
  35. {objbase-0.5.0 → objbase-0.5.2}/tests/test_storage_contract.py +4 -4
  36. {objbase-0.5.0 → objbase-0.5.2}/uv.lock +1 -1
  37. objbase-0.5.0/examples/async_file_example.py +0 -30
  38. objbase-0.5.0/src/objbase/asyncio/collection.py +0 -46
  39. {objbase-0.5.0 → objbase-0.5.2}/.github/dependabot.yml +0 -0
  40. {objbase-0.5.0 → objbase-0.5.2}/.github/workflows/ci.yml +0 -0
  41. {objbase-0.5.0 → objbase-0.5.2}/.github/workflows/release.yml +0 -0
  42. {objbase-0.5.0 → objbase-0.5.2}/.gitignore +0 -0
  43. {objbase-0.5.0 → objbase-0.5.2}/DEVELOPER.md +0 -0
  44. {objbase-0.5.0 → objbase-0.5.2}/LICENSE +0 -0
  45. {objbase-0.5.0 → objbase-0.5.2}/release.sh +0 -0
  46. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/asyncio/__init__.py +0 -0
  47. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/asyncio/storage/__init__.py +0 -0
  48. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/asyncio/storage/redis.py +0 -0
  49. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/asyncio/storage/sqlite.py +0 -0
  50. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/asyncio/storage/threaded.py +0 -0
  51. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/interface.py +0 -0
  52. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/py.typed +0 -0
  53. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/storage/__init__.py +0 -0
  54. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/storage/sqlite.py +0 -0
  55. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/util/__init__.py +0 -0
  56. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/util/file_util.py +0 -0
  57. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/util/mongodb_util.py +0 -0
  58. {objbase-0.5.0 → objbase-0.5.2}/src/objbase/util/redis_util.py +0 -0
  59. {objbase-0.5.0 → objbase-0.5.2}/tests/test_inmemory_storage.py +0 -0
  60. {objbase-0.5.0 → objbase-0.5.2}/tests/test_package.py +0 -0
  61. {objbase-0.5.0 → objbase-0.5.2}/tests/test_sqlite_storage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: objbase
3
- Version: 0.5.0
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
@@ -41,7 +41,7 @@ __No thrills__ - **just a simple key-value store for serializable Python objects
41
41
 
42
42
  ## What you get
43
43
 
44
- - Basic CRUD operations: `save`, `get`, `filter`, `keys`, `patch`, `delete`
44
+ - Basic CRUD operations: `save`, `get`, `items`, `keys`, `patch`, `delete`
45
45
  - Multiple storage adapters (in-memory, file-based, SQLite, Redis, MongoDB)
46
46
  - Optional Pydantic model validation with `PydanticCollection` / `AsyncPydanticCollection`
47
47
  - Async support via `AsyncCollection` with async storage adapters (in-memory, file-based, SQLite, Redis, MongoDB)
@@ -97,12 +97,13 @@ or from their submodules as in the examples below:
97
97
  | Module | Contents |
98
98
  |---|---|
99
99
  | `objbase.interface` | `Storage`, `AsyncStorage` protocols and the `Item` type |
100
- | `objbase.inventory` | `Collection` |
101
- | `objbase.errors` | `CollectionError`, `ItemNotFoundError` |
100
+ | `objbase.collection` | `Collection` |
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
- | `objbase.storage.{inmemory,file,sqlite,redis,mongodb}_storage` | Sync storage adapters |
104
- | `objbase.asyncio.inventory` | `AsyncCollection` |
105
- | `objbase.asyncio.storage.{file,sqlite,redis,mongodb}_storage` | Async storage adapters |
104
+ | `objbase.storage.{inmemory,local,sqlite,redis,mongodb}` | Sync storage adapters |
105
+ | `objbase.asyncio.collection` | `AsyncCollection` |
106
+ | `objbase.asyncio.storage.{local,sqlite,redis,mongodb}` | Async storage adapters |
106
107
 
107
108
  `import objbase` works without Pydantic installed; `PydanticCollection` and
108
109
  `AsyncPydanticCollection` are loaded on first access.
@@ -111,7 +112,7 @@ or from their submodules as in the examples below:
111
112
 
112
113
  All adapters follow the same contract (verified by a shared test suite):
113
114
 
114
- - `get` returns `None` for a missing item; `filter` and `keys` return `[]` for an empty type.
115
+ - `get` returns `None` for a missing item; `items` and `keys` return `[]` for an empty type.
115
116
  - `save` inserts a new item or **replaces** an existing one entirely (it does not merge fields).
116
117
  - `patch` merges the given fields into an existing item. It cannot change the item's `id`.
117
118
  - `delete` returns `True` if the item was removed, `False` if it did not exist.
@@ -123,8 +124,9 @@ Errors are raised, not returned:
123
124
  |---|---|
124
125
  | `save` an item without an `id` (or with an empty one) | `ValueError` |
125
126
  | `patch` with data that changes the item's `id` | `ValueError` |
126
- | `patch` a missing item | `objbase.ItemNotFoundError` (an `CollectionError` and a `LookupError`) |
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
 
@@ -258,9 +260,9 @@ client = redis.Redis(host="localhost", port=6379, decode_responses=True)
258
260
  storage = RedisStorage(redis_client=client)
259
261
  ```
260
262
 
261
- Each item type is one Redis hash, `inventory:{item_type}`, mapping item ids to
263
+ Each item type is one Redis hash, `collection:{item_type}`, mapping item ids to
262
264
  JSON-encoded items, so value types (numbers, booleans, lists, nested dicts) are
263
- preserved. Pass `key_prefix="myapp:"` to use a different prefix than `inventory:`.
265
+ preserved. Pass `key_prefix="myapp:"` to use a different prefix than `collection:`.
264
266
 
265
267
  Pass a pre-configured `redis.Redis` client (sync); `decode_responses` may be on or off.
266
268
  Requires `redis-py`. `AsyncRedisStorage` takes a `redis.asyncio.Redis` client
@@ -276,10 +278,11 @@ client = pymongo.MongoClient("mongodb://localhost:27017")
276
278
  storage = MongoDBStorage(mongo_client=client)
277
279
  ```
278
280
 
279
- Items are stored in the `collection.py` database, one collection per `item_type`.
281
+ Items are stored in the `collection` database, one collection per `item_type`.
282
+ Pass `db_name="myapp"` to use a different database.
280
283
  The MongoDB `_id` field is stripped from results automatically.
281
284
  Pass a pre-configured `pymongo.MongoClient`. Requires `pymongo`. `AsyncMongoDBStorage`
282
- takes a `pymongo.AsyncMongoClient` and uses the same layout, so sync and async adapters
285
+ takes a `pymongo.AsyncMongoClient` (and the same `db_name` option) and uses the same layout, so sync and async adapters
283
286
  can share data. Both accept an optional MongoDB `query` in `items` / `aitems` to filter results.
284
287
 
285
288
  ---
@@ -314,7 +317,7 @@ if item is not None:
314
317
  ```
315
318
 
316
319
  The model type is inferred from `model_class`, so type checkers know that
317
- `todos.get()` returns `Todo | None` and `todos.filter()` returns `list[Todo]`.
320
+ `todos.get()` returns `Todo | None` and `todos.items()` returns `list[Todo]`.
318
321
  `todos.keys()` returns the item ids (`list[str]`) without loading or validating
319
322
  any items.
320
323
 
@@ -369,7 +372,7 @@ todos = AsyncCollection(item_type="todo", storage=AsyncRedisStorage(client))
369
372
 
370
373
  await todos.save({"id": "1", "title": "Buy milk", "done": False})
371
374
  await todos.get("1") # → {"id": "1", "title": "Buy milk", "done": False}
372
- await todos.filter() # → [{"id": "1", ...}]
375
+ await todos.items() # → [{"id": "1", ...}]
373
376
  await todos.keys() # → ["1"]
374
377
  await todos.patch("1", {"done": True}) # → {"id": "1", ..., "done": True}
375
378
  await todos.delete("1") # → True
@@ -384,6 +387,83 @@ can also be called directly on the storage.
384
387
 
385
388
  ---
386
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
+
387
467
  ## Examples
388
468
 
389
469
  Runnable scripts are in [`examples/`](examples/):
@@ -405,7 +485,7 @@ uv run python examples/async_sqlite_example.py
405
485
  ```
406
486
 
407
487
  The file-based and SQLite examples write to `data/` in the current directory
408
- (ignored by git); set `INVENTORY_DIR` or `SQLITE_DB_PATH` to change that. The MongoDB
488
+ (ignored by git); set `OBJBASE_DATA_DIR` or `SQLITE_DB_PATH` to change that. The MongoDB
409
489
  examples need a running server — `docker run --rm -p 27017:27017 mongo:7.0` — and
410
490
  connect to `MONGODB_URI` (default `mongodb://localhost:27017`).
411
491
 
@@ -453,7 +533,7 @@ def get_todos(request: Request) -> AsyncCollection:
453
533
 
454
534
  @app.get("/todos")
455
535
  async def list_todos(todos: AsyncCollection = Depends(get_todos)):
456
- return await todos.filter()
536
+ return await todos.items()
457
537
 
458
538
 
459
539
  @app.get("/todos/{todo_id}")
@@ -9,7 +9,7 @@ __No thrills__ - **just a simple key-value store for serializable Python objects
9
9
 
10
10
  ## What you get
11
11
 
12
- - Basic CRUD operations: `save`, `get`, `filter`, `keys`, `patch`, `delete`
12
+ - Basic CRUD operations: `save`, `get`, `items`, `keys`, `patch`, `delete`
13
13
  - Multiple storage adapters (in-memory, file-based, SQLite, Redis, MongoDB)
14
14
  - Optional Pydantic model validation with `PydanticCollection` / `AsyncPydanticCollection`
15
15
  - Async support via `AsyncCollection` with async storage adapters (in-memory, file-based, SQLite, Redis, MongoDB)
@@ -65,12 +65,13 @@ or from their submodules as in the examples below:
65
65
  | Module | Contents |
66
66
  |---|---|
67
67
  | `objbase.interface` | `Storage`, `AsyncStorage` protocols and the `Item` type |
68
- | `objbase.inventory` | `Collection` |
69
- | `objbase.errors` | `CollectionError`, `ItemNotFoundError` |
68
+ | `objbase.collection` | `Collection` |
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
- | `objbase.storage.{inmemory,file,sqlite,redis,mongodb}_storage` | Sync storage adapters |
72
- | `objbase.asyncio.inventory` | `AsyncCollection` |
73
- | `objbase.asyncio.storage.{file,sqlite,redis,mongodb}_storage` | Async storage adapters |
72
+ | `objbase.storage.{inmemory,local,sqlite,redis,mongodb}` | Sync storage adapters |
73
+ | `objbase.asyncio.collection` | `AsyncCollection` |
74
+ | `objbase.asyncio.storage.{local,sqlite,redis,mongodb}` | Async storage adapters |
74
75
 
75
76
  `import objbase` works without Pydantic installed; `PydanticCollection` and
76
77
  `AsyncPydanticCollection` are loaded on first access.
@@ -79,7 +80,7 @@ or from their submodules as in the examples below:
79
80
 
80
81
  All adapters follow the same contract (verified by a shared test suite):
81
82
 
82
- - `get` returns `None` for a missing item; `filter` and `keys` return `[]` for an empty type.
83
+ - `get` returns `None` for a missing item; `items` and `keys` return `[]` for an empty type.
83
84
  - `save` inserts a new item or **replaces** an existing one entirely (it does not merge fields).
84
85
  - `patch` merges the given fields into an existing item. It cannot change the item's `id`.
85
86
  - `delete` returns `True` if the item was removed, `False` if it did not exist.
@@ -91,8 +92,9 @@ Errors are raised, not returned:
91
92
  |---|---|
92
93
  | `save` an item without an `id` (or with an empty one) | `ValueError` |
93
94
  | `patch` with data that changes the item's `id` | `ValueError` |
94
- | `patch` a missing item | `objbase.ItemNotFoundError` (an `CollectionError` and a `LookupError`) |
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
 
@@ -226,9 +228,9 @@ client = redis.Redis(host="localhost", port=6379, decode_responses=True)
226
228
  storage = RedisStorage(redis_client=client)
227
229
  ```
228
230
 
229
- Each item type is one Redis hash, `inventory:{item_type}`, mapping item ids to
231
+ Each item type is one Redis hash, `collection:{item_type}`, mapping item ids to
230
232
  JSON-encoded items, so value types (numbers, booleans, lists, nested dicts) are
231
- preserved. Pass `key_prefix="myapp:"` to use a different prefix than `inventory:`.
233
+ preserved. Pass `key_prefix="myapp:"` to use a different prefix than `collection:`.
232
234
 
233
235
  Pass a pre-configured `redis.Redis` client (sync); `decode_responses` may be on or off.
234
236
  Requires `redis-py`. `AsyncRedisStorage` takes a `redis.asyncio.Redis` client
@@ -244,10 +246,11 @@ client = pymongo.MongoClient("mongodb://localhost:27017")
244
246
  storage = MongoDBStorage(mongo_client=client)
245
247
  ```
246
248
 
247
- Items are stored in the `collection.py` database, one collection per `item_type`.
249
+ Items are stored in the `collection` database, one collection per `item_type`.
250
+ Pass `db_name="myapp"` to use a different database.
248
251
  The MongoDB `_id` field is stripped from results automatically.
249
252
  Pass a pre-configured `pymongo.MongoClient`. Requires `pymongo`. `AsyncMongoDBStorage`
250
- takes a `pymongo.AsyncMongoClient` and uses the same layout, so sync and async adapters
253
+ takes a `pymongo.AsyncMongoClient` (and the same `db_name` option) and uses the same layout, so sync and async adapters
251
254
  can share data. Both accept an optional MongoDB `query` in `items` / `aitems` to filter results.
252
255
 
253
256
  ---
@@ -282,7 +285,7 @@ if item is not None:
282
285
  ```
283
286
 
284
287
  The model type is inferred from `model_class`, so type checkers know that
285
- `todos.get()` returns `Todo | None` and `todos.filter()` returns `list[Todo]`.
288
+ `todos.get()` returns `Todo | None` and `todos.items()` returns `list[Todo]`.
286
289
  `todos.keys()` returns the item ids (`list[str]`) without loading or validating
287
290
  any items.
288
291
 
@@ -337,7 +340,7 @@ todos = AsyncCollection(item_type="todo", storage=AsyncRedisStorage(client))
337
340
 
338
341
  await todos.save({"id": "1", "title": "Buy milk", "done": False})
339
342
  await todos.get("1") # → {"id": "1", "title": "Buy milk", "done": False}
340
- await todos.filter() # → [{"id": "1", ...}]
343
+ await todos.items() # → [{"id": "1", ...}]
341
344
  await todos.keys() # → ["1"]
342
345
  await todos.patch("1", {"done": True}) # → {"id": "1", ..., "done": True}
343
346
  await todos.delete("1") # → True
@@ -352,6 +355,83 @@ can also be called directly on the storage.
352
355
 
353
356
  ---
354
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
+
355
435
  ## Examples
356
436
 
357
437
  Runnable scripts are in [`examples/`](examples/):
@@ -373,7 +453,7 @@ uv run python examples/async_sqlite_example.py
373
453
  ```
374
454
 
375
455
  The file-based and SQLite examples write to `data/` in the current directory
376
- (ignored by git); set `INVENTORY_DIR` or `SQLITE_DB_PATH` to change that. The MongoDB
456
+ (ignored by git); set `OBJBASE_DATA_DIR` or `SQLITE_DB_PATH` to change that. The MongoDB
377
457
  examples need a running server — `docker run --rm -p 27017:27017 mongo:7.0` — and
378
458
  connect to `MONGODB_URI` (default `mongodb://localhost:27017`).
379
459
 
@@ -421,7 +501,7 @@ def get_todos(request: Request) -> AsyncCollection:
421
501
 
422
502
  @app.get("/todos")
423
503
  async def list_todos(todos: AsyncCollection = Depends(get_todos)):
424
- return await todos.filter()
504
+ return await todos.items()
425
505
 
426
506
 
427
507
  @app.get("/todos/{todo_id}")
@@ -1,5 +1,5 @@
1
1
  # Async to-do list stored in one JSON file per item ({base_dir}/todo/{id}.json). No extra dependencies needed.
2
- # Set INVENTORY_DIR to use a different directory.
2
+ # Set OBJBASE_DATA_DIR to use a different directory.
3
3
  import asyncio
4
4
  import os
5
5
 
@@ -8,28 +8,28 @@ from objbase.asyncio.storage.local import AsyncLocalDirectoryStorage
8
8
 
9
9
 
10
10
  async def main() -> None:
11
- base_dir = os.getenv("INVENTORY_DIR", "data")
11
+ base_dir = os.getenv("OBJBASE_DATA_DIR", "data")
12
12
  os.makedirs(base_dir, exist_ok=True) # the base directory must exist
13
13
  storage = AsyncLocalDirectoryStorage(base_dir=base_dir)
14
- todos_inventory = AsyncCollection(item_type="todo", storage=storage)
14
+ todos_collection = AsyncCollection(item_type="todo", storage=storage)
15
15
 
16
16
  # Create some to-do items concurrently; the adapter's file locks keep the index consistent
17
17
  await asyncio.gather(
18
- todos_inventory.save({"id": "1", "name": "Buy groceries", "status": "pending"}),
19
- todos_inventory.save({"id": "2", "name": "Walk the dog", "status": "pending"}),
18
+ todos_collection.save({"id": "1", "name": "Buy groceries", "status": "pending"}),
19
+ todos_collection.save({"id": "2", "name": "Walk the dog", "status": "pending"}),
20
20
  )
21
- print("To-do ids:", sorted(await todos_inventory.keys()))
21
+ print("To-do ids:", sorted(await todos_collection.keys()))
22
22
 
23
23
  # Update a to-do item
24
- updated_todo = await todos_inventory.patch("1", {"status": "completed"})
24
+ updated_todo = await todos_collection.patch("1", {"status": "completed"})
25
25
  print("Updated To-do:", updated_todo)
26
26
 
27
27
  # Rebuild the index, e.g. after item files were added or removed by hand
28
28
  await storage.arebuild_index("todo")
29
29
 
30
30
  # Delete the to-do items
31
- for todo_id in await todos_inventory.keys():
32
- print(f"Deleted To-do {todo_id}:", await todos_inventory.delete(todo_id))
31
+ for todo_id in await todos_collection.keys():
32
+ print(f"Deleted To-do {todo_id}:", await todos_collection.delete(todo_id))
33
33
 
34
34
 
35
35
  asyncio.run(main())
@@ -6,22 +6,22 @@ from objbase.storage.inmemory import InMemoryStorage
6
6
 
7
7
  async def main():
8
8
  # Replace with AsyncRedisStorage(redis.asyncio.Redis(...)) for real persistence
9
- todos_inventory = AsyncCollection(item_type="todo", storage=InMemoryStorage())
9
+ todos_collection = AsyncCollection(item_type="todo", storage=InMemoryStorage())
10
10
 
11
11
  # Create a new to-do item
12
- created_todo = await todos_inventory.save({"id": "1", "name": "Buy groceries", "status": "pending"})
12
+ created_todo = await todos_collection.save({"id": "1", "name": "Buy groceries", "status": "pending"})
13
13
  print("Created To-do:", created_todo)
14
14
 
15
15
  # Read the to-do item
16
- fetched_todo = await todos_inventory.get("1")
16
+ fetched_todo = await todos_collection.get("1")
17
17
  print("Fetched To-do:", fetched_todo)
18
18
 
19
19
  # Update the to-do item
20
- updated_todo = await todos_inventory.patch("1", {"status": "completed"})
20
+ updated_todo = await todos_collection.patch("1", {"status": "completed"})
21
21
  print("Updated To-do:", updated_todo)
22
22
 
23
23
  # Delete the to-do item
24
- delete_result = await todos_inventory.delete("1")
24
+ delete_result = await todos_collection.delete("1")
25
25
  print("Deleted To-do:", delete_result)
26
26
 
27
27
 
@@ -0,0 +1,30 @@
1
+ # Async to-do list stored in one JSON file per item type ({base_dir}/todo.json). No extra dependencies needed.
2
+ # Set OBJBASE_DATA_DIR to use a different directory.
3
+ import asyncio
4
+ import os
5
+
6
+ from objbase.asyncio.collection import AsyncCollection
7
+ from objbase.asyncio.storage.local import AsyncLocalFileStorage
8
+
9
+
10
+ async def main() -> None:
11
+ base_dir = os.getenv("OBJBASE_DATA_DIR", "data")
12
+ os.makedirs(base_dir, exist_ok=True) # the base directory must exist
13
+ storage = AsyncLocalFileStorage(base_dir=base_dir)
14
+ todos_collection = AsyncCollection(item_type="todo", storage=storage)
15
+
16
+ # Create some to-do items
17
+ await todos_collection.save({"id": "1", "name": "Buy groceries", "status": "pending"})
18
+ await todos_collection.save({"id": "2", "name": "Walk the dog", "status": "pending"})
19
+ print("All To-dos:", await todos_collection.items())
20
+
21
+ # Update a to-do item
22
+ updated_todo = await todos_collection.patch("1", {"status": "completed"})
23
+ print("Updated To-do:", updated_todo)
24
+
25
+ # Delete the to-do items
26
+ for todo_id in await todos_collection.keys():
27
+ print(f"Deleted To-do {todo_id}:", await todos_collection.delete(todo_id))
28
+
29
+
30
+ asyncio.run(main())
@@ -15,23 +15,23 @@ async def main() -> None:
15
15
  os.getenv("MONGODB_URI", "mongodb://localhost:27017")
16
16
  )
17
17
  storage = AsyncMongoDBStorage(mongo_client=client)
18
- todos_inventory = AsyncCollection(item_type="todo", storage=storage)
18
+ todos_collection = AsyncCollection(item_type="todo", storage=storage)
19
19
 
20
20
  # Create some to-do items
21
- await todos_inventory.save({"id": "1", "name": "Buy groceries", "status": "pending"})
22
- await todos_inventory.save({"id": "2", "name": "Walk the dog", "status": "pending"})
23
- print("All To-dos:", await todos_inventory.filter())
21
+ await todos_collection.save({"id": "1", "name": "Buy groceries", "status": "pending"})
22
+ await todos_collection.save({"id": "2", "name": "Walk the dog", "status": "pending"})
23
+ print("All To-dos:", await todos_collection.items())
24
24
 
25
25
  # Update a to-do item
26
- updated_todo = await todos_inventory.patch("1", {"status": "completed"})
26
+ updated_todo = await todos_collection.patch("1", {"status": "completed"})
27
27
  print("Updated To-do:", updated_todo)
28
28
 
29
29
  # Filter with a MongoDB query (a MongoDB-only extension of the storage adapter)
30
30
  print("Pending To-dos:", await storage.aitems("todo", query={"status": "pending"}))
31
31
 
32
32
  # Delete the to-do items
33
- for todo_id in await todos_inventory.keys():
34
- print(f"Deleted To-do {todo_id}:", await todos_inventory.delete(todo_id))
33
+ for todo_id in await todos_collection.keys():
34
+ print(f"Deleted To-do {todo_id}:", await todos_collection.delete(todo_id))
35
35
 
36
36
  await client.close()
37
37
 
@@ -14,30 +14,30 @@ class Todo(pydantic.BaseModel):
14
14
 
15
15
  async def main() -> None:
16
16
  # Replace with AsyncRedisStorage(redis.asyncio.Redis(...)) for real persistence
17
- todos_inventory = AsyncPydanticCollection(item_type="todos", storage=InMemoryStorage(), model_class=Todo)
17
+ todos_collection = AsyncPydanticCollection(item_type="todos", storage=InMemoryStorage(), model_class=Todo)
18
18
 
19
19
  # Create a new to-do item
20
- created_todo = await todos_inventory.save(Todo(id="1", title="Buy milk"))
20
+ created_todo = await todos_collection.save(Todo(id="1", title="Buy milk"))
21
21
  print("Created To-do:", created_todo)
22
22
 
23
23
  # Read the to-do item
24
- fetched_todo = await todos_inventory.get("1")
24
+ fetched_todo = await todos_collection.get("1")
25
25
  print("Fetched To-do:", fetched_todo)
26
26
  assert fetched_todo is not None # get() returns None for a missing id
27
27
 
28
28
  # Update the to-do item
29
29
  fetched_todo.completed = True
30
- updated_todo = await todos_inventory.patch("1", fetched_todo)
30
+ updated_todo = await todos_collection.patch("1", fetched_todo)
31
31
  print("Updated To-do:", updated_todo)
32
32
 
33
33
  # Invalid data is rejected and never stored
34
34
  try:
35
- await todos_inventory.patch("1", {"completed": "not a bool"})
35
+ await todos_collection.patch("1", {"completed": "not a bool"})
36
36
  except pydantic.ValidationError:
37
- print("Rejected invalid patch; stored item unchanged:", await todos_inventory.get("1"))
37
+ print("Rejected invalid patch; stored item unchanged:", await todos_collection.get("1"))
38
38
 
39
39
  # Delete the to-do item
40
- delete_result = await todos_inventory.delete("1")
40
+ delete_result = await todos_collection.delete("1")
41
41
  print("Deleted To-do:", delete_result)
42
42
 
43
43
 
@@ -11,20 +11,20 @@ async def main() -> None:
11
11
  db_path = os.getenv("SQLITE_DB_PATH", os.path.join("data", "todos.db"))
12
12
  os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True) # sqlite3 doesn't create missing directories
13
13
  storage = AsyncSQLiteStorage(db_path=db_path)
14
- todos_inventory = AsyncCollection(item_type="todo", storage=storage)
14
+ todos_collection = AsyncCollection(item_type="todo", storage=storage)
15
15
 
16
16
  # Create some to-do items
17
- await todos_inventory.save({"id": "1", "name": "Buy groceries", "status": "pending"})
18
- await todos_inventory.save({"id": "2", "name": "Walk the dog", "status": "pending"})
19
- print("All To-dos:", await todos_inventory.filter())
17
+ await todos_collection.save({"id": "1", "name": "Buy groceries", "status": "pending"})
18
+ await todos_collection.save({"id": "2", "name": "Walk the dog", "status": "pending"})
19
+ print("All To-dos:", await todos_collection.items())
20
20
 
21
21
  # Update a to-do item
22
- updated_todo = await todos_inventory.patch("1", {"status": "completed"})
22
+ updated_todo = await todos_collection.patch("1", {"status": "completed"})
23
23
  print("Updated To-do:", updated_todo)
24
24
 
25
25
  # Delete the to-do items
26
- for todo_id in await todos_inventory.keys():
27
- print(f"Deleted To-do {todo_id}:", await todos_inventory.delete(todo_id))
26
+ for todo_id in await todos_collection.keys():
27
+ print(f"Deleted To-do {todo_id}:", await todos_collection.delete(todo_id))
28
28
 
29
29
 
30
30
  asyncio.run(main())
@@ -3,21 +3,21 @@ from objbase.collection import Collection
3
3
  from objbase.storage.inmemory import InMemoryStorage
4
4
 
5
5
  # Replace with actual storage instance
6
- todos_inventory = Collection(item_type="todo", storage=InMemoryStorage())
6
+ todos_collection = Collection(item_type="todo", storage=InMemoryStorage())
7
7
 
8
8
  # Create a new to-do item
9
9
  new_todo = {"id": "1", "name": "Buy groceries", "status": "pending"}
10
- created_todo = todos_inventory.save(new_todo)
10
+ created_todo = todos_collection.save(new_todo)
11
11
  print("Created To-do:", created_todo)
12
12
 
13
13
  # Read the to-do item
14
- fetched_todo = todos_inventory.get("1")
14
+ fetched_todo = todos_collection.get("1")
15
15
  print("Fetched To-do:", fetched_todo)
16
16
 
17
17
  # Update the to-do item
18
- updated_todo = todos_inventory.patch("1", {"status": "completed"})
18
+ updated_todo = todos_collection.patch("1", {"status": "completed"})
19
19
  print("Updated To-do:", updated_todo)
20
20
 
21
21
  # Delete the to-do item
22
- delete_result = todos_inventory.delete("1")
22
+ delete_result = todos_collection.delete("1")
23
23
  print("Deleted To-do:", delete_result)
@@ -10,22 +10,22 @@ from objbase.storage.mongodb import MongoDBStorage
10
10
 
11
11
  client: pymongo.MongoClient[Item] = pymongo.MongoClient(os.getenv("MONGODB_URI", "mongodb://localhost:27017"))
12
12
  storage = MongoDBStorage(mongo_client=client)
13
- todos_inventory = Collection(item_type="todo", storage=storage)
13
+ todos_collection = Collection(item_type="todo", storage=storage)
14
14
 
15
15
  # Create some to-do items
16
- todos_inventory.save({"id": "1", "name": "Buy groceries", "status": "pending"})
17
- todos_inventory.save({"id": "2", "name": "Walk the dog", "status": "pending"})
18
- print("All To-dos:", todos_inventory.items())
16
+ todos_collection.save({"id": "1", "name": "Buy groceries", "status": "pending"})
17
+ todos_collection.save({"id": "2", "name": "Walk the dog", "status": "pending"})
18
+ print("All To-dos:", todos_collection.items())
19
19
 
20
20
  # Update a to-do item
21
- updated_todo = todos_inventory.patch("1", {"status": "completed"})
21
+ updated_todo = todos_collection.patch("1", {"status": "completed"})
22
22
  print("Updated To-do:", updated_todo)
23
23
 
24
24
  # Filter with a MongoDB query (a MongoDB-only extension of the storage adapter)
25
25
  print("Pending To-dos:", storage.items("todo", query={"status": "pending"}))
26
26
 
27
27
  # Delete the to-do items
28
- for todo_id in todos_inventory.keys():
29
- print(f"Deleted To-do {todo_id}:", todos_inventory.delete(todo_id))
28
+ for todo_id in todos_collection.keys():
29
+ print(f"Deleted To-do {todo_id}:", todos_collection.delete(todo_id))
30
30
 
31
31
  client.close()