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,307 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import uuid
6
+ from collections.abc import Awaitable, Callable, Sequence
7
+ from typing import Any
8
+
9
+ from swiftpy.core.config import Config, ConfigurationError
10
+ from swiftpy.core.container import Container
11
+ from swiftpy.core.context import Context
12
+ from swiftpy.http.exceptions import ClientDisconnect
13
+ from swiftpy.http.request import Request
14
+ from swiftpy.http.response import Response
15
+
16
+ Handler = Callable[..., Awaitable[Response]]
17
+
18
+ _REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._\-:]{1,128}$")
19
+
20
+
21
+ class Middleware:
22
+ async def handle(self, request: Request, next_handler: Handler) -> Response:
23
+ return await next_handler(request)
24
+
25
+
26
+ class MiddlewarePipeline:
27
+ def __init__(
28
+ self, container: Container, middleware: Sequence[type[Middleware]]
29
+ ) -> None:
30
+ self.container = container
31
+ self.middleware = list(middleware)
32
+
33
+ def wrap(
34
+ self,
35
+ handler: Handler,
36
+ *,
37
+ exception_handler: Callable[[Exception, Request], Awaitable[Response]] | None = None,
38
+ ) -> Handler:
39
+ wrapped = handler
40
+
41
+ for middleware_cls in reversed(self.middleware):
42
+ current = wrapped
43
+
44
+ async def wrapped_handler(
45
+ request: Request,
46
+ *args: Any,
47
+ middleware_cls: type[Middleware] = middleware_cls,
48
+ current: Handler = current,
49
+ **kwargs: Any,
50
+ ) -> Response:
51
+ middleware = self.container.resolve(middleware_cls)
52
+
53
+ async def next_handler(req: Request) -> Response:
54
+ if exception_handler is None:
55
+ return await current(req, *args, **kwargs)
56
+ try:
57
+ return await current(req, *args, **kwargs)
58
+ except ClientDisconnect:
59
+ raise
60
+ except Exception as exc: # noqa: BLE001
61
+ return await exception_handler(exc, req)
62
+
63
+ return await middleware.handle(request, next_handler)
64
+
65
+ wrapped = wrapped_handler
66
+
67
+ return wrapped
68
+
69
+
70
+ class SecurityHeadersMiddleware(Middleware):
71
+ """
72
+ Opt-in middleware that attaches standard defensive HTTP security headers.
73
+ """
74
+
75
+ DEFAULT_HEADERS: tuple[tuple[str, str], ...] = (
76
+ ("X-Content-Type-Options", "nosniff"),
77
+ ("X-Frame-Options", "DENY"),
78
+ ("Referrer-Policy", "strict-origin-when-cross-origin"),
79
+ ("X-XSS-Protection", "0"),
80
+ ("Cross-Origin-Opener-Policy", "same-origin"),
81
+ )
82
+
83
+ async def handle(self, request: Request, next_handler: Handler) -> Response:
84
+ response = await next_handler(request)
85
+ for header_name, header_val in self.DEFAULT_HEADERS:
86
+ if header_name not in response.headers:
87
+ response.headers[header_name] = header_val
88
+ return response
89
+
90
+
91
+ class RequestIdMiddleware(Middleware):
92
+ """
93
+ Opt-in middleware that assigns or propagates a validated `X-Request-ID` header
94
+ and stores `request_id` in `request.state` and `Context`.
95
+ """
96
+
97
+ HEADER_NAME: str = "X-Request-ID"
98
+
99
+ async def handle(self, request: Request, next_handler: Handler) -> Response:
100
+ incoming = request.headers.get(self.HEADER_NAME)
101
+ if incoming and _REQUEST_ID_RE.fullmatch(incoming):
102
+ request_id = incoming
103
+ else:
104
+ request_id = str(uuid.uuid4())
105
+
106
+ request.state["request_id"] = request_id
107
+ Context.set("request_id", request_id)
108
+
109
+ response = await next_handler(request)
110
+ response.headers[self.HEADER_NAME] = request_id
111
+ return response
112
+
113
+
114
+ class CORSMiddleware(Middleware):
115
+ """
116
+ Opt-in Cross-Origin Resource Sharing (CORS) middleware configured via `config/cors.py`.
117
+ Defaults to deny-all (`allowed_origins = []`).
118
+ """
119
+
120
+ def __init__(self, config: Config) -> None:
121
+ self._config = config
122
+ self._parse_cors_settings()
123
+
124
+ def _get_cors_config(self) -> dict[str, Any]:
125
+ raw = self._config.get("cors", {})
126
+ return raw if isinstance(raw, dict) else {}
127
+
128
+ @staticmethod
129
+ def _normalize_list(
130
+ value: Any,
131
+ *,
132
+ default: Sequence[str] = (),
133
+ field: str = "",
134
+ ) -> list[str]:
135
+ if value is None:
136
+ return list(default)
137
+ if isinstance(value, str):
138
+ stripped = value.strip()
139
+ if not stripped:
140
+ return list(default) if default else []
141
+ if stripped.startswith("[") and stripped.endswith("]"):
142
+ try:
143
+ parsed = json.loads(stripped)
144
+ except (json.JSONDecodeError, ValueError) as exc:
145
+ raise ConfigurationError(
146
+ f"CORS configuration field '{field}' contains invalid JSON list: '{value}'"
147
+ ) from exc
148
+ if not isinstance(parsed, list):
149
+ raise ConfigurationError(
150
+ f"CORS configuration field '{field}' expects a JSON array"
151
+ )
152
+ items = [str(item).strip() for item in parsed if str(item).strip()]
153
+ else:
154
+ items = [item.strip() for item in stripped.split(",") if item.strip()]
155
+ return items if items or not default else list(default)
156
+ if isinstance(value, (list, tuple, set)):
157
+ items = [str(item).strip() for item in value if str(item).strip()]
158
+ return items if items or not default else list(default)
159
+ raise ConfigurationError(
160
+ f"CORS configuration field '{field}' must be a sequence or comma-separated string, got {type(value).__name__}"
161
+ )
162
+
163
+ @staticmethod
164
+ def _normalize_bool(value: Any, default: bool = False) -> bool:
165
+ if value is None:
166
+ return default
167
+ if isinstance(value, bool):
168
+ return value
169
+ if isinstance(value, str):
170
+ return value.strip().lower() in ("true", "1", "yes", "on")
171
+ return bool(value)
172
+
173
+ def _parse_cors_settings(
174
+ self,
175
+ ) -> tuple[list[str], list[str], list[str], list[str], bool, int | None]:
176
+ cfg = self._get_cors_config()
177
+ allowed_origins = self._normalize_list(
178
+ cfg.get("allowed_origins"),
179
+ field="allowed_origins",
180
+ )
181
+ allowed_methods = self._normalize_list(
182
+ cfg.get("allowed_methods"),
183
+ default=("GET", "HEAD", "POST"),
184
+ field="allowed_methods",
185
+ )
186
+ allowed_headers = self._normalize_list(
187
+ cfg.get("allowed_headers"),
188
+ field="allowed_headers",
189
+ )
190
+ expose_headers = self._normalize_list(
191
+ cfg.get("expose_headers"),
192
+ field="expose_headers",
193
+ )
194
+ allow_credentials = self._normalize_bool(
195
+ cfg.get("allow_credentials"),
196
+ default=False,
197
+ )
198
+ if "*" in allowed_origins and allow_credentials:
199
+ raise ConfigurationError(
200
+ "Invalid CORS configuration: 'allowed_origins' cannot include '*' when 'allow_credentials' is True."
201
+ )
202
+ max_age: int | None = (
203
+ int(cfg["max_age"]) if cfg.get("max_age") is not None else 600
204
+ )
205
+ return (
206
+ allowed_origins,
207
+ allowed_methods,
208
+ allowed_headers,
209
+ expose_headers,
210
+ allow_credentials,
211
+ max_age,
212
+ )
213
+
214
+ @staticmethod
215
+ def _is_origin_allowed(origin: str, allowed_origins: Sequence[str]) -> bool:
216
+ if not allowed_origins:
217
+ return False
218
+ if "*" in allowed_origins:
219
+ return True
220
+ return origin in allowed_origins
221
+
222
+ async def handle(self, request: Request, next_handler: Handler) -> Response:
223
+ (
224
+ allowed_origins,
225
+ allowed_methods,
226
+ allowed_headers,
227
+ expose_headers,
228
+ allow_credentials,
229
+ max_age,
230
+ ) = self._parse_cors_settings()
231
+
232
+ origin = request.headers.get("origin")
233
+ if not origin:
234
+ return await next_handler(request)
235
+
236
+ is_preflight = (
237
+ request.method.upper() == "OPTIONS"
238
+ and "access-control-request-method" in request.headers
239
+ )
240
+ origin_allowed = self._is_origin_allowed(origin, allowed_origins)
241
+
242
+ if is_preflight:
243
+ if not origin_allowed:
244
+ return Response(b"", status_code=204)
245
+
246
+ headers: dict[str, str] = {}
247
+ if "*" in allowed_origins and not allow_credentials:
248
+ headers["Access-Control-Allow-Origin"] = "*"
249
+ else:
250
+ headers["Access-Control-Allow-Origin"] = origin
251
+ headers["Vary"] = "Origin"
252
+
253
+ headers["Access-Control-Allow-Methods"] = ", ".join(allowed_methods)
254
+ if allowed_headers:
255
+ if "*" in allowed_headers:
256
+ req_hdrs = request.headers.get("access-control-request-headers")
257
+ if req_hdrs:
258
+ headers["Access-Control-Allow-Headers"] = req_hdrs
259
+ else:
260
+ headers["Access-Control-Allow-Headers"] = ", ".join(allowed_headers)
261
+
262
+ if allow_credentials:
263
+ headers["Access-Control-Allow-Credentials"] = "true"
264
+ if max_age is not None:
265
+ headers["Access-Control-Max-Age"] = str(max_age)
266
+
267
+ return Response(b"", status_code=204, headers=headers)
268
+
269
+ response = await next_handler(request)
270
+ if not origin_allowed:
271
+ return response
272
+
273
+ if "*" in allowed_origins and not allow_credentials:
274
+ response.headers["Access-Control-Allow-Origin"] = "*"
275
+ else:
276
+ response.headers["Access-Control-Allow-Origin"] = origin
277
+ existing_vary = response.headers.get("Vary")
278
+ if not existing_vary:
279
+ response.headers["Vary"] = "Origin"
280
+ elif "Origin" not in [v.strip() for v in existing_vary.split(",")]:
281
+ response.headers["Vary"] = f"{existing_vary}, Origin"
282
+
283
+ if allow_credentials:
284
+ response.headers["Access-Control-Allow-Credentials"] = "true"
285
+ if expose_headers:
286
+ response.headers["Access-Control-Expose-Headers"] = ", ".join(
287
+ expose_headers
288
+ )
289
+
290
+ return response
291
+
292
+
293
+ SecurityHeaders = SecurityHeadersMiddleware
294
+ RequestId = RequestIdMiddleware
295
+ CORS = CORSMiddleware
296
+
297
+ __all__ = [
298
+ "CORS",
299
+ "CORSMiddleware",
300
+ "Handler",
301
+ "Middleware",
302
+ "MiddlewarePipeline",
303
+ "RequestId",
304
+ "RequestIdMiddleware",
305
+ "SecurityHeaders",
306
+ "SecurityHeadersMiddleware",
307
+ ]
@@ -0,0 +1,375 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator, Iterator, Mapping
5
+ from typing import Any, cast
6
+ from urllib.parse import parse_qs
7
+
8
+ from swiftpy.core.config import Config
9
+ from swiftpy.core.container import get_container
10
+ from swiftpy.filesystem.uploads.handlers import FormData, MultipartParser
11
+ from swiftpy.filesystem.uploads.uploaded_file import UploadedFile
12
+ from swiftpy.http.exceptions import (
13
+ ClientDisconnect,
14
+ HttpException,
15
+ PayloadTooLargeError,
16
+ )
17
+
18
+ DEFAULT_MAX_BODY_SIZE: int = 2 * 1024 * 1024 # 2 MiB
19
+ DEFAULT_MAX_CONTENT_LENGTH: int = 2 * 1024 * 1024 # 2 MiB
20
+ DEFAULT_MAX_JSON_DEPTH: int = 32
21
+
22
+
23
+ def _validate_json_depth(
24
+ obj: Any,
25
+ current_depth: int = 1,
26
+ max_depth: int = DEFAULT_MAX_JSON_DEPTH,
27
+ ) -> None:
28
+ """Validate iteratively that nested JSON structures do not exceed maximum nesting depth."""
29
+ stack: list[tuple[Any, int]] = [(obj, current_depth)]
30
+ while stack:
31
+ node, depth = stack.pop()
32
+ if depth > max_depth:
33
+ raise HttpException(
34
+ f"JSON payload exceeds maximum allowed depth of {max_depth}",
35
+ status_code=400,
36
+ )
37
+ if isinstance(node, dict):
38
+ next_depth = depth + 1
39
+ for val in node.values():
40
+ if isinstance(val, (dict, list)):
41
+ stack.append((val, next_depth))
42
+ elif isinstance(node, list):
43
+ next_depth = depth + 1
44
+ for item in node:
45
+ if isinstance(item, (dict, list)):
46
+ stack.append((item, next_depth))
47
+
48
+
49
+ class RequestHeaders(Mapping[str, str]):
50
+ """
51
+ Case-insensitive, immutable HTTP request header container decoded as latin-1 per ASGI spec.
52
+ Preserves duplicate headers via `.getlist(name)` while using last-wins semantics for
53
+ dict-style single-value lookups (`headers[name]`, `headers.get(name)`).
54
+ """
55
+
56
+ __slots__ = ("_by_lower", "_items", "_list_by_lower")
57
+
58
+ def __init__(self, raw_headers: list[tuple[bytes, bytes]] | None = None) -> None:
59
+ items: list[tuple[str, str]] = []
60
+ by_lower: dict[str, str] = {}
61
+ list_by_lower: dict[str, list[str]] = {}
62
+
63
+ for raw_k, raw_v in raw_headers or ():
64
+ key = raw_k.decode("latin-1")
65
+ val = raw_v.decode("latin-1")
66
+ lower_key = key.lower()
67
+ items.append((key, val))
68
+ by_lower[lower_key] = val
69
+ list_by_lower.setdefault(lower_key, []).append(val)
70
+
71
+ self._items: list[tuple[str, str]] = items
72
+ self._by_lower: dict[str, str] = by_lower
73
+ self._list_by_lower: dict[str, list[str]] = list_by_lower
74
+
75
+ def __getitem__(self, name: str) -> str:
76
+ lower = name.lower()
77
+ if lower not in self._by_lower:
78
+ raise KeyError(name)
79
+ return self._by_lower[lower]
80
+
81
+ def __iter__(self) -> Iterator[str]:
82
+ return iter(self._by_lower)
83
+
84
+ def __len__(self) -> int:
85
+ return len(self._by_lower)
86
+
87
+ def __contains__(self, name: object) -> bool:
88
+ if not isinstance(name, str):
89
+ return False
90
+ return name.lower() in self._by_lower
91
+
92
+ def getlist(self, name: str) -> list[str]:
93
+ """Return all values for `name` (case-insensitive) in arrival order."""
94
+ return list(self._list_by_lower.get(name.lower(), ()))
95
+
96
+ def raw_items(self) -> list[tuple[str, str]]:
97
+ """Return all `(name, value)` header pairs in arrival order."""
98
+ return list(self._items)
99
+
100
+
101
+ class Request:
102
+ def __init__(
103
+ self,
104
+ scope: dict[str, Any],
105
+ receive: Any,
106
+ *,
107
+ max_body_size: int = DEFAULT_MAX_BODY_SIZE,
108
+ max_content_length: int | None = None,
109
+ max_json_depth: int = DEFAULT_MAX_JSON_DEPTH,
110
+ ) -> None:
111
+ self.scope = scope
112
+ self._receive = receive
113
+ self.max_body_size = max_body_size
114
+ self.max_content_length = max_content_length
115
+ self.max_json_depth = max_json_depth
116
+
117
+ self._headers: RequestHeaders | None = None
118
+ self._query_params: dict[str, str] | None = None
119
+ self._query_param_list: dict[str, list[str]] | None = None
120
+ self._body: bytes | None = None
121
+ self._json: Any = None
122
+ self._json_parsed: bool = False
123
+ self._form: FormData | None = None
124
+
125
+ self.state: dict[str, Any] = {}
126
+
127
+ @property
128
+ def method(self) -> str:
129
+ return cast(str, self.scope["method"])
130
+
131
+ @property
132
+ def path(self) -> str:
133
+ return cast(str, self.scope["path"])
134
+
135
+ @property
136
+ def headers(self) -> RequestHeaders:
137
+ if self._headers is None:
138
+ self._headers = RequestHeaders(self.scope.get("headers", []))
139
+ return self._headers
140
+
141
+ @property
142
+ def query_param_list(self) -> dict[str, list[str]]:
143
+ """Parsed dictionary of URL query string parameters with all values as lists (cached)."""
144
+ if self._query_param_list is None:
145
+ raw_qs: bytes = self.scope.get("query_string", b"")
146
+ if not raw_qs:
147
+ self._query_param_list = {}
148
+ else:
149
+ decoded = raw_qs.decode("utf-8", errors="replace")
150
+ self._query_param_list = parse_qs(
151
+ decoded,
152
+ keep_blank_values=True,
153
+ encoding="utf-8",
154
+ errors="replace",
155
+ )
156
+ return self._query_param_list
157
+
158
+ @property
159
+ def query_params(self) -> dict[str, str]:
160
+ """Parsed dictionary of URL query string parameters (first value for each key, cached)."""
161
+ if self._query_params is None:
162
+ self._query_params = {
163
+ k: v[0] for k, v in self.query_param_list.items() if v
164
+ }
165
+ return self._query_params
166
+
167
+ async def body(self, *, max_size: int | None = None) -> bytes:
168
+ limit = max_size if max_size is not None else self.max_body_size
169
+
170
+ content_length_values = self.headers.getlist("content-length")
171
+ if content_length_values:
172
+ parsed_lengths: list[int] = []
173
+ for raw_cl in content_length_values:
174
+ try:
175
+ parsed_cl = int(raw_cl)
176
+ except ValueError:
177
+ raise HttpException(
178
+ "Invalid Content-Length header", status_code=400
179
+ ) from None
180
+ if parsed_cl < 0:
181
+ raise HttpException(
182
+ "Invalid Content-Length header", status_code=400
183
+ )
184
+ parsed_lengths.append(parsed_cl)
185
+ if len(content_length_values) > 1 and (
186
+ len(set(parsed_lengths)) > 1
187
+ or len({val.strip() for val in content_length_values}) > 1
188
+ ):
189
+ raise HttpException(
190
+ "Conflicting Content-Length headers", status_code=400
191
+ )
192
+ content_length = parsed_lengths[0]
193
+ cl_limit = (
194
+ self.max_content_length
195
+ if self.max_content_length is not None
196
+ else limit
197
+ )
198
+ if cl_limit is not None and content_length > cl_limit:
199
+ raise PayloadTooLargeError(
200
+ f"Request content length exceeds maximum size of {cl_limit} bytes"
201
+ )
202
+ if limit is not None and content_length > limit:
203
+ raise PayloadTooLargeError(
204
+ f"Request body exceeds maximum size of {limit} bytes"
205
+ )
206
+
207
+ if self._body is None:
208
+ chunks: list[bytes] = []
209
+ total_bytes = 0
210
+
211
+ while True:
212
+ message = await self._receive()
213
+ msg_type = message.get("type")
214
+
215
+ if msg_type == "http.disconnect":
216
+ raise ClientDisconnect(
217
+ "Client disconnected before request body was completely received"
218
+ )
219
+
220
+ chunk: bytes = message.get("body", b"")
221
+ total_bytes += len(chunk)
222
+ if limit is not None and total_bytes > limit:
223
+ raise PayloadTooLargeError(
224
+ f"Request body exceeds maximum size of {limit} bytes"
225
+ )
226
+
227
+ chunks.append(chunk)
228
+
229
+ if not message.get("more_body", False):
230
+ break
231
+
232
+ self._body = b"".join(chunks)
233
+ elif limit is not None and len(self._body) > limit:
234
+ raise PayloadTooLargeError(
235
+ f"Request body exceeds maximum size of {limit} bytes"
236
+ )
237
+
238
+ return self._body
239
+
240
+ async def json(self) -> Any:
241
+ if not self._json_parsed:
242
+ raw_bytes = await self.body()
243
+ if not raw_bytes:
244
+ self._json = {}
245
+ else:
246
+ try:
247
+ raw_text = raw_bytes.decode("utf-8")
248
+ self._json = json.loads(raw_text) if raw_text else {}
249
+ except (
250
+ UnicodeDecodeError,
251
+ json.JSONDecodeError,
252
+ RecursionError,
253
+ ValueError,
254
+ ):
255
+ raise HttpException(
256
+ "Invalid JSON payload", status_code=400
257
+ ) from None
258
+ _validate_json_depth(self._json, max_depth=self.max_json_depth)
259
+ self._json_parsed = True
260
+
261
+ return self._json
262
+
263
+ def _resolve_upload_limits(self) -> tuple[int, int, int, int, str | None]:
264
+ """Resolve upload parser limits from filesystems.uploads config and Request overrides."""
265
+ default_max_file = 100 * 1024 * 1024
266
+ default_max_req = 200 * 1024 * 1024
267
+ default_mem_limit = 1024 * 1024
268
+
269
+ uploads_cfg: Mapping[str, Any] | None = None
270
+ try:
271
+ container = get_container()
272
+ if container.has(Config):
273
+ cfg = container.resolve(Config)
274
+ raw_cfg = cfg.get("filesystems.uploads")
275
+ if isinstance(raw_cfg, Mapping):
276
+ uploads_cfg = raw_cfg
277
+ except RuntimeError:
278
+ uploads_cfg = None
279
+
280
+ max_file_size = default_max_file
281
+ memory_limit = default_mem_limit
282
+ max_field_size = default_mem_limit
283
+ temp_dir: str | None = None
284
+ cfg_max_request: int | None = None
285
+
286
+ if uploads_cfg is not None:
287
+ raw_file_limit = uploads_cfg.get("max_file_size")
288
+ if isinstance(raw_file_limit, int) and not isinstance(raw_file_limit, bool) and raw_file_limit > 0:
289
+ max_file_size = raw_file_limit
290
+ raw_mem = uploads_cfg.get("memory_limit")
291
+ if isinstance(raw_mem, int) and not isinstance(raw_mem, bool) and raw_mem > 0:
292
+ memory_limit = raw_mem
293
+ max_field_size = memory_limit
294
+ raw_field_limit = uploads_cfg.get("max_field_size")
295
+ if isinstance(raw_field_limit, int) and not isinstance(raw_field_limit, bool) and raw_field_limit > 0:
296
+ max_field_size = raw_field_limit
297
+ raw_req_limit = uploads_cfg.get("max_request_size")
298
+ if isinstance(raw_req_limit, int) and not isinstance(raw_req_limit, bool) and raw_req_limit > 0:
299
+ cfg_max_request = raw_req_limit
300
+ raw_temp_dir = uploads_cfg.get("temp_dir")
301
+ if isinstance(raw_temp_dir, str) and raw_temp_dir.strip():
302
+ temp_dir = raw_temp_dir
303
+
304
+ if self.max_body_size != DEFAULT_MAX_BODY_SIZE and self.max_body_size is not None:
305
+ max_request_size = (
306
+ min(self.max_body_size, cfg_max_request)
307
+ if cfg_max_request is not None
308
+ else self.max_body_size
309
+ )
310
+ elif cfg_max_request is not None:
311
+ max_request_size = cfg_max_request
312
+ else:
313
+ max_request_size = (
314
+ self.max_body_size if self.max_body_size is not None else default_max_req
315
+ )
316
+
317
+ return max_file_size, max_request_size, memory_limit, max_field_size, temp_dir
318
+
319
+ async def form(self) -> FormData:
320
+ if self._form is None:
321
+ content_type = self.headers.get("content-type", "")
322
+ if "multipart/form-data" in content_type.lower():
323
+ boundary = MultipartParser.extract_boundary(content_type)
324
+ (
325
+ max_file_size,
326
+ max_request_size,
327
+ memory_limit,
328
+ max_field_size,
329
+ temp_dir,
330
+ ) = self._resolve_upload_limits()
331
+ parser = MultipartParser(
332
+ boundary,
333
+ max_file_size=max_file_size,
334
+ max_request_size=max_request_size,
335
+ memory_limit=memory_limit,
336
+ max_field_size=max_field_size,
337
+ temp_dir=temp_dir,
338
+ )
339
+ if self._body is not None:
340
+ async def _body_stream(raw: bytes) -> AsyncIterator[bytes]:
341
+ chunk_size = 64 * 1024
342
+ for i in range(0, len(raw), chunk_size):
343
+ yield raw[i : i + chunk_size]
344
+
345
+ self._form = await parser.parse(_body_stream(self._body))
346
+ else:
347
+ self._form = await parser.parse(self._receive)
348
+ else:
349
+ raw_bytes = await self.body()
350
+ form_data = FormData()
351
+ if raw_bytes:
352
+ decoded = raw_bytes.decode("utf-8", errors="replace")
353
+ parsed = parse_qs(decoded, keep_blank_values=True)
354
+ for k, vals in parsed.items():
355
+ for v in vals:
356
+ form_data.add_field(k, v)
357
+ self._form = form_data
358
+ return self._form
359
+
360
+ async def file(self, name: str) -> UploadedFile | None:
361
+ form_data = await self.form()
362
+ return form_data.get_file(name)
363
+
364
+ async def files(
365
+ self, name: str | None = None
366
+ ) -> list[UploadedFile] | dict[str, list[UploadedFile]]:
367
+ form_data = await self.form()
368
+ if name is not None:
369
+ return form_data.get_files(name)
370
+ return dict(form_data.files_list)
371
+
372
+ async def close(self) -> None:
373
+ if self._form is not None:
374
+ await self._form.close()
375
+ self._form = None