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.
- storage/accessor.py +51 -38
- storage/bootstrap.py +13 -26
- storage/crud_service.py +160 -0
- storage/database_service.py +243 -0
- storage/io_helpers.py +14 -0
- storage/utils.py +26 -1
- storage/vention_communication.py +476 -0
- {vention_storage-0.5.2.dist-info → vention_storage-0.6.0.dist-info}/METADATA +107 -52
- vention_storage-0.6.0.dist-info/RECORD +14 -0
- {vention_storage-0.5.2.dist-info → vention_storage-0.6.0.dist-info}/WHEEL +1 -1
- storage/router_database.py +0 -275
- storage/router_model.py +0 -247
- vention_storage-0.5.2.dist-info/RECORD +0 -13
|
@@ -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))
|