swiftpy-framework 0.1.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.
Files changed (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,655 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import hmac
6
+ import mimetypes
7
+ import tempfile
8
+ import xml.etree.ElementTree as ET
9
+ from collections.abc import AsyncIterator
10
+ from datetime import UTC, datetime, timedelta
11
+ from email.utils import parsedate_to_datetime
12
+ from typing import Any, BinaryIO
13
+ from urllib.parse import quote, urlencode, urlparse
14
+
15
+ import httpx
16
+
17
+ from swiftpy.filesystem.drivers.base import StorageDriver
18
+ from swiftpy.filesystem.exceptions import FileNotFoundError
19
+ from swiftpy.filesystem.metadata import FileMetadata
20
+ from swiftpy.filesystem.paths import PathNormalizer
21
+ from swiftpy.filesystem.visibility import Visibility
22
+
23
+ _EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
24
+ _S3_SPOOL_THRESHOLD = 1024 * 1024 # 1 MiB bounded RAM threshold for streaming PUTs
25
+
26
+
27
+ def _create_s3_spool() -> tempfile.SpooledTemporaryFile[bytes]:
28
+ return tempfile.SpooledTemporaryFile(max_size=_S3_SPOOL_THRESHOLD)
29
+
30
+
31
+ async def _iter_s3_spool(
32
+ spool: tempfile.SpooledTemporaryFile[bytes],
33
+ chunk_size: int,
34
+ ) -> AsyncIterator[bytes]:
35
+ await asyncio.to_thread(spool.seek, 0)
36
+ while True:
37
+ chunk = await asyncio.to_thread(spool.read, chunk_size)
38
+ if not chunk:
39
+ break
40
+ yield chunk
41
+
42
+
43
+ def _get_signature_key(key: str, date_stamp: str, region_name: str, service_name: str) -> bytes:
44
+ k_date = hmac.new(b"AWS4" + key.encode("utf-8"), date_stamp.encode("utf-8"), hashlib.sha256).digest()
45
+ k_region = hmac.new(k_date, region_name.encode("utf-8"), hashlib.sha256).digest()
46
+ k_service = hmac.new(k_region, service_name.encode("utf-8"), hashlib.sha256).digest()
47
+ return hmac.new(k_service, b"aws4_request", hashlib.sha256).digest()
48
+
49
+
50
+ class S3StorageDriver(StorageDriver):
51
+ """
52
+ S3-compatible object storage driver supporting AWS S3, MinIO, Cloudflare R2,
53
+ Ceph, and DigitalOcean Spaces via async HTTP with native AWS SigV4 authentication.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ bucket: str,
59
+ access_key: str,
60
+ secret_key: str,
61
+ region: str = "us-east-1",
62
+ endpoint: str | None = None,
63
+ url: str | None = None,
64
+ use_path_style_endpoint: bool = False,
65
+ default_visibility: Visibility | str = Visibility.PRIVATE,
66
+ client: httpx.AsyncClient | None = None,
67
+ ) -> None:
68
+ self.bucket = bucket
69
+ self.access_key = access_key
70
+ self.secret_key = secret_key
71
+ self.region = region
72
+ self.endpoint = endpoint
73
+ self.public_url = url
74
+ self.use_path_style = use_path_style_endpoint
75
+ self.default_visibility = Visibility(default_visibility)
76
+
77
+ self._client = client or httpx.AsyncClient(timeout=30.0)
78
+ self._owns_client = client is None
79
+
80
+ # Determine base URL host and path prefix
81
+ if self.endpoint:
82
+ parsed = urlparse(self.endpoint)
83
+ self._scheme = parsed.scheme or "https"
84
+ self._host = parsed.netloc
85
+ self._base_path = parsed.path.rstrip("/")
86
+ else:
87
+ self._scheme = "https"
88
+ self._host = f"{self.bucket}.s3.{self.region}.amazonaws.com" if not self.use_path_style else f"s3.{self.region}.amazonaws.com"
89
+ self._base_path = ""
90
+
91
+ @classmethod
92
+ def from_config(cls, config: dict[str, Any]) -> S3StorageDriver:
93
+ bucket = str(config.get("bucket", ""))
94
+ access_key = str(config.get("key", config.get("access_key", "")))
95
+ secret_key = str(config.get("secret", config.get("secret_key", "")))
96
+ region = str(config.get("region", "us-east-1"))
97
+ endpoint = config.get("endpoint")
98
+ url = config.get("url")
99
+ use_path_style = bool(config.get("use_path_style_endpoint", False))
100
+ visibility = str(config.get("visibility", "private"))
101
+
102
+ return cls(
103
+ bucket=bucket,
104
+ access_key=access_key,
105
+ secret_key=secret_key,
106
+ region=region,
107
+ endpoint=str(endpoint) if endpoint else None,
108
+ url=str(url) if url else None,
109
+ use_path_style_endpoint=use_path_style,
110
+ default_visibility=visibility,
111
+ )
112
+
113
+ def _get_key_path(self, key: str) -> str:
114
+ clean = PathNormalizer.normalize(key)
115
+ if self.use_path_style:
116
+ resource = f"/{self.bucket}/{clean}" if clean else f"/{self.bucket}"
117
+ else:
118
+ resource = f"/{clean}" if clean else "/"
119
+ if self._base_path:
120
+ if resource == "/":
121
+ return self._base_path
122
+ return f"{self._base_path}{resource}"
123
+ return resource
124
+
125
+ def _build_url(self, key: str, query: str = "") -> str:
126
+ path = quote(self._get_key_path(key), safe="/-_.~")
127
+ url = f"{self._scheme}://{self._host}{path}"
128
+ if query:
129
+ url = f"{url}?{query}"
130
+ return url
131
+
132
+ def _sign_headers(
133
+ self,
134
+ method: str,
135
+ path: str,
136
+ query: str,
137
+ headers: dict[str, str],
138
+ payload_hash: str,
139
+ timestamp: datetime | None = None,
140
+ ) -> dict[str, str]:
141
+ now = timestamp or datetime.now(UTC)
142
+ amz_date = now.strftime("%Y%m%dT%H%M%SZ")
143
+ date_stamp = now.strftime("%Y%m%d")
144
+
145
+ req_headers: dict[str, str] = {
146
+ str(k).lower(): str(v).strip() for k, v in headers.items()
147
+ }
148
+ req_headers["host"] = self._host
149
+ req_headers["x-amz-date"] = amz_date
150
+ req_headers["x-amz-content-sha256"] = payload_hash
151
+
152
+ # Canonical headers
153
+ sorted_keys = sorted(req_headers)
154
+ canonical_headers = "".join(f"{k}:{req_headers[k]}\n" for k in sorted_keys)
155
+ signed_headers = ";".join(sorted_keys)
156
+
157
+ canonical_uri = quote(path, safe="/-_.~")
158
+ canonical_query = query
159
+
160
+ canonical_request = (
161
+ f"{method}\n{canonical_uri}\n{canonical_query}\n"
162
+ f"{canonical_headers}\n{signed_headers}\n{payload_hash}"
163
+ )
164
+
165
+ credential_scope = f"{date_stamp}/{self.region}/s3/aws4_request"
166
+ string_to_sign = (
167
+ f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n"
168
+ f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
169
+ )
170
+
171
+ signing_key = _get_signature_key(self.secret_key, date_stamp, self.region, "s3")
172
+ signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
173
+
174
+ req_headers["authorization"] = (
175
+ f"AWS4-HMAC-SHA256 Credential={self.access_key}/{credential_scope}, "
176
+ f"SignedHeaders={signed_headers}, Signature={signature}"
177
+ )
178
+ return req_headers
179
+
180
+ @staticmethod
181
+ def _parse_s3_error(body: str) -> str:
182
+ """Extract S3 error Code and Message from XML response body."""
183
+ try:
184
+ root = ET.fromstring(body)
185
+ code = ""
186
+ message = ""
187
+ for elem in root.iter():
188
+ tag = elem.tag.split("}", 1)[-1]
189
+ if tag == "Code" and elem.text:
190
+ code = elem.text.strip()
191
+ elif tag == "Message" and elem.text:
192
+ message = elem.text.strip()
193
+ if code and message:
194
+ return f"{code}: {message}"
195
+ if code or message:
196
+ return code or message
197
+ except (ET.ParseError, ValueError):
198
+ return body
199
+ return body
200
+
201
+ @staticmethod
202
+ def _parse_list_objects_v2(body: str) -> tuple[list[str], list[str], bool, str | None]:
203
+ """
204
+ Parse S3 ListObjectsV2 XML response.
205
+ Returns: (keys, common_prefixes, is_truncated, next_continuation_token)
206
+ Handles XML namespaces and entity unescaping automatically.
207
+ """
208
+ keys: list[str] = []
209
+ prefixes: list[str] = []
210
+ is_truncated = False
211
+ next_continuation_token: str | None = None
212
+
213
+ try:
214
+ root = ET.fromstring(body)
215
+ for elem in root.iter():
216
+ tag = elem.tag.split("}", 1)[-1]
217
+ if tag == "Key" and elem.text:
218
+ keys.append(elem.text)
219
+ elif tag == "CommonPrefixes":
220
+ for sub in elem.iter():
221
+ sub_tag = sub.tag.split("}", 1)[-1]
222
+ if sub_tag == "Prefix" and sub.text:
223
+ prefixes.append(sub.text.rstrip("/"))
224
+ elif tag == "IsTruncated" and elem.text:
225
+ is_truncated = elem.text.strip().lower() == "true"
226
+ elif tag == "NextContinuationToken" and elem.text:
227
+ next_continuation_token = elem.text.strip()
228
+ except (ET.ParseError, ValueError):
229
+ return keys, prefixes, is_truncated, next_continuation_token
230
+
231
+ return keys, prefixes, is_truncated, next_continuation_token
232
+
233
+ async def write(
234
+ self,
235
+ path: str,
236
+ content: bytes | str,
237
+ visibility: Visibility | str | None = None,
238
+ ) -> int:
239
+ clean = PathNormalizer.normalize(path)
240
+ data = content.encode("utf-8") if isinstance(content, str) else content
241
+ payload_hash = hashlib.sha256(data).hexdigest()
242
+ key_path = self._get_key_path(clean)
243
+
244
+ vis = Visibility(visibility) if visibility is not None else self.default_visibility
245
+ acl = "public-read" if vis == Visibility.PUBLIC else "private"
246
+ mime, _ = mimetypes.guess_type(clean)
247
+
248
+ headers: dict[str, str] = {
249
+ "content-length": str(len(data)),
250
+ "content-type": mime or "application/octet-stream",
251
+ "x-amz-acl": acl,
252
+ }
253
+
254
+ signed_headers = self._sign_headers("PUT", key_path, "", headers, payload_hash)
255
+ url = self._build_url(clean)
256
+
257
+ resp = await self._client.put(url, headers=signed_headers, content=data)
258
+ if resp.status_code == 403:
259
+ raise PermissionError(f"S3 PUT denied for '{path}': {self._parse_s3_error(resp.text)}")
260
+ if resp.status_code not in (200, 201, 204):
261
+ raise RuntimeError(f"S3 PUT failed with status {resp.status_code}: {self._parse_s3_error(resp.text)}")
262
+ return len(data)
263
+
264
+ async def write_stream(
265
+ self,
266
+ path: str,
267
+ stream: AsyncIterator[bytes] | BinaryIO,
268
+ chunk_size: int = 64 * 1024,
269
+ visibility: Visibility | str | None = None,
270
+ ) -> int:
271
+ clean = PathNormalizer.normalize(path)
272
+ key_path = self._get_key_path(clean)
273
+ vis = Visibility(visibility) if visibility is not None else self.default_visibility
274
+ acl = "public-read" if vis == Visibility.PUBLIC else "private"
275
+ mime, _ = mimetypes.guess_type(clean)
276
+
277
+ spool = _create_s3_spool()
278
+ hasher = hashlib.sha256()
279
+ total = 0
280
+ try:
281
+ if isinstance(stream, AsyncIterator):
282
+ async for chunk in stream:
283
+ if chunk:
284
+ total += len(chunk)
285
+ hasher.update(chunk)
286
+ await asyncio.to_thread(spool.write, chunk)
287
+ else:
288
+ while True:
289
+ chunk = await asyncio.to_thread(stream.read, chunk_size)
290
+ if not chunk:
291
+ break
292
+ total += len(chunk)
293
+ hasher.update(chunk)
294
+ await asyncio.to_thread(spool.write, chunk)
295
+
296
+ payload_hash = hasher.hexdigest()
297
+ headers = {
298
+ "content-type": mime or "application/octet-stream",
299
+ "x-amz-acl": acl,
300
+ "content-length": str(total),
301
+ }
302
+ signed_headers = self._sign_headers("PUT", key_path, "", headers, payload_hash)
303
+ url = self._build_url(clean)
304
+
305
+ resp = await self._client.put(
306
+ url,
307
+ headers=signed_headers,
308
+ content=_iter_s3_spool(spool, chunk_size),
309
+ )
310
+ if resp.status_code == 403:
311
+ raise PermissionError(f"S3 PUT denied for '{path}': {self._parse_s3_error(resp.text)}")
312
+ if resp.status_code not in (200, 201, 204):
313
+ raise RuntimeError(f"S3 PUT failed with status {resp.status_code}: {self._parse_s3_error(resp.text)}")
314
+ return total
315
+ finally:
316
+ await asyncio.to_thread(spool.close)
317
+
318
+ async def read(self, path: str) -> bytes:
319
+ clean = PathNormalizer.normalize(path)
320
+ key_path = self._get_key_path(clean)
321
+ signed_headers = self._sign_headers("GET", key_path, "", {}, _EMPTY_SHA256)
322
+ url = self._build_url(clean)
323
+
324
+ resp = await self._client.get(url, headers=signed_headers)
325
+ if resp.status_code == 404:
326
+ raise FileNotFoundError(f"File not found on S3 at '{path}'.")
327
+ if resp.status_code == 403:
328
+ raise PermissionError(f"S3 GET denied for '{path}': {self._parse_s3_error(resp.text)}")
329
+ if resp.status_code != 200:
330
+ raise RuntimeError(f"S3 GET failed with status {resp.status_code}: {self._parse_s3_error(resp.text)}")
331
+ return resp.content
332
+
333
+ async def read_text(self, path: str, encoding: str = "utf-8") -> str:
334
+ data = await self.read(path)
335
+ return data.decode(encoding)
336
+
337
+ async def read_stream(
338
+ self,
339
+ path: str,
340
+ chunk_size: int = 64 * 1024,
341
+ ) -> AsyncIterator[bytes]:
342
+ clean = PathNormalizer.normalize(path)
343
+ key_path = self._get_key_path(clean)
344
+ signed_headers = self._sign_headers("GET", key_path, "", {}, _EMPTY_SHA256)
345
+ url = self._build_url(clean)
346
+
347
+ req = self._client.build_request("GET", url, headers=signed_headers)
348
+ resp = await self._client.send(req, stream=True)
349
+ if resp.status_code == 404:
350
+ await resp.aclose()
351
+ raise FileNotFoundError(f"File not found on S3 at '{path}'.")
352
+ if resp.status_code == 403:
353
+ text = (await resp.aread()).decode("utf-8", "ignore")
354
+ await resp.aclose()
355
+ raise PermissionError(f"S3 GET stream denied for '{path}': {self._parse_s3_error(text)}")
356
+ if resp.status_code != 200:
357
+ text = (await resp.aread()).decode("utf-8", "ignore")
358
+ await resp.aclose()
359
+ raise RuntimeError(f"S3 GET stream failed with status {resp.status_code}: {self._parse_s3_error(text)}")
360
+
361
+ try:
362
+ async for chunk in resp.aiter_bytes(chunk_size):
363
+ yield chunk
364
+ finally:
365
+ await resp.aclose()
366
+
367
+ async def exists(self, path: str) -> bool:
368
+ clean = PathNormalizer.normalize(path)
369
+ key_path = self._get_key_path(clean)
370
+ signed_headers = self._sign_headers("HEAD", key_path, "", {}, _EMPTY_SHA256)
371
+ url = self._build_url(clean)
372
+
373
+ resp = await self._client.head(url, headers=signed_headers)
374
+ return resp.status_code == 200
375
+
376
+ async def missing(self, path: str) -> bool:
377
+ return not await self.exists(path)
378
+
379
+ async def delete(self, path: str) -> bool:
380
+ clean = PathNormalizer.normalize(path)
381
+ key_path = self._get_key_path(clean)
382
+ signed_headers = self._sign_headers("DELETE", key_path, "", {}, _EMPTY_SHA256)
383
+ url = self._build_url(clean)
384
+
385
+ resp = await self._client.delete(url, headers=signed_headers)
386
+ return resp.status_code in (200, 204)
387
+
388
+ async def copy(self, source: str, destination: str) -> bool:
389
+ src = PathNormalizer.normalize(source)
390
+ dst = PathNormalizer.normalize(destination)
391
+ key_path = self._get_key_path(dst)
392
+
393
+ headers = {
394
+ "x-amz-copy-source": quote(f"/{self.bucket}/{src}"),
395
+ }
396
+ signed_headers = self._sign_headers("PUT", key_path, "", headers, _EMPTY_SHA256)
397
+ url = self._build_url(dst)
398
+
399
+ resp = await self._client.put(url, headers=signed_headers)
400
+ if resp.status_code == 404:
401
+ raise FileNotFoundError(f"Source file not found at '{source}'.")
402
+ if resp.status_code == 403:
403
+ raise PermissionError(f"S3 COPY denied for '{source}': {self._parse_s3_error(resp.text)}")
404
+ if resp.status_code not in (200, 201):
405
+ raise RuntimeError(f"S3 COPY failed with status {resp.status_code}: {self._parse_s3_error(resp.text)}")
406
+ return True
407
+
408
+ async def move(self, source: str, destination: str) -> bool:
409
+ await self.copy(source, destination)
410
+ await self.delete(source)
411
+ return True
412
+
413
+ async def size(self, path: str) -> int:
414
+ meta = await self.metadata(path)
415
+ return meta.size
416
+
417
+ async def mime_type(self, path: str) -> str:
418
+ clean = PathNormalizer.normalize(path)
419
+ mime, _ = mimetypes.guess_type(clean)
420
+ return mime or "application/octet-stream"
421
+
422
+ async def last_modified(self, path: str) -> datetime:
423
+ meta = await self.metadata(path)
424
+ return meta.last_modified
425
+
426
+ async def checksum(self, path: str, algorithm: str = "sha256") -> str:
427
+ hasher = hashlib.new(algorithm)
428
+ async for chunk in self.read_stream(path):
429
+ hasher.update(chunk)
430
+ return hasher.hexdigest()
431
+
432
+ async def files(self, directory: str = "", recursive: bool = False) -> list[str]:
433
+ clean = PathNormalizer.normalize(directory) if directory else ""
434
+ prefix = f"{clean}/" if clean else ""
435
+ results: list[str] = []
436
+ continuation_token: str | None = None
437
+
438
+ while True:
439
+ query_params: dict[str, str] = {
440
+ "list-type": "2",
441
+ "prefix": prefix,
442
+ }
443
+ if not recursive:
444
+ query_params["delimiter"] = "/"
445
+ if continuation_token:
446
+ query_params["continuation-token"] = continuation_token
447
+
448
+ query_str = urlencode(sorted(query_params.items()))
449
+ key_path = self._get_key_path("")
450
+ signed_headers = self._sign_headers("GET", key_path, query_str, {}, _EMPTY_SHA256)
451
+ url = self._build_url("", query=query_str)
452
+
453
+ resp = await self._client.get(url, headers=signed_headers)
454
+ if resp.status_code == 403:
455
+ raise PermissionError(f"S3 list files denied for '{directory}': {self._parse_s3_error(resp.text)}")
456
+ if resp.status_code != 200:
457
+ raise RuntimeError(f"S3 list files failed with status {resp.status_code}: {self._parse_s3_error(resp.text)}")
458
+
459
+ keys, _, is_truncated, next_token = self._parse_list_objects_v2(resp.text)
460
+ for k in keys:
461
+ if not k.endswith("/"):
462
+ results.append(k)
463
+
464
+ if not is_truncated or not next_token:
465
+ break
466
+ continuation_token = next_token
467
+
468
+ results.sort()
469
+ return results
470
+
471
+ async def directories(self, directory: str = "", recursive: bool = False) -> list[str]:
472
+ clean = PathNormalizer.normalize(directory) if directory else ""
473
+ prefix = f"{clean}/" if clean else ""
474
+ results: set[str] = set()
475
+ continuation_token: str | None = None
476
+
477
+ while True:
478
+ query_params: dict[str, str] = {
479
+ "list-type": "2",
480
+ "prefix": prefix,
481
+ }
482
+ if not recursive:
483
+ query_params["delimiter"] = "/"
484
+ if continuation_token:
485
+ query_params["continuation-token"] = continuation_token
486
+
487
+ query_str = urlencode(sorted(query_params.items()))
488
+ key_path = self._get_key_path("")
489
+ signed_headers = self._sign_headers("GET", key_path, query_str, {}, _EMPTY_SHA256)
490
+ url = self._build_url("", query=query_str)
491
+
492
+ resp = await self._client.get(url, headers=signed_headers)
493
+ if resp.status_code == 403:
494
+ raise PermissionError(f"S3 list directories denied for '{directory}': {self._parse_s3_error(resp.text)}")
495
+ if resp.status_code != 200:
496
+ raise RuntimeError(
497
+ f"S3 list directories failed with status {resp.status_code}: {self._parse_s3_error(resp.text)}"
498
+ )
499
+
500
+ keys, prefixes, is_truncated, next_token = self._parse_list_objects_v2(resp.text)
501
+ for p in prefixes:
502
+ if p:
503
+ results.add(p)
504
+
505
+ if recursive:
506
+ for k in keys:
507
+ rel = k[len(prefix):] if prefix and k.startswith(prefix) else k
508
+ parts = rel.split("/")
509
+ if len(parts) > 1:
510
+ accum = prefix
511
+ for p in parts[:-1]:
512
+ accum = f"{accum}{p}"
513
+ results.add(accum.rstrip("/"))
514
+ accum = f"{accum}/"
515
+
516
+ if not is_truncated or not next_token:
517
+ break
518
+ continuation_token = next_token
519
+
520
+ sorted_results = sorted(results)
521
+ return sorted_results
522
+
523
+ async def make_directory(self, directory: str) -> bool:
524
+ clean = PathNormalizer.normalize(directory)
525
+ if clean:
526
+ # S3 directory placeholder is an empty object ending in '/'
527
+ await self.write(f"{clean}/", b"")
528
+ return True
529
+
530
+ async def delete_directory(self, directory: str) -> bool:
531
+ all_files = await self.files(directory, recursive=True)
532
+ for f in all_files:
533
+ await self.delete(f)
534
+ clean = PathNormalizer.normalize(directory)
535
+ if clean:
536
+ await self.delete(f"{clean}/")
537
+ return True
538
+
539
+ async def url(self, path: str) -> str:
540
+ clean = PathNormalizer.normalize(path)
541
+ if self.public_url:
542
+ encoded = quote(clean, safe="/-_.~")
543
+ return f"{self.public_url.rstrip('/')}/{encoded}"
544
+ return self._build_url(clean)
545
+
546
+ async def temporary_url(
547
+ self,
548
+ path: str,
549
+ expires_in: int | timedelta,
550
+ timestamp: datetime | None = None,
551
+ ) -> str:
552
+ if isinstance(expires_in, bool):
553
+ raise TypeError("expires_in must be an integer or timedelta, not bool.")
554
+ clean = PathNormalizer.normalize(path)
555
+ duration = (
556
+ int(expires_in)
557
+ if isinstance(expires_in, (int, float))
558
+ else int(expires_in.total_seconds())
559
+ )
560
+ if duration <= 0:
561
+ raise ValueError("expires_in must be greater than 0 seconds.")
562
+ now = timestamp or datetime.now(UTC)
563
+ amz_date = now.strftime("%Y%m%dT%H%M%SZ")
564
+ date_stamp = now.strftime("%Y%m%d")
565
+ credential_scope = f"{date_stamp}/{self.region}/s3/aws4_request"
566
+
567
+ query_params = {
568
+ "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
569
+ "X-Amz-Credential": f"{self.access_key}/{credential_scope}",
570
+ "X-Amz-Date": amz_date,
571
+ "X-Amz-Expires": str(duration),
572
+ "X-Amz-SignedHeaders": "host",
573
+ }
574
+ canonical_query = urlencode(sorted(query_params.items()))
575
+ key_path = self._get_key_path(clean)
576
+ encoded_key_path = quote(key_path, safe="/-_.~")
577
+
578
+ canonical_headers = f"host:{self._host}\n"
579
+ canonical_request = (
580
+ f"GET\n{encoded_key_path}\n{canonical_query}\n"
581
+ f"{canonical_headers}\nhost\nUNSIGNED-PAYLOAD"
582
+ )
583
+
584
+ string_to_sign = (
585
+ f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n"
586
+ f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
587
+ )
588
+
589
+ signing_key = _get_signature_key(self.secret_key, date_stamp, self.region, "s3")
590
+ signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
591
+
592
+ final_query = f"{canonical_query}&X-Amz-Signature={signature}"
593
+ return f"{self._scheme}://{self._host}{encoded_key_path}?{final_query}"
594
+
595
+ async def get_visibility(self, path: str) -> Visibility:
596
+ clean = PathNormalizer.normalize(path)
597
+ key_path = self._get_key_path(clean)
598
+ signed_headers = self._sign_headers("GET", key_path, "acl", {}, _EMPTY_SHA256)
599
+ url = self._build_url(clean, query="acl")
600
+
601
+ resp = await self._client.get(url, headers=signed_headers)
602
+ if resp.status_code == 200 and "AllUsers" in resp.text and "READ" in resp.text:
603
+ return Visibility.PUBLIC
604
+ return Visibility.PRIVATE
605
+
606
+ async def set_visibility(self, path: str, visibility: Visibility | str) -> bool:
607
+ clean = PathNormalizer.normalize(path)
608
+ key_path = self._get_key_path(clean)
609
+ vis = Visibility(visibility)
610
+ acl = "public-read" if vis == Visibility.PUBLIC else "private"
611
+
612
+ headers = {"x-amz-acl": acl}
613
+ signed_headers = self._sign_headers("PUT", key_path, "acl", headers, _EMPTY_SHA256)
614
+ url = self._build_url(clean, query="acl")
615
+
616
+ resp = await self._client.put(url, headers=signed_headers)
617
+ return resp.status_code == 200
618
+
619
+ async def metadata(self, path: str) -> FileMetadata:
620
+ clean = PathNormalizer.normalize(path)
621
+ key_path = self._get_key_path(clean)
622
+ signed_headers = self._sign_headers("HEAD", key_path, "", {}, _EMPTY_SHA256)
623
+ url = self._build_url(clean)
624
+
625
+ resp = await self._client.head(url, headers=signed_headers)
626
+ if resp.status_code == 404:
627
+ raise FileNotFoundError(f"File not found on S3 at '{path}'.")
628
+ if resp.status_code != 200:
629
+ raise RuntimeError(f"S3 HEAD failed with status {resp.status_code}")
630
+
631
+ size = int(resp.headers.get("content-length", "0"))
632
+ mime = resp.headers.get("content-type") or "application/octet-stream"
633
+ etag = resp.headers.get("etag", "").strip('"')
634
+
635
+ last_mod_header = resp.headers.get("last-modified")
636
+ if last_mod_header:
637
+ try:
638
+ last_mod = parsedate_to_datetime(last_mod_header).astimezone(UTC)
639
+ except (ValueError, TypeError, OverflowError):
640
+ last_mod = datetime.now(UTC)
641
+ else:
642
+ last_mod = datetime.now(UTC)
643
+
644
+ return FileMetadata(
645
+ path=clean,
646
+ size=size,
647
+ mime_type=mime,
648
+ last_modified=last_mod,
649
+ visibility=self.default_visibility,
650
+ etag=etag or None,
651
+ )
652
+
653
+ async def close(self) -> None:
654
+ if self._owns_client and not self._client.is_closed:
655
+ await self._client.aclose()
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import ClassVar
5
+
6
+
7
+ @dataclass(slots=True, frozen=True)
8
+ class FileUploaded:
9
+ """Dispatched after a file is stored on a disk."""
10
+
11
+ EVENT_NAME: ClassVar[str] = "storage.file.uploaded"
12
+
13
+ disk: str
14
+ path: str
15
+ size: int
16
+ mime_type: str
17
+
18
+
19
+ @dataclass(slots=True, frozen=True)
20
+ class FileDeleted:
21
+ """Dispatched after a file is deleted from a disk."""
22
+
23
+ EVENT_NAME: ClassVar[str] = "storage.file.deleted"
24
+
25
+ disk: str
26
+ path: str
27
+
28
+
29
+ @dataclass(slots=True, frozen=True)
30
+ class FileMoved:
31
+ """Dispatched after a file is moved/renamed on a disk."""
32
+
33
+ EVENT_NAME: ClassVar[str] = "storage.file.moved"
34
+
35
+ disk: str
36
+ source: str
37
+ destination: str