svc-infra 0.1.602__py3-none-any.whl → 0.1.603__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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/api/fastapi/db/sql/crud_router.py +41 -23
- svc_infra/api/fastapi/docs/add.py +29 -7
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +26 -0
- svc_infra/data/add.py +8 -6
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/sql/repository.py +3 -2
- {svc_infra-0.1.602.dist-info → svc_infra-0.1.603.dist-info}/METADATA +2 -1
- {svc_infra-0.1.602.dist-info → svc_infra-0.1.603.dist-info}/RECORD +13 -9
- {svc_infra-0.1.602.dist-info → svc_infra-0.1.603.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.602.dist-info → svc_infra-0.1.603.dist-info}/entry_points.txt +0 -0
|
@@ -46,6 +46,18 @@ def make_crud_router_plus_sql(
|
|
|
46
46
|
redirect_slashes=False,
|
|
47
47
|
)
|
|
48
48
|
|
|
49
|
+
def _coerce_id(v: Any) -> Any:
|
|
50
|
+
"""Best-effort coercion of path ids: cast digit-only strings to int.
|
|
51
|
+
|
|
52
|
+
Keeps original type otherwise to avoid breaking non-integer IDs.
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(v, str) and v.isdigit():
|
|
55
|
+
try:
|
|
56
|
+
return int(v)
|
|
57
|
+
except Exception:
|
|
58
|
+
return v
|
|
59
|
+
return v
|
|
60
|
+
|
|
49
61
|
def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
|
|
50
62
|
if not order_spec:
|
|
51
63
|
return []
|
|
@@ -87,9 +99,7 @@ def make_crud_router_plus_sql(
|
|
|
87
99
|
else:
|
|
88
100
|
items = await service.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
|
|
89
101
|
total = await service.count(session)
|
|
90
|
-
return Page[
|
|
91
|
-
total=total, items=items, limit=lp.limit, offset=lp.offset
|
|
92
|
-
)
|
|
102
|
+
return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
|
|
93
103
|
|
|
94
104
|
# -------- GET by id --------
|
|
95
105
|
@router.get(
|
|
@@ -98,7 +108,7 @@ def make_crud_router_plus_sql(
|
|
|
98
108
|
description=f"Get item of type {model.__name__}",
|
|
99
109
|
)
|
|
100
110
|
async def get_item(item_id: Any, session: SqlSessionDep): # type: ignore[name-defined]
|
|
101
|
-
row = await service.get(session, item_id)
|
|
111
|
+
row = await service.get(session, _coerce_id(item_id))
|
|
102
112
|
if not row:
|
|
103
113
|
raise HTTPException(404, "Not found")
|
|
104
114
|
return row
|
|
@@ -139,7 +149,7 @@ def make_crud_router_plus_sql(
|
|
|
139
149
|
data = payload
|
|
140
150
|
else:
|
|
141
151
|
raise HTTPException(422, "invalid_payload")
|
|
142
|
-
row = await service.update(session, item_id, data)
|
|
152
|
+
row = await service.update(session, _coerce_id(item_id), data)
|
|
143
153
|
if not row:
|
|
144
154
|
raise HTTPException(404, "Not found")
|
|
145
155
|
return row
|
|
@@ -149,7 +159,7 @@ def make_crud_router_plus_sql(
|
|
|
149
159
|
"/{item_id}", status_code=204, description=f"Delete item of type {model.__name__}"
|
|
150
160
|
)
|
|
151
161
|
async def delete_item(item_id: Any, session: SqlSessionDep): # type: ignore[name-defined]
|
|
152
|
-
ok = await service.delete(session, item_id)
|
|
162
|
+
ok = await service.delete(session, _coerce_id(item_id))
|
|
153
163
|
if not ok:
|
|
154
164
|
raise HTTPException(404, "Not found")
|
|
155
165
|
return
|
|
@@ -180,6 +190,25 @@ def make_tenant_crud_router_plus_sql(
|
|
|
180
190
|
redirect_slashes=False,
|
|
181
191
|
)
|
|
182
192
|
|
|
193
|
+
# Evaluate the base service once to preserve in-memory state across requests in tests/local.
|
|
194
|
+
# Consumers may pass either an instance or a zero-arg factory function.
|
|
195
|
+
try:
|
|
196
|
+
_base_instance = service_factory() if callable(service_factory) else service_factory # type: ignore[misc]
|
|
197
|
+
except TypeError:
|
|
198
|
+
# If the callable requires args, assume it's already an instance
|
|
199
|
+
_base_instance = service_factory # type: ignore[assignment]
|
|
200
|
+
|
|
201
|
+
def _coerce_id(v: Any) -> Any:
|
|
202
|
+
"""Best-effort coercion of path ids: cast digit-only strings to int.
|
|
203
|
+
Keeps original type otherwise.
|
|
204
|
+
"""
|
|
205
|
+
if isinstance(v, str) and v.isdigit():
|
|
206
|
+
try:
|
|
207
|
+
return int(v)
|
|
208
|
+
except Exception:
|
|
209
|
+
return v
|
|
210
|
+
return v
|
|
211
|
+
|
|
183
212
|
def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
|
|
184
213
|
if not order_spec:
|
|
185
214
|
return []
|
|
@@ -194,17 +223,8 @@ def make_tenant_crud_router_plus_sql(
|
|
|
194
223
|
|
|
195
224
|
# create per-request service with tenant scoping
|
|
196
225
|
async def _svc(session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
197
|
-
|
|
198
|
-
svc =
|
|
199
|
-
if callable(base):
|
|
200
|
-
svc = base # the consumer likely closed over repo
|
|
201
|
-
# if callable returns a service, call it now
|
|
202
|
-
try:
|
|
203
|
-
svc = base() # type: ignore[misc]
|
|
204
|
-
except TypeError:
|
|
205
|
-
svc = base # already instance
|
|
206
|
-
if not isinstance(svc, TenantSqlService):
|
|
207
|
-
svc = TenantSqlService(getattr(svc, "repo", svc), tenant_id=tenant_id, tenant_field=tenant_field) # type: ignore[arg-type]
|
|
226
|
+
repo_or_service = getattr(_base_instance, "repo", _base_instance)
|
|
227
|
+
svc: Any = TenantSqlService(repo_or_service, tenant_id=tenant_id, tenant_field=tenant_field) # type: ignore[arg-type]
|
|
208
228
|
return svc # type: ignore[return-value]
|
|
209
229
|
|
|
210
230
|
@router.get("", response_model=cast(Any, Page[Any]))
|
|
@@ -232,14 +252,12 @@ def make_tenant_crud_router_plus_sql(
|
|
|
232
252
|
else:
|
|
233
253
|
items = await svc.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
|
|
234
254
|
total = await svc.count(session)
|
|
235
|
-
return Page[
|
|
236
|
-
total=total, items=items, limit=lp.limit, offset=lp.offset
|
|
237
|
-
)
|
|
255
|
+
return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
|
|
238
256
|
|
|
239
257
|
@router.get("/{item_id}", response_model=cast(Any, Any))
|
|
240
258
|
async def get_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
241
259
|
svc = await _svc(session, tenant_id)
|
|
242
|
-
row = await svc.get(session, item_id)
|
|
260
|
+
row = await svc.get(session, _coerce_id(item_id))
|
|
243
261
|
if not row:
|
|
244
262
|
raise HTTPException(404, "Not found")
|
|
245
263
|
return row
|
|
@@ -273,7 +291,7 @@ def make_tenant_crud_router_plus_sql(
|
|
|
273
291
|
data = payload
|
|
274
292
|
else:
|
|
275
293
|
raise HTTPException(422, "invalid_payload")
|
|
276
|
-
row = await svc.update(session, item_id, data)
|
|
294
|
+
row = await svc.update(session, _coerce_id(item_id), data)
|
|
277
295
|
if not row:
|
|
278
296
|
raise HTTPException(404, "Not found")
|
|
279
297
|
return row
|
|
@@ -281,7 +299,7 @@ def make_tenant_crud_router_plus_sql(
|
|
|
281
299
|
@router.delete("/{item_id}", status_code=204)
|
|
282
300
|
async def delete_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
283
301
|
svc = await _svc(session, tenant_id)
|
|
284
|
-
ok = await svc.delete(session, item_id)
|
|
302
|
+
ok = await svc.delete(session, _coerce_id(item_id))
|
|
285
303
|
if not ok:
|
|
286
304
|
raise HTTPException(404, "Not found")
|
|
287
305
|
return
|
|
@@ -5,6 +5,8 @@ from pathlib import Path
|
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
7
|
from fastapi import FastAPI
|
|
8
|
+
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
|
9
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
def add_docs(
|
|
@@ -15,21 +17,41 @@ def add_docs(
|
|
|
15
17
|
openapi_url: str = "/openapi.json",
|
|
16
18
|
export_openapi_to: Optional[str] = None,
|
|
17
19
|
) -> None:
|
|
18
|
-
"""Enable docs endpoints and optionally export OpenAPI schema to disk on startup.
|
|
19
|
-
# Configure FastAPI docs URLs
|
|
20
|
-
app.docs_url = swagger_url
|
|
21
|
-
app.redoc_url = redoc_url
|
|
22
|
-
app.openapi_url = openapi_url
|
|
20
|
+
"""Enable docs endpoints and optionally export OpenAPI schema to disk on startup.
|
|
23
21
|
|
|
22
|
+
We mount docs and OpenAPI routes explicitly so this works even when configured post-init.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# OpenAPI JSON route
|
|
26
|
+
async def openapi_handler() -> JSONResponse: # noqa: ANN201
|
|
27
|
+
return JSONResponse(app.openapi())
|
|
28
|
+
|
|
29
|
+
app.add_api_route(openapi_url, openapi_handler, methods=["GET"], include_in_schema=False)
|
|
30
|
+
|
|
31
|
+
# Swagger UI route
|
|
32
|
+
async def swagger_ui() -> HTMLResponse: # noqa: ANN201
|
|
33
|
+
return get_swagger_ui_html(openapi_url=openapi_url, title="API Docs")
|
|
34
|
+
|
|
35
|
+
app.add_api_route(swagger_url, swagger_ui, methods=["GET"], include_in_schema=False)
|
|
36
|
+
|
|
37
|
+
# Redoc route
|
|
38
|
+
async def redoc_ui() -> HTMLResponse: # noqa: ANN201
|
|
39
|
+
return get_redoc_html(openapi_url=openapi_url, title="API ReDoc")
|
|
40
|
+
|
|
41
|
+
app.add_api_route(redoc_url, redoc_ui, methods=["GET"], include_in_schema=False)
|
|
42
|
+
|
|
43
|
+
# Optional export to disk on startup
|
|
24
44
|
if export_openapi_to:
|
|
25
45
|
export_path = Path(export_openapi_to)
|
|
26
46
|
|
|
27
|
-
|
|
28
|
-
|
|
47
|
+
async def _export_docs() -> None:
|
|
48
|
+
# Startup export
|
|
29
49
|
spec = app.openapi()
|
|
30
50
|
export_path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
51
|
export_path.write_text(json.dumps(spec, indent=2))
|
|
32
52
|
|
|
53
|
+
app.add_event_handler("startup", _export_docs)
|
|
54
|
+
|
|
33
55
|
|
|
34
56
|
def add_sdk_generation_stub(
|
|
35
57
|
app: FastAPI,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
from importlib import import_module
|
|
4
5
|
from typing import List, Optional
|
|
5
6
|
|
|
6
7
|
import typer
|
|
@@ -209,3 +210,28 @@ def register(app: typer.Typer) -> None:
|
|
|
209
210
|
app.command("sql-stamp")(cmd_stamp)
|
|
210
211
|
app.command("sql-merge-heads")(cmd_merge_heads)
|
|
211
212
|
app.command("sql-setup-and-migrate")(cmd_setup_and_migrate)
|
|
213
|
+
app.command("sql-seed")(cmd_seed)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _import_callable(path: str):
|
|
217
|
+
mod_name, _, fn_name = path.partition(":")
|
|
218
|
+
if not mod_name or not fn_name:
|
|
219
|
+
raise typer.BadParameter("Expected format 'module.path:callable'")
|
|
220
|
+
mod = import_module(mod_name)
|
|
221
|
+
fn = getattr(mod, fn_name, None)
|
|
222
|
+
if not callable(fn):
|
|
223
|
+
raise typer.BadParameter(f"Callable '{fn_name}' not found in module '{mod_name}'")
|
|
224
|
+
return fn
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def cmd_seed(
|
|
228
|
+
target: str = typer.Argument(..., help="Seed callable path 'module:func'"),
|
|
229
|
+
database_url: Optional[str] = typer.Option(
|
|
230
|
+
None,
|
|
231
|
+
help="Database URL; overrides env for this command.",
|
|
232
|
+
),
|
|
233
|
+
):
|
|
234
|
+
"""Run a user-provided seed function to load fixtures/reference data."""
|
|
235
|
+
apply_database_url(database_url)
|
|
236
|
+
fn = _import_callable(target)
|
|
237
|
+
fn()
|
svc_infra/data/add.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import inspect
|
|
3
4
|
from typing import Callable, Iterable, Optional
|
|
4
5
|
|
|
5
6
|
from fastapi import FastAPI
|
|
@@ -30,10 +31,9 @@ def add_data_lifecycle(
|
|
|
30
31
|
and offers extension points. Jobs should be scheduled using svc_infra.jobs helpers.
|
|
31
32
|
"""
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
async def _run_lifecycle() -> None:
|
|
35
|
+
# Startup
|
|
35
36
|
if auto_migrate:
|
|
36
|
-
# Use existing CLI function to perform end-to-end setup and migrate.
|
|
37
37
|
cmd_setup_and_migrate(
|
|
38
38
|
database_url=database_url,
|
|
39
39
|
overwrite_scaffold=False,
|
|
@@ -44,10 +44,12 @@ def add_data_lifecycle(
|
|
|
44
44
|
discover_packages=discover_packages,
|
|
45
45
|
with_payments=with_payments,
|
|
46
46
|
)
|
|
47
|
-
|
|
48
47
|
if on_load_fixtures:
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
res = on_load_fixtures()
|
|
49
|
+
if inspect.isawaitable(res):
|
|
50
|
+
await res # type: ignore[misc]
|
|
51
|
+
|
|
52
|
+
app.add_event_handler("startup", _run_lifecycle)
|
|
51
53
|
|
|
52
54
|
# Store optional jobs on app.state for external schedulers to discover/register.
|
|
53
55
|
if retention_jobs is not None:
|
svc_infra/data/backup.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class BackupHealthReport:
|
|
10
|
+
ok: bool
|
|
11
|
+
last_success: Optional[datetime]
|
|
12
|
+
retention_days: Optional[int]
|
|
13
|
+
message: str = ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_backups(
|
|
17
|
+
*, last_success: Optional[datetime] = None, retention_days: Optional[int] = None
|
|
18
|
+
) -> BackupHealthReport:
|
|
19
|
+
"""Return a basic backup health report.
|
|
20
|
+
|
|
21
|
+
In production, callers should plug a provider-specific checker and translate into this report.
|
|
22
|
+
"""
|
|
23
|
+
if last_success is None:
|
|
24
|
+
return BackupHealthReport(
|
|
25
|
+
ok=False, last_success=None, retention_days=retention_days, message="no_backup_seen"
|
|
26
|
+
)
|
|
27
|
+
now = datetime.now(timezone.utc)
|
|
28
|
+
age_days = (now - last_success).total_seconds() / 86400.0
|
|
29
|
+
ok = retention_days is None or age_days <= max(1, retention_days)
|
|
30
|
+
return BackupHealthReport(ok=ok, last_success=last_success, retention_days=retention_days)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["BackupHealthReport", "verify_backups"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def make_backup_verification_job(
|
|
37
|
+
checker: Callable[[], BackupHealthReport],
|
|
38
|
+
*,
|
|
39
|
+
on_report: Optional[Callable[[BackupHealthReport], None]] = None,
|
|
40
|
+
):
|
|
41
|
+
"""Return a callable suitable for scheduling in a job runner.
|
|
42
|
+
|
|
43
|
+
The checker should perform provider-specific checks and return a BackupHealthReport.
|
|
44
|
+
If on_report is provided, it will be invoked with the report.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def _job() -> BackupHealthReport:
|
|
48
|
+
rep = checker()
|
|
49
|
+
if on_report:
|
|
50
|
+
on_report(rep)
|
|
51
|
+
return rep
|
|
52
|
+
|
|
53
|
+
return _job
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Awaitable, Callable, Iterable, Optional, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SqlSession(Protocol): # minimal protocol for tests/integration
|
|
8
|
+
async def execute(self, stmt: Any) -> Any:
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ErasureStep:
|
|
14
|
+
name: str
|
|
15
|
+
run: Callable[[SqlSession, str], Awaitable[int] | int]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ErasurePlan:
|
|
20
|
+
steps: Iterable[ErasureStep]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def run_erasure(
|
|
24
|
+
session: SqlSession,
|
|
25
|
+
principal_id: str,
|
|
26
|
+
plan: ErasurePlan,
|
|
27
|
+
*,
|
|
28
|
+
on_audit: Optional[Callable[[str, dict[str, Any]], None]] = None,
|
|
29
|
+
) -> int:
|
|
30
|
+
"""Run an erasure plan and optionally emit an audit event.
|
|
31
|
+
|
|
32
|
+
Returns total affected rows across steps.
|
|
33
|
+
"""
|
|
34
|
+
total = 0
|
|
35
|
+
for s in plan.steps:
|
|
36
|
+
res = s.run(session, principal_id)
|
|
37
|
+
if hasattr(res, "__await__"):
|
|
38
|
+
res = await res # type: ignore[misc]
|
|
39
|
+
total += int(res or 0)
|
|
40
|
+
if on_audit:
|
|
41
|
+
on_audit("erasure.completed", {"principal_id": principal_id, "affected": total})
|
|
42
|
+
return total
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = ["ErasureStep", "ErasurePlan", "run_erasure"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Awaitable, Callable, Iterable, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def run_fixtures(
|
|
9
|
+
loaders: Iterable[Callable[[], None | Awaitable[None]]], *, run_once_file: Optional[str] = None
|
|
10
|
+
) -> None:
|
|
11
|
+
"""Run a sequence of fixture loaders (sync or async).
|
|
12
|
+
|
|
13
|
+
- If run_once_file is provided and exists, does nothing.
|
|
14
|
+
- On success, creates the run_once_file sentinel (parent dirs included).
|
|
15
|
+
"""
|
|
16
|
+
if run_once_file:
|
|
17
|
+
sentinel = Path(run_once_file)
|
|
18
|
+
if sentinel.exists():
|
|
19
|
+
return
|
|
20
|
+
for fn in loaders:
|
|
21
|
+
res = fn()
|
|
22
|
+
if inspect.isawaitable(res): # type: ignore[arg-type]
|
|
23
|
+
await res # type: ignore[misc]
|
|
24
|
+
if run_once_file:
|
|
25
|
+
sentinel.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
Path(run_once_file).write_text("ok")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_on_load_fixtures(
|
|
30
|
+
*loaders: Callable[[], None | Awaitable[None]], run_once_file: Optional[str] = None
|
|
31
|
+
) -> Callable[[], Awaitable[None]]:
|
|
32
|
+
"""Return an async callable suitable for add_data_lifecycle(on_load_fixtures=...)."""
|
|
33
|
+
|
|
34
|
+
async def _runner() -> None:
|
|
35
|
+
await run_fixtures(loaders, run_once_file=run_once_file)
|
|
36
|
+
|
|
37
|
+
return _runner
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["run_fixtures", "make_on_load_fixtures"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Any, Iterable, Optional, Protocol, Sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SqlSession(Protocol): # minimal protocol for tests/integration
|
|
9
|
+
async def execute(self, stmt: Any) -> Any:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class RetentionPolicy:
|
|
15
|
+
name: str
|
|
16
|
+
model: Any # SQLAlchemy model or test double exposing columns
|
|
17
|
+
older_than_days: int
|
|
18
|
+
soft_delete_field: Optional[str] = "deleted_at"
|
|
19
|
+
extra_where: Optional[Sequence[Any]] = None
|
|
20
|
+
hard_delete: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def purge_policy(session: SqlSession, policy: RetentionPolicy) -> int:
|
|
24
|
+
"""Execute a single retention purge according to policy.
|
|
25
|
+
|
|
26
|
+
If hard_delete is False and soft_delete_field exists on model, set timestamp; else DELETE.
|
|
27
|
+
Returns number of affected rows (best-effort; test doubles may return an int directly).
|
|
28
|
+
"""
|
|
29
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=policy.older_than_days)
|
|
30
|
+
m = policy.model
|
|
31
|
+
where = list(policy.extra_where or [])
|
|
32
|
+
created_col = getattr(m, "created_at", None)
|
|
33
|
+
if created_col is not None and hasattr(created_col, "__le__"):
|
|
34
|
+
where.append(created_col <= cutoff) # type: ignore[operator]
|
|
35
|
+
|
|
36
|
+
# Soft-delete path when available and requested
|
|
37
|
+
if not policy.hard_delete and policy.soft_delete_field and hasattr(m, policy.soft_delete_field):
|
|
38
|
+
stmt = m.update().where(*where).values({policy.soft_delete_field: cutoff}) # type: ignore[attr-defined]
|
|
39
|
+
res = await session.execute(stmt)
|
|
40
|
+
return getattr(res, "rowcount", 0)
|
|
41
|
+
|
|
42
|
+
# Hard delete fallback
|
|
43
|
+
stmt = m.delete().where(*where) # type: ignore[attr-defined]
|
|
44
|
+
res = await session.execute(stmt)
|
|
45
|
+
return getattr(res, "rowcount", 0)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def run_retention_purge(session: SqlSession, policies: Iterable[RetentionPolicy]) -> int:
|
|
49
|
+
total = 0
|
|
50
|
+
for p in policies:
|
|
51
|
+
total += await purge_policy(session, p)
|
|
52
|
+
return total
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = ["RetentionPolicy", "purge_policy", "run_retention_purge"]
|
svc_infra/db/sql/repository.py
CHANGED
|
@@ -124,9 +124,10 @@ class SqlRepository:
|
|
|
124
124
|
return False
|
|
125
125
|
if self.soft_delete:
|
|
126
126
|
# Prefer timestamp, also optionally set flag to False
|
|
127
|
-
|
|
127
|
+
# Check attributes on the instance to support test doubles without class-level fields
|
|
128
|
+
if hasattr(obj, self.soft_delete_field):
|
|
128
129
|
setattr(obj, self.soft_delete_field, func.now())
|
|
129
|
-
if self.soft_delete_flag_field and hasattr(
|
|
130
|
+
if self.soft_delete_flag_field and hasattr(obj, self.soft_delete_flag_field):
|
|
130
131
|
setattr(obj, self.soft_delete_flag_field, False)
|
|
131
132
|
await session.flush()
|
|
132
133
|
return True
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: svc-infra
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.603
|
|
4
4
|
Summary: Infrastructure for building and deploying prod-ready services
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
|
|
@@ -93,6 +93,7 @@ svc-infra packages the shared building blocks we use to ship production FastAPI
|
|
|
93
93
|
| Observability | Prometheus middleware, Grafana automation, OTEL hooks | [Observability guide](docs/observability.md) |
|
|
94
94
|
| Webhooks | Subscription store, signing, retry worker | [Webhooks framework](docs/webhooks.md) |
|
|
95
95
|
| Security | Password policy, lockout, signed cookies, headers | [Security hardening](docs/security.md) |
|
|
96
|
+
| Data Lifecycle | Fixtures, retention, erasure, backups | [Data lifecycle](docs/data-lifecycle.md) |
|
|
96
97
|
|
|
97
98
|
## Minimal FastAPI bootstrap
|
|
98
99
|
|
|
@@ -50,13 +50,13 @@ svc_infra/api/fastapi/db/nosql/mongo/health.py,sha256=OM4_Pig3IRSrBhz2yHweIhGlSB
|
|
|
50
50
|
svc_infra/api/fastapi/db/sql/README.md,sha256=uF_k4wbNSeWR6JF_8evWkeSFidBEXMXcIiYyR6sEv48,3082
|
|
51
51
|
svc_infra/api/fastapi/db/sql/__init__.py,sha256=R9P1Vy2Uqf9gFISxChMhO9tOGciIjEymVPhpXmsY3zc,255
|
|
52
52
|
svc_infra/api/fastapi/db/sql/add.py,sha256=xGZnbGnP9PQtWvr5vuXA3_PXzTKldPM_cD3L0uztAvo,4768
|
|
53
|
-
svc_infra/api/fastapi/db/sql/crud_router.py,sha256=
|
|
53
|
+
svc_infra/api/fastapi/db/sql/crud_router.py,sha256=H7212YK4BsmuuS5E-HWKnRXzgc4RQxVSFWT6wzb4IXQ,11575
|
|
54
54
|
svc_infra/api/fastapi/db/sql/health.py,sha256=ELLgQerooHHnvZRhGueSAc4QJsb3C4RojUGIu_U-hA4,792
|
|
55
55
|
svc_infra/api/fastapi/db/sql/session.py,sha256=DUBqKTRJAX4fqRz9B-w9eD9SpzZ8EUS862-GsjCL3ts,1869
|
|
56
56
|
svc_infra/api/fastapi/db/sql/users.py,sha256=68HGJgYVTEjKJm4-DPPC8-6nwXJoCukmgrYIIOHEUjs,5346
|
|
57
57
|
svc_infra/api/fastapi/dependencies/ratelimit.py,sha256=DiOC-MJfqTtSydM6RAaeAsiXXL_6oZQoBLvRSpdWzs4,3794
|
|
58
58
|
svc_infra/api/fastapi/docs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
59
|
-
svc_infra/api/fastapi/docs/add.py,sha256=
|
|
59
|
+
svc_infra/api/fastapi/docs/add.py,sha256=Ytk4iqWrNqPg3-SELJ5OUNuTieltpTBmIFOPKGnHVag,2563
|
|
60
60
|
svc_infra/api/fastapi/docs/landing.py,sha256=5JqJYCxQDCWy-BeDLfkv7OBlzWQKSGWUCYXQ51hojG8,4627
|
|
61
61
|
svc_infra/api/fastapi/docs/scoped.py,sha256=AuN35Op-9fUvHQCLOBtRjd5eWSpB5C9EAW_7-Boxmfo,7540
|
|
62
62
|
svc_infra/api/fastapi/dual/__init__.py,sha256=scHLcNFkGbgX_R21V702xnAv6GMCkQ4n7yUtNDNgliM,552
|
|
@@ -133,7 +133,7 @@ svc_infra/cli/cmds/db/nosql/mongo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeR
|
|
|
133
133
|
svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py,sha256=83_I0-63aRyR2uRLhpG1DKavH8BJ6fwdL3qpCpksyBU,6109
|
|
134
134
|
svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py,sha256=gsv7V3eGxyhQQm4J8IPYV9xQkdv0DoX7txcPLgbiejk,4277
|
|
135
135
|
svc_infra/cli/cmds/db/sql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
136
|
-
svc_infra/cli/cmds/db/sql/alembic_cmds.py,sha256=
|
|
136
|
+
svc_infra/cli/cmds/db/sql/alembic_cmds.py,sha256=HfnLsLJMug6llfitiwUvwdrXzcjRqrn-BVyJNcQDLFA,8519
|
|
137
137
|
svc_infra/cli/cmds/db/sql/sql_export_cmds.py,sha256=6MxoQO-9upoXg0cl1RHIqz96yXFVGidiBYp_ewhB0E0,2700
|
|
138
138
|
svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py,sha256=eNTCqHXOxgl9H3WTbGVn9BHXYwCpjIEJsDqhEFdrYMM,4613
|
|
139
139
|
svc_infra/cli/cmds/help.py,sha256=wGfZFMYaR2ZPwW2JwKDU7M3m4AtdCd8GRQ412AmEBUM,758
|
|
@@ -144,7 +144,11 @@ svc_infra/cli/cmds/obs/obs_cmds.py,sha256=fltUZu5fcnZdl0_JPJBIxIaA1Xqpw1BXE-SWBP
|
|
|
144
144
|
svc_infra/cli/foundation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
145
145
|
svc_infra/cli/foundation/runner.py,sha256=RbfjKwb3aHk1Y0MYU8xMpKRpIqRVMVr8GuL2EDZ6n38,1862
|
|
146
146
|
svc_infra/cli/foundation/typer_bootstrap.py,sha256=KapgH1R-qON9FuYH1KYlVx_5sJvjmAGl25pB61XCpm4,985
|
|
147
|
-
svc_infra/data/add.py,sha256=
|
|
147
|
+
svc_infra/data/add.py,sha256=IG_1kMthOcYwpBYfZEGr90XE5Z96z2TEnl1hs5w0jAs,2222
|
|
148
|
+
svc_infra/data/backup.py,sha256=LZcfw7aQGge0I0PG8P1dQWkgFHTTPKc5iXnsvf9oAUA,1619
|
|
149
|
+
svc_infra/data/erasure.py,sha256=VG4XKCE9XMc63qHW7jd-mivwzeITOJe-Ru2X1-7bE_I,1155
|
|
150
|
+
svc_infra/data/fixtures.py,sha256=2WKU6-HCKfA_uiwjI0cB_79F-aCbqoo3Ro05ksGbH3Y,1278
|
|
151
|
+
svc_infra/data/retention.py,sha256=8K_lG4vVLl5BaLVw3S2bm2Vu88oTZOpUL9_ifGtes6I,2062
|
|
148
152
|
svc_infra/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
149
153
|
svc_infra/db/crud_schema.py,sha256=-fv-Om1lHVt6lcNbie6A2kRcPex4SDByUPfks6SpmUc,2521
|
|
150
154
|
svc_infra/db/inbox.py,sha256=drxLRLHaMRrCDgo_8wj12do80wDh5ssHV6LGkaM98no,1996
|
|
@@ -178,7 +182,7 @@ svc_infra/db/sql/base.py,sha256=yeOM4Xo2CtTzsfx0hN8KLFTgOPZp6BrbV7Omz_bZUMg,317
|
|
|
178
182
|
svc_infra/db/sql/constants.py,sha256=FqtoBWqP13gW8avIWpr8E41PDW5aQ88tjGmnokCV9Lk,1656
|
|
179
183
|
svc_infra/db/sql/core.py,sha256=2W7o1uyEZBDbqX1ptArP5Fa5obaVpZqHpczJCdf1NZk,10855
|
|
180
184
|
svc_infra/db/sql/management.py,sha256=bVpzj8BapwcghxAwmBsTnntWf3kaNf7CYETndppaiUM,3620
|
|
181
|
-
svc_infra/db/sql/repository.py,sha256=
|
|
185
|
+
svc_infra/db/sql/repository.py,sha256=B80mqW0Hjv1HmbnnDqHDliOHKSusRFoCtHiuaBC_9M0,7131
|
|
182
186
|
svc_infra/db/sql/resource.py,sha256=CfBmRvMq_1-cgYBS9uc_G4i1W5e6wk6zo4MGdKTPT9I,1194
|
|
183
187
|
svc_infra/db/sql/scaffold.py,sha256=aRC572W9Jqs1nLprR_zmmgERthMlJwoUjQ_xbRh53RU,9107
|
|
184
188
|
svc_infra/db/sql/service.py,sha256=Jtb89FQgXWNRrh_bPLCvNyM7Up0_rn_wx1_CGWfiqNs,2790
|
|
@@ -278,7 +282,7 @@ svc_infra/webhooks/fastapi.py,sha256=BCNvGNxukf6dC2a4i-6en-PrjBGV19YvCWOot5lXWsA
|
|
|
278
282
|
svc_infra/webhooks/router.py,sha256=6JvAVPMEth_xxHX-IsIOcyMgHX7g1H0OVxVXKLuMp9w,1596
|
|
279
283
|
svc_infra/webhooks/service.py,sha256=hWgiJRXKBwKunJOx91C7EcLUkotDtD3Xp0RT6vj2IC0,1797
|
|
280
284
|
svc_infra/webhooks/signing.py,sha256=NCwdZzmravUe7HVIK_uXK0qqf12FG-_MVsgPvOw6lsM,784
|
|
281
|
-
svc_infra-0.1.
|
|
282
|
-
svc_infra-0.1.
|
|
283
|
-
svc_infra-0.1.
|
|
284
|
-
svc_infra-0.1.
|
|
285
|
+
svc_infra-0.1.603.dist-info/METADATA,sha256=Y_24yijEGFoG0M5wtJE7EjMaf5BMobwLNwxJoR4ACtk,7941
|
|
286
|
+
svc_infra-0.1.603.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
287
|
+
svc_infra-0.1.603.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
|
|
288
|
+
svc_infra-0.1.603.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|