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

316 statements  

1from __future__ import annotations 

2 

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 

16 

17import anyio 

18import anyio.abc 

19import anyio.from_thread 

20from anyio.streams.stapled import StapledObjectStream 

21 

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 

26 

27if sys.version_info >= (3, 11): # pragma: no cover 

28 from typing import Self 

29else: # pragma: no cover 

30 from typing_extensions import Self 

31 

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 ) 

52 

53_PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]] 

54 

55ASGIInstance = Callable[[Receive, Send], Awaitable[None]] 

56ASGI2App = Callable[[Scope], ASGIInstance] 

57ASGI3App = Callable[[Scope, Receive, Send], Awaitable[None]] 

58 

59 

60_RequestData = Mapping[str, str | Iterable[str] | bytes] 

61 

62 

63def _is_asgi3(app: ASGI2App | ASGI3App) -> TypeGuard[ASGI3App]: 

64 if inspect.isclass(app): 

65 return hasattr(app, "__await__") 

66 return is_async_callable(app) 

67 

68 

69class _WrapASGI2: 

70 """ 

71 Provide an ASGI3 interface onto an ASGI2 app. 

72 """ 

73 

74 def __init__(self, app: ASGI2App) -> None: 

75 self.app = app 

76 

77 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 

78 instance = self.app(scope) 

79 await instance(receive, send) 

80 

81 

82class _AsyncBackend(TypedDict): 

83 backend: str 

84 backend_options: dict[str, Any] 

85 

86 

87class _Upgrade(Exception): 

88 def __init__(self, session: WebSocketTestSession) -> None: 

89 self.session = session 

90 

91 

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 """ 

100 

101 

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 

114 

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 

129 

130 def __exit__(self, *args: Any) -> bool | None: 

131 return self.exit_stack.__exit__(*args) 

132 

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) 

146 

147 # wait for cs.cancel to be called before closing streams 

148 await anyio.sleep_forever() 

149 

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)) 

164 

165 def send(self, message: Message) -> None: 

166 self.portal.call(self._receive_tx.send, message) 

167 

168 def send_text(self, data: str) -> None: 

169 self.send({"type": "websocket.receive", "text": data}) 

170 

171 def send_bytes(self, data: bytes) -> None: 

172 self.send({"type": "websocket.receive", "bytes": data}) 

173 

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")}) 

180 

181 def close(self, code: int = 1000, reason: str | None = None) -> None: 

182 self.send({"type": "websocket.disconnect", "code": code, "reason": reason}) 

183 

184 def receive(self) -> Message: 

185 return self.portal.call(self._send_rx.receive) 

186 

187 def receive_text(self) -> str: 

188 message = self.receive() 

189 self._raise_on_close(message) 

190 return cast(str, message["text"]) 

191 

192 def receive_bytes(self) -> bytes: 

193 message = self.receive() 

194 self._raise_on_close(message) 

195 return cast(bytes, message["bytes"]) 

196 

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) 

205 

206 

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 

224 

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") 

231 

232 default_port = {"http": 80, "ws": 80, "https": 443, "wss": 443}[scheme] 

233 

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 

240 

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())] 

248 

249 # Include other request headers. 

250 headers += [(key.lower().encode(), value.encode()) for key, value in request.headers.multi_items()] 

251 

252 scope: dict[str, Any] 

253 

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) 

276 

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 } 

292 

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 

298 

299 async def receive() -> Message: 

300 nonlocal request_complete 

301 

302 if request_complete: 

303 if not response_complete.is_set(): 

304 await response_complete.wait() 

305 return {"type": "http.disconnect"} 

306 

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 

323 

324 request_complete = True 

325 return {"type": "http.request", "body": body_bytes} 

326 

327 async def send(message: Message) -> None: 

328 nonlocal raw_kwargs, response_started, debug_info 

329 

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"] 

347 

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 

355 

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 } 

364 

365 raw_kwargs["stream"] = httpx.ByteStream(raw_kwargs["stream"].read()) 

366 

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 

375 

376 

377class TestClient(httpx.Client): 

378 __test__ = False 

379 task: Future[None] 

380 portal: anyio.abc.BlockingPortal | None = None 

381 

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 ) 

421 

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 

429 

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 ) 

470 

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 ) 

493 

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 ) 

516 

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 ) 

539 

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 ) 

570 

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 ) 

601 

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 ) 

632 

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 ) 

655 

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 

676 

677 return session 

678 

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)) 

682 

683 @stack.callback 

684 def reset_portal() -> None: 

685 self.portal = None 

686 

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) 

699 

700 @stack.callback 

701 def wait_shutdown() -> None: 

702 portal.call(self.wait_shutdown) 

703 

704 self.exit_stack = stack.pop_all() 

705 

706 return self 

707 

708 def __exit__(self, *args: Any) -> None: 

709 self.exit_stack.close() 

710 

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) 

717 

718 async def wait_startup(self) -> None: 

719 await self.stream_receive.send({"type": "lifespan.startup"}) 

720 

721 async def receive() -> Any: 

722 message = await self.stream_send.receive() 

723 if message is None: 

724 self.task.result() 

725 return message 

726 

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() 

734 

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 

741 

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()