vention-storage 0.5.4__py3-none-any.whl → 0.6.0__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.4.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.4.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.4.dist-info/RECORD +0 -13
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: vention-storage
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: A framework for storing and managing component and application data for machine apps.
|
|
5
5
|
License: Proprietary
|
|
6
6
|
Author: VentionCo
|
|
@@ -8,10 +8,9 @@ Requires-Python: >=3.10,<3.11
|
|
|
8
8
|
Classifier: License :: Other/Proprietary License
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
-
Requires-Dist: fastapi (==0.121.1)
|
|
12
11
|
Requires-Dist: python-multipart (>=0.0.20,<0.0.21)
|
|
13
12
|
Requires-Dist: sqlmodel (==0.0.27)
|
|
14
|
-
Requires-Dist:
|
|
13
|
+
Requires-Dist: vention-communication (>=0.2.2,<0.3.0)
|
|
15
14
|
Description-Content-Type: text/markdown
|
|
16
15
|
|
|
17
16
|
# Vention Storage
|
|
@@ -35,8 +34,8 @@ A framework for storing and managing component and application data with persist
|
|
|
35
34
|
- Strong typing & validation via SQLModel
|
|
36
35
|
- Lifecycle hooks before/after insert, update, delete
|
|
37
36
|
- Soft delete with `deleted_at` fields
|
|
38
|
-
-
|
|
39
|
-
- Health & monitoring
|
|
37
|
+
- ConnectRPC bundle generation with Create, Read, Update, Delete + audit
|
|
38
|
+
- Health & monitoring actions (audit log, schema diagram)
|
|
40
39
|
- Batch operations for insert/delete
|
|
41
40
|
- Session management with smart reuse & transactions
|
|
42
41
|
- Bootstrap system for one-command setup
|
|
@@ -51,7 +50,7 @@ Vention Storage is a component-based persistence layer for machine apps:
|
|
|
51
50
|
- **ModelAccessor** → Strongly-typed Create, Read, Update, Delete interface for your SQLModel classes
|
|
52
51
|
- **Hooks** → Functions that run before/after Create, Read, Update, Delete operations
|
|
53
52
|
- **AuditLog** → Automatically records all data mutations
|
|
54
|
-
- **
|
|
53
|
+
- **RpcBundle** → Auto-generated ConnectRPC bundle with Create, Read, Update, Delete + database management actions
|
|
55
54
|
|
|
56
55
|
## ⚙️ Installation & Setup
|
|
57
56
|
|
|
@@ -76,15 +75,16 @@ pip install sqlalchemy-schemadisplay
|
|
|
76
75
|
|
|
77
76
|
## 🚀 Quickstart Tutorial
|
|
78
77
|
|
|
79
|
-
Define a model, bootstrap storage, and get full Create, Read, Update, Delete
|
|
78
|
+
Define a model, bootstrap storage, and get full Create, Read, Update, Delete RPC actions in minutes:
|
|
80
79
|
|
|
81
80
|
```python
|
|
82
81
|
from datetime import datetime
|
|
83
82
|
from typing import Optional
|
|
84
83
|
from sqlmodel import Field, SQLModel
|
|
85
|
-
from
|
|
84
|
+
from communication.app import VentionApp
|
|
86
85
|
from storage.bootstrap import bootstrap
|
|
87
86
|
from storage.accessor import ModelAccessor
|
|
87
|
+
from storage.vention_communication import build_storage_bundle
|
|
88
88
|
|
|
89
89
|
class User(SQLModel, table=True):
|
|
90
90
|
id: Optional[int] = Field(default=None, primary_key=True)
|
|
@@ -92,18 +92,27 @@ class User(SQLModel, table=True):
|
|
|
92
92
|
email: str
|
|
93
93
|
deleted_at: Optional[datetime] = Field(default=None, index=True)
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
user_accessor = ModelAccessor(User, "users")
|
|
97
|
-
|
|
95
|
+
# Initialize database
|
|
98
96
|
bootstrap(
|
|
99
|
-
app,
|
|
100
|
-
accessors=[user_accessor],
|
|
101
97
|
database_url="sqlite:///./my_app.db",
|
|
102
98
|
create_tables=True
|
|
103
99
|
)
|
|
100
|
+
|
|
101
|
+
# Create accessor
|
|
102
|
+
user_accessor = ModelAccessor(User, "users")
|
|
103
|
+
|
|
104
|
+
# Build RPC bundle and add to app
|
|
105
|
+
app = VentionApp(name="my-app")
|
|
106
|
+
storage_bundle = build_storage_bundle(
|
|
107
|
+
accessors=[user_accessor],
|
|
108
|
+
max_records_per_model=100,
|
|
109
|
+
enable_db_actions=True
|
|
110
|
+
)
|
|
111
|
+
app.add_bundle(storage_bundle)
|
|
112
|
+
app.finalize()
|
|
104
113
|
```
|
|
105
114
|
|
|
106
|
-
➡️ You now have Create, Read, Update, Delete, audit, backup, and CSV
|
|
115
|
+
➡️ You now have Create, Read, Update, Delete, audit, backup, and CSV actions available via ConnectRPC.
|
|
107
116
|
|
|
108
117
|
## 🛠 How-to Guides
|
|
109
118
|
|
|
@@ -115,35 +124,46 @@ bootstrap(
|
|
|
115
124
|
|
|
116
125
|
product_accessor = ModelAccessor(Product, "products")
|
|
117
126
|
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
# Build bundle with multiple accessors
|
|
128
|
+
storage_bundle = build_storage_bundle(
|
|
120
129
|
accessors=[user_accessor, product_accessor],
|
|
121
|
-
database_url="sqlite:///./my_app.db",
|
|
122
|
-
create_tables=True,
|
|
123
130
|
max_records_per_model=100,
|
|
124
|
-
|
|
131
|
+
enable_db_actions=True
|
|
125
132
|
)
|
|
133
|
+
app.add_bundle(storage_bundle)
|
|
126
134
|
```
|
|
127
135
|
|
|
128
136
|
### Export to CSV
|
|
129
137
|
|
|
130
138
|
```python
|
|
131
|
-
|
|
139
|
+
# Using ConnectRPC client
|
|
140
|
+
from communication.client import ConnectClient
|
|
141
|
+
|
|
142
|
+
client = ConnectClient("http://localhost:8000")
|
|
143
|
+
response = await client.call("Database_ExportZip", {})
|
|
132
144
|
with open("backup.zip", "wb") as f:
|
|
133
|
-
f.write(response.
|
|
145
|
+
f.write(response.data)
|
|
134
146
|
```
|
|
135
147
|
|
|
136
148
|
### Backup & Restore
|
|
137
149
|
|
|
138
150
|
```python
|
|
139
151
|
# Backup
|
|
140
|
-
|
|
141
|
-
with open(
|
|
152
|
+
backup_response = await client.call("Database_BackupSqlite", {})
|
|
153
|
+
with open(backup_response.filename, "wb") as f:
|
|
154
|
+
f.write(backup_response.data)
|
|
142
155
|
|
|
143
156
|
# Restore
|
|
144
157
|
with open("backup.sqlite", "rb") as f:
|
|
145
|
-
|
|
146
|
-
|
|
158
|
+
restore_response = await client.call(
|
|
159
|
+
"Database_RestoreSqlite",
|
|
160
|
+
{
|
|
161
|
+
"bytes": f.read(),
|
|
162
|
+
"filename": "backup.sqlite",
|
|
163
|
+
"integrity_check": True,
|
|
164
|
+
"dry_run": False
|
|
165
|
+
}
|
|
166
|
+
)
|
|
147
167
|
```
|
|
148
168
|
|
|
149
169
|
### Use Lifecycle Hooks
|
|
@@ -190,52 +210,73 @@ user_accessor.restore(user.id, actor="admin")
|
|
|
190
210
|
```
|
|
191
211
|
|
|
192
212
|
|
|
193
|
-
### Using
|
|
213
|
+
### Using ConnectRPC Client
|
|
194
214
|
|
|
195
|
-
Once
|
|
215
|
+
Once the bundle is added to your `VentionApp`, each `ModelAccessor` automatically exposes full CRUD actions via ConnectRPC.
|
|
196
216
|
|
|
197
|
-
Example: interacting with the
|
|
217
|
+
Example: interacting with the `Users` RPC actions.
|
|
198
218
|
|
|
199
219
|
```typescript
|
|
200
|
-
import
|
|
220
|
+
import { createPromiseClient } from "@connectrpc/connect";
|
|
221
|
+
import { createConnectTransport } from "@connectrpc/connect-web";
|
|
201
222
|
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
headers: { "X-User": "operator" }, // used in audit logs
|
|
223
|
+
const transport = createConnectTransport({
|
|
224
|
+
baseUrl: "http://localhost:8000",
|
|
205
225
|
});
|
|
206
226
|
|
|
227
|
+
const client = createPromiseClient(YourServiceClient, transport);
|
|
228
|
+
|
|
207
229
|
// Create
|
|
208
230
|
export async function createUser(name: string, email: string) {
|
|
209
|
-
const res = await
|
|
210
|
-
|
|
231
|
+
const res = await client.usersCreateRecord({
|
|
232
|
+
record: { name, email },
|
|
233
|
+
actor: "operator"
|
|
234
|
+
});
|
|
235
|
+
return res.record;
|
|
211
236
|
}
|
|
212
237
|
|
|
213
238
|
// Read
|
|
214
239
|
export async function getUser(id: number) {
|
|
215
|
-
const res = await
|
|
216
|
-
|
|
240
|
+
const res = await client.usersGetRecord({
|
|
241
|
+
recordId: id,
|
|
242
|
+
includeDeleted: false
|
|
243
|
+
});
|
|
244
|
+
return res.record;
|
|
217
245
|
}
|
|
218
246
|
|
|
219
247
|
// Update
|
|
220
248
|
export async function updateUser(id: number, name: string) {
|
|
221
|
-
const res = await
|
|
222
|
-
|
|
249
|
+
const res = await client.usersUpdateRecord({
|
|
250
|
+
recordId: id,
|
|
251
|
+
record: { name },
|
|
252
|
+
actor: "operator"
|
|
253
|
+
});
|
|
254
|
+
return res.record;
|
|
223
255
|
}
|
|
224
256
|
|
|
225
257
|
// Delete (soft delete if model supports deleted_at)
|
|
226
258
|
export async function deleteUser(id: number) {
|
|
227
|
-
await
|
|
259
|
+
await client.usersDeleteRecord({
|
|
260
|
+
recordId: id,
|
|
261
|
+
actor: "operator"
|
|
262
|
+
});
|
|
228
263
|
}
|
|
229
264
|
|
|
230
265
|
// Restore
|
|
231
266
|
export async function restoreUser(id: number) {
|
|
232
|
-
await
|
|
267
|
+
const res = await client.usersRestoreRecord({
|
|
268
|
+
recordId: id,
|
|
269
|
+
actor: "operator"
|
|
270
|
+
});
|
|
271
|
+
return res.record;
|
|
233
272
|
}
|
|
234
273
|
|
|
235
274
|
// List
|
|
236
275
|
export async function listUsers() {
|
|
237
|
-
const res = await
|
|
238
|
-
|
|
276
|
+
const res = await client.usersListRecords({
|
|
277
|
+
includeDeleted: false
|
|
278
|
+
});
|
|
279
|
+
return res.records;
|
|
239
280
|
}
|
|
240
281
|
```
|
|
241
282
|
|
|
@@ -246,20 +287,36 @@ export async function listUsers() {
|
|
|
246
287
|
|
|
247
288
|
```python
|
|
248
289
|
def bootstrap(
|
|
249
|
-
app: FastAPI,
|
|
250
290
|
*,
|
|
251
|
-
accessors: Iterable[ModelAccessor[Any]],
|
|
252
291
|
database_url: Optional[str] = None,
|
|
253
292
|
create_tables: bool = True,
|
|
254
|
-
max_records_per_model: Optional[int] = 5,
|
|
255
|
-
enable_db_router: bool = True,
|
|
256
293
|
) -> None
|
|
257
294
|
```
|
|
258
295
|
|
|
296
|
+
Initialize the database engine and optionally create tables. This function performs environment setup only.
|
|
297
|
+
|
|
298
|
+
### build_storage_bundle
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
def build_storage_bundle(
|
|
302
|
+
*,
|
|
303
|
+
accessors: Sequence[ModelAccessor[Any]],
|
|
304
|
+
max_records_per_model: Optional[int] = 5,
|
|
305
|
+
enable_db_actions: bool = True,
|
|
306
|
+
) -> RpcBundle
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Build a ConnectRPC RpcBundle exposing CRUD and database utilities. Returns an `RpcBundle` that can be added to a `VentionApp` using `app.add_bundle()`.
|
|
310
|
+
|
|
259
311
|
### ModelAccessor
|
|
260
312
|
|
|
261
313
|
```python
|
|
262
|
-
ModelAccessor(
|
|
314
|
+
ModelAccessor(
|
|
315
|
+
model: Type[ModelType],
|
|
316
|
+
component_name: str,
|
|
317
|
+
*,
|
|
318
|
+
enable_auditing: bool = True,
|
|
319
|
+
)
|
|
263
320
|
```
|
|
264
321
|
|
|
265
322
|
**Read**
|
|
@@ -284,10 +341,8 @@ ModelAccessor(model: Type[ModelType], component_name: str)
|
|
|
284
341
|
- `@accessor.before_delete()`
|
|
285
342
|
- `@accessor.after_delete()`
|
|
286
343
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
- `build_crud_router(accessor, max_records=100) -> APIRouter`
|
|
290
|
-
- `build_db_router(audit_default_limit=100, audit_max_limit=1000) -> APIRouter`
|
|
344
|
+
**Parameters**
|
|
345
|
+
- `enable_auditing`: If `False`, disables audit logging for this accessor. Useful for models that shouldn't be audited (e.g., audit logs themselves). Defaults to `True`.
|
|
291
346
|
|
|
292
347
|
### Database Helpers
|
|
293
348
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
storage/accessor.py,sha256=QgLnju-31jBprLtFMyAy8duKZKAshO8Rngd5cBjduhY,10879
|
|
3
|
+
storage/auditor.py,sha256=tsvsb9qlHdcknY5OAXRZBMcN4nFDKtN3Ln1LfgozJrw,2090
|
|
4
|
+
storage/bootstrap.py,sha256=rZBXQF4M-Lm4Rw2T74M4-swMBgAWEts16v9mG64t2hM,846
|
|
5
|
+
storage/crud_service.py,sha256=9XUkOjiw16O9UzRYPBUV1NYUu34-pEbYTp310xopgkU,5281
|
|
6
|
+
storage/database.py,sha256=BGRnlH0qBVnYffp0F1TCWGb6OS8cdlMmEJ1-LeHH4oc,3184
|
|
7
|
+
storage/database_service.py,sha256=pUhS3LfSswIfr7wlIcUbO3uVuT_GzfKh4QwOuQrwf1k,7868
|
|
8
|
+
storage/hooks.py,sha256=rMK8R-VO0B7caZn9HtTlcetx0KQMvsl1Aq-t9mbFA4Q,1298
|
|
9
|
+
storage/io_helpers.py,sha256=oTyVXRdD4eVuqpIOFNh_4ULqiNJ46pNCT38he1dzMBg,5528
|
|
10
|
+
storage/utils.py,sha256=AV8d1WQmdBLAnpoGJX3c8th5_mYFAei6d_ZxDG5QOAE,1320
|
|
11
|
+
storage/vention_communication.py,sha256=sBtwA5SLBSq0-fnQgwL1UmQwloSSnbv-pS2Iiv32o3g,15421
|
|
12
|
+
vention_storage-0.6.0.dist-info/METADATA,sha256=xWgWEcwA-9z4zVJepXBTTUFmhngRQan7fkxuLV_uAtk,9858
|
|
13
|
+
vention_storage-0.6.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
14
|
+
vention_storage-0.6.0.dist-info/RECORD,,
|
storage/router_database.py
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from datetime import datetime, timezone
|
|
4
|
-
from typing import Dict, List, Optional
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
from fastapi import APIRouter, HTTPException, Query, Response, UploadFile, File
|
|
8
|
-
from sqlmodel import SQLModel, select
|
|
9
|
-
from sqlalchemy import desc
|
|
10
|
-
|
|
11
|
-
from storage import database, io_helpers
|
|
12
|
-
from storage.auditor import AuditLog
|
|
13
|
-
from storage.utils import Operation
|
|
14
|
-
|
|
15
|
-
from storage.io_helpers import (
|
|
16
|
-
discover_user_tables,
|
|
17
|
-
build_export_zip_bytes,
|
|
18
|
-
db_file_path,
|
|
19
|
-
build_backup_bytes,
|
|
20
|
-
validate_sqlite_file,
|
|
21
|
-
safe_unlink,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
__all__ = ["build_db_router"]
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def build_db_router(
|
|
28
|
-
*,
|
|
29
|
-
audit_default_limit: int = 100,
|
|
30
|
-
audit_max_limit: int = 1000,
|
|
31
|
-
) -> APIRouter:
|
|
32
|
-
"""
|
|
33
|
-
Build a FastAPI router exposing database-wide utilities.
|
|
34
|
-
|
|
35
|
-
Endpoints:
|
|
36
|
-
- /db/health : Verify DB engine is available
|
|
37
|
-
- /db/audit : Query audit logs (filters + pagination)
|
|
38
|
-
- /db/diagram.svg : Schema diagram (requires Graphviz)
|
|
39
|
-
- /db/export.zip : CSV export (one CSV per table)
|
|
40
|
-
- /db/backup.sqlite : Full SQLite backup file
|
|
41
|
-
- /db/restore : Upload and restore a .sqlite backup (atomic replace)
|
|
42
|
-
"""
|
|
43
|
-
router = APIRouter(prefix="/db", tags=["db"])
|
|
44
|
-
|
|
45
|
-
@router.get("/health")
|
|
46
|
-
def health() -> Dict[str, str]:
|
|
47
|
-
"""
|
|
48
|
-
Check database connectivity.
|
|
49
|
-
|
|
50
|
-
Returns:
|
|
51
|
-
dict: {"status": "ok"} if the database engine can be initialized.
|
|
52
|
-
"""
|
|
53
|
-
_ = database.get_engine()
|
|
54
|
-
return {"status": "ok"}
|
|
55
|
-
|
|
56
|
-
@router.get("/audit")
|
|
57
|
-
def read_audit(
|
|
58
|
-
component: Optional[str] = Query(None, description="Filter by component name"),
|
|
59
|
-
record_id: Optional[int] = Query(None, description="Filter by record ID"),
|
|
60
|
-
actor: Optional[str] = Query(None, description="Filter by actor identifier"),
|
|
61
|
-
operation: Optional[Operation] = Query(
|
|
62
|
-
None, description="Filter by operation type"
|
|
63
|
-
),
|
|
64
|
-
since: Optional[datetime] = Query(
|
|
65
|
-
None, description="Include only logs on/after this timestamp"
|
|
66
|
-
),
|
|
67
|
-
until: Optional[datetime] = Query(
|
|
68
|
-
None, description="Include only logs before this timestamp"
|
|
69
|
-
),
|
|
70
|
-
limit: int = Query(
|
|
71
|
-
audit_default_limit,
|
|
72
|
-
ge=1,
|
|
73
|
-
le=audit_max_limit,
|
|
74
|
-
description="Maximum rows to return",
|
|
75
|
-
),
|
|
76
|
-
offset: int = Query(
|
|
77
|
-
0, ge=0, description="Number of rows to skip (for pagination)"
|
|
78
|
-
),
|
|
79
|
-
) -> List[AuditLog]:
|
|
80
|
-
"""
|
|
81
|
-
Query the audit log table.
|
|
82
|
-
|
|
83
|
-
Supports filtering by component, record_id, actor, operation,
|
|
84
|
-
and timestamp range, with pagination.
|
|
85
|
-
|
|
86
|
-
Args:
|
|
87
|
-
component (str, optional): Restrict to a specific component.
|
|
88
|
-
record_id (int, optional): Restrict to a specific record ID.
|
|
89
|
-
actor (str, optional): Restrict to a specific actor (user/system).
|
|
90
|
-
operation (Operation, optional): Restrict to a specific operation.
|
|
91
|
-
since (datetime, optional): Include only logs since this timestamp.
|
|
92
|
-
until (datetime, optional): Include only logs before this timestamp.
|
|
93
|
-
limit (int): Maximum number of logs to return (bounded by audit_max_limit).
|
|
94
|
-
offset (int): Number of rows to skip for pagination.
|
|
95
|
-
|
|
96
|
-
Returns:
|
|
97
|
-
List[AuditLog]: A list of audit log entries matching the criteria.
|
|
98
|
-
"""
|
|
99
|
-
with database.use_session() as session:
|
|
100
|
-
statement = select(AuditLog)
|
|
101
|
-
if component:
|
|
102
|
-
statement = statement.where(AuditLog.component == component)
|
|
103
|
-
if record_id is not None:
|
|
104
|
-
statement = statement.where(AuditLog.record_id == record_id)
|
|
105
|
-
if actor:
|
|
106
|
-
statement = statement.where(AuditLog.actor == actor)
|
|
107
|
-
if operation:
|
|
108
|
-
statement = statement.where(AuditLog.operation == operation)
|
|
109
|
-
if since is not None:
|
|
110
|
-
statement = statement.where(AuditLog.timestamp >= since)
|
|
111
|
-
if until is not None:
|
|
112
|
-
statement = statement.where(AuditLog.timestamp < until)
|
|
113
|
-
statement = (
|
|
114
|
-
statement.order_by(desc(AuditLog.timestamp)).offset(offset).limit(limit)
|
|
115
|
-
)
|
|
116
|
-
rows: List[AuditLog] = session.exec(statement).all()
|
|
117
|
-
return rows
|
|
118
|
-
|
|
119
|
-
@router.get("/diagram.svg", response_class=Response)
|
|
120
|
-
def diagram_svg() -> Response:
|
|
121
|
-
"""
|
|
122
|
-
Generate a database schema diagram in SVG format.
|
|
123
|
-
|
|
124
|
-
Requires `sqlalchemy-schemadisplay` and Graphviz to be installed.
|
|
125
|
-
The diagram reflects the current SQLModel metadata.
|
|
126
|
-
|
|
127
|
-
Returns:
|
|
128
|
-
Response: SVG image of the database schema.
|
|
129
|
-
|
|
130
|
-
Raises:
|
|
131
|
-
HTTPException 503: If required dependencies are missing
|
|
132
|
-
or Graphviz is not available.
|
|
133
|
-
"""
|
|
134
|
-
try:
|
|
135
|
-
# import here to avoid hard dependency if not used
|
|
136
|
-
from sqlalchemy_schemadisplay import create_schema_graph
|
|
137
|
-
except Exception as e:
|
|
138
|
-
raise HTTPException(
|
|
139
|
-
status_code=503,
|
|
140
|
-
detail=(
|
|
141
|
-
"sqlalchemy-schemadisplay is required. "
|
|
142
|
-
"Install with: pip install sqlalchemy-schemadisplay"
|
|
143
|
-
),
|
|
144
|
-
) from e
|
|
145
|
-
|
|
146
|
-
try:
|
|
147
|
-
graph = create_schema_graph(
|
|
148
|
-
engine=database.get_engine(),
|
|
149
|
-
metadata=SQLModel.metadata,
|
|
150
|
-
show_datatypes=True,
|
|
151
|
-
show_indexes=False,
|
|
152
|
-
concentrate=False,
|
|
153
|
-
)
|
|
154
|
-
return Response(content=graph.create_svg(), media_type="image/svg+xml")
|
|
155
|
-
except Exception as e:
|
|
156
|
-
msg = str(e).lower()
|
|
157
|
-
if "executable" in msg or "dot not found" in msg or "graphviz" in msg:
|
|
158
|
-
raise HTTPException(
|
|
159
|
-
status_code=503,
|
|
160
|
-
detail=(
|
|
161
|
-
"Graphviz is required to render the diagram. "
|
|
162
|
-
"Install it (e.g. brew install graphviz / apt-get install graphviz)."
|
|
163
|
-
),
|
|
164
|
-
) from e
|
|
165
|
-
raise
|
|
166
|
-
|
|
167
|
-
@router.get("/export.zip")
|
|
168
|
-
def export_zip() -> Response:
|
|
169
|
-
"""
|
|
170
|
-
Export the entire database as a ZIP archive.
|
|
171
|
-
|
|
172
|
-
The archive contains one CSV file per user-defined table found in SQLModel metadata.
|
|
173
|
-
SQLite internal tables (e.g., "sqlite_sequence") are excluded.
|
|
174
|
-
|
|
175
|
-
Returns:
|
|
176
|
-
Response: application/zip payload with "{table}.csv" entries.
|
|
177
|
-
"""
|
|
178
|
-
headers = {"Content-Disposition": 'attachment; filename="export.zip"'}
|
|
179
|
-
try:
|
|
180
|
-
zip_bytes = build_export_zip_bytes(discover_user_tables())
|
|
181
|
-
except Exception as e:
|
|
182
|
-
raise HTTPException(
|
|
183
|
-
status_code=503, detail=f"Failed to build export.zip: {e}"
|
|
184
|
-
) from e
|
|
185
|
-
return Response(
|
|
186
|
-
content=zip_bytes, media_type="application/zip", headers=headers
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
@router.get("/backup.sqlite")
|
|
190
|
-
def backup_sqlite() -> Response:
|
|
191
|
-
"""
|
|
192
|
-
Create and return a consistent SQLite backup of the current database file.
|
|
193
|
-
|
|
194
|
-
Uses the SQLite Backup API for correctness.
|
|
195
|
-
|
|
196
|
-
Returns:
|
|
197
|
-
Response: application/x-sqlite3 payload with a `.sqlite` file.
|
|
198
|
-
|
|
199
|
-
Raises:
|
|
200
|
-
HTTPException 503: Operational failure creating the backup.
|
|
201
|
-
"""
|
|
202
|
-
path = db_file_path()
|
|
203
|
-
headers = {
|
|
204
|
-
"Content-Disposition": f'attachment; filename="backup-{_backup_timestamp_slug()}.sqlite"'
|
|
205
|
-
}
|
|
206
|
-
try:
|
|
207
|
-
data = build_backup_bytes(path)
|
|
208
|
-
except Exception as e:
|
|
209
|
-
raise HTTPException(
|
|
210
|
-
status_code=503, detail=f"Failed to create backup: {e}"
|
|
211
|
-
) from e
|
|
212
|
-
return Response(
|
|
213
|
-
content=data, media_type="application/x-sqlite3", headers=headers
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
@router.post("/restore")
|
|
217
|
-
def restore_sqlite(
|
|
218
|
-
file: UploadFile = File(..., description="SQLite .sqlite backup to restore"),
|
|
219
|
-
integrity_check: bool = Query(
|
|
220
|
-
True, description="Run PRAGMA integrity_check before replacing"
|
|
221
|
-
),
|
|
222
|
-
dry_run: bool = Query(
|
|
223
|
-
False, description="Validate only; do not modify current database"
|
|
224
|
-
),
|
|
225
|
-
) -> Dict[str, object]:
|
|
226
|
-
"""
|
|
227
|
-
Restore the database from an uploaded SQLite file by atomically replacing the current DB file.
|
|
228
|
-
|
|
229
|
-
Steps:
|
|
230
|
-
1. Save upload to a temporary path.
|
|
231
|
-
2. Validate header and (optionally) PRAGMA integrity_check.
|
|
232
|
-
3. If dry_run: report validation OK and exit.
|
|
233
|
-
4. Dispose engine connections and os.replace(temp, db_path).
|
|
234
|
-
|
|
235
|
-
Returns:
|
|
236
|
-
dict: {status: "ok", restored: bool, bytes: int}
|
|
237
|
-
|
|
238
|
-
Raises:
|
|
239
|
-
HTTPException 422: Invalid SQLite file or failed integrity check.
|
|
240
|
-
HTTPException 503: Operational failure during file I/O.
|
|
241
|
-
"""
|
|
242
|
-
path = db_file_path()
|
|
243
|
-
db_dir = Path(path).resolve().parent
|
|
244
|
-
try:
|
|
245
|
-
tmp_path, total = io_helpers.save_upload_to_temp(file, db_dir)
|
|
246
|
-
except Exception as e:
|
|
247
|
-
raise HTTPException(503, f"Failed to save upload: {e}") from e
|
|
248
|
-
|
|
249
|
-
try:
|
|
250
|
-
validate_sqlite_file(tmp_path, run_integrity_check=integrity_check)
|
|
251
|
-
except ValueError as ve:
|
|
252
|
-
safe_unlink(tmp_path)
|
|
253
|
-
raise HTTPException(422, str(ve)) from ve
|
|
254
|
-
except Exception as e:
|
|
255
|
-
safe_unlink(tmp_path)
|
|
256
|
-
raise HTTPException(422, f"Invalid SQLite file: {e}") from e
|
|
257
|
-
|
|
258
|
-
if dry_run:
|
|
259
|
-
safe_unlink(tmp_path)
|
|
260
|
-
return {"status": "ok", "restored": False, "bytes": total}
|
|
261
|
-
|
|
262
|
-
try:
|
|
263
|
-
io_helpers.atomic_replace_db(tmp_path, Path(path))
|
|
264
|
-
except Exception as e:
|
|
265
|
-
safe_unlink(tmp_path)
|
|
266
|
-
raise HTTPException(503, f"Failed to replace database file: {e}") from e
|
|
267
|
-
|
|
268
|
-
return {"status": "ok", "restored": True, "bytes": total}
|
|
269
|
-
|
|
270
|
-
return router
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def _backup_timestamp_slug() -> str:
|
|
274
|
-
"""Timestamp safe for filenames (UTC, no colons)."""
|
|
275
|
-
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|