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,127 @@
1
+ from __future__ import annotations
2
+
3
+ import builtins
4
+
5
+ from swiftpy.http.exceptions import HttpException, PayloadTooLargeError
6
+
7
+
8
+ class FilesystemException(Exception):
9
+ """Base exception for all filesystem and storage operations."""
10
+
11
+
12
+ class DiskNotFoundError(FilesystemException, KeyError):
13
+ """Raised when an unconfigured disk is requested."""
14
+
15
+
16
+ class FileNotFoundError(FilesystemException, builtins.FileNotFoundError):
17
+ """Raised when a requested file does not exist on storage."""
18
+
19
+
20
+ class FileAlreadyExistsError(FilesystemException, FileExistsError):
21
+ """Raised when a file already exists and overwrite is false."""
22
+
23
+
24
+ class PathTraversalError(FilesystemException, PermissionError):
25
+ """Raised when a path traversal attempt is detected."""
26
+
27
+
28
+ class ReadOnlyFileSystemError(FilesystemException, PermissionError):
29
+ """Raised when a write or delete operation is attempted on a read-only disk."""
30
+
31
+
32
+ class StorageQuotaExceededError(FilesystemException):
33
+ """Raised when storage disk quota or size threshold is exceeded."""
34
+
35
+
36
+ class InvalidSignatureError(FilesystemException):
37
+ """Raised when a signed temporary URL has an invalid signature or is expired."""
38
+
39
+
40
+ class DriverNotSupportedError(FilesystemException):
41
+ """Raised when a driver name is unknown or an operation is unsupported."""
42
+
43
+
44
+ class FileUploadError(FilesystemException):
45
+ """Base exception for HTTP upload operations."""
46
+
47
+
48
+ class FileValidationError(FileUploadError, HttpException):
49
+ """Raised when file upload validation fails. Defaults to HTTP 422."""
50
+
51
+ def __init__(
52
+ self,
53
+ errors: dict[str, list[str]],
54
+ message: str = "The uploaded file was invalid.",
55
+ headers: dict[str, str] | None = None,
56
+ ) -> None:
57
+ self.errors = errors
58
+ super().__init__(message=message, status_code=422, headers=headers)
59
+
60
+
61
+ class FileTooLargeError(FileUploadError, PayloadTooLargeError):
62
+ """Raised when an uploaded file or total request exceeds configured size limits."""
63
+
64
+ def __init__(self, message: str = "Uploaded file payload is too large") -> None:
65
+ super().__init__(message=message)
66
+
67
+
68
+ class InvalidMimeTypeError(FileValidationError):
69
+ """Raised when an uploaded file's MIME type does not match allowed types."""
70
+
71
+ def __init__(
72
+ self,
73
+ errors_or_message: str | dict[str, list[str]] = "Invalid file MIME type.",
74
+ message: str | None = None,
75
+ headers: dict[str, str] | None = None,
76
+ field_name: str = "file",
77
+ ) -> None:
78
+ errors, resolved_msg = _normalize_validation_args(
79
+ errors_or_message,
80
+ message=message,
81
+ default_message="Invalid file MIME type.",
82
+ field_name=field_name,
83
+ )
84
+ super().__init__(errors=errors, message=resolved_msg, headers=headers)
85
+
86
+
87
+ class InvalidFileExtensionError(FileValidationError):
88
+ """Raised when an uploaded file's extension is disallowed."""
89
+
90
+ def __init__(
91
+ self,
92
+ errors_or_message: str | dict[str, list[str]] = "Invalid file extension.",
93
+ message: str | None = None,
94
+ headers: dict[str, str] | None = None,
95
+ field_name: str = "file",
96
+ ) -> None:
97
+ errors, resolved_msg = _normalize_validation_args(
98
+ errors_or_message,
99
+ message=message,
100
+ default_message="Invalid file extension.",
101
+ field_name=field_name,
102
+ )
103
+ super().__init__(errors=errors, message=resolved_msg, headers=headers)
104
+
105
+
106
+ def _normalize_validation_args(
107
+ errors_or_message: str | dict[str, list[str]],
108
+ *,
109
+ message: str | None,
110
+ default_message: str,
111
+ field_name: str,
112
+ ) -> tuple[dict[str, list[str]], str]:
113
+ if isinstance(errors_or_message, str):
114
+ resolved_msg = message or errors_or_message
115
+ return {field_name: [resolved_msg]}, resolved_msg
116
+ first_msg = next(
117
+ (msgs[0] for msgs in errors_or_message.values() if msgs),
118
+ default_message,
119
+ )
120
+ return errors_or_message, message or first_msg
121
+
122
+
123
+ class MalformedMultipartError(FileUploadError, HttpException):
124
+ """Raised when multipart form body is corrupted or improperly formatted."""
125
+
126
+ def __init__(self, message: str = "Malformed multipart request payload") -> None:
127
+ super().__init__(message=message, status_code=400)
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from swiftpy.core.config import Config
7
+ from swiftpy.core.container import get_container
8
+ from swiftpy.filesystem.disk import StorageDisk
9
+ from swiftpy.filesystem.drivers.base import StorageDriver
10
+ from swiftpy.filesystem.drivers.local import LocalStorageDriver
11
+ from swiftpy.filesystem.drivers.s3 import S3StorageDriver
12
+ from swiftpy.filesystem.exceptions import DiskNotFoundError, DriverNotSupportedError
13
+
14
+
15
+ class FilesystemManager:
16
+ """
17
+ Registry and factory for configured StorageDisk instances.
18
+ Integrates with SwiftPY's configuration repository and DI container.
19
+ """
20
+
21
+ _instance: FilesystemManager | None = None
22
+
23
+ def __init__(self, config: Config | None = None) -> None:
24
+ if config is None:
25
+ try:
26
+ container = get_container()
27
+ if container.has(Config):
28
+ self.config = container.resolve(Config)
29
+ else:
30
+ self.config = Config.get_instance()
31
+ except RuntimeError:
32
+ self.config = Config.get_instance()
33
+ else:
34
+ self.config = config
35
+
36
+ self._disks: dict[str, StorageDisk] = {}
37
+ self._custom_creators: dict[str, Callable[[dict[str, Any]], StorageDriver]] = {}
38
+
39
+ @classmethod
40
+ def get_instance(cls) -> FilesystemManager:
41
+ try:
42
+ container = get_container()
43
+ if container.has(cls):
44
+ return container.resolve(cls)
45
+ except RuntimeError:
46
+ pass
47
+
48
+ if cls._instance is None:
49
+ cls._instance = cls()
50
+ return cls._instance
51
+
52
+ @property
53
+ def default_disk_name(self) -> str:
54
+ return str(self.config.get("filesystems.default", "local"))
55
+
56
+ def extend(
57
+ self,
58
+ driver_name: str,
59
+ factory: Callable[[dict[str, Any]], StorageDriver],
60
+ ) -> None:
61
+ """Register a custom storage driver factory."""
62
+ self._custom_creators[driver_name] = factory
63
+
64
+ def disk(self, name: str | None = None) -> StorageDisk:
65
+ """Resolve a storage disk instance by name, or return the default disk."""
66
+ disk_name = name or self.default_disk_name
67
+
68
+ if disk_name in self._disks:
69
+ return self._disks[disk_name]
70
+
71
+ disk_instance = self._resolve_disk(disk_name)
72
+ self._disks[disk_name] = disk_instance
73
+ return disk_instance
74
+
75
+ def build(self, disk_config: dict[str, Any], name: str = "on_demand") -> StorageDisk:
76
+ """Build an unmanaged on-demand storage disk from a configuration dict."""
77
+ driver = self._create_driver(disk_config)
78
+ read_only = bool(disk_config.get("read_only", False))
79
+ prefix = str(disk_config.get("prefix", ""))
80
+ return StorageDisk(name=name, driver=driver, read_only=read_only, prefix=prefix)
81
+
82
+ def _resolve_disk(self, name: str) -> StorageDisk:
83
+ disks = self.config.get("filesystems.disks", {})
84
+ if not isinstance(disks, dict) or name not in disks:
85
+ raise DiskNotFoundError(f"Storage disk '{name}' is not configured.")
86
+
87
+ disk_config = disks[name]
88
+ if not isinstance(disk_config, dict):
89
+ raise DiskNotFoundError(f"Configuration for disk '{name}' must be a dictionary.")
90
+
91
+ driver = self._create_driver(disk_config)
92
+ read_only = bool(disk_config.get("read_only", False))
93
+ prefix = str(disk_config.get("prefix", ""))
94
+
95
+ return StorageDisk(name=name, driver=driver, read_only=read_only, prefix=prefix)
96
+
97
+ def _create_driver(self, disk_config: dict[str, Any]) -> StorageDriver:
98
+ driver_type = str(disk_config.get("driver", "local")).lower()
99
+
100
+ if driver_type in self._custom_creators:
101
+ return self._custom_creators[driver_type](disk_config)
102
+
103
+ if driver_type == "local":
104
+ root = str(disk_config.get("root", "storage/app/private"))
105
+ url_prefix = str(disk_config.get("url", "/storage"))
106
+ visibility = str(disk_config.get("visibility", "private"))
107
+ raw_signing_key = self.config.get("filesystems.signing_key")
108
+ if raw_signing_key is None or (
109
+ isinstance(raw_signing_key, str)
110
+ and (not raw_signing_key.strip() or raw_signing_key.strip().lower() == "none")
111
+ ):
112
+ raw_signing_key = self.config.get("app.key")
113
+ signing_key = (
114
+ str(raw_signing_key)
115
+ if isinstance(raw_signing_key, str)
116
+ and raw_signing_key.strip()
117
+ and raw_signing_key.strip().lower() != "none"
118
+ else None
119
+ )
120
+ permissions = disk_config.get("permissions")
121
+ return LocalStorageDriver(
122
+ root=root,
123
+ url_prefix=url_prefix,
124
+ default_visibility=visibility,
125
+ signing_key=signing_key,
126
+ permissions=permissions if isinstance(permissions, dict) else None,
127
+ )
128
+
129
+ if driver_type == "s3":
130
+ return S3StorageDriver.from_config(disk_config)
131
+
132
+ raise DriverNotSupportedError(f"Unsupported storage driver: '{driver_type}'")
133
+
134
+ async def close(self) -> None:
135
+ """Close and clean up all instantiated storage drivers."""
136
+ for disk_instance in self._disks.values():
137
+ await disk_instance.driver.close()
138
+ self._disks.clear()
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from swiftpy.filesystem.visibility import Visibility
8
+
9
+
10
+ @dataclass(slots=True, frozen=True)
11
+ class FileMetadata:
12
+ """Unified metadata representation for a stored file."""
13
+
14
+ path: str
15
+ size: int
16
+ mime_type: str
17
+ last_modified: datetime
18
+ visibility: Visibility | str = Visibility.PRIVATE
19
+ etag: str | None = None
20
+ checksum: str | None = None
21
+ extra: dict[str, Any] = field(default_factory=dict)
22
+
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import uuid
6
+ from pathlib import Path
7
+ from urllib.parse import unquote
8
+
9
+ from swiftpy.filesystem.exceptions import PathTraversalError
10
+
11
+
12
+ class PathNormalizer:
13
+ """Security-focused path normalization and collision-resistant name generation."""
14
+
15
+ _SUSPICIOUS_ENCODED = re.compile(r"%2e|%2f|%5c", re.IGNORECASE)
16
+ _SAFE_EXT = re.compile(r"^[a-zA-Z0-9]+$")
17
+ _WINDOWS_DRIVE = re.compile(r"^[a-zA-Z]:(?:[/\\]|$)", re.IGNORECASE)
18
+
19
+ @classmethod
20
+ def normalize(cls, path: str) -> str:
21
+ """
22
+ Normalize a relative storage path.
23
+ Strips leading/trailing slashes, normalizes separators to '/',
24
+ and rejects path traversal attempts.
25
+ """
26
+ if "\x00" in path:
27
+ raise PathTraversalError("Null byte detected in storage path.")
28
+
29
+ # Reject Windows drive letters (e.g., C:\, C:/, D:)
30
+ if cls._WINDOWS_DRIVE.match(path):
31
+ raise PathTraversalError(f"Windows drive letter detected in storage path: '{path}'.")
32
+
33
+ # Check for URL-encoded and double-encoded traversal indicators
34
+ decoded = unquote(path)
35
+ double_decoded = unquote(decoded)
36
+ if "\x00" in decoded or "\x00" in double_decoded:
37
+ raise PathTraversalError("Null byte detected in storage path.")
38
+
39
+ if ".." in decoded or ".." in double_decoded:
40
+ raise PathTraversalError(f"Encoded path traversal sequence detected in '{path}'.")
41
+
42
+ if (cls._SUSPICIOUS_ENCODED.search(path) or cls._SUSPICIOUS_ENCODED.search(decoded)) and (
43
+ "/" in decoded or "\\" in decoded or "/" in double_decoded or "\\" in double_decoded
44
+ ):
45
+ raise PathTraversalError("Encoded path traversal characters detected.")
46
+
47
+ clean = path.replace("\\", "/")
48
+ parts = [p for p in clean.split("/") if p and p != "."]
49
+
50
+ for part in parts:
51
+ if part == "..":
52
+ raise PathTraversalError(f"Path traversal sequence '..' detected in '{path}'.")
53
+ if re.match(r"^[a-zA-Z]:$", part, re.IGNORECASE):
54
+ raise PathTraversalError(f"Windows drive letter detected in storage path: '{path}'.")
55
+
56
+ return "/".join(parts)
57
+
58
+ @classmethod
59
+ def resolve_safe_path(cls, root: str | Path, path: str) -> Path:
60
+ """
61
+ Resolve a relative path against a storage root and strictly ensure
62
+ it does not escape the root via symlinks or relative references.
63
+ """
64
+ clean_rel = cls.normalize(path)
65
+ root_path = Path(root).resolve()
66
+ target = (root_path / clean_rel).resolve()
67
+
68
+ if target != root_path and not target.is_relative_to(root_path):
69
+ raise PathTraversalError(
70
+ f"Storage path '{path}' escapes configured root '{root}'."
71
+ )
72
+ return target
73
+
74
+ @classmethod
75
+ def generate_name(
76
+ cls,
77
+ original_filename: str | None = None,
78
+ prefix: str = "",
79
+ extension: str | None = None,
80
+ ) -> str:
81
+ """
82
+ Generate a cryptographically random, collision-resistant storage filename.
83
+ Preserves or overrides a sanitized extension.
84
+ """
85
+ random_hex = uuid.uuid4().hex
86
+ ext = extension
87
+
88
+ if ext is None and original_filename:
89
+ sanitized = original_filename.replace("\x00", "")
90
+ _, raw_ext = os.path.splitext(sanitized)
91
+ candidate = raw_ext.lstrip(".").lower()
92
+ if cls._SAFE_EXT.match(candidate):
93
+ ext = candidate
94
+
95
+ filename = f"{prefix}{random_hex}"
96
+ if ext:
97
+ filename = f"{filename}.{ext}"
98
+ return filename
99
+
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from swiftpy.core.providers.base import ServiceProvider
4
+ from swiftpy.filesystem.manager import FilesystemManager
5
+
6
+
7
+ class FilesystemServiceProvider(ServiceProvider):
8
+ """
9
+ Registers the FilesystemManager singleton into the application DI container.
10
+ """
11
+
12
+ def register(self) -> None:
13
+ self.container.singleton(FilesystemManager)
14
+
15
+ def boot(self) -> None:
16
+ pass
17
+
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from swiftpy.filesystem.uploads.handlers import FormData, MultipartParser
4
+ from swiftpy.filesystem.uploads.uploaded_file import UploadedFile
5
+ from swiftpy.filesystem.uploads.validators import FileValidator
6
+
7
+ __all__ = [
8
+ "FileValidator",
9
+ "FormData",
10
+ "MultipartParser",
11
+ "UploadedFile",
12
+ ]
13
+