testclient.py 27 KB

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