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,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"]
|