svc-infra 0.1.600__py3-none-any.whl → 0.1.664__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/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/setup.py +0 -2
- svc_infra/api/fastapi/auth/add.py +0 -4
- svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +41 -6
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +59 -1
- svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +114 -0
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +3 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +21 -13
- 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/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -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 +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +28 -8
- svc_infra/cli/cmds/__init__.py +8 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/sql/repository.py +51 -11
- svc_infra/db/sql/resource.py +5 -0
- 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 +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/docs/acceptance-matrix.md +88 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
- svc_infra/docs/api.md +186 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/storage.md +982 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/webhook_delivery.py +14 -2
- svc_infra/jobs/queue.py +9 -1
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/worker.py +17 -1
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +6 -2
- svc_infra/security/models.py +27 -7
- svc_infra/security/oauth_models.py +59 -0
- svc_infra/security/permissions.py +1 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +214 -0
- svc_infra/storage/backends/s3.py +329 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +182 -0
- svc_infra/storage/settings.py +192 -0
- svc_infra/webhooks/service.py +10 -2
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/METADATA +45 -14
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/RECORD +140 -52
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local filesystem storage backend.
|
|
3
|
+
|
|
4
|
+
Ideal for Railway persistent volumes, Render disks, and local development.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import secrets
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
from urllib.parse import urlencode
|
|
15
|
+
|
|
16
|
+
import aiofiles
|
|
17
|
+
import aiofiles.os
|
|
18
|
+
|
|
19
|
+
from ..base import FileNotFoundError as StorageFileNotFoundError
|
|
20
|
+
from ..base import InvalidKeyError, PermissionDeniedError, StorageError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LocalBackend:
|
|
24
|
+
"""
|
|
25
|
+
Local filesystem storage backend.
|
|
26
|
+
|
|
27
|
+
Stores files on the local filesystem with metadata stored as JSON sidecar files.
|
|
28
|
+
Supports HMAC-based signed URLs with expiration.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
base_path: Base directory for file storage
|
|
32
|
+
base_url: Base URL for file serving (e.g., "http://localhost:8000/files")
|
|
33
|
+
signing_secret: Secret key for URL signing (auto-generated if not provided)
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> # Railway persistent volume
|
|
37
|
+
>>> backend = LocalBackend(
|
|
38
|
+
... base_path="/data/uploads",
|
|
39
|
+
... base_url="https://api.example.com/files"
|
|
40
|
+
... )
|
|
41
|
+
>>>
|
|
42
|
+
>>> # Local development
|
|
43
|
+
>>> backend = LocalBackend(
|
|
44
|
+
... base_path="./uploads",
|
|
45
|
+
... base_url="http://localhost:8000/files"
|
|
46
|
+
... )
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
base_path: str = "/data/uploads",
|
|
52
|
+
base_url: str = "http://localhost:8000/files",
|
|
53
|
+
signing_secret: Optional[str] = None,
|
|
54
|
+
):
|
|
55
|
+
self.base_path = Path(base_path)
|
|
56
|
+
self.base_url = base_url.rstrip("/")
|
|
57
|
+
self.signing_secret = signing_secret or secrets.token_urlsafe(32)
|
|
58
|
+
|
|
59
|
+
def _validate_key(self, key: str) -> None:
|
|
60
|
+
"""Validate storage key format."""
|
|
61
|
+
if not key:
|
|
62
|
+
raise InvalidKeyError("Key cannot be empty")
|
|
63
|
+
|
|
64
|
+
if key.startswith("/"):
|
|
65
|
+
raise InvalidKeyError("Key cannot start with /")
|
|
66
|
+
|
|
67
|
+
if ".." in key:
|
|
68
|
+
raise InvalidKeyError("Key cannot contain .. (path traversal)")
|
|
69
|
+
|
|
70
|
+
if len(key) > 1024:
|
|
71
|
+
raise InvalidKeyError("Key cannot exceed 1024 characters")
|
|
72
|
+
|
|
73
|
+
# Check for safe characters
|
|
74
|
+
safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/")
|
|
75
|
+
if not all(c in safe_chars for c in key):
|
|
76
|
+
raise InvalidKeyError(
|
|
77
|
+
"Key can only contain alphanumeric, dot, dash, underscore, and slash"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _get_file_path(self, key: str) -> Path:
|
|
81
|
+
"""Get absolute file path for a key."""
|
|
82
|
+
return self.base_path / key
|
|
83
|
+
|
|
84
|
+
def _get_metadata_path(self, key: str) -> Path:
|
|
85
|
+
"""Get metadata file path for a key."""
|
|
86
|
+
return self.base_path / f"{key}.meta.json"
|
|
87
|
+
|
|
88
|
+
def _sign_url(self, key: str, expires_at: int, download: bool) -> str:
|
|
89
|
+
"""Generate HMAC signature for URL."""
|
|
90
|
+
message = f"{key}:{expires_at}:{download}"
|
|
91
|
+
signature = hmac.new(
|
|
92
|
+
self.signing_secret.encode(),
|
|
93
|
+
message.encode(),
|
|
94
|
+
hashlib.sha256,
|
|
95
|
+
).hexdigest()
|
|
96
|
+
return signature
|
|
97
|
+
|
|
98
|
+
def _verify_signature(self, key: str, expires_at: int, download: bool, signature: str) -> bool:
|
|
99
|
+
"""Verify HMAC signature."""
|
|
100
|
+
expected = self._sign_url(key, expires_at, download)
|
|
101
|
+
return hmac.compare_digest(expected, signature)
|
|
102
|
+
|
|
103
|
+
async def put(
|
|
104
|
+
self,
|
|
105
|
+
key: str,
|
|
106
|
+
data: bytes,
|
|
107
|
+
content_type: str,
|
|
108
|
+
metadata: Optional[dict] = None,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Store file on local filesystem."""
|
|
111
|
+
self._validate_key(key)
|
|
112
|
+
|
|
113
|
+
file_path = self._get_file_path(key)
|
|
114
|
+
meta_path = self._get_metadata_path(key)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
# Create parent directories
|
|
118
|
+
await aiofiles.os.makedirs(file_path.parent, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
# Write file atomically using temp file
|
|
121
|
+
temp_path = file_path.with_suffix(f"{file_path.suffix}.tmp")
|
|
122
|
+
async with aiofiles.open(temp_path, "wb") as f:
|
|
123
|
+
await f.write(data)
|
|
124
|
+
|
|
125
|
+
# Rename to final path (atomic on POSIX)
|
|
126
|
+
await aiofiles.os.rename(temp_path, file_path)
|
|
127
|
+
|
|
128
|
+
# Write metadata
|
|
129
|
+
meta_data = {
|
|
130
|
+
"size": len(data),
|
|
131
|
+
"content_type": content_type,
|
|
132
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
133
|
+
**(metadata or {}),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async with aiofiles.open(meta_path, "w") as f:
|
|
137
|
+
await f.write(json.dumps(meta_data, indent=2))
|
|
138
|
+
|
|
139
|
+
except PermissionError as e:
|
|
140
|
+
raise PermissionDeniedError(f"Permission denied writing to {key}: {e}")
|
|
141
|
+
except OSError as e:
|
|
142
|
+
raise StorageError(f"Failed to write file {key}: {e}")
|
|
143
|
+
|
|
144
|
+
# Return signed URL (1 hour expiration)
|
|
145
|
+
return await self.get_url(key, expires_in=3600)
|
|
146
|
+
|
|
147
|
+
async def get(self, key: str) -> bytes:
|
|
148
|
+
"""Retrieve file from local filesystem."""
|
|
149
|
+
self._validate_key(key)
|
|
150
|
+
|
|
151
|
+
file_path = self._get_file_path(key)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
async with aiofiles.open(file_path, "rb") as f:
|
|
155
|
+
return await f.read()
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
raise StorageFileNotFoundError(f"File not found: {key}")
|
|
158
|
+
except PermissionError as e:
|
|
159
|
+
raise PermissionDeniedError(f"Permission denied reading {key}: {e}")
|
|
160
|
+
except OSError as e:
|
|
161
|
+
raise StorageError(f"Failed to read file {key}: {e}")
|
|
162
|
+
|
|
163
|
+
async def delete(self, key: str) -> bool:
|
|
164
|
+
"""Delete file from local filesystem."""
|
|
165
|
+
self._validate_key(key)
|
|
166
|
+
|
|
167
|
+
file_path = self._get_file_path(key)
|
|
168
|
+
meta_path = self._get_metadata_path(key)
|
|
169
|
+
|
|
170
|
+
if not file_path.exists():
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
# Delete file
|
|
175
|
+
await aiofiles.os.remove(file_path)
|
|
176
|
+
|
|
177
|
+
# Delete metadata if exists
|
|
178
|
+
if meta_path.exists():
|
|
179
|
+
await aiofiles.os.remove(meta_path)
|
|
180
|
+
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
except PermissionError as e:
|
|
184
|
+
raise PermissionDeniedError(f"Permission denied deleting {key}: {e}")
|
|
185
|
+
except OSError as e:
|
|
186
|
+
raise StorageError(f"Failed to delete file {key}: {e}")
|
|
187
|
+
|
|
188
|
+
async def exists(self, key: str) -> bool:
|
|
189
|
+
"""Check if file exists on local filesystem."""
|
|
190
|
+
self._validate_key(key)
|
|
191
|
+
|
|
192
|
+
file_path = self._get_file_path(key)
|
|
193
|
+
return file_path.exists()
|
|
194
|
+
|
|
195
|
+
async def get_url(
|
|
196
|
+
self,
|
|
197
|
+
key: str,
|
|
198
|
+
expires_in: int = 3600,
|
|
199
|
+
download: bool = False,
|
|
200
|
+
) -> str:
|
|
201
|
+
"""
|
|
202
|
+
Generate signed URL for file access.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
key: Storage key
|
|
206
|
+
expires_in: URL expiration in seconds (default: 1 hour)
|
|
207
|
+
download: If True, force download instead of inline display
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Signed URL with expiration and signature
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
>>> url = await backend.get_url("avatars/user_123/profile.jpg")
|
|
214
|
+
>>> # https://api.example.com/files/avatars/user_123/profile.jpg?expires=...&signature=...
|
|
215
|
+
"""
|
|
216
|
+
self._validate_key(key)
|
|
217
|
+
|
|
218
|
+
# Check if file exists
|
|
219
|
+
if not await self.exists(key):
|
|
220
|
+
raise StorageFileNotFoundError(f"File not found: {key}")
|
|
221
|
+
|
|
222
|
+
# Calculate expiration timestamp
|
|
223
|
+
expires_at = int(datetime.now(timezone.utc).timestamp()) + expires_in
|
|
224
|
+
|
|
225
|
+
# Generate signature
|
|
226
|
+
signature = self._sign_url(key, expires_at, download)
|
|
227
|
+
|
|
228
|
+
# Build URL with query parameters
|
|
229
|
+
params = {
|
|
230
|
+
"expires": str(expires_at),
|
|
231
|
+
"signature": signature,
|
|
232
|
+
}
|
|
233
|
+
if download:
|
|
234
|
+
params["download"] = "true"
|
|
235
|
+
|
|
236
|
+
url = f"{self.base_url}/{key}?{urlencode(params)}"
|
|
237
|
+
return url
|
|
238
|
+
|
|
239
|
+
def verify_url(self, key: str, expires: str, signature: str, download: bool = False) -> bool:
|
|
240
|
+
"""
|
|
241
|
+
Verify a signed URL (for use in file serving endpoint).
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
key: Storage key
|
|
245
|
+
expires: Expiration timestamp as string
|
|
246
|
+
signature: HMAC signature
|
|
247
|
+
download: Download flag
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
True if signature is valid and not expired
|
|
251
|
+
|
|
252
|
+
Example:
|
|
253
|
+
>>> # In file serving route
|
|
254
|
+
>>> if not backend.verify_url(key, expires, signature):
|
|
255
|
+
... raise HTTPException(403, "Invalid signature")
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
expires_at = int(expires)
|
|
259
|
+
except (ValueError, TypeError):
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
# Check expiration
|
|
263
|
+
now = int(datetime.now(timezone.utc).timestamp())
|
|
264
|
+
if now > expires_at:
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
# Verify signature
|
|
268
|
+
return self._verify_signature(key, expires_at, download, signature)
|
|
269
|
+
|
|
270
|
+
async def list_keys(
|
|
271
|
+
self,
|
|
272
|
+
prefix: str = "",
|
|
273
|
+
limit: int = 100,
|
|
274
|
+
) -> list[str]:
|
|
275
|
+
"""List stored keys with optional prefix filter."""
|
|
276
|
+
import os
|
|
277
|
+
|
|
278
|
+
prefix_path = self.base_path / prefix if prefix else self.base_path
|
|
279
|
+
|
|
280
|
+
if not prefix_path.exists():
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
keys: list[str] = []
|
|
284
|
+
|
|
285
|
+
# Walk directory tree (using os.walk for Python 3.11 compatibility)
|
|
286
|
+
for root, _, files in os.walk(prefix_path):
|
|
287
|
+
for file in files:
|
|
288
|
+
# Skip metadata files
|
|
289
|
+
if file.endswith(".meta.json"):
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
# Get relative path
|
|
293
|
+
file_path = Path(root) / file
|
|
294
|
+
relative = file_path.relative_to(self.base_path)
|
|
295
|
+
key = str(relative)
|
|
296
|
+
|
|
297
|
+
keys.append(key)
|
|
298
|
+
|
|
299
|
+
if len(keys) >= limit:
|
|
300
|
+
return keys
|
|
301
|
+
|
|
302
|
+
return keys
|
|
303
|
+
|
|
304
|
+
async def get_metadata(self, key: str) -> dict:
|
|
305
|
+
"""Get file metadata."""
|
|
306
|
+
self._validate_key(key)
|
|
307
|
+
|
|
308
|
+
meta_path = self._get_metadata_path(key)
|
|
309
|
+
|
|
310
|
+
if not meta_path.exists():
|
|
311
|
+
# File exists but no metadata, create basic metadata
|
|
312
|
+
file_path = self._get_file_path(key)
|
|
313
|
+
if not file_path.exists():
|
|
314
|
+
raise StorageFileNotFoundError(f"File not found: {key}")
|
|
315
|
+
|
|
316
|
+
stat = await aiofiles.os.stat(file_path)
|
|
317
|
+
return {
|
|
318
|
+
"size": stat.st_size,
|
|
319
|
+
"content_type": "application/octet-stream",
|
|
320
|
+
"created_at": datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc).isoformat(),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
async with aiofiles.open(meta_path, "r") as f:
|
|
325
|
+
content = await f.read()
|
|
326
|
+
return json.loads(content)
|
|
327
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
328
|
+
raise StorageError(f"Failed to read metadata for {key}: {e}")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
__all__ = ["LocalBackend"]
|
|
@@ -0,0 +1,214 @@
|
|
|
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("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/")
|
|
61
|
+
if not all(c in safe_chars for c in key):
|
|
62
|
+
raise InvalidKeyError(
|
|
63
|
+
"Key can only contain alphanumeric, dot, dash, underscore, and slash"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _get_total_size(self) -> int:
|
|
67
|
+
"""Calculate total storage size."""
|
|
68
|
+
return sum(len(data) for data in self._storage.values())
|
|
69
|
+
|
|
70
|
+
async def put(
|
|
71
|
+
self,
|
|
72
|
+
key: str,
|
|
73
|
+
data: bytes,
|
|
74
|
+
content_type: str,
|
|
75
|
+
metadata: Optional[dict] = None,
|
|
76
|
+
) -> str:
|
|
77
|
+
"""Store file in memory."""
|
|
78
|
+
self._validate_key(key)
|
|
79
|
+
|
|
80
|
+
async with self._lock:
|
|
81
|
+
# Check quota
|
|
82
|
+
current_size = self._get_total_size()
|
|
83
|
+
new_size = len(data)
|
|
84
|
+
|
|
85
|
+
# If replacing existing file, subtract its size
|
|
86
|
+
if key in self._storage:
|
|
87
|
+
current_size -= len(self._storage[key])
|
|
88
|
+
|
|
89
|
+
if current_size + new_size > self.max_size:
|
|
90
|
+
raise QuotaExceededError(
|
|
91
|
+
f"Storage quota exceeded. "
|
|
92
|
+
f"Current: {current_size}, New: {new_size}, Max: {self.max_size}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Store file
|
|
96
|
+
self._storage[key] = data
|
|
97
|
+
|
|
98
|
+
# Store metadata
|
|
99
|
+
self._metadata[key] = {
|
|
100
|
+
"size": len(data),
|
|
101
|
+
"content_type": content_type,
|
|
102
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
103
|
+
**(metadata or {}),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Return memory:// URL
|
|
107
|
+
return f"memory://{key}"
|
|
108
|
+
|
|
109
|
+
async def get(self, key: str) -> bytes:
|
|
110
|
+
"""Retrieve file from memory."""
|
|
111
|
+
self._validate_key(key)
|
|
112
|
+
|
|
113
|
+
async with self._lock:
|
|
114
|
+
if key not in self._storage:
|
|
115
|
+
raise FileNotFoundError(f"File not found: {key}")
|
|
116
|
+
|
|
117
|
+
return self._storage[key]
|
|
118
|
+
|
|
119
|
+
async def delete(self, key: str) -> bool:
|
|
120
|
+
"""Delete file from memory."""
|
|
121
|
+
self._validate_key(key)
|
|
122
|
+
|
|
123
|
+
async with self._lock:
|
|
124
|
+
if key not in self._storage:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
del self._storage[key]
|
|
128
|
+
del self._metadata[key]
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
async def exists(self, key: str) -> bool:
|
|
132
|
+
"""Check if file exists in memory."""
|
|
133
|
+
self._validate_key(key)
|
|
134
|
+
|
|
135
|
+
async with self._lock:
|
|
136
|
+
return key in self._storage
|
|
137
|
+
|
|
138
|
+
async def get_url(
|
|
139
|
+
self,
|
|
140
|
+
key: str,
|
|
141
|
+
expires_in: int = 3600,
|
|
142
|
+
download: bool = False,
|
|
143
|
+
) -> str:
|
|
144
|
+
"""
|
|
145
|
+
Generate memory:// URL.
|
|
146
|
+
|
|
147
|
+
Note: Memory backend doesn't support real URLs or expiration.
|
|
148
|
+
Returns memory:// scheme for testing purposes.
|
|
149
|
+
"""
|
|
150
|
+
self._validate_key(key)
|
|
151
|
+
|
|
152
|
+
async with self._lock:
|
|
153
|
+
if key not in self._storage:
|
|
154
|
+
raise FileNotFoundError(f"File not found: {key}")
|
|
155
|
+
|
|
156
|
+
# Memory backend doesn't support real URLs
|
|
157
|
+
# Return memory:// scheme for testing
|
|
158
|
+
suffix = "?download=true" if download else ""
|
|
159
|
+
return f"memory://{key}{suffix}"
|
|
160
|
+
|
|
161
|
+
async def list_keys(
|
|
162
|
+
self,
|
|
163
|
+
prefix: str = "",
|
|
164
|
+
limit: int = 100,
|
|
165
|
+
) -> list[str]:
|
|
166
|
+
"""List stored keys with optional prefix filter."""
|
|
167
|
+
async with self._lock:
|
|
168
|
+
keys = [key for key in self._storage.keys() if key.startswith(prefix)]
|
|
169
|
+
return keys[:limit]
|
|
170
|
+
|
|
171
|
+
async def get_metadata(self, key: str) -> dict:
|
|
172
|
+
"""Get file metadata."""
|
|
173
|
+
self._validate_key(key)
|
|
174
|
+
|
|
175
|
+
async with self._lock:
|
|
176
|
+
if key not in self._metadata:
|
|
177
|
+
raise FileNotFoundError(f"File not found: {key}")
|
|
178
|
+
|
|
179
|
+
return self._metadata[key].copy()
|
|
180
|
+
|
|
181
|
+
async def clear(self) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Clear all stored files (testing utility).
|
|
184
|
+
|
|
185
|
+
Example:
|
|
186
|
+
>>> backend = MemoryBackend()
|
|
187
|
+
>>> await backend.put("test.txt", b"data", "text/plain")
|
|
188
|
+
>>> await backend.clear()
|
|
189
|
+
>>> await backend.exists("test.txt") # False
|
|
190
|
+
"""
|
|
191
|
+
async with self._lock:
|
|
192
|
+
self._storage.clear()
|
|
193
|
+
self._metadata.clear()
|
|
194
|
+
|
|
195
|
+
def get_stats(self) -> dict:
|
|
196
|
+
"""
|
|
197
|
+
Get storage statistics (testing utility).
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Dict with file_count, total_size, max_size
|
|
201
|
+
|
|
202
|
+
Example:
|
|
203
|
+
>>> backend = MemoryBackend(max_size=1000)
|
|
204
|
+
>>> stats = backend.get_stats()
|
|
205
|
+
>>> print(f"Files: {stats['file_count']}, Size: {stats['total_size']}")
|
|
206
|
+
"""
|
|
207
|
+
return {
|
|
208
|
+
"file_count": len(self._storage),
|
|
209
|
+
"total_size": self._get_total_size(),
|
|
210
|
+
"max_size": self.max_size,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = ["MemoryBackend"]
|