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,559 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import mimetypes
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import AsyncIterator
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import BinaryIO
|
|
16
|
+
from urllib.parse import parse_qs, quote, unquote, urlsplit
|
|
17
|
+
|
|
18
|
+
from swiftpy.core.config import ConfigurationError
|
|
19
|
+
from swiftpy.filesystem.drivers.base import StorageDriver
|
|
20
|
+
from swiftpy.filesystem.exceptions import FileNotFoundError, InvalidSignatureError
|
|
21
|
+
from swiftpy.filesystem.metadata import FileMetadata
|
|
22
|
+
from swiftpy.filesystem.paths import PathNormalizer
|
|
23
|
+
from swiftpy.filesystem.visibility import (
|
|
24
|
+
DEFAULT_DIR_PERMISSIONS,
|
|
25
|
+
DEFAULT_FILE_PERMISSIONS,
|
|
26
|
+
Visibility,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _local_write_atomic(
|
|
31
|
+
target: Path,
|
|
32
|
+
data: bytes,
|
|
33
|
+
file_mode: int,
|
|
34
|
+
dir_mode: int,
|
|
35
|
+
) -> int:
|
|
36
|
+
target.parent.mkdir(parents=True, exist_ok=True, mode=dir_mode)
|
|
37
|
+
tmp_target = target.parent / f".tmp-{uuid.uuid4().hex}"
|
|
38
|
+
try:
|
|
39
|
+
with open(tmp_target, "wb") as f:
|
|
40
|
+
f.write(data)
|
|
41
|
+
f.flush()
|
|
42
|
+
os.fsync(f.fileno())
|
|
43
|
+
os.chmod(tmp_target, file_mode)
|
|
44
|
+
os.replace(tmp_target, target)
|
|
45
|
+
except BaseException:
|
|
46
|
+
if tmp_target.exists():
|
|
47
|
+
try:
|
|
48
|
+
tmp_target.unlink()
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
raise
|
|
52
|
+
return len(data)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _local_open_write_tmp(target: Path, dir_mode: int) -> tuple[Path, BinaryIO]:
|
|
56
|
+
target.parent.mkdir(parents=True, exist_ok=True, mode=dir_mode)
|
|
57
|
+
tmp_target = target.parent / f".tmp-{uuid.uuid4().hex}"
|
|
58
|
+
handle = open(tmp_target, "wb") # noqa: SIM115
|
|
59
|
+
return tmp_target, handle
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _local_finalize_write_tmp(
|
|
63
|
+
tmp_target: Path,
|
|
64
|
+
target: Path,
|
|
65
|
+
handle: BinaryIO,
|
|
66
|
+
file_mode: int,
|
|
67
|
+
) -> None:
|
|
68
|
+
handle.flush()
|
|
69
|
+
os.fsync(handle.fileno())
|
|
70
|
+
handle.close()
|
|
71
|
+
os.chmod(tmp_target, file_mode)
|
|
72
|
+
os.replace(tmp_target, target)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _local_cleanup_write_tmp(tmp_target: Path, handle: BinaryIO) -> None:
|
|
76
|
+
try:
|
|
77
|
+
handle.close()
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
if tmp_target.exists():
|
|
81
|
+
try:
|
|
82
|
+
tmp_target.unlink()
|
|
83
|
+
except OSError:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _local_read_bytes(target: Path, path_label: str) -> bytes:
|
|
88
|
+
if not target.is_file():
|
|
89
|
+
raise FileNotFoundError(f"File not found at '{path_label}'.")
|
|
90
|
+
with open(target, "rb") as f:
|
|
91
|
+
return f.read()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _local_open_read_stream(target: Path, path_label: str) -> BinaryIO:
|
|
95
|
+
if not target.is_file():
|
|
96
|
+
raise FileNotFoundError(f"File not found at '{path_label}'.")
|
|
97
|
+
return open(target, "rb")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _local_delete_file(target: Path) -> bool:
|
|
101
|
+
if target.is_file():
|
|
102
|
+
target.unlink()
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _local_copy_file(src: Path, dst: Path, src_label: str) -> bool:
|
|
108
|
+
if not src.is_file():
|
|
109
|
+
raise FileNotFoundError(f"Source file not found at '{src_label}'.")
|
|
110
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
shutil.copy2(src, dst)
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _local_move_file(src: Path, dst: Path, src_label: str) -> bool:
|
|
116
|
+
if not src.is_file():
|
|
117
|
+
raise FileNotFoundError(f"Source file not found at '{src_label}'.")
|
|
118
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
os.replace(src, dst)
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _local_file_size(target: Path, path_label: str) -> int:
|
|
124
|
+
if not target.is_file():
|
|
125
|
+
raise FileNotFoundError(f"File not found at '{path_label}'.")
|
|
126
|
+
return target.stat().st_size
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _local_last_modified(target: Path, path_label: str) -> datetime:
|
|
130
|
+
if not target.is_file():
|
|
131
|
+
raise FileNotFoundError(f"File not found at '{path_label}'.")
|
|
132
|
+
return datetime.fromtimestamp(target.stat().st_mtime, tz=UTC)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _local_checksum(target: Path, path_label: str, algorithm: str) -> str:
|
|
136
|
+
if not target.is_file():
|
|
137
|
+
raise FileNotFoundError(f"File not found at '{path_label}'.")
|
|
138
|
+
hasher = hashlib.new(algorithm)
|
|
139
|
+
with open(target, "rb") as f:
|
|
140
|
+
while chunk := f.read(64 * 1024):
|
|
141
|
+
hasher.update(chunk)
|
|
142
|
+
return hasher.hexdigest()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_contained_file(root: Path, candidate: Path) -> bool:
|
|
146
|
+
try:
|
|
147
|
+
resolved = candidate.resolve()
|
|
148
|
+
except OSError:
|
|
149
|
+
return False
|
|
150
|
+
return resolved.is_relative_to(root) and candidate.is_file()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_contained_dir(root: Path, candidate: Path) -> bool:
|
|
154
|
+
try:
|
|
155
|
+
resolved = candidate.resolve()
|
|
156
|
+
except OSError:
|
|
157
|
+
return False
|
|
158
|
+
return resolved != root and resolved.is_relative_to(root) and candidate.is_dir()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _local_list_files(root: Path, target: Path, recursive: bool) -> list[str]:
|
|
162
|
+
if not target.is_dir():
|
|
163
|
+
return []
|
|
164
|
+
result: list[str] = []
|
|
165
|
+
if recursive:
|
|
166
|
+
for walk_root, dirnames, filenames in os.walk(target, followlinks=False):
|
|
167
|
+
walk_path = Path(walk_root)
|
|
168
|
+
dirnames[:] = sorted(
|
|
169
|
+
d for d in dirnames if not (walk_path / d).is_symlink()
|
|
170
|
+
)
|
|
171
|
+
for filename in filenames:
|
|
172
|
+
full_p = walk_path / filename
|
|
173
|
+
if _is_contained_file(root, full_p):
|
|
174
|
+
result.append(full_p.relative_to(root).as_posix())
|
|
175
|
+
else:
|
|
176
|
+
with os.scandir(target) as it:
|
|
177
|
+
for entry in it:
|
|
178
|
+
entry_path = Path(entry.path)
|
|
179
|
+
if _is_contained_file(root, entry_path):
|
|
180
|
+
result.append(entry_path.relative_to(root).as_posix())
|
|
181
|
+
result.sort()
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _local_list_directories(root: Path, target: Path, recursive: bool) -> list[str]:
|
|
186
|
+
if not target.is_dir():
|
|
187
|
+
return []
|
|
188
|
+
result: list[str] = []
|
|
189
|
+
if recursive:
|
|
190
|
+
for walk_root, dirnames, _ in os.walk(target, followlinks=False):
|
|
191
|
+
walk_path = Path(walk_root)
|
|
192
|
+
kept: list[str] = []
|
|
193
|
+
for dirname in dirnames:
|
|
194
|
+
full_p = walk_path / dirname
|
|
195
|
+
if not full_p.is_symlink() and _is_contained_dir(root, full_p):
|
|
196
|
+
kept.append(dirname)
|
|
197
|
+
result.append(full_p.relative_to(root).as_posix())
|
|
198
|
+
dirnames[:] = kept
|
|
199
|
+
else:
|
|
200
|
+
with os.scandir(target) as it:
|
|
201
|
+
for entry in it:
|
|
202
|
+
entry_path = Path(entry.path)
|
|
203
|
+
if not entry.is_symlink() and _is_contained_dir(root, entry_path):
|
|
204
|
+
result.append(entry_path.relative_to(root).as_posix())
|
|
205
|
+
result.sort()
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _local_make_dir(target: Path, dir_mode: int) -> bool:
|
|
210
|
+
target.mkdir(parents=True, exist_ok=True, mode=dir_mode)
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _local_delete_dir(target: Path) -> bool:
|
|
215
|
+
if target.is_dir():
|
|
216
|
+
shutil.rmtree(target)
|
|
217
|
+
return True
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _local_get_visibility(target: Path, path_label: str) -> Visibility:
|
|
222
|
+
if not target.is_file():
|
|
223
|
+
raise FileNotFoundError(f"File not found at '{path_label}'.")
|
|
224
|
+
mode = target.stat().st_mode
|
|
225
|
+
if mode & stat.S_IROTH:
|
|
226
|
+
return Visibility.PUBLIC
|
|
227
|
+
return Visibility.PRIVATE
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _local_set_visibility(target: Path, path_label: str, mode: int) -> bool:
|
|
231
|
+
if not target.is_file():
|
|
232
|
+
raise FileNotFoundError(f"File not found at '{path_label}'.")
|
|
233
|
+
os.chmod(target, mode)
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _local_stat_metadata(target: Path, clean: str, path_label: str) -> FileMetadata:
|
|
238
|
+
if not target.is_file():
|
|
239
|
+
raise FileNotFoundError(f"File not found at '{path_label}'.")
|
|
240
|
+
st = target.stat()
|
|
241
|
+
size = st.st_size
|
|
242
|
+
mtime = datetime.fromtimestamp(st.st_mtime, tz=UTC)
|
|
243
|
+
vis = Visibility.PUBLIC if (st.st_mode & stat.S_IROTH) else Visibility.PRIVATE
|
|
244
|
+
mime, _ = mimetypes.guess_type(clean)
|
|
245
|
+
return FileMetadata(
|
|
246
|
+
path=clean,
|
|
247
|
+
size=size,
|
|
248
|
+
mime_type=mime or "application/octet-stream",
|
|
249
|
+
last_modified=mtime,
|
|
250
|
+
visibility=vis,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class LocalStorageDriver(StorageDriver):
|
|
255
|
+
"""
|
|
256
|
+
Local filesystem storage driver.
|
|
257
|
+
Offloads all synchronous filesystem operations to worker threads via asyncio.to_thread
|
|
258
|
+
to guarantee zero event-loop blocking.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
root: str | Path,
|
|
264
|
+
url_prefix: str = "/storage",
|
|
265
|
+
default_visibility: Visibility | str = Visibility.PRIVATE,
|
|
266
|
+
signing_key: str | None = None,
|
|
267
|
+
permissions: dict[str, dict[str, int]] | None = None,
|
|
268
|
+
) -> None:
|
|
269
|
+
self.root = Path(root).resolve()
|
|
270
|
+
self.url_prefix = url_prefix
|
|
271
|
+
self.default_visibility = Visibility(default_visibility)
|
|
272
|
+
self.signing_key: str | None = signing_key
|
|
273
|
+
|
|
274
|
+
perm = permissions or {}
|
|
275
|
+
file_perms = perm.get("file", {})
|
|
276
|
+
dir_perms = perm.get("dir", {})
|
|
277
|
+
|
|
278
|
+
self._file_permissions: dict[str, int] = {
|
|
279
|
+
Visibility.PUBLIC: file_perms.get("public", DEFAULT_FILE_PERMISSIONS[Visibility.PUBLIC]),
|
|
280
|
+
Visibility.PRIVATE: file_perms.get("private", DEFAULT_FILE_PERMISSIONS[Visibility.PRIVATE]),
|
|
281
|
+
}
|
|
282
|
+
self._dir_permissions: dict[str, int] = {
|
|
283
|
+
Visibility.PUBLIC: dir_perms.get("public", DEFAULT_DIR_PERMISSIONS[Visibility.PUBLIC]),
|
|
284
|
+
Visibility.PRIVATE: dir_perms.get("private", DEFAULT_DIR_PERMISSIONS[Visibility.PRIVATE]),
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
def _resolve(self, path: str) -> Path:
|
|
288
|
+
return PathNormalizer.resolve_safe_path(self.root, path)
|
|
289
|
+
|
|
290
|
+
def _get_file_mode(self, visibility: Visibility | str | None) -> int:
|
|
291
|
+
vis = Visibility(visibility) if visibility is not None else self.default_visibility
|
|
292
|
+
return self._file_permissions.get(vis, 0o600)
|
|
293
|
+
|
|
294
|
+
def _get_dir_mode(self, visibility: Visibility | str | None) -> int:
|
|
295
|
+
vis = Visibility(visibility) if visibility is not None else self.default_visibility
|
|
296
|
+
return self._dir_permissions.get(vis, 0o700)
|
|
297
|
+
|
|
298
|
+
async def write(
|
|
299
|
+
self,
|
|
300
|
+
path: str,
|
|
301
|
+
content: bytes | str,
|
|
302
|
+
visibility: Visibility | str | None = None,
|
|
303
|
+
) -> int:
|
|
304
|
+
target = self._resolve(path)
|
|
305
|
+
data = content.encode("utf-8") if isinstance(content, str) else content
|
|
306
|
+
file_mode = self._get_file_mode(visibility)
|
|
307
|
+
dir_mode = self._get_dir_mode(visibility)
|
|
308
|
+
return await asyncio.to_thread(_local_write_atomic, target, data, file_mode, dir_mode)
|
|
309
|
+
|
|
310
|
+
async def write_stream(
|
|
311
|
+
self,
|
|
312
|
+
path: str,
|
|
313
|
+
stream: AsyncIterator[bytes] | BinaryIO,
|
|
314
|
+
chunk_size: int = 64 * 1024,
|
|
315
|
+
visibility: Visibility | str | None = None,
|
|
316
|
+
) -> int:
|
|
317
|
+
target = self._resolve(path)
|
|
318
|
+
file_mode = self._get_file_mode(visibility)
|
|
319
|
+
dir_mode = self._get_dir_mode(visibility)
|
|
320
|
+
|
|
321
|
+
tmp_target, handle = await asyncio.to_thread(_local_open_write_tmp, target, dir_mode)
|
|
322
|
+
total_written = 0
|
|
323
|
+
committed = False
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
if isinstance(stream, AsyncIterator):
|
|
327
|
+
async for chunk in stream:
|
|
328
|
+
if chunk:
|
|
329
|
+
total_written += len(chunk)
|
|
330
|
+
await asyncio.to_thread(handle.write, chunk)
|
|
331
|
+
else:
|
|
332
|
+
while True:
|
|
333
|
+
chunk = await asyncio.to_thread(stream.read, chunk_size)
|
|
334
|
+
if not chunk:
|
|
335
|
+
break
|
|
336
|
+
total_written += len(chunk)
|
|
337
|
+
await asyncio.to_thread(handle.write, chunk)
|
|
338
|
+
|
|
339
|
+
await asyncio.to_thread(
|
|
340
|
+
_local_finalize_write_tmp,
|
|
341
|
+
tmp_target,
|
|
342
|
+
target,
|
|
343
|
+
handle,
|
|
344
|
+
file_mode,
|
|
345
|
+
)
|
|
346
|
+
committed = True
|
|
347
|
+
return total_written
|
|
348
|
+
finally:
|
|
349
|
+
if not committed:
|
|
350
|
+
try:
|
|
351
|
+
await asyncio.to_thread(_local_cleanup_write_tmp, tmp_target, handle)
|
|
352
|
+
except asyncio.CancelledError:
|
|
353
|
+
_local_cleanup_write_tmp(tmp_target, handle)
|
|
354
|
+
|
|
355
|
+
async def read(self, path: str) -> bytes:
|
|
356
|
+
target = self._resolve(path)
|
|
357
|
+
return await asyncio.to_thread(_local_read_bytes, target, path)
|
|
358
|
+
|
|
359
|
+
async def read_text(self, path: str, encoding: str = "utf-8") -> str:
|
|
360
|
+
data = await self.read(path)
|
|
361
|
+
return data.decode(encoding)
|
|
362
|
+
|
|
363
|
+
async def read_stream(
|
|
364
|
+
self,
|
|
365
|
+
path: str,
|
|
366
|
+
chunk_size: int = 64 * 1024,
|
|
367
|
+
) -> AsyncIterator[bytes]:
|
|
368
|
+
target = self._resolve(path)
|
|
369
|
+
handle = await asyncio.to_thread(_local_open_read_stream, target, path)
|
|
370
|
+
try:
|
|
371
|
+
while True:
|
|
372
|
+
chunk = await asyncio.to_thread(handle.read, chunk_size)
|
|
373
|
+
if not chunk:
|
|
374
|
+
break
|
|
375
|
+
yield chunk
|
|
376
|
+
finally:
|
|
377
|
+
await asyncio.to_thread(handle.close)
|
|
378
|
+
|
|
379
|
+
async def exists(self, path: str) -> bool:
|
|
380
|
+
target = self._resolve(path)
|
|
381
|
+
return await asyncio.to_thread(target.is_file)
|
|
382
|
+
|
|
383
|
+
async def missing(self, path: str) -> bool:
|
|
384
|
+
return not await self.exists(path)
|
|
385
|
+
|
|
386
|
+
async def delete(self, path: str) -> bool:
|
|
387
|
+
target = self._resolve(path)
|
|
388
|
+
return await asyncio.to_thread(_local_delete_file, target)
|
|
389
|
+
|
|
390
|
+
async def copy(self, source: str, destination: str) -> bool:
|
|
391
|
+
src = self._resolve(source)
|
|
392
|
+
dst = self._resolve(destination)
|
|
393
|
+
return await asyncio.to_thread(_local_copy_file, src, dst, source)
|
|
394
|
+
|
|
395
|
+
async def move(self, source: str, destination: str) -> bool:
|
|
396
|
+
src = self._resolve(source)
|
|
397
|
+
dst = self._resolve(destination)
|
|
398
|
+
return await asyncio.to_thread(_local_move_file, src, dst, source)
|
|
399
|
+
|
|
400
|
+
async def size(self, path: str) -> int:
|
|
401
|
+
target = self._resolve(path)
|
|
402
|
+
return await asyncio.to_thread(_local_file_size, target, path)
|
|
403
|
+
|
|
404
|
+
async def mime_type(self, path: str) -> str:
|
|
405
|
+
clean = PathNormalizer.normalize(path)
|
|
406
|
+
mime, _ = mimetypes.guess_type(clean)
|
|
407
|
+
return mime or "application/octet-stream"
|
|
408
|
+
|
|
409
|
+
async def last_modified(self, path: str) -> datetime:
|
|
410
|
+
target = self._resolve(path)
|
|
411
|
+
return await asyncio.to_thread(_local_last_modified, target, path)
|
|
412
|
+
|
|
413
|
+
async def checksum(self, path: str, algorithm: str = "sha256") -> str:
|
|
414
|
+
target = self._resolve(path)
|
|
415
|
+
return await asyncio.to_thread(_local_checksum, target, path, algorithm)
|
|
416
|
+
|
|
417
|
+
async def files(self, directory: str = "", recursive: bool = False) -> list[str]:
|
|
418
|
+
target = self._resolve(directory)
|
|
419
|
+
return await asyncio.to_thread(_local_list_files, self.root, target, recursive)
|
|
420
|
+
|
|
421
|
+
async def directories(self, directory: str = "", recursive: bool = False) -> list[str]:
|
|
422
|
+
target = self._resolve(directory)
|
|
423
|
+
return await asyncio.to_thread(_local_list_directories, self.root, target, recursive)
|
|
424
|
+
|
|
425
|
+
async def make_directory(self, directory: str) -> bool:
|
|
426
|
+
target = self._resolve(directory)
|
|
427
|
+
dir_mode = self._get_dir_mode(None)
|
|
428
|
+
return await asyncio.to_thread(_local_make_dir, target, dir_mode)
|
|
429
|
+
|
|
430
|
+
async def delete_directory(self, directory: str) -> bool:
|
|
431
|
+
target = self._resolve(directory)
|
|
432
|
+
return await asyncio.to_thread(_local_delete_dir, target)
|
|
433
|
+
|
|
434
|
+
def _require_signing_key(self) -> str:
|
|
435
|
+
key = self.signing_key
|
|
436
|
+
if (
|
|
437
|
+
not key
|
|
438
|
+
or not isinstance(key, str)
|
|
439
|
+
or not key.strip()
|
|
440
|
+
or key.strip().lower() == "none"
|
|
441
|
+
):
|
|
442
|
+
raise ConfigurationError(
|
|
443
|
+
"Cannot generate or verify temporary URL without a non-empty signing_key "
|
|
444
|
+
"(configure FILESYSTEM_SIGNING_KEY or APP_KEY)."
|
|
445
|
+
)
|
|
446
|
+
return key
|
|
447
|
+
|
|
448
|
+
async def url(self, path: str) -> str:
|
|
449
|
+
clean = PathNormalizer.normalize(path)
|
|
450
|
+
encoded = quote(clean, safe="/-_.~")
|
|
451
|
+
prefix = self.url_prefix.rstrip("/")
|
|
452
|
+
if not encoded:
|
|
453
|
+
return prefix or "/"
|
|
454
|
+
return f"{prefix}/{encoded}"
|
|
455
|
+
|
|
456
|
+
async def temporary_url(
|
|
457
|
+
self,
|
|
458
|
+
path: str,
|
|
459
|
+
expires_in: int | timedelta,
|
|
460
|
+
) -> str:
|
|
461
|
+
if isinstance(expires_in, bool):
|
|
462
|
+
raise TypeError("expires_in must be an integer or timedelta, not bool.")
|
|
463
|
+
duration = (
|
|
464
|
+
int(expires_in)
|
|
465
|
+
if isinstance(expires_in, (int, float))
|
|
466
|
+
else int(expires_in.total_seconds())
|
|
467
|
+
)
|
|
468
|
+
if duration <= 0:
|
|
469
|
+
raise ValueError("expires_in must be greater than 0 seconds.")
|
|
470
|
+
signing_key = self._require_signing_key()
|
|
471
|
+
clean = PathNormalizer.normalize(path)
|
|
472
|
+
encoded = quote(clean, safe="/-_.~")
|
|
473
|
+
expires = int(time.time() + duration)
|
|
474
|
+
payload = f"GET\n{encoded}\n{expires}"
|
|
475
|
+
signature = hmac.new(
|
|
476
|
+
signing_key.encode("utf-8"),
|
|
477
|
+
payload.encode("utf-8"),
|
|
478
|
+
hashlib.sha256,
|
|
479
|
+
).hexdigest()
|
|
480
|
+
|
|
481
|
+
base_url = await self.url(clean)
|
|
482
|
+
return f"{base_url}?expires={expires}&signature={signature}"
|
|
483
|
+
|
|
484
|
+
def verify_temporary_url(
|
|
485
|
+
self,
|
|
486
|
+
signed_url: str,
|
|
487
|
+
*,
|
|
488
|
+
method: str = "GET",
|
|
489
|
+
now: float | None = None,
|
|
490
|
+
) -> str:
|
|
491
|
+
"""
|
|
492
|
+
Verify a signed temporary URL's HMAC signature and expiration timestamp.
|
|
493
|
+
Returns the normalized relative storage path on success or raises InvalidSignatureError.
|
|
494
|
+
"""
|
|
495
|
+
signing_key = self._require_signing_key()
|
|
496
|
+
parsed = urlsplit(signed_url)
|
|
497
|
+
prefix_path = urlsplit(self.url_prefix).path.rstrip("/")
|
|
498
|
+
raw_path = parsed.path
|
|
499
|
+
|
|
500
|
+
if prefix_path:
|
|
501
|
+
expected_prefix = f"{prefix_path}/"
|
|
502
|
+
if not raw_path.startswith(expected_prefix):
|
|
503
|
+
raise InvalidSignatureError("Temporary URL prefix does not match disk configuration.")
|
|
504
|
+
encoded_rel = raw_path[len(expected_prefix) :]
|
|
505
|
+
else:
|
|
506
|
+
encoded_rel = raw_path.lstrip("/")
|
|
507
|
+
|
|
508
|
+
if not encoded_rel:
|
|
509
|
+
raise InvalidSignatureError("Temporary URL path is empty.")
|
|
510
|
+
|
|
511
|
+
query_params = parse_qs(parsed.query, keep_blank_values=True)
|
|
512
|
+
expires_vals = query_params.get("expires")
|
|
513
|
+
sig_vals = query_params.get("signature")
|
|
514
|
+
if (
|
|
515
|
+
not expires_vals
|
|
516
|
+
or not sig_vals
|
|
517
|
+
or len(expires_vals) != 1
|
|
518
|
+
or len(sig_vals) != 1
|
|
519
|
+
or not expires_vals[0]
|
|
520
|
+
or not sig_vals[0]
|
|
521
|
+
):
|
|
522
|
+
raise InvalidSignatureError("Temporary URL is missing required signature parameters.")
|
|
523
|
+
|
|
524
|
+
expires_str = expires_vals[0]
|
|
525
|
+
provided_sig = sig_vals[0]
|
|
526
|
+
try:
|
|
527
|
+
expires_ts = int(expires_str)
|
|
528
|
+
except ValueError as exc:
|
|
529
|
+
raise InvalidSignatureError("Temporary URL expiration timestamp is invalid.") from exc
|
|
530
|
+
|
|
531
|
+
payload = f"{method.upper()}\n{encoded_rel}\n{expires_str}"
|
|
532
|
+
expected_sig = hmac.new(
|
|
533
|
+
signing_key.encode("utf-8"),
|
|
534
|
+
payload.encode("utf-8"),
|
|
535
|
+
hashlib.sha256,
|
|
536
|
+
).hexdigest()
|
|
537
|
+
if not hmac.compare_digest(expected_sig, provided_sig):
|
|
538
|
+
raise InvalidSignatureError("Temporary URL signature is invalid.")
|
|
539
|
+
|
|
540
|
+
current_ts = time.time() if now is None else now
|
|
541
|
+
if expires_ts < current_ts:
|
|
542
|
+
raise InvalidSignatureError("Temporary URL has expired.")
|
|
543
|
+
|
|
544
|
+
return PathNormalizer.normalize(unquote(encoded_rel))
|
|
545
|
+
|
|
546
|
+
async def get_visibility(self, path: str) -> Visibility:
|
|
547
|
+
target = self._resolve(path)
|
|
548
|
+
return await asyncio.to_thread(_local_get_visibility, target, path)
|
|
549
|
+
|
|
550
|
+
async def set_visibility(self, path: str, visibility: Visibility | str) -> bool:
|
|
551
|
+
target = self._resolve(path)
|
|
552
|
+
vis = Visibility(visibility)
|
|
553
|
+
mode = self._file_permissions.get(vis, 0o600)
|
|
554
|
+
return await asyncio.to_thread(_local_set_visibility, target, path, mode)
|
|
555
|
+
|
|
556
|
+
async def metadata(self, path: str) -> FileMetadata:
|
|
557
|
+
target = self._resolve(path)
|
|
558
|
+
clean = PathNormalizer.normalize(path)
|
|
559
|
+
return await asyncio.to_thread(_local_stat_metadata, target, clean, path)
|