svc-infra 0.1.595__py3-none-any.whl → 0.1.706__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 (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -5,6 +5,11 @@ This module offers high-level decorators for read/write caching, cache invalidat
5
5
  and resource-based cache management.
6
6
  """
7
7
 
8
+ from .add import add_cache
9
+
10
+ # Cache instance access for object-oriented usage
11
+ from .backend import get_cache
12
+
8
13
  # Core decorators - main public API
9
14
  from .decorators import cached # alias for cache_read
10
15
  from .decorators import mutates # alias for cache_write
@@ -32,4 +37,8 @@ __all__ = [
32
37
  # Resource-based caching
33
38
  "resource",
34
39
  "entity",
40
+ # Easy integration helper
41
+ "add_cache",
42
+ # Cache instance access
43
+ "get_cache",
35
44
  ]
svc_infra/cache/add.py ADDED
@@ -0,0 +1,170 @@
1
+ """Easy integration helper to wire the cache backend into an ASGI app lifecycle.
2
+
3
+ Contract:
4
+ - Idempotent: multiple calls are safe; startup/shutdown handlers are registered once.
5
+ - Env-driven defaults: respects CACHE_URL/REDIS_URL, CACHE_PREFIX, CACHE_VERSION, APP_ENV.
6
+ - Lifecycle: registers startup (init + readiness probe) and shutdown (graceful close).
7
+ - Ergonomics: exposes the underlying cache instance at app.state.cache by default.
8
+
9
+ This does not replace the per-function decorators (`cache_read`, `cache_write`) and
10
+ does not alter existing direct APIs; it simply standardizes initialization and wiring.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ from typing import Any, Callable, Optional
18
+
19
+ from svc_infra.cache.backend import DEFAULT_READINESS_TIMEOUT
20
+ from svc_infra.cache.backend import get_cache as _get_cache
21
+ from svc_infra.cache.backend import setup_cache as _setup_cache
22
+ from svc_infra.cache.backend import shutdown_cache as _shutdown_cache
23
+ from svc_infra.cache.backend import wait_ready as _wait_ready
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _instance() -> Any:
29
+ """Return the current cache instance.
30
+
31
+ This is a thin compatibility shim used by tests and older callers.
32
+ """
33
+
34
+ return _get_cache()
35
+
36
+
37
+ def _derive_settings(
38
+ url: Optional[str], prefix: Optional[str], version: Optional[str]
39
+ ) -> tuple[str, str, str]:
40
+ """Derive cache settings from parameters or environment variables.
41
+
42
+ Precedence:
43
+ - explicit function arguments
44
+ - environment variables (CACHE_URL/REDIS_URL, CACHE_PREFIX, CACHE_VERSION)
45
+ - sensible defaults (mem://, "svc", "v1")
46
+ """
47
+
48
+ derived_url = url or os.getenv("CACHE_URL") or os.getenv("REDIS_URL") or "mem://"
49
+ derived_prefix = prefix or os.getenv("CACHE_PREFIX") or "svc"
50
+ derived_version = version or os.getenv("CACHE_VERSION") or "v1"
51
+ return derived_url, derived_prefix, derived_version
52
+
53
+
54
+ def add_cache(
55
+ app: Any | None = None,
56
+ *,
57
+ url: str | None = None,
58
+ prefix: str | None = None,
59
+ version: str | None = None,
60
+ readiness_timeout: float | None = None,
61
+ expose_state: bool = True,
62
+ state_key: str = "cache",
63
+ ) -> Callable[[], None]:
64
+ """Wire cache initialization and lifecycle into the ASGI app.
65
+
66
+ If an app is provided, registers startup/shutdown handlers. Otherwise performs
67
+ immediate initialization (best-effort) without awaiting readiness.
68
+
69
+ Returns a no-op shutdown callable for API symmetry with other helpers.
70
+ """
71
+
72
+ # Compute effective settings
73
+ eff_url, eff_prefix, eff_version = _derive_settings(url, prefix, version)
74
+
75
+ # If no app provided, do a simple init and return
76
+ if app is None:
77
+ try:
78
+ _setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
79
+ logger.info(
80
+ "Cache initialized (no app wiring): backend=%s namespace=%s",
81
+ eff_url,
82
+ f"{eff_prefix}:{eff_version}",
83
+ )
84
+ except Exception:
85
+ logger.exception("Cache initialization failed (no app wiring)")
86
+ return lambda: None
87
+
88
+ # Idempotence: avoid duplicate wiring
89
+ try:
90
+ state = getattr(app, "state", None)
91
+ already = bool(getattr(state, "_svc_cache_wired", False))
92
+ except Exception:
93
+ state = None
94
+ already = False
95
+
96
+ if already:
97
+ logger.debug("add_cache: app already wired; skipping re-registration")
98
+ return lambda: None
99
+
100
+ # Define lifecycle handlers
101
+ async def _startup():
102
+ _setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
103
+ try:
104
+ await _wait_ready(timeout=readiness_timeout or DEFAULT_READINESS_TIMEOUT)
105
+ except Exception:
106
+ # Bubble up to fail fast on startup; tests and prod prefer visibility
107
+ logger.exception("Cache readiness probe failed during startup")
108
+ raise
109
+ # Expose cache instance for convenience
110
+ if expose_state and hasattr(app, "state"):
111
+ try:
112
+ setattr(app.state, state_key, _instance())
113
+ except Exception:
114
+ logger.debug(
115
+ "Unable to expose cache instance on app.state", exc_info=True
116
+ )
117
+
118
+ async def _shutdown():
119
+ try:
120
+ await _shutdown_cache()
121
+ except Exception:
122
+ # Best-effort; shutdown should not crash the app
123
+ logger.debug("Cache shutdown encountered errors (ignored)", exc_info=True)
124
+
125
+ # Register event handlers when supported
126
+ register_ok = False
127
+ try:
128
+ if hasattr(app, "add_event_handler"):
129
+ app.add_event_handler("startup", _startup)
130
+ app.add_event_handler("shutdown", _shutdown)
131
+ register_ok = True
132
+ except Exception:
133
+ register_ok = False
134
+
135
+ if not register_ok:
136
+ # Fallback: attempt FastAPI/Starlette .on_event decorators dynamically
137
+ try:
138
+ on_event = getattr(app, "on_event", None)
139
+ if callable(on_event):
140
+ on_event("startup")(_startup)
141
+ on_event("shutdown")(_shutdown)
142
+ register_ok = True
143
+ except Exception:
144
+ register_ok = False
145
+
146
+ # Mark wired and expose state immediately if desired
147
+ if hasattr(app, "state"):
148
+ try:
149
+ setattr(app.state, "_svc_cache_wired", True)
150
+ if expose_state and not hasattr(app.state, state_key):
151
+ setattr(app.state, state_key, _instance())
152
+ except Exception:
153
+ pass
154
+
155
+ if register_ok:
156
+ logger.info(
157
+ "Cache wired: url=%s namespace=%s", eff_url, f"{eff_prefix}:{eff_version}"
158
+ )
159
+ else:
160
+ # If we cannot register handlers, at least initialize now
161
+ try:
162
+ _setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
163
+ except Exception:
164
+ logger.exception("Cache initialization failed (no event registration)")
165
+
166
+ # Return a simple shutdown handle for symmetry with other add_* helpers
167
+ return lambda: None
168
+
169
+
170
+ __all__ = ["add_cache"]
@@ -80,9 +80,12 @@ def setup_cache(
80
80
  logger.info(f"Cache version updated to: {_current_version}")
81
81
 
82
82
  # Setup backend connection
83
+ # Newer cashews versions require an explicit settings_url; default to in-memory
84
+ # backend when no URL is provided so acceptance/unit tests work out of the box.
83
85
  try:
84
- setup_awaitable = _cache.setup(url) if url else _cache.setup()
85
- logger.info(f"Cache backend setup initiated with URL: {url or 'default'}")
86
+ settings_url = url or "mem://"
87
+ setup_awaitable = _cache.setup(settings_url)
88
+ logger.info(f"Cache backend setup initiated with URL: {settings_url}")
86
89
  except Exception as e:
87
90
  logger.error(f"Failed to setup cache backend: {e}")
88
91
  raise
@@ -115,9 +118,7 @@ async def wait_ready(timeout: float = DEFAULT_READINESS_TIMEOUT) -> None:
115
118
  retrieved_value = await _cache.get(probe_key)
116
119
 
117
120
  if retrieved_value != PROBE_VALUE:
118
- error_msg = (
119
- f"Cache readiness probe failed. Expected '{PROBE_VALUE}', got '{retrieved_value}'"
120
- )
121
+ error_msg = f"Cache readiness probe failed. Expected '{PROBE_VALUE}', got '{retrieved_value}'"
121
122
  logger.error(error_msg)
122
123
  raise RuntimeError(error_msg)
123
124
 
@@ -144,7 +145,7 @@ async def shutdown_cache() -> None:
144
145
  logger.warning(f"Error during cache shutdown (ignored): {e}")
145
146
 
146
147
 
147
- def instance():
148
+ def get_cache():
148
149
  """
149
150
  Get the underlying cashews cache instance.
150
151
 
@@ -7,6 +7,7 @@ invalidating cache on write operations, and managing cache recaching strategies.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import inspect
10
11
  import logging
11
12
  from typing import Any, Awaitable, Callable, Iterable, Optional, Union
12
13
 
@@ -16,16 +17,12 @@ from svc_infra.cache.backend import alias as _alias
16
17
  from svc_infra.cache.backend import setup_cache as _setup_cache
17
18
  from svc_infra.cache.backend import wait_ready as _wait_ready
18
19
 
19
- from .keys import (
20
- build_key_template,
21
- build_key_variants_renderer,
22
- create_tags_function,
23
- resolve_tags,
24
- )
20
+ from .keys import build_key_template, build_key_variants_renderer, resolve_tags
25
21
  from .recache import RecachePlan, RecacheSpec, execute_recache, recache
26
22
  from .resources import Resource, entity, resource
27
23
  from .tags import invalidate_tags
28
24
  from .ttl import validate_ttl
25
+ from .utils import validate_cache_key
29
26
 
30
27
  logger = logging.getLogger(__name__)
31
28
 
@@ -98,23 +95,40 @@ def cache_read(
98
95
  ttl_val = validate_ttl(ttl)
99
96
  template = build_key_template(key)
100
97
  namespace = _alias() or ""
101
- tags_func = create_tags_function(tags)
98
+ # Cashews expects `tags` to be an iterable of (template) strings.
99
+ # If no explicit tags are provided, default to tagging by the key template.
100
+ # This enables the common pattern:
101
+ # @cache_read(key="thing:{id}")
102
+ # @cache_write(tags=["thing:{id}"])
103
+ # where writes invalidate reads without requiring tags on every read.
104
+ dynamic_tags_func: Callable[..., Iterable[str]] | None = None
105
+ if tags is None:
106
+ tags_param: Iterable[str] = (template,)
107
+ elif callable(tags):
108
+ # Preserve API surface area, but cashews doesn't accept callables here.
109
+ # We'll attach tag mappings manually after each call.
110
+ dynamic_tags_func = tags
111
+ tags_param = ()
112
+ else:
113
+ tags_param = tags
102
114
 
103
115
  def _decorator(func: Callable[..., Awaitable[Any]]):
104
116
  # Try different cashews cache decorator signatures for compatibility
105
- cache_kwargs = {"tags": tags_func}
117
+ cache_kwargs: dict[str, Any] = {"tags": tuple(tags_param)}
106
118
  if early_ttl is not None:
107
119
  cache_kwargs["early_ttl"] = early_ttl
108
120
  if refresh is not None:
109
121
  cache_kwargs["refresh"] = refresh
110
122
 
111
123
  wrapped = None
112
- error_msgs = []
124
+ error_msgs: list[str] = []
113
125
 
114
126
  # Attempt 1: With prefix parameter (preferred)
115
127
  if namespace:
116
128
  try:
117
- wrapped = _cache.cache(ttl_val, template, prefix=namespace, **cache_kwargs)(func)
129
+ wrapped = _cache.cache(
130
+ ttl_val, template, prefix=namespace, **cache_kwargs
131
+ )(func)
118
132
  except TypeError as e:
119
133
  error_msgs.append(f"prefix parameter: {e}")
120
134
 
@@ -126,23 +140,72 @@ def cache_read(
126
140
  if namespace and not template.startswith(f"{namespace}:")
127
141
  else template
128
142
  )
129
- wrapped = _cache.cache(ttl_val, key_with_namespace, **cache_kwargs)(func)
143
+ wrapped = _cache.cache(ttl_val, key_with_namespace, **cache_kwargs)(
144
+ func
145
+ )
130
146
  except TypeError as e:
131
147
  error_msgs.append(f"embedded namespace: {e}")
132
148
 
133
149
  # Attempt 3: Minimal fallback
134
150
  if wrapped is None:
135
151
  try:
136
- key_with_namespace = f"{namespace}:{template}" if namespace else template
152
+ key_with_namespace = (
153
+ f"{namespace}:{template}" if namespace else template
154
+ )
137
155
  wrapped = _cache.cache(ttl_val, key_with_namespace)(func)
138
156
  except Exception as e:
139
157
  error_msgs.append(f"minimal fallback: {e}")
140
158
  logger.error(f"All cache decorator attempts failed: {error_msgs}")
141
- raise RuntimeError(f"Failed to apply cache decorator: {error_msgs[-1]}") from e
159
+ raise RuntimeError(
160
+ f"Failed to apply cache decorator: {error_msgs[-1]}"
161
+ ) from e
142
162
 
143
163
  # Attach key variants renderer for cache writers
144
164
  setattr(wrapped, "__svc_key_variants__", build_key_variants_renderer(template))
145
- return wrapped
165
+
166
+ # If tags were provided as a callable, populate cashews tag sets manually.
167
+ # This is best-effort and only affects invalidation-by-tag behavior.
168
+ if dynamic_tags_func is None:
169
+ return wrapped
170
+
171
+ sig = inspect.signature(func)
172
+ tag_key_prefix = getattr(_cache, "_tags_key_prefix", "_tag:")
173
+
174
+ async def _wrapped_with_dynamic_tags(*args, **kwargs):
175
+ result = await wrapped(*args, **kwargs)
176
+
177
+ try:
178
+ bound = sig.bind_partial(*args, **kwargs)
179
+ bound.apply_defaults()
180
+ ctx = dict(bound.arguments)
181
+
182
+ rendered_key = validate_cache_key(template.format(**ctx))
183
+ full_key = f"{namespace}:{rendered_key}" if namespace else rendered_key
184
+
185
+ raw_tags = dynamic_tags_func(*args, **kwargs)
186
+ for t in list(raw_tags) if raw_tags is not None else []:
187
+ tag_val = str(t)
188
+ if "{" in tag_val and "}" in tag_val:
189
+ try:
190
+ tag_val = tag_val.format(**ctx)
191
+ except Exception:
192
+ pass
193
+ if tag_val:
194
+ await _cache.set_add(
195
+ tag_key_prefix + tag_val, full_key, expire=ttl_val
196
+ )
197
+ except Exception:
198
+ # Don't let best-effort tag mapping break cache reads.
199
+ pass
200
+
201
+ return result
202
+
203
+ setattr(
204
+ _wrapped_with_dynamic_tags,
205
+ "__svc_key_variants__",
206
+ getattr(wrapped, "__svc_key_variants__", None),
207
+ )
208
+ return _wrapped_with_dynamic_tags
146
209
 
147
210
  return _decorator
148
211
 
@@ -203,7 +266,10 @@ def cache_write(
203
266
  if recache:
204
267
  try:
205
268
  await execute_recache(
206
- recache, *args, max_concurrency=recache_max_concurrency, **kwargs
269
+ recache,
270
+ *args,
271
+ max_concurrency=recache_max_concurrency,
272
+ **kwargs,
207
273
  )
208
274
  except Exception as e:
209
275
  logger.error(f"Cache recaching failed: {e}")
svc_infra/cache/demo.py CHANGED
@@ -83,10 +83,10 @@ async def main():
83
83
  # Try to fetch deleted user - should hit DB and get KeyError
84
84
  try:
85
85
  p4 = await get_user_profile(user_id=uid)
86
- print("ERROR: Fetched profile after delete:", p4)
86
+ print("[ERROR] Fetched profile after delete:", p4)
87
87
  print("This shouldn't happen - user should be deleted!")
88
88
  except KeyError as e:
89
- print(f" User successfully deleted - {e}")
89
+ print(f"[OK] User successfully deleted - {e}")
90
90
  print("Cache invalidation and deletion worked perfectly!")
91
91
 
92
92
 
svc_infra/cache/keys.py CHANGED
@@ -88,12 +88,32 @@ def build_key_variants_renderer(template: str) -> Callable[..., list[str]]:
88
88
 
89
89
 
90
90
  def resolve_tags(tags, *args, **kwargs) -> list[str]:
91
- """Resolve tags from static list or callable."""
91
+ """Resolve tags from static list or callable and render templates with kwargs.
92
+
93
+ Supports entries like "thing:{id}" which will be formatted using provided kwargs.
94
+ Non-string items are passed through as str(). Missing keys are skipped with a warning.
95
+ """
92
96
  try:
97
+ # 1) Obtain raw tags list
93
98
  if callable(tags):
94
- result = tags(*args, **kwargs)
95
- return list(result) if result is not None else []
96
- return list(tags)
99
+ raw = tags(*args, **kwargs)
100
+ raw_list = list(raw) if raw is not None else []
101
+ else:
102
+ raw_list = list(tags)
103
+
104
+ # 2) Render any templates using kwargs
105
+ rendered: list[str] = []
106
+ for t in raw_list:
107
+ try:
108
+ if isinstance(t, str) and ("{" in t and "}" in t):
109
+ rendered.append(t.format(**kwargs))
110
+ else:
111
+ rendered.append(str(t))
112
+ except KeyError as e:
113
+ logger.warning(f"Tag template missing key {e} in '{t}'")
114
+ except Exception as e:
115
+ logger.warning(f"Failed to render tag '{t}': {e}")
116
+ return [r for r in rendered if r]
97
117
  except Exception as e:
98
118
  logger.error(f"Failed to resolve cache tags: {e}")
99
119
  return []
@@ -61,7 +61,9 @@ def recache(
61
61
  Returns:
62
62
  RecachePlan instance
63
63
  """
64
- return RecachePlan(getter=getter, include=include, rename=rename, extra=extra, key=key)
64
+ return RecachePlan(
65
+ getter=getter, include=include, rename=rename, extra=extra, key=key
66
+ )
65
67
 
66
68
 
67
69
  RecacheSpec = Union[
@@ -165,7 +167,7 @@ def build_getter_kwargs(
165
167
  if isinstance(spec, tuple):
166
168
  getter, mapping_or_builder = spec
167
169
  getter_params = signature(getter).parameters
168
- call_kwargs: dict[str, Any] = {}
170
+ legacy_call_kwargs: dict[str, Any] = {}
169
171
 
170
172
  if callable(mapping_or_builder):
171
173
  try:
@@ -173,7 +175,7 @@ def build_getter_kwargs(
173
175
  if isinstance(produced, dict):
174
176
  for param_name, value in produced.items():
175
177
  if param_name in getter_params:
176
- call_kwargs[param_name] = value
178
+ legacy_call_kwargs[param_name] = value
177
179
  except Exception as e:
178
180
  logger.warning(f"Recache mapping function failed: {e}")
179
181
  elif isinstance(mapping_or_builder, dict):
@@ -182,25 +184,31 @@ def build_getter_kwargs(
182
184
  continue
183
185
  try:
184
186
  if callable(source):
185
- call_kwargs[getter_param] = source(*mut_args, **mut_kwargs)
187
+ legacy_call_kwargs[getter_param] = source(
188
+ *mut_args, **mut_kwargs
189
+ )
186
190
  elif isinstance(source, str) and source in mut_kwargs:
187
- call_kwargs[getter_param] = mut_kwargs[source]
191
+ legacy_call_kwargs[getter_param] = mut_kwargs[source]
188
192
  except Exception as e:
189
- logger.warning(f"Recache parameter mapping failed for {getter_param}: {e}")
193
+ logger.warning(
194
+ f"Recache parameter mapping failed for {getter_param}: {e}"
195
+ )
190
196
 
191
197
  # Add direct parameter matches
192
198
  for param_name in getter_params.keys():
193
- if param_name not in call_kwargs and param_name in mut_kwargs:
194
- call_kwargs[param_name] = mut_kwargs[param_name]
199
+ if param_name not in legacy_call_kwargs and param_name in mut_kwargs:
200
+ legacy_call_kwargs[param_name] = mut_kwargs[param_name]
195
201
 
196
- call_kwargs = {k: v for k, v in call_kwargs.items() if k in getter_params}
197
- return getter, call_kwargs
202
+ legacy_call_kwargs = {
203
+ k: v for k, v in legacy_call_kwargs.items() if k in getter_params
204
+ }
205
+ return getter, legacy_call_kwargs
198
206
 
199
207
  # Handle simple getter function
200
208
  getter = spec
201
209
  getter_params = signature(getter).parameters
202
- call_kwargs = {k: v for k, v in mut_kwargs.items() if k in getter_params}
203
- return getter, call_kwargs
210
+ simple_call_kwargs = {k: v for k, v in mut_kwargs.items() if k in getter_params}
211
+ return getter, simple_call_kwargs
204
212
 
205
213
 
206
214
  async def execute_recache(
@@ -224,7 +232,9 @@ async def execute_recache(
224
232
  try:
225
233
  await _cache.delete(key_variant)
226
234
  except Exception as e:
227
- logger.debug(f"Failed to delete cache key {key_variant}: {e}")
235
+ logger.debug(
236
+ f"Failed to delete cache key {key_variant}: {e}"
237
+ )
228
238
 
229
239
  # Execute the getter to warm the cache
230
240
  await getter(**call_kwargs)
@@ -233,4 +243,6 @@ async def execute_recache(
233
243
  logger.error(f"Recache operation failed: {e}")
234
244
 
235
245
  # Execute all recache operations concurrently
236
- await asyncio.gather(*[_run_single_recache(spec) for spec in specs], return_exceptions=True)
246
+ await asyncio.gather(
247
+ *[_run_single_recache(spec) for spec in specs], return_exceptions=True
248
+ )
@@ -63,7 +63,9 @@ class Resource:
63
63
 
64
64
  def _decorator(func: Callable):
65
65
  try:
66
- return _cache(ttl=ttl, key=key_template, tags=tags_template, lock=lock)(func)
66
+ return _cache(ttl=ttl, key=key_template, tags=tags_template, lock=lock)(
67
+ func
68
+ )
67
69
  except TypeError:
68
70
  # Fallback for older cashews versions
69
71
  return _cache(ttl=ttl, key=key_template, tags=tags_template)(func)
@@ -97,7 +99,9 @@ class Resource:
97
99
  """Delete all cache keys for a specific entity."""
98
100
  namespace = _alias() or ""
99
101
  namespace_prefix = (
100
- f"{namespace}:" if namespace and not namespace.endswith(":") else namespace
102
+ f"{namespace}:"
103
+ if namespace and not namespace.endswith(":")
104
+ else namespace
101
105
  )
102
106
 
103
107
  # Generate candidate keys to delete
@@ -133,7 +137,9 @@ class Resource:
133
137
  # Namespaced wildcard
134
138
  if namespace_prefix:
135
139
  await _maybe_await(
136
- delete_match(f"{namespace_prefix}{entity_name}:*:{entity_id}*")
140
+ delete_match(
141
+ f"{namespace_prefix}{entity_name}:*:{entity_id}*"
142
+ )
137
143
  )
138
144
  # Non-namespaced wildcard
139
145
  await _maybe_await(delete_match(f"{entity_name}:*:{entity_id}*"))
@@ -157,7 +163,8 @@ class Resource:
157
163
  logger.error(f"Resource recache failed: {e}")
158
164
 
159
165
  await asyncio.gather(
160
- *[_run_single_resource_recache(spec) for spec in specs], return_exceptions=True
166
+ *[_run_single_resource_recache(spec) for spec in specs],
167
+ return_exceptions=True,
161
168
  )
162
169
 
163
170
  def _decorator(mutator: Callable):
@@ -171,7 +178,9 @@ class Resource:
171
178
  # Tag invalidation
172
179
  invalidate_func = getattr(_cache, "invalidate", None)
173
180
  if callable(invalidate_func):
174
- await _maybe_await(invalidate_func(f"{self.name}:{entity_id}"))
181
+ await _maybe_await(
182
+ invalidate_func(f"{self.name}:{entity_id}")
183
+ )
175
184
 
176
185
  # Precise key deletion
177
186
  await _delete_entity_keys(self.name, str(entity_id))
svc_infra/cache/tags.py CHANGED
@@ -28,50 +28,25 @@ async def invalidate_tags(*tags: str) -> int:
28
28
  if not tags:
29
29
  return 0
30
30
 
31
- count = 0
31
+ # Preserve order while de-duplicating.
32
+ tags_to_delete = list(dict.fromkeys(tags))
32
33
 
33
- # Strategy 1: Modern cashews invalidate with tags parameter
34
+ # Cashews supports explicit tag deletion via delete_tags().
34
35
  try:
35
- result = await _cache.invalidate(tags=list(tags))
36
- return int(result) if isinstance(result, int) else len(tags)
37
- except (TypeError, AttributeError):
38
- pass
36
+ if hasattr(_cache, "delete_tags"):
37
+ await _cache.delete_tags(*tags_to_delete)
38
+ return len(tags_to_delete)
39
39
  except Exception as e:
40
- logger.warning(f"Modern tag invalidation failed: {e}")
41
-
42
- # Strategy 2: Legacy cashews invalidate with positional args
43
- try:
44
- result = await _cache.invalidate(*tags)
45
- return int(result) if isinstance(result, int) else len(tags)
46
- except (TypeError, AttributeError):
47
- pass
48
- except Exception as e:
49
- logger.warning(f"Legacy tag invalidation failed: {e}")
50
-
51
- # Strategy 3: Individual tag methods
52
- for tag in tags:
53
- for method_name in ("delete_tag", "invalidate_tag", "tag_invalidate"):
54
- if hasattr(_cache, method_name):
55
- try:
56
- method = getattr(_cache, method_name)
57
- result = await method(tag)
58
- count += int(result) if isinstance(result, int) else 1
59
- break
60
- except Exception as e:
61
- logger.debug(f"Tag method {method_name} failed for tag {tag}: {e}")
62
- continue
63
- else:
64
- # Strategy 4: Pattern matching fallback
65
- for method_name in ("delete_match", "invalidate_match", "invalidate"):
66
- if hasattr(_cache, method_name):
67
- try:
68
- method = getattr(_cache, method_name)
69
- pattern = f"*{tag}*"
70
- result = await method(pattern)
71
- count += int(result) if isinstance(result, int) else 1
72
- break
73
- except Exception as e:
74
- logger.debug(f"Pattern method {method_name} failed for tag {tag}: {e}")
75
- continue
76
-
77
- return count
40
+ logger.warning(f"Cache tag invalidation failed: {e}")
41
+
42
+ # Fallback: attempt private per-tag deletion when available.
43
+ deleted = 0
44
+ for tag in tags_to_delete:
45
+ try:
46
+ if hasattr(_cache, "_delete_tag"):
47
+ await _cache._delete_tag(tag)
48
+ deleted += 1
49
+ except Exception as e:
50
+ logger.debug(f"Tag deletion failed for tag {tag}: {e}")
51
+
52
+ return deleted
svc_infra/cache/utils.py CHANGED
@@ -33,7 +33,9 @@ def stable_hash(*args: Any, **kwargs: Any) -> str:
33
33
  """
34
34
  try:
35
35
  # Use JSON serialization for stable, deterministic output
36
- raw = json.dumps([args, kwargs], default=str, sort_keys=True, separators=(",", ":"))
36
+ raw = json.dumps(
37
+ [args, kwargs], default=str, sort_keys=True, separators=(",", ":")
38
+ )
37
39
  except (TypeError, ValueError) as e:
38
40
  # Fallback to repr if JSON serialization fails
39
41
  logger.warning(f"JSON serialization failed for hash input, using repr: {e}")