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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -57
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +3 -4
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {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"]
|