|
18 | 18 | from collections.abc import Callable |
19 | 19 | from typing import Any |
20 | 20 |
|
| 21 | +import httpx |
| 22 | + |
21 | 23 | _BLOCKING_LOOP_LOCAL = threading.local() |
22 | 24 |
|
23 | 25 |
|
@@ -409,3 +411,62 @@ async def asgi3(scope: dict[str, Any], receive: Any, send: Any) -> None: |
409 | 411 |
|
410 | 412 | asgi_test_app = build_test_app |
411 | 413 | """Alias: ``asgi_test_app(app)`` returns an ASGI3 callable for httpx.ASGITransport.""" |
| 414 | + |
| 415 | + |
| 416 | +class TestClient(httpx.Client): |
| 417 | + """Synchronous test client for OxyRoute apps. |
| 418 | +
|
| 419 | + Wraps the RSGI testing transport and an async httpx client in a background |
| 420 | + thread so it can be used in fully synchronous tests. |
| 421 | + """ |
| 422 | + |
| 423 | + def __init__(self, app: Any, base_url: str = "http://testserver", **kwargs: Any) -> None: |
| 424 | + self.app = app |
| 425 | + self._loop = asyncio.new_event_loop() |
| 426 | + self._thread = threading.Thread(target=self._run_loop, daemon=True) |
| 427 | + self._thread.start() |
| 428 | + |
| 429 | + transport = httpx.ASGITransport(app=asgi_test_app(app), client=("127.0.0.1", 12345)) |
| 430 | + self.async_client = httpx.AsyncClient(transport=transport, base_url=base_url, **kwargs) |
| 431 | + |
| 432 | + super().__init__( |
| 433 | + transport=httpx.MockTransport(lambda r: httpx.Response(200)), |
| 434 | + base_url=base_url, |
| 435 | + **kwargs, |
| 436 | + ) |
| 437 | + |
| 438 | + def _run_loop(self) -> None: |
| 439 | + asyncio.set_event_loop(self._loop) |
| 440 | + self._loop.run_forever() |
| 441 | + |
| 442 | + def _run_sync(self, coro: Any) -> Any: |
| 443 | + return asyncio.run_coroutine_threadsafe(coro, self._loop).result() |
| 444 | + |
| 445 | + def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response: |
| 446 | + resp = self._run_sync(self.async_client.send(request, **kwargs)) |
| 447 | + self._run_sync(resp.aread()) |
| 448 | + return resp |
| 449 | + |
| 450 | + def close(self) -> None: |
| 451 | + self._run_sync(self.async_client.aclose()) |
| 452 | + if self._loop.is_running(): |
| 453 | + self._loop.call_soon_threadsafe(self._loop.stop) |
| 454 | + self._thread.join() |
| 455 | + super().close() |
| 456 | + |
| 457 | + def __enter__(self) -> TestClient: |
| 458 | + self._run_sync(self.async_client.__aenter__()) |
| 459 | + if hasattr(self.app, "__rsgi_init__"): |
| 460 | + init = self.app.__rsgi_init__() |
| 461 | + if asyncio.iscoroutine(init): |
| 462 | + self._run_sync(init) |
| 463 | + return self |
| 464 | + |
| 465 | + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: |
| 466 | + self._run_sync(self.async_client.__aexit__(exc_type, exc_value, traceback)) |
| 467 | + if hasattr(self.app, "__rsgi_del__"): |
| 468 | + dele = self.app.__rsgi_del__() |
| 469 | + if asyncio.iscoroutine(dele): |
| 470 | + self._run_sync(dele) |
| 471 | + self.close() |
| 472 | + super().__exit__(exc_type, exc_value, traceback) |
0 commit comments