vention-storage 0.5.2__py3-none-any.whl → 0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,476 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Optional, Sequence, Type
3
+
4
+ from pydantic import BaseModel, create_model
5
+
6
+ from communication.entries import RpcBundle, ActionEntry
7
+ from communication.errors import ConnectError
8
+
9
+ from storage.accessor import ModelAccessor
10
+ from storage.crud_service import (
11
+ CrudService,
12
+ CrudError,
13
+ NotFoundError,
14
+ ConflictError,
15
+ ValidationError,
16
+ UnsupportedError,
17
+ )
18
+ from storage.database_service import (
19
+ DatabaseService,
20
+ DatabaseError,
21
+ )
22
+ from storage.utils import parse_audit_operation, parse_audit_datetime
23
+
24
+
25
+ __all__ = ["build_storage_rpc_bundle"]
26
+
27
+
28
+ class CrudListRequest(BaseModel):
29
+ include_deleted: bool = False
30
+
31
+
32
+ class CrudGetRequest(BaseModel):
33
+ record_id: int
34
+ include_deleted: bool = False
35
+
36
+
37
+ class CrudDeleteRequest(BaseModel):
38
+ record_id: int
39
+ actor: str
40
+
41
+
42
+ class CrudRestoreRequest(BaseModel):
43
+ record_id: int
44
+ actor: str
45
+
46
+
47
+ class AuditQueryRequest(BaseModel):
48
+ component: Optional[str] = None
49
+ record_id: Optional[int] = None
50
+ actor: Optional[str] = None
51
+ operation: Optional[str] = None
52
+ since: Optional[str] = None
53
+ until: Optional[str] = None
54
+ limit: Optional[int] = None
55
+ offset: int = 0
56
+
57
+
58
+ class FileResponse(BaseModel):
59
+ filename: str
60
+ bytes: bytes
61
+
62
+
63
+ # ---------------------------------------------------------
64
+ # Helpers: Dynamic, strongly-typed models per SQLModel
65
+ # ---------------------------------------------------------
66
+
67
+
68
+ def make_list_response_model(model_cls: Type[BaseModel], name: str) -> Type[BaseModel]:
69
+ """
70
+ <Name>ListResponse { records: List[model_cls] }
71
+ """
72
+ return create_model(
73
+ f"{name}ListResponse",
74
+ records=(List[model_cls], ...), # type: ignore[valid-type]
75
+ __base__=BaseModel,
76
+ )
77
+
78
+
79
+ def make_single_response_model(
80
+ model_cls: Type[BaseModel],
81
+ name: str,
82
+ field_name: str = "record",
83
+ ) -> Type[BaseModel]:
84
+ """
85
+ <Name>Response { <field_name>: model_cls }
86
+ """
87
+ return create_model( # type: ignore[call-overload,no-any-return]
88
+ f"{name}Response",
89
+ **{field_name: (model_cls, ...)},
90
+ __base__=BaseModel,
91
+ )
92
+
93
+
94
+ def make_status_response_model(name: str) -> Type[BaseModel]:
95
+ """
96
+ <Name>StatusResponse { status: str }
97
+ """
98
+ return create_model(
99
+ f"{name}StatusResponse",
100
+ status=(str, ...),
101
+ __base__=BaseModel,
102
+ )
103
+
104
+
105
+ def make_create_request_model(model_cls: Type[BaseModel], name: str) -> Type[BaseModel]:
106
+ """
107
+ <Name>CreateRequest { record: model_cls, actor: str }
108
+ """
109
+ return create_model(
110
+ f"{name}CreateRequest",
111
+ record=(model_cls, ...),
112
+ actor=(str, ...),
113
+ __base__=BaseModel,
114
+ )
115
+
116
+
117
+ def make_update_request_model(model_cls: Type[BaseModel], name: str) -> Type[BaseModel]:
118
+ """
119
+ <Name>UpdateRequest { recordId: int, record: model_cls, actor: str }
120
+ """
121
+ return create_model(
122
+ f"{name}UpdateRequest",
123
+ record_id=(int, ...),
124
+ record=(model_cls, ...),
125
+ actor=(str, ...),
126
+ __base__=BaseModel,
127
+ )
128
+
129
+
130
+ def make_dict_list_response_model(name: str) -> Type[BaseModel]:
131
+ """
132
+ <Name>ListResponse { records: List[Dict[str, Any]] }
133
+ Used for audit rows etc.
134
+ """
135
+ return create_model(
136
+ f"{name}ListResponse",
137
+ records=(List[Dict[str, Any]], ...),
138
+ __base__=BaseModel,
139
+ )
140
+
141
+
142
+ # ---------------------------------------------------------
143
+ # Bundle builder
144
+ # ---------------------------------------------------------
145
+
146
+
147
+ def build_storage_rpc_bundle(
148
+ *,
149
+ accessors: Sequence[ModelAccessor[Any]],
150
+ max_records_per_model: Optional[int] = 5,
151
+ enable_db_actions: bool = True,
152
+ ) -> RpcBundle:
153
+ """
154
+ Build a ConnectRPC RpcBundle exposing CRUD and database utilities.
155
+
156
+ Includes:
157
+ - CRUD actions for each ModelAccessor
158
+ - Optional DB actions (health, audit, export, backup, restore)
159
+ """
160
+ bundle = RpcBundle()
161
+
162
+ # -----------------------------------------------------
163
+ # Per-model CRUD actions (typed per model)
164
+ # -----------------------------------------------------
165
+ for accessor in accessors:
166
+ model_cls = accessor.model
167
+ model_name = accessor.component.capitalize()
168
+
169
+ crud = CrudService(accessor, max_records=max_records_per_model)
170
+
171
+ # -------- Typed request/response models for this model --------
172
+ ListResponse = make_list_response_model(model_cls, model_name)
173
+ GetResponse = make_single_response_model(model_cls, f"{model_name}Get")
174
+ CreateRequest = make_create_request_model(model_cls, model_name)
175
+ CreateResponse = make_single_response_model(model_cls, f"{model_name}Create")
176
+ UpdateRequest = make_update_request_model(model_cls, model_name)
177
+ UpdateResponse = make_single_response_model(model_cls, f"{model_name}Update")
178
+ DeleteResponse = make_status_response_model(model_name)
179
+ RestoreResponse = make_single_response_model(model_cls, f"{model_name}Restore")
180
+
181
+ # ---------------- List ----------------
182
+ async def list_records(
183
+ req: CrudListRequest,
184
+ _crud: CrudService = crud,
185
+ _Resp: Type[BaseModel] = ListResponse,
186
+ ) -> BaseModel:
187
+ try:
188
+ records: List[BaseModel] = list(
189
+ _crud.list_records(include_deleted=req.include_deleted)
190
+ )
191
+ return _Resp(records=records)
192
+ except CrudError as e:
193
+ raise _to_rpc(e)
194
+
195
+ bundle.actions.append(
196
+ ActionEntry(
197
+ name=f"{model_name}_ListRecords",
198
+ func=list_records,
199
+ input_type=CrudListRequest,
200
+ output_type=ListResponse,
201
+ )
202
+ )
203
+
204
+ # ---------------- Get ----------------
205
+ async def get_record(
206
+ req: CrudGetRequest,
207
+ _crud: CrudService = crud,
208
+ _Resp: Type[BaseModel] = GetResponse,
209
+ ) -> BaseModel:
210
+ try:
211
+ rec: BaseModel = _crud.get_record(
212
+ req.record_id, include_deleted=req.include_deleted
213
+ )
214
+ return _Resp(record=rec)
215
+ except CrudError as e:
216
+ raise _to_rpc(e)
217
+
218
+ bundle.actions.append(
219
+ ActionEntry(
220
+ name=f"{model_name}_GetRecord",
221
+ func=get_record,
222
+ input_type=CrudGetRequest,
223
+ output_type=GetResponse,
224
+ )
225
+ )
226
+
227
+ # ---------------- Create ----------------
228
+ CreateRequestType = CreateRequest # Store type for annotation
229
+
230
+ async def create_record(
231
+ req: CreateRequestType, # type: ignore[valid-type]
232
+ _crud: CrudService = crud,
233
+ _Resp: Type[BaseModel] = CreateResponse,
234
+ ) -> BaseModel:
235
+ try:
236
+ # record is a typed Pydantic model → dump to dict for CrudService
237
+ payload = req.record.model_dump(exclude_unset=True, by_alias=False) # type: ignore[attr-defined]
238
+ rec: BaseModel = _crud.create_record(payload, req.actor) # type: ignore[attr-defined]
239
+ return _Resp(record=rec)
240
+ except CrudError as e:
241
+ raise _to_rpc(e)
242
+
243
+ bundle.actions.append(
244
+ ActionEntry(
245
+ name=f"{model_name}_CreateRecord",
246
+ func=create_record,
247
+ input_type=CreateRequest,
248
+ output_type=CreateResponse,
249
+ )
250
+ )
251
+
252
+ # ---------------- Update ----------------
253
+ UpdateRequestType = UpdateRequest # Store type for annotation
254
+
255
+ async def update_record(
256
+ req: UpdateRequestType, # type: ignore[valid-type]
257
+ _crud: CrudService = crud,
258
+ _Resp: Type[BaseModel] = UpdateResponse,
259
+ ) -> BaseModel:
260
+ try:
261
+ payload = req.record.model_dump(exclude_unset=True, by_alias=False) # type: ignore[attr-defined]
262
+ rec: BaseModel = _crud.update_record(req.record_id, payload, req.actor) # type: ignore[attr-defined]
263
+ return _Resp(record=rec)
264
+ except CrudError as e:
265
+ raise _to_rpc(e)
266
+
267
+ bundle.actions.append(
268
+ ActionEntry(
269
+ name=f"{model_name}_UpdateRecord",
270
+ func=update_record,
271
+ input_type=UpdateRequest,
272
+ output_type=UpdateResponse,
273
+ )
274
+ )
275
+
276
+ # ---------------- Delete ----------------
277
+ async def delete_record(
278
+ req: CrudDeleteRequest,
279
+ _crud: CrudService = crud,
280
+ _Resp: Type[BaseModel] = DeleteResponse,
281
+ ) -> BaseModel:
282
+ try:
283
+ _crud.delete_record(req.record_id, req.actor)
284
+ return _Resp(status="deleted")
285
+ except CrudError as e:
286
+ raise _to_rpc(e)
287
+
288
+ bundle.actions.append(
289
+ ActionEntry(
290
+ name=f"{model_name}_DeleteRecord",
291
+ func=delete_record,
292
+ input_type=CrudDeleteRequest,
293
+ output_type=DeleteResponse,
294
+ )
295
+ )
296
+
297
+ # ---------------- Restore ----------------
298
+ async def restore_record(
299
+ req: CrudRestoreRequest,
300
+ _crud: CrudService = crud,
301
+ _Resp: Type[BaseModel] = RestoreResponse,
302
+ ) -> BaseModel:
303
+ try:
304
+ rec: BaseModel = _crud.restore_record(req.record_id, req.actor)
305
+ return _Resp(record=rec)
306
+ except CrudError as e:
307
+ raise _to_rpc(e)
308
+
309
+ bundle.actions.append(
310
+ ActionEntry(
311
+ name=f"{model_name}_RestoreRecord",
312
+ func=restore_record,
313
+ input_type=CrudRestoreRequest,
314
+ output_type=RestoreResponse,
315
+ )
316
+ )
317
+
318
+ # -----------------------------------------------------
319
+ # Database utilities
320
+ # -----------------------------------------------------
321
+ if enable_db_actions:
322
+ db = DatabaseService()
323
+
324
+ # ---- Health ----
325
+ class HealthResponse(BaseModel):
326
+ status: str
327
+
328
+ async def health() -> HealthResponse:
329
+ try:
330
+ res = db.health()
331
+ return HealthResponse(status=res.get("status", "ok"))
332
+ except DatabaseError as e:
333
+ raise _to_rpc(e)
334
+
335
+ bundle.actions.append(
336
+ ActionEntry(
337
+ name="Database_Health",
338
+ func=health,
339
+ input_type=None,
340
+ output_type=HealthResponse,
341
+ )
342
+ )
343
+
344
+ # ---- Audit ----
345
+ AuditListResponse = make_dict_list_response_model("Audit")
346
+ AuditListResponseType = AuditListResponse # Store type for annotation
347
+
348
+ async def audit(req: AuditQueryRequest) -> AuditListResponseType: # type: ignore[valid-type]
349
+ try:
350
+ operation_op = parse_audit_operation(req.operation)
351
+ since_dt = parse_audit_datetime(req.since)
352
+ until_dt = parse_audit_datetime(req.until)
353
+
354
+ results = list(
355
+ db.read_audit(
356
+ component=req.component,
357
+ record_id=req.record_id,
358
+ actor=req.actor,
359
+ operation=operation_op,
360
+ since=since_dt,
361
+ until=until_dt,
362
+ limit=req.limit,
363
+ offset=req.offset,
364
+ )
365
+ )
366
+ # Convert AuditLog objects to dictionaries
367
+ records = [log.model_dump() for log in results]
368
+ return AuditListResponse(records=records)
369
+ except DatabaseError as e:
370
+ raise _to_rpc(e)
371
+
372
+ bundle.actions.append(
373
+ ActionEntry(
374
+ name="Database_ReadAudit",
375
+ func=audit,
376
+ input_type=AuditQueryRequest,
377
+ output_type=AuditListResponse,
378
+ )
379
+ )
380
+
381
+ # ---- Export zip ----
382
+ async def export_zip() -> FileResponse:
383
+ try:
384
+ data = db.export_zip()
385
+ return FileResponse(filename="export.zip", bytes=data)
386
+ except DatabaseError as e:
387
+ raise _to_rpc(e)
388
+
389
+ bundle.actions.append(
390
+ ActionEntry(
391
+ name="Database_ExportZip",
392
+ func=export_zip,
393
+ input_type=None,
394
+ output_type=FileResponse,
395
+ )
396
+ )
397
+
398
+ # ---- Backup sqlite ----
399
+ async def backup_sqlite() -> FileResponse:
400
+ try:
401
+ result = db.backup_sqlite()
402
+ return FileResponse(filename=result["filename"], bytes=result["data"])
403
+ except DatabaseError as e:
404
+ raise _to_rpc(e)
405
+
406
+ bundle.actions.append(
407
+ ActionEntry(
408
+ name="Database_BackupSqlite",
409
+ func=backup_sqlite,
410
+ input_type=None,
411
+ output_type=FileResponse,
412
+ )
413
+ )
414
+
415
+ # ---- Restore sqlite ----
416
+ class DatabaseRestoreRequest(BaseModel):
417
+ bytes: bytes
418
+ filename: Optional[str] = "upload.sqlite"
419
+ integrity_check: bool = True
420
+ dry_run: bool = False
421
+
422
+ class DatabaseRestoreResponse(BaseModel):
423
+ ok: bool
424
+ message: Optional[str] = None
425
+
426
+ async def restore_sqlite(
427
+ req: DatabaseRestoreRequest,
428
+ ) -> DatabaseRestoreResponse:
429
+ try:
430
+ result = db.restore_sqlite(
431
+ file_bytes=req.bytes,
432
+ filename=req.filename or "upload.sqlite",
433
+ integrity_check=req.integrity_check,
434
+ dry_run=req.dry_run,
435
+ )
436
+ return DatabaseRestoreResponse(**result)
437
+ except DatabaseError as e:
438
+ raise _to_rpc(e)
439
+
440
+ bundle.actions.append(
441
+ ActionEntry(
442
+ name="Database_RestoreSqlite",
443
+ func=restore_sqlite,
444
+ input_type=DatabaseRestoreRequest,
445
+ output_type=DatabaseRestoreResponse,
446
+ )
447
+ )
448
+
449
+ return bundle
450
+
451
+
452
+ # ---------------------------------------------------------
453
+ # Error mapping
454
+ # ---------------------------------------------------------
455
+
456
+
457
+ def _to_rpc(exc: Exception) -> ConnectError:
458
+ """Map service exceptions to ConnectRPC RpcError codes."""
459
+ if isinstance(exc, NotFoundError):
460
+ return ConnectError(code="not_found", message=str(exc))
461
+ if isinstance(exc, ConflictError):
462
+ return ConnectError(code="failed_precondition", message=str(exc))
463
+ if isinstance(exc, ValidationError):
464
+ return ConnectError(code="invalid_argument", message=str(exc))
465
+ if isinstance(exc, UnsupportedError):
466
+ return ConnectError(code="failed_precondition", message=str(exc))
467
+ if isinstance(exc, DatabaseError):
468
+ mapping = {
469
+ "DEPENDENCY_ERROR": "unavailable",
470
+ "VALIDATION_ERROR": "invalid_argument",
471
+ "OPERATIONAL_ERROR": "internal",
472
+ }
473
+ return ConnectError(code=mapping.get(exc.code, "internal"), message=str(exc))
474
+ if isinstance(exc, CrudError):
475
+ return ConnectError(code="internal", message=str(exc))
476
+ return ConnectError(code="internal", message=str(exc))