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