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
swiftpy/http/router.py ADDED
@@ -0,0 +1,1050 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import math
5
+ import re
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Literal, TypeVar, get_args, get_origin, get_type_hints
9
+
10
+ from swiftpy.core.config import ConfigurationError
11
+ from swiftpy.core.container import Container
12
+ from swiftpy.core.exceptions import MethodNotAllowedError, ValidationError
13
+ from swiftpy.filesystem.uploads.uploaded_file import UploadedFile
14
+ from swiftpy.http.request import Request
15
+ from swiftpy.http.response import Response
16
+ from swiftpy.validation.form_request import FormRequest
17
+ from swiftpy.websockets.connection import WebSocket, WebSocketConnection
18
+
19
+ RouteHandler = Any
20
+ F = TypeVar("F", bound=Callable[..., Any])
21
+
22
+ _PARAM_TOKEN_RE = re.compile(r"\{([^{}]*)\}")
23
+
24
+ _BUILTIN_TYPES: dict[str, type[Any]] = {
25
+ "int": int,
26
+ "str": str,
27
+ "float": float,
28
+ "bool": bool,
29
+ "bytes": bytes,
30
+ "dict": dict,
31
+ "list": list,
32
+ "set": set,
33
+ "tuple": tuple,
34
+ "Any": Any,
35
+ }
36
+
37
+ ParamKind = Literal[
38
+ "request", "path", "form", "di", "default", "websocket", "file", "files"
39
+ ]
40
+
41
+
42
+ class RouteRegistrationError(ConfigurationError, TypeError):
43
+ """Raised at route registration or compilation time when a route or handler is invalid."""
44
+
45
+
46
+ class PathParamCoercionError(ValidationError):
47
+ """Raised when a URL path parameter cannot be coerced to its declared type."""
48
+
49
+ def __init__(self, param: str, value: str, expected_type: type[Any]) -> None:
50
+ self.param = param
51
+ self.value = value
52
+ self.expected_type = expected_type
53
+ msg = (
54
+ f"Path parameter '{param}' with value '{value}' could not be "
55
+ f"converted to {expected_type.__name__}"
56
+ )
57
+ super().__init__(errors={param: [msg]}, message=msg)
58
+
59
+
60
+ def _coerce_bool(value: str) -> bool:
61
+ norm = value.strip().lower()
62
+ if norm in ("true", "1", "yes"):
63
+ return True
64
+ if norm in ("false", "0", "no"):
65
+ return False
66
+ raise ValueError(f"Invalid boolean value: {value!r}")
67
+
68
+
69
+ def _coerce_float(value: str) -> float:
70
+ parsed = float(value)
71
+ if not math.isfinite(parsed):
72
+ raise ValueError(f"Non-finite float value: {value!r}")
73
+ return parsed
74
+
75
+
76
+ def _collect_caller_locals(handler: Callable[..., Any]) -> dict[str, Any]:
77
+ localns: dict[str, Any] = {}
78
+ target_code = getattr(handler, "__code__", None)
79
+ target_file = getattr(target_code, "co_filename", None)
80
+ frame = inspect.currentframe()
81
+ try:
82
+ curr = frame.f_back if frame is not None else None
83
+ depth = 0
84
+ while curr is not None and depth < 8:
85
+ if target_file is None or curr.f_code.co_filename == target_file:
86
+ for k, v in curr.f_locals.items():
87
+ if k not in localns:
88
+ localns[k] = v
89
+ curr = curr.f_back
90
+ depth += 1
91
+ finally:
92
+ del frame
93
+ return localns
94
+
95
+
96
+ def _resolve_handler_type_hints(
97
+ handler: Callable[..., Any],
98
+ sig: inspect.Signature,
99
+ extra_locals: dict[str, Any] | None = None,
100
+ ) -> dict[str, Any]:
101
+ merged_locals: dict[str, Any] = dict(_BUILTIN_TYPES)
102
+ if extra_locals:
103
+ merged_locals.update(extra_locals)
104
+ try:
105
+ closure_vars = inspect.getclosurevars(handler).nonlocals
106
+ merged_locals.update(closure_vars)
107
+ except (TypeError, ValueError):
108
+ pass
109
+
110
+ globalns: dict[str, Any] = getattr(handler, "__globals__", {})
111
+
112
+ try:
113
+ hints = get_type_hints(handler, globalns=globalns, localns=merged_locals)
114
+ except (NameError, TypeError, ValueError):
115
+ hints = {}
116
+
117
+ resolved: dict[str, Any] = {}
118
+ for name, param in sig.parameters.items():
119
+ ann = hints.get(name, param.annotation)
120
+ if isinstance(ann, str):
121
+ if ann in merged_locals:
122
+ ann = merged_locals[ann]
123
+ elif ann in globalns:
124
+ ann = globalns[ann]
125
+ resolved[name] = ann
126
+ return resolved
127
+
128
+
129
+ @dataclass(slots=True, frozen=True)
130
+ class ParamSpec:
131
+ name: str
132
+ kind: ParamKind
133
+ annotation: Any
134
+ default: Any = inspect.Parameter.empty
135
+
136
+
137
+ @dataclass(slots=True)
138
+ class Route:
139
+ method: str
140
+ path: str
141
+ handler: RouteHandler
142
+ middleware: list[type[Any]]
143
+ is_websocket: bool = False
144
+ order: int = 0
145
+ max_body_size: int | None = None
146
+ max_content_length: int | None = None
147
+ max_json_depth: int | None = None
148
+ localns: dict[str, Any] | None = field(default=None, repr=False)
149
+ pattern: re.Pattern[str] = field(init=False)
150
+ param_names: tuple[str, ...] = field(init=False)
151
+ normalized_path: str = field(init=False)
152
+ precedence_key: tuple[int, tuple[int, ...], int, int] = field(init=False)
153
+ signature: inspect.Signature = field(init=False, repr=False)
154
+ type_hints: dict[str, Any] = field(init=False, repr=False)
155
+ param_coercers: dict[str, tuple[type[Any], Callable[[str], Any]]] = field(
156
+ init=False, repr=False
157
+ )
158
+ param_specs: tuple[ParamSpec, ...] = field(default=(), init=False, repr=False)
159
+ has_var_keyword: bool = field(default=False, init=False, repr=False)
160
+ compiled_handler: RouteHandler | None = field(default=None, init=False, repr=False)
161
+ analysis_deferred: bool = field(default=False, init=False, repr=False)
162
+
163
+ def __post_init__(self) -> None:
164
+ self.method = self.method.upper()
165
+ if not self.path.startswith("/"):
166
+ raise RouteRegistrationError(
167
+ f"Route path must start with '/': {self.path!r}"
168
+ )
169
+
170
+ regex_parts: list[str] = []
171
+ normalized_parts: list[str] = []
172
+ param_names: list[str] = []
173
+ last_idx = 0
174
+
175
+ for match in _PARAM_TOKEN_RE.finditer(self.path):
176
+ literal = self.path[last_idx : match.start()]
177
+ if "{" in literal or "}" in literal:
178
+ raise RouteRegistrationError(
179
+ f"Malformed route path '{self.path}': unmatched brace."
180
+ )
181
+ regex_parts.append(re.escape(literal))
182
+ normalized_parts.append(literal)
183
+
184
+ param_name = match.group(1)
185
+ if not param_name or not param_name.isidentifier():
186
+ raise RouteRegistrationError(
187
+ f"Invalid path parameter name '{param_name}' in route '{self.path}'."
188
+ )
189
+ if param_name in param_names:
190
+ raise RouteRegistrationError(
191
+ f"Duplicate path parameter '{param_name}' in route '{self.path}'."
192
+ )
193
+ param_names.append(param_name)
194
+ regex_parts.append(f"(?P<{param_name}>[^/]+)")
195
+ normalized_parts.append("{}")
196
+ last_idx = match.end()
197
+
198
+ tail = self.path[last_idx:]
199
+ if "{" in tail or "}" in tail:
200
+ raise RouteRegistrationError(
201
+ f"Malformed route path '{self.path}': unmatched brace."
202
+ )
203
+ regex_parts.append(re.escape(tail))
204
+ normalized_parts.append(tail)
205
+
206
+ self.pattern = re.compile(f"^{''.join(regex_parts)}$")
207
+ self.param_names = tuple(param_names)
208
+ self.normalized_path = "".join(normalized_parts)
209
+
210
+ segments = [seg for seg in self.path.split("/") if seg]
211
+ segment_kinds = tuple(
212
+ 1 if _PARAM_TOKEN_RE.search(seg) else 0 for seg in segments
213
+ )
214
+ num_dynamic = sum(segment_kinds)
215
+ static_chars = sum(len(_PARAM_TOKEN_RE.sub("", seg)) for seg in segments)
216
+ self.precedence_key = (
217
+ num_dynamic,
218
+ segment_kinds,
219
+ -static_chars,
220
+ self.order,
221
+ )
222
+
223
+ self.signature = inspect.signature(self.handler)
224
+ self.type_hints = _resolve_handler_type_hints(
225
+ self.handler,
226
+ self.signature,
227
+ self.localns,
228
+ )
229
+ self.has_var_keyword = any(
230
+ p.kind == inspect.Parameter.VAR_KEYWORD
231
+ for p in self.signature.parameters.values()
232
+ )
233
+
234
+ coercers: dict[str, tuple[type[Any], Callable[[str], Any]]] = {}
235
+ for p_name in self.param_names:
236
+ ann = self.type_hints.get(p_name, inspect.Parameter.empty)
237
+ if ann is bool:
238
+ coercers[p_name] = (bool, _coerce_bool)
239
+ elif ann is int:
240
+ coercers[p_name] = (int, int)
241
+ elif ann is float:
242
+ coercers[p_name] = (float, _coerce_float)
243
+ elif ann is str or ann is inspect.Parameter.empty or ann is Any:
244
+ coercers[p_name] = (str, str)
245
+ elif isinstance(ann, type) and callable(ann):
246
+ coercers[p_name] = (ann, ann)
247
+ else:
248
+ coercers[p_name] = (str, str)
249
+ self.param_coercers = coercers
250
+
251
+
252
+ def _normalize_route_prefix(prefix: str) -> str:
253
+ """Validate and normalize a router/group prefix (empty string for root `'/'`)."""
254
+ clean_prefix = prefix.strip()
255
+ if not clean_prefix or clean_prefix == "/":
256
+ return ""
257
+ if any(ch.isspace() for ch in clean_prefix):
258
+ raise RouteRegistrationError(
259
+ f"Router prefix must not contain whitespace: {prefix!r}"
260
+ )
261
+ if any(token in clean_prefix for token in ("{", "}")):
262
+ raise RouteRegistrationError(
263
+ f"Router prefix must be a literal path, not a dynamic pattern: {prefix!r}"
264
+ )
265
+ if "//" in clean_prefix:
266
+ raise RouteRegistrationError(
267
+ f"Router prefix must not contain empty path segments: {prefix!r}"
268
+ )
269
+ stripped = clean_prefix.strip("/")
270
+ return f"/{stripped}" if stripped else ""
271
+
272
+
273
+ def _join_route_path(prefix: str, child_path: str) -> str:
274
+ """Join a normalized prefix with a child route path while preserving trailing slashes."""
275
+ clean_prefix = prefix.strip("/")
276
+ base = f"/{clean_prefix}" if clean_prefix else ""
277
+
278
+ if child_path in ("", "/"):
279
+ return base or "/"
280
+
281
+ stripped_child = child_path.strip("/")
282
+ if not stripped_child:
283
+ return base or "/"
284
+
285
+ suffix = f"{stripped_child}/" if child_path.endswith("/") else stripped_child
286
+ return f"{base}/{suffix}"
287
+
288
+
289
+ class RouteGroup:
290
+ def __init__(
291
+ self,
292
+ prefix: str,
293
+ router: Router,
294
+ middleware: list[type[Any]] | None = None,
295
+ ) -> None:
296
+ normalized = _normalize_route_prefix(prefix)
297
+ self.prefix = normalized or "/"
298
+ self._normalized_prefix = normalized
299
+ self.router = router
300
+ self.middleware = middleware or []
301
+
302
+ def _add_route(
303
+ self,
304
+ method: str,
305
+ path: str,
306
+ handler: RouteHandler,
307
+ *,
308
+ is_websocket: bool = False,
309
+ max_body_size: int | None = None,
310
+ max_content_length: int | None = None,
311
+ max_json_depth: int | None = None,
312
+ localns: dict[str, Any] | None = None,
313
+ ) -> None:
314
+ full_path = _join_route_path(self._normalized_prefix, path)
315
+ self.router.add_route(
316
+ method=method,
317
+ path=full_path,
318
+ handler=handler,
319
+ middleware=list(self.middleware),
320
+ is_websocket=is_websocket,
321
+ max_body_size=max_body_size,
322
+ max_content_length=max_content_length,
323
+ max_json_depth=max_json_depth,
324
+ localns=localns,
325
+ )
326
+
327
+ def get(
328
+ self,
329
+ path: str,
330
+ *,
331
+ max_body_size: int | None = None,
332
+ max_content_length: int | None = None,
333
+ max_json_depth: int | None = None,
334
+ ) -> Callable[[F], F]:
335
+ def decorator(func: F) -> F:
336
+ self._add_route(
337
+ "GET",
338
+ path,
339
+ func,
340
+ max_body_size=max_body_size,
341
+ max_content_length=max_content_length,
342
+ max_json_depth=max_json_depth,
343
+ localns=_collect_caller_locals(func),
344
+ )
345
+ return func
346
+
347
+ return decorator
348
+
349
+ def post(
350
+ self,
351
+ path: str,
352
+ *,
353
+ max_body_size: int | None = None,
354
+ max_content_length: int | None = None,
355
+ max_json_depth: int | None = None,
356
+ ) -> Callable[[F], F]:
357
+ def decorator(func: F) -> F:
358
+ self._add_route(
359
+ "POST",
360
+ path,
361
+ func,
362
+ max_body_size=max_body_size,
363
+ max_content_length=max_content_length,
364
+ max_json_depth=max_json_depth,
365
+ localns=_collect_caller_locals(func),
366
+ )
367
+ return func
368
+
369
+ return decorator
370
+
371
+ def put(
372
+ self,
373
+ path: str,
374
+ *,
375
+ max_body_size: int | None = None,
376
+ max_content_length: int | None = None,
377
+ max_json_depth: int | None = None,
378
+ ) -> Callable[[F], F]:
379
+ def decorator(func: F) -> F:
380
+ self._add_route(
381
+ "PUT",
382
+ path,
383
+ func,
384
+ max_body_size=max_body_size,
385
+ max_content_length=max_content_length,
386
+ max_json_depth=max_json_depth,
387
+ localns=_collect_caller_locals(func),
388
+ )
389
+ return func
390
+
391
+ return decorator
392
+
393
+ def patch(
394
+ self,
395
+ path: str,
396
+ *,
397
+ max_body_size: int | None = None,
398
+ max_content_length: int | None = None,
399
+ max_json_depth: int | None = None,
400
+ ) -> Callable[[F], F]:
401
+ def decorator(func: F) -> F:
402
+ self._add_route(
403
+ "PATCH",
404
+ path,
405
+ func,
406
+ max_body_size=max_body_size,
407
+ max_content_length=max_content_length,
408
+ max_json_depth=max_json_depth,
409
+ localns=_collect_caller_locals(func),
410
+ )
411
+ return func
412
+
413
+ return decorator
414
+
415
+ def delete(
416
+ self,
417
+ path: str,
418
+ *,
419
+ max_body_size: int | None = None,
420
+ max_content_length: int | None = None,
421
+ max_json_depth: int | None = None,
422
+ ) -> Callable[[F], F]:
423
+ def decorator(func: F) -> F:
424
+ self._add_route(
425
+ "DELETE",
426
+ path,
427
+ func,
428
+ max_body_size=max_body_size,
429
+ max_content_length=max_content_length,
430
+ max_json_depth=max_json_depth,
431
+ localns=_collect_caller_locals(func),
432
+ )
433
+ return func
434
+
435
+ return decorator
436
+
437
+ def head(
438
+ self,
439
+ path: str,
440
+ *,
441
+ max_body_size: int | None = None,
442
+ max_content_length: int | None = None,
443
+ max_json_depth: int | None = None,
444
+ ) -> Callable[[F], F]:
445
+ def decorator(func: F) -> F:
446
+ self._add_route(
447
+ "HEAD",
448
+ path,
449
+ func,
450
+ max_body_size=max_body_size,
451
+ max_content_length=max_content_length,
452
+ max_json_depth=max_json_depth,
453
+ localns=_collect_caller_locals(func),
454
+ )
455
+ return func
456
+
457
+ return decorator
458
+
459
+ def options(
460
+ self,
461
+ path: str,
462
+ *,
463
+ max_body_size: int | None = None,
464
+ max_content_length: int | None = None,
465
+ max_json_depth: int | None = None,
466
+ ) -> Callable[[F], F]:
467
+ def decorator(func: F) -> F:
468
+ self._add_route(
469
+ "OPTIONS",
470
+ path,
471
+ func,
472
+ max_body_size=max_body_size,
473
+ max_content_length=max_content_length,
474
+ max_json_depth=max_json_depth,
475
+ localns=_collect_caller_locals(func),
476
+ )
477
+ return func
478
+
479
+ return decorator
480
+
481
+ def websocket(self, path: str) -> Callable[[F], F]:
482
+ def decorator(func: F) -> F:
483
+ self._add_route(
484
+ "WEBSOCKET",
485
+ path,
486
+ func,
487
+ is_websocket=True,
488
+ localns=_collect_caller_locals(func),
489
+ )
490
+ return func
491
+
492
+ return decorator
493
+
494
+
495
+ class Router:
496
+ """
497
+ HTTP and WebSocket router with static-first precedence, precomputed handler
498
+ signatures, and strict path parameter coercion.
499
+ Trailing-slash policy: redirect-free exact match (no silent normalization).
500
+ """
501
+
502
+ def __init__(self, container: Container | None = None) -> None:
503
+ self.container = container
504
+ self.routes: list[Route] = []
505
+ self._http_routes: list[Route] = []
506
+ self._routes_by_method: dict[str, list[Route]] = {}
507
+ self._websocket_routes: list[Route] = []
508
+ self._static_routes: dict[str, dict[str, Route]] = {}
509
+ self._static_websocket_routes: dict[str, Route] = {}
510
+ self._dynamic_routes_by_method: dict[str, list[Route]] = {}
511
+ self._dynamic_websocket_routes: list[Route] = []
512
+ self._registered_keys: set[tuple[str, str]] = set()
513
+ self._counter = 0
514
+
515
+ def _can_resolve_di(
516
+ self,
517
+ annotation: Any,
518
+ container: Container | None = None,
519
+ ) -> bool:
520
+ target_container = container if container is not None else self.container
521
+ if target_container is None:
522
+ return False
523
+ return target_container.can_resolve(annotation)
524
+
525
+ def _analyze_handler(
526
+ self,
527
+ route: Route,
528
+ *,
529
+ container: Container | None = None,
530
+ ) -> tuple[ParamSpec, ...]:
531
+ specs: list[ParamSpec] = []
532
+ ws_assigned = False
533
+
534
+ for name, param in route.signature.parameters.items():
535
+ if param.kind in (
536
+ inspect.Parameter.VAR_POSITIONAL,
537
+ inspect.Parameter.VAR_KEYWORD,
538
+ ):
539
+ continue
540
+
541
+ ann = route.type_hints.get(name, param.annotation)
542
+
543
+ if route.is_websocket:
544
+ is_ws_type = (
545
+ ann is WebSocketConnection
546
+ or ann is WebSocket
547
+ or (isinstance(ann, type) and issubclass(ann, WebSocketConnection))
548
+ )
549
+ if is_ws_type or (
550
+ not ws_assigned
551
+ and name in ("websocket", "ws", "conn", "connection")
552
+ and (ann is inspect.Parameter.empty or ann is Any)
553
+ ):
554
+ ws_assigned = True
555
+ specs.append(
556
+ ParamSpec(name=name, kind="websocket", annotation=ann)
557
+ )
558
+ elif name in route.param_names:
559
+ specs.append(ParamSpec(name=name, kind="path", annotation=ann))
560
+ elif self._can_resolve_di(ann, container=container):
561
+ specs.append(ParamSpec(name=name, kind="di", annotation=ann))
562
+ elif param.default is not inspect.Parameter.empty:
563
+ specs.append(
564
+ ParamSpec(
565
+ name=name,
566
+ kind="default",
567
+ annotation=ann,
568
+ default=param.default,
569
+ )
570
+ )
571
+ else:
572
+ raise RouteRegistrationError(
573
+ f"WebSocket handler '{route.handler.__qualname__}' has "
574
+ f"unsatisfiable parameter '{name}' (annotation: {ann!r})."
575
+ )
576
+ continue
577
+
578
+ if isinstance(ann, type) and issubclass(ann, Request):
579
+ specs.append(ParamSpec(name=name, kind="request", annotation=ann))
580
+ elif isinstance(ann, type) and issubclass(ann, UploadedFile):
581
+ specs.append(ParamSpec(name=name, kind="file", annotation=ann))
582
+ elif (
583
+ get_origin(ann) is list
584
+ and get_args(ann)
585
+ and isinstance(get_args(ann)[0], type)
586
+ and issubclass(get_args(ann)[0], UploadedFile)
587
+ ):
588
+ specs.append(ParamSpec(name=name, kind="files", annotation=ann))
589
+ elif name in route.param_names:
590
+ specs.append(ParamSpec(name=name, kind="path", annotation=ann))
591
+ elif isinstance(ann, type) and issubclass(ann, FormRequest):
592
+ specs.append(ParamSpec(name=name, kind="form", annotation=ann))
593
+ elif self._can_resolve_di(ann, container=container):
594
+ specs.append(ParamSpec(name=name, kind="di", annotation=ann))
595
+ elif param.default is not inspect.Parameter.empty:
596
+ specs.append(
597
+ ParamSpec(
598
+ name=name,
599
+ kind="default",
600
+ annotation=ann,
601
+ default=param.default,
602
+ )
603
+ )
604
+ else:
605
+ raise RouteRegistrationError(
606
+ f"Handler '{route.handler.__qualname__}' has unsatisfiable "
607
+ f"parameter '{name}' (annotation: {ann!r}). Route handler "
608
+ "parameters must be path parameters, Request, FormRequest "
609
+ "subclasses, or container-resolvable types."
610
+ )
611
+
612
+ return tuple(specs)
613
+
614
+ def ensure_route_analyzed(
615
+ self,
616
+ route: Route,
617
+ *,
618
+ container: Container | None = None,
619
+ ) -> tuple[ParamSpec, ...]:
620
+ """Resolve deferred handler parameter specs once a DI container is available."""
621
+ if route.analysis_deferred:
622
+ target = container or self.container or Container()
623
+ route.param_specs = self._analyze_handler(route, container=target)
624
+ route.analysis_deferred = False
625
+ return route.param_specs
626
+
627
+ def add_compiled_route(self, route: Route) -> Route:
628
+ """Register an already-compiled Route object into all routing indexes."""
629
+ key = (route.method, route.normalized_path)
630
+ self._registered_keys.add(key)
631
+ if route not in self.routes:
632
+ self.routes.append(route)
633
+
634
+ is_static = not bool(route.param_names)
635
+ if route.is_websocket:
636
+ if route not in self._websocket_routes:
637
+ self._websocket_routes.append(route)
638
+ self._websocket_routes.sort(key=lambda r: r.precedence_key)
639
+ if is_static:
640
+ self._static_websocket_routes[route.path] = route
641
+ else:
642
+ if route not in self._dynamic_websocket_routes:
643
+ self._dynamic_websocket_routes.append(route)
644
+ self._dynamic_websocket_routes.sort(key=lambda r: r.precedence_key)
645
+ else:
646
+ if route not in self._http_routes:
647
+ self._http_routes.append(route)
648
+ self._http_routes.sort(key=lambda r: r.precedence_key)
649
+ method_list = self._routes_by_method.setdefault(route.method, [])
650
+ if route not in method_list:
651
+ method_list.append(route)
652
+ method_list.sort(key=lambda r: r.precedence_key)
653
+ if is_static:
654
+ self._static_routes.setdefault(route.method, {})[route.path] = route
655
+ else:
656
+ dyn_list = self._dynamic_routes_by_method.setdefault(route.method, [])
657
+ if route not in dyn_list:
658
+ dyn_list.append(route)
659
+ dyn_list.sort(key=lambda r: r.precedence_key)
660
+
661
+ return route
662
+
663
+ def add_route(
664
+ self,
665
+ method: str,
666
+ path: str,
667
+ handler: RouteHandler,
668
+ middleware: list[type[Any]] | None = None,
669
+ *,
670
+ is_websocket: bool = False,
671
+ max_body_size: int | None = None,
672
+ max_content_length: int | None = None,
673
+ max_json_depth: int | None = None,
674
+ localns: dict[str, Any] | None = None,
675
+ ) -> Route:
676
+ norm_method = method.upper().strip()
677
+ if not norm_method:
678
+ raise RouteRegistrationError("HTTP method must not be empty.")
679
+ if norm_method == "WEBSOCKET" and not is_websocket:
680
+ raise RouteRegistrationError(
681
+ "Method 'WEBSOCKET' is internal-only; use websocket() to register WebSocket routes."
682
+ )
683
+
684
+ effective_locals = (
685
+ localns if localns is not None else _collect_caller_locals(handler)
686
+ )
687
+ route = Route(
688
+ method="WEBSOCKET" if is_websocket else norm_method,
689
+ path=path,
690
+ handler=handler,
691
+ middleware=middleware or [],
692
+ is_websocket=is_websocket,
693
+ order=self._counter,
694
+ max_body_size=max_body_size,
695
+ max_content_length=max_content_length,
696
+ max_json_depth=max_json_depth,
697
+ localns=effective_locals,
698
+ )
699
+
700
+ key = (route.method, route.normalized_path)
701
+ if key in self._registered_keys:
702
+ raise RouteRegistrationError(
703
+ f"Duplicate route registered for {route.method} {path}"
704
+ )
705
+
706
+ if self.container is None:
707
+ # Standalone routers may declare app-bound DI dependencies before
708
+ # mounting onto an Application; defer strict DI checks until include_router().
709
+ route.analysis_deferred = True
710
+ else:
711
+ route.param_specs = self._analyze_handler(route)
712
+ self._counter += 1
713
+ return self.add_compiled_route(route)
714
+
715
+ def route(
716
+ self,
717
+ method: str,
718
+ path: str,
719
+ *,
720
+ middleware: list[type[Any]] | None = None,
721
+ is_websocket: bool = False,
722
+ max_body_size: int | None = None,
723
+ max_content_length: int | None = None,
724
+ max_json_depth: int | None = None,
725
+ ) -> Callable[[F], F]:
726
+ if method.upper().strip() == "WEBSOCKET" and not is_websocket:
727
+ raise RouteRegistrationError(
728
+ "Method 'WEBSOCKET' is internal-only; use websocket() to register WebSocket routes."
729
+ )
730
+
731
+ def decorator(func: F) -> F:
732
+ self.add_route(
733
+ method,
734
+ path,
735
+ func,
736
+ middleware=middleware,
737
+ is_websocket=is_websocket,
738
+ max_body_size=max_body_size,
739
+ max_content_length=max_content_length,
740
+ max_json_depth=max_json_depth,
741
+ localns=_collect_caller_locals(func),
742
+ )
743
+ return func
744
+
745
+ return decorator
746
+
747
+ def get(
748
+ self,
749
+ path: str,
750
+ *,
751
+ middleware: list[type[Any]] | None = None,
752
+ max_body_size: int | None = None,
753
+ max_content_length: int | None = None,
754
+ max_json_depth: int | None = None,
755
+ ) -> Callable[[F], F]:
756
+ return self.route(
757
+ "GET",
758
+ path,
759
+ middleware=middleware,
760
+ max_body_size=max_body_size,
761
+ max_content_length=max_content_length,
762
+ max_json_depth=max_json_depth,
763
+ )
764
+
765
+ def post(
766
+ self,
767
+ path: str,
768
+ *,
769
+ middleware: list[type[Any]] | None = None,
770
+ max_body_size: int | None = None,
771
+ max_content_length: int | None = None,
772
+ max_json_depth: int | None = None,
773
+ ) -> Callable[[F], F]:
774
+ return self.route(
775
+ "POST",
776
+ path,
777
+ middleware=middleware,
778
+ max_body_size=max_body_size,
779
+ max_content_length=max_content_length,
780
+ max_json_depth=max_json_depth,
781
+ )
782
+
783
+ def put(
784
+ self,
785
+ path: str,
786
+ *,
787
+ middleware: list[type[Any]] | None = None,
788
+ max_body_size: int | None = None,
789
+ max_content_length: int | None = None,
790
+ max_json_depth: int | None = None,
791
+ ) -> Callable[[F], F]:
792
+ return self.route(
793
+ "PUT",
794
+ path,
795
+ middleware=middleware,
796
+ max_body_size=max_body_size,
797
+ max_content_length=max_content_length,
798
+ max_json_depth=max_json_depth,
799
+ )
800
+
801
+ def patch(
802
+ self,
803
+ path: str,
804
+ *,
805
+ middleware: list[type[Any]] | None = None,
806
+ max_body_size: int | None = None,
807
+ max_content_length: int | None = None,
808
+ max_json_depth: int | None = None,
809
+ ) -> Callable[[F], F]:
810
+ return self.route(
811
+ "PATCH",
812
+ path,
813
+ middleware=middleware,
814
+ max_body_size=max_body_size,
815
+ max_content_length=max_content_length,
816
+ max_json_depth=max_json_depth,
817
+ )
818
+
819
+ def delete(
820
+ self,
821
+ path: str,
822
+ *,
823
+ middleware: list[type[Any]] | None = None,
824
+ max_body_size: int | None = None,
825
+ max_content_length: int | None = None,
826
+ max_json_depth: int | None = None,
827
+ ) -> Callable[[F], F]:
828
+ return self.route(
829
+ "DELETE",
830
+ path,
831
+ middleware=middleware,
832
+ max_body_size=max_body_size,
833
+ max_content_length=max_content_length,
834
+ max_json_depth=max_json_depth,
835
+ )
836
+
837
+ def head(
838
+ self,
839
+ path: str,
840
+ *,
841
+ middleware: list[type[Any]] | None = None,
842
+ max_body_size: int | None = None,
843
+ max_content_length: int | None = None,
844
+ max_json_depth: int | None = None,
845
+ ) -> Callable[[F], F]:
846
+ return self.route(
847
+ "HEAD",
848
+ path,
849
+ middleware=middleware,
850
+ max_body_size=max_body_size,
851
+ max_content_length=max_content_length,
852
+ max_json_depth=max_json_depth,
853
+ )
854
+
855
+ def options(
856
+ self,
857
+ path: str,
858
+ *,
859
+ middleware: list[type[Any]] | None = None,
860
+ max_body_size: int | None = None,
861
+ max_content_length: int | None = None,
862
+ max_json_depth: int | None = None,
863
+ ) -> Callable[[F], F]:
864
+ return self.route(
865
+ "OPTIONS",
866
+ path,
867
+ middleware=middleware,
868
+ max_body_size=max_body_size,
869
+ max_content_length=max_content_length,
870
+ max_json_depth=max_json_depth,
871
+ )
872
+
873
+ def websocket(
874
+ self,
875
+ path: str,
876
+ *,
877
+ middleware: list[type[Any]] | None = None,
878
+ ) -> Callable[[F], F]:
879
+ return self.route(
880
+ "WEBSOCKET",
881
+ path,
882
+ middleware=middleware,
883
+ is_websocket=True,
884
+ )
885
+
886
+ @staticmethod
887
+ def _coerce_matched_params(
888
+ route: Route, raw_params: dict[str, str]
889
+ ) -> dict[str, Any]:
890
+ coerced: dict[str, Any] = {}
891
+ for name, raw_val in raw_params.items():
892
+ coercer_entry = route.param_coercers.get(name)
893
+ if coercer_entry is None:
894
+ coerced[name] = raw_val
895
+ continue
896
+ expected_type, coercer = coercer_entry
897
+ try:
898
+ coerced[name] = coercer(raw_val)
899
+ except (ValueError, TypeError):
900
+ raise PathParamCoercionError(name, raw_val, expected_type) from None
901
+ return coerced
902
+
903
+ def match_websocket(
904
+ self,
905
+ path: str,
906
+ ) -> tuple[Route | None, dict[str, Any]]:
907
+ static_route = self._static_websocket_routes.get(path)
908
+ if static_route is not None:
909
+ return static_route, {}
910
+
911
+ for route in self._dynamic_websocket_routes:
912
+ m = route.pattern.match(path)
913
+ if m is not None:
914
+ return route, self._coerce_matched_params(route, m.groupdict())
915
+ return None, {}
916
+
917
+ def match(
918
+ self,
919
+ method: str,
920
+ path: str,
921
+ ) -> tuple[
922
+ Route | None,
923
+ dict[str, Any],
924
+ ]:
925
+ norm_method = method.upper()
926
+
927
+ # 1. Never match WEBSOCKET routes over HTTP
928
+ if norm_method != "WEBSOCKET":
929
+ # Fast path: O(1) exact static lookup
930
+ static_candidates = self._static_routes.get(norm_method)
931
+ if static_candidates is not None and path in static_candidates:
932
+ return static_candidates[path], {}
933
+
934
+ dynamic_candidates = self._dynamic_routes_by_method.get(norm_method, ())
935
+ for route in dynamic_candidates:
936
+ m = route.pattern.match(path)
937
+ if m is not None:
938
+ return route, self._coerce_matched_params(route, m.groupdict())
939
+
940
+ # 2. Automatic HEAD fallback to GET
941
+ if norm_method == "HEAD":
942
+ static_get = self._static_routes.get("GET")
943
+ if static_get is not None and path in static_get:
944
+ return static_get[path], {}
945
+ get_candidates = self._dynamic_routes_by_method.get("GET", ())
946
+ for route in get_candidates:
947
+ m = route.pattern.match(path)
948
+ if m is not None:
949
+ return route, self._coerce_matched_params(route, m.groupdict())
950
+
951
+ # 3. Check if any HTTP methods match this path (for automatic OPTIONS or 405)
952
+ matched_methods: set[str] = set()
953
+ matched_routes: list[Route] = []
954
+ for meth, static_map in self._static_routes.items():
955
+ if meth != "WEBSOCKET" and path in static_map:
956
+ matched_methods.add(meth)
957
+ matched_routes.append(static_map[path])
958
+
959
+ for route in self._http_routes:
960
+ if route.param_names and route.pattern.match(path) is not None:
961
+ matched_methods.add(route.method)
962
+ matched_routes.append(route)
963
+
964
+ if matched_methods:
965
+ if "GET" in matched_methods:
966
+ matched_methods.add("HEAD")
967
+
968
+ if norm_method == "OPTIONS":
969
+ options_methods = sorted(matched_methods | {"OPTIONS"})
970
+ allow_header = ", ".join(options_methods)
971
+ inherited_middleware = list(
972
+ dict.fromkeys(
973
+ mw for matched_route in matched_routes for mw in matched_route.middleware
974
+ )
975
+ )
976
+
977
+ async def _options_handler(request: Request) -> Response:
978
+ return Response(
979
+ b"",
980
+ status_code=204,
981
+ headers={"Allow": allow_header},
982
+ )
983
+
984
+ synthetic_route = Route(
985
+ method="OPTIONS",
986
+ path="/",
987
+ handler=_options_handler,
988
+ middleware=inherited_middleware,
989
+ )
990
+ synthetic_route.param_specs = self._analyze_handler(
991
+ synthetic_route, container=self.container
992
+ )
993
+ return synthetic_route, {}
994
+
995
+ unique_methods = sorted(matched_methods)
996
+ allow_header = ", ".join(unique_methods)
997
+ raise MethodNotAllowedError(
998
+ f"Method '{method}' not allowed for path '{path}'. Allowed: {allow_header}",
999
+ headers={"Allow": allow_header},
1000
+ )
1001
+
1002
+ return None, {}
1003
+
1004
+ def group(
1005
+ self,
1006
+ prefix: str,
1007
+ middleware: list[type[Any]] | None = None,
1008
+ ) -> Callable[[Callable[[RouteGroup], None]], Callable[[RouteGroup], None]]:
1009
+ def decorator(
1010
+ func: Callable[[RouteGroup], None],
1011
+ ) -> Callable[[RouteGroup], None]:
1012
+ group = RouteGroup(
1013
+ router=self,
1014
+ prefix=prefix,
1015
+ middleware=middleware,
1016
+ )
1017
+ func(group)
1018
+ return func
1019
+
1020
+ return decorator
1021
+
1022
+ def include_router(
1023
+ self,
1024
+ router: Router,
1025
+ prefix: str = "",
1026
+ middleware: list[type[Any]] | None = None,
1027
+ ) -> None:
1028
+ """
1029
+ Include routes from another router instance under an optional path prefix and middleware.
1030
+ """
1031
+ clean_prefix = _normalize_route_prefix(prefix)
1032
+
1033
+ parent_mw = list(middleware or [])
1034
+
1035
+ for child_route in router.routes:
1036
+ full_path = _join_route_path(clean_prefix, child_route.path)
1037
+
1038
+ combined_middleware = parent_mw + list(child_route.middleware)
1039
+
1040
+ self.add_route(
1041
+ method=child_route.method,
1042
+ path=full_path,
1043
+ handler=child_route.handler,
1044
+ middleware=combined_middleware,
1045
+ is_websocket=child_route.is_websocket,
1046
+ max_body_size=child_route.max_body_size,
1047
+ max_content_length=child_route.max_content_length,
1048
+ max_json_depth=child_route.max_json_depth,
1049
+ localns=child_route.localns,
1050
+ )