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,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)