Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/starlette/testclient.py: 25%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from __future__ import annotations
3import contextlib
4import inspect
5import io
6import json
7import math
8import sys
9import warnings
10from collections.abc import Awaitable, Callable, Generator, Iterable, Mapping, MutableMapping, Sequence
11from concurrent.futures import Future
12from contextlib import AbstractContextManager
13from types import GeneratorType
14from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeGuard, cast
15from urllib.parse import unquote, urljoin
17import anyio
18import anyio.abc
19import anyio.from_thread
20from anyio.streams.stapled import StapledObjectStream
22from starlette._utils import is_async_callable
23from starlette.exceptions import StarletteDeprecationWarning
24from starlette.types import ASGIApp, Message, Receive, Scope, Send
25from starlette.websockets import WebSocketDisconnect
27if sys.version_info >= (3, 11): # pragma: no cover
28 from typing import Self
29else: # pragma: no cover
30 from typing_extensions import Self
32if TYPE_CHECKING:
33 import httpx2 as httpx
34else:
35 try:
36 import httpx2 as httpx
37 except ModuleNotFoundError: # pragma: no cover
38 try:
39 import httpx
40 except ModuleNotFoundError:
41 raise RuntimeError(
42 "The starlette.testclient module requires the httpx2 package to be installed.\n"
43 "You can install this with:\n"
44 " $ pip install httpx2\n"
45 ) from None
46 else:
47 warnings.warn(
48 "Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.",
49 StarletteDeprecationWarning,
50 stacklevel=2,
51 )
53_PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]
55ASGIInstance = Callable[[Receive, Send], Awaitable[None]]
56ASGI2App = Callable[[Scope], ASGIInstance]
57ASGI3App = Callable[[Scope, Receive, Send], Awaitable[None]]
60_RequestData = Mapping[str, str | Iterable[str] | bytes]
63def _is_asgi3(app: ASGI2App | ASGI3App) -> TypeGuard[ASGI3App]:
64 if inspect.isclass(app):
65 return hasattr(app, "__await__")
66 return is_async_callable(app)
69class _WrapASGI2:
70 """
71 Provide an ASGI3 interface onto an ASGI2 app.
72 """
74 def __init__(self, app: ASGI2App) -> None:
75 self.app = app
77 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
78 instance = self.app(scope)
79 await instance(receive, send)
82class _AsyncBackend(TypedDict):
83 backend: str
84 backend_options: dict[str, Any]
87class _Upgrade(Exception):
88 def __init__(self, session: WebSocketTestSession) -> None:
89 self.session = session
92class WebSocketDenialResponse( # type: ignore[misc]
93 httpx.Response,
94 WebSocketDisconnect,
95):
96 """
97 A special case of `WebSocketDisconnect`, raised in the `TestClient` if the
98 `WebSocket` is closed before being accepted with a `send_denial_response()`.
99 """
102class WebSocketTestSession:
103 def __init__(
104 self,
105 app: ASGI3App,
106 scope: Scope,
107 portal_factory: _PortalFactoryType,
108 ) -> None:
109 self.app = app
110 self.scope = scope
111 self.accepted_subprotocol = None
112 self.portal_factory = portal_factory
113 self.extra_headers = None
115 def __enter__(self) -> Self:
116 with contextlib.ExitStack() as stack:
117 self.portal = portal = stack.enter_context(self.portal_factory())
118 fut, cs = portal.start_task(self._run)
119 stack.callback(fut.result)
120 stack.callback(portal.call, cs.cancel)
121 self.send({"type": "websocket.connect"})
122 message = self.receive()
123 self._raise_on_close(message)
124 self.accepted_subprotocol = message.get("subprotocol", None)
125 self.extra_headers = message.get("headers", None)
126 stack.callback(self.close, 1000)
127 self.exit_stack = stack.pop_all()
128 return self
130 def __exit__(self, *args: Any) -> bool | None:
131 return self.exit_stack.__exit__(*args)
133 async def _run(self, *, task_status: anyio.abc.TaskStatus[anyio.CancelScope]) -> None:
134 """
135 The sub-thread in which the websocket session runs.
136 """
137 send: anyio.create_memory_object_stream[Message] = anyio.create_memory_object_stream(math.inf)
138 send_tx, send_rx = send
139 receive: anyio.create_memory_object_stream[Message] = anyio.create_memory_object_stream(math.inf)
140 receive_tx, receive_rx = receive
141 with send_tx, send_rx, receive_tx, receive_rx, anyio.CancelScope() as cs:
142 self._receive_tx = receive_tx
143 self._send_rx = send_rx
144 task_status.started(cs)
145 await self.app(self.scope, receive_rx.receive, send_tx.send)
147 # wait for cs.cancel to be called before closing streams
148 await anyio.sleep_forever()
150 def _raise_on_close(self, message: Message) -> None:
151 if message["type"] == "websocket.close":
152 raise WebSocketDisconnect(code=message.get("code", 1000), reason=message.get("reason", ""))
153 elif message["type"] == "websocket.http.response.start":
154 status_code: int = message["status"]
155 headers: list[tuple[bytes, bytes]] = message["headers"]
156 body: list[bytes] = []
157 while True:
158 message = self.receive()
159 assert message["type"] == "websocket.http.response.body"
160 body.append(message["body"])
161 if not message.get("more_body", False):
162 break
163 raise WebSocketDenialResponse(status_code=status_code, headers=headers, content=b"".join(body))
165 def send(self, message: Message) -> None:
166 self.portal.call(self._receive_tx.send, message)
168 def send_text(self, data: str) -> None:
169 self.send({"type": "websocket.receive", "text": data})
171 def send_bytes(self, data: bytes) -> None:
172 self.send({"type": "websocket.receive", "bytes": data})
174 def send_json(self, data: Any, mode: Literal["text", "binary"] = "text") -> None:
175 text = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
176 if mode == "text":
177 self.send({"type": "websocket.receive", "text": text})
178 else:
179 self.send({"type": "websocket.receive", "bytes": text.encode("utf-8")})
181 def close(self, code: int = 1000, reason: str | None = None) -> None:
182 self.send({"type": "websocket.disconnect", "code": code, "reason": reason})
184 def receive(self) -> Message:
185 return self.portal.call(self._send_rx.receive)
187 def receive_text(self) -> str:
188 message = self.receive()
189 self._raise_on_close(message)
190 return cast(str, message["text"])
192 def receive_bytes(self) -> bytes:
193 message = self.receive()
194 self._raise_on_close(message)
195 return cast(bytes, message["bytes"])
197 def receive_json(self, mode: Literal["text", "binary"] = "text") -> Any:
198 message = self.receive()
199 self._raise_on_close(message)
200 if mode == "text":
201 text = message["text"]
202 else:
203 text = message["bytes"].decode("utf-8")
204 return json.loads(text)
207class _TestClientTransport(httpx.BaseTransport):
208 def __init__(
209 self,
210 app: ASGI3App,
211 portal_factory: _PortalFactoryType,
212 raise_server_exceptions: bool = True,
213 root_path: str = "",
214 *,
215 client: tuple[str, int],
216 app_state: dict[str, Any],
217 ) -> None:
218 self.app = app
219 self.raise_server_exceptions = raise_server_exceptions
220 self.root_path = root_path
221 self.portal_factory = portal_factory
222 self.app_state = app_state
223 self.client = client
225 def handle_request(self, request: httpx.Request) -> httpx.Response:
226 scheme = request.url.scheme
227 netloc = request.url.netloc.decode(encoding="ascii")
228 path = request.url.path
229 raw_path = request.url.raw_path
230 query = request.url.query.decode(encoding="ascii")
232 default_port = {"http": 80, "ws": 80, "https": 443, "wss": 443}[scheme]
234 if ":" in netloc:
235 host, port_string = netloc.split(":", 1)
236 port = int(port_string)
237 else:
238 host = netloc
239 port = default_port
241 # Include the 'host' header.
242 if "host" in request.headers:
243 headers: list[tuple[bytes, bytes]] = []
244 elif port == default_port: # pragma: no cover
245 headers = [(b"host", host.encode())]
246 else: # pragma: no cover
247 headers = [(b"host", (f"{host}:{port}").encode())]
249 # Include other request headers.
250 headers += [(key.lower().encode(), value.encode()) for key, value in request.headers.multi_items()]
252 scope: dict[str, Any]
254 if scheme in {"ws", "wss"}:
255 subprotocol = request.headers.get("sec-websocket-protocol", None)
256 if subprotocol is None:
257 subprotocols: Sequence[str] = []
258 else:
259 subprotocols = [value.strip() for value in subprotocol.split(",")]
260 scope = {
261 "type": "websocket",
262 "path": unquote(path),
263 "raw_path": raw_path.split(b"?", 1)[0],
264 "root_path": self.root_path,
265 "scheme": scheme,
266 "query_string": query.encode(),
267 "headers": headers,
268 "client": self.client,
269 "server": [host, port],
270 "subprotocols": subprotocols,
271 "state": self.app_state.copy(),
272 "extensions": {"websocket.http.response": {}},
273 }
274 session = WebSocketTestSession(self.app, scope, self.portal_factory)
275 raise _Upgrade(session)
277 scope = {
278 "type": "http",
279 "http_version": "1.1",
280 "method": request.method,
281 "path": unquote(path),
282 "raw_path": raw_path.split(b"?", 1)[0],
283 "root_path": self.root_path,
284 "scheme": scheme,
285 "query_string": query.encode(),
286 "headers": headers,
287 "client": self.client,
288 "server": [host, port],
289 "extensions": {"http.response.debug": {}},
290 "state": self.app_state.copy(),
291 }
293 request_complete = False
294 response_started = False
295 response_complete: anyio.Event
296 raw_kwargs: dict[str, Any] = {"stream": io.BytesIO()}
297 template = None
298 context = None
300 async def receive() -> Message:
301 nonlocal request_complete
303 if request_complete:
304 if not response_complete.is_set():
305 await response_complete.wait()
306 return {"type": "http.disconnect"}
308 body = request.read()
309 if isinstance(body, str):
310 body_bytes: bytes = body.encode("utf-8") # pragma: no cover
311 elif body is None:
312 body_bytes = b"" # pragma: no cover
313 elif isinstance(body, GeneratorType):
314 try: # pragma: no cover
315 chunk = body.send(None)
316 if isinstance(chunk, str):
317 chunk = chunk.encode("utf-8")
318 return {"type": "http.request", "body": chunk, "more_body": True}
319 except StopIteration: # pragma: no cover
320 request_complete = True
321 return {"type": "http.request", "body": b""}
322 else:
323 body_bytes = body
325 request_complete = True
326 return {"type": "http.request", "body": body_bytes}
328 async def send(message: Message) -> None:
329 nonlocal raw_kwargs, response_started, template, context
331 if message["type"] == "http.response.start":
332 assert not response_started, 'Received multiple "http.response.start" messages.'
333 raw_kwargs["status_code"] = message["status"]
334 raw_kwargs["headers"] = [(key.decode(), value.decode()) for key, value in message.get("headers", [])]
335 response_started = True
336 elif message["type"] == "http.response.body":
337 assert response_started, 'Received "http.response.body" without "http.response.start".'
338 assert not response_complete.is_set(), 'Received "http.response.body" after response completed.'
339 body = message.get("body", b"")
340 more_body = message.get("more_body", False)
341 if request.method != "HEAD":
342 raw_kwargs["stream"].write(body)
343 if not more_body:
344 raw_kwargs["stream"].seek(0)
345 response_complete.set()
346 elif message["type"] == "http.response.debug":
347 template = message["info"]["template"]
348 context = message["info"]["context"]
350 try:
351 with self.portal_factory() as portal:
352 response_complete = portal.call(anyio.Event)
353 portal.call(self.app, scope, receive, send)
354 except BaseException as exc:
355 if self.raise_server_exceptions:
356 raise exc
358 if self.raise_server_exceptions:
359 assert response_started, "TestClient did not receive any response."
360 elif not response_started:
361 raw_kwargs = {
362 "status_code": 500,
363 "headers": [],
364 "stream": io.BytesIO(),
365 }
367 raw_kwargs["stream"] = httpx.ByteStream(raw_kwargs["stream"].read())
369 response = httpx.Response(**raw_kwargs, request=request)
370 if template is not None:
371 response.template = template # type: ignore[attr-defined]
372 response.context = context # type: ignore[attr-defined]
373 return response
376class TestClient(httpx.Client):
377 __test__ = False
378 task: Future[None]
379 portal: anyio.abc.BlockingPortal | None = None
381 def __init__(
382 self,
383 app: ASGIApp,
384 base_url: str = "http://testserver",
385 raise_server_exceptions: bool = True,
386 root_path: str = "",
387 backend: Literal["asyncio", "trio"] = "asyncio",
388 backend_options: dict[str, Any] | None = None,
389 cookies: httpx._types.CookieTypes | None = None,
390 headers: dict[str, str] | None = None,
391 follow_redirects: bool = True,
392 client: tuple[str, int] = ("testclient", 50000),
393 ) -> None:
394 self.async_backend = _AsyncBackend(backend=backend, backend_options=backend_options or {})
395 if _is_asgi3(app):
396 asgi_app = app
397 else:
398 app = cast(ASGI2App, app) # type: ignore[assignment]
399 asgi_app = _WrapASGI2(app) # type: ignore[arg-type]
400 self.app = asgi_app
401 self.app_state: dict[str, Any] = {}
402 transport = _TestClientTransport(
403 self.app,
404 portal_factory=self._portal_factory,
405 raise_server_exceptions=raise_server_exceptions,
406 root_path=root_path,
407 app_state=self.app_state,
408 client=client,
409 )
410 if headers is None:
411 headers = {}
412 headers.setdefault("user-agent", "testclient")
413 super().__init__(
414 base_url=base_url,
415 headers=headers,
416 transport=transport,
417 follow_redirects=follow_redirects,
418 cookies=cookies,
419 )
421 @contextlib.contextmanager
422 def _portal_factory(self) -> Generator[anyio.abc.BlockingPortal, None, None]:
423 if self.portal is not None:
424 yield self.portal
425 else:
426 with anyio.from_thread.start_blocking_portal(**self.async_backend) as portal:
427 yield portal
429 def request( # type: ignore[override]
430 self,
431 method: str,
432 url: httpx._types.URLTypes,
433 *,
434 content: httpx._types.RequestContent | None = None,
435 data: _RequestData | None = None,
436 files: httpx._types.RequestFiles | None = None,
437 json: Any = None,
438 params: httpx._types.QueryParamTypes | None = None,
439 headers: httpx._types.HeaderTypes | None = None,
440 cookies: httpx._types.CookieTypes | None = None,
441 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
442 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
443 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
444 extensions: dict[str, Any] | None = None,
445 ) -> httpx.Response:
446 if timeout is not httpx.USE_CLIENT_DEFAULT:
447 warnings.warn(
448 "You should not use the 'timeout' argument with the TestClient. "
449 "See https://github.com/Kludex/starlette/issues/1108 for more information.",
450 StarletteDeprecationWarning,
451 stacklevel=2,
452 )
453 url = self._merge_url(url)
454 return super().request(
455 method,
456 url,
457 content=content,
458 data=data,
459 files=files,
460 json=json,
461 params=params,
462 headers=headers,
463 cookies=cookies,
464 auth=auth,
465 follow_redirects=follow_redirects,
466 timeout=timeout,
467 extensions=extensions,
468 )
470 def get( # type: ignore[override]
471 self,
472 url: httpx._types.URLTypes,
473 *,
474 params: httpx._types.QueryParamTypes | None = None,
475 headers: httpx._types.HeaderTypes | None = None,
476 cookies: httpx._types.CookieTypes | None = None,
477 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
478 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
479 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
480 extensions: dict[str, Any] | None = None,
481 ) -> httpx.Response:
482 return super().get(
483 url,
484 params=params,
485 headers=headers,
486 cookies=cookies,
487 auth=auth,
488 follow_redirects=follow_redirects,
489 timeout=timeout,
490 extensions=extensions,
491 )
493 def options( # type: ignore[override]
494 self,
495 url: httpx._types.URLTypes,
496 *,
497 params: httpx._types.QueryParamTypes | None = None,
498 headers: httpx._types.HeaderTypes | None = None,
499 cookies: httpx._types.CookieTypes | None = None,
500 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
501 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
502 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
503 extensions: dict[str, Any] | None = None,
504 ) -> httpx.Response:
505 return super().options(
506 url,
507 params=params,
508 headers=headers,
509 cookies=cookies,
510 auth=auth,
511 follow_redirects=follow_redirects,
512 timeout=timeout,
513 extensions=extensions,
514 )
516 def head( # type: ignore[override]
517 self,
518 url: httpx._types.URLTypes,
519 *,
520 params: httpx._types.QueryParamTypes | None = None,
521 headers: httpx._types.HeaderTypes | None = None,
522 cookies: httpx._types.CookieTypes | None = None,
523 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
524 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
525 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
526 extensions: dict[str, Any] | None = None,
527 ) -> httpx.Response:
528 return super().head(
529 url,
530 params=params,
531 headers=headers,
532 cookies=cookies,
533 auth=auth,
534 follow_redirects=follow_redirects,
535 timeout=timeout,
536 extensions=extensions,
537 )
539 def post( # type: ignore[override]
540 self,
541 url: httpx._types.URLTypes,
542 *,
543 content: httpx._types.RequestContent | None = None,
544 data: _RequestData | None = None,
545 files: httpx._types.RequestFiles | None = None,
546 json: Any = None,
547 params: httpx._types.QueryParamTypes | None = None,
548 headers: httpx._types.HeaderTypes | None = None,
549 cookies: httpx._types.CookieTypes | None = None,
550 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
551 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
552 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
553 extensions: dict[str, Any] | None = None,
554 ) -> httpx.Response:
555 return super().post(
556 url,
557 content=content,
558 data=data,
559 files=files,
560 json=json,
561 params=params,
562 headers=headers,
563 cookies=cookies,
564 auth=auth,
565 follow_redirects=follow_redirects,
566 timeout=timeout,
567 extensions=extensions,
568 )
570 def put( # type: ignore[override]
571 self,
572 url: httpx._types.URLTypes,
573 *,
574 content: httpx._types.RequestContent | None = None,
575 data: _RequestData | None = None,
576 files: httpx._types.RequestFiles | None = None,
577 json: Any = None,
578 params: httpx._types.QueryParamTypes | None = None,
579 headers: httpx._types.HeaderTypes | None = None,
580 cookies: httpx._types.CookieTypes | None = None,
581 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
582 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
583 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
584 extensions: dict[str, Any] | None = None,
585 ) -> httpx.Response:
586 return super().put(
587 url,
588 content=content,
589 data=data,
590 files=files,
591 json=json,
592 params=params,
593 headers=headers,
594 cookies=cookies,
595 auth=auth,
596 follow_redirects=follow_redirects,
597 timeout=timeout,
598 extensions=extensions,
599 )
601 def patch( # type: ignore[override]
602 self,
603 url: httpx._types.URLTypes,
604 *,
605 content: httpx._types.RequestContent | None = None,
606 data: _RequestData | None = None,
607 files: httpx._types.RequestFiles | None = None,
608 json: Any = None,
609 params: httpx._types.QueryParamTypes | None = None,
610 headers: httpx._types.HeaderTypes | None = None,
611 cookies: httpx._types.CookieTypes | None = None,
612 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
613 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
614 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
615 extensions: dict[str, Any] | None = None,
616 ) -> httpx.Response:
617 return super().patch(
618 url,
619 content=content,
620 data=data,
621 files=files,
622 json=json,
623 params=params,
624 headers=headers,
625 cookies=cookies,
626 auth=auth,
627 follow_redirects=follow_redirects,
628 timeout=timeout,
629 extensions=extensions,
630 )
632 def delete( # type: ignore[override]
633 self,
634 url: httpx._types.URLTypes,
635 *,
636 params: httpx._types.QueryParamTypes | None = None,
637 headers: httpx._types.HeaderTypes | None = None,
638 cookies: httpx._types.CookieTypes | None = None,
639 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
640 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
641 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
642 extensions: dict[str, Any] | None = None,
643 ) -> httpx.Response:
644 return super().delete(
645 url,
646 params=params,
647 headers=headers,
648 cookies=cookies,
649 auth=auth,
650 follow_redirects=follow_redirects,
651 timeout=timeout,
652 extensions=extensions,
653 )
655 def websocket_connect(
656 self,
657 url: str,
658 subprotocols: Sequence[str] | None = None,
659 **kwargs: Any,
660 ) -> WebSocketTestSession:
661 url = urljoin("ws://testserver", url)
662 headers = kwargs.get("headers", {})
663 headers.setdefault("connection", "upgrade")
664 headers.setdefault("sec-websocket-key", "testserver==")
665 headers.setdefault("sec-websocket-version", "13")
666 if subprotocols is not None:
667 headers.setdefault("sec-websocket-protocol", ", ".join(subprotocols))
668 kwargs["headers"] = headers
669 try:
670 super().request("GET", url, **kwargs)
671 except _Upgrade as exc:
672 session = exc.session
673 else:
674 raise RuntimeError("Expected WebSocket upgrade") # pragma: no cover
676 return session
678 def __enter__(self) -> Self:
679 with contextlib.ExitStack() as stack:
680 self.portal = portal = stack.enter_context(anyio.from_thread.start_blocking_portal(**self.async_backend))
682 @stack.callback
683 def reset_portal() -> None:
684 self.portal = None
686 send: anyio.create_memory_object_stream[MutableMapping[str, Any] | None] = (
687 anyio.create_memory_object_stream(math.inf)
688 )
689 receive: anyio.create_memory_object_stream[MutableMapping[str, Any]] = anyio.create_memory_object_stream(
690 math.inf
691 )
692 for channel in (*send, *receive):
693 stack.callback(channel.close)
694 self.stream_send = StapledObjectStream(*send)
695 self.stream_receive = StapledObjectStream(*receive)
696 self.task = portal.start_task_soon(self.lifespan)
697 portal.call(self.wait_startup)
699 @stack.callback
700 def wait_shutdown() -> None:
701 portal.call(self.wait_shutdown)
703 self.exit_stack = stack.pop_all()
705 return self
707 def __exit__(self, *args: Any) -> None:
708 self.exit_stack.close()
710 async def lifespan(self) -> None:
711 scope = {"type": "lifespan", "state": self.app_state}
712 try:
713 await self.app(scope, self.stream_receive.receive, self.stream_send.send)
714 finally:
715 await self.stream_send.send(None)
717 async def wait_startup(self) -> None:
718 await self.stream_receive.send({"type": "lifespan.startup"})
720 async def receive() -> Any:
721 message = await self.stream_send.receive()
722 if message is None:
723 self.task.result()
724 return message
726 message = await receive()
727 assert message["type"] in (
728 "lifespan.startup.complete",
729 "lifespan.startup.failed",
730 )
731 if message["type"] == "lifespan.startup.failed":
732 await receive()
734 async def wait_shutdown(self) -> None:
735 async def receive() -> Any:
736 message = await self.stream_send.receive()
737 if message is None:
738 self.task.result()
739 return message
741 await self.stream_receive.send({"type": "lifespan.shutdown"})
742 message = await receive()
743 assert message["type"] in (
744 "lifespan.shutdown.complete",
745 "lifespan.shutdown.failed",
746 )
747 if message["type"] == "lifespan.shutdown.failed":
748 await receive()