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 CHANGED
@@ -29,17 +29,24 @@ class ModelAccessor(Generic[ModelType]):
29
29
  """
30
30
  Accessor for a single SQLModel type with:
31
31
  - strongly-typed lifecycle hooks (before/after insert/update/delete)
32
- - atomic writes with auditing
32
+ - atomic writes with optional auditing
33
33
  - optional soft delete (if model defines `deleted_at`)
34
34
  - batch helpers
35
35
  - implicit session reuse inside hooks (no .bind() needed)
36
36
  """
37
37
 
38
- def __init__(self, model: Type[ModelType], component_name: str) -> None:
38
+ def __init__(
39
+ self,
40
+ model: Type[ModelType],
41
+ component_name: str,
42
+ *,
43
+ enable_auditing: bool = True,
44
+ ) -> None:
39
45
  self.model = model
40
46
  self.component = component_name
41
47
  self._hooks: HookRegistry[ModelType] = HookRegistry()
42
48
  self._has_soft_delete = hasattr(model, "deleted_at")
49
+ self._enable_auditing = enable_auditing
43
50
 
44
51
  # ---------- Hook decorators ----------
45
52
  def before_insert(self) -> Callable[[HookFn[ModelType]], HookFn[ModelType]]:
@@ -70,6 +77,8 @@ class ModelAccessor(Generic[ModelType]):
70
77
  self, *, session: Session, instance: ModelType, actor: str
71
78
  ) -> None:
72
79
  """Audit a create operation."""
80
+ if not self._enable_auditing:
81
+ return
73
82
  audit_operation(
74
83
  session=session,
75
84
  component=self.component,
@@ -150,15 +159,16 @@ class ModelAccessor(Generic[ModelType]):
150
159
  self._emit("before_update", session=session, instance=merged)
151
160
  session.flush()
152
161
  session.refresh(merged)
153
- audit_operation(
154
- session=session,
155
- component=self.component,
156
- operation="update",
157
- record_id=int(getattr(merged, "id")),
158
- actor=actor,
159
- before=before,
160
- after=merged.model_dump(),
161
- )
162
+ if self._enable_auditing:
163
+ audit_operation(
164
+ session=session,
165
+ component=self.component,
166
+ operation="update",
167
+ record_id=int(getattr(merged, "id")),
168
+ actor=actor,
169
+ before=before,
170
+ after=merged.model_dump(),
171
+ )
162
172
  self._emit("after_update", session=session, instance=merged)
163
173
  return cast(ModelType, merged)
164
174
 
@@ -173,15 +183,16 @@ class ModelAccessor(Generic[ModelType]):
173
183
  return False
174
184
  self._emit("before_delete", session=session, instance=obj)
175
185
  op_name, before_payload, after_payload = _soft_or_hard_delete(session, obj)
176
- audit_operation(
177
- session=session,
178
- component=self.component,
179
- operation=op_name,
180
- record_id=id,
181
- actor=actor,
182
- before=before_payload,
183
- after=after_payload,
184
- )
186
+ if self._enable_auditing:
187
+ audit_operation(
188
+ session=session,
189
+ component=self.component,
190
+ operation=op_name,
191
+ record_id=id,
192
+ actor=actor,
193
+ before=before_payload,
194
+ after=after_payload,
195
+ )
185
196
  self._emit("after_delete", session=session, instance=obj)
186
197
  return True
187
198
 
@@ -201,15 +212,16 @@ class ModelAccessor(Generic[ModelType]):
201
212
  session.add(obj)
202
213
  session.flush()
203
214
  session.refresh(obj)
204
- audit_operation(
205
- session=session,
206
- component=self.component,
207
- operation="restore",
208
- record_id=id,
209
- actor=actor,
210
- before=before,
211
- after=obj.model_dump(),
212
- )
215
+ if self._enable_auditing:
216
+ audit_operation(
217
+ session=session,
218
+ component=self.component,
219
+ operation="restore",
220
+ record_id=id,
221
+ actor=actor,
222
+ before=before,
223
+ after=obj.model_dump(),
224
+ )
213
225
  return True
214
226
 
215
227
  return self._run_write(write_operation)
@@ -248,15 +260,16 @@ class ModelAccessor(Generic[ModelType]):
248
260
  op_name, before_payload, after_payload = _soft_or_hard_delete(
249
261
  session, obj
250
262
  )
251
- audit_operation(
252
- session=session,
253
- component=self.component,
254
- operation=op_name,
255
- record_id=id_,
256
- actor=actor,
257
- before=before_payload,
258
- after=after_payload,
259
- )
263
+ if self._enable_auditing:
264
+ audit_operation(
265
+ session=session,
266
+ component=self.component,
267
+ operation=op_name,
268
+ record_id=id_,
269
+ actor=actor,
270
+ before=before_payload,
271
+ after=after_payload,
272
+ )
260
273
  self._emit("after_delete", session=session, instance=obj)
261
274
  count += 1
262
275
  return count
storage/bootstrap.py CHANGED
@@ -1,47 +1,34 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Iterable, Optional
4
-
5
- from fastapi import FastAPI
3
+ from typing import Optional
6
4
  from sqlmodel import SQLModel
7
5
 
8
6
  from storage.database import get_engine, set_database_url
9
- from storage.accessor import ModelAccessor
10
- from storage.router_model import build_crud_router
11
- from storage.router_database import build_db_router
7
+
8
+
9
+ __all__ = ["bootstrap"]
12
10
 
13
11
 
14
12
  def bootstrap(
15
- app: FastAPI,
16
13
  *,
17
- accessors: Iterable[ModelAccessor[Any]],
18
14
  database_url: Optional[str] = None,
19
15
  create_tables: bool = True,
20
- max_records_per_model: Optional[int] = 5,
21
- enable_db_router: bool = True,
22
16
  ) -> None:
23
17
  """
24
- Bootstrap the storage system for a FastAPI app.
18
+ Initialize the database engine and optionally create tables.
19
+
20
+ This function performs **environment setup only**:
21
+ - Applies a database URL override (if provided)
22
+ - Creates SQLModel tables (if requested)
25
23
 
26
- This helper wires up:
27
- - Database engine initialization (optionally overriding the URL).
28
- - Optional table creation via `SQLModel.metadata.create_all`.
29
- - One CRUD router per registered `ModelAccessor`.
30
- - The global /db router (health, audit, diagram, backup/restore) if enabled.
24
+ Args:
25
+ database_url: Optional DB URL override (e.g. sqlite:///foo.sqlite)
26
+ create_tables: Whether to auto-create tables using metadata.
31
27
  """
32
28
  if database_url is not None:
33
29
  set_database_url(database_url)
34
30
 
35
31
  engine = get_engine()
32
+
36
33
  if create_tables:
37
34
  SQLModel.metadata.create_all(engine)
38
-
39
- # Per-model CRUD routers
40
- for accessor in accessors:
41
- app.include_router(
42
- build_crud_router(accessor, max_records=max_records_per_model)
43
- )
44
-
45
- # Global DB router (health, audit, diagram, backup/restore)
46
- if enable_db_router:
47
- app.include_router(build_db_router())
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from sqlalchemy.exc import DataError, IntegrityError, StatementError
5
+ from storage.accessor import ModelAccessor
6
+ from storage.utils import ModelType
7
+
8
+
9
+ __all__ = ["CrudService", "CrudError"]
10
+
11
+
12
+ # ---------------------------------------------------------
13
+ # Exceptions
14
+ # ---------------------------------------------------------
15
+
16
+
17
+ class CrudError(Exception):
18
+ """Base class for CRUD-related errors."""
19
+
20
+ def __init__(self, message: str, code: str = "UNKNOWN"):
21
+ super().__init__(message)
22
+ self.code = code
23
+
24
+
25
+ class NotFoundError(CrudError):
26
+ def __init__(self, message: str = "Not found"):
27
+ super().__init__(message, code="NOT_FOUND")
28
+
29
+
30
+ class ConflictError(CrudError):
31
+ def __init__(self, message: str):
32
+ super().__init__(message, code="CONFLICT")
33
+
34
+
35
+ class ValidationError(CrudError):
36
+ def __init__(self, message: str):
37
+ super().__init__(message, code="VALIDATION_ERROR")
38
+
39
+
40
+ class UnsupportedError(CrudError):
41
+ def __init__(self, message: str):
42
+ super().__init__(message, code="UNSUPPORTED_OPERATION")
43
+
44
+
45
+ # ---------------------------------------------------------
46
+ # CRUD Service
47
+ # ---------------------------------------------------------
48
+
49
+
50
+ class CrudService:
51
+ """
52
+ Transport-agnostic CRUD logic for SQLModel components.
53
+
54
+ Provides safe, actor-aware CRUD operations. Intended to be reused
55
+ by both FastAPI routers (REST) and RPC bundles (ConnectRPC).
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ accessor: ModelAccessor[ModelType],
61
+ *,
62
+ max_records: Optional[int] = None,
63
+ ) -> None:
64
+ self.accessor = accessor
65
+ self.max_records = max_records
66
+
67
+ # ---------------- READS ----------------
68
+
69
+ def list_records(self, *, include_deleted: bool = False) -> List[ModelType]:
70
+ """Return all records for this model."""
71
+ return self.accessor.all(include_deleted=include_deleted)
72
+
73
+ def get_record(
74
+ self,
75
+ record_id: int,
76
+ *,
77
+ include_deleted: bool = False,
78
+ ) -> ModelType: # type: ignore[type-var]
79
+ """Retrieve a single record by ID."""
80
+ obj = self.accessor.get(record_id, include_deleted=include_deleted)
81
+ if not obj:
82
+ raise NotFoundError()
83
+ return obj
84
+
85
+ # ---------------- WRITES ----------------
86
+
87
+ def create_record(self, payload: Dict[str, Any], actor: str) -> ModelType: # type: ignore[type-var]
88
+ """Insert a new record into the database."""
89
+ if self.max_records is not None:
90
+ total = len(self.accessor.all(include_deleted=True))
91
+ if total >= self.max_records:
92
+ raise ConflictError(
93
+ f"Max {self.max_records} records allowed for {self.accessor.component}"
94
+ )
95
+
96
+ obj = self.accessor.model(**payload)
97
+ try:
98
+ result = self.accessor.insert(obj, actor=actor)
99
+ return result
100
+ except (IntegrityError, DataError, StatementError) as e:
101
+ raise ValidationError(str(e)) from e
102
+
103
+ def update_record(
104
+ self,
105
+ record_id: int,
106
+ payload: Dict[str, Any],
107
+ actor: str,
108
+ ) -> ModelType: # type: ignore[type-var]
109
+ """Upsert a record (PUT semantics)."""
110
+ existed = self.accessor.get(record_id, include_deleted=True) is not None
111
+
112
+ if not existed and self.max_records is not None:
113
+ total = len(self.accessor.all(include_deleted=True))
114
+ if total >= self.max_records:
115
+ raise ConflictError(
116
+ f"Max {self.max_records} records allowed for {self.accessor.component}"
117
+ )
118
+
119
+ payload_no_id = {k: v for k, v in payload.items() if k != "id"}
120
+ obj = self.accessor.model(id=record_id, **payload_no_id)
121
+
122
+ try:
123
+ saved = self.accessor.save(obj, actor=actor)
124
+ except (IntegrityError, DataError, StatementError) as e:
125
+ raise ValidationError(str(e)) from e
126
+
127
+ return saved
128
+
129
+ def delete_record(self, record_id: int, actor: str) -> None:
130
+ """Delete a record by ID (soft or hard)."""
131
+ ok = self.accessor.delete(record_id, actor=actor)
132
+ if not ok:
133
+ raise NotFoundError()
134
+
135
+ def restore_record(self, record_id: int, actor: str) -> ModelType: # type: ignore[type-var]
136
+ """Restore a soft-deleted record (if supported).
137
+
138
+ Returns the restored model instance. If the record was already
139
+ not deleted, returns it unchanged.
140
+ """
141
+ model_cls = self.accessor.model
142
+ if not hasattr(model_cls, "deleted_at"):
143
+ raise UnsupportedError(
144
+ f"{self.accessor.component} does not support soft delete/restore"
145
+ )
146
+
147
+ obj = self.accessor.get(record_id, include_deleted=True)
148
+ if not obj:
149
+ raise NotFoundError()
150
+
151
+ if getattr(obj, "deleted_at") is None:
152
+ # already restored, return as-is
153
+ return obj
154
+
155
+ self.accessor.restore(record_id, actor=actor)
156
+ # Fetch the restored record to return it
157
+ restored = self.accessor.get(record_id, include_deleted=False)
158
+ if not restored:
159
+ raise NotFoundError("Record not found after restore")
160
+ return restored
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+ from datetime import datetime, timezone
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from sqlmodel import SQLModel, select
7
+ from sqlalchemy import desc
8
+
9
+ from storage import database, io_helpers
10
+ from storage.auditor import AuditLog
11
+ from storage.utils import Operation
12
+ from storage.io_helpers import (
13
+ discover_user_tables,
14
+ build_export_zip_bytes,
15
+ db_file_path,
16
+ build_backup_bytes,
17
+ validate_sqlite_file,
18
+ safe_unlink,
19
+ )
20
+
21
+
22
+ __all__ = ["DatabaseService", "DatabaseError"]
23
+
24
+
25
+ # ---------------------------------------------------------
26
+ # Exceptions (transport-agnostic)
27
+ # ---------------------------------------------------------
28
+
29
+
30
+ class DatabaseError(Exception):
31
+ """Base class for database-level service errors."""
32
+
33
+ def __init__(self, message: str, code: str = "UNKNOWN"):
34
+ super().__init__(message)
35
+ self.code = code
36
+
37
+
38
+ class DependencyError(DatabaseError):
39
+ def __init__(self, message: str):
40
+ super().__init__(message, code="DEPENDENCY_ERROR")
41
+
42
+
43
+ class ValidationError(DatabaseError):
44
+ def __init__(self, message: str):
45
+ super().__init__(message, code="VALIDATION_ERROR")
46
+
47
+
48
+ class OperationalError(DatabaseError):
49
+ def __init__(self, message: str):
50
+ super().__init__(message, code="OPERATIONAL_ERROR")
51
+
52
+
53
+ # ---------------------------------------------------------
54
+ # Database Service
55
+ # ---------------------------------------------------------
56
+
57
+
58
+ class DatabaseService:
59
+ """
60
+ Transport-agnostic database utilities.
61
+
62
+ Exposes operations like:
63
+ - health check
64
+ - audit log queries
65
+ - schema diagram (SVG)
66
+ - export.zip / backup.sqlite creation
67
+ - restore.sqlite upload + validation
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ audit_default_limit: int = 100,
74
+ audit_max_limit: int = 1000,
75
+ ) -> None:
76
+ self.audit_default_limit = audit_default_limit
77
+ self.audit_max_limit = audit_max_limit
78
+
79
+ # ---------------- HEALTH ----------------
80
+
81
+ def health(self) -> Dict[str, str]:
82
+ """Verify that a database engine can be initialized."""
83
+ _ = database.get_engine()
84
+ return {"status": "ok"}
85
+
86
+ # ---------------- AUDIT LOG ----------------
87
+
88
+ def read_audit(
89
+ self,
90
+ *,
91
+ component: Optional[str] = None,
92
+ record_id: Optional[int] = None,
93
+ actor: Optional[str] = None,
94
+ operation: Optional[Operation] = None,
95
+ since: Optional[datetime] = None,
96
+ until: Optional[datetime] = None,
97
+ limit: Optional[int] = None,
98
+ offset: int = 0,
99
+ ) -> List[AuditLog]:
100
+ """
101
+ Query the audit log table with filtering + pagination.
102
+ """
103
+ limit = limit or self.audit_default_limit
104
+ if limit > self.audit_max_limit:
105
+ limit = self.audit_max_limit
106
+
107
+ with database.use_session() as session:
108
+ statement = select(AuditLog)
109
+ if component:
110
+ statement = statement.where(AuditLog.component == component)
111
+ if record_id is not None:
112
+ statement = statement.where(AuditLog.record_id == record_id)
113
+ if actor:
114
+ statement = statement.where(AuditLog.actor == actor)
115
+ if operation:
116
+ statement = statement.where(AuditLog.operation == operation)
117
+ if since is not None:
118
+ statement = statement.where(AuditLog.timestamp >= since)
119
+ if until is not None:
120
+ statement = statement.where(AuditLog.timestamp < until)
121
+
122
+ statement = (
123
+ statement.order_by(desc(AuditLog.timestamp)).offset(offset).limit(limit)
124
+ )
125
+ results = session.exec(statement).all()
126
+ return list(results)
127
+
128
+ # ---------------- DIAGRAM ----------------
129
+
130
+ def schema_diagram_svg(self) -> bytes:
131
+ """
132
+ Generate a database schema diagram as SVG bytes.
133
+ Requires `sqlalchemy-schemadisplay` and Graphviz installed.
134
+ """
135
+ try:
136
+ from sqlalchemy_schemadisplay import create_schema_graph
137
+ except Exception as e:
138
+ raise DependencyError(
139
+ "sqlalchemy-schemadisplay is required. Install with: "
140
+ "pip install sqlalchemy-schemadisplay"
141
+ ) from e
142
+
143
+ try:
144
+ graph = create_schema_graph(
145
+ engine=database.get_engine(),
146
+ metadata=SQLModel.metadata,
147
+ show_datatypes=True,
148
+ show_indexes=False,
149
+ concentrate=False,
150
+ )
151
+ svg_bytes = graph.create_svg()
152
+ if isinstance(svg_bytes, str):
153
+ return svg_bytes.encode("utf-8")
154
+ return bytes(svg_bytes)
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 DependencyError(
159
+ "Graphviz is required to render the diagram. "
160
+ "Install it via `brew install graphviz` or `apt-get install graphviz`."
161
+ ) from e
162
+ raise OperationalError(f"Failed to generate schema diagram: {e}") from e
163
+
164
+ # ---------------- EXPORT ZIP ----------------
165
+
166
+ def export_zip(self) -> bytes:
167
+ """
168
+ Export all user-defined tables as a ZIP archive of CSVs.
169
+ """
170
+ try:
171
+ return build_export_zip_bytes(discover_user_tables())
172
+ except Exception as e:
173
+ raise OperationalError(f"Failed to build export.zip: {e}") from e
174
+
175
+ # ---------------- BACKUP SQLITE ----------------
176
+
177
+ def backup_sqlite(self) -> Dict[str, Any]:
178
+ """
179
+ Create a consistent SQLite backup and return metadata + bytes.
180
+ """
181
+ path = db_file_path()
182
+ try:
183
+ data = build_backup_bytes(path)
184
+ except Exception as e:
185
+ raise OperationalError(f"Failed to create backup: {e}") from e
186
+
187
+ filename = f"backup-{self._timestamp_slug()}.sqlite"
188
+ return {"filename": filename, "data": data}
189
+
190
+ # ---------------- RESTORE SQLITE ----------------
191
+
192
+ def restore_sqlite(
193
+ self,
194
+ file_bytes: bytes,
195
+ *,
196
+ filename: str = "upload.sqlite",
197
+ integrity_check: bool = True,
198
+ dry_run: bool = False,
199
+ ) -> Dict[str, Any]:
200
+ """
201
+ Restore a database from raw SQLite bytes.
202
+
203
+ Steps:
204
+ 1. Save to a temp path
205
+ 2. Validate header & integrity
206
+ 3. Optionally replace existing DB atomically
207
+ """
208
+ path = db_file_path()
209
+ db_dir = Path(path).resolve().parent
210
+
211
+ try:
212
+ tmp_path, total = io_helpers.save_bytes_to_temp(
213
+ file_bytes, db_dir, filename=filename
214
+ )
215
+ except Exception as e:
216
+ raise OperationalError(f"Failed to save upload: {e}") from e
217
+
218
+ try:
219
+ validate_sqlite_file(tmp_path, run_integrity_check=integrity_check)
220
+ except ValueError as ve:
221
+ safe_unlink(tmp_path)
222
+ raise ValidationError(str(ve)) from ve
223
+ except Exception as e:
224
+ safe_unlink(tmp_path)
225
+ raise ValidationError(f"Invalid SQLite file: {e}") from e
226
+
227
+ if dry_run:
228
+ safe_unlink(tmp_path)
229
+ return {"status": "ok", "restored": False, "bytes": total}
230
+
231
+ try:
232
+ io_helpers.atomic_replace_db(tmp_path, Path(path))
233
+ except Exception as e:
234
+ safe_unlink(tmp_path)
235
+ raise OperationalError(f"Failed to replace database file: {e}") from e
236
+
237
+ return {"status": "ok", "restored": True, "bytes": total}
238
+
239
+ # ---------------- INTERNAL ----------------
240
+
241
+ def _timestamp_slug(self) -> str:
242
+ """UTC timestamp safe for filenames."""
243
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
storage/io_helpers.py CHANGED
@@ -25,6 +25,7 @@ __all__ = [
25
25
  "db_file_path",
26
26
  "build_backup_bytes",
27
27
  "validate_sqlite_file",
28
+ "save_bytes_to_temp",
28
29
  "safe_unlink",
29
30
  ]
30
31
 
@@ -159,6 +160,19 @@ def save_upload_to_temp(file: UploadFile, dest_dir: Path) -> tuple[Path, int]:
159
160
  return tmp_path, total
160
161
 
161
162
 
163
+ def save_bytes_to_temp(
164
+ file_bytes: bytes, dest_dir: Path, *, filename: str = "upload.sqlite"
165
+ ) -> tuple[Path, int]:
166
+ """Save bytes to a temporary file in the destination directory."""
167
+ with tempfile.NamedTemporaryFile(
168
+ prefix="restore-", suffix=".sqlite", dir=dest_dir, delete=False
169
+ ) as tmp:
170
+ tmp_path = Path(tmp.name)
171
+ tmp.write(file_bytes)
172
+ total = len(file_bytes)
173
+ return tmp_path, total
174
+
175
+
162
176
  def atomic_replace_db(tmp_path: Path, db_path: Path) -> None:
163
177
  database.get_engine().dispose()
164
178
  os.replace(str(tmp_path), str(db_path))
storage/utils.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from datetime import datetime, timezone, date, time
2
- from typing import Literal, TypeVar, Any
2
+ from typing import Literal, TypeVar, Any, Optional, get_args
3
3
  from sqlmodel import SQLModel
4
4
 
5
5
  ModelType = TypeVar("ModelType", bound=SQLModel)
@@ -18,3 +18,28 @@ def to_primitive(value: Any) -> Any:
18
18
  if isinstance(value, (datetime, date, time)):
19
19
  return value.isoformat()
20
20
  return value
21
+
22
+
23
+ def parse_audit_operation(operation: Optional[str]) -> Optional[Operation]:
24
+ """
25
+ Parse and validate an operation string for audit queries.
26
+ """
27
+ if operation is None:
28
+ return None
29
+
30
+ if operation not in get_args(Operation):
31
+ raise ValueError(
32
+ f"Invalid operation: {operation}. Must be one of {get_args(Operation)}"
33
+ )
34
+
35
+ return operation # type: ignore
36
+
37
+
38
+ def parse_audit_datetime(dt_str: Optional[str]) -> Optional[datetime]:
39
+ """
40
+ Parse an ISO 8601 datetime string for audit queries.
41
+ Handles both 'Z' and '+00:00' timezone formats.
42
+ """
43
+ if dt_str is None:
44
+ return None
45
+ return datetime.fromisoformat(dt_str.replace("Z", "+00:00"))