svc-infra 0.1.595__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 (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
svc_infra/utils.py CHANGED
@@ -1,15 +1,43 @@
1
+ """svc-infra utilities module.
2
+
3
+ This module provides utility functions and helpers for svc-infra, including:
4
+ - Template rendering and file writing utilities
5
+ - Deprecation decorators and warnings
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
1
11
  import importlib.resources as pkg
12
+ import warnings
13
+ from collections.abc import Callable
2
14
  from pathlib import Path
3
15
  from string import Template as _T
4
- from typing import Any, Dict
16
+ from typing import Any, TypeVar
17
+
18
+ __all__ = [
19
+ # Template utilities
20
+ "render_template",
21
+ "write",
22
+ "ensure_init_py",
23
+ # Deprecation utilities
24
+ "deprecated",
25
+ "deprecated_parameter",
26
+ "DeprecatedWarning",
27
+ ]
28
+
5
29
 
30
+ # =============================================================================
31
+ # Template Utilities
32
+ # =============================================================================
6
33
 
7
- def render_template(tmpl_dir: str, name: str, subs: dict[str, Any] = None) -> str:
34
+
35
+ def render_template(tmpl_dir: str, name: str, subs: dict[str, Any] | None = None) -> str:
8
36
  txt = pkg.files(tmpl_dir).joinpath(name).read_text(encoding="utf-8")
9
- return _T(txt).safe_substitute(subs)
37
+ return _T(txt).safe_substitute(subs or {})
10
38
 
11
39
 
12
- def write(dest: Path, content: str, overwrite: bool = False) -> Dict[str, Any]:
40
+ def write(dest: Path, content: str, overwrite: bool = False) -> dict[str, Any]:
13
41
  dest = dest.resolve()
14
42
  dest.parent.mkdir(parents=True, exist_ok=True)
15
43
  if dest.exists() and not overwrite:
@@ -18,6 +46,143 @@ def write(dest: Path, content: str, overwrite: bool = False) -> Dict[str, Any]:
18
46
  return {"path": str(dest), "action": "wrote"}
19
47
 
20
48
 
21
- def ensure_init_py(dir_path: Path, overwrite: bool, paired: bool, content: str) -> Dict[str, Any]:
49
+ def ensure_init_py(dir_path: Path, overwrite: bool, paired: bool, content: str) -> dict[str, Any]:
22
50
  """Create __init__.py; paired=True writes models/schemas re-exports, otherwise minimal."""
23
51
  return write(dir_path / "__init__.py", content, overwrite)
52
+
53
+
54
+ # =============================================================================
55
+ # Deprecation Utilities
56
+ # =============================================================================
57
+
58
+ F = TypeVar("F", bound=Callable[..., Any])
59
+
60
+
61
+ class DeprecatedWarning(DeprecationWarning):
62
+ """Custom deprecation warning for svc-infra.
63
+
64
+ This warning is used to distinguish svc-infra deprecations from
65
+ Python's built-in DeprecationWarning.
66
+ """
67
+
68
+ pass
69
+
70
+
71
+ def deprecated(
72
+ version: str,
73
+ reason: str,
74
+ removal_version: str | None = None,
75
+ *,
76
+ stacklevel: int = 2,
77
+ ) -> Callable[[F], F]:
78
+ """Decorator to mark a function or class as deprecated.
79
+
80
+ The decorated function/class will emit a DeprecationWarning when called/instantiated.
81
+
82
+ Args:
83
+ version: The version in which the feature was deprecated (e.g., "1.2.0").
84
+ reason: The reason for deprecation and recommended alternative.
85
+ removal_version: The version in which the feature will be removed (e.g., "1.4.0").
86
+ stacklevel: Stack level for the warning (default 2 for immediate caller).
87
+
88
+ Returns:
89
+ A decorator that wraps the function/class with deprecation warning.
90
+
91
+ Example:
92
+ >>> @deprecated(
93
+ ... version="1.2.0",
94
+ ... reason="Use new_function() instead",
95
+ ... removal_version="1.4.0"
96
+ ... )
97
+ ... def old_function():
98
+ ... return "result"
99
+ >>>
100
+ >>> old_function() # Emits DeprecationWarning
101
+ 'result'
102
+ """
103
+
104
+ def decorator(func: F) -> F:
105
+ # Build the warning message
106
+ name = getattr(func, "__qualname__", getattr(func, "__name__", str(func)))
107
+ message = f"{name} is deprecated since version {version}."
108
+
109
+ if removal_version:
110
+ message += f" It will be removed in version {removal_version}."
111
+
112
+ message += f" {reason}"
113
+
114
+ if isinstance(func, type):
115
+ # Handle class deprecation
116
+ original_init = func.__init__ # type: ignore[misc]
117
+
118
+ @functools.wraps(original_init)
119
+ def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
120
+ warnings.warn(message, DeprecatedWarning, stacklevel=stacklevel)
121
+ original_init(self, *args, **kwargs)
122
+
123
+ func.__init__ = new_init # type: ignore[misc]
124
+
125
+ # Add deprecation info to docstring
126
+ if func.__doc__:
127
+ func.__doc__ = f".. deprecated:: {version}\n {reason}\n\n{func.__doc__}"
128
+ else:
129
+ func.__doc__ = f".. deprecated:: {version}\n {reason}"
130
+
131
+ return func # type: ignore[return-value]
132
+ else:
133
+ # Handle function deprecation
134
+ @functools.wraps(func)
135
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
136
+ warnings.warn(message, DeprecatedWarning, stacklevel=stacklevel)
137
+ return func(*args, **kwargs)
138
+
139
+ # Add deprecation info to docstring
140
+ if wrapper.__doc__:
141
+ wrapper.__doc__ = f".. deprecated:: {version}\n {reason}\n\n{wrapper.__doc__}"
142
+ else:
143
+ wrapper.__doc__ = f".. deprecated:: {version}\n {reason}"
144
+
145
+ return wrapper # type: ignore[return-value]
146
+
147
+ return decorator
148
+
149
+
150
+ def deprecated_parameter(
151
+ name: str,
152
+ version: str,
153
+ reason: str,
154
+ removal_version: str | None = None,
155
+ *,
156
+ stacklevel: int = 2,
157
+ ) -> None:
158
+ """Emit a deprecation warning for a deprecated parameter.
159
+
160
+ Call this function when a deprecated parameter is used. This should be
161
+ called at the beginning of a function that has deprecated parameters.
162
+
163
+ Args:
164
+ name: The name of the deprecated parameter.
165
+ version: The version in which the parameter was deprecated.
166
+ reason: The reason for deprecation and recommended alternative.
167
+ removal_version: The version in which the parameter will be removed.
168
+ stacklevel: Stack level for the warning (default 2 for immediate caller).
169
+
170
+ Example:
171
+ >>> def my_function(new_param: str, old_param: str | None = None):
172
+ ... if old_param is not None:
173
+ ... deprecated_parameter(
174
+ ... name="old_param",
175
+ ... version="1.2.0",
176
+ ... reason="Use new_param instead"
177
+ ... )
178
+ ... new_param = old_param
179
+ ... return new_param
180
+ """
181
+ message = f"Parameter '{name}' is deprecated since version {version}."
182
+
183
+ if removal_version:
184
+ message += f" It will be removed in version {removal_version}."
185
+
186
+ message += f" {reason}"
187
+
188
+ warnings.warn(message, DeprecatedWarning, stacklevel=stacklevel + 1)
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any, cast
5
+
6
+ from .add import add_webhooks
7
+ from .encryption import decrypt_secret, encrypt_secret, is_encrypted
8
+
9
+ __all__ = [
10
+ "add_webhooks",
11
+ "encrypt_secret",
12
+ "decrypt_secret",
13
+ "is_encrypted",
14
+ "trigger_webhook",
15
+ ]
16
+
17
+ _logger = logging.getLogger(__name__)
18
+
19
+
20
+ async def trigger_webhook(
21
+ event: str,
22
+ data: dict[str, Any],
23
+ *,
24
+ webhook_service: Any | None = None,
25
+ ) -> int | None:
26
+ """
27
+ Trigger a webhook event.
28
+
29
+ This is a convenience function for sending webhook events. It requires
30
+ that webhooks have been configured via add_webhooks() first.
31
+
32
+ Args:
33
+ event: The event/topic name (e.g., "goal.milestone_reached")
34
+ data: The event payload data
35
+ webhook_service: Optional WebhookService instance. If not provided,
36
+ attempts to use the global service from add_webhooks.
37
+
38
+ Returns:
39
+ The outbox message ID if successful, None if no webhook service configured.
40
+
41
+ Example:
42
+ from svc_infra.webhooks import trigger_webhook
43
+
44
+ await trigger_webhook(
45
+ event="user.created",
46
+ data={"user_id": "123", "email": "user@example.com"}
47
+ )
48
+
49
+ Note:
50
+ For this to work, you must first configure webhooks:
51
+
52
+ from svc_infra.webhooks import add_webhooks
53
+ add_webhooks(app)
54
+ """
55
+ if webhook_service is None:
56
+ # Try to get the global webhook service from app state
57
+ _logger.warning(
58
+ "No webhook_service provided and no global service configured. "
59
+ "Call add_webhooks(app) first to enable webhook delivery."
60
+ )
61
+ return None
62
+
63
+ try:
64
+ msg_id = cast("int", webhook_service.publish(event, data))
65
+ _logger.info(f"Triggered webhook event '{event}' with message ID {msg_id}")
66
+ return msg_id
67
+ except Exception as e:
68
+ _logger.error(f"Failed to trigger webhook event '{event}': {e}")
69
+ return None
@@ -0,0 +1,327 @@
1
+ """FastAPI integration helpers for the webhooks router.
2
+
3
+ The :func:`add_webhooks` helper wires the public router into an app and makes
4
+ sure dependency overrides share a single set of stores instead of the in-file
5
+ defaults that create a new in-memory object per request. Callers can:
6
+
7
+ * rely on the in-memory defaults (suitable for tests / local usage);
8
+ * configure persistent stores through environment variables; or
9
+ * provide concrete instances / factories explicitly via keyword arguments.
10
+
11
+ When queue / scheduler objects are provided the helper also wires up the
12
+ standard outbox tick task and webhook delivery job handler so the caller only
13
+ needs to start their existing worker loop.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ from collections.abc import Callable, Iterable, Mapping
22
+ from datetime import UTC, datetime
23
+ from typing import Any, Protocol, TypeGuard, TypeVar, cast
24
+
25
+ from fastapi import FastAPI
26
+
27
+ from svc_infra.db.inbox import InboxStore, InMemoryInboxStore
28
+ from svc_infra.db.outbox import InMemoryOutboxStore, OutboxMessage, OutboxStore
29
+ from svc_infra.jobs.builtins.outbox_processor import make_outbox_tick
30
+ from svc_infra.jobs.builtins.webhook_delivery import make_webhook_handler
31
+ from svc_infra.jobs.queue import JobQueue
32
+ from svc_infra.jobs.scheduler import InMemoryScheduler
33
+
34
+ from . import router as router_module
35
+ from .service import InMemoryWebhookSubscriptions
36
+
37
+ try: # Optional dependency – only required when redis backends are selected.
38
+ from redis import Redis
39
+ except Exception: # pragma: no cover - redis is optional in most test runs.
40
+ Redis = None # type: ignore[misc,assignment]
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ T_co = TypeVar("T_co", covariant=True)
46
+
47
+
48
+ class _Factory(Protocol[T_co]):
49
+ def __call__(self) -> T_co:
50
+ pass
51
+
52
+
53
+ class RedisOutboxStore(OutboxStore):
54
+ """Minimal Redis-backed outbox implementation used by :func:`add_webhooks`.
55
+
56
+ The implementation is intentionally lightweight – it keeps message payloads
57
+ in Redis hashes and a FIFO list of message identifiers. It fulfils the
58
+ contract expected by :func:`make_outbox_tick` while remaining simple enough
59
+ for environments where a fully fledged SQL implementation is unavailable.
60
+ """
61
+
62
+ def __init__(self, client: Redis, *, prefix: str = "webhooks:outbox"):
63
+ if Redis is None: # pragma: no cover - defensive guard
64
+ raise RuntimeError("redis-py is required for RedisOutboxStore")
65
+ self._client = client
66
+ self._prefix = prefix.rstrip(":")
67
+
68
+ # Redis key helpers -------------------------------------------------
69
+ @property
70
+ def _seq_key(self) -> str:
71
+ return f"{self._prefix}:seq"
72
+
73
+ @property
74
+ def _queue_key(self) -> str:
75
+ return f"{self._prefix}:queue"
76
+
77
+ def _msg_key(self, msg_id: int) -> str:
78
+ return f"{self._prefix}:msg:{msg_id}"
79
+
80
+ # Protocol methods --------------------------------------------------
81
+ def enqueue(self, topic: str, payload: dict[str, Any]) -> OutboxMessage:
82
+ incr_result = cast("Any", self._client.incr(self._seq_key))
83
+ # Redis incr always returns an int for the sync client. Be defensive for mocks.
84
+ try:
85
+ msg_id = int(incr_result)
86
+ except (TypeError, ValueError):
87
+ msg_id = 0
88
+ created_at = datetime.now(UTC)
89
+ record: dict[str, str] = {
90
+ "id": str(msg_id),
91
+ "topic": topic,
92
+ "payload": json.dumps(payload),
93
+ "created_at": created_at.isoformat(),
94
+ "attempts": "0",
95
+ "processed_at": "",
96
+ }
97
+ self._client.hset(self._msg_key(msg_id), mapping=record)
98
+ self._client.rpush(self._queue_key, msg_id)
99
+ return OutboxMessage(id=msg_id, topic=topic, payload=payload, created_at=created_at)
100
+
101
+ def fetch_next(self, topics: Iterable[str] | None = None) -> OutboxMessage | None:
102
+ allowed = set(topics) if topics else None
103
+ ids = cast("list[Any]", self._client.lrange(self._queue_key, 0, -1))
104
+ for raw_id in ids:
105
+ raw_id_str = raw_id.decode() if isinstance(raw_id, (bytes, bytearray)) else str(raw_id)
106
+ msg_id = int(raw_id_str)
107
+ msg = cast("dict[Any, Any]", self._client.hgetall(self._msg_key(msg_id)))
108
+ if not msg:
109
+ continue
110
+ topic = msg.get(b"topic")
111
+ if topic is None:
112
+ continue
113
+ topic_str = topic.decode() if isinstance(topic, (bytes, bytearray)) else str(topic)
114
+ if allowed is not None and topic_str not in allowed:
115
+ continue
116
+ attempts = int(msg.get(b"attempts", 0))
117
+ processed_raw = msg.get(b"processed_at") or b""
118
+ if processed_raw:
119
+ continue
120
+ if attempts > 0:
121
+ continue
122
+ payload_raw = msg.get(b"payload") or b"{}"
123
+ payload_txt = (
124
+ payload_raw.decode()
125
+ if isinstance(payload_raw, (bytes, bytearray))
126
+ else str(payload_raw)
127
+ )
128
+ payload = json.loads(payload_txt)
129
+ created_raw = msg.get(b"created_at") or b""
130
+ created_at = (
131
+ datetime.fromisoformat(
132
+ created_raw.decode()
133
+ if isinstance(created_raw, (bytes, bytearray))
134
+ else str(created_raw)
135
+ )
136
+ if created_raw
137
+ else datetime.now(UTC)
138
+ )
139
+ return OutboxMessage(
140
+ id=msg_id,
141
+ topic=topic_str,
142
+ payload=payload,
143
+ created_at=created_at,
144
+ attempts=attempts,
145
+ )
146
+ return None
147
+
148
+ def mark_processed(self, msg_id: int) -> None:
149
+ key = self._msg_key(msg_id)
150
+ if not self._client.exists(key):
151
+ return
152
+ self._client.hset(key, "processed_at", datetime.now(UTC).isoformat())
153
+
154
+ def mark_failed(self, msg_id: int) -> None:
155
+ key = self._msg_key(msg_id)
156
+ self._client.hincrby(key, "attempts", 1)
157
+
158
+
159
+ class RedisInboxStore(InboxStore):
160
+ """Lightweight Redis dedupe store for webhook deliveries."""
161
+
162
+ def __init__(self, client: Redis, *, prefix: str = "webhooks:inbox"):
163
+ if Redis is None: # pragma: no cover - defensive guard
164
+ raise RuntimeError("redis-py is required for RedisInboxStore")
165
+ self._client = client
166
+ self._prefix = prefix.rstrip(":")
167
+
168
+ def _key(self, key: str) -> str:
169
+ return f"{self._prefix}:{key}"
170
+
171
+ def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
172
+ return bool(self._client.set(self._key(key), 1, nx=True, ex=ttl_seconds))
173
+
174
+ def purge_expired(self) -> int:
175
+ # Redis takes care of expirations. We return 0 to satisfy the interface.
176
+ return 0
177
+
178
+ def is_marked(self, key: str) -> bool:
179
+ return bool(self._client.exists(self._key(key)))
180
+
181
+
182
+ def _is_factory(obj: Any) -> TypeGuard[Callable[[], Any]]:
183
+ return callable(obj) and not isinstance(obj, (str, bytes, bytearray))
184
+
185
+
186
+ def _resolve_value(value: T_co | _Factory[T_co] | None, default_factory: _Factory[T_co]) -> T_co:
187
+ if value is None:
188
+ return default_factory()
189
+ if _is_factory(value):
190
+ return cast("T_co", value())
191
+ return cast("T_co", value)
192
+
193
+
194
+ def _build_redis_client(env: Mapping[str, str]) -> Redis | None:
195
+ if Redis is None:
196
+ logger.warning(
197
+ "Redis backend requested but redis-py is not installed; falling back to in-memory stores"
198
+ )
199
+ return None
200
+ url = env.get("REDIS_URL", "redis://localhost:6379/0")
201
+ return Redis.from_url(url)
202
+
203
+
204
+ def _default_outbox(env: Mapping[str, str]) -> OutboxStore:
205
+ backend = (env.get("WEBHOOKS_OUTBOX") or "memory").lower()
206
+ if backend == "redis":
207
+ client = _build_redis_client(env)
208
+ if client is not None:
209
+ logger.info("Using Redis outbox store for webhooks")
210
+ return RedisOutboxStore(client)
211
+ elif backend == "sql": # pragma: no cover - SQL backend is currently a placeholder
212
+ logger.warning(
213
+ "WEBHOOKS_OUTBOX=sql specified but SQL backend is not implemented; falling back to in-memory store"
214
+ )
215
+ return InMemoryOutboxStore()
216
+
217
+
218
+ def _default_inbox(env: Mapping[str, str]) -> InboxStore:
219
+ backend = (env.get("WEBHOOKS_INBOX") or "memory").lower()
220
+ if backend == "redis":
221
+ client = _build_redis_client(env)
222
+ if client is not None:
223
+ logger.info("Using Redis inbox store for webhooks")
224
+ return RedisInboxStore(client)
225
+ return InMemoryInboxStore()
226
+
227
+
228
+ def _default_subscriptions() -> InMemoryWebhookSubscriptions:
229
+ return InMemoryWebhookSubscriptions()
230
+
231
+
232
+ def _subscription_lookup(
233
+ subs: InMemoryWebhookSubscriptions,
234
+ ) -> tuple[Callable[[str], str], Callable[[str], str]]:
235
+ def _get_url(topic: str) -> str:
236
+ items = subs.get_for_topic(topic)
237
+ if not items:
238
+ raise LookupError(f"No webhook subscription for topic '{topic}'")
239
+ return items[0].url
240
+
241
+ def _get_secret(topic: str) -> str:
242
+ items = subs.get_for_topic(topic)
243
+ if not items:
244
+ raise LookupError(f"No webhook subscription for topic '{topic}'")
245
+ return items[0].secret
246
+
247
+ return _get_url, _get_secret
248
+
249
+
250
+ def add_webhooks(
251
+ app: FastAPI,
252
+ *,
253
+ outbox: OutboxStore | _Factory[OutboxStore] | None = None,
254
+ inbox: InboxStore | _Factory[InboxStore] | None = None,
255
+ subscriptions: (
256
+ InMemoryWebhookSubscriptions | _Factory[InMemoryWebhookSubscriptions] | None
257
+ ) = None,
258
+ queue: JobQueue | None = None,
259
+ scheduler: InMemoryScheduler | None = None,
260
+ schedule_tick: bool = True,
261
+ env: Mapping[str, str] = os.environ,
262
+ ) -> None:
263
+ """Attach the shared webhooks router and stores to a FastAPI app.
264
+
265
+ Parameters
266
+ ----------
267
+ app:
268
+ The FastAPI application to configure.
269
+ outbox / inbox / subscriptions:
270
+ Optional instances or callables returning instances to use. When left
271
+ as ``None`` the helper chooses sensible defaults: in-memory stores for
272
+ local runs or Redis-backed stores when ``WEBHOOKS_OUTBOX`` /
273
+ ``WEBHOOKS_INBOX`` are set to ``"redis"`` and ``REDIS_URL`` is
274
+ available.
275
+ queue / scheduler:
276
+ Provide these when you want :func:`make_outbox_tick` and the webhook
277
+ delivery handler registered for you. The tick task is scheduled every
278
+ second by default; disable that registration by passing
279
+ ``schedule_tick=False``.
280
+ env:
281
+ Mapping used to resolve environment-driven defaults. Defaults to
282
+ :data:`os.environ` so standard environment variables Just Work.
283
+
284
+ Side effects
285
+ ------------
286
+ * ``app.include_router`` is invoked for :mod:`svc_infra.webhooks.router`.
287
+ * ``app.dependency_overrides`` is populated so router dependencies reuse the
288
+ shared stores.
289
+ * References are stored on ``app.state`` for further customisation:
290
+ ``webhooks_outbox``, ``webhooks_inbox``, ``webhooks_subscriptions``,
291
+ ``webhooks_outbox_tick`` (when a queue is present) and
292
+ ``webhooks_delivery_handler`` (when queue+inbox are present).
293
+ """
294
+
295
+ resolved_outbox = _resolve_value(outbox, lambda: _default_outbox(env))
296
+ resolved_inbox = _resolve_value(inbox, lambda: _default_inbox(env))
297
+ resolved_subs = _resolve_value(subscriptions, _default_subscriptions)
298
+
299
+ app.state.webhooks_outbox = resolved_outbox
300
+ app.state.webhooks_inbox = resolved_inbox
301
+ app.state.webhooks_subscriptions = resolved_subs
302
+
303
+ app.include_router(router_module.router)
304
+
305
+ app.dependency_overrides[router_module.get_outbox] = lambda: resolved_outbox
306
+ app.dependency_overrides[router_module.get_subs] = lambda: resolved_subs
307
+
308
+ outbox_tick = None
309
+ if queue is not None:
310
+ outbox_tick = make_outbox_tick(resolved_outbox, queue)
311
+ app.state.webhooks_outbox_tick = outbox_tick
312
+ if scheduler is not None and schedule_tick:
313
+ scheduler.add_task("webhooks.outbox", 1, outbox_tick)
314
+
315
+ url_lookup, secret_lookup = _subscription_lookup(resolved_subs)
316
+ handler = make_webhook_handler(
317
+ outbox=resolved_outbox,
318
+ inbox=resolved_inbox,
319
+ get_webhook_url_for_topic=url_lookup,
320
+ get_secret_for_topic=secret_lookup,
321
+ )
322
+ app.state.webhooks_delivery_handler = handler
323
+ elif scheduler is not None and schedule_tick:
324
+ logger.warning("Scheduler provided without queue; skipping outbox tick registration")
325
+
326
+
327
+ __all__ = ["add_webhooks"]
@@ -0,0 +1,115 @@
1
+ """Encryption utilities for webhook secrets.
2
+
3
+ Provides symmetric encryption for webhook secrets stored in the outbox.
4
+ Uses Fernet (AES-128-CBC with HMAC-SHA256) for authenticated encryption.
5
+
6
+ The encryption key is derived from WEBHOOK_ENCRYPTION_KEY environment variable.
7
+ In production, this MUST be set to a securely generated 32-byte base64 key.
8
+
9
+ Generate a key:
10
+ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import hashlib
17
+ import os
18
+ from functools import lru_cache
19
+ from typing import cast
20
+
21
+ from svc_infra.app.env import require_secret
22
+
23
+ # Marker prefix for encrypted values
24
+ _ENCRYPTED_PREFIX = "enc:v1:"
25
+
26
+
27
+ def _get_encryption_key() -> bytes:
28
+ """Get the webhook encryption key, requiring it in production."""
29
+ key_str = require_secret(
30
+ os.getenv("WEBHOOK_ENCRYPTION_KEY"),
31
+ "WEBHOOK_ENCRYPTION_KEY",
32
+ dev_default="dev-only-webhook-encryption-key-not-for-production",
33
+ )
34
+ # If it's a Fernet key (44 chars base64), use it directly
35
+ # Otherwise derive a key from it using SHA256
36
+ if len(key_str) == 44 and key_str.endswith("="):
37
+ return base64.urlsafe_b64decode(key_str)
38
+ # Derive a 32-byte key from arbitrary string
39
+ return hashlib.sha256(key_str.encode()).digest()
40
+
41
+
42
+ @lru_cache(maxsize=1)
43
+ def _get_fernet():
44
+ """Get or create the Fernet cipher for encryption/decryption."""
45
+ try:
46
+ from cryptography.fernet import Fernet
47
+ except ImportError:
48
+ # If cryptography is not installed, fall back to no encryption
49
+ # but log a warning
50
+ import logging
51
+
52
+ logging.getLogger(__name__).warning(
53
+ "cryptography package not installed - webhook secrets will NOT be encrypted. "
54
+ "Install with: pip install cryptography"
55
+ )
56
+ return None
57
+
58
+ key = _get_encryption_key()
59
+ # Fernet requires a 32-byte key encoded as base64
60
+ fernet_key = base64.urlsafe_b64encode(key)
61
+ return Fernet(fernet_key)
62
+
63
+
64
+ def encrypt_secret(plaintext: str) -> str:
65
+ """Encrypt a webhook secret for storage.
66
+
67
+ Args:
68
+ plaintext: The secret to encrypt
69
+
70
+ Returns:
71
+ Encrypted string with "enc:v1:" prefix, or original if encryption unavailable
72
+ """
73
+ fernet = _get_fernet()
74
+ if fernet is None:
75
+ return plaintext
76
+
77
+ encrypted = fernet.encrypt(plaintext.encode())
78
+ return _ENCRYPTED_PREFIX + cast("str", encrypted.decode())
79
+
80
+
81
+ def decrypt_secret(ciphertext: str) -> str:
82
+ """Decrypt a webhook secret from storage.
83
+
84
+ Args:
85
+ ciphertext: The encrypted secret (with "enc:v1:" prefix)
86
+
87
+ Returns:
88
+ Decrypted plaintext secret
89
+
90
+ Note:
91
+ If the value doesn't have the encryption prefix, it's returned as-is
92
+ for backwards compatibility with existing unencrypted secrets.
93
+ """
94
+ # If not encrypted, return as-is (backwards compatibility)
95
+ if not ciphertext.startswith(_ENCRYPTED_PREFIX):
96
+ return ciphertext
97
+
98
+ fernet = _get_fernet()
99
+ if fernet is None:
100
+ # Can't decrypt without cryptography - return as-is
101
+ # This shouldn't happen in practice if encrypt_secret was used
102
+ import logging
103
+
104
+ logging.getLogger(__name__).error(
105
+ "Cannot decrypt webhook secret - cryptography package not installed"
106
+ )
107
+ return ciphertext
108
+
109
+ encrypted = ciphertext[len(_ENCRYPTED_PREFIX) :].encode()
110
+ return cast("str", fernet.decrypt(encrypted).decode())
111
+
112
+
113
+ def is_encrypted(value: str) -> bool:
114
+ """Check if a value is encrypted."""
115
+ return value.startswith(_ENCRYPTED_PREFIX)