vention-storage 0.5.4__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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: vention-storage
3
- Version: 0.5.4
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: uvicorn (>=0.35.0,<0.36.0)
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
- - REST API generation with Create, Read, Update, Delete + audit
39
- - Health & monitoring endpoints (audit log, schema diagram)
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
- - **Routers** → Auto-generated FastAPI endpoints for Create, Read, Update, Delete + database management
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 endpoints in minutes:
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 fastapi import FastAPI
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
- app = FastAPI()
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 endpoints at `/users` and `/db`.
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
- bootstrap(
119
- app,
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
- enable_db_router=True
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
- response = requests.get("http://localhost:8000/db/export.zip")
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.content)
145
+ f.write(response.data)
134
146
  ```
135
147
 
136
148
  ### Backup & Restore
137
149
 
138
150
  ```python
139
151
  # Backup
140
- r = requests.get("http://localhost:8000/db/backup.sqlite")
141
- with open("backup.sqlite", "wb") as f: f.write(r.content)
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
- files = {"file": ("backup.sqlite", f, "application/x-sqlite3")}
146
- requests.post("http://localhost:8000/db/restore", files=files)
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 the REST API
213
+ ### Using ConnectRPC Client
194
214
 
195
- Once bootstrapped, each `ModelAccessor` automatically exposes full CRUD endpoints.
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 `/users` API.
217
+ Example: interacting with the `Users` RPC actions.
198
218
 
199
219
  ```typescript
200
- import axios from "axios";
220
+ import { createPromiseClient } from "@connectrpc/connect";
221
+ import { createConnectTransport } from "@connectrpc/connect-web";
201
222
 
202
- const api = axios.create({
203
- baseURL: "http://localhost:8000", // your backend url
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 api.post("/users/", { name, email });
210
- return res.data;
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 api.get(`/users/${id}`);
216
- return res.data;
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 api.put(`/users/${id}`, { name });
222
- return res.data;
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 api.delete(`/users/${id}`);
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 api.post(`/users/${id}/restore`);
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 api.get("/users/");
238
- return res.data;
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(model: Type[ModelType], component_name: str)
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
- ### Routers
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.9.1
2
+ Generator: poetry-core 2.2.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -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")