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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|