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.

@@ -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[read_schema].from_items(
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
- base = service_factory # consumer-provided factory or instance
198
- svc = base # assume already a SqlService by default
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[read_schema].from_items(
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"]
@@ -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"]
@@ -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
- if hasattr(self.model, self.soft_delete_field):
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(self.model, self.soft_delete_flag_field):
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.601
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=t4SYCYfzLIAnCXFfR2xDmIHjAI8septLd_wUBKFF1Xg,10816
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=KjumtKSOZR1UxbpZUuqllpknerDLNcY-0kqqqxiOnL4,7664
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=SG3cBf0BUnfds47BuOWjZJiFtrd2SmRkMINGioUhVn0,7047
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.601.dist-info/METADATA,sha256=--fXg5XHvGu56tmJ-UArjNr8FjrvQ9d-75Bwww4i3bI,7839
278
- svc_infra-0.1.601.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
279
- svc_infra-0.1.601.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
280
- svc_infra-0.1.601.dist-info/RECORD,,
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,,