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.
@@ -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
+ ]
@@ -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)
@@ -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)