svc-infra 0.1.706__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (227) hide show
  1. svc_infra/apf_payments/models.py +47 -108
  2. svc_infra/apf_payments/provider/__init__.py +2 -2
  3. svc_infra/apf_payments/provider/aiydan.py +42 -100
  4. svc_infra/apf_payments/provider/base.py +10 -26
  5. svc_infra/apf_payments/provider/registry.py +3 -5
  6. svc_infra/apf_payments/provider/stripe.py +63 -135
  7. svc_infra/apf_payments/schemas.py +82 -90
  8. svc_infra/apf_payments/service.py +40 -86
  9. svc_infra/apf_payments/settings.py +10 -13
  10. svc_infra/api/__init__.py +13 -13
  11. svc_infra/api/fastapi/__init__.py +19 -0
  12. svc_infra/api/fastapi/admin/add.py +13 -18
  13. svc_infra/api/fastapi/apf_payments/router.py +47 -84
  14. svc_infra/api/fastapi/apf_payments/setup.py +7 -13
  15. svc_infra/api/fastapi/auth/__init__.py +1 -1
  16. svc_infra/api/fastapi/auth/_cookies.py +3 -9
  17. svc_infra/api/fastapi/auth/add.py +4 -8
  18. svc_infra/api/fastapi/auth/gaurd.py +9 -26
  19. svc_infra/api/fastapi/auth/mfa/models.py +4 -7
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
  21. svc_infra/api/fastapi/auth/mfa/router.py +9 -15
  22. svc_infra/api/fastapi/auth/mfa/security.py +3 -5
  23. svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
  24. svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
  25. svc_infra/api/fastapi/auth/providers.py +4 -6
  26. svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
  27. svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
  28. svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
  29. svc_infra/api/fastapi/auth/security.py +17 -28
  30. svc_infra/api/fastapi/auth/sender.py +1 -3
  31. svc_infra/api/fastapi/auth/settings.py +18 -19
  32. svc_infra/api/fastapi/auth/state.py +6 -7
  33. svc_infra/api/fastapi/auth/ws_security.py +2 -2
  34. svc_infra/api/fastapi/billing/router.py +6 -8
  35. svc_infra/api/fastapi/db/http.py +10 -11
  36. svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
  37. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
  38. svc_infra/api/fastapi/db/sql/add.py +6 -14
  39. svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
  40. svc_infra/api/fastapi/db/sql/health.py +1 -3
  41. svc_infra/api/fastapi/db/sql/session.py +4 -5
  42. svc_infra/api/fastapi/db/sql/users.py +8 -11
  43. svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
  44. svc_infra/api/fastapi/docs/add.py +13 -23
  45. svc_infra/api/fastapi/docs/landing.py +6 -8
  46. svc_infra/api/fastapi/docs/scoped.py +34 -42
  47. svc_infra/api/fastapi/dual/dualize.py +1 -1
  48. svc_infra/api/fastapi/dual/protected.py +12 -21
  49. svc_infra/api/fastapi/dual/router.py +14 -31
  50. svc_infra/api/fastapi/ease.py +57 -13
  51. svc_infra/api/fastapi/http/conditional.py +3 -5
  52. svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
  53. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
  54. svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
  55. svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
  56. svc_infra/api/fastapi/middleware/idempotency.py +11 -16
  57. svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
  58. svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
  59. svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
  60. svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
  61. svc_infra/api/fastapi/middleware/request_id.py +1 -3
  62. svc_infra/api/fastapi/middleware/timeout.py +9 -10
  63. svc_infra/api/fastapi/object_router.py +1060 -0
  64. svc_infra/api/fastapi/openapi/apply.py +5 -6
  65. svc_infra/api/fastapi/openapi/conventions.py +4 -4
  66. svc_infra/api/fastapi/openapi/mutators.py +13 -31
  67. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  68. svc_infra/api/fastapi/openapi/responses.py +4 -6
  69. svc_infra/api/fastapi/openapi/security.py +1 -3
  70. svc_infra/api/fastapi/ops/add.py +7 -9
  71. svc_infra/api/fastapi/pagination.py +25 -37
  72. svc_infra/api/fastapi/routers/__init__.py +16 -38
  73. svc_infra/api/fastapi/setup.py +13 -31
  74. svc_infra/api/fastapi/tenancy/add.py +3 -2
  75. svc_infra/api/fastapi/tenancy/context.py +8 -7
  76. svc_infra/api/fastapi/versioned.py +3 -2
  77. svc_infra/app/env.py +5 -7
  78. svc_infra/app/logging/add.py +2 -1
  79. svc_infra/app/logging/filter.py +1 -1
  80. svc_infra/app/logging/formats.py +3 -2
  81. svc_infra/app/root.py +3 -3
  82. svc_infra/billing/__init__.py +19 -2
  83. svc_infra/billing/async_service.py +27 -7
  84. svc_infra/billing/jobs.py +23 -33
  85. svc_infra/billing/models.py +21 -52
  86. svc_infra/billing/quotas.py +5 -7
  87. svc_infra/billing/schemas.py +4 -6
  88. svc_infra/cache/__init__.py +12 -5
  89. svc_infra/cache/add.py +6 -9
  90. svc_infra/cache/backend.py +6 -5
  91. svc_infra/cache/decorators.py +17 -28
  92. svc_infra/cache/keys.py +2 -2
  93. svc_infra/cache/recache.py +22 -35
  94. svc_infra/cache/resources.py +8 -16
  95. svc_infra/cache/ttl.py +2 -3
  96. svc_infra/cache/utils.py +5 -6
  97. svc_infra/cli/__init__.py +4 -12
  98. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
  99. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
  100. svc_infra/cli/cmds/db/ops_cmds.py +3 -6
  101. svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
  102. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
  103. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
  104. svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
  105. svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
  106. svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
  107. svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
  108. svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
  109. svc_infra/cli/foundation/runner.py +6 -11
  110. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  111. svc_infra/data/__init__.py +83 -0
  112. svc_infra/data/add.py +5 -5
  113. svc_infra/data/backup.py +8 -10
  114. svc_infra/data/erasure.py +3 -2
  115. svc_infra/data/fixtures.py +3 -3
  116. svc_infra/data/retention.py +8 -13
  117. svc_infra/db/crud_schema.py +9 -8
  118. svc_infra/db/nosql/__init__.py +0 -1
  119. svc_infra/db/nosql/constants.py +1 -1
  120. svc_infra/db/nosql/core.py +7 -14
  121. svc_infra/db/nosql/indexes.py +11 -10
  122. svc_infra/db/nosql/management.py +3 -3
  123. svc_infra/db/nosql/mongo/client.py +3 -3
  124. svc_infra/db/nosql/mongo/settings.py +2 -6
  125. svc_infra/db/nosql/repository.py +27 -28
  126. svc_infra/db/nosql/resource.py +15 -20
  127. svc_infra/db/nosql/scaffold.py +13 -17
  128. svc_infra/db/nosql/service.py +3 -4
  129. svc_infra/db/nosql/service_with_hooks.py +4 -3
  130. svc_infra/db/nosql/types.py +2 -6
  131. svc_infra/db/nosql/utils.py +4 -4
  132. svc_infra/db/ops.py +14 -18
  133. svc_infra/db/outbox.py +15 -18
  134. svc_infra/db/sql/apikey.py +12 -21
  135. svc_infra/db/sql/authref.py +3 -7
  136. svc_infra/db/sql/constants.py +9 -9
  137. svc_infra/db/sql/core.py +11 -11
  138. svc_infra/db/sql/management.py +2 -6
  139. svc_infra/db/sql/repository.py +17 -24
  140. svc_infra/db/sql/resource.py +14 -13
  141. svc_infra/db/sql/scaffold.py +13 -17
  142. svc_infra/db/sql/service.py +7 -16
  143. svc_infra/db/sql/service_with_hooks.py +4 -3
  144. svc_infra/db/sql/tenant.py +6 -14
  145. svc_infra/db/sql/uniq.py +8 -7
  146. svc_infra/db/sql/uniq_hooks.py +14 -19
  147. svc_infra/db/sql/utils.py +24 -53
  148. svc_infra/db/utils.py +3 -3
  149. svc_infra/deploy/__init__.py +8 -15
  150. svc_infra/documents/add.py +7 -8
  151. svc_infra/documents/ease.py +8 -8
  152. svc_infra/documents/models.py +3 -3
  153. svc_infra/documents/storage.py +11 -13
  154. svc_infra/dx/__init__.py +58 -0
  155. svc_infra/dx/add.py +1 -3
  156. svc_infra/dx/changelog.py +2 -2
  157. svc_infra/dx/checks.py +1 -1
  158. svc_infra/health/__init__.py +15 -16
  159. svc_infra/http/client.py +10 -14
  160. svc_infra/jobs/__init__.py +79 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +3 -5
  162. svc_infra/jobs/builtins/webhook_delivery.py +1 -3
  163. svc_infra/jobs/loader.py +4 -5
  164. svc_infra/jobs/queue.py +14 -24
  165. svc_infra/jobs/redis_queue.py +20 -34
  166. svc_infra/jobs/runner.py +7 -11
  167. svc_infra/jobs/scheduler.py +5 -5
  168. svc_infra/jobs/worker.py +1 -1
  169. svc_infra/loaders/base.py +5 -4
  170. svc_infra/loaders/github.py +1 -3
  171. svc_infra/loaders/url.py +3 -9
  172. svc_infra/logging/__init__.py +7 -6
  173. svc_infra/mcp/__init__.py +82 -0
  174. svc_infra/mcp/svc_infra_mcp.py +2 -2
  175. svc_infra/obs/add.py +4 -3
  176. svc_infra/obs/cloud_dash.py +1 -1
  177. svc_infra/obs/metrics/__init__.py +3 -3
  178. svc_infra/obs/metrics/asgi.py +9 -14
  179. svc_infra/obs/metrics/base.py +13 -13
  180. svc_infra/obs/metrics/http.py +5 -9
  181. svc_infra/obs/metrics/sqlalchemy.py +9 -12
  182. svc_infra/obs/metrics.py +3 -3
  183. svc_infra/obs/settings.py +2 -6
  184. svc_infra/resilience/__init__.py +44 -0
  185. svc_infra/resilience/circuit_breaker.py +328 -0
  186. svc_infra/resilience/retry.py +289 -0
  187. svc_infra/security/__init__.py +167 -0
  188. svc_infra/security/add.py +5 -9
  189. svc_infra/security/audit.py +14 -17
  190. svc_infra/security/audit_service.py +9 -9
  191. svc_infra/security/hibp.py +3 -6
  192. svc_infra/security/jwt_rotation.py +7 -10
  193. svc_infra/security/lockout.py +12 -11
  194. svc_infra/security/models.py +37 -46
  195. svc_infra/security/oauth_models.py +8 -8
  196. svc_infra/security/org_invites.py +11 -13
  197. svc_infra/security/passwords.py +4 -6
  198. svc_infra/security/permissions.py +8 -7
  199. svc_infra/security/session.py +6 -7
  200. svc_infra/security/signed_cookies.py +9 -9
  201. svc_infra/storage/add.py +5 -8
  202. svc_infra/storage/backends/local.py +13 -21
  203. svc_infra/storage/backends/memory.py +4 -7
  204. svc_infra/storage/backends/s3.py +17 -36
  205. svc_infra/storage/base.py +2 -2
  206. svc_infra/storage/easy.py +4 -8
  207. svc_infra/storage/settings.py +16 -18
  208. svc_infra/testing/__init__.py +36 -39
  209. svc_infra/utils.py +169 -8
  210. svc_infra/webhooks/__init__.py +1 -1
  211. svc_infra/webhooks/add.py +17 -29
  212. svc_infra/webhooks/encryption.py +2 -2
  213. svc_infra/webhooks/fastapi.py +2 -4
  214. svc_infra/webhooks/router.py +3 -3
  215. svc_infra/webhooks/service.py +5 -6
  216. svc_infra/webhooks/signing.py +5 -5
  217. svc_infra/websocket/add.py +2 -3
  218. svc_infra/websocket/client.py +3 -2
  219. svc_infra/websocket/config.py +6 -18
  220. svc_infra/websocket/manager.py +9 -10
  221. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
  222. svc_infra-1.1.0.dist-info/RECORD +364 -0
  223. svc_infra/billing/service.py +0 -123
  224. svc_infra-0.1.706.dist-info/RECORD +0 -357
  225. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
  226. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  227. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- from typing import Optional
5
4
 
6
5
  import typer
7
6
 
@@ -14,12 +13,8 @@ app = typer.Typer(help="Background jobs and scheduler commands")
14
13
 
15
14
  @app.command("run")
16
15
  def run(
17
- poll_interval: float = typer.Option(
18
- 0.5, help="Sleep seconds between loops when idle"
19
- ),
20
- max_loops: Optional[int] = typer.Option(
21
- None, help="Max loops before exit (for tests)"
22
- ),
16
+ poll_interval: float = typer.Option(0.5, help="Sleep seconds between loops when idle"),
17
+ max_loops: int | None = typer.Option(None, help="Max loops before exit (for tests)"),
23
18
  ):
24
19
  """Run scheduler ticks and process jobs in a simple loop."""
25
20
 
@@ -3,8 +3,9 @@ from __future__ import annotations
3
3
  import os
4
4
  import socket
5
5
  import subprocess
6
+ from collections.abc import Callable
6
7
  from pathlib import Path
7
- from typing import Any, Callable
8
+ from typing import Any
8
9
  from urllib.parse import urlparse
9
10
 
10
11
  import typer
@@ -29,9 +30,7 @@ def _run(cmd: list[str], *, env: dict | None = None):
29
30
  def _emit_local_stack(root: Path, metrics_url: str):
30
31
  write(
31
32
  root / "docker-compose.yml",
32
- render_template(
33
- "svc_infra.obs.providers.grafana.templates", "docker-compose.yml.tmpl", {}
34
- ),
33
+ render_template("svc_infra.obs.providers.grafana.templates", "docker-compose.yml.tmpl", {}),
35
34
  )
36
35
  p = urlparse(metrics_url)
37
36
  prom_yml = render_template(
@@ -116,9 +115,7 @@ def up():
116
115
 
117
116
  root = Path(".obs")
118
117
  root.mkdir(exist_ok=True)
119
- metrics_url = os.getenv(
120
- "SVC_INFRA_METRICS_URL", "http://host.docker.internal:8000/metrics"
121
- )
118
+ metrics_url = os.getenv("SVC_INFRA_METRICS_URL", "http://host.docker.internal:8000/metrics")
122
119
 
123
120
  cloud_url = os.getenv("GRAFANA_CLOUD_URL", "").strip()
124
121
  cloud_token = os.getenv("GRAFANA_CLOUD_TOKEN", "").strip()
@@ -4,9 +4,7 @@ import subprocess
4
4
 
5
5
  import typer
6
6
 
7
- app = typer.Typer(
8
- no_args_is_help=True, add_completion=False, help="Generate SDKs from OpenAPI."
9
- )
7
+ app = typer.Typer(no_args_is_help=True, add_completion=False, help="Generate SDKs from OpenAPI.")
10
8
 
11
9
 
12
10
  def _echo(cmd: list[str]):
@@ -30,9 +28,7 @@ def _parse_bool(val: str | bool | None, default: bool = True) -> bool:
30
28
  def sdk_ts(
31
29
  openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
32
30
  outdir: str = typer.Option("sdk-ts", help="Output directory"),
33
- dry_run: str = typer.Option(
34
- "true", help="Print commands instead of running (true/false)"
35
- ),
31
+ dry_run: str = typer.Option("true", help="Print commands instead of running (true/false)"),
36
32
  ):
37
33
  """Generate a TypeScript SDK (openapi-typescript-codegen as default)."""
38
34
  cmd = [
@@ -55,9 +51,7 @@ def sdk_py(
55
51
  openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
56
52
  outdir: str = typer.Option("sdk-py", help="Output directory"),
57
53
  package_name: str = typer.Option("client_sdk", help="Python package name"),
58
- dry_run: str = typer.Option(
59
- "true", help="Print commands instead of running (true/false)"
60
- ),
54
+ dry_run: str = typer.Option("true", help="Print commands instead of running (true/false)"),
61
55
  ):
62
56
  """Generate a Python SDK via openapi-generator-cli with "python" generator."""
63
57
  cmd = [
@@ -84,12 +78,8 @@ def sdk_py(
84
78
  @app.command("postman")
85
79
  def sdk_postman(
86
80
  openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
87
- out: str = typer.Option(
88
- "postman_collection.json", help="Output Postman collection"
89
- ),
90
- dry_run: str = typer.Option(
91
- "true", help="Print commands instead of running (true/false)"
92
- ),
81
+ out: str = typer.Option("postman_collection.json", help="Output Postman collection"),
82
+ dry_run: str = typer.Option("true", help="Print commands instead of running (true/false)"),
93
83
  ):
94
84
  """Convert OpenAPI to a Postman collection via openapi-to-postmanv2."""
95
85
  cmd = [
@@ -3,21 +3,20 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import shutil
5
5
  from pathlib import Path
6
- from typing import List, Optional
7
6
 
8
7
 
9
8
  def _has_poetry(root: Path) -> bool:
10
9
  return (root / "pyproject.toml").exists() and bool(shutil.which("poetry"))
11
10
 
12
11
 
13
- def candidate_cmds(root: Path, prog: str, argv: List[str]) -> List[List[str]]:
12
+ def candidate_cmds(root: Path, prog: str, argv: list[str]) -> list[list[str]]:
14
13
  """
15
14
  Return argv lists to try in order:
16
15
  1) poetry run <prog> ...
17
16
  2) <prog> ...
18
17
  3) python -m <module> ...
19
18
  """
20
- cmds: List[List[str]] = []
19
+ cmds: list[list[str]] = []
21
20
  if _has_poetry(root):
22
21
  cmds.append(["poetry", "run", prog, *argv])
23
22
 
@@ -25,20 +24,18 @@ def candidate_cmds(root: Path, prog: str, argv: List[str]) -> List[List[str]]:
25
24
  cmds.append([prog, *argv])
26
25
 
27
26
  py = shutil.which("python3") or shutil.which("python") or "python"
28
- module = (
29
- prog.replace("-", "_") + ".cli_shim"
30
- ) # e.g., svc-infra -> svc_infra.cli_shim
27
+ module = prog.replace("-", "_") + ".cli_shim" # e.g., svc-infra -> svc_infra.cli_shim
31
28
  cmds.append([py, "-m", module, *argv])
32
29
 
33
30
  return cmds
34
31
 
35
32
 
36
- async def run_from_root(root: Path, prog: str, argv: List[str]) -> str:
33
+ async def run_from_root(root: Path, prog: str, argv: list[str]) -> str:
37
34
  """
38
35
  cd to project root and run the first working candidate command.
39
36
  Returns captured stdout+stderr text; raises on total failure.
40
37
  """
41
- last_exc: Optional[BaseException] = None
38
+ last_exc: BaseException | None = None
42
39
  for cmd in candidate_cmds(root, prog, argv):
43
40
  try:
44
41
  proc = await asyncio.create_subprocess_exec(
@@ -56,6 +53,4 @@ async def run_from_root(root: Path, prog: str, argv: List[str]) -> str:
56
53
  except Exception as e:
57
54
  last_exc = e
58
55
  continue
59
- raise RuntimeError(
60
- f"All runners failed in {root} for: {prog} {' '.join(argv)}"
61
- ) from last_exc
56
+ raise RuntimeError(f"All runners failed in {root} for: {prog} {' '.join(argv)}") from last_exc
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
- from typing import Optional
5
4
 
6
5
  import typer
7
6
 
@@ -12,7 +11,7 @@ from svc_infra.app.root import resolve_project_root
12
11
  def pre_cli(app: typer.Typer) -> None:
13
12
  @app.callback()
14
13
  def _bootstrap(
15
- env_file: Optional[Path] = typer.Option(
14
+ env_file: Path | None = typer.Option(
16
15
  None,
17
16
  "--env-file",
18
17
  dir_okay=False,
@@ -0,0 +1,83 @@
1
+ """Data lifecycle module for backup verification, retention, and GDPR erasure.
2
+
3
+ This module provides data lifecycle management primitives:
4
+
5
+ - **add_data_lifecycle**: FastAPI integration for auto-migration and fixtures
6
+ - **Backup**: Backup health verification utilities
7
+ - **Retention**: Data retention policies and purge execution
8
+ - **Erasure**: GDPR-compliant data erasure workflows
9
+ - **Fixtures**: Fixture loading with run-once semantics
10
+
11
+ Example:
12
+ from fastapi import FastAPI
13
+ from svc_infra.data import add_data_lifecycle, make_on_load_fixtures
14
+
15
+ app = FastAPI()
16
+
17
+ # Enable auto-migration and fixture loading
18
+ add_data_lifecycle(
19
+ app,
20
+ auto_migrate=True,
21
+ on_load_fixtures=make_on_load_fixtures(load_seed_data),
22
+ )
23
+
24
+ # Define retention policies
25
+ from svc_infra.data import RetentionPolicy, run_retention_purge
26
+
27
+ policies = [
28
+ RetentionPolicy(name="old_logs", model=AuditLog, older_than_days=90),
29
+ RetentionPolicy(name="expired_tokens", model=RefreshToken, older_than_days=30),
30
+ ]
31
+
32
+ # Run in a scheduled job
33
+ affected = await run_retention_purge(session, policies)
34
+
35
+ # GDPR erasure
36
+ from svc_infra.data import ErasurePlan, ErasureStep, run_erasure
37
+
38
+ plan = ErasurePlan(steps=[
39
+ ErasureStep(name="anonymize_user", run=anonymize_user_data),
40
+ ErasureStep(name="delete_logs", run=delete_user_logs),
41
+ ])
42
+ await run_erasure(session, principal_id="user_123", plan=plan)
43
+
44
+ See Also:
45
+ - docs/data-lifecycle.md for detailed documentation
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ # FastAPI integration
51
+ from .add import add_data_lifecycle
52
+
53
+ # Backup verification
54
+ from .backup import BackupHealthReport, make_backup_verification_job, verify_backups
55
+
56
+ # GDPR erasure
57
+ from .erasure import ErasurePlan, ErasureStep, run_erasure
58
+
59
+ # Fixture loading
60
+ from .fixtures import make_on_load_fixtures, run_fixtures
61
+
62
+ # Retention policies
63
+ from .retention import RetentionPolicy, purge_policy, run_retention_purge
64
+
65
+ __all__ = [
66
+ # FastAPI integration
67
+ "add_data_lifecycle",
68
+ # Backup
69
+ "BackupHealthReport",
70
+ "verify_backups",
71
+ "make_backup_verification_job",
72
+ # Retention
73
+ "RetentionPolicy",
74
+ "purge_policy",
75
+ "run_retention_purge",
76
+ # Erasure
77
+ "ErasureStep",
78
+ "ErasurePlan",
79
+ "run_erasure",
80
+ # Fixtures
81
+ "run_fixtures",
82
+ "make_on_load_fixtures",
83
+ ]
svc_infra/data/add.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
- from typing import Callable, Iterable, Optional
4
+ from collections.abc import Callable, Iterable
5
5
 
6
6
  from fastapi import FastAPI
7
7
 
@@ -13,11 +13,11 @@ def add_data_lifecycle(
13
13
  *,
14
14
  auto_migrate: bool = True,
15
15
  database_url: str | None = None,
16
- discover_packages: Optional[list[str]] = None,
16
+ discover_packages: list[str] | None = None,
17
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,
18
+ on_load_fixtures: Callable[[], None] | None = None,
19
+ retention_jobs: Iterable[Callable[[], None]] | None = None,
20
+ erasure_job: Callable[[str], None] | None = None,
21
21
  ) -> None:
22
22
  """
23
23
  Wire data lifecycle conveniences:
svc_infra/data/backup.py CHANGED
@@ -1,20 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Callable
3
4
  from dataclasses import dataclass
4
- from datetime import datetime, timezone
5
- from typing import Callable, Optional
5
+ from datetime import UTC, datetime
6
6
 
7
7
 
8
8
  @dataclass(frozen=True)
9
9
  class BackupHealthReport:
10
10
  ok: bool
11
- last_success: Optional[datetime]
12
- retention_days: Optional[int]
11
+ last_success: datetime | None
12
+ retention_days: int | None
13
13
  message: str = ""
14
14
 
15
15
 
16
16
  def verify_backups(
17
- *, last_success: Optional[datetime] = None, retention_days: Optional[int] = None
17
+ *, last_success: datetime | None = None, retention_days: int | None = None
18
18
  ) -> BackupHealthReport:
19
19
  """Return a basic backup health report.
20
20
 
@@ -27,12 +27,10 @@ def verify_backups(
27
27
  retention_days=retention_days,
28
28
  message="no_backup_seen",
29
29
  )
30
- now = datetime.now(timezone.utc)
30
+ now = datetime.now(UTC)
31
31
  age_days = (now - last_success).total_seconds() / 86400.0
32
32
  ok = retention_days is None or age_days <= max(1, retention_days)
33
- return BackupHealthReport(
34
- ok=ok, last_success=last_success, retention_days=retention_days
35
- )
33
+ return BackupHealthReport(ok=ok, last_success=last_success, retention_days=retention_days)
36
34
 
37
35
 
38
36
  __all__ = ["BackupHealthReport", "verify_backups"]
@@ -41,7 +39,7 @@ __all__ = ["BackupHealthReport", "verify_backups"]
41
39
  def make_backup_verification_job(
42
40
  checker: Callable[[], BackupHealthReport],
43
41
  *,
44
- on_report: Optional[Callable[[BackupHealthReport], None]] = None,
42
+ on_report: Callable[[BackupHealthReport], None] | None = None,
45
43
  ):
46
44
  """Return a callable suitable for scheduling in a job runner.
47
45
 
svc_infra/data/erasure.py CHANGED
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Awaitable, Callable, Iterable
3
4
  from dataclasses import dataclass
4
- from typing import Any, Awaitable, Callable, Iterable, Optional, Protocol
5
+ from typing import Any, Protocol
5
6
 
6
7
 
7
8
  class SqlSession(Protocol): # minimal protocol for tests/integration
@@ -25,7 +26,7 @@ async def run_erasure(
25
26
  principal_id: str,
26
27
  plan: ErasurePlan,
27
28
  *,
28
- on_audit: Optional[Callable[[str, dict[str, Any]], None]] = None,
29
+ on_audit: Callable[[str, dict[str, Any]], None] | None = None,
29
30
  ) -> int:
30
31
  """Run an erasure plan and optionally emit an audit event.
31
32
 
@@ -1,14 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
+ from collections.abc import Awaitable, Callable, Iterable
4
5
  from pathlib import Path
5
- from typing import Awaitable, Callable, Iterable, Optional
6
6
 
7
7
 
8
8
  async def run_fixtures(
9
9
  loaders: Iterable[Callable[[], None | Awaitable[None]]],
10
10
  *,
11
- run_once_file: Optional[str] = None,
11
+ run_once_file: str | None = None,
12
12
  ) -> None:
13
13
  """Run a sequence of fixture loaders (sync or async).
14
14
 
@@ -29,7 +29,7 @@ async def run_fixtures(
29
29
 
30
30
 
31
31
  def make_on_load_fixtures(
32
- *loaders: Callable[[], None | Awaitable[None]], run_once_file: Optional[str] = None
32
+ *loaders: Callable[[], None | Awaitable[None]], run_once_file: str | None = None
33
33
  ) -> Callable[[], Awaitable[None]]:
34
34
  """Return an async callable suitable for add_data_lifecycle(on_load_fixtures=...)."""
35
35
 
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterable, Sequence
3
4
  from dataclasses import dataclass
4
- from datetime import datetime, timedelta, timezone
5
- from typing import Any, Iterable, Optional, Protocol, Sequence
5
+ from datetime import UTC, datetime, timedelta
6
+ from typing import Any, Protocol
6
7
 
7
8
 
8
9
  class SqlSession(Protocol): # minimal protocol for tests/integration
@@ -15,8 +16,8 @@ class RetentionPolicy:
15
16
  name: str
16
17
  model: Any # SQLAlchemy model or test double exposing columns
17
18
  older_than_days: int
18
- soft_delete_field: Optional[str] = "deleted_at"
19
- extra_where: Optional[Sequence[Any]] = None
19
+ soft_delete_field: str | None = "deleted_at"
20
+ extra_where: Sequence[Any] | None = None
20
21
  hard_delete: bool = False
21
22
 
22
23
 
@@ -26,7 +27,7 @@ async def purge_policy(session: SqlSession, policy: RetentionPolicy) -> int:
26
27
  If hard_delete is False and soft_delete_field exists on model, set timestamp; else DELETE.
27
28
  Returns number of affected rows (best-effort; test doubles may return an int directly).
28
29
  """
29
- cutoff = datetime.now(timezone.utc) - timedelta(days=policy.older_than_days)
30
+ cutoff = datetime.now(UTC) - timedelta(days=policy.older_than_days)
30
31
  m = policy.model
31
32
  where = list(policy.extra_where or [])
32
33
  created_col = getattr(m, "created_at", None)
@@ -34,11 +35,7 @@ async def purge_policy(session: SqlSession, policy: RetentionPolicy) -> int:
34
35
  where.append(created_col <= cutoff)
35
36
 
36
37
  # Soft-delete path when available and requested
37
- if (
38
- not policy.hard_delete
39
- and policy.soft_delete_field
40
- and hasattr(m, policy.soft_delete_field)
41
- ):
38
+ if not policy.hard_delete and policy.soft_delete_field and hasattr(m, policy.soft_delete_field):
42
39
  stmt = m.update().where(*where).values({policy.soft_delete_field: cutoff})
43
40
  res = await session.execute(stmt)
44
41
  return getattr(res, "rowcount", 0)
@@ -49,9 +46,7 @@ async def purge_policy(session: SqlSession, policy: RetentionPolicy) -> int:
49
46
  return getattr(res, "rowcount", 0)
50
47
 
51
48
 
52
- async def run_retention_purge(
53
- session: SqlSession, policies: Iterable[RetentionPolicy]
54
- ) -> int:
49
+ async def run_retention_purge(session: SqlSession, policies: Iterable[RetentionPolicy]) -> int:
55
50
  total = 0
56
51
  for p in policies:
57
52
  total += await purge_policy(session, p)
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Sequence
3
4
  from dataclasses import dataclass
4
- from typing import Any, Optional, Sequence, cast
5
+ from typing import Any, cast
5
6
 
6
7
  from pydantic import BaseModel, ConfigDict, create_model
7
8
 
@@ -35,10 +36,10 @@ def _opt(t: type[Any]) -> tuple[Any, Any]:
35
36
  def make_crud_schemas_from_specs(
36
37
  *,
37
38
  specs: Sequence[FieldSpec],
38
- read_name: Optional[str],
39
- create_name: Optional[str],
40
- update_name: Optional[str],
41
- json_encoders: Optional[dict[type[Any], Any]] = None,
39
+ read_name: str | None,
40
+ create_name: str | None,
41
+ update_name: str | None,
42
+ json_encoders: dict[type[Any], Any] | None = None,
42
43
  ) -> tuple[type[BaseModel], type[BaseModel], type[BaseModel]]:
43
44
  ann_read: dict[str, tuple[Any, Any]] = {}
44
45
  ann_create: dict[str, tuple[Any, Any]] = {}
@@ -60,9 +61,9 @@ def make_crud_schemas_from_specs(
60
61
  if not s.exclude_from_update:
61
62
  ann_update[s.name] = _opt(s.typ)
62
63
 
63
- Read = create_model(read_name or "Read", **cast(dict[str, Any], ann_read))
64
- Create = create_model(create_name or "Create", **cast(dict[str, Any], ann_create))
65
- Update = create_model(update_name or "Update", **cast(dict[str, Any], ann_update))
64
+ Read = create_model(read_name or "Read", **cast("dict[str, Any]", ann_read))
65
+ Create = create_model(create_name or "Create", **cast("dict[str, Any]", ann_create))
66
+ Update = create_model(update_name or "Update", **cast("dict[str, Any]", ann_update))
66
67
 
67
68
  cfg = ConfigDict(from_attributes=True)
68
69
  if json_encoders:
@@ -4,7 +4,6 @@ from svc_infra.db.nosql.resource import NoSqlResource
4
4
 
5
5
  from .repository import NoSqlRepository
6
6
 
7
-
8
7
  __all__ = [
9
8
  "NoSqlResource",
10
9
  "NoSqlRepository",
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Sequence
3
+ from collections.abc import Sequence
4
4
 
5
5
  # Environment variable names to look up for Mongo URL
6
6
  DEFAULT_MONGO_ENV_VARS: Sequence[str] = (
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterable, Sequence
3
4
  from dataclasses import dataclass
4
- from typing import Any, Iterable, Sequence, cast
5
+ from typing import Any, cast
5
6
 
6
7
  try:
7
8
  from motor.motor_asyncio import AsyncIOMotorDatabase
@@ -47,7 +48,7 @@ async def _apply_indexes(
47
48
  if not indexes:
48
49
  return []
49
50
  result = await db[collection].create_indexes(list(indexes))
50
- return cast(list[str], result)
51
+ return cast("list[str]", result)
51
52
 
52
53
 
53
54
  # collection + doc used to "lock" the chosen DB name for this app
@@ -61,9 +62,7 @@ async def assert_db_locked(
61
62
  registry = db.client.get_database(_REG_DB)
62
63
  await registry[_REG_COLL].create_index("service_id", unique=True)
63
64
 
64
- doc = await registry[_REG_COLL].find_one(
65
- {"service_id": service_id}, projection={"db_name": 1}
66
- )
65
+ doc = await registry[_REG_COLL].find_one({"service_id": service_id}, projection={"db_name": 1})
67
66
  if doc is None:
68
67
  await registry[_REG_COLL].insert_one(
69
68
  {"service_id": service_id, "db_name": expected_db_name}
@@ -106,13 +105,9 @@ async def prepare_mongo(
106
105
 
107
106
  expected_db = get_mongo_dbname_from_env(required=True)
108
107
  if db.name != expected_db:
109
- raise RuntimeError(
110
- f"Connected to Mongo DB '{db.name}', but env says '{expected_db}'."
111
- )
108
+ raise RuntimeError(f"Connected to Mongo DB '{db.name}', but env says '{expected_db}'.")
112
109
 
113
- await assert_db_locked(
114
- db, expected_db, service_id=service_id, allow_rebind=allow_rebind
115
- )
110
+ await assert_db_locked(db, expected_db, service_id=service_id, allow_rebind=allow_rebind)
116
111
 
117
112
  # collections
118
113
  colls = [r.resolved_collection() for r in resources]
@@ -128,9 +123,7 @@ async def prepare_mongo(
128
123
  names = await _apply_indexes(db, collection=coll, indexes=idx_models)
129
124
  created_idx[coll] = names
130
125
 
131
- return PrepareResult(
132
- ok=True, created_collections=created_colls, created_indexes=created_idx
133
- )
126
+ return PrepareResult(ok=True, created_collections=created_colls, created_indexes=created_idx)
134
127
 
135
128
 
136
129
  def setup_and_prepare(
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
3
+ from collections.abc import Iterable
4
+ from typing import Any
4
5
 
5
6
  from pymongo import ASCENDING, DESCENDING, IndexModel
6
7
  from pymongo.collation import Collation
@@ -17,12 +18,12 @@ from pymongo.collation import Collation
17
18
  # "sparse": True/False,
18
19
  # "background": True/False # ignored by MongoDB 6+, harmless to pass
19
20
  # }
20
- Alias = Dict[str, Any]
21
- KeySpec = Union[str, Tuple[str, int]]
21
+ Alias = dict[str, Any]
22
+ KeySpec = str | tuple[str, int]
22
23
 
23
24
 
24
- def _normalize_keys(keys: Iterable[KeySpec]) -> List[Tuple[str, int]]:
25
- out: List[Tuple[str, int]] = []
25
+ def _normalize_keys(keys: Iterable[KeySpec]) -> list[tuple[str, int]]:
26
+ out: list[tuple[str, int]] = []
26
27
  for k in keys:
27
28
  if isinstance(k, tuple):
28
29
  field, dir_val = k
@@ -36,20 +37,20 @@ def _normalize_keys(keys: Iterable[KeySpec]) -> List[Tuple[str, int]]:
36
37
  return out
37
38
 
38
39
 
39
- def _normalize_collation(c: Optional[Dict[str, Any]]) -> Optional[Collation]:
40
+ def _normalize_collation(c: dict[str, Any] | None) -> Collation | None:
40
41
  if not c:
41
42
  return None
42
43
  # common short form e.g. {"locale":"en","strength":2}
43
44
  return Collation(**c)
44
45
 
45
46
 
46
- def normalize_index(idx: Union[IndexModel, Alias]) -> IndexModel:
47
+ def normalize_index(idx: IndexModel | Alias) -> IndexModel:
47
48
  if isinstance(idx, IndexModel):
48
49
  return idx
49
50
  keys = _normalize_keys(idx.get("keys", []))
50
51
  if not keys:
51
52
  raise ValueError("Index alias requires 'keys'.")
52
- kwargs: Dict[str, Any] = {}
53
+ kwargs: dict[str, Any] = {}
53
54
  for k in (
54
55
  "name",
55
56
  "unique",
@@ -67,8 +68,8 @@ def normalize_index(idx: Union[IndexModel, Alias]) -> IndexModel:
67
68
 
68
69
 
69
70
  def normalize_indexes(
70
- indexes: Optional[Iterable[Union[IndexModel, Alias]]],
71
- ) -> List[IndexModel]:
71
+ indexes: Iterable[IndexModel | Alias] | None,
72
+ ) -> list[IndexModel]:
72
73
  if not indexes:
73
74
  return []
74
75
  return [normalize_index(i) for i in indexes]
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Optional, Type, Union, get_args, get_origin
3
+ from typing import Any, Optional, Union, get_args, get_origin
4
4
 
5
5
  from bson import ObjectId
6
6
  from pydantic import BaseModel
@@ -24,7 +24,7 @@ def _unwrap_union(annotation: Any) -> set[type]:
24
24
  """
25
25
  origin = get_origin(annotation)
26
26
  if origin is Union:
27
- return {t for t in get_args(annotation) if t is not type(None)} # noqa: E721
27
+ return {t for t in get_args(annotation) if t is not type(None)}
28
28
  return {annotation} if annotation is not None else set()
29
29
 
30
30
 
@@ -37,7 +37,7 @@ def _is_objectid_like(annotation: Any) -> bool:
37
37
 
38
38
 
39
39
  def make_document_crud_schemas(
40
- document_model: Type[BaseModel],
40
+ document_model: type[BaseModel],
41
41
  *,
42
42
  create_exclude: tuple[str, ...] = ("_id",),
43
43
  read_name: str | None = None,
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Optional
3
+ from typing import Any
4
4
 
5
5
  try:
6
6
  from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
@@ -13,8 +13,8 @@ except ImportError: # pragma: no cover
13
13
 
14
14
  from .settings import MongoSettings
15
15
 
16
- _client: Optional[AsyncIOMotorClient] = None
17
- _db: Optional[AsyncIOMotorDatabase] = None
16
+ _client: AsyncIOMotorClient | None = None
17
+ _db: AsyncIOMotorDatabase | None = None
18
18
 
19
19
 
20
20
  def _require_motor() -> None:
@@ -6,12 +6,8 @@ from pydantic import AnyUrl, BaseModel, Field
6
6
 
7
7
 
8
8
  class MongoSettings(BaseModel):
9
- url: AnyUrl = Field(
10
- default_factory=lambda: os.getenv("MONGO_URL", "mongodb://localhost:27017")
11
- ) # type: ignore[assignment]
9
+ url: AnyUrl = Field(default_factory=lambda: os.getenv("MONGO_URL", "mongodb://localhost:27017")) # type: ignore[assignment]
12
10
  db_name: str = Field(default_factory=lambda: os.getenv("MONGO_DB", ""))
13
- appname: str = Field(
14
- default_factory=lambda: os.getenv("MONGO_APPNAME", "svc-infra")
15
- )
11
+ appname: str = Field(default_factory=lambda: os.getenv("MONGO_APPNAME", "svc-infra"))
16
12
  min_pool_size: int = int(os.getenv("MONGO_MIN_POOL", "0"))
17
13
  max_pool_size: int = int(os.getenv("MONGO_MAX_POOL", "100"))