py-storebridge 1.0.0__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.
- py_storebridge/__init__.py +126 -0
- py_storebridge/client.py +222 -0
- py_storebridge/config.py +296 -0
- py_storebridge/exceptions.py +209 -0
- py_storebridge/factory.py +58 -0
- py_storebridge/hooks.py +50 -0
- py_storebridge/io.py +79 -0
- py_storebridge/keys.py +59 -0
- py_storebridge/logging.py +52 -0
- py_storebridge/middleware.py +45 -0
- py_storebridge/mime.py +49 -0
- py_storebridge/providers/__init__.py +9 -0
- py_storebridge/providers/_deps.py +15 -0
- py_storebridge/providers/appwrite.py +235 -0
- py_storebridge/providers/azure.py +277 -0
- py_storebridge/providers/b2.py +43 -0
- py_storebridge/providers/base.py +402 -0
- py_storebridge/providers/firebase.py +48 -0
- py_storebridge/providers/gcs.py +269 -0
- py_storebridge/providers/minio.py +46 -0
- py_storebridge/providers/r2.py +42 -0
- py_storebridge/providers/s3.py +39 -0
- py_storebridge/providers/s3compat.py +406 -0
- py_storebridge/providers/spaces.py +42 -0
- py_storebridge/providers/supabase.py +281 -0
- py_storebridge/py.typed +0 -0
- py_storebridge/retry.py +76 -0
- py_storebridge/schemas.py +222 -0
- py_storebridge/transfer.py +207 -0
- py_storebridge-1.0.0.dist-info/METADATA +562 -0
- py_storebridge-1.0.0.dist-info/RECORD +34 -0
- py_storebridge-1.0.0.dist-info/WHEEL +5 -0
- py_storebridge-1.0.0.dist-info/licenses/LICENSE +21 -0
- py_storebridge-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""py_storebridge — unified cloud storage SDK for Python."""
|
|
2
|
+
|
|
3
|
+
from py_storebridge.client import StorageClient
|
|
4
|
+
from py_storebridge.config import (
|
|
5
|
+
AppwriteCredentials,
|
|
6
|
+
AzureCredentials,
|
|
7
|
+
B2Credentials,
|
|
8
|
+
FirebaseCredentials,
|
|
9
|
+
GCSCredentials,
|
|
10
|
+
LogLevel,
|
|
11
|
+
MinIOCredentials,
|
|
12
|
+
PROVIDERS,
|
|
13
|
+
R2Credentials,
|
|
14
|
+
RetryConfig,
|
|
15
|
+
S3Credentials,
|
|
16
|
+
SpacesCredentials,
|
|
17
|
+
StorageConfig,
|
|
18
|
+
SupabaseCredentials,
|
|
19
|
+
Visibility,
|
|
20
|
+
parse_storage_config,
|
|
21
|
+
)
|
|
22
|
+
from py_storebridge.exceptions import (
|
|
23
|
+
BucketNotFoundError,
|
|
24
|
+
FileNotFoundError,
|
|
25
|
+
MaxRetriesExceededError,
|
|
26
|
+
MissingConfigError,
|
|
27
|
+
MissingDependencyError,
|
|
28
|
+
MultipartUploadError,
|
|
29
|
+
NetworkError,
|
|
30
|
+
PermissionError,
|
|
31
|
+
ProviderError,
|
|
32
|
+
StoreBridgeError,
|
|
33
|
+
StorixError,
|
|
34
|
+
TransferError,
|
|
35
|
+
UnknownMimeTypeError,
|
|
36
|
+
ValidationError,
|
|
37
|
+
)
|
|
38
|
+
from py_storebridge.factory import create_storage, create_storage_from_provider
|
|
39
|
+
from py_storebridge.hooks import HOOK_EVENTS, HookSystem
|
|
40
|
+
from py_storebridge.middleware import MiddlewareChain, MiddlewareContext
|
|
41
|
+
from py_storebridge.mime import detect_mime_type
|
|
42
|
+
from py_storebridge.providers.base import BaseProvider
|
|
43
|
+
from py_storebridge.retry import merge_retry_config, with_retry
|
|
44
|
+
from py_storebridge.schemas import (
|
|
45
|
+
CopyOptions,
|
|
46
|
+
DeleteOptions,
|
|
47
|
+
FileListEntry,
|
|
48
|
+
FileMetadata,
|
|
49
|
+
ListOptions,
|
|
50
|
+
ListResult,
|
|
51
|
+
MultipartPartResult,
|
|
52
|
+
MultipartUploadSession,
|
|
53
|
+
SignedUrlOptions,
|
|
54
|
+
TransferItemResult,
|
|
55
|
+
TransferItemStatus,
|
|
56
|
+
TransferOptions,
|
|
57
|
+
TransferResult,
|
|
58
|
+
UploadOptions,
|
|
59
|
+
UploadPartOptions,
|
|
60
|
+
UploadResult,
|
|
61
|
+
)
|
|
62
|
+
from py_storebridge.transfer import transfer
|
|
63
|
+
|
|
64
|
+
__version__ = "1.0.0"
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"AppwriteCredentials",
|
|
68
|
+
"AzureCredentials",
|
|
69
|
+
"B2Credentials",
|
|
70
|
+
"BaseProvider",
|
|
71
|
+
"BucketNotFoundError",
|
|
72
|
+
"CopyOptions",
|
|
73
|
+
"DeleteOptions",
|
|
74
|
+
"FileListEntry",
|
|
75
|
+
"FileMetadata",
|
|
76
|
+
"FileNotFoundError",
|
|
77
|
+
"FirebaseCredentials",
|
|
78
|
+
"GCSCredentials",
|
|
79
|
+
"HOOK_EVENTS",
|
|
80
|
+
"HookSystem",
|
|
81
|
+
"ListOptions",
|
|
82
|
+
"ListResult",
|
|
83
|
+
"LogLevel",
|
|
84
|
+
"MaxRetriesExceededError",
|
|
85
|
+
"MiddlewareChain",
|
|
86
|
+
"MiddlewareContext",
|
|
87
|
+
"MinIOCredentials",
|
|
88
|
+
"MissingConfigError",
|
|
89
|
+
"MissingDependencyError",
|
|
90
|
+
"MultipartPartResult",
|
|
91
|
+
"MultipartUploadError",
|
|
92
|
+
"MultipartUploadSession",
|
|
93
|
+
"NetworkError",
|
|
94
|
+
"PROVIDERS",
|
|
95
|
+
"PermissionError",
|
|
96
|
+
"ProviderError",
|
|
97
|
+
"R2Credentials",
|
|
98
|
+
"RetryConfig",
|
|
99
|
+
"S3Credentials",
|
|
100
|
+
"SignedUrlOptions",
|
|
101
|
+
"SpacesCredentials",
|
|
102
|
+
"StorageClient",
|
|
103
|
+
"StorageConfig",
|
|
104
|
+
"StoreBridgeError",
|
|
105
|
+
"StorixError",
|
|
106
|
+
"SupabaseCredentials",
|
|
107
|
+
"TransferError",
|
|
108
|
+
"TransferItemResult",
|
|
109
|
+
"TransferItemStatus",
|
|
110
|
+
"TransferOptions",
|
|
111
|
+
"TransferResult",
|
|
112
|
+
"UnknownMimeTypeError",
|
|
113
|
+
"UploadOptions",
|
|
114
|
+
"UploadPartOptions",
|
|
115
|
+
"UploadResult",
|
|
116
|
+
"ValidationError",
|
|
117
|
+
"Visibility",
|
|
118
|
+
"__version__",
|
|
119
|
+
"create_storage",
|
|
120
|
+
"create_storage_from_provider",
|
|
121
|
+
"detect_mime_type",
|
|
122
|
+
"merge_retry_config",
|
|
123
|
+
"parse_storage_config",
|
|
124
|
+
"transfer",
|
|
125
|
+
"with_retry",
|
|
126
|
+
]
|
py_storebridge/client.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Public StorageClient wrapping a provider with hooks and middleware."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, BinaryIO, Iterator, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from py_storebridge.exceptions import StoreBridgeError
|
|
8
|
+
from py_storebridge.hooks import HookHandler, HookSystem
|
|
9
|
+
from py_storebridge.middleware import Middleware, MiddlewareChain, MiddlewareContext
|
|
10
|
+
from py_storebridge.providers.base import BaseProvider
|
|
11
|
+
from py_storebridge.schemas import (
|
|
12
|
+
CopyOptions,
|
|
13
|
+
DeleteOptions,
|
|
14
|
+
FileListEntry,
|
|
15
|
+
FileMetadata,
|
|
16
|
+
ListOptions,
|
|
17
|
+
ListResult,
|
|
18
|
+
MultipartPartResult,
|
|
19
|
+
MultipartUploadSession,
|
|
20
|
+
SignedUrlOptions,
|
|
21
|
+
TransferOptions,
|
|
22
|
+
TransferResult,
|
|
23
|
+
UploadOptions,
|
|
24
|
+
UploadPartOptions,
|
|
25
|
+
UploadResult,
|
|
26
|
+
parse_model,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StorageClient:
|
|
31
|
+
"""Unified storage surface. Construct via ``create_storage()``."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, provider: BaseProvider) -> None:
|
|
34
|
+
self._provider = provider
|
|
35
|
+
self._hooks = HookSystem()
|
|
36
|
+
self._middleware = MiddlewareChain()
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def provider_name(self) -> str:
|
|
40
|
+
return self._provider.provider_name
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def provider(self) -> BaseProvider:
|
|
44
|
+
return self._provider
|
|
45
|
+
|
|
46
|
+
def use(self, middleware: Middleware) -> "StorageClient":
|
|
47
|
+
self._middleware.use(middleware)
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def on(self, event: str, handler: HookHandler) -> "StorageClient":
|
|
51
|
+
self._hooks.on(event, handler)
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def off(self, event: str, handler: HookHandler) -> "StorageClient":
|
|
55
|
+
self._hooks.off(event, handler)
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def upload(self, options: Union[UploadOptions, dict, None] = None, **kwargs: Any) -> UploadResult:
|
|
59
|
+
parsed = parse_model(UploadOptions, options, **kwargs)
|
|
60
|
+
self._hooks.emit("before:upload", parsed)
|
|
61
|
+
try:
|
|
62
|
+
result = self._middleware.execute(
|
|
63
|
+
MiddlewareContext(options=parsed, provider=self.provider_name),
|
|
64
|
+
lambda ctx: self._provider.upload(ctx.options),
|
|
65
|
+
)
|
|
66
|
+
except StoreBridgeError as exc:
|
|
67
|
+
self._hooks.emit("error", {"operation": "upload", "error": exc})
|
|
68
|
+
raise
|
|
69
|
+
self._hooks.emit("after:upload", result)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
def delete(self, options: Union[DeleteOptions, dict, None] = None, **kwargs: Any) -> None:
|
|
73
|
+
parsed = parse_model(DeleteOptions, options, **kwargs)
|
|
74
|
+
self._hooks.emit("before:delete", parsed)
|
|
75
|
+
try:
|
|
76
|
+
self._provider.delete(parsed)
|
|
77
|
+
except StoreBridgeError as exc:
|
|
78
|
+
self._hooks.emit("error", {"operation": "delete", "error": exc})
|
|
79
|
+
raise
|
|
80
|
+
self._hooks.emit("after:delete", parsed)
|
|
81
|
+
|
|
82
|
+
def exists(self, key: str) -> bool:
|
|
83
|
+
return self._provider.exists(key)
|
|
84
|
+
|
|
85
|
+
def get_metadata(self, key: str) -> FileMetadata:
|
|
86
|
+
return self._provider.get_metadata(key)
|
|
87
|
+
|
|
88
|
+
def list(self, options: Union[ListOptions, dict, None] = None, **kwargs: Any) -> ListResult:
|
|
89
|
+
parsed = parse_model(ListOptions, options or kwargs or {})
|
|
90
|
+
self._hooks.emit("before:list", {"prefix": parsed.prefix})
|
|
91
|
+
try:
|
|
92
|
+
result = self._provider.list(parsed)
|
|
93
|
+
except StoreBridgeError as exc:
|
|
94
|
+
self._hooks.emit("error", {"operation": "list", "error": exc})
|
|
95
|
+
raise
|
|
96
|
+
self._hooks.emit("after:list", result)
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
def iter_files(self, prefix: Optional[str] = None, page_size: int = 1000) -> Iterator[FileListEntry]:
|
|
100
|
+
return self._provider.iter_files(prefix=prefix, page_size=page_size)
|
|
101
|
+
|
|
102
|
+
def get_url(self, key: str) -> str:
|
|
103
|
+
return self._provider.get_url(key)
|
|
104
|
+
|
|
105
|
+
def get_signed_url(
|
|
106
|
+
self,
|
|
107
|
+
key: str,
|
|
108
|
+
options: Union[SignedUrlOptions, dict, None] = None,
|
|
109
|
+
**kwargs: Any,
|
|
110
|
+
) -> str:
|
|
111
|
+
parsed = parse_model(SignedUrlOptions, options, **kwargs) if options or kwargs else SignedUrlOptions()
|
|
112
|
+
self._hooks.emit("before:getSignedUrl", {"key": key, "expires_in": parsed.expires_in})
|
|
113
|
+
try:
|
|
114
|
+
url = self._provider.get_signed_url(key, parsed)
|
|
115
|
+
except StoreBridgeError as exc:
|
|
116
|
+
self._hooks.emit("error", {"operation": "getSignedUrl", "error": exc})
|
|
117
|
+
raise
|
|
118
|
+
self._hooks.emit("after:getSignedUrl", {"key": key, "url": url})
|
|
119
|
+
return url
|
|
120
|
+
|
|
121
|
+
def copy(self, options: Union[CopyOptions, dict, None] = None, **kwargs: Any) -> UploadResult:
|
|
122
|
+
parsed = parse_model(CopyOptions, options, **kwargs)
|
|
123
|
+
self._hooks.emit(
|
|
124
|
+
"before:copy",
|
|
125
|
+
{"source_key": parsed.source_key, "destination_key": parsed.destination_key},
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
result = self._provider.copy(parsed)
|
|
129
|
+
except StoreBridgeError as exc:
|
|
130
|
+
self._hooks.emit("error", {"operation": "copy", "error": exc})
|
|
131
|
+
raise
|
|
132
|
+
self._hooks.emit("after:copy", result)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
def move(self, options: Union[CopyOptions, dict, None] = None, **kwargs: Any) -> UploadResult:
|
|
136
|
+
parsed = parse_model(CopyOptions, options, **kwargs)
|
|
137
|
+
self._hooks.emit(
|
|
138
|
+
"before:move",
|
|
139
|
+
{"source_key": parsed.source_key, "destination_key": parsed.destination_key},
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
result = self._provider.move(parsed)
|
|
143
|
+
except StoreBridgeError as exc:
|
|
144
|
+
self._hooks.emit("error", {"operation": "move", "error": exc})
|
|
145
|
+
raise
|
|
146
|
+
self._hooks.emit("after:move", result)
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
def rename(self, source_key: str, destination_key: str) -> UploadResult:
|
|
150
|
+
return self.move(source_key=source_key, destination_key=destination_key)
|
|
151
|
+
|
|
152
|
+
def get_stream(self, key: str) -> BinaryIO:
|
|
153
|
+
return self._provider.get_stream(key)
|
|
154
|
+
|
|
155
|
+
def get_buffer(self, key: str) -> bytes:
|
|
156
|
+
return self._provider.get_buffer(key)
|
|
157
|
+
|
|
158
|
+
def download_file(self, key: str, destination: Union[str, Any]) -> None:
|
|
159
|
+
from pathlib import Path
|
|
160
|
+
|
|
161
|
+
data = self.get_buffer(key)
|
|
162
|
+
path = Path(destination)
|
|
163
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
path.write_bytes(data)
|
|
165
|
+
|
|
166
|
+
def upload_file(self, path: Union[str, Any], key: Optional[str] = None, **kwargs: Any) -> UploadResult:
|
|
167
|
+
from pathlib import Path
|
|
168
|
+
|
|
169
|
+
file_path = Path(path)
|
|
170
|
+
object_key = key or file_path.name
|
|
171
|
+
return self.upload(key=object_key, path=file_path, **kwargs)
|
|
172
|
+
|
|
173
|
+
def create_multipart_upload(self, key: str, content_type: Optional[str] = None) -> MultipartUploadSession:
|
|
174
|
+
return self._provider.create_multipart_upload(key, content_type)
|
|
175
|
+
|
|
176
|
+
def upload_part(self, options: Union[UploadPartOptions, dict, None] = None, **kwargs: Any) -> MultipartPartResult:
|
|
177
|
+
parsed = parse_model(UploadPartOptions, options, **kwargs)
|
|
178
|
+
return self._provider.upload_part(parsed)
|
|
179
|
+
|
|
180
|
+
def complete_multipart_upload(
|
|
181
|
+
self,
|
|
182
|
+
session: MultipartUploadSession,
|
|
183
|
+
parts: List[MultipartPartResult],
|
|
184
|
+
) -> UploadResult:
|
|
185
|
+
return self._provider.complete_multipart_upload(session, parts)
|
|
186
|
+
|
|
187
|
+
def abort_multipart_upload(self, session: MultipartUploadSession) -> None:
|
|
188
|
+
self._provider.abort_multipart_upload(session)
|
|
189
|
+
|
|
190
|
+
def list_parts(self, session: MultipartUploadSession) -> List[FileListEntry]:
|
|
191
|
+
return self._provider.list_parts(session)
|
|
192
|
+
|
|
193
|
+
def upload_multipart(
|
|
194
|
+
self,
|
|
195
|
+
options: Union[UploadOptions, dict, None] = None,
|
|
196
|
+
chunk_size: int = 5 * 1024 * 1024,
|
|
197
|
+
**kwargs: Any,
|
|
198
|
+
) -> UploadResult:
|
|
199
|
+
parsed = parse_model(UploadOptions, options, **kwargs)
|
|
200
|
+
return self._provider.upload_multipart(parsed, chunk_size=chunk_size)
|
|
201
|
+
|
|
202
|
+
def delete_prefix(self, prefix: str, *, continue_on_error: bool = True) -> int:
|
|
203
|
+
"""Delete every object under ``prefix``. Returns the number deleted."""
|
|
204
|
+
deleted = 0
|
|
205
|
+
for entry in self.iter_files(prefix=prefix):
|
|
206
|
+
try:
|
|
207
|
+
self.delete(key=entry.key)
|
|
208
|
+
deleted += 1
|
|
209
|
+
except StoreBridgeError:
|
|
210
|
+
if not continue_on_error:
|
|
211
|
+
raise
|
|
212
|
+
return deleted
|
|
213
|
+
|
|
214
|
+
def transfer_to(
|
|
215
|
+
self,
|
|
216
|
+
destination: "StorageClient",
|
|
217
|
+
options: Union[TransferOptions, dict, None] = None,
|
|
218
|
+
**kwargs: Any,
|
|
219
|
+
) -> TransferResult:
|
|
220
|
+
from py_storebridge.transfer import transfer
|
|
221
|
+
|
|
222
|
+
return transfer(self, destination, options, **kwargs)
|
py_storebridge/config.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Pydantic configuration and credential models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
|
|
7
|
+
|
|
8
|
+
from pydantic import (
|
|
9
|
+
BaseModel,
|
|
10
|
+
ConfigDict,
|
|
11
|
+
Field,
|
|
12
|
+
TypeAdapter,
|
|
13
|
+
ValidationError as PydanticValidationError,
|
|
14
|
+
field_validator,
|
|
15
|
+
model_validator,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from py_storebridge.exceptions import ValidationError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Visibility(str, Enum):
|
|
22
|
+
PUBLIC = "public"
|
|
23
|
+
PRIVATE = "private"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LogLevel(str, Enum):
|
|
27
|
+
DEBUG = "debug"
|
|
28
|
+
INFO = "info"
|
|
29
|
+
WARN = "warn"
|
|
30
|
+
ERROR = "error"
|
|
31
|
+
SILENT = "silent"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Model(BaseModel):
|
|
35
|
+
model_config = ConfigDict(
|
|
36
|
+
populate_by_name=True,
|
|
37
|
+
extra="forbid",
|
|
38
|
+
str_strip_whitespace=True,
|
|
39
|
+
use_enum_values=True,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RetryConfig(_Model):
|
|
44
|
+
"""Retry policy. Delays are milliseconds, matching the Node.js SDK."""
|
|
45
|
+
|
|
46
|
+
max_retries: int = Field(default=3, ge=0, le=50, alias="maxRetries")
|
|
47
|
+
base_delay: int = Field(default=200, ge=0, alias="baseDelay")
|
|
48
|
+
max_delay: int = Field(default=10_000, ge=0, alias="maxDelay")
|
|
49
|
+
retryable_status_codes: List[int] = Field(
|
|
50
|
+
default_factory=lambda: [408, 429, 500, 502, 503, 504],
|
|
51
|
+
alias="retryableStatusCodes",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@model_validator(mode="after")
|
|
55
|
+
def _delays(self) -> "RetryConfig":
|
|
56
|
+
if self.max_delay < self.base_delay:
|
|
57
|
+
raise ValueError("max_delay must be greater than or equal to base_delay")
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class S3Credentials(_Model):
|
|
62
|
+
access_key_id: str = Field(min_length=1, alias="accessKeyId")
|
|
63
|
+
secret_access_key: str = Field(min_length=1, alias="secretAccessKey")
|
|
64
|
+
region: str = Field(min_length=1)
|
|
65
|
+
bucket: str = Field(min_length=1)
|
|
66
|
+
endpoint: Optional[str] = None
|
|
67
|
+
force_path_style: Optional[bool] = Field(default=None, alias="forcePathStyle")
|
|
68
|
+
session_token: Optional[str] = Field(default=None, alias="sessionToken")
|
|
69
|
+
cdn_url: Optional[str] = Field(default=None, alias="cdnUrl")
|
|
70
|
+
custom_domain: Optional[str] = Field(default=None, alias="customDomain")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class R2Credentials(_Model):
|
|
74
|
+
account_id: str = Field(min_length=1, alias="accountId")
|
|
75
|
+
access_key_id: str = Field(min_length=1, alias="accessKeyId")
|
|
76
|
+
secret_access_key: str = Field(min_length=1, alias="secretAccessKey")
|
|
77
|
+
bucket: str = Field(min_length=1)
|
|
78
|
+
cdn_url: Optional[str] = Field(default=None, alias="cdnUrl")
|
|
79
|
+
custom_domain: Optional[str] = Field(default=None, alias="customDomain")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GCSServiceAccount(_Model):
|
|
83
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
84
|
+
|
|
85
|
+
client_email: str = Field(min_length=1)
|
|
86
|
+
private_key: str = Field(min_length=1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class GCSCredentials(_Model):
|
|
90
|
+
project_id: str = Field(min_length=1, alias="projectId")
|
|
91
|
+
bucket: str = Field(min_length=1)
|
|
92
|
+
key_filename: Optional[str] = Field(default=None, alias="keyFilename")
|
|
93
|
+
credentials: Optional[GCSServiceAccount] = None
|
|
94
|
+
service_account: Optional[Dict[str, Any]] = Field(default=None, alias="serviceAccount")
|
|
95
|
+
cdn_url: Optional[str] = Field(default=None, alias="cdnUrl")
|
|
96
|
+
custom_domain: Optional[str] = Field(default=None, alias="customDomain")
|
|
97
|
+
|
|
98
|
+
@field_validator("credentials", mode="before")
|
|
99
|
+
@classmethod
|
|
100
|
+
def _coerce_credentials(cls, value: Any) -> Any:
|
|
101
|
+
if isinstance(value, dict) and "client_email" in value:
|
|
102
|
+
return value
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AzureCredentials(_Model):
|
|
107
|
+
account_name: str = Field(min_length=1, alias="accountName")
|
|
108
|
+
container_name: str = Field(min_length=1, alias="containerName")
|
|
109
|
+
account_key: Optional[str] = Field(default=None, alias="accountKey")
|
|
110
|
+
connection_string: Optional[str] = Field(default=None, alias="connectionString")
|
|
111
|
+
cdn_url: Optional[str] = Field(default=None, alias="cdnUrl")
|
|
112
|
+
custom_domain: Optional[str] = Field(default=None, alias="customDomain")
|
|
113
|
+
|
|
114
|
+
@model_validator(mode="after")
|
|
115
|
+
def _auth(self) -> "AzureCredentials":
|
|
116
|
+
if not self.account_key and not self.connection_string:
|
|
117
|
+
raise ValueError("Provide account_key or connection_string")
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SpacesCredentials(_Model):
|
|
122
|
+
access_key_id: str = Field(min_length=1, alias="accessKeyId")
|
|
123
|
+
secret_access_key: str = Field(min_length=1, alias="secretAccessKey")
|
|
124
|
+
region: str = Field(min_length=1)
|
|
125
|
+
bucket: str = Field(min_length=1)
|
|
126
|
+
endpoint: Optional[str] = None
|
|
127
|
+
cdn_url: Optional[str] = Field(default=None, alias="cdnUrl")
|
|
128
|
+
custom_domain: Optional[str] = Field(default=None, alias="customDomain")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class B2Credentials(_Model):
|
|
132
|
+
application_key_id: str = Field(min_length=1, alias="applicationKeyId")
|
|
133
|
+
application_key: str = Field(min_length=1, alias="applicationKey")
|
|
134
|
+
bucket: str = Field(min_length=1)
|
|
135
|
+
endpoint: Optional[str] = None
|
|
136
|
+
region: Optional[str] = "us-west-004"
|
|
137
|
+
cdn_url: Optional[str] = Field(default=None, alias="cdnUrl")
|
|
138
|
+
custom_domain: Optional[str] = Field(default=None, alias="customDomain")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class MinIOCredentials(_Model):
|
|
142
|
+
end_point: str = Field(min_length=1, alias="endPoint")
|
|
143
|
+
access_key: str = Field(min_length=1, alias="accessKey")
|
|
144
|
+
secret_key: str = Field(min_length=1, alias="secretKey")
|
|
145
|
+
bucket: str = Field(min_length=1)
|
|
146
|
+
port: Optional[int] = Field(default=9000, ge=1, le=65535)
|
|
147
|
+
use_ssl: bool = Field(default=True, alias="useSSL")
|
|
148
|
+
region: Optional[str] = "us-east-1"
|
|
149
|
+
cdn_url: Optional[str] = Field(default=None, alias="cdnUrl")
|
|
150
|
+
custom_domain: Optional[str] = Field(default=None, alias="customDomain")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class SupabaseCredentials(_Model):
|
|
154
|
+
url: str = Field(min_length=1)
|
|
155
|
+
service_role_key: str = Field(min_length=1, alias="serviceRoleKey")
|
|
156
|
+
bucket: str = Field(min_length=1)
|
|
157
|
+
cdn_url: Optional[str] = Field(default=None, alias="cdnUrl")
|
|
158
|
+
custom_domain: Optional[str] = Field(default=None, alias="customDomain")
|
|
159
|
+
|
|
160
|
+
@field_validator("url")
|
|
161
|
+
@classmethod
|
|
162
|
+
def _url(cls, value: str) -> str:
|
|
163
|
+
if not value.startswith(("http://", "https://")):
|
|
164
|
+
raise ValueError("url must start with http:// or https://")
|
|
165
|
+
return value.rstrip("/")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class FirebaseCredentials(_Model):
|
|
169
|
+
project_id: str = Field(min_length=1, alias="projectId")
|
|
170
|
+
bucket: str = Field(min_length=1)
|
|
171
|
+
service_account: Optional[Dict[str, Any]] = Field(default=None, alias="serviceAccount")
|
|
172
|
+
key_filename: Optional[str] = Field(default=None, alias="keyFilename")
|
|
173
|
+
cdn_url: Optional[str] = Field(default=None, alias="cdnUrl")
|
|
174
|
+
custom_domain: Optional[str] = Field(default=None, alias="customDomain")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class AppwriteCredentials(_Model):
|
|
178
|
+
endpoint: str = Field(min_length=1)
|
|
179
|
+
project_id: str = Field(min_length=1, alias="projectId")
|
|
180
|
+
api_key: str = Field(min_length=1, alias="apiKey")
|
|
181
|
+
bucket_id: str = Field(min_length=1, alias="bucketId")
|
|
182
|
+
cdn_url: Optional[str] = Field(default=None, alias="cdnUrl")
|
|
183
|
+
custom_domain: Optional[str] = Field(default=None, alias="customDomain")
|
|
184
|
+
|
|
185
|
+
@field_validator("endpoint")
|
|
186
|
+
@classmethod
|
|
187
|
+
def _endpoint(cls, value: str) -> str:
|
|
188
|
+
if not value.startswith(("http://", "https://")):
|
|
189
|
+
raise ValueError("endpoint must start with http:// or https://")
|
|
190
|
+
return value.rstrip("/")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _Shared(_Model):
|
|
194
|
+
visibility: Visibility = Visibility.PRIVATE
|
|
195
|
+
retry: RetryConfig = Field(default_factory=RetryConfig)
|
|
196
|
+
log_level: LogLevel = Field(default=LogLevel.WARN, alias="logLevel")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class S3Config(_Shared):
|
|
200
|
+
provider: Literal["s3"] = "s3"
|
|
201
|
+
credentials: S3Credentials
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class R2Config(_Shared):
|
|
205
|
+
provider: Literal["r2"] = "r2"
|
|
206
|
+
credentials: R2Credentials
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class GCSConfig(_Shared):
|
|
210
|
+
provider: Literal["gcs"] = "gcs"
|
|
211
|
+
credentials: GCSCredentials
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class AzureConfig(_Shared):
|
|
215
|
+
provider: Literal["azure"] = "azure"
|
|
216
|
+
credentials: AzureCredentials
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class SpacesConfig(_Shared):
|
|
220
|
+
provider: Literal["spaces"] = "spaces"
|
|
221
|
+
credentials: SpacesCredentials
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class B2Config(_Shared):
|
|
225
|
+
provider: Literal["b2"] = "b2"
|
|
226
|
+
credentials: B2Credentials
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class MinIOConfig(_Shared):
|
|
230
|
+
provider: Literal["minio"] = "minio"
|
|
231
|
+
credentials: MinIOCredentials
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class SupabaseConfig(_Shared):
|
|
235
|
+
provider: Literal["supabase"] = "supabase"
|
|
236
|
+
credentials: SupabaseCredentials
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class FirebaseConfig(_Shared):
|
|
240
|
+
provider: Literal["firebase"] = "firebase"
|
|
241
|
+
credentials: FirebaseCredentials
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class AppwriteConfig(_Shared):
|
|
245
|
+
provider: Literal["appwrite"] = "appwrite"
|
|
246
|
+
credentials: AppwriteCredentials
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
StorageConfig = Annotated[
|
|
250
|
+
Union[
|
|
251
|
+
S3Config,
|
|
252
|
+
R2Config,
|
|
253
|
+
GCSConfig,
|
|
254
|
+
AzureConfig,
|
|
255
|
+
SpacesConfig,
|
|
256
|
+
B2Config,
|
|
257
|
+
MinIOConfig,
|
|
258
|
+
SupabaseConfig,
|
|
259
|
+
FirebaseConfig,
|
|
260
|
+
AppwriteConfig,
|
|
261
|
+
],
|
|
262
|
+
Field(discriminator="provider"),
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
_STORAGE_CONFIG_ADAPTER: TypeAdapter[StorageConfig] = TypeAdapter(StorageConfig)
|
|
266
|
+
|
|
267
|
+
PROVIDERS = (
|
|
268
|
+
"s3",
|
|
269
|
+
"r2",
|
|
270
|
+
"gcs",
|
|
271
|
+
"azure",
|
|
272
|
+
"spaces",
|
|
273
|
+
"b2",
|
|
274
|
+
"minio",
|
|
275
|
+
"supabase",
|
|
276
|
+
"firebase",
|
|
277
|
+
"appwrite",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def parse_storage_config(data: Any) -> StorageConfig:
|
|
282
|
+
"""Validate a dict or model into a discriminated StorageConfig."""
|
|
283
|
+
if isinstance(data, _Shared) and hasattr(data, "provider"):
|
|
284
|
+
return data # type: ignore[return-value]
|
|
285
|
+
try:
|
|
286
|
+
return _STORAGE_CONFIG_ADAPTER.validate_python(data)
|
|
287
|
+
except PydanticValidationError as exc:
|
|
288
|
+
raise ValidationError(_format_pydantic(exc)) from exc
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _format_pydantic(exc: PydanticValidationError) -> str:
|
|
292
|
+
parts = []
|
|
293
|
+
for err in exc.errors():
|
|
294
|
+
loc = ".".join(str(item) for item in err.get("loc", ()))
|
|
295
|
+
parts.append(f"{loc}: {err.get('msg')}" if loc else str(err.get("msg")))
|
|
296
|
+
return "Invalid storage configuration: " + "; ".join(parts)
|