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,682 @@
1
+ """Testing utilities for svc-infra applications.
2
+
3
+ This module provides mock implementations and test fixtures for
4
+ testing applications built with svc-infra, without requiring
5
+ real Redis, PostgreSQL, or other external services.
6
+
7
+ Features:
8
+ - MockCache: In-memory cache backend for tests
9
+ - MockJobQueue: Synchronous job queue for tests
10
+ - Test fixture factories for users and tenants
11
+ - Async test client utilities
12
+
13
+ Example:
14
+ >>> from svc_infra.testing import MockCache, MockJobQueue
15
+ >>>
16
+ >>> # Use mock cache in tests
17
+ >>> cache = MockCache()
18
+ >>> cache.set("key", "value", ttl=60)
19
+ >>> assert cache.get("key") == "value"
20
+ >>>
21
+ >>> # Use mock job queue
22
+ >>> queue = MockJobQueue()
23
+ >>> queue.enqueue("send_email", {"to": "test@example.com"})
24
+ >>> assert len(queue.jobs) == 1
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import time
30
+ import uuid
31
+ from collections.abc import Callable
32
+ from dataclasses import dataclass, field
33
+ from datetime import UTC, datetime, timedelta
34
+ from typing import Any, TypeVar
35
+
36
+ # Type variable for generic model creation
37
+ T = TypeVar("T")
38
+
39
+
40
+ # =============================================================================
41
+ # Mock Cache
42
+ # =============================================================================
43
+
44
+
45
+ @dataclass
46
+ class CacheEntry:
47
+ """Internal representation of a cached value."""
48
+
49
+ value: Any
50
+ expires_at: float | None = None # Unix timestamp
51
+
52
+ def is_expired(self) -> bool:
53
+ """Check if this entry has expired."""
54
+ if self.expires_at is None:
55
+ return False
56
+ return time.time() > self.expires_at
57
+
58
+
59
+ class MockCache:
60
+ """
61
+ In-memory cache backend for testing.
62
+
63
+ Provides a simple synchronous cache that mimics the behavior of
64
+ Redis or other cache backends without external dependencies.
65
+
66
+ Features:
67
+ - TTL support with expiration
68
+ - Key prefix namespacing
69
+ - Pattern-based key deletion
70
+ - Thread-safe for single-threaded tests
71
+
72
+ Example:
73
+ >>> cache = MockCache(prefix="test")
74
+ >>> cache.set("user:123", {"name": "Alice"}, ttl=300)
75
+ >>> cache.get("user:123")
76
+ {'name': 'Alice'}
77
+ >>> cache.delete("user:123")
78
+ >>> cache.get("user:123") is None
79
+ True
80
+ """
81
+
82
+ def __init__(self, prefix: str = "test"):
83
+ """
84
+ Initialize mock cache.
85
+
86
+ Args:
87
+ prefix: Key prefix for namespacing (default: "test")
88
+ """
89
+ self.prefix = prefix
90
+ self._store: dict[str, CacheEntry] = {}
91
+ self._tags: dict[str, set[str]] = {} # tag -> set of keys
92
+
93
+ def _prefixed_key(self, key: str) -> str:
94
+ """Get the full key with prefix."""
95
+ return f"{self.prefix}:{key}"
96
+
97
+ def get(self, key: str) -> Any | None:
98
+ """
99
+ Get a value from the cache.
100
+
101
+ Args:
102
+ key: Cache key
103
+
104
+ Returns:
105
+ Cached value or None if not found/expired
106
+ """
107
+ full_key = self._prefixed_key(key)
108
+ entry = self._store.get(full_key)
109
+ if entry is None:
110
+ return None
111
+ if entry.is_expired():
112
+ del self._store[full_key]
113
+ return None
114
+ return entry.value
115
+
116
+ def set(
117
+ self,
118
+ key: str,
119
+ value: Any,
120
+ ttl: int | None = None,
121
+ tags: list[str] | None = None,
122
+ ) -> None:
123
+ """
124
+ Set a value in the cache.
125
+
126
+ Args:
127
+ key: Cache key
128
+ value: Value to cache
129
+ ttl: Time-to-live in seconds (None for no expiration)
130
+ tags: Optional list of tags for grouped invalidation
131
+ """
132
+ full_key = self._prefixed_key(key)
133
+ expires_at = time.time() + ttl if ttl else None
134
+ self._store[full_key] = CacheEntry(value=value, expires_at=expires_at)
135
+
136
+ # Track tags
137
+ if tags:
138
+ for tag in tags:
139
+ if tag not in self._tags:
140
+ self._tags[tag] = set()
141
+ self._tags[tag].add(full_key)
142
+
143
+ def delete(self, key: str) -> bool:
144
+ """
145
+ Delete a key from the cache.
146
+
147
+ Args:
148
+ key: Cache key
149
+
150
+ Returns:
151
+ True if key existed, False otherwise
152
+ """
153
+ full_key = self._prefixed_key(key)
154
+ if full_key in self._store:
155
+ del self._store[full_key]
156
+ return True
157
+ return False
158
+
159
+ def delete_pattern(self, pattern: str) -> int:
160
+ """
161
+ Delete all keys matching a pattern.
162
+
163
+ Args:
164
+ pattern: Pattern with * as wildcard (e.g., "user:*")
165
+
166
+ Returns:
167
+ Number of keys deleted
168
+ """
169
+ import fnmatch
170
+
171
+ full_pattern = self._prefixed_key(pattern)
172
+ to_delete = [k for k in self._store if fnmatch.fnmatch(k, full_pattern)]
173
+ for key in to_delete:
174
+ del self._store[key]
175
+ return len(to_delete)
176
+
177
+ def delete_by_tag(self, tag: str) -> int:
178
+ """
179
+ Delete all keys associated with a tag.
180
+
181
+ Args:
182
+ tag: Tag name
183
+
184
+ Returns:
185
+ Number of keys deleted
186
+ """
187
+ keys = self._tags.pop(tag, set())
188
+ count = 0
189
+ for key in keys:
190
+ if key in self._store:
191
+ del self._store[key]
192
+ count += 1
193
+ return count
194
+
195
+ def exists(self, key: str) -> bool:
196
+ """
197
+ Check if a key exists in the cache.
198
+
199
+ Args:
200
+ key: Cache key
201
+
202
+ Returns:
203
+ True if key exists and is not expired
204
+ """
205
+ return self.get(key) is not None
206
+
207
+ def clear(self) -> None:
208
+ """Clear all cached values."""
209
+ self._store.clear()
210
+ self._tags.clear()
211
+
212
+ def keys(self, pattern: str = "*") -> list[str]:
213
+ """
214
+ Get all keys matching a pattern.
215
+
216
+ Args:
217
+ pattern: Pattern with * as wildcard
218
+
219
+ Returns:
220
+ List of matching keys (without prefix)
221
+ """
222
+ import fnmatch
223
+
224
+ full_pattern = self._prefixed_key(pattern)
225
+ prefix_len = len(self.prefix) + 1 # +1 for the colon
226
+ return [
227
+ k[prefix_len:]
228
+ for k in self._store
229
+ if fnmatch.fnmatch(k, full_pattern) and not self._store[k].is_expired()
230
+ ]
231
+
232
+ def size(self) -> int:
233
+ """Get the number of cached items (excluding expired)."""
234
+ # Clean up expired entries
235
+ now = time.time()
236
+ self._store = {
237
+ k: v for k, v in self._store.items() if v.expires_at is None or v.expires_at > now
238
+ }
239
+ return len(self._store)
240
+
241
+
242
+ # =============================================================================
243
+ # Mock Job Queue
244
+ # =============================================================================
245
+
246
+
247
+ @dataclass
248
+ class MockJob:
249
+ """Representation of a job in the mock queue."""
250
+
251
+ id: str
252
+ name: str
253
+ payload: dict[str, Any]
254
+ created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
255
+ available_at: datetime = field(default_factory=lambda: datetime.now(UTC))
256
+ attempts: int = 0
257
+ max_attempts: int = 5
258
+ status: str = "pending" # pending, processing, completed, failed
259
+ result: Any | None = None
260
+ error: str | None = None
261
+
262
+
263
+ class MockJobQueue:
264
+ """
265
+ Synchronous mock job queue for testing.
266
+
267
+ Jobs can be processed immediately (sync_mode=True) or queued
268
+ for manual processing. Useful for testing job handlers without
269
+ Redis or async complexity.
270
+
271
+ Example:
272
+ >>> queue = MockJobQueue()
273
+ >>>
274
+ >>> # Register a handler
275
+ >>> @queue.handler("send_email")
276
+ ... def handle_email(payload):
277
+ ... print(f"Sending to {payload['to']}")
278
+ ...
279
+ >>> # Enqueue a job
280
+ >>> job = queue.enqueue("send_email", {"to": "test@example.com"})
281
+ >>>
282
+ >>> # Process all pending jobs
283
+ >>> queue.process_all()
284
+ Sending to test@example.com
285
+ """
286
+
287
+ def __init__(self, sync_mode: bool = False):
288
+ """
289
+ Initialize mock job queue.
290
+
291
+ Args:
292
+ sync_mode: If True, execute jobs immediately on enqueue
293
+ """
294
+ self.sync_mode = sync_mode
295
+ self._seq = 0
296
+ self._jobs: list[MockJob] = []
297
+ self._handlers: dict[str, Callable[[dict[str, Any]], Any]] = {}
298
+ self._completed: list[MockJob] = []
299
+ self._failed: list[MockJob] = []
300
+
301
+ def _next_id(self) -> str:
302
+ """Generate next job ID."""
303
+ self._seq += 1
304
+ return f"job-{self._seq}"
305
+
306
+ def handler(self, name: str) -> Callable:
307
+ """
308
+ Decorator to register a job handler.
309
+
310
+ Args:
311
+ name: Job name to handle
312
+
313
+ Returns:
314
+ Decorator function
315
+ """
316
+
317
+ def decorator(func: Callable[[dict[str, Any]], Any]) -> Callable:
318
+ self._handlers[name] = func
319
+ return func
320
+
321
+ return decorator
322
+
323
+ def register_handler(self, name: str, handler: Callable[[dict[str, Any]], Any]) -> None:
324
+ """
325
+ Register a job handler function.
326
+
327
+ Args:
328
+ name: Job name to handle
329
+ handler: Handler function that receives payload dict
330
+ """
331
+ self._handlers[name] = handler
332
+
333
+ def enqueue(
334
+ self,
335
+ name: str,
336
+ payload: dict[str, Any],
337
+ *,
338
+ delay_seconds: int = 0,
339
+ max_attempts: int = 5,
340
+ ) -> MockJob:
341
+ """
342
+ Enqueue a job.
343
+
344
+ Args:
345
+ name: Job name (must have a registered handler to process)
346
+ payload: Job payload dictionary
347
+ delay_seconds: Delay before job becomes available
348
+ max_attempts: Maximum retry attempts
349
+
350
+ Returns:
351
+ The created MockJob
352
+ """
353
+ available_at = datetime.now(UTC) + timedelta(seconds=delay_seconds)
354
+ job = MockJob(
355
+ id=self._next_id(),
356
+ name=name,
357
+ payload=dict(payload),
358
+ available_at=available_at,
359
+ max_attempts=max_attempts,
360
+ )
361
+ self._jobs.append(job)
362
+
363
+ if self.sync_mode and delay_seconds == 0:
364
+ self._process_job(job)
365
+
366
+ return job
367
+
368
+ def _process_job(self, job: MockJob) -> bool:
369
+ """
370
+ Process a single job.
371
+
372
+ Returns:
373
+ True if job succeeded, False if failed
374
+ """
375
+ handler = self._handlers.get(job.name)
376
+ if handler is None:
377
+ job.status = "failed"
378
+ job.error = f"No handler registered for job type: {job.name}"
379
+ self._failed.append(job)
380
+ return False
381
+
382
+ job.attempts += 1
383
+ job.status = "processing"
384
+
385
+ try:
386
+ result = handler(job.payload)
387
+ job.status = "completed"
388
+ job.result = result
389
+ self._completed.append(job)
390
+ return True
391
+ except Exception as e:
392
+ job.error = str(e)
393
+ if job.attempts >= job.max_attempts:
394
+ job.status = "failed"
395
+ self._failed.append(job)
396
+ else:
397
+ job.status = "pending"
398
+ # Exponential backoff
399
+ delay = 60 * job.attempts
400
+ job.available_at = datetime.now(UTC) + timedelta(seconds=delay)
401
+ return False
402
+
403
+ def process_next(self) -> MockJob | None:
404
+ """
405
+ Process the next available job.
406
+
407
+ Returns:
408
+ The processed job, or None if no jobs available
409
+ """
410
+ now = datetime.now(UTC)
411
+ for job in self._jobs:
412
+ if job.status == "pending" and job.available_at <= now:
413
+ self._process_job(job)
414
+ if job.status in ("completed", "failed"):
415
+ self._jobs.remove(job)
416
+ return job
417
+ return None
418
+
419
+ def process_all(self) -> int:
420
+ """
421
+ Process all available jobs.
422
+
423
+ Returns:
424
+ Number of jobs processed
425
+ """
426
+ count = 0
427
+ while self.process_next() is not None:
428
+ count += 1
429
+ return count
430
+
431
+ @property
432
+ def jobs(self) -> list[MockJob]:
433
+ """Get all pending jobs."""
434
+ return [j for j in self._jobs if j.status == "pending"]
435
+
436
+ @property
437
+ def completed_jobs(self) -> list[MockJob]:
438
+ """Get all completed jobs."""
439
+ return self._completed.copy()
440
+
441
+ @property
442
+ def failed_jobs(self) -> list[MockJob]:
443
+ """Get all failed jobs."""
444
+ return self._failed.copy()
445
+
446
+ def clear(self) -> None:
447
+ """Clear all jobs (pending, completed, and failed)."""
448
+ self._jobs.clear()
449
+ self._completed.clear()
450
+ self._failed.clear()
451
+
452
+ def get_job(self, job_id: str) -> MockJob | None:
453
+ """
454
+ Get a job by ID.
455
+
456
+ Args:
457
+ job_id: Job ID
458
+
459
+ Returns:
460
+ The job or None if not found
461
+ """
462
+ for job in self._jobs + self._completed + self._failed:
463
+ if job.id == job_id:
464
+ return job
465
+ return None
466
+
467
+
468
+ # =============================================================================
469
+ # Test Fixture Factories
470
+ # =============================================================================
471
+
472
+
473
+ def generate_uuid() -> str:
474
+ """Generate a random UUID string."""
475
+ return str(uuid.uuid4())
476
+
477
+
478
+ def generate_email(prefix: str = "test") -> str:
479
+ """Generate a unique test email address."""
480
+ return f"{prefix}+{uuid.uuid4().hex[:8]}@example.com"
481
+
482
+
483
+ @dataclass
484
+ class UserFixtureData:
485
+ """Data for creating a test user."""
486
+
487
+ id: str = field(default_factory=generate_uuid)
488
+ email: str = field(default_factory=lambda: generate_email("user"))
489
+ hashed_password: str = "$2b$12$test.hashed.password.placeholder"
490
+ is_active: bool = True
491
+ is_verified: bool = True
492
+ is_superuser: bool = False
493
+ full_name: str | None = None
494
+ extra: dict[str, Any] = field(default_factory=dict)
495
+
496
+
497
+ @dataclass
498
+ class TenantFixtureData:
499
+ """Data for creating a test tenant."""
500
+
501
+ id: str = field(default_factory=generate_uuid)
502
+ name: str = field(default_factory=lambda: f"Test Tenant {uuid.uuid4().hex[:6]}")
503
+ slug: str | None = None
504
+ is_active: bool = True
505
+ extra: dict[str, Any] = field(default_factory=dict)
506
+
507
+ def __post_init__(self):
508
+ if self.slug is None:
509
+ self.slug = self.name.lower().replace(" ", "-")
510
+
511
+
512
+ def create_test_user_data(**overrides: Any) -> UserFixtureData:
513
+ """
514
+ Create test user data with optional overrides.
515
+
516
+ Args:
517
+ **overrides: Fields to override (id, email, is_superuser, etc.)
518
+
519
+ Returns:
520
+ UserFixtureData instance
521
+
522
+ Example:
523
+ >>> user_data = create_test_user_data(is_superuser=True)
524
+ >>> print(user_data.is_superuser)
525
+ True
526
+ """
527
+ return UserFixtureData(**overrides)
528
+
529
+
530
+ def create_test_tenant_data(**overrides: Any) -> TenantFixtureData:
531
+ """
532
+ Create test tenant data with optional overrides.
533
+
534
+ Args:
535
+ **overrides: Fields to override (id, name, slug, etc.)
536
+
537
+ Returns:
538
+ TenantFixtureData instance
539
+
540
+ Example:
541
+ >>> tenant_data = create_test_tenant_data(name="Acme Corp")
542
+ >>> print(tenant_data.slug)
543
+ 'acme-corp'
544
+ """
545
+ return TenantFixtureData(**overrides)
546
+
547
+
548
+ async def create_test_user(
549
+ session: Any,
550
+ user_model: Callable[..., T],
551
+ **overrides: Any,
552
+ ) -> T:
553
+ """
554
+ Create a test user in the database.
555
+
556
+ Args:
557
+ session: SQLAlchemy async session
558
+ user_model: User model class (must accept id, email, hashed_password,
559
+ is_active, is_verified, is_superuser as kwargs)
560
+ **overrides: Field overrides
561
+
562
+ Returns:
563
+ Created user instance
564
+
565
+ Example:
566
+ >>> async with async_session() as session:
567
+ ... user = await create_test_user(session, User, is_superuser=True)
568
+ ... print(user.email)
569
+ """
570
+ data = create_test_user_data(**overrides)
571
+ user = user_model(
572
+ id=data.id,
573
+ email=data.email,
574
+ hashed_password=data.hashed_password,
575
+ is_active=data.is_active,
576
+ is_verified=data.is_verified,
577
+ is_superuser=data.is_superuser,
578
+ **data.extra,
579
+ )
580
+ if hasattr(user, "full_name") and data.full_name:
581
+ user.full_name = data.full_name
582
+
583
+ session.add(user)
584
+ await session.commit()
585
+ await session.refresh(user)
586
+ return user
587
+
588
+
589
+ async def create_test_tenant(
590
+ session: Any,
591
+ tenant_model: Callable[..., T],
592
+ **overrides: Any,
593
+ ) -> T:
594
+ """
595
+ Create a test tenant in the database.
596
+
597
+ Args:
598
+ session: SQLAlchemy async session
599
+ tenant_model: Tenant model class
600
+ **overrides: Field overrides
601
+
602
+ Returns:
603
+ Created tenant instance
604
+
605
+ Example:
606
+ >>> async with async_session() as session:
607
+ ... tenant = await create_test_tenant(session, Tenant, name="Test Co")
608
+ ... print(tenant.slug)
609
+ """
610
+ data = create_test_tenant_data(**overrides)
611
+ tenant = tenant_model(
612
+ id=data.id,
613
+ name=data.name,
614
+ slug=data.slug,
615
+ is_active=data.is_active,
616
+ **data.extra,
617
+ )
618
+ session.add(tenant)
619
+ await session.commit()
620
+ await session.refresh(tenant)
621
+ return tenant
622
+
623
+
624
+ # =============================================================================
625
+ # Pytest Fixtures (importable for conftest.py)
626
+ # =============================================================================
627
+
628
+
629
+ def pytest_fixtures() -> dict[str, Callable]:
630
+ """
631
+ Get pytest fixture functions for use in conftest.py.
632
+
633
+ Returns:
634
+ Dictionary of fixture name -> fixture function
635
+
636
+ Example:
637
+ In your conftest.py:
638
+ >>> from svc_infra.testing import pytest_fixtures
639
+ >>> import pytest
640
+ >>>
641
+ >>> fixtures = pytest_fixtures()
642
+ >>> mock_cache = pytest.fixture(fixtures["mock_cache"])
643
+ >>> mock_job_queue = pytest.fixture(fixtures["mock_job_queue"])
644
+ """
645
+
646
+ def mock_cache() -> MockCache:
647
+ """Provide a fresh MockCache for each test."""
648
+ return MockCache()
649
+
650
+ def mock_job_queue() -> MockJobQueue:
651
+ """Provide a fresh MockJobQueue for each test."""
652
+ return MockJobQueue()
653
+
654
+ def sync_job_queue() -> MockJobQueue:
655
+ """Provide a MockJobQueue that executes jobs immediately."""
656
+ return MockJobQueue(sync_mode=True)
657
+
658
+ return {
659
+ "mock_cache": mock_cache,
660
+ "mock_job_queue": mock_job_queue,
661
+ "sync_job_queue": sync_job_queue,
662
+ }
663
+
664
+
665
+ __all__ = [
666
+ # Mock implementations
667
+ "MockCache",
668
+ "MockJobQueue",
669
+ "MockJob",
670
+ "CacheEntry",
671
+ # Test data factories
672
+ "UserFixtureData",
673
+ "TenantFixtureData",
674
+ "create_test_user_data",
675
+ "create_test_tenant_data",
676
+ "create_test_user",
677
+ "create_test_tenant",
678
+ # Utilities
679
+ "generate_uuid",
680
+ "generate_email",
681
+ "pytest_fixtures",
682
+ ]