svc-infra 0.1.601__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 +82 -0
- svc_infra/api/fastapi/ops/add.py +65 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +26 -0
- svc_infra/data/add.py +61 -0
- 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/dx/add.py +63 -0
- {svc_infra-0.1.601.dist-info → svc_infra-0.1.603.dist-info}/METADATA +2 -1
- {svc_infra-0.1.601.dist-info → svc_infra-0.1.603.dist-info}/RECORD +15 -7
- {svc_infra-0.1.601.dist-info → svc_infra-0.1.603.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.601.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
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
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
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_docs(
|
|
13
|
+
app: FastAPI,
|
|
14
|
+
*,
|
|
15
|
+
redoc_url: str = "/redoc",
|
|
16
|
+
swagger_url: str = "/docs",
|
|
17
|
+
openapi_url: str = "/openapi.json",
|
|
18
|
+
export_openapi_to: Optional[str] = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Enable docs endpoints and optionally export OpenAPI schema to disk on startup.
|
|
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
|
|
44
|
+
if export_openapi_to:
|
|
45
|
+
export_path = Path(export_openapi_to)
|
|
46
|
+
|
|
47
|
+
async def _export_docs() -> None:
|
|
48
|
+
# Startup export
|
|
49
|
+
spec = app.openapi()
|
|
50
|
+
export_path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
export_path.write_text(json.dumps(spec, indent=2))
|
|
52
|
+
|
|
53
|
+
app.add_event_handler("startup", _export_docs)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def add_sdk_generation_stub(
|
|
57
|
+
app: FastAPI,
|
|
58
|
+
*,
|
|
59
|
+
on_generate: Optional[callable] = None,
|
|
60
|
+
openapi_path: str = "/openapi.json",
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Hook to add an SDK generation stub.
|
|
63
|
+
|
|
64
|
+
Provide `on_generate()` to run generation (e.g., openapi-generator). This is a stub only; we
|
|
65
|
+
don't ship a hard dependency. If `on_generate` is provided, we expose `/_docs/generate-sdk`.
|
|
66
|
+
"""
|
|
67
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
68
|
+
|
|
69
|
+
if not on_generate:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
router = public_router(prefix="/_docs", include_in_schema=False)
|
|
73
|
+
|
|
74
|
+
@router.post("/generate-sdk")
|
|
75
|
+
async def _generate() -> dict: # noqa: ANN201
|
|
76
|
+
on_generate()
|
|
77
|
+
return {"status": "ok"}
|
|
78
|
+
|
|
79
|
+
app.include_router(router)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["add_docs", "add_sdk_generation_stub"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
7
|
+
from starlette.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_probes(
|
|
11
|
+
app: FastAPI,
|
|
12
|
+
*,
|
|
13
|
+
prefix: str = "/_ops",
|
|
14
|
+
include_in_schema: bool = False,
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Mount basic liveness/readiness/startup probes under prefix."""
|
|
17
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
18
|
+
|
|
19
|
+
router = public_router(prefix=prefix, tags=["ops"], include_in_schema=include_in_schema)
|
|
20
|
+
|
|
21
|
+
@router.get("/live")
|
|
22
|
+
async def live() -> JSONResponse: # noqa: D401, ANN201
|
|
23
|
+
return JSONResponse({"status": "ok"})
|
|
24
|
+
|
|
25
|
+
@router.get("/ready")
|
|
26
|
+
async def ready() -> JSONResponse: # noqa: D401, ANN201
|
|
27
|
+
# In the future, add checks (DB ping, cache ping) via DI hooks.
|
|
28
|
+
return JSONResponse({"status": "ok"})
|
|
29
|
+
|
|
30
|
+
@router.get("/startup")
|
|
31
|
+
async def startup_probe() -> JSONResponse: # noqa: D401, ANN201
|
|
32
|
+
return JSONResponse({"status": "ok"})
|
|
33
|
+
|
|
34
|
+
app.include_router(router)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def add_maintenance_mode(app: FastAPI, *, env_var: str = "MAINTENANCE_MODE") -> None:
|
|
38
|
+
"""Enable a simple maintenance gate controlled by an env var.
|
|
39
|
+
|
|
40
|
+
When MAINTENANCE_MODE is truthy, all non-GET requests return 503.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
@app.middleware("http")
|
|
44
|
+
async def _maintenance_gate(request: Request, call_next): # noqa: ANN001, ANN202
|
|
45
|
+
flag = str(os.getenv(env_var, "")).lower() in {"1", "true", "yes", "on"}
|
|
46
|
+
if flag and request.method not in {"GET", "HEAD", "OPTIONS"}:
|
|
47
|
+
return JSONResponse({"detail": "maintenance"}, status_code=503)
|
|
48
|
+
return await call_next(request)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def circuit_breaker_dependency(limit: int = 100, window_seconds: int = 60) -> Callable:
|
|
52
|
+
"""Return a dependency that can trip rejective errors based on external metrics.
|
|
53
|
+
|
|
54
|
+
This is a placeholder; callers can swap with a provider that tracks failures and opens the
|
|
55
|
+
breaker. Here, we read an env var to simulate an open breaker.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
async def _dep(_: Request) -> None: # noqa: D401, ANN202
|
|
59
|
+
if str(os.getenv("CIRCUIT_OPEN", "")).lower() in {"1", "true", "yes", "on"}:
|
|
60
|
+
raise HTTPException(status_code=503, detail="circuit open")
|
|
61
|
+
|
|
62
|
+
return _dep
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ["add_probes", "add_maintenance_mode", "circuit_breaker_dependency"]
|
|
@@ -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
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Callable, Iterable, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
|
|
8
|
+
from svc_infra.cli.cmds.db.sql.alembic_cmds import cmd_setup_and_migrate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def add_data_lifecycle(
|
|
12
|
+
app: FastAPI,
|
|
13
|
+
*,
|
|
14
|
+
auto_migrate: bool = True,
|
|
15
|
+
database_url: str | None = None,
|
|
16
|
+
discover_packages: Optional[list[str]] = None,
|
|
17
|
+
with_payments: bool | None = None,
|
|
18
|
+
on_load_fixtures: Optional[Callable[[], None]] = None,
|
|
19
|
+
retention_jobs: Optional[Iterable[Callable[[], None]]] = None,
|
|
20
|
+
erasure_job: Optional[Callable[[str], None]] = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Wire data lifecycle conveniences:
|
|
24
|
+
|
|
25
|
+
- auto_migrate: run end-to-end Alembic setup-and-migrate on startup (idempotent).
|
|
26
|
+
- on_load_fixtures: optional callback to load reference/fixture data once at startup.
|
|
27
|
+
- retention_jobs: optional list of callables to register purge tasks (scheduler integration is external).
|
|
28
|
+
- erasure_job: optional callable to trigger a GDPR erasure workflow for a given principal ID.
|
|
29
|
+
|
|
30
|
+
This helper is intentionally minimal: it coordinates existing building blocks
|
|
31
|
+
and offers extension points. Jobs should be scheduled using svc_infra.jobs helpers.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
async def _run_lifecycle() -> None:
|
|
35
|
+
# Startup
|
|
36
|
+
if auto_migrate:
|
|
37
|
+
cmd_setup_and_migrate(
|
|
38
|
+
database_url=database_url,
|
|
39
|
+
overwrite_scaffold=False,
|
|
40
|
+
create_db_if_missing=True,
|
|
41
|
+
create_followup_revision=True,
|
|
42
|
+
initial_message="initial schema",
|
|
43
|
+
followup_message="autogen",
|
|
44
|
+
discover_packages=discover_packages,
|
|
45
|
+
with_payments=with_payments,
|
|
46
|
+
)
|
|
47
|
+
if on_load_fixtures:
|
|
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)
|
|
53
|
+
|
|
54
|
+
# Store optional jobs on app.state for external schedulers to discover/register.
|
|
55
|
+
if retention_jobs is not None:
|
|
56
|
+
app.state.data_retention_jobs = list(retention_jobs)
|
|
57
|
+
if erasure_job is not None:
|
|
58
|
+
app.state.data_erasure_job = erasure_job
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = ["add_data_lifecycle"]
|
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
|
svc_infra/dx/add.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def write_ci_workflow(
|
|
7
|
+
*,
|
|
8
|
+
target_dir: str | Path,
|
|
9
|
+
name: str = "ci.yml",
|
|
10
|
+
python_version: str = "3.12",
|
|
11
|
+
) -> Path:
|
|
12
|
+
"""Write a minimal CI workflow file (GitHub Actions) with tests/lint/type steps."""
|
|
13
|
+
p = Path(target_dir) / ".github" / "workflows" / name
|
|
14
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
content = f"""
|
|
16
|
+
name: CI
|
|
17
|
+
|
|
18
|
+
on:
|
|
19
|
+
push:
|
|
20
|
+
branches: [ main ]
|
|
21
|
+
pull_request:
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
build:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: '{python_version}'
|
|
31
|
+
- name: Install Poetry
|
|
32
|
+
run: pipx install poetry
|
|
33
|
+
- name: Install deps
|
|
34
|
+
run: poetry install
|
|
35
|
+
- name: Lint
|
|
36
|
+
run: poetry run flake8 --select=E,F
|
|
37
|
+
- name: Typecheck
|
|
38
|
+
run: poetry run mypy src
|
|
39
|
+
- name: Tests
|
|
40
|
+
run: poetry run pytest -q -W error
|
|
41
|
+
"""
|
|
42
|
+
p.write_text(content.strip() + "\n")
|
|
43
|
+
return p
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_openapi_lint_config(*, target_dir: str | Path, name: str = ".redocly.yaml") -> Path:
|
|
47
|
+
"""Write a minimal OpenAPI lint config placeholder (Redocly)."""
|
|
48
|
+
p = Path(target_dir) / name
|
|
49
|
+
content = """
|
|
50
|
+
apis:
|
|
51
|
+
main:
|
|
52
|
+
root: openapi.json
|
|
53
|
+
|
|
54
|
+
rules:
|
|
55
|
+
operation-operationId: warn
|
|
56
|
+
no-unused-components: warn
|
|
57
|
+
security-defined: off
|
|
58
|
+
"""
|
|
59
|
+
p.write_text(content.strip() + "\n")
|
|
60
|
+
return p
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["write_ci_workflow", "write_openapi_lint_config"]
|
|
@@ -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,12 +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=Ytk4iqWrNqPg3-SELJ5OUNuTieltpTBmIFOPKGnHVag,2563
|
|
59
60
|
svc_infra/api/fastapi/docs/landing.py,sha256=5JqJYCxQDCWy-BeDLfkv7OBlzWQKSGWUCYXQ51hojG8,4627
|
|
60
61
|
svc_infra/api/fastapi/docs/scoped.py,sha256=AuN35Op-9fUvHQCLOBtRjd5eWSpB5C9EAW_7-Boxmfo,7540
|
|
61
62
|
svc_infra/api/fastapi/dual/__init__.py,sha256=scHLcNFkGbgX_R21V702xnAv6GMCkQ4n7yUtNDNgliM,552
|
|
@@ -91,6 +92,7 @@ svc_infra/api/fastapi/openapi/mutators.py,sha256=KcWnJ3dsn_VdBG-x0ytddf_vwJ6u-84
|
|
|
91
92
|
svc_infra/api/fastapi/openapi/pipeline.py,sha256=GAf-qzwmWlYbrAlPirr8w89fEO4-kFrhCoeMj-7mE44,646
|
|
92
93
|
svc_infra/api/fastapi/openapi/responses.py,sha256=pBUoJd0lltBkQBJACS1Zd1wd975gbw6dYyMEqyueRuw,1093
|
|
93
94
|
svc_infra/api/fastapi/openapi/security.py,sha256=U78KMwgc7FilFPLbIE2f6xrp74hq6TDFXpUMGRyK_bg,1248
|
|
95
|
+
svc_infra/api/fastapi/ops/add.py,sha256=39puYLuJdZuIBkpmMiHF6N8H4D-96TRtFdYidbzqndI,2311
|
|
94
96
|
svc_infra/api/fastapi/pagination.py,sha256=770RbYyzgJotMA8gcc_y5zeAiP3em7fOHKuKDDlpOkI,10867
|
|
95
97
|
svc_infra/api/fastapi/paths/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
96
98
|
svc_infra/api/fastapi/paths/auth.py,sha256=hy9N0QFQnpv7dBOuHStui5eP9oIGHrawa0sADIVVD64,553
|
|
@@ -131,7 +133,7 @@ svc_infra/cli/cmds/db/nosql/mongo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeR
|
|
|
131
133
|
svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py,sha256=83_I0-63aRyR2uRLhpG1DKavH8BJ6fwdL3qpCpksyBU,6109
|
|
132
134
|
svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py,sha256=gsv7V3eGxyhQQm4J8IPYV9xQkdv0DoX7txcPLgbiejk,4277
|
|
133
135
|
svc_infra/cli/cmds/db/sql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
134
|
-
svc_infra/cli/cmds/db/sql/alembic_cmds.py,sha256=
|
|
136
|
+
svc_infra/cli/cmds/db/sql/alembic_cmds.py,sha256=HfnLsLJMug6llfitiwUvwdrXzcjRqrn-BVyJNcQDLFA,8519
|
|
135
137
|
svc_infra/cli/cmds/db/sql/sql_export_cmds.py,sha256=6MxoQO-9upoXg0cl1RHIqz96yXFVGidiBYp_ewhB0E0,2700
|
|
136
138
|
svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py,sha256=eNTCqHXOxgl9H3WTbGVn9BHXYwCpjIEJsDqhEFdrYMM,4613
|
|
137
139
|
svc_infra/cli/cmds/help.py,sha256=wGfZFMYaR2ZPwW2JwKDU7M3m4AtdCd8GRQ412AmEBUM,758
|
|
@@ -142,6 +144,11 @@ svc_infra/cli/cmds/obs/obs_cmds.py,sha256=fltUZu5fcnZdl0_JPJBIxIaA1Xqpw1BXE-SWBP
|
|
|
142
144
|
svc_infra/cli/foundation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
143
145
|
svc_infra/cli/foundation/runner.py,sha256=RbfjKwb3aHk1Y0MYU8xMpKRpIqRVMVr8GuL2EDZ6n38,1862
|
|
144
146
|
svc_infra/cli/foundation/typer_bootstrap.py,sha256=KapgH1R-qON9FuYH1KYlVx_5sJvjmAGl25pB61XCpm4,985
|
|
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
|
|
145
152
|
svc_infra/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
146
153
|
svc_infra/db/crud_schema.py,sha256=-fv-Om1lHVt6lcNbie6A2kRcPex4SDByUPfks6SpmUc,2521
|
|
147
154
|
svc_infra/db/inbox.py,sha256=drxLRLHaMRrCDgo_8wj12do80wDh5ssHV6LGkaM98no,1996
|
|
@@ -175,7 +182,7 @@ svc_infra/db/sql/base.py,sha256=yeOM4Xo2CtTzsfx0hN8KLFTgOPZp6BrbV7Omz_bZUMg,317
|
|
|
175
182
|
svc_infra/db/sql/constants.py,sha256=FqtoBWqP13gW8avIWpr8E41PDW5aQ88tjGmnokCV9Lk,1656
|
|
176
183
|
svc_infra/db/sql/core.py,sha256=2W7o1uyEZBDbqX1ptArP5Fa5obaVpZqHpczJCdf1NZk,10855
|
|
177
184
|
svc_infra/db/sql/management.py,sha256=bVpzj8BapwcghxAwmBsTnntWf3kaNf7CYETndppaiUM,3620
|
|
178
|
-
svc_infra/db/sql/repository.py,sha256=
|
|
185
|
+
svc_infra/db/sql/repository.py,sha256=B80mqW0Hjv1HmbnnDqHDliOHKSusRFoCtHiuaBC_9M0,7131
|
|
179
186
|
svc_infra/db/sql/resource.py,sha256=CfBmRvMq_1-cgYBS9uc_G4i1W5e6wk6zo4MGdKTPT9I,1194
|
|
180
187
|
svc_infra/db/sql/scaffold.py,sha256=aRC572W9Jqs1nLprR_zmmgERthMlJwoUjQ_xbRh53RU,9107
|
|
181
188
|
svc_infra/db/sql/service.py,sha256=Jtb89FQgXWNRrh_bPLCvNyM7Up0_rn_wx1_CGWfiqNs,2790
|
|
@@ -198,6 +205,7 @@ svc_infra/db/sql/uniq_hooks.py,sha256=6gCnO0_Y-rhB0p-VuY0mZ9m1u3haiLWI3Ns_iUTqF_
|
|
|
198
205
|
svc_infra/db/sql/utils.py,sha256=nzuDcDhnVNehx5Y9BZLgxw8fvpfYbxTfXQsgnznVf4w,32862
|
|
199
206
|
svc_infra/db/sql/versioning.py,sha256=okZu2ad5RAFXNLXJgGpcQvZ5bc6gPjRWzwiBT0rEJJw,400
|
|
200
207
|
svc_infra/db/utils.py,sha256=aTD49VJSEu319kIWJ1uijUoP51co4lNJ3S0_tvuyGio,802
|
|
208
|
+
svc_infra/dx/add.py,sha256=FAnLGP0BPm_q_VCEcpUwfj-b0mEse988chh9DHeS7GU,1474
|
|
201
209
|
svc_infra/jobs/builtins/outbox_processor.py,sha256=VZoehNyjdaV_MmV74WMcbZR6z9E3VFMtZC-pxEwK0x0,1247
|
|
202
210
|
svc_infra/jobs/builtins/webhook_delivery.py,sha256=z_cl6YKwnduGjGaB8ZoUpKhFcEAhUZqqBma8v2FO1so,2982
|
|
203
211
|
svc_infra/jobs/easy.py,sha256=eix-OxWeE3vdkY3GGNoYM0GAyOxc928SpiSzMkr9k0A,977
|
|
@@ -274,7 +282,7 @@ svc_infra/webhooks/fastapi.py,sha256=BCNvGNxukf6dC2a4i-6en-PrjBGV19YvCWOot5lXWsA
|
|
|
274
282
|
svc_infra/webhooks/router.py,sha256=6JvAVPMEth_xxHX-IsIOcyMgHX7g1H0OVxVXKLuMp9w,1596
|
|
275
283
|
svc_infra/webhooks/service.py,sha256=hWgiJRXKBwKunJOx91C7EcLUkotDtD3Xp0RT6vj2IC0,1797
|
|
276
284
|
svc_infra/webhooks/signing.py,sha256=NCwdZzmravUe7HVIK_uXK0qqf12FG-_MVsgPvOw6lsM,784
|
|
277
|
-
svc_infra-0.1.
|
|
278
|
-
svc_infra-0.1.
|
|
279
|
-
svc_infra-0.1.
|
|
280
|
-
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
|