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,239 @@
1
+ """
2
+ Base storage abstractions and exceptions.
3
+
4
+ Defines the StorageBackend protocol that all storage implementations must follow.
5
+ """
6
+
7
+ from typing import Optional, Protocol
8
+
9
+
10
+ class StorageError(Exception):
11
+ """Base exception for all storage operations."""
12
+
13
+ pass
14
+
15
+
16
+ class FileNotFoundError(StorageError):
17
+ """Raised when a requested file does not exist."""
18
+
19
+ pass
20
+
21
+
22
+ class PermissionDeniedError(StorageError):
23
+ """Raised when lacking permissions for an operation."""
24
+
25
+ pass
26
+
27
+
28
+ class QuotaExceededError(StorageError):
29
+ """Raised when storage quota is exceeded."""
30
+
31
+ pass
32
+
33
+
34
+ class InvalidKeyError(StorageError):
35
+ """Raised when a key format is invalid."""
36
+
37
+ pass
38
+
39
+
40
+ class StorageBackend(Protocol):
41
+ """
42
+ Abstract storage backend interface.
43
+
44
+ All storage backends must implement this protocol to be compatible
45
+ with the storage system.
46
+
47
+ Example:
48
+ >>> from svc_infra.storage import StorageBackend
49
+ >>>
50
+ >>> class MyBackend:
51
+ ... async def put(self, key, data, content_type, metadata=None):
52
+ ... # Custom implementation
53
+ ... return "https://example.com/files/key"
54
+ >>>
55
+ >>> # MyBackend is now a valid StorageBackend
56
+ """
57
+
58
+ async def put(
59
+ self,
60
+ key: str,
61
+ data: bytes,
62
+ content_type: str,
63
+ metadata: Optional[dict] = None,
64
+ ) -> str:
65
+ """
66
+ Store file content and return its URL.
67
+
68
+ Args:
69
+ key: Storage key (path) for the file
70
+ data: File content as bytes
71
+ content_type: MIME type (e.g., "image/jpeg", "application/pdf")
72
+ metadata: Optional metadata dict (user_id, tenant_id, etc.)
73
+
74
+ Returns:
75
+ Public or signed URL to access the file
76
+
77
+ Raises:
78
+ InvalidKeyError: If key format is invalid
79
+ PermissionDeniedError: If lacking write permissions
80
+ QuotaExceededError: If storage quota exceeded
81
+ StorageError: For other storage errors
82
+
83
+ Example:
84
+ >>> url = await storage.put(
85
+ ... key="avatars/user_123/profile.jpg",
86
+ ... data=image_bytes,
87
+ ... content_type="image/jpeg",
88
+ ... metadata={"user_id": "user_123"}
89
+ ... )
90
+ """
91
+ ...
92
+
93
+ async def get(self, key: str) -> bytes:
94
+ """
95
+ Retrieve file content.
96
+
97
+ Args:
98
+ key: Storage key (path) for the file
99
+
100
+ Returns:
101
+ File content as bytes
102
+
103
+ Raises:
104
+ FileNotFoundError: If file does not exist
105
+ PermissionDeniedError: If lacking read permissions
106
+ StorageError: For other storage errors
107
+
108
+ Example:
109
+ >>> data = await storage.get("avatars/user_123/profile.jpg")
110
+ """
111
+ ...
112
+
113
+ async def delete(self, key: str) -> bool:
114
+ """
115
+ Delete a file.
116
+
117
+ Args:
118
+ key: Storage key (path) for the file
119
+
120
+ Returns:
121
+ True if file was deleted, False if file did not exist
122
+
123
+ Raises:
124
+ PermissionDeniedError: If lacking delete permissions
125
+ StorageError: For other storage errors
126
+
127
+ Example:
128
+ >>> deleted = await storage.delete("avatars/user_123/profile.jpg")
129
+ """
130
+ ...
131
+
132
+ async def exists(self, key: str) -> bool:
133
+ """
134
+ Check if a file exists.
135
+
136
+ Args:
137
+ key: Storage key (path) for the file
138
+
139
+ Returns:
140
+ True if file exists, False otherwise
141
+
142
+ Example:
143
+ >>> if await storage.exists("avatars/user_123/profile.jpg"):
144
+ ... print("File exists")
145
+ """
146
+ ...
147
+
148
+ async def get_url(
149
+ self,
150
+ key: str,
151
+ expires_in: int = 3600,
152
+ download: bool = False,
153
+ ) -> str:
154
+ """
155
+ Generate a signed or public URL for file access.
156
+
157
+ Args:
158
+ key: Storage key (path) for the file
159
+ expires_in: URL expiration time in seconds (default: 1 hour)
160
+ download: If True, force download instead of inline display
161
+
162
+ Returns:
163
+ Signed or public URL
164
+
165
+ Raises:
166
+ FileNotFoundError: If file does not exist
167
+ StorageError: For other storage errors
168
+
169
+ Example:
170
+ >>> # Get 1-hour signed URL for viewing
171
+ >>> url = await storage.get_url("documents/invoice.pdf")
172
+ >>>
173
+ >>> # Get 5-minute download URL
174
+ >>> url = await storage.get_url(
175
+ ... "documents/invoice.pdf",
176
+ ... expires_in=300,
177
+ ... download=True
178
+ ... )
179
+ """
180
+ ...
181
+
182
+ async def list_keys(
183
+ self,
184
+ prefix: str = "",
185
+ limit: int = 100,
186
+ ) -> list[str]:
187
+ """
188
+ List stored file keys with optional prefix filter.
189
+
190
+ Args:
191
+ prefix: Key prefix to filter by (e.g., "avatars/")
192
+ limit: Maximum number of keys to return (default: 100)
193
+
194
+ Returns:
195
+ List of matching keys
196
+
197
+ Example:
198
+ >>> # List all avatars for a user
199
+ >>> keys = await storage.list_keys(prefix="avatars/user_123/")
200
+ >>>
201
+ >>> # List all files
202
+ >>> keys = await storage.list_keys()
203
+ """
204
+ ...
205
+
206
+ async def get_metadata(self, key: str) -> dict:
207
+ """
208
+ Get file metadata.
209
+
210
+ Args:
211
+ key: Storage key (path) for the file
212
+
213
+ Returns:
214
+ Metadata dict containing:
215
+ - size: File size in bytes
216
+ - content_type: MIME type
217
+ - created_at: Creation timestamp (ISO 8601)
218
+ - Custom metadata from put() call
219
+
220
+ Raises:
221
+ FileNotFoundError: If file does not exist
222
+ StorageError: For other storage errors
223
+
224
+ Example:
225
+ >>> meta = await storage.get_metadata("avatars/user_123/profile.jpg")
226
+ >>> print(f"Size: {meta['size']} bytes")
227
+ >>> print(f"Type: {meta['content_type']}")
228
+ """
229
+ ...
230
+
231
+
232
+ __all__ = [
233
+ "StorageBackend",
234
+ "StorageError",
235
+ "FileNotFoundError",
236
+ "PermissionDeniedError",
237
+ "QuotaExceededError",
238
+ "InvalidKeyError",
239
+ ]
@@ -0,0 +1,185 @@
1
+ """
2
+ Easy storage backend builder with auto-detection.
3
+
4
+ Simplifies storage backend initialization with sensible defaults.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ from typing import Optional
10
+
11
+ from .backends import LocalBackend, MemoryBackend, S3Backend
12
+ from .base import StorageBackend
13
+ from .settings import StorageSettings
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def easy_storage(
19
+ backend: Optional[str] = None,
20
+ **kwargs,
21
+ ) -> StorageBackend:
22
+ """
23
+ Create a storage backend with auto-detection or explicit selection.
24
+
25
+ This is the recommended way to initialize storage in most applications.
26
+ It handles environment-based configuration and provides sensible defaults.
27
+
28
+ Args:
29
+ backend: Explicit backend type ("local", "s3", "gcs", "cloudinary", "memory")
30
+ If None, auto-detects from environment variables
31
+ **kwargs: Backend-specific configuration overrides
32
+
33
+ Returns:
34
+ Initialized storage backend
35
+
36
+ Auto-Detection Order:
37
+ 1. Explicit backend parameter
38
+ 2. STORAGE_BACKEND environment variable
39
+ 3. Railway volume (RAILWAY_VOLUME_MOUNT_PATH) → LocalBackend
40
+ 4. S3 credentials (AWS_ACCESS_KEY_ID or STORAGE_S3_BUCKET) → S3Backend
41
+ 5. GCS credentials (GOOGLE_APPLICATION_CREDENTIALS) → GCSBackend
42
+ 6. Cloudinary credentials (CLOUDINARY_URL) → CloudinaryBackend
43
+ 7. Default: MemoryBackend (with warning)
44
+
45
+ Examples:
46
+ >>> # Auto-detect backend from environment
47
+ >>> storage = easy_storage()
48
+ >>>
49
+ >>> # Explicit local backend
50
+ >>> storage = easy_storage(
51
+ ... backend="local",
52
+ ... base_path="/data/uploads"
53
+ ... )
54
+ >>>
55
+ >>> # Explicit S3 backend
56
+ >>> storage = easy_storage(
57
+ ... backend="s3",
58
+ ... bucket="my-uploads",
59
+ ... region="us-west-2"
60
+ ... )
61
+ >>>
62
+ >>> # DigitalOcean Spaces
63
+ >>> storage = easy_storage(
64
+ ... backend="s3",
65
+ ... bucket="my-uploads",
66
+ ... region="nyc3",
67
+ ... endpoint="https://nyc3.digitaloceanspaces.com"
68
+ ... )
69
+
70
+ Environment Variables:
71
+ See StorageSettings for full list of environment variables.
72
+
73
+ Raises:
74
+ ValueError: If backend type is unsupported or configuration is invalid
75
+ ImportError: If required backend dependencies are not installed
76
+
77
+ Note:
78
+ For production deployments, it's recommended to set STORAGE_BACKEND
79
+ explicitly to avoid unexpected auto-detection behavior.
80
+ """
81
+ # Load settings
82
+ settings = StorageSettings()
83
+
84
+ # Determine backend type
85
+ backend_type = backend or settings.detect_backend()
86
+
87
+ logger.info(f"Initializing {backend_type} storage backend")
88
+
89
+ # Create backend instance
90
+ if backend_type == "memory":
91
+ # Memory backend
92
+ if backend_type == settings.detect_backend() and not backend:
93
+ logger.warning(
94
+ "Using MemoryBackend (in-memory storage). "
95
+ "Data will be lost on restart. "
96
+ "Set STORAGE_BACKEND environment variable for production."
97
+ )
98
+
99
+ max_size = kwargs.get("max_size", 100_000_000)
100
+ return MemoryBackend(max_size=max_size)
101
+
102
+ elif backend_type == "local":
103
+ # Local filesystem backend
104
+ base_path = kwargs.get("base_path") or settings.storage_base_path
105
+
106
+ # Check for Railway volume
107
+ railway_volume = os.getenv("RAILWAY_VOLUME_MOUNT_PATH")
108
+ if railway_volume and not kwargs.get("base_path"):
109
+ base_path = railway_volume
110
+ logger.info(f"Detected Railway volume at {base_path}")
111
+
112
+ base_url = kwargs.get("base_url") or settings.storage_base_url
113
+ signing_secret = kwargs.get("signing_secret") or settings.storage_signing_secret
114
+
115
+ return LocalBackend(
116
+ base_path=base_path,
117
+ base_url=base_url,
118
+ signing_secret=signing_secret,
119
+ )
120
+
121
+ elif backend_type == "s3":
122
+ # S3-compatible backend
123
+ bucket = kwargs.get("bucket") or settings.storage_s3_bucket
124
+ if not bucket:
125
+ raise ValueError(
126
+ "S3 bucket is required. "
127
+ "Set STORAGE_S3_BUCKET environment variable or pass bucket parameter."
128
+ )
129
+
130
+ region = kwargs.get("region") or settings.storage_s3_region
131
+ endpoint = kwargs.get("endpoint") or settings.storage_s3_endpoint
132
+
133
+ # Get credentials with fallback
134
+ access_key = kwargs.get("access_key")
135
+ secret_key = kwargs.get("secret_key")
136
+
137
+ if not access_key or not secret_key:
138
+ access_key_from_settings, secret_key_from_settings = (
139
+ settings.get_s3_credentials()
140
+ )
141
+ access_key = access_key or access_key_from_settings
142
+ secret_key = secret_key or secret_key_from_settings
143
+
144
+ # Log provider detection
145
+ if endpoint:
146
+ if "digitalocean" in endpoint:
147
+ logger.info("Detected DigitalOcean Spaces")
148
+ elif "wasabi" in endpoint:
149
+ logger.info("Detected Wasabi")
150
+ elif "backblaze" in endpoint:
151
+ logger.info("Detected Backblaze B2")
152
+ else:
153
+ logger.info(f"Using custom S3 endpoint: {endpoint}")
154
+ else:
155
+ logger.info("Using AWS S3")
156
+
157
+ return S3Backend(
158
+ bucket=bucket,
159
+ region=region,
160
+ endpoint=endpoint,
161
+ access_key=access_key,
162
+ secret_key=secret_key,
163
+ )
164
+
165
+ elif backend_type == "gcs":
166
+ # Google Cloud Storage backend
167
+ raise NotImplementedError(
168
+ "GCS backend not yet implemented. " "Use 'local' or 's3' backend for now."
169
+ )
170
+
171
+ elif backend_type == "cloudinary":
172
+ # Cloudinary backend
173
+ raise NotImplementedError(
174
+ "Cloudinary backend not yet implemented. "
175
+ "Use 'local' or 's3' backend for now."
176
+ )
177
+
178
+ else:
179
+ raise ValueError(
180
+ f"Unsupported storage backend: {backend_type}. "
181
+ f"Supported: local, s3, memory (gcs, cloudinary coming soon)"
182
+ )
183
+
184
+
185
+ __all__ = ["easy_storage"]
@@ -0,0 +1,195 @@
1
+ """
2
+ Storage configuration and settings.
3
+
4
+ Handles environment-based configuration and auto-detection of storage backends.
5
+ """
6
+
7
+ import os
8
+ from typing import Literal, Optional
9
+
10
+ from pydantic import Field
11
+ from pydantic_settings import BaseSettings
12
+
13
+
14
+ class StorageSettings(BaseSettings):
15
+ """
16
+ Storage system configuration.
17
+
18
+ Supports multiple backends with auto-detection from environment variables.
19
+
20
+ Environment Variables:
21
+ STORAGE_BACKEND: Explicit backend selection ("local", "s3", "gcs", "cloudinary", "memory")
22
+
23
+ Local Backend:
24
+ STORAGE_BASE_PATH: Base directory for file storage (default: /data/uploads)
25
+ STORAGE_BASE_URL: Base URL for file serving (default: http://localhost:8000/files)
26
+ STORAGE_SIGNING_SECRET: Secret key for URL signing (auto-generated if not set)
27
+
28
+ S3 Backend:
29
+ STORAGE_S3_BUCKET: S3 bucket name (required for S3)
30
+ STORAGE_S3_REGION: AWS region (default: us-east-1)
31
+ STORAGE_S3_ENDPOINT: Custom endpoint for S3-compatible services (optional)
32
+ STORAGE_S3_ACCESS_KEY: AWS access key (optional, uses AWS_ACCESS_KEY_ID if not set)
33
+ STORAGE_S3_SECRET_KEY: AWS secret key (optional, uses AWS_SECRET_ACCESS_KEY if not set)
34
+
35
+ GCS Backend:
36
+ STORAGE_GCS_BUCKET: GCS bucket name (required for GCS)
37
+ STORAGE_GCS_PROJECT: GCP project ID (optional)
38
+ STORAGE_GCS_CREDENTIALS_PATH: Path to service account JSON (optional)
39
+
40
+ Cloudinary Backend:
41
+ STORAGE_CLOUDINARY_CLOUD_NAME: Cloudinary cloud name (required)
42
+ STORAGE_CLOUDINARY_API_KEY: Cloudinary API key (required)
43
+ STORAGE_CLOUDINARY_API_SECRET: Cloudinary API secret (required)
44
+
45
+ Example:
46
+ >>> # Auto-detect backend from environment
47
+ >>> settings = StorageSettings()
48
+ >>> backend = settings.detect_backend()
49
+ >>>
50
+ >>> # Explicit backend selection
51
+ >>> settings = StorageSettings(storage_backend="s3")
52
+ """
53
+
54
+ # Backend selection
55
+ storage_backend: Optional[Literal["local", "s3", "gcs", "cloudinary", "memory"]] = (
56
+ Field(
57
+ default=None,
58
+ description="Storage backend type (auto-detected if not set)",
59
+ )
60
+ )
61
+
62
+ # Local backend settings
63
+ storage_base_path: str = Field(
64
+ default="/data/uploads",
65
+ description="Base directory for local file storage",
66
+ )
67
+ storage_base_url: str = Field(
68
+ default="http://localhost:8000/files",
69
+ description="Base URL for serving files",
70
+ )
71
+ storage_signing_secret: Optional[str] = Field(
72
+ default=None,
73
+ description="Secret key for URL signing (auto-generated if not set)",
74
+ )
75
+
76
+ # S3 backend settings
77
+ storage_s3_bucket: Optional[str] = Field(
78
+ default=None,
79
+ description="S3 bucket name",
80
+ )
81
+ storage_s3_region: str = Field(
82
+ default="us-east-1",
83
+ description="AWS region",
84
+ )
85
+ storage_s3_endpoint: Optional[str] = Field(
86
+ default=None,
87
+ description="Custom S3 endpoint (for DigitalOcean Spaces, Wasabi, etc.)",
88
+ )
89
+ storage_s3_access_key: Optional[str] = Field(
90
+ default=None,
91
+ description="S3 access key (falls back to AWS_ACCESS_KEY_ID)",
92
+ )
93
+ storage_s3_secret_key: Optional[str] = Field(
94
+ default=None,
95
+ description="S3 secret key (falls back to AWS_SECRET_ACCESS_KEY)",
96
+ )
97
+
98
+ # GCS backend settings
99
+ storage_gcs_bucket: Optional[str] = Field(
100
+ default=None,
101
+ description="Google Cloud Storage bucket name",
102
+ )
103
+ storage_gcs_project: Optional[str] = Field(
104
+ default=None,
105
+ description="GCP project ID",
106
+ )
107
+ storage_gcs_credentials_path: Optional[str] = Field(
108
+ default=None,
109
+ description="Path to GCP service account JSON",
110
+ )
111
+
112
+ # Cloudinary backend settings
113
+ storage_cloudinary_cloud_name: Optional[str] = Field(
114
+ default=None,
115
+ description="Cloudinary cloud name",
116
+ )
117
+ storage_cloudinary_api_key: Optional[str] = Field(
118
+ default=None,
119
+ description="Cloudinary API key",
120
+ )
121
+ storage_cloudinary_api_secret: Optional[str] = Field(
122
+ default=None,
123
+ description="Cloudinary API secret",
124
+ )
125
+
126
+ model_config = {
127
+ "env_file": ".env",
128
+ "case_sensitive": False,
129
+ "extra": "ignore", # Ignore unknown environment variables
130
+ }
131
+
132
+ def detect_backend(self) -> str:
133
+ """
134
+ Auto-detect storage backend from environment.
135
+
136
+ Detection order:
137
+ 1. Explicit STORAGE_BACKEND setting
138
+ 2. Railway volume (RAILWAY_VOLUME_MOUNT_PATH) → local
139
+ 3. S3 credentials (AWS_ACCESS_KEY_ID or STORAGE_S3_BUCKET) → s3
140
+ 4. GCS credentials (GOOGLE_APPLICATION_CREDENTIALS or STORAGE_GCS_BUCKET) → gcs
141
+ 5. Cloudinary credentials (CLOUDINARY_URL or STORAGE_CLOUDINARY_CLOUD_NAME) → cloudinary
142
+ 6. Default → memory (with warning)
143
+
144
+ Returns:
145
+ Backend type string
146
+
147
+ Example:
148
+ >>> settings = StorageSettings()
149
+ >>> backend_type = settings.detect_backend()
150
+ >>> print(f"Using {backend_type} backend")
151
+ """
152
+ # Explicit setting takes precedence
153
+ if self.storage_backend:
154
+ return self.storage_backend
155
+
156
+ # Check for Railway volume
157
+ railway_volume = os.getenv("RAILWAY_VOLUME_MOUNT_PATH")
158
+ if railway_volume:
159
+ return "local"
160
+
161
+ # Check for S3
162
+ has_s3_key = os.getenv("AWS_ACCESS_KEY_ID") or self.storage_s3_access_key
163
+ if has_s3_key or self.storage_s3_bucket:
164
+ return "s3"
165
+
166
+ # Check for GCS
167
+ has_gcs_creds = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
168
+ if has_gcs_creds or self.storage_gcs_bucket:
169
+ return "gcs"
170
+
171
+ # Check for Cloudinary
172
+ has_cloudinary = os.getenv("CLOUDINARY_URL")
173
+ if has_cloudinary or self.storage_cloudinary_cloud_name:
174
+ return "cloudinary"
175
+
176
+ # Default to memory (for development/testing)
177
+ return "memory"
178
+
179
+ def get_s3_credentials(self) -> tuple[Optional[str], Optional[str]]:
180
+ """
181
+ Get S3 credentials with fallback to AWS environment variables.
182
+
183
+ Returns:
184
+ Tuple of (access_key, secret_key)
185
+
186
+ Example:
187
+ >>> settings = StorageSettings()
188
+ >>> access_key, secret_key = settings.get_s3_credentials()
189
+ """
190
+ access_key = self.storage_s3_access_key or os.getenv("AWS_ACCESS_KEY_ID")
191
+ secret_key = self.storage_s3_secret_key or os.getenv("AWS_SECRET_ACCESS_KEY")
192
+ return access_key, secret_key
193
+
194
+
195
+ __all__ = ["StorageSettings"]