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 debug_info: dict[str, Any] | None = None
299 async def receive() -> Message:
300 nonlocal request_complete
302 if request_complete:
303 if not response_complete.is_set():
304 await response_complete.wait()
305 return {"type": "http.disconnect"}
307 body = request.read()
308 if isinstance(body, str):
309 body_bytes: bytes = body.encode("utf-8") # pragma: no cover
310 elif body is None:
311 body_bytes = b"" # pragma: no cover
312 elif isinstance(body, GeneratorType):
313 try: # pragma: no cover
314 chunk = body.send(None)
315 if isinstance(chunk, str):
316 chunk = chunk.encode("utf-8")
317 return {"type": "http.request", "body": chunk, "more_body": True}
318 except StopIteration: # pragma: no cover
319 request_complete = True
320 return {"type": "http.request", "body": b""}
321 else:
322 body_bytes = body
324 request_complete = True
325 return {"type": "http.request", "body": body_bytes}
327 async def send(message: Message) -> None:
328 nonlocal raw_kwargs, response_started, debug_info
330 if message["type"] == "http.response.start":
331 assert not response_started, 'Received multiple "http.response.start" messages.'
332 raw_kwargs["status_code"] = message["status"]
333 raw_kwargs["headers"] = [(key.decode(), value.decode()) for key, value in message.get("headers", [])]
334 response_started = True
335 elif message["type"] == "http.response.body":
336 assert response_started, 'Received "http.response.body" without "http.response.start".'
337 assert not response_complete.is_set(), 'Received "http.response.body" after response completed.'
338 body = message.get("body", b"")
339 more_body = message.get("more_body", False)
340 if request.method != "HEAD":
341 raw_kwargs["stream"].write(body)
342 if not more_body:
343 raw_kwargs["stream"].seek(0)
344 response_complete.set()
345 elif message["type"] == "http.response.debug":
346 debug_info = message["info"]
348 try:
349 with self.portal_factory() as portal:
350 response_complete = portal.call(anyio.Event)
351 portal.call(self.app, scope, receive, send)
352 except BaseException as exc:
353 if self.raise_server_exceptions:
354 raise exc
356 if self.raise_server_exceptions:
357 assert response_started, "TestClient did not receive any response."
358 elif not response_started:
359 raw_kwargs = {
360 "status_code": 500,
361 "headers": [],
362 "stream": io.BytesIO(),
363 }
365 raw_kwargs["stream"] = httpx.ByteStream(raw_kwargs["stream"].read())
367 response = httpx.Response(**raw_kwargs, request=request)
368 if debug_info is not None:
369 response.extensions["http.response.debug"] = debug_info
370 if "template" in debug_info:
371 response.template = debug_info["template"] # type: ignore[attr-defined]
372 if "context" in debug_info:
373 response.context = debug_info["context"] # type: ignore[attr-defined]
374 return response
377class TestClient(httpx.Client):
378 __test__ = False
379 task: Future[None]
380 portal: anyio.abc.BlockingPortal | None = None
382 def __init__(
383 self,
384 app: ASGIApp,
385 base_url: str = "http://testserver",
386 raise_server_exceptions: bool = True,
387 root_path: str = "",
388 backend: Literal["asyncio", "trio"] = "asyncio",
389 backend_options: dict[str, Any] | None = None,
390 cookies: httpx._types.CookieTypes | None = None,
391 headers: dict[str, str] | None = None,
392 follow_redirects: bool = True,
393 client: tuple[str, int] = ("testclient", 50000),
394 ) -> None:
395 self.async_backend = _AsyncBackend(backend=backend, backend_options=backend_options or {})
396 if _is_asgi3(app):
397 asgi_app = app
398 else:
399 app = cast(ASGI2App, app) # type: ignore[assignment]
400 asgi_app = _WrapASGI2(app) # type: ignore[arg-type]
401 self.app = asgi_app
402 self.app_state: dict[str, Any] = {}
403 transport = _TestClientTransport(
404 self.app,
405 portal_factory=self._portal_factory,
406 raise_server_exceptions=raise_server_exceptions,
407 root_path=root_path,
408 app_state=self.app_state,
409 client=client,
410 )
411 if headers is None:
412 headers = {}
413 headers.setdefault("user-agent", "testclient")
414 super().__init__(
415 base_url=base_url,
416 headers=headers,
417 transport=transport,
418 follow_redirects=follow_redirects,
419 cookies=cookies,
420 )
422 @contextlib.contextmanager
423 def _portal_factory(self) -> Generator[anyio.abc.BlockingPortal, None, None]:
424 if self.portal is not None:
425 yield self.portal
426 else:
427 with anyio.from_thread.start_blocking_portal(**self.async_backend) as portal:
428 yield portal
430 def request( # type: ignore[override]
431 self,
432 method: str,
433 url: httpx._types.URLTypes,
434 *,
435 content: httpx._types.RequestContent | None = None,
436 data: _RequestData | None = None,
437 files: httpx._types.RequestFiles | None = None,
438 json: Any = None,
439 params: httpx._types.QueryParamTypes | None = None,
440 headers: httpx._types.HeaderTypes | None = None,
441 cookies: httpx._types.CookieTypes | None = None,
442 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
443 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
444 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
445 extensions: dict[str, Any] | None = None,
446 ) -> httpx.Response:
447 if timeout is not httpx.USE_CLIENT_DEFAULT:
448 warnings.warn(
449 "You should not use the 'timeout' argument with the TestClient. "
450 "See https://github.com/Kludex/starlette/issues/1108 for more information.",
451 StarletteDeprecationWarning,
452 stacklevel=2,
453 )
454 url = self._merge_url(url)
455 return super().request(
456 method,
457 url,
458 content=content,
459 data=data,
460 files=files,
461 json=json,
462 params=params,
463 headers=headers,
464 cookies=cookies,
465 auth=auth,
466 follow_redirects=follow_redirects,
467 timeout=timeout,
468 extensions=extensions,
469 )
471 def get( # type: ignore[override]
472 self,
473 url: httpx._types.URLTypes,
474 *,
475 params: httpx._types.QueryParamTypes | None = None,
476 headers: httpx._types.HeaderTypes | None = None,
477 cookies: httpx._types.CookieTypes | None = None,
478 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
479 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
480 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
481 extensions: dict[str, Any] | None = None,
482 ) -> httpx.Response:
483 return super().get(
484 url,
485 params=params,
486 headers=headers,
487 cookies=cookies,
488 auth=auth,
489 follow_redirects=follow_redirects,
490 timeout=timeout,
491 extensions=extensions,
492 )
494 def options( # type: ignore[override]
495 self,
496 url: httpx._types.URLTypes,
497 *,
498 params: httpx._types.QueryParamTypes | None = None,
499 headers: httpx._types.HeaderTypes | None = None,
500 cookies: httpx._types.CookieTypes | None = None,
501 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
502 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
503 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
504 extensions: dict[str, Any] | None = None,
505 ) -> httpx.Response:
506 return super().options(
507 url,
508 params=params,
509 headers=headers,
510 cookies=cookies,
511 auth=auth,
512 follow_redirects=follow_redirects,
513 timeout=timeout,
514 extensions=extensions,
515 )
517 def head( # type: ignore[override]
518 self,
519 url: httpx._types.URLTypes,
520 *,
521 params: httpx._types.QueryParamTypes | None = None,
522 headers: httpx._types.HeaderTypes | None = None,
523 cookies: httpx._types.CookieTypes | None = None,
524 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
525 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
526 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
527 extensions: dict[str, Any] | None = None,
528 ) -> httpx.Response:
529 return super().head(
530 url,
531 params=params,
532 headers=headers,
533 cookies=cookies,
534 auth=auth,
535 follow_redirects=follow_redirects,
536 timeout=timeout,
537 extensions=extensions,
538 )
540 def post( # type: ignore[override]
541 self,
542 url: httpx._types.URLTypes,
543 *,
544 content: httpx._types.RequestContent | None = None,
545 data: _RequestData | None = None,
546 files: httpx._types.RequestFiles | None = None,
547 json: Any = None,
548 params: httpx._types.QueryParamTypes | None = None,
549 headers: httpx._types.HeaderTypes | None = None,
550 cookies: httpx._types.CookieTypes | None = None,
551 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
552 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
553 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
554 extensions: dict[str, Any] | None = None,
555 ) -> httpx.Response:
556 return super().post(
557 url,
558 content=content,
559 data=data,
560 files=files,
561 json=json,
562 params=params,
563 headers=headers,
564 cookies=cookies,
565 auth=auth,
566 follow_redirects=follow_redirects,
567 timeout=timeout,
568 extensions=extensions,
569 )
571 def put( # type: ignore[override]
572 self,
573 url: httpx._types.URLTypes,
574 *,
575 content: httpx._types.RequestContent | None = None,
576 data: _RequestData | None = None,
577 files: httpx._types.RequestFiles | None = None,
578 json: Any = None,
579 params: httpx._types.QueryParamTypes | None = None,
580 headers: httpx._types.HeaderTypes | None = None,
581 cookies: httpx._types.CookieTypes | None = None,
582 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
583 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
584 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
585 extensions: dict[str, Any] | None = None,
586 ) -> httpx.Response:
587 return super().put(
588 url,
589 content=content,
590 data=data,
591 files=files,
592 json=json,
593 params=params,
594 headers=headers,
595 cookies=cookies,
596 auth=auth,
597 follow_redirects=follow_redirects,
598 timeout=timeout,
599 extensions=extensions,
600 )
602 def patch( # type: ignore[override]
603 self,
604 url: httpx._types.URLTypes,
605 *,
606 content: httpx._types.RequestContent | None = None,
607 data: _RequestData | None = None,
608 files: httpx._types.RequestFiles | None = None,
609 json: Any = None,
610 params: httpx._types.QueryParamTypes | None = None,
611 headers: httpx._types.HeaderTypes | None = None,
612 cookies: httpx._types.CookieTypes | None = None,
613 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
614 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
615 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
616 extensions: dict[str, Any] | None = None,
617 ) -> httpx.Response:
618 return super().patch(
619 url,
620 content=content,
621 data=data,
622 files=files,
623 json=json,
624 params=params,
625 headers=headers,
626 cookies=cookies,
627 auth=auth,
628 follow_redirects=follow_redirects,
629 timeout=timeout,
630 extensions=extensions,
631 )
633 def delete( # type: ignore[override]
634 self,
635 url: httpx._types.URLTypes,
636 *,
637 params: httpx._types.QueryParamTypes | None = None,
638 headers: httpx._types.HeaderTypes | None = None,
639 cookies: httpx._types.CookieTypes | None = None,
640 auth: httpx._types.AuthTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
641 follow_redirects: bool | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
642 timeout: httpx._types.TimeoutTypes | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
643 extensions: dict[str, Any] | None = None,
644 ) -> httpx.Response:
645 return super().delete(
646 url,
647 params=params,
648 headers=headers,
649 cookies=cookies,
650 auth=auth,
651 follow_redirects=follow_redirects,
652 timeout=timeout,
653 extensions=extensions,
654 )
656 def websocket_connect(
657 self,
658 url: str,
659 subprotocols: Sequence[str] | None = None,
660 **kwargs: Any,
661 ) -> WebSocketTestSession:
662 url = urljoin("ws://testserver", url)
663 headers = kwargs.get("headers", {})
664 headers.setdefault("connection", "upgrade")
665 headers.setdefault("sec-websocket-key", "testserver==")
666 headers.setdefault("sec-websocket-version", "13")
667 if subprotocols is not None:
668 headers.setdefault("sec-websocket-protocol", ", ".join(subprotocols))
669 kwargs["headers"] = headers
670 try:
671 super().request("GET", url, **kwargs)
672 except _Upgrade as exc:
673 session = exc.session
674 else:
675 raise RuntimeError("Expected WebSocket upgrade") # pragma: no cover
677 return session
679 def __enter__(self) -> Self:
680 with contextlib.ExitStack() as stack:
681 self.portal = portal = stack.enter_context(anyio.from_thread.start_blocking_portal(**self.async_backend))
683 @stack.callback
684 def reset_portal() -> None:
685 self.portal = None
687 send: anyio.create_memory_object_stream[MutableMapping[str, Any] | None] = (
688 anyio.create_memory_object_stream(math.inf)
689 )
690 receive: anyio.create_memory_object_stream[MutableMapping[str, Any]] = anyio.create_memory_object_stream(
691 math.inf
692 )
693 for channel in (*send, *receive):
694 stack.callback(channel.close)
695 self.stream_send = StapledObjectStream(*send)
696 self.stream_receive = StapledObjectStream(*receive)
697 self.task = portal.start_task_soon(self.lifespan)
698 portal.call(self.wait_startup)
700 @stack.callback
701 def wait_shutdown() -> None:
702 portal.call(self.wait_shutdown)
704 self.exit_stack = stack.pop_all()
706 return self
708 def __exit__(self, *args: Any) -> None:
709 self.exit_stack.close()
711 async def lifespan(self) -> None:
712 scope = {"type": "lifespan", "state": self.app_state}
713 try:
714 await self.app(scope, self.stream_receive.receive, self.stream_send.send)
715 finally:
716 await self.stream_send.send(None)
718 async def wait_startup(self) -> None:
719 await self.stream_receive.send({"type": "lifespan.startup"})
721 async def receive() -> Any:
722 message = await self.stream_send.receive()
723 if message is None:
724 self.task.result()
725 return message
727 message = await receive()
728 assert message["type"] in (
729 "lifespan.startup.complete",
730 "lifespan.startup.failed",
731 )
732 if message["type"] == "lifespan.startup.failed":
733 await receive()
735 async def wait_shutdown(self) -> None:
736 async def receive() -> Any:
737 message = await self.stream_send.receive()
738 if message is None:
739 self.task.result()
740 return message
742 await self.stream_receive.send({"type": "lifespan.shutdown"})
743 message = await receive()
744 assert message["type"] in (
745 "lifespan.shutdown.complete",
746 "lifespan.shutdown.failed",
747 )
748 if message["type"] == "lifespan.shutdown.failed":
749 await receive()