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
@@ -0,0 +1,13 @@
1
+ from .client import (
2
+ get_default_timeout_seconds,
3
+ make_timeout,
4
+ new_async_httpx_client,
5
+ new_httpx_client,
6
+ )
7
+
8
+ __all__ = [
9
+ "get_default_timeout_seconds",
10
+ "new_httpx_client",
11
+ "new_async_httpx_client",
12
+ "make_timeout",
13
+ ]
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from contextvars import ContextVar
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from svc_infra.app.env import pick
10
+
11
+ # Context var for request ID propagation across async boundaries
12
+ _request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None)
13
+
14
+
15
+ def set_request_id(request_id: str | None) -> None:
16
+ """Set the current request ID for propagation to outbound HTTP calls."""
17
+ _request_id_ctx.set(request_id)
18
+
19
+
20
+ def get_request_id() -> str | None:
21
+ """Get the current request ID for propagation."""
22
+ return _request_id_ctx.get()
23
+
24
+
25
+ def _merge_request_id_header(headers: dict[str, str] | None) -> dict[str, str]:
26
+ """Merge X-Request-Id header into headers dict if request ID is set."""
27
+ result = dict(headers) if headers else {}
28
+ request_id = get_request_id()
29
+ if request_id and "X-Request-Id" not in result:
30
+ result["X-Request-Id"] = request_id
31
+ return result
32
+
33
+
34
+ def _parse_float_env(name: str, default: float) -> float:
35
+ raw = os.getenv(name)
36
+ if raw is None or raw == "":
37
+ return default
38
+ try:
39
+ return float(raw)
40
+ except ValueError:
41
+ return default
42
+
43
+
44
+ def get_default_timeout_seconds() -> float:
45
+ """Return default outbound HTTP client timeout in seconds.
46
+
47
+ Env var: HTTP_CLIENT_TIMEOUT_SECONDS (float)
48
+ Defaults: 10.0 seconds for all envs unless overridden; tweakable via pick() if needed.
49
+ """
50
+ default = pick(prod=10.0, nonprod=10.0)
51
+ return _parse_float_env("HTTP_CLIENT_TIMEOUT_SECONDS", default)
52
+
53
+
54
+ def make_timeout(seconds: float | None = None) -> httpx.Timeout:
55
+ s = seconds if seconds is not None else get_default_timeout_seconds()
56
+ # Apply same timeout for connect/read/write/pool for simplicity
57
+ return httpx.Timeout(timeout=s)
58
+
59
+
60
+ def new_httpx_client(
61
+ *,
62
+ timeout_seconds: float | None = None,
63
+ headers: dict[str, str] | None = None,
64
+ base_url: str | None = None,
65
+ propagate_request_id: bool = True,
66
+ **kwargs: Any,
67
+ ) -> httpx.Client:
68
+ """Create a sync httpx Client with default timeout and optional headers/base_url.
69
+
70
+ Callers can override timeout_seconds; remaining kwargs are forwarded to httpx.Client.
71
+ If propagate_request_id=True (default), X-Request-Id header is added from context.
72
+ """
73
+ timeout = make_timeout(timeout_seconds)
74
+ merged_headers = _merge_request_id_header(headers) if propagate_request_id else headers
75
+ # httpx doesn't accept base_url=None; only pass if non-None
76
+ client_kwargs = {"timeout": timeout, "headers": merged_headers, **kwargs}
77
+ if base_url is not None:
78
+ client_kwargs["base_url"] = base_url
79
+ return httpx.Client(**client_kwargs)
80
+
81
+
82
+ def new_async_httpx_client(
83
+ *,
84
+ timeout_seconds: float | None = None,
85
+ headers: dict[str, str] | None = None,
86
+ base_url: str | None = None,
87
+ propagate_request_id: bool = True,
88
+ **kwargs: Any,
89
+ ) -> httpx.AsyncClient:
90
+ """Create an async httpx AsyncClient with default timeout and optional headers/base_url.
91
+
92
+ Callers can override timeout_seconds; remaining kwargs are forwarded to httpx.AsyncClient.
93
+ If propagate_request_id=True (default), X-Request-Id header is added from context.
94
+ """
95
+ timeout = make_timeout(timeout_seconds)
96
+ merged_headers = _merge_request_id_header(headers) if propagate_request_id else headers
97
+ # httpx doesn't accept base_url=None; only pass if non-None
98
+ client_kwargs = {"timeout": timeout, "headers": merged_headers, **kwargs}
99
+ if base_url is not None:
100
+ client_kwargs["base_url"] = base_url
101
+ return httpx.AsyncClient(**client_kwargs)
@@ -0,0 +1,79 @@
1
+ """Background jobs module providing queue abstraction and worker utilities.
2
+
3
+ This module provides a flexible background job system with multiple backends:
4
+
5
+ - **InMemoryJobQueue**: Simple in-memory queue for tests and local development
6
+ - **RedisJobQueue**: Production-ready Redis-backed queue with visibility timeout
7
+ - **InMemoryScheduler**: Interval-based scheduler for periodic tasks
8
+
9
+ Example:
10
+ from svc_infra.jobs import easy_jobs, Job
11
+
12
+ # Initialize queue and scheduler (auto-detects Redis or uses memory)
13
+ queue, scheduler = easy_jobs()
14
+
15
+ # Enqueue a job
16
+ job = queue.enqueue("send_email", {"to": "user@example.com"})
17
+ print(f"Enqueued job: {job.id}")
18
+
19
+ # Process jobs with a worker
20
+ from svc_infra.jobs import process_one
21
+
22
+ async def handler(job: Job):
23
+ if job.name == "send_email":
24
+ await send_email(job.payload["to"])
25
+
26
+ await process_one(queue, handler)
27
+
28
+ Environment Variables:
29
+ JOBS_DRIVER: Backend driver ("memory" or "redis"), defaults to "memory"
30
+ REDIS_URL: Redis connection URL for redis driver
31
+ JOB_DEFAULT_TIMEOUT_SECONDS: Per-job execution timeout
32
+ JOBS_SCHEDULE_JSON: JSON array of scheduled task definitions
33
+
34
+ See Also:
35
+ - docs/jobs.md for detailed documentation
36
+ - svc_infra.jobs.builtins for webhook delivery and outbox processing
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ # Easy setup function
42
+ from .easy import easy_jobs
43
+
44
+ # Loader for schedule configuration
45
+ from .loader import schedule_from_env
46
+
47
+ # Core queue abstractions
48
+ from .queue import InMemoryJobQueue, Job, JobQueue
49
+
50
+ # Redis-backed queue for production
51
+ from .redis_queue import RedisJobQueue
52
+
53
+ # Runner for long-lived workers
54
+ from .runner import WorkerRunner
55
+
56
+ # Scheduler for periodic tasks
57
+ from .scheduler import InMemoryScheduler, ScheduledTask
58
+
59
+ # Worker utilities
60
+ from .worker import process_one
61
+
62
+ __all__ = [
63
+ # Core types
64
+ "Job",
65
+ "JobQueue",
66
+ # Queue implementations
67
+ "InMemoryJobQueue",
68
+ "RedisJobQueue",
69
+ # Scheduler
70
+ "InMemoryScheduler",
71
+ "ScheduledTask",
72
+ # Easy setup
73
+ "easy_jobs",
74
+ # Worker utilities
75
+ "process_one",
76
+ "WorkerRunner",
77
+ # Configuration loader
78
+ "schedule_from_env",
79
+ ]
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from svc_infra.db.outbox import OutboxStore
6
+ from svc_infra.jobs.queue import JobQueue
7
+
8
+
9
+ def make_outbox_tick(
10
+ outbox: OutboxStore,
11
+ queue: JobQueue,
12
+ *,
13
+ topics: Iterable[str] | None = None,
14
+ job_name_prefix: str = "outbox",
15
+ ):
16
+ """Return an async task function to move one outbox message into the job queue.
17
+
18
+ - It fetches at most one unprocessed message per tick to avoid starving others.
19
+ - The enqueued job name is f"{job_name_prefix}.{topic}" to allow routing.
20
+ - The job payload contains `outbox_id`, `topic`, and original `payload`.
21
+ """
22
+
23
+ dispatched: set[int] = set()
24
+
25
+ async def _tick():
26
+ # Outbox is sync; this wrapper is async for scheduler compatibility
27
+ msg = outbox.fetch_next(topics=topics)
28
+ if not msg:
29
+ return
30
+ if msg.id in dispatched:
31
+ return
32
+ job_name = f"{job_name_prefix}.{msg.topic}"
33
+ queue.enqueue(job_name, {"outbox_id": msg.id, "topic": msg.topic, "payload": msg.payload})
34
+ # mark as dispatched (bump attempts) so it won't be re-enqueued by fetch_next
35
+ outbox.mark_failed(msg.id)
36
+ dispatched.add(msg.id)
37
+
38
+ return _tick
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from svc_infra.db.inbox import InboxStore
6
+ from svc_infra.db.outbox import OutboxStore
7
+ from svc_infra.http import get_default_timeout_seconds, new_async_httpx_client
8
+ from svc_infra.jobs.queue import Job
9
+ from svc_infra.webhooks.encryption import decrypt_secret
10
+ from svc_infra.webhooks.signing import sign
11
+
12
+
13
+ def make_webhook_handler(
14
+ *,
15
+ outbox: OutboxStore,
16
+ inbox: InboxStore,
17
+ get_webhook_url_for_topic,
18
+ get_secret_for_topic,
19
+ header_name: str = "X-Signature",
20
+ ):
21
+ """Return an async job handler to deliver webhooks.
22
+
23
+ Expected job payload shape:
24
+ {"outbox_id": int, "topic": str, "payload": {...}}
25
+ """
26
+
27
+ async def _handler(job: Job) -> None:
28
+ data = job.payload or {}
29
+ outbox_id = data.get("outbox_id")
30
+ topic = data.get("topic")
31
+ payload = data.get("payload") or {}
32
+ if not outbox_id or not topic:
33
+ # Nothing we can do; ack to avoid poison loop
34
+ return
35
+ # dedupe marker key (marked after successful delivery)
36
+ key = f"webhook:{outbox_id}"
37
+ if inbox.is_marked(key):
38
+ # already delivered
39
+ outbox.mark_processed(int(outbox_id))
40
+ return
41
+ event = payload.get("event") if isinstance(payload, dict) else None
42
+ subscription = payload.get("subscription") if isinstance(payload, dict) else None
43
+ if event is not None and subscription is not None:
44
+ delivery_payload = event
45
+ url = subscription.get("url") or get_webhook_url_for_topic(topic)
46
+ # Decrypt secret (handles both encrypted and plaintext for backwards compat)
47
+ raw_secret = subscription.get("secret") or get_secret_for_topic(topic)
48
+ secret = decrypt_secret(raw_secret)
49
+ subscription_id = subscription.get("id")
50
+ else:
51
+ delivery_payload = payload
52
+ url = get_webhook_url_for_topic(topic)
53
+ secret = get_secret_for_topic(topic)
54
+ subscription_id = None
55
+ sig = sign(secret, delivery_payload)
56
+ headers = {
57
+ header_name: sig,
58
+ "X-Event-Id": str(outbox_id),
59
+ "X-Topic": str(topic),
60
+ "X-Attempt": str(job.attempts or 1),
61
+ "X-Signature-Alg": "hmac-sha256",
62
+ "X-Signature-Version": "v1",
63
+ }
64
+ if subscription_id:
65
+ headers["X-Webhook-Subscription"] = str(subscription_id)
66
+ # include event payload version if present
67
+ version = None
68
+ if isinstance(delivery_payload, dict):
69
+ version = delivery_payload.get("version")
70
+ if version is not None:
71
+ headers["X-Payload-Version"] = str(version)
72
+ # Derive timeout: dedicated WEBHOOK_DELIVERY_TIMEOUT_SECONDS or default HTTP client timeout
73
+ timeout_seconds = None
74
+ env_timeout = os.getenv("WEBHOOK_DELIVERY_TIMEOUT_SECONDS")
75
+ if env_timeout:
76
+ try:
77
+ timeout_seconds = float(env_timeout)
78
+ except ValueError:
79
+ timeout_seconds = get_default_timeout_seconds()
80
+ else:
81
+ timeout_seconds = get_default_timeout_seconds()
82
+
83
+ async with new_async_httpx_client(timeout_seconds=timeout_seconds) as client:
84
+ resp = await client.post(url, json=delivery_payload, headers=headers)
85
+ if 200 <= resp.status_code < 300:
86
+ # record delivery and mark processed
87
+ inbox.mark_if_new(key, ttl_seconds=24 * 3600)
88
+ outbox.mark_processed(int(outbox_id))
89
+ return
90
+ # allow retry on non-2xx: raise to trigger fail/backoff
91
+ raise RuntimeError(f"webhook delivery failed: {resp.status_code}")
92
+
93
+ return _handler
svc_infra/jobs/easy.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from redis import Redis
6
+
7
+ from .queue import InMemoryJobQueue, JobQueue
8
+ from .redis_queue import RedisJobQueue
9
+ from .scheduler import InMemoryScheduler
10
+
11
+
12
+ class JobsConfig:
13
+ def __init__(self, driver: str | None = None):
14
+ # Future: support redis/sql drivers via extras
15
+ self.driver = driver or os.getenv("JOBS_DRIVER", "memory").lower()
16
+
17
+
18
+ def easy_jobs(*, driver: str | None = None) -> tuple[JobQueue, InMemoryScheduler]:
19
+ """One-call wiring for jobs: returns (queue, scheduler).
20
+
21
+ Defaults to in-memory implementations for local/dev. ENV override via JOBS_DRIVER.
22
+ """
23
+ cfg = JobsConfig(driver=driver)
24
+ # Choose backend
25
+ queue: JobQueue
26
+ if cfg.driver == "redis":
27
+ url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
28
+ client = Redis.from_url(url)
29
+ queue = RedisJobQueue(client)
30
+ else:
31
+ queue = InMemoryJobQueue()
32
+ scheduler = InMemoryScheduler()
33
+ return queue, scheduler
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import importlib
5
+ import json
6
+ import logging
7
+ import os
8
+ from collections.abc import Awaitable, Callable
9
+ from typing import cast
10
+
11
+ from .scheduler import InMemoryScheduler
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _resolve_target(path: str) -> Callable[[], Awaitable[None]]:
17
+ mod_name, func_name = path.split(":", 1)
18
+ mod = importlib.import_module(mod_name)
19
+ fn = getattr(mod, func_name)
20
+ if asyncio.iscoroutinefunction(fn):
21
+ return cast("Callable[[], Awaitable[None]]", fn)
22
+
23
+ # wrap sync into async
24
+ async def _wrapped():
25
+ fn()
26
+
27
+ return _wrapped
28
+
29
+
30
+ def schedule_from_env(scheduler: InMemoryScheduler, env_var: str = "JOBS_SCHEDULE_JSON") -> None:
31
+ data = os.getenv(env_var)
32
+ if not data:
33
+ return
34
+ try:
35
+ tasks = json.loads(data)
36
+ except json.JSONDecodeError:
37
+ return
38
+ if not isinstance(tasks, list):
39
+ return
40
+ for t in tasks:
41
+ try:
42
+ name = t["name"]
43
+ interval = int(t.get("interval_seconds", 60))
44
+ target = t["target"]
45
+ fn = _resolve_target(target)
46
+ scheduler.add_task(name, interval, fn)
47
+ except Exception as e:
48
+ logger.warning("Failed to load scheduled job entry %s: %s", t, e)
49
+ continue
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import warnings
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime, timedelta
8
+ from typing import Any, Protocol
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ _INMEMORY_WARNED = False
13
+
14
+
15
+ def _check_inmemory_production_warning(class_name: str) -> None:
16
+ """Warn if in-memory store is used in production."""
17
+ global _INMEMORY_WARNED
18
+ if _INMEMORY_WARNED:
19
+ return
20
+ env = os.getenv("ENV", "development").lower()
21
+ if env in ("production", "staging", "prod"):
22
+ _INMEMORY_WARNED = True
23
+ msg = (
24
+ f"{class_name} is being used in {env} environment. "
25
+ "This is NOT suitable for production - data will be lost on restart. "
26
+ "Use RedisJobQueue instead."
27
+ )
28
+ warnings.warn(msg, RuntimeWarning, stacklevel=3)
29
+ logger.critical(msg)
30
+
31
+
32
+ @dataclass
33
+ class Job:
34
+ id: str
35
+ name: str
36
+ payload: dict[str, Any]
37
+ available_at: datetime = field(default_factory=lambda: datetime.now(UTC))
38
+ attempts: int = 0
39
+ max_attempts: int = 5
40
+ backoff_seconds: int = 60 # base backoff for retry
41
+ last_error: str | None = None
42
+
43
+
44
+ class JobQueue(Protocol):
45
+ def enqueue(self, name: str, payload: dict[str, Any], *, delay_seconds: int = 0) -> Job:
46
+ pass
47
+
48
+ def reserve_next(self) -> Job | None:
49
+ pass
50
+
51
+ def ack(self, job_id: str) -> None:
52
+ pass
53
+
54
+ def fail(self, job_id: str, *, error: str | None = None) -> None:
55
+ pass
56
+
57
+
58
+ class InMemoryJobQueue:
59
+ """Simple in-memory queue for tests and local runs.
60
+
61
+ Single-threaded reserve/ack/fail semantics. Not suitable for production.
62
+ """
63
+
64
+ def __init__(self):
65
+ _check_inmemory_production_warning("InMemoryJobQueue")
66
+ self._seq = 0
67
+ self._jobs: list[Job] = []
68
+
69
+ def _next_id(self) -> str:
70
+ self._seq += 1
71
+ return str(self._seq)
72
+
73
+ def enqueue(self, name: str, payload: dict[str, Any], *, delay_seconds: int = 0) -> Job:
74
+ when = datetime.now(UTC) + timedelta(seconds=delay_seconds)
75
+ job = Job(id=self._next_id(), name=name, payload=dict(payload), available_at=when)
76
+ self._jobs.append(job)
77
+ return job
78
+
79
+ def reserve_next(self) -> Job | None:
80
+ now = datetime.now(UTC)
81
+ for job in self._jobs:
82
+ if job.available_at <= now and job.attempts >= 0 and job.attempts < job.max_attempts:
83
+ job.attempts += 1
84
+ return job
85
+ return None
86
+
87
+ def ack(self, job_id: str) -> None:
88
+ self._jobs = [j for j in self._jobs if j.id != job_id]
89
+
90
+ def fail(self, job_id: str, *, error: str | None = None) -> None:
91
+ now = datetime.now(UTC)
92
+ for job in self._jobs:
93
+ if job.id == job_id:
94
+ job.last_error = error
95
+ # Exponential backoff: base * attempts
96
+ delay = job.backoff_seconds * max(1, job.attempts)
97
+ if delay > 0:
98
+ # Add a tiny fudge so an immediate subsequent poll in ultra-fast
99
+ # environments (like our acceptance API) doesn't re-reserve the job.
100
+ # This keeps tests deterministic without impacting semantics.
101
+ job.available_at = now + timedelta(seconds=delay, milliseconds=250)
102
+ else:
103
+ # When backoff is explicitly zero (e.g., unit tests forcing
104
+ # immediate retry), make the job available right away.
105
+ job.available_at = now
106
+ return