sillo-framework 0.0.1a1__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 (266) hide show
  1. sillo/__init__.py +138 -0
  2. sillo/_internals/__init__.py +0 -0
  3. sillo/_internals/_middleware.py +546 -0
  4. sillo/admin/__init__.py +232 -0
  5. sillo/admin/auth.py +167 -0
  6. sillo/admin/models.py +153 -0
  7. sillo/admin/registry.py +302 -0
  8. sillo/admin/router.py +463 -0
  9. sillo/admin/routes.py +1734 -0
  10. sillo/admin/static/logo.png +0 -0
  11. sillo/admin/templates/base.html +497 -0
  12. sillo/admin/templates/create.html +76 -0
  13. sillo/admin/templates/dashboard.html +45 -0
  14. sillo/admin/templates/delete.html +15 -0
  15. sillo/admin/templates/detail.html +61 -0
  16. sillo/admin/templates/list.html +117 -0
  17. sillo/admin/templates/login.html +54 -0
  18. sillo/admin/templates/query.html +56 -0
  19. sillo/admin/templates/update.html +85 -0
  20. sillo/admin/templating.py +44 -0
  21. sillo/application.py +2582 -0
  22. sillo/auth/__init__.py +47 -0
  23. sillo/auth/apikey/__init__.py +71 -0
  24. sillo/auth/apikey/backend.py +104 -0
  25. sillo/auth/apikey/mixins.py +132 -0
  26. sillo/auth/apikey/models.py +308 -0
  27. sillo/auth/backend.py +117 -0
  28. sillo/auth/decorator.py +277 -0
  29. sillo/auth/exceptions.py +197 -0
  30. sillo/auth/jwt_auth/__init__.py +87 -0
  31. sillo/auth/jwt_auth/backend.py +153 -0
  32. sillo/auth/jwt_auth/mixins.py +237 -0
  33. sillo/auth/jwt_auth/models.py +266 -0
  34. sillo/auth/jwt_auth/tokens.py +301 -0
  35. sillo/auth/middleware.py +158 -0
  36. sillo/auth/model.py +35 -0
  37. sillo/auth/session_auth/__init__.py +27 -0
  38. sillo/auth/session_auth/backend.py +166 -0
  39. sillo/auth/session_auth/guard.py +285 -0
  40. sillo/auth/session_auth/mixins.py +164 -0
  41. sillo/auth/session_auth/models.py +185 -0
  42. sillo/auth/use_auth.py +276 -0
  43. sillo/cache/__init__.py +53 -0
  44. sillo/cache/backends.py +835 -0
  45. sillo/cache/base.py +644 -0
  46. sillo/cache/config.py +163 -0
  47. sillo/cache/decorator.py +355 -0
  48. sillo/config/__init__.py +25 -0
  49. sillo/config/core.py +117 -0
  50. sillo/core/__init__.py +6 -0
  51. sillo/core/converters.py +572 -0
  52. sillo/core/dependencies/__init__.py +11 -0
  53. sillo/core/dependencies/base.py +583 -0
  54. sillo/core/encoding.py +395 -0
  55. sillo/core/error/__init__.py +3 -0
  56. sillo/core/error/handler.py +1606 -0
  57. sillo/core/helpers/__init__.py +0 -0
  58. sillo/core/helpers/async_helpers.py +257 -0
  59. sillo/core/helpers/deprecation.py +208 -0
  60. sillo/core/http/__init__.py +4 -0
  61. sillo/core/http/cookies.py +53 -0
  62. sillo/core/http/request.py +1627 -0
  63. sillo/core/http/response.py +1740 -0
  64. sillo/core/routing/__init__.py +12 -0
  65. sillo/core/routing/_utils.py +66 -0
  66. sillo/core/routing/base.py +266 -0
  67. sillo/core/routing/grouping.py +263 -0
  68. sillo/core/routing/router.py +3390 -0
  69. sillo/core/routing/websocket.py +309 -0
  70. sillo/decorator_helper.py +104 -0
  71. sillo/encoding.py +31 -0
  72. sillo/events/__init__.py +35 -0
  73. sillo/events/core.py +867 -0
  74. sillo/events/emitter.py +663 -0
  75. sillo/events/enums.py +53 -0
  76. sillo/events/exceptions.py +106 -0
  77. sillo/events/mixins.py +109 -0
  78. sillo/events/transports/__init__.py +183 -0
  79. sillo/events/transports/base.py +579 -0
  80. sillo/events/transports/memory.py +77 -0
  81. sillo/events/transports/persistent.py +288 -0
  82. sillo/events/transports/record.py +231 -0
  83. sillo/events/transports/redis.py +242 -0
  84. sillo/events/types.py +141 -0
  85. sillo/exception_handler.py +407 -0
  86. sillo/exceptions.py +242 -0
  87. sillo/formparser.py +683 -0
  88. sillo/frontend.py +211 -0
  89. sillo/graphql/__init__.py +3 -0
  90. sillo/graphql/handler.py +326 -0
  91. sillo/handlers/__init__.py +0 -0
  92. sillo/handlers/not_found.py +162 -0
  93. sillo/hashing/__init__.py +74 -0
  94. sillo/hashing/config.py +74 -0
  95. sillo/hashing/core.py +259 -0
  96. sillo/hashing/exceptions.py +19 -0
  97. sillo/hashing/utils.py +166 -0
  98. sillo/helpers/__init__.py +6 -0
  99. sillo/helpers/crypto.py +250 -0
  100. sillo/helpers/files.py +412 -0
  101. sillo/helpers/hashing.py +189 -0
  102. sillo/helpers/html.py +253 -0
  103. sillo/helpers/jwt.py +434 -0
  104. sillo/helpers/network.py +294 -0
  105. sillo/helpers/retry.py +289 -0
  106. sillo/helpers/strings.py +320 -0
  107. sillo/helpers/text.py +262 -0
  108. sillo/http/__init__.py +133 -0
  109. sillo/http/accepts.py +1312 -0
  110. sillo/http/client/__init__.py +104 -0
  111. sillo/http/client/caching.py +202 -0
  112. sillo/http/client/client.py +578 -0
  113. sillo/http/client/config.py +120 -0
  114. sillo/http/client/errors.py +101 -0
  115. sillo/http/client/middleware.py +134 -0
  116. sillo/http/client/models.py +120 -0
  117. sillo/http/client/retry.py +85 -0
  118. sillo/http/client/transport.py +56 -0
  119. sillo/http/client/utils.py +85 -0
  120. sillo/http/etag.py +174 -0
  121. sillo/http/lifecycle/__init__.py +37 -0
  122. sillo/http/lifecycle/context.py +248 -0
  123. sillo/http/lifecycle/helpers.py +184 -0
  124. sillo/http/lifecycle/middleware.py +205 -0
  125. sillo/http/status.py +138 -0
  126. sillo/logging.py +187 -0
  127. sillo/mail/__init__.py +13 -0
  128. sillo/mail/client.py +404 -0
  129. sillo/mail/config.py +156 -0
  130. sillo/mail/models.py +225 -0
  131. sillo/middleware/__init__.py +5 -0
  132. sillo/middleware/base.py +168 -0
  133. sillo/middleware/gzip.py +297 -0
  134. sillo/middleware/security.py +7 -0
  135. sillo/middleware/utils.py +112 -0
  136. sillo/normalize/__init__.py +33 -0
  137. sillo/normalize/helpers.py +308 -0
  138. sillo/normalize/middleware.py +298 -0
  139. sillo/objects/__init__.py +38 -0
  140. sillo/objects/common.py +209 -0
  141. sillo/objects/datastructures.py +444 -0
  142. sillo/objects/http.py +1063 -0
  143. sillo/objects/routing.py +778 -0
  144. sillo/openapi/__init__.py +2 -0
  145. sillo/openapi/_builder.py +787 -0
  146. sillo/openapi/config.py +173 -0
  147. sillo/openapi/models.py +767 -0
  148. sillo/openapi/utils.py +47 -0
  149. sillo/pagination.py +540 -0
  150. sillo/parameters.py +39 -0
  151. sillo/permissions/__init__.py +17 -0
  152. sillo/permissions/mixins.py +225 -0
  153. sillo/permissions/models.py +726 -0
  154. sillo/py.typed +0 -0
  155. sillo/record/__init__.py +83 -0
  156. sillo/record/casting.py +247 -0
  157. sillo/record/collection.py +448 -0
  158. sillo/record/commands/__init__.py +98 -0
  159. sillo/record/config.py +156 -0
  160. sillo/record/events.py +275 -0
  161. sillo/record/exceptions.py +113 -0
  162. sillo/record/factories.py +134 -0
  163. sillo/record/fields.py +176 -0
  164. sillo/record/helpers.py +321 -0
  165. sillo/record/logging.py +207 -0
  166. sillo/record/manager.py +265 -0
  167. sillo/record/mixins/__init__.py +240 -0
  168. sillo/record/models.py +417 -0
  169. sillo/record/pagination.py +107 -0
  170. sillo/record/pydantic.py +98 -0
  171. sillo/record/queries.py +156 -0
  172. sillo/record/scopes.py +135 -0
  173. sillo/record/transactions.py +115 -0
  174. sillo/route_builder.py +252 -0
  175. sillo/security/__init__.py +50 -0
  176. sillo/security/cors/__init__.py +4 -0
  177. sillo/security/cors/_middleware.py +332 -0
  178. sillo/security/cors/config.py +245 -0
  179. sillo/security/csrf/__init__.py +4 -0
  180. sillo/security/csrf/_middleware.py +184 -0
  181. sillo/security/csrf/config.py +230 -0
  182. sillo/security/ratelimit/__init__.py +105 -0
  183. sillo/security/ratelimit/_middleware.py +147 -0
  184. sillo/security/ratelimit/backends/__init__.py +44 -0
  185. sillo/security/ratelimit/backends/base.py +65 -0
  186. sillo/security/ratelimit/backends/memory.py +85 -0
  187. sillo/security/ratelimit/backends/record.py +65 -0
  188. sillo/security/ratelimit/backends/redis.py +125 -0
  189. sillo/security/ratelimit/config.py +102 -0
  190. sillo/security/ratelimit/models.py +102 -0
  191. sillo/security/ratelimit/strategies/__init__.py +48 -0
  192. sillo/security/ratelimit/strategies/base.py +69 -0
  193. sillo/security/ratelimit/strategies/fixed_window.py +75 -0
  194. sillo/security/ratelimit/strategies/sliding_window.py +74 -0
  195. sillo/security/ratelimit/strategies/token_bucket.py +76 -0
  196. sillo/security/shield.py +323 -0
  197. sillo/session/__init__.py +4 -0
  198. sillo/session/base.py +160 -0
  199. sillo/session/config.py +201 -0
  200. sillo/session/file.py +152 -0
  201. sillo/session/middleware.py +122 -0
  202. sillo/session/session_objects.py +392 -0
  203. sillo/session/signed_cookies.py +114 -0
  204. sillo/static.py +252 -0
  205. sillo/templating/__init__.py +150 -0
  206. sillo/templating/middleware.py +105 -0
  207. sillo/templating/utils.py +124 -0
  208. sillo/testclient/__init__.py +17 -0
  209. sillo/testclient/_internal/__init__.py +0 -0
  210. sillo/testclient/_internal/inputs.py +54 -0
  211. sillo/testclient/_internal/transport.py +827 -0
  212. sillo/testclient/_internal/types.py +15 -0
  213. sillo/testclient/_internal/utils.py +78 -0
  214. sillo/testclient/_internal/websockets.py +303 -0
  215. sillo/testclient/async_client.py +461 -0
  216. sillo/testclient/base.py +639 -0
  217. sillo/testclient/exceptions.py +34 -0
  218. sillo/testclient/helpers.py +148 -0
  219. sillo/types.py +41 -0
  220. sillo/users/__init__.py +34 -0
  221. sillo/users/base.py +749 -0
  222. sillo/users/managers.py +164 -0
  223. sillo/users/simple.py +169 -0
  224. sillo/utils/__init__.py +1 -0
  225. sillo/utils/concurrency.py +41 -0
  226. sillo/validation/__init__.py +89 -0
  227. sillo/validation/compiler.py +521 -0
  228. sillo/validation/errors.py +121 -0
  229. sillo/validation/fields.py +666 -0
  230. sillo/websockets/__init__.py +26 -0
  231. sillo/websockets/base.py +390 -0
  232. sillo/websockets/channels.py +338 -0
  233. sillo/websockets/consumers.py +250 -0
  234. sillo/websockets/errors.py +80 -0
  235. sillo/websockets/history.py +146 -0
  236. sillo/websockets/status.py +66 -0
  237. sillo/websockets/utils.py +84 -0
  238. sillo/work/__init__.py +67 -0
  239. sillo/work/backends.py +586 -0
  240. sillo/work/background/__init__.py +11 -0
  241. sillo/work/background/supervisor.py +161 -0
  242. sillo/work/background/tasks.py +258 -0
  243. sillo/work/dependency.py +59 -0
  244. sillo/work/middleware.py +291 -0
  245. sillo/work/queue/__init__.py +51 -0
  246. sillo/work/queue/batches.py +219 -0
  247. sillo/work/queue/connection.py +412 -0
  248. sillo/work/queue/events.py +236 -0
  249. sillo/work/queue/failed.py +183 -0
  250. sillo/work/queue/job.py +334 -0
  251. sillo/work/queue/listener.py +215 -0
  252. sillo/work/queue/middleware.py +229 -0
  253. sillo/work/queue/payloads.py +95 -0
  254. sillo/work/queue/workers.py +333 -0
  255. sillo/work/scheduler/__init__.py +34 -0
  256. sillo/work/scheduler/cron.py +125 -0
  257. sillo/work/scheduler/jobs.py +167 -0
  258. sillo/work/scheduler/manager.py +306 -0
  259. sillo/work/scheduler/middleware.py +117 -0
  260. sillo/work/scheduler/triggers.py +170 -0
  261. sillo/work/task.py +670 -0
  262. sillo/work/types.py +408 -0
  263. sillo_framework-0.0.1a1.dist-info/METADATA +293 -0
  264. sillo_framework-0.0.1a1.dist-info/RECORD +266 -0
  265. sillo_framework-0.0.1a1.dist-info/WHEEL +4 -0
  266. sillo_framework-0.0.1a1.dist-info/licenses/LICENSE +27 -0
sillo/__init__.py ADDED
@@ -0,0 +1,138 @@
1
+ """
2
+ sillo — The Platform for Python Backends
3
+
4
+ A modern ASGI web framework. Async-native. Zero boilerplate validation.
5
+ Built-in DI with pre-flattened execution plans. Everything you need — routing,
6
+ middleware, auth, CORS, CSRF, sessions, caching, GraphQL — ships as first-party.
7
+
8
+ Key Features:
9
+ - ASGI-based, async/await throughout
10
+ - Dependency injection with pre-flattened execution plan (zero recursion at runtime)
11
+ - Pydantic validation on every input and output — no type annotations needed
12
+ - Fluent Responder for building HTTP responses (json, html, file, stream, redirect)
13
+ - Middleware system: CORS, CSRF, sessions, auth, rate limiting, compression
14
+ - Depend(get_request=True) to inject the raw Request into any dependency
15
+ - GraphQL support via Strawberry (sillo.graphql)
16
+ - WebSocket support with type safety
17
+ - Flexible routing with path parameters and type conversion
18
+ - OpenAPI documentation generation
19
+ - Testing utilities with TestClient
20
+
21
+ Quick Start:
22
+ from sillo import silloApp
23
+ from pydantic import BaseModel
24
+
25
+ app = silloApp(title="My API", version="1.0.0")
26
+
27
+ @app.get("/hello/{name}")
28
+ async def hello(request, response, name: str):
29
+ return response.json({"message": f"Hello, {name}!"})
30
+
31
+ Common Patterns:
32
+
33
+ 1. Validation (zero annotations — the type lives on the declaration):
34
+ from sillo import Query, Path, Form, File
35
+
36
+ class UserCreate(BaseModel):
37
+ name: str
38
+ email: str
39
+
40
+ @app.post("/teams/{team_id}/users",
41
+ request_model=UserCreate, # JSON body
42
+ response_model=UserOut) # shapes the reply
43
+ async def create_user(request, response, user, # <- the body
44
+ team_id=Path(type=int), # path segment
45
+ notify=Query(False, type=bool),
46
+ db=Depend(get_db)):
47
+ return await save(user, team_id, db)
48
+
49
+ The JSON body is declared once, on the decorator, with request_model=. It
50
+ is injected into the first plain parameter after request/response, and also
51
+ available as request.validated_data. It composes freely with Depend and
52
+ with parameter markers.
53
+
54
+ Every other location has a marker: Query, Header, Cookie, Path, Form, File.
55
+ Constraints go on the marker — Query(1, type=int, ge=1, le=100) — and feed
56
+ both validation and the generated OpenAPI schema, so the published contract
57
+ and the enforced one cannot drift apart.
58
+
59
+ Bad input returns 422 naming the location that failed:
60
+ {"detail": [{"loc": ["query", "page"], "msg": "...", "type": "..."}]}
61
+
62
+ Markers written the old way — Query(1), Header(), Cookie("dark") — keep
63
+ their original behavior. Pass silloApp(strict_validation=True) to validate
64
+ those too, and to get the unified error shape for request_model bodies.
65
+
66
+ 2. Dependency Injection:
67
+ from sillo import Depend
68
+
69
+ async def get_db():
70
+ return Database()
71
+
72
+ @app.get("/items")
73
+ async def list_items(request, response, db=Depend(get_db)):
74
+ return response.json(await db.query("SELECT * FROM items"))
75
+
76
+ # Inject Request into any dependency:
77
+ def get_auth(req=Depend(get_request=True)):
78
+ return req.headers.get("Authorization")
79
+
80
+ 3. Middleware:
81
+ app.use(CORSMiddleware(config=CorsConfig(allow_origins=["*"])))
82
+ app.use(RateLimitMiddleware(rate=100))
83
+ app.use(SessionMiddleware(config=SessionConfig()))
84
+
85
+ 4. Responses:
86
+ return response.json(data, status_code=201)
87
+ return response.html("<h1>Hello</h1>")
88
+ return response.file("downloads/report.pdf")
89
+ return response.stream(async_generator(), content_type="text/plain")
90
+ return response.redirect("/dashboard", status_code=302)
91
+
92
+ 5. Exception Handlers:
93
+ async def custom_handler(request, response, exc):
94
+ return response.json({"error": str(exc)}).status(400)
95
+
96
+ app.add_exception_handler(CustomError, custom_handler)
97
+
98
+ 6. GraphQL:
99
+ from sillo.graphql import GraphQL
100
+
101
+ GraphQL(app, schema, path="/graphql", graphiql=True)
102
+ """
103
+
104
+ from sillo.core.routing import Route, Router
105
+
106
+ __version__: str = "0.0.1a1"
107
+
108
+ from .application import silloApp
109
+ from .frontend import FrontendApp
110
+ from sillo.core.dependencies import Depend
111
+ from .validation import (
112
+ Cookie,
113
+ File,
114
+ Form,
115
+ Header,
116
+ Path,
117
+ Query,
118
+ RequestValidationError,
119
+ ResponseValidationError,
120
+ UploadFile,
121
+ )
122
+
123
+ __all__ = [
124
+ "silloApp",
125
+ "Depend",
126
+ "Router",
127
+ "Route",
128
+ "FrontendApp",
129
+ "Query",
130
+ "Header",
131
+ "Cookie",
132
+ "Path",
133
+ "Form",
134
+ "File",
135
+ "UploadFile",
136
+ "RequestValidationError",
137
+ "ResponseValidationError",
138
+ ]
File without changes
@@ -0,0 +1,546 @@
1
+ from __future__ import annotations
2
+
3
+ import typing
4
+ from collections.abc import Iterator
5
+ from typing import Any, AsyncIterable, Callable, Mapping, MutableMapping
6
+
7
+ import anyio
8
+
9
+ from sillo.core.http.request import ClientDisconnect, Request
10
+ from sillo.core.http.response import (
11
+ BaseResponse,
12
+ )
13
+ from sillo.core.http.response import Responder as Response
14
+ from sillo.types import ASGIApp, Message, MiddlewareType, Receive, Scope, Send
15
+ from sillo.core.helpers.async_helpers import collapse_excgroups
16
+ from sillo.websockets import WebSocket
17
+
18
+ RequestResponseEndpoint = typing.Callable[[Request], typing.Awaitable[Response]]
19
+
20
+ T = typing.TypeVar("T")
21
+
22
+ AsyncContentStream = AsyncIterable[str | bytes | memoryview | MutableMapping[str, Any]]
23
+
24
+ MiddlewareFactory = Callable[..., ASGIApp]
25
+
26
+
27
+ class DefineMiddleware:
28
+ """Container that pairs a middleware factory with its positional and keyword arguments.
29
+
30
+ This class acts as a deferred middleware descriptor. It stores the middleware
31
+ class (or factory callable) together with the arguments that should be passed
32
+ when the middleware is instantiated. The application iterates over a list of
33
+ ``DefineMiddleware`` instances to build the middleware stack at startup.
34
+
35
+ Attributes:
36
+ cls: The middleware factory or class to be instantiated.
37
+ args: Positional arguments forwarded to the middleware constructor.
38
+ kwargs: Keyword arguments forwarded to the middleware constructor.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ cls: MiddlewareFactory,
44
+ *args: Any,
45
+ **kwargs: Any,
46
+ ) -> None:
47
+ """Initialise the descriptor with a middleware factory and its arguments.
48
+
49
+ Stores the middleware class together with any positional and keyword
50
+ arguments that should be forwarded when the middleware is later
51
+ instantiated by the application stack builder.
52
+
53
+ Args:
54
+ cls: A callable that produces an ASGI application when invoked with
55
+ the remaining arguments. Typically a middleware class.
56
+ *args: Positional arguments forwarded to ``cls`` at instantiation.
57
+ **kwargs: Keyword arguments forwarded to ``cls`` at instantiation.
58
+ """
59
+ self.cls = cls
60
+ self.args = args
61
+ self.kwargs = kwargs
62
+
63
+ def __iter__(self) -> Iterator[Any]:
64
+ """Yield the middleware components as a three-element tuple.
65
+
66
+ Allows the instance to be unpacked into ``(cls, args, kwargs)``, which
67
+ is the format expected by the application's middleware stack builder.
68
+
69
+ Returns:
70
+ An iterator over ``(cls, args, kwargs)`` where ``cls`` is the
71
+ middleware factory, ``args`` is a tuple of positional arguments,
72
+ and ``kwargs`` is a dictionary of keyword arguments.
73
+ """
74
+ as_tuple = (self.cls, self.args, self.kwargs)
75
+ return iter(as_tuple)
76
+
77
+ def __repr__(self) -> str:
78
+ """Return a developer-friendly string representation of this descriptor.
79
+
80
+ The representation includes the middleware class name, all positional
81
+ arguments, and all keyword arguments so that the descriptor can be
82
+ identified at a glance during debugging or logging.
83
+
84
+ Returns:
85
+ A string in the form ``DefineMiddleware(MiddlewareName, arg1, ...,
86
+ key=value, ...)`` suitable for debugging output.
87
+ """
88
+ class_name = self.__class__.__name__
89
+ args_strings = [f"{value!r}" for value in self.args]
90
+ option_strings = [f"{key}={value!r}" for key, value in self.kwargs.items()]
91
+ name = getattr(self.cls, "__name__", "")
92
+ args_repr = ", ".join([name] + args_strings + option_strings)
93
+ return f"{class_name}({args_repr})"
94
+
95
+
96
+ class _CachedRequest(Request):
97
+ """A request subclass that caches the body for dispatch-based middleware.
98
+
99
+ If the user calls ``Request.body()`` from their dispatch function we cache
100
+ the entire request body in memory and pass that to downstream middleware,
101
+ but if they call ``Request.stream()`` then all we do is send an empty body
102
+ so that downstream things don't hang forever.
103
+
104
+ The class manages three internal states for the wrapped receive callable:
105
+ disconnected, consumed-but-not-disconnected, and not-yet-consumed. Each
106
+ state determines how subsequent calls to ``wrapped_receive`` behave when
107
+ communicating with the downstream ASGI application.
108
+
109
+ Attributes:
110
+ _wrapped_rcv_disconnected: Whether a disconnect has already been sent
111
+ to the downstream application.
112
+ _wrapped_rcv_consumed: Whether the request body has been fully
113
+ consumed by the dispatch function.
114
+ _wrapped_rc_stream: The async iterator over the raw request body
115
+ chunks, created at initialisation time.
116
+ """
117
+
118
+ def __init__(self, scope: Scope, receive: Receive):
119
+ """Initialise the cached request with ASGI scope and receive callable.
120
+
121
+ Sets up internal state tracking flags and creates the initial stream
122
+ iterator from the parent ``Request`` class so that body chunks can be
123
+ lazily consumed by the dispatch middleware.
124
+
125
+ Args:
126
+ scope: The ASGI connection scope dictionary containing metadata
127
+ about the incoming HTTP request such as headers, path, and
128
+ query string.
129
+ receive: An ASGI receive callable that yields messages from the
130
+ client, used to read the request body incrementally.
131
+ """
132
+ super().__init__(scope, receive)
133
+ self._wrapped_rcv_disconnected = False
134
+ self._wrapped_rcv_consumed = False
135
+ self._wrapped_rc_stream = self.stream()
136
+
137
+ async def wrapped_receive(self) -> Message:
138
+ """Return the next ASGI message for the downstream application.
139
+
140
+ Manages three internal states to control how request body data is
141
+ forwarded to the downstream ASGI application. When the body has been
142
+ cached via ``body()``, it returns the cached bytes immediately. When
143
+ the stream has been consumed, it returns an empty body. Otherwise it
144
+ reads the next chunk from the underlying stream.
145
+
146
+ The method also handles client disconnection by tracking whether a
147
+ disconnect message has already been forwarded, preventing duplicate
148
+ disconnect signals from being sent to the downstream application.
149
+
150
+ Returns:
151
+ An ASGI message dictionary. Either an ``http.request`` message
152
+ containing a body chunk with a ``more_body`` flag, or an
153
+ ``http.disconnect`` message when the client has disconnected.
154
+
155
+ Raises:
156
+ RuntimeError: If an unexpected message type is received after the
157
+ stream has been consumed and the message is not a disconnect.
158
+ """
159
+ # wrapped_rcv state 1: disconnected
160
+ if self._wrapped_rcv_disconnected:
161
+ # we've already sent a disconnect to the downstream app
162
+ # we don't need to wait to get another one
163
+ # (although most ASGI servers will just keep sending it)
164
+ return {"type": "http.disconnect"}
165
+ # wrapped_rcv state 1: consumed but not yet disconnected
166
+ if self._wrapped_rcv_consumed:
167
+ # since the downstream app has consumed us all that is left
168
+ # is to send it a disconnect
169
+ if self._is_disconnected:
170
+ # the middleware has already seen the disconnect
171
+ # since we know the client is disconnected no need to wait
172
+ # for the message
173
+ self._wrapped_rcv_disconnected = True
174
+ return {"type": "http.disconnect"}
175
+ # we don't know yet if the client is disconnected or not
176
+ # so we'll wait until we get that message
177
+ msg = await self.receive()
178
+ if msg["type"] != "http.disconnect": # pragma: no cover
179
+ # at this point a disconnect is all that we should be receiving
180
+ # if we get something else, things went wrong somewhere
181
+ raise RuntimeError(f"Unexpected message received: {msg['type']}")
182
+ self._wrapped_rcv_disconnected = True
183
+ return msg
184
+
185
+ # wrapped_rcv state 3: not yet consumed
186
+ if getattr(self, "_body", None) is not None:
187
+ # body() was called, we return it even if the client disconnected
188
+ self._wrapped_rcv_consumed = True
189
+ return {
190
+ "type": "http.request",
191
+ "body": self._body,
192
+ "more_body": False,
193
+ }
194
+ elif self._stream_consumed:
195
+ # stream() was called to completion
196
+ # return an empty body so that downstream apps don't hang
197
+ # waiting for a disconnect
198
+ self._wrapped_rcv_consumed = True
199
+ return {
200
+ "type": "http.request",
201
+ "body": b"",
202
+ "more_body": False,
203
+ }
204
+ else:
205
+ # body() was never called and stream() wasn't consumed
206
+ try:
207
+ stream = self.stream()
208
+ chunk = await stream.__anext__()
209
+ self._wrapped_rcv_consumed = self._stream_consumed
210
+ return {
211
+ "type": "http.request",
212
+ "body": chunk,
213
+ "more_body": not self._stream_consumed,
214
+ }
215
+ except ClientDisconnect:
216
+ self._wrapped_rcv_disconnected = True
217
+ return {"type": "http.disconnect"}
218
+
219
+
220
+ class ASGIRequestResponseBridge:
221
+ """Bridges dispatch-style middleware into the ASGI application stack.
222
+
223
+ This class wraps an inner ASGI application and a dispatch function that
224
+ follows the request/response middleware pattern. It intercepts HTTP
225
+ requests, creates a cached request object, and uses an in-memory stream
226
+ to shuttle response data between the dispatch function and the inner
227
+ application. Non-HTTP scopes (e.g. websocket, lifespan) are forwarded
228
+ directly to the inner application without interception.
229
+
230
+ Attributes:
231
+ app: The inner ASGI application to delegate to.
232
+ dispatch_func: The dispatch middleware function that receives the
233
+ request, response, and a ``call_next`` callable.
234
+ """
235
+
236
+ def __init__(self, app: ASGIApp, dispatch: MiddlewareType) -> None:
237
+ """Initialise the bridge with an inner app and dispatch function.
238
+
239
+ Args:
240
+ app: The inner ASGI application that will handle the actual
241
+ request processing after the dispatch middleware runs.
242
+ dispatch: A middleware function conforming to the dispatch
243
+ pattern, accepting ``(request, response, call_next)`` and
244
+ returning an awaitable response.
245
+ """
246
+ self.app = app
247
+ self.dispatch_func = dispatch
248
+
249
+ def __str__(self) -> str:
250
+ """Return a string representation of the bridge instance.
251
+
252
+ Returns:
253
+ A human-readable string showing the inner app and dispatch
254
+ function references for debugging purposes.
255
+ """
256
+ return f"ASGIRequestResponseBridge({self.app!r}, {self.dispatch_func!r})"
257
+
258
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
259
+ """Handle an incoming ASGI connection by bridging dispatch middleware.
260
+
261
+ For non-HTTP scopes the call is forwarded directly to the inner app.
262
+ For HTTP scopes, a ``_CachedRequest`` is created along with a
263
+ ``Responder`` and an in-memory object stream. The dispatch function is
264
+ invoked with these objects and a ``call_next`` closure that runs the
265
+ inner app in a background task, streaming the response back through
266
+ the memory channel.
267
+
268
+ Args:
269
+ scope: The ASGI connection scope dictionary describing the
270
+ connection type and metadata.
271
+ receive: The ASGI receive callable for reading incoming messages.
272
+ send: The ASGI send callable for transmitting outgoing messages.
273
+ """
274
+ if scope["type"] != "http":
275
+ await self.app(scope, receive, send)
276
+ return
277
+
278
+ request = _CachedRequest(scope, receive)
279
+ response = Response(request=request)
280
+ wrapped_receive = request.wrapped_receive
281
+ response_sent = anyio.Event()
282
+
283
+ async def call_next(*_):
284
+ """Invoke the inner application and return its response as a stream.
285
+
286
+ Runs the inner ASGI application inside a background task and
287
+ returns a ``_StreamingResponse`` that replays the response
288
+ headers and body chunks received through the in-memory stream.
289
+ Handles debug info messages, client disconnection, and exception
290
+ propagation from the inner application.
291
+
292
+ Returns:
293
+ A ``_StreamingResponse`` instance containing the status code,
294
+ headers, and body stream from the inner application.
295
+
296
+ Raises:
297
+ RuntimeError: If the client disconnects before the inner app
298
+ sends a response, or if the inner app raises no exception
299
+ but the stream ends prematurely.
300
+ Exception: Re-raises any exception caught from the inner
301
+ application during request processing.
302
+ """
303
+ app_exc: Exception | None = None
304
+
305
+ async def receive_or_disconnect() -> Message:
306
+ """Return the next message or a disconnect if the response is sent.
307
+
308
+ Races the wrapped receive callable against the ``response_sent``
309
+ event so that the inner application receives a disconnect
310
+ signal once the middleware has finished sending the response.
311
+
312
+ Returns:
313
+ An ASGI message dictionary, either from the underlying
314
+ receive callable or a synthetic ``http.disconnect``
315
+ message if the response has already been sent.
316
+ """
317
+ if response_sent.is_set():
318
+ return {"type": "http.disconnect"}
319
+ async with anyio.create_task_group() as task_group:
320
+
321
+ async def wrap(
322
+ func: typing.Callable[[], typing.Awaitable[T]],
323
+ ) -> T:
324
+ """Await a callable then cancel the sibling task in the group.
325
+
326
+ Args:
327
+ func: A zero-argument async callable to execute.
328
+
329
+ Returns:
330
+ The return value of the awaited callable.
331
+ """
332
+ result = await func()
333
+ task_group.cancel_scope.cancel()
334
+ return result
335
+
336
+ task_group.start_soon(wrap, response_sent.wait)
337
+ message = await wrap(wrapped_receive)
338
+ if response_sent.is_set():
339
+ return {"type": "http.disconnect"}
340
+ return message
341
+
342
+ async def send_no_error(message: Message) -> None:
343
+ """Send an ASGI message, raising on broken stream resources.
344
+
345
+ Args:
346
+ message: The ASGI message dictionary to send through the
347
+ in-memory stream to the dispatch function.
348
+
349
+ Raises:
350
+ RuntimeError: If the send stream has been closed or
351
+ broken, indicating the response was not properly
352
+ returned by the dispatch function.
353
+ """
354
+ try:
355
+ await send_stream.send(message)
356
+ except anyio.BrokenResourceError:
357
+ raise RuntimeError("No response returned")
358
+
359
+ async def coro() -> None:
360
+ """Run the inner ASGI application and capture any exception.
361
+
362
+ Executes the inner application within a context-managed send
363
+ stream, catching and storing any exception so it can be
364
+ re-raised in the dispatch function's task after the body
365
+ stream has been fully consumed.
366
+ """
367
+ nonlocal app_exc
368
+ with send_stream:
369
+ try:
370
+ await self.app(scope, receive_or_disconnect, send_no_error)
371
+ except Exception as exc:
372
+ app_exc = exc
373
+
374
+ task_group.start_soon(coro)
375
+ try:
376
+ message = await recv_stream.receive()
377
+ info = message.get("info", None)
378
+ if message["type"] == "http.response.debug" and info is not None:
379
+ message = await recv_stream.receive()
380
+ except anyio.EndOfStream:
381
+ if app_exc is not None:
382
+ raise app_exc
383
+ raise RuntimeError("Client disconnected before response was sent")
384
+ assert message["type"] == "http.response.start"
385
+
386
+ async def body_stream() -> typing.AsyncGenerator[bytes, None]:
387
+ """Yield response body chunks from the in-memory receive stream.
388
+
389
+ Iterates over messages from the receive stream, yielding body
390
+ byte strings until a message with ``more_body`` set to
391
+ ``False`` is encountered. Re-raises any exception captured
392
+ from the inner application after the body is fully consumed.
393
+
394
+ Yields:
395
+ Byte strings containing portions of the HTTP response
396
+ body as forwarded by the inner ASGI application.
397
+
398
+ Raises:
399
+ Exception: Re-raises any exception that occurred in the
400
+ inner application during response generation.
401
+ """
402
+ async for message in recv_stream:
403
+ assert message["type"] == "http.response.body"
404
+ body = message.get("body", b"")
405
+ if body:
406
+ yield body
407
+ if not message.get("more_body", False):
408
+ break
409
+ if app_exc is not None:
410
+ raise app_exc
411
+
412
+ response_object = _StreamingResponse(
413
+ content=body_stream(),
414
+ status_code=message["status"],
415
+ )
416
+ response_object.raw_headers = message["headers"]
417
+ response._response = response_object
418
+ return response_object
419
+
420
+ streams = anyio.create_memory_object_stream()
421
+ send_stream, recv_stream = streams
422
+ with recv_stream, send_stream, collapse_excgroups():
423
+ async with anyio.create_task_group() as task_group:
424
+ returned_response = await self.dispatch_func(
425
+ request, response, call_next
426
+ )
427
+ await returned_response(scope, wrapped_receive, send)
428
+ response_sent.set()
429
+ recv_stream.close()
430
+
431
+
432
+ class _StreamingResponse(BaseResponse):
433
+ """A response that streams its body content from an async iterable.
434
+
435
+ Unlike ``BaseResponse`` which holds the full body in memory, this response
436
+ class consumes an async content stream chunk by chunk, sending each chunk
437
+ as a separate ASGI ``http.response.body`` message. This enables efficient
438
+ handling of large responses or responses whose content is generated
439
+ incrementally by the inner application.
440
+
441
+ Optionally supports sending a debug info message before the response
442
+ start, and handles both raw byte chunks and ASGI message dictionaries
443
+ (for features like ``pathsend``) within the content stream.
444
+
445
+ Attributes:
446
+ info: Optional debug information dictionary to send before the
447
+ response start message.
448
+ content_iterator: The async iterable yielding body chunks or ASGI
449
+ message dictionaries.
450
+ status_code: The HTTP status code for the response.
451
+ media_type: Optional media type string for the Content-Type header.
452
+ """
453
+
454
+ def __init__(
455
+ self,
456
+ content: AsyncContentStream,
457
+ status_code: int = 200,
458
+ headers: Mapping[str, str] | None = None,
459
+ media_type: str | None = None,
460
+ info: Mapping[str, Any] | None = None,
461
+ ) -> None:
462
+ """Initialise a streaming response with an async content source.
463
+
464
+ Args:
465
+ content: An async iterable yielding body chunks as strings, bytes,
466
+ memoryview objects, or ASGI message dictionaries.
467
+ status_code: The HTTP status code to send in the response start
468
+ message. Defaults to 200.
469
+ headers: An optional mapping of HTTP header names to values. If
470
+ ``None``, an empty header set is used.
471
+ media_type: An optional media type string for the Content-Type
472
+ response header.
473
+ info: An optional dictionary of debug information to send as an
474
+ ``http.response.debug`` message before the response start.
475
+ """
476
+ self.info = info
477
+ self.content_iterator = content
478
+ self.status_code = status_code
479
+ self.media_type = media_type
480
+
481
+ super().__init__(headers=dict(headers or {}), status_code=status_code)
482
+
483
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
484
+ """Send the streaming response through the ASGI send callable.
485
+
486
+ Sends the response in three phases: an optional debug info message,
487
+ the response start message with status and headers, and then the body
488
+ chunks from the content iterator. Each body chunk is sent as a
489
+ separate ``http.response.body`` message with ``more_body`` set to
490
+ ``True``. A final empty body message with ``more_body`` set to
491
+ ``False`` is sent to signal the end of the response, unless the
492
+ content stream contained ASGI message dictionaries.
493
+
494
+ Args:
495
+ scope: The ASGI connection scope dictionary.
496
+ receive: The ASGI receive callable (unused by this response).
497
+ send: The ASGI send callable used to transmit all response
498
+ messages to the client.
499
+ """
500
+ if self.info is not None:
501
+ await send({"type": "http.response.debug", "info": self.info})
502
+ await send(
503
+ {
504
+ "type": "http.response.start",
505
+ "status": self.status_code,
506
+ "headers": self.raw_headers,
507
+ }
508
+ )
509
+
510
+ should_close_body = True
511
+ async for chunk in self.content_iterator:
512
+ if isinstance(chunk, dict):
513
+ # We got an ASGI message which is not response body (eg: pathsend)
514
+ should_close_body = False
515
+ await send(chunk)
516
+ continue
517
+ await send({"type": "http.response.body", "body": chunk, "more_body": True})
518
+
519
+ if should_close_body:
520
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
521
+
522
+
523
+ WebSocketDispatchFunction = typing.Callable[
524
+ ["WebSocket", typing.Coroutine[None, None, typing.Any]], typing.Awaitable[None]
525
+ ]
526
+
527
+
528
+ def wrap_middleware(middleware_function: MiddlewareType) -> DefineMiddleware:
529
+ """Wrap a dispatch-style middleware function into a ``DefineMiddleware`` instance.
530
+
531
+ Creates a ``DefineMiddleware`` descriptor that pairs the
532
+ ``ASGIRequestResponseBridge`` class with the given dispatch middleware
533
+ function. This allows the middleware to be added to the application's
534
+ middleware stack in the standard format expected by the framework.
535
+
536
+ Args:
537
+ middleware_function: A dispatch-style middleware callable that accepts
538
+ ``(request, response, call_next)`` and returns an awaitable
539
+ response object.
540
+
541
+ Returns:
542
+ A ``DefineMiddleware`` instance wrapping the
543
+ ``ASGIRequestResponseBridge`` with the provided dispatch function
544
+ bound as the ``dispatch`` keyword argument.
545
+ """
546
+ return DefineMiddleware(ASGIRequestResponseBridge, dispatch=middleware_function)