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
@@ -5,17 +5,29 @@ 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
- from .decorators import cached # alias for cache_read
10
- from .decorators import mutates # alias for cache_write
11
- from .decorators import cache_read, cache_write, init_cache, init_cache_async
14
+ from .decorators import (
15
+ cache_read,
16
+ cache_write,
17
+ cached, # alias for cache_read
18
+ init_cache,
19
+ init_cache_async,
20
+ mutates, # alias for cache_write
21
+ )
12
22
 
13
23
  # Recaching functionality for advanced use cases
14
24
  from .recache import RecachePlan, recache
15
25
 
16
26
  # Resource management for entity-based caching
17
- from .resources import entity # legacy alias
18
- from .resources import resource
27
+ from .resources import (
28
+ entity, # legacy alias
29
+ resource,
30
+ )
19
31
 
20
32
  __all__ = [
21
33
  # Primary decorators developers use
@@ -32,4 +44,8 @@ __all__ = [
32
44
  # Resource-based caching
33
45
  "resource",
34
46
  "entity",
47
+ # Easy integration helper
48
+ "add_cache",
49
+ # Cache instance access
50
+ "get_cache",
35
51
  ]
svc_infra/cache/add.py ADDED
@@ -0,0 +1,167 @@
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 collections.abc import Callable
18
+ from typing import Any
19
+
20
+ from svc_infra.cache.backend import DEFAULT_READINESS_TIMEOUT
21
+ from svc_infra.cache.backend import get_cache as _get_cache
22
+ from svc_infra.cache.backend import setup_cache as _setup_cache
23
+ from svc_infra.cache.backend import shutdown_cache as _shutdown_cache
24
+ from svc_infra.cache.backend import wait_ready as _wait_ready
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def _instance() -> Any:
30
+ """Return the current cache instance.
31
+
32
+ This is a thin compatibility shim used by tests and older callers.
33
+ """
34
+
35
+ return _get_cache()
36
+
37
+
38
+ def _derive_settings(
39
+ url: str | None, prefix: str | None, version: str | None
40
+ ) -> tuple[str, str, str]:
41
+ """Derive cache settings from parameters or environment variables.
42
+
43
+ Precedence:
44
+ - explicit function arguments
45
+ - environment variables (CACHE_URL/REDIS_URL, CACHE_PREFIX, CACHE_VERSION)
46
+ - sensible defaults (mem://, "svc", "v1")
47
+ """
48
+
49
+ derived_url = url or os.getenv("CACHE_URL") or os.getenv("REDIS_URL") or "mem://"
50
+ derived_prefix = prefix or os.getenv("CACHE_PREFIX") or "svc"
51
+ derived_version = version or os.getenv("CACHE_VERSION") or "v1"
52
+ return derived_url, derived_prefix, derived_version
53
+
54
+
55
+ def add_cache(
56
+ app: Any | None = None,
57
+ *,
58
+ url: str | None = None,
59
+ prefix: str | None = None,
60
+ version: str | None = None,
61
+ readiness_timeout: float | None = None,
62
+ expose_state: bool = True,
63
+ state_key: str = "cache",
64
+ ) -> Callable[[], None]:
65
+ """Wire cache initialization and lifecycle into the ASGI app.
66
+
67
+ If an app is provided, registers startup/shutdown handlers. Otherwise performs
68
+ immediate initialization (best-effort) without awaiting readiness.
69
+
70
+ Returns a no-op shutdown callable for API symmetry with other helpers.
71
+ """
72
+
73
+ # Compute effective settings
74
+ eff_url, eff_prefix, eff_version = _derive_settings(url, prefix, version)
75
+
76
+ # If no app provided, do a simple init and return
77
+ if app is None:
78
+ try:
79
+ _setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
80
+ logger.info(
81
+ "Cache initialized (no app wiring): backend=%s namespace=%s",
82
+ eff_url,
83
+ f"{eff_prefix}:{eff_version}",
84
+ )
85
+ except Exception:
86
+ logger.exception("Cache initialization failed (no app wiring)")
87
+ return lambda: None
88
+
89
+ # Idempotence: avoid duplicate wiring
90
+ try:
91
+ state = getattr(app, "state", None)
92
+ already = bool(getattr(state, "_svc_cache_wired", False))
93
+ except Exception:
94
+ state = None
95
+ already = False
96
+
97
+ if already:
98
+ logger.debug("add_cache: app already wired; skipping re-registration")
99
+ return lambda: None
100
+
101
+ # Define lifecycle handlers
102
+ async def _startup():
103
+ _setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
104
+ try:
105
+ await _wait_ready(timeout=readiness_timeout or DEFAULT_READINESS_TIMEOUT)
106
+ except Exception:
107
+ # Bubble up to fail fast on startup; tests and prod prefer visibility
108
+ logger.exception("Cache readiness probe failed during startup")
109
+ raise
110
+ # Expose cache instance for convenience
111
+ if expose_state and hasattr(app, "state"):
112
+ try:
113
+ setattr(app.state, state_key, _instance())
114
+ except Exception:
115
+ logger.debug("Unable to expose cache instance on app.state", exc_info=True)
116
+
117
+ async def _shutdown():
118
+ try:
119
+ await _shutdown_cache()
120
+ except Exception:
121
+ # Best-effort; shutdown should not crash the app
122
+ logger.debug("Cache shutdown encountered errors (ignored)", exc_info=True)
123
+
124
+ # Register event handlers when supported
125
+ register_ok = False
126
+ try:
127
+ if hasattr(app, "add_event_handler"):
128
+ app.add_event_handler("startup", _startup)
129
+ app.add_event_handler("shutdown", _shutdown)
130
+ register_ok = True
131
+ except Exception:
132
+ register_ok = False
133
+
134
+ if not register_ok:
135
+ # Fallback: attempt FastAPI/Starlette .on_event decorators dynamically
136
+ try:
137
+ on_event = getattr(app, "on_event", None)
138
+ if callable(on_event):
139
+ on_event("startup")(_startup)
140
+ on_event("shutdown")(_shutdown)
141
+ register_ok = True
142
+ except Exception:
143
+ register_ok = False
144
+
145
+ # Mark wired and expose state immediately if desired
146
+ if hasattr(app, "state"):
147
+ try:
148
+ app.state._svc_cache_wired = True
149
+ if expose_state and not hasattr(app.state, state_key):
150
+ setattr(app.state, state_key, _instance())
151
+ except Exception:
152
+ pass
153
+
154
+ if register_ok:
155
+ logger.info("Cache wired: url=%s namespace=%s", eff_url, f"{eff_prefix}:{eff_version}")
156
+ else:
157
+ # If we cannot register handlers, at least initialize now
158
+ try:
159
+ _setup_cache(url=eff_url, prefix=eff_prefix, version=eff_version)
160
+ except Exception:
161
+ logger.exception("Cache initialization failed (no event registration)")
162
+
163
+ # Return a simple shutdown handle for symmetry with other add_* helpers
164
+ return lambda: None
165
+
166
+
167
+ __all__ = ["add_cache"]
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- from typing import Optional
5
4
 
6
5
  from cashews import cache as _cache
7
6
 
@@ -42,10 +41,10 @@ def _full_prefix() -> str:
42
41
 
43
42
 
44
43
  def setup_cache(
45
- url: Optional[str] = None,
44
+ url: str | None = None,
46
45
  *,
47
- prefix: Optional[str] = None,
48
- version: Optional[str] = None,
46
+ prefix: str | None = None,
47
+ version: str | None = None,
49
48
  ):
50
49
  """
51
50
  Configure Cashews and set a global key prefix for namespacing.
@@ -80,9 +79,12 @@ def setup_cache(
80
79
  logger.info(f"Cache version updated to: {_current_version}")
81
80
 
82
81
  # Setup backend connection
82
+ # Newer cashews versions require an explicit settings_url; default to in-memory
83
+ # backend when no URL is provided so acceptance/unit tests work out of the box.
83
84
  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'}")
85
+ settings_url = url or "mem://"
86
+ setup_awaitable = _cache.setup(settings_url)
87
+ logger.info(f"Cache backend setup initiated with URL: {settings_url}")
86
88
  except Exception as e:
87
89
  logger.error(f"Failed to setup cache backend: {e}")
88
90
  raise
@@ -144,7 +146,7 @@ async def shutdown_cache() -> None:
144
146
  logger.warning(f"Error during cache shutdown (ignored): {e}")
145
147
 
146
148
 
147
- def instance():
149
+ def get_cache():
148
150
  """
149
151
  Get the underlying cashews cache instance.
150
152
 
@@ -7,8 +7,10 @@ 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
- from typing import Any, Awaitable, Callable, Iterable, Optional, Union
12
+ from collections.abc import Awaitable, Callable, Iterable
13
+ from typing import Any
12
14
 
13
15
  from cashews import cache as _cache
14
16
 
@@ -16,16 +18,12 @@ from svc_infra.cache.backend import alias as _alias
16
18
  from svc_infra.cache.backend import setup_cache as _setup_cache
17
19
  from svc_infra.cache.backend import wait_ready as _wait_ready
18
20
 
19
- from .keys import (
20
- build_key_template,
21
- build_key_variants_renderer,
22
- create_tags_function,
23
- resolve_tags,
24
- )
21
+ from .keys import build_key_template, build_key_variants_renderer, resolve_tags
25
22
  from .recache import RecachePlan, RecacheSpec, execute_recache, recache
26
23
  from .resources import Resource, entity, resource
27
24
  from .tags import invalidate_tags
28
25
  from .ttl import validate_ttl
26
+ from .utils import validate_cache_key
29
27
 
30
28
  logger = logging.getLogger(__name__)
31
29
 
@@ -67,11 +65,11 @@ async def init_cache_async(
67
65
 
68
66
  def cache_read(
69
67
  *,
70
- key: Union[str, tuple[str, ...]],
71
- ttl: Optional[int] = None,
72
- tags: Optional[Union[Iterable[str], Callable[..., Iterable[str]]]] = None,
73
- early_ttl: Optional[int] = None,
74
- refresh: Optional[bool] = None,
68
+ key: str | tuple[str, ...],
69
+ ttl: int | None = None,
70
+ tags: Iterable[str] | Callable[..., Iterable[str]] | None = None,
71
+ early_ttl: int | None = None,
72
+ refresh: bool | None = None,
75
73
  ):
76
74
  """
77
75
  Cache decorator for read operations with version-resilient key handling.
@@ -98,18 +96,33 @@ def cache_read(
98
96
  ttl_val = validate_ttl(ttl)
99
97
  template = build_key_template(key)
100
98
  namespace = _alias() or ""
101
- tags_func = create_tags_function(tags)
99
+ # Cashews expects `tags` to be an iterable of (template) strings.
100
+ # If no explicit tags are provided, default to tagging by the key template.
101
+ # This enables the common pattern:
102
+ # @cache_read(key="thing:{id}")
103
+ # @cache_write(tags=["thing:{id}"])
104
+ # where writes invalidate reads without requiring tags on every read.
105
+ dynamic_tags_func: Callable[..., Iterable[str]] | None = None
106
+ if tags is None:
107
+ tags_param: Iterable[str] = (template,)
108
+ elif callable(tags):
109
+ # Preserve API surface area, but cashews doesn't accept callables here.
110
+ # We'll attach tag mappings manually after each call.
111
+ dynamic_tags_func = tags
112
+ tags_param = ()
113
+ else:
114
+ tags_param = tags
102
115
 
103
116
  def _decorator(func: Callable[..., Awaitable[Any]]):
104
117
  # Try different cashews cache decorator signatures for compatibility
105
- cache_kwargs = {"tags": tags_func}
118
+ cache_kwargs: dict[str, Any] = {"tags": tuple(tags_param)}
106
119
  if early_ttl is not None:
107
120
  cache_kwargs["early_ttl"] = early_ttl
108
121
  if refresh is not None:
109
122
  cache_kwargs["refresh"] = refresh
110
123
 
111
124
  wrapped = None
112
- error_msgs = []
125
+ error_msgs: list[str] = []
113
126
 
114
127
  # Attempt 1: With prefix parameter (preferred)
115
128
  if namespace:
@@ -141,8 +154,47 @@ def cache_read(
141
154
  raise RuntimeError(f"Failed to apply cache decorator: {error_msgs[-1]}") from e
142
155
 
143
156
  # Attach key variants renderer for cache writers
144
- setattr(wrapped, "__svc_key_variants__", build_key_variants_renderer(template))
145
- return wrapped
157
+ wrapped.__svc_key_variants__ = build_key_variants_renderer(template) # type: ignore[attr-defined]
158
+
159
+ # If tags were provided as a callable, populate cashews tag sets manually.
160
+ # This is best-effort and only affects invalidation-by-tag behavior.
161
+ if dynamic_tags_func is None:
162
+ return wrapped
163
+
164
+ sig = inspect.signature(func)
165
+ tag_key_prefix = getattr(_cache, "_tags_key_prefix", "_tag:")
166
+
167
+ async def _wrapped_with_dynamic_tags(*args, **kwargs):
168
+ result = await wrapped(*args, **kwargs)
169
+
170
+ try:
171
+ bound = sig.bind_partial(*args, **kwargs)
172
+ bound.apply_defaults()
173
+ ctx = dict(bound.arguments)
174
+
175
+ rendered_key = validate_cache_key(template.format(**ctx))
176
+ full_key = f"{namespace}:{rendered_key}" if namespace else rendered_key
177
+
178
+ raw_tags = dynamic_tags_func(*args, **kwargs)
179
+ for t in list(raw_tags) if raw_tags is not None else []:
180
+ tag_val = str(t)
181
+ if "{" in tag_val and "}" in tag_val:
182
+ try:
183
+ tag_val = tag_val.format(**ctx)
184
+ except Exception:
185
+ pass
186
+ if tag_val:
187
+ await _cache.set_add(tag_key_prefix + tag_val, full_key, expire=ttl_val)
188
+ except Exception:
189
+ # Don't let best-effort tag mapping break cache reads.
190
+ pass
191
+
192
+ return result
193
+
194
+ _wrapped_with_dynamic_tags.__svc_key_variants__ = getattr( # type: ignore[attr-defined]
195
+ wrapped, "__svc_key_variants__", None
196
+ )
197
+ return _wrapped_with_dynamic_tags
146
198
 
147
199
  return _decorator
148
200
 
@@ -156,8 +208,8 @@ cached = cache_read
156
208
 
157
209
  def cache_write(
158
210
  *,
159
- tags: Union[Iterable[str], Callable[..., Iterable[str]]],
160
- recache: Optional[Iterable[RecacheSpec]] = None,
211
+ tags: Iterable[str] | Callable[..., Iterable[str]],
212
+ recache: Iterable[RecacheSpec] | None = None,
161
213
  recache_max_concurrency: int = 5,
162
214
  ):
163
215
  """
@@ -203,7 +255,10 @@ def cache_write(
203
255
  if recache:
204
256
  try:
205
257
  await execute_recache(
206
- recache, *args, max_concurrency=recache_max_concurrency, **kwargs
258
+ recache,
259
+ *args,
260
+ max_concurrency=recache_max_concurrency,
261
+ **kwargs,
207
262
  )
208
263
  except Exception as e:
209
264
  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
@@ -6,7 +6,7 @@ with version-resilient handling and namespace support.
6
6
  """
7
7
 
8
8
  import logging
9
- from typing import Callable, Union
9
+ from collections.abc import Callable
10
10
 
11
11
  from svc_infra.cache.backend import alias as _alias
12
12
 
@@ -15,7 +15,7 @@ from .utils import validate_cache_key
15
15
  logger = logging.getLogger(__name__)
16
16
 
17
17
 
18
- def build_key_template(key: Union[str, tuple[str, ...]]) -> str:
18
+ def build_key_template(key: str | tuple[str, ...]) -> str:
19
19
  """Convert key to template string."""
20
20
  if isinstance(key, tuple):
21
21
  parts = [part for part in key if part]
@@ -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 []
@@ -7,9 +7,10 @@ including recache plans and execution strategies.
7
7
 
8
8
  import asyncio
9
9
  import logging
10
+ from collections.abc import Awaitable, Callable, Iterable
10
11
  from dataclasses import dataclass
11
12
  from inspect import Parameter, signature
12
- from typing import Any, Awaitable, Callable, Iterable, Optional, Union
13
+ from typing import Any
13
14
 
14
15
  from cashews import cache as _cache
15
16
 
@@ -34,19 +35,19 @@ class RecachePlan:
34
35
  """
35
36
 
36
37
  getter: Callable[..., Awaitable[Any]]
37
- include: Optional[Iterable[str]] = None
38
- rename: Optional[dict[str, str]] = None
39
- extra: Optional[dict[str, Any]] = None
40
- key: Optional[Union[str, tuple[str, ...]]] = None
38
+ include: Iterable[str] | None = None
39
+ rename: dict[str, str] | None = None
40
+ extra: dict[str, Any] | None = None
41
+ key: str | tuple[str, ...] | None = None
41
42
 
42
43
 
43
44
  def recache(
44
45
  getter: Callable[..., Awaitable[Any]],
45
46
  *,
46
- include: Optional[Iterable[str]] = None,
47
- rename: Optional[dict[str, str]] = None,
48
- extra: Optional[dict[str, Any]] = None,
49
- key: Optional[Union[str, tuple[str, ...]]] = None,
47
+ include: Iterable[str] | None = None,
48
+ rename: dict[str, str] | None = None,
49
+ extra: dict[str, Any] | None = None,
50
+ key: str | tuple[str, ...] | None = None,
50
51
  ) -> RecachePlan:
51
52
  """
52
53
  Create a recache plan for cache warming after invalidation.
@@ -64,16 +65,14 @@ def recache(
64
65
  return RecachePlan(getter=getter, include=include, rename=rename, extra=extra, key=key)
65
66
 
66
67
 
67
- RecacheSpec = Union[
68
- Callable[..., Awaitable[Any]],
69
- RecachePlan,
70
- tuple[Callable[..., Awaitable[Any]], Any], # Legacy format
71
- ]
68
+ RecacheSpec = (
69
+ Callable[..., Awaitable[Any]]
70
+ | RecachePlan
71
+ | tuple[Callable[..., Awaitable[Any]], Any] # Legacy format
72
+ )
72
73
 
73
74
 
74
- def generate_key_variants(
75
- template: Union[str, tuple[str, ...]], params: dict[str, Any]
76
- ) -> list[str]:
75
+ def generate_key_variants(template: str | tuple[str, ...], params: dict[str, Any]) -> list[str]:
77
76
  """
78
77
  Generate all possible cache key variants for deletion.
79
78
 
@@ -165,7 +164,7 @@ def build_getter_kwargs(
165
164
  if isinstance(spec, tuple):
166
165
  getter, mapping_or_builder = spec
167
166
  getter_params = signature(getter).parameters
168
- call_kwargs: dict[str, Any] = {}
167
+ legacy_call_kwargs: dict[str, Any] = {}
169
168
 
170
169
  if callable(mapping_or_builder):
171
170
  try:
@@ -173,7 +172,7 @@ def build_getter_kwargs(
173
172
  if isinstance(produced, dict):
174
173
  for param_name, value in produced.items():
175
174
  if param_name in getter_params:
176
- call_kwargs[param_name] = value
175
+ legacy_call_kwargs[param_name] = value
177
176
  except Exception as e:
178
177
  logger.warning(f"Recache mapping function failed: {e}")
179
178
  elif isinstance(mapping_or_builder, dict):
@@ -182,25 +181,25 @@ def build_getter_kwargs(
182
181
  continue
183
182
  try:
184
183
  if callable(source):
185
- call_kwargs[getter_param] = source(*mut_args, **mut_kwargs)
184
+ legacy_call_kwargs[getter_param] = source(*mut_args, **mut_kwargs)
186
185
  elif isinstance(source, str) and source in mut_kwargs:
187
- call_kwargs[getter_param] = mut_kwargs[source]
186
+ legacy_call_kwargs[getter_param] = mut_kwargs[source]
188
187
  except Exception as e:
189
188
  logger.warning(f"Recache parameter mapping failed for {getter_param}: {e}")
190
189
 
191
190
  # Add direct parameter matches
192
191
  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]
192
+ if param_name not in legacy_call_kwargs and param_name in mut_kwargs:
193
+ legacy_call_kwargs[param_name] = mut_kwargs[param_name]
195
194
 
196
- call_kwargs = {k: v for k, v in call_kwargs.items() if k in getter_params}
197
- return getter, call_kwargs
195
+ legacy_call_kwargs = {k: v for k, v in legacy_call_kwargs.items() if k in getter_params}
196
+ return getter, legacy_call_kwargs
198
197
 
199
198
  # Handle simple getter function
200
199
  getter = spec
201
200
  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
201
+ simple_call_kwargs = {k: v for k, v in mut_kwargs.items() if k in getter_params}
202
+ return getter, simple_call_kwargs
204
203
 
205
204
 
206
205
  async def execute_recache(
@@ -8,7 +8,7 @@ with standardized key patterns and tag management.
8
8
  import asyncio
9
9
  import inspect
10
10
  import logging
11
- from typing import Callable, Optional, Tuple
11
+ from collections.abc import Callable
12
12
 
13
13
  from cashews import cache as _cache
14
14
 
@@ -41,8 +41,8 @@ class Resource:
41
41
  *,
42
42
  suffix: str,
43
43
  ttl: int,
44
- key_template: Optional[str] = None,
45
- tags_template: Optional[Tuple[str, ...]] = None,
44
+ key_template: str | None = None,
45
+ tags_template: tuple[str, ...] | None = None,
46
46
  lock: bool = True,
47
47
  ):
48
48
  """
@@ -73,7 +73,7 @@ class Resource:
73
73
  def cache_write(
74
74
  self,
75
75
  *,
76
- recache: Optional[list[tuple[Callable, Callable]]] = None,
76
+ recache: list[tuple[Callable, Callable]] | None = None,
77
77
  recache_max_concurrency: int = 5,
78
78
  ):
79
79
  """
@@ -157,7 +157,8 @@ class Resource:
157
157
  logger.error(f"Resource recache failed: {e}")
158
158
 
159
159
  await asyncio.gather(
160
- *[_run_single_resource_recache(spec) for spec in specs], return_exceptions=True
160
+ *[_run_single_resource_recache(spec) for spec in specs],
161
+ return_exceptions=True,
161
162
  )
162
163
 
163
164
  def _decorator(mutator: Callable):