svc-infra 0.1.595__py3-none-any.whl → 0.1.706__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,216 @@
1
+ """
2
+ In-memory storage backend for testing and development.
3
+
4
+ WARNING: Data is not persisted across restarts. Use only for testing or development.
5
+ """
6
+
7
+ import asyncio
8
+ from datetime import datetime, timezone
9
+ from typing import Optional
10
+
11
+ from ..base import FileNotFoundError, InvalidKeyError, QuotaExceededError
12
+
13
+
14
+ class MemoryBackend:
15
+ """
16
+ In-memory storage backend.
17
+
18
+ Stores files in memory using dictionaries. Fast and simple for testing,
19
+ but data is lost when the process restarts.
20
+
21
+ Args:
22
+ max_size: Maximum total storage size in bytes (default: 100MB)
23
+ default_expires_in: Default URL expiration in seconds (default: 3600)
24
+
25
+ Example:
26
+ >>> backend = MemoryBackend(max_size=10_000_000) # 10MB max
27
+ >>> url = await backend.put(
28
+ ... key="test/file.txt",
29
+ ... data=b"Hello, World!",
30
+ ... content_type="text/plain"
31
+ ... )
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ max_size: int = 100_000_000, # 100MB
37
+ default_expires_in: int = 3600,
38
+ ):
39
+ self.max_size = max_size
40
+ self.default_expires_in = default_expires_in
41
+ self._storage: dict[str, bytes] = {}
42
+ self._metadata: dict[str, dict] = {}
43
+ self._lock = asyncio.Lock()
44
+
45
+ def _validate_key(self, key: str) -> None:
46
+ """Validate storage key format."""
47
+ if not key:
48
+ raise InvalidKeyError("Key cannot be empty")
49
+
50
+ if key.startswith("/"):
51
+ raise InvalidKeyError("Key cannot start with /")
52
+
53
+ if ".." in key:
54
+ raise InvalidKeyError("Key cannot contain .. (path traversal)")
55
+
56
+ if len(key) > 1024:
57
+ raise InvalidKeyError("Key cannot exceed 1024 characters")
58
+
59
+ # Check for safe characters
60
+ safe_chars = set(
61
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/"
62
+ )
63
+ if not all(c in safe_chars for c in key):
64
+ raise InvalidKeyError(
65
+ "Key can only contain alphanumeric, dot, dash, underscore, and slash"
66
+ )
67
+
68
+ def _get_total_size(self) -> int:
69
+ """Calculate total storage size."""
70
+ return sum(len(data) for data in self._storage.values())
71
+
72
+ async def put(
73
+ self,
74
+ key: str,
75
+ data: bytes,
76
+ content_type: str,
77
+ metadata: Optional[dict] = None,
78
+ ) -> str:
79
+ """Store file in memory."""
80
+ self._validate_key(key)
81
+
82
+ async with self._lock:
83
+ # Check quota
84
+ current_size = self._get_total_size()
85
+ new_size = len(data)
86
+
87
+ # If replacing existing file, subtract its size
88
+ if key in self._storage:
89
+ current_size -= len(self._storage[key])
90
+
91
+ if current_size + new_size > self.max_size:
92
+ raise QuotaExceededError(
93
+ f"Storage quota exceeded. "
94
+ f"Current: {current_size}, New: {new_size}, Max: {self.max_size}"
95
+ )
96
+
97
+ # Store file
98
+ self._storage[key] = data
99
+
100
+ # Store metadata
101
+ self._metadata[key] = {
102
+ "size": len(data),
103
+ "content_type": content_type,
104
+ "created_at": datetime.now(timezone.utc).isoformat(),
105
+ **(metadata or {}),
106
+ }
107
+
108
+ # Return memory:// URL
109
+ return f"memory://{key}"
110
+
111
+ async def get(self, key: str) -> bytes:
112
+ """Retrieve file from memory."""
113
+ self._validate_key(key)
114
+
115
+ async with self._lock:
116
+ if key not in self._storage:
117
+ raise FileNotFoundError(f"File not found: {key}")
118
+
119
+ return self._storage[key]
120
+
121
+ async def delete(self, key: str) -> bool:
122
+ """Delete file from memory."""
123
+ self._validate_key(key)
124
+
125
+ async with self._lock:
126
+ if key not in self._storage:
127
+ return False
128
+
129
+ del self._storage[key]
130
+ del self._metadata[key]
131
+ return True
132
+
133
+ async def exists(self, key: str) -> bool:
134
+ """Check if file exists in memory."""
135
+ self._validate_key(key)
136
+
137
+ async with self._lock:
138
+ return key in self._storage
139
+
140
+ async def get_url(
141
+ self,
142
+ key: str,
143
+ expires_in: int = 3600,
144
+ download: bool = False,
145
+ ) -> str:
146
+ """
147
+ Generate memory:// URL.
148
+
149
+ Note: Memory backend doesn't support real URLs or expiration.
150
+ Returns memory:// scheme for testing purposes.
151
+ """
152
+ self._validate_key(key)
153
+
154
+ async with self._lock:
155
+ if key not in self._storage:
156
+ raise FileNotFoundError(f"File not found: {key}")
157
+
158
+ # Memory backend doesn't support real URLs
159
+ # Return memory:// scheme for testing
160
+ suffix = "?download=true" if download else ""
161
+ return f"memory://{key}{suffix}"
162
+
163
+ async def list_keys(
164
+ self,
165
+ prefix: str = "",
166
+ limit: int = 100,
167
+ ) -> list[str]:
168
+ """List stored keys with optional prefix filter."""
169
+ async with self._lock:
170
+ keys = [key for key in self._storage.keys() if key.startswith(prefix)]
171
+ return keys[:limit]
172
+
173
+ async def get_metadata(self, key: str) -> dict:
174
+ """Get file metadata."""
175
+ self._validate_key(key)
176
+
177
+ async with self._lock:
178
+ if key not in self._metadata:
179
+ raise FileNotFoundError(f"File not found: {key}")
180
+
181
+ return self._metadata[key].copy()
182
+
183
+ async def clear(self) -> None:
184
+ """
185
+ Clear all stored files (testing utility).
186
+
187
+ Example:
188
+ >>> backend = MemoryBackend()
189
+ >>> await backend.put("test.txt", b"data", "text/plain")
190
+ >>> await backend.clear()
191
+ >>> await backend.exists("test.txt") # False
192
+ """
193
+ async with self._lock:
194
+ self._storage.clear()
195
+ self._metadata.clear()
196
+
197
+ def get_stats(self) -> dict:
198
+ """
199
+ Get storage statistics (testing utility).
200
+
201
+ Returns:
202
+ Dict with file_count, total_size, max_size
203
+
204
+ Example:
205
+ >>> backend = MemoryBackend(max_size=1000)
206
+ >>> stats = backend.get_stats()
207
+ >>> print(f"Files: {stats['file_count']}, Size: {stats['total_size']}")
208
+ """
209
+ return {
210
+ "file_count": len(self._storage),
211
+ "total_size": self._get_total_size(),
212
+ "max_size": self.max_size,
213
+ }
214
+
215
+
216
+ __all__ = ["MemoryBackend"]
@@ -0,0 +1,353 @@
1
+ """
2
+ S3-compatible storage backend.
3
+
4
+ Works with AWS S3, DigitalOcean Spaces, Wasabi, Backblaze B2, Minio, and
5
+ any S3-compatible object storage service.
6
+ """
7
+
8
+ from typing import Optional, cast
9
+
10
+ try:
11
+ import aioboto3
12
+ from botocore.exceptions import ClientError, NoCredentialsError
13
+ except ImportError:
14
+ aioboto3 = None
15
+ ClientError = Exception
16
+ NoCredentialsError = Exception
17
+
18
+ from ..base import (
19
+ FileNotFoundError,
20
+ InvalidKeyError,
21
+ PermissionDeniedError,
22
+ StorageError,
23
+ )
24
+
25
+
26
+ class S3Backend:
27
+ """
28
+ S3-compatible storage backend.
29
+
30
+ Supports AWS S3, DigitalOcean Spaces, Wasabi, Backblaze B2, Minio,
31
+ and any S3-compatible object storage.
32
+
33
+ Args:
34
+ bucket: S3 bucket name
35
+ region: AWS region (default: "us-east-1")
36
+ endpoint: Custom endpoint URL for S3-compatible services
37
+ access_key: AWS access key (uses AWS_ACCESS_KEY_ID env var if not provided)
38
+ secret_key: AWS secret key (uses AWS_SECRET_ACCESS_KEY env var if not provided)
39
+
40
+ Example:
41
+ >>> # AWS S3
42
+ >>> backend = S3Backend(
43
+ ... bucket="my-uploads",
44
+ ... region="us-west-2"
45
+ ... )
46
+ >>>
47
+ >>> # DigitalOcean Spaces
48
+ >>> backend = S3Backend(
49
+ ... bucket="my-uploads",
50
+ ... region="nyc3",
51
+ ... endpoint="https://nyc3.digitaloceanspaces.com",
52
+ ... access_key="...",
53
+ ... secret_key="..."
54
+ ... )
55
+ >>>
56
+ >>> # Wasabi
57
+ >>> backend = S3Backend(
58
+ ... bucket="my-uploads",
59
+ ... region="us-east-1",
60
+ ... endpoint="https://s3.wasabisys.com"
61
+ ... )
62
+
63
+ Raises:
64
+ ImportError: If aioboto3 is not installed
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ bucket: str,
70
+ region: str = "us-east-1",
71
+ endpoint: Optional[str] = None,
72
+ access_key: Optional[str] = None,
73
+ secret_key: Optional[str] = None,
74
+ ):
75
+ if aioboto3 is None:
76
+ raise ImportError(
77
+ "aioboto3 is required for S3Backend. "
78
+ "Install it with: pip install aioboto3"
79
+ )
80
+
81
+ self.bucket = bucket
82
+ self.region = region
83
+ self.endpoint = endpoint
84
+ self.access_key = access_key
85
+ self.secret_key = secret_key
86
+
87
+ # Session configuration
88
+ self._session_config = {
89
+ "region_name": region,
90
+ }
91
+ if endpoint:
92
+ self._session_config["endpoint_url"] = endpoint
93
+
94
+ # Client configuration
95
+ self._client_config = {}
96
+ if access_key and secret_key:
97
+ self._client_config = {
98
+ "aws_access_key_id": access_key,
99
+ "aws_secret_access_key": secret_key,
100
+ }
101
+
102
+ def _validate_key(self, key: str) -> None:
103
+ """Validate storage key format."""
104
+ if not key:
105
+ raise InvalidKeyError("Key cannot be empty")
106
+
107
+ if key.startswith("/"):
108
+ raise InvalidKeyError("Key cannot start with /")
109
+
110
+ if ".." in key:
111
+ raise InvalidKeyError("Key cannot contain .. (path traversal)")
112
+
113
+ if len(key) > 1024:
114
+ raise InvalidKeyError("Key cannot exceed 1024 characters")
115
+
116
+ async def put(
117
+ self,
118
+ key: str,
119
+ data: bytes,
120
+ content_type: str,
121
+ metadata: Optional[dict] = None,
122
+ ) -> str:
123
+ """Store file in S3."""
124
+ self._validate_key(key)
125
+
126
+ # Prepare S3 metadata (must be string key-value pairs)
127
+ s3_metadata = {}
128
+ if metadata:
129
+ for k, v in metadata.items():
130
+ s3_metadata[str(k)] = str(v)
131
+
132
+ try:
133
+ session = aioboto3.Session()
134
+ async with session.client(
135
+ "s3", **self._session_config, **self._client_config
136
+ ) as s3:
137
+ # Upload file
138
+ await s3.put_object(
139
+ Bucket=self.bucket,
140
+ Key=key,
141
+ Body=data,
142
+ ContentType=content_type,
143
+ Metadata=s3_metadata,
144
+ )
145
+
146
+ except NoCredentialsError as e:
147
+ raise PermissionDeniedError(f"S3 credentials not found: {e}")
148
+ except ClientError as e:
149
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
150
+ if error_code == "AccessDenied":
151
+ raise PermissionDeniedError(f"S3 access denied: {e}")
152
+ elif error_code == "NoSuchBucket":
153
+ raise StorageError(f"S3 bucket does not exist: {self.bucket}")
154
+ else:
155
+ raise StorageError(f"S3 upload failed: {e}")
156
+ except Exception as e:
157
+ raise StorageError(f"Failed to upload to S3: {e}")
158
+
159
+ # Return presigned URL (1 hour expiration)
160
+ return await self.get_url(key, expires_in=3600)
161
+
162
+ async def get(self, key: str) -> bytes:
163
+ """Retrieve file from S3."""
164
+ self._validate_key(key)
165
+
166
+ try:
167
+ session = aioboto3.Session()
168
+ async with session.client(
169
+ "s3", **self._session_config, **self._client_config
170
+ ) as s3:
171
+ response = await s3.get_object(Bucket=self.bucket, Key=key)
172
+ async with response["Body"] as stream:
173
+ return cast(bytes, await stream.read())
174
+
175
+ except ClientError as e:
176
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
177
+ if error_code == "NoSuchKey":
178
+ raise FileNotFoundError(f"File not found: {key}")
179
+ elif error_code == "AccessDenied":
180
+ raise PermissionDeniedError(f"S3 access denied: {e}")
181
+ else:
182
+ raise StorageError(f"S3 download failed: {e}")
183
+ except Exception as e:
184
+ raise StorageError(f"Failed to download from S3: {e}")
185
+
186
+ async def delete(self, key: str) -> bool:
187
+ """Delete file from S3."""
188
+ self._validate_key(key)
189
+
190
+ # Check if file exists first
191
+ if not await self.exists(key):
192
+ return False
193
+
194
+ try:
195
+ session = aioboto3.Session()
196
+ async with session.client(
197
+ "s3", **self._session_config, **self._client_config
198
+ ) as s3:
199
+ await s3.delete_object(Bucket=self.bucket, Key=key)
200
+ return True
201
+
202
+ except ClientError as e:
203
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
204
+ if error_code == "AccessDenied":
205
+ raise PermissionDeniedError(f"S3 access denied: {e}")
206
+ else:
207
+ raise StorageError(f"S3 delete failed: {e}")
208
+ except Exception as e:
209
+ raise StorageError(f"Failed to delete from S3: {e}")
210
+
211
+ async def exists(self, key: str) -> bool:
212
+ """Check if file exists in S3."""
213
+ self._validate_key(key)
214
+
215
+ try:
216
+ session = aioboto3.Session()
217
+ async with session.client(
218
+ "s3", **self._session_config, **self._client_config
219
+ ) as s3:
220
+ await s3.head_object(Bucket=self.bucket, Key=key)
221
+ return True
222
+
223
+ except ClientError as e:
224
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
225
+ if error_code in ("NoSuchKey", "404"):
226
+ return False
227
+ else:
228
+ raise StorageError(f"S3 head_object failed: {e}")
229
+ except Exception as e:
230
+ raise StorageError(f"Failed to check S3 file existence: {e}")
231
+
232
+ async def get_url(
233
+ self,
234
+ key: str,
235
+ expires_in: int = 3600,
236
+ download: bool = False,
237
+ ) -> str:
238
+ """
239
+ Generate presigned URL for file access.
240
+
241
+ Args:
242
+ key: Storage key
243
+ expires_in: URL expiration in seconds (default: 1 hour)
244
+ download: If True, force download instead of inline display
245
+
246
+ Returns:
247
+ Presigned S3 URL
248
+
249
+ Example:
250
+ >>> url = await backend.get_url("documents/invoice.pdf", expires_in=300)
251
+ """
252
+ self._validate_key(key)
253
+
254
+ # Check if file exists
255
+ if not await self.exists(key):
256
+ raise FileNotFoundError(f"File not found: {key}")
257
+
258
+ try:
259
+ session = aioboto3.Session()
260
+ async with session.client(
261
+ "s3", **self._session_config, **self._client_config
262
+ ) as s3:
263
+ # Prepare parameters
264
+ params = {"Bucket": self.bucket, "Key": key}
265
+
266
+ # Add Content-Disposition for downloads
267
+ if download:
268
+ # Extract filename from key
269
+ filename = key.split("/")[-1]
270
+ params["ResponseContentDisposition"] = (
271
+ f'attachment; filename="{filename}"'
272
+ )
273
+
274
+ # Generate presigned URL
275
+ url = await s3.generate_presigned_url(
276
+ "get_object",
277
+ Params=params,
278
+ ExpiresIn=expires_in,
279
+ )
280
+ return cast(str, url)
281
+
282
+ except ClientError as e:
283
+ raise StorageError(f"Failed to generate presigned URL: {e}")
284
+ except Exception as e:
285
+ raise StorageError(f"Failed to generate presigned URL: {e}")
286
+
287
+ async def list_keys(
288
+ self,
289
+ prefix: str = "",
290
+ limit: int = 100,
291
+ ) -> list[str]:
292
+ """List stored keys with optional prefix filter."""
293
+ try:
294
+ session = aioboto3.Session()
295
+ async with session.client(
296
+ "s3", **self._session_config, **self._client_config
297
+ ) as s3:
298
+ params = {
299
+ "Bucket": self.bucket,
300
+ "MaxKeys": limit,
301
+ }
302
+ if prefix:
303
+ params["Prefix"] = prefix
304
+
305
+ response = await s3.list_objects_v2(**params)
306
+
307
+ # Extract keys from response
308
+ contents = response.get("Contents", [])
309
+ keys = [obj["Key"] for obj in contents]
310
+ return keys
311
+
312
+ except ClientError as e:
313
+ raise StorageError(f"S3 list failed: {e}")
314
+ except Exception as e:
315
+ raise StorageError(f"Failed to list S3 keys: {e}")
316
+
317
+ async def get_metadata(self, key: str) -> dict:
318
+ """Get file metadata from S3."""
319
+ self._validate_key(key)
320
+
321
+ try:
322
+ session = aioboto3.Session()
323
+ async with session.client(
324
+ "s3", **self._session_config, **self._client_config
325
+ ) as s3:
326
+ response = await s3.head_object(Bucket=self.bucket, Key=key)
327
+
328
+ # Extract metadata
329
+ metadata = {
330
+ "size": response["ContentLength"],
331
+ "content_type": response.get(
332
+ "ContentType", "application/octet-stream"
333
+ ),
334
+ "created_at": response["LastModified"].isoformat(),
335
+ }
336
+
337
+ # Add custom metadata
338
+ if "Metadata" in response:
339
+ metadata.update(response["Metadata"])
340
+
341
+ return metadata
342
+
343
+ except ClientError as e:
344
+ error_code = e.response.get("Error", {}).get("Code", "Unknown")
345
+ if error_code == "NoSuchKey":
346
+ raise FileNotFoundError(f"File not found: {key}")
347
+ else:
348
+ raise StorageError(f"S3 head_object failed: {e}")
349
+ except Exception as e:
350
+ raise StorageError(f"Failed to get S3 metadata: {e}")
351
+
352
+
353
+ __all__ = ["S3Backend"]