litellm/tests/integration/_support/asgi.py
devin-ai-integration[bot] e26a6450c8
test(integration): add MCP gateway coverage wave 1 with a dedicated mcp shard and proxy coverage artifact (#42711)
* test(integration): drop the contracts.json manifest and the covers requirement

Groups live as a GROUPS literal in run.py, the browser expectations move next to the
browser tests, and the runner fails only on pytest failure, collection errors or a
selected file that collects zero tests. The covers marker stays registered for the
existing tests but is no longer checked. The mcp directory gets its own group

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci(integration): run mcp as its own shard with xdist and a peer proxy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci(integration): INTEGRATION_COVERAGE=1 runs the proxy under coverage for the MCP modules

The mcp shard sets it. The proxy and its peer start under coverage run in parallel mode,
get SIGTERM after the tests so coverage flushes, and the combined text and HTML reports
land in the suite results that CircleCI already stores as artifacts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci(integration): let the test proxy flush coverage when uvicorn re-raises SIGTERM

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): add SSE, stdio, scripted, OpenAPI and OAuth 2.1 MCP peer doubles

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): add MCP transport and access-control matrices

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): add MCP credential and OAuth flow coverage

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): add MCP LLM endpoint, accounting, guardrail, resilience and lifecycle coverage

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): stop the same-URL grant test from counting a late initialize as a leaked call and satisfy the test-tree lint

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): assert the REST denied-server listing is refused or empty

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): pin the REST denied-server listing to 403 access_denied

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yuneng <yuneng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 07:48:46 -07:00

83 lines
3.2 KiB
Python

import asyncio
import logging
import queue
import socket
import threading
import time
from collections.abc import Callable, Iterator
from concurrent.futures import Future
from contextlib import contextmanager
from typing import Final
import uvicorn
from starlette.types import ASGIApp
@contextmanager
def asgi_server(app: ASGIApp, *, before_stop: Callable[[], None] | None = None) -> Iterator[str]:
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
port: Final = listener.getsockname()[1]
server: Final = uvicorn.Server(
uvicorn.Config(
app,
host="127.0.0.1",
port=port,
lifespan="on",
log_level="warning",
timeout_keep_alive=1,
timeout_graceful_shutdown=5,
)
)
errors: Final[queue.SimpleQueue[str]] = queue.SimpleQueue()
loop_ready: Final[Future[asyncio.AbstractEventLoop]] = Future()
def serve() -> None:
with asyncio.Runner() as runner:
loop_ready.set_result(runner.get_loop())
try:
runner.run(server.serve(sockets=[listener]))
except BaseException as error:
errors.put(type(error).__name__ + ": " + str(error))
if asyncio.all_tasks(runner.get_loop()):
errors.put("Owned ASGI loop retained unfinished tasks")
worker: Final = threading.Thread(target=serve)
class Capture(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
if record.thread == worker.ident and record.levelno >= logging.ERROR:
errors.put(self.format(record))
handler: Final = Capture()
logger: Final = logging.getLogger("uvicorn.error")
logger.addHandler(handler)
worker.start()
try:
deadline: Final = time.monotonic() + 8
while not server.started:
assert worker.is_alive() and time.monotonic() < deadline, "Owned ASGI peer failed readiness"
time.sleep(0.01)
yield f"http://127.0.0.1:{port}"
finally:
if before_stop is not None:
before_stop()
server.should_exit = True
worker.join(timeout=8)
forced: Final = worker.is_alive()
if forced:
server.force_exit = True
loop: Final = loop_ready.result(timeout=1)
def cancel_owned() -> None:
for task in asyncio.all_tasks(loop):
task.cancel()
loop.call_soon_threadsafe(cancel_owned)
worker.join(timeout=3)
logger.removeHandler(handler)
assert not worker.is_alive(), "Owned ASGI peer survived forced cleanup"
assert not forced, "Owned ASGI peer required forced cleanup"
assert not server.server_state.tasks, "Owned ASGI peer retained request tasks"
assert not server.lifespan.error_occurred and not server.lifespan.shutdown_failed
assert errors.empty(), tuple(errors.get_nowait() for _ in range(errors.qsize()))