mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-02 18:51:31 +08:00
Add ASGI framework compatibility E2E test suite
Docker-based test suite validating gunicorn's ASGI worker against: - Django + Channels - FastAPI - Starlette - Quart - Litestar - BlackSheep Tests cover HTTP scope, HTTP messages, WebSocket, lifespan protocol, and streaming responses. Includes compatibility grid generator. Results: 403/444 tests passed (90%)
This commit is contained in:
parent
1c82d4b518
commit
26ae6e6f47
@ -315,6 +315,25 @@ pip install uvloop
|
|||||||
gunicorn myapp:app --worker-class asgi --asgi-loop uvloop
|
gunicorn myapp:app --worker-class asgi --asgi-loop uvloop
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Framework Compatibility
|
||||||
|
|
||||||
|
The ASGI worker has been tested for compatibility with major ASGI frameworks.
|
||||||
|
|
||||||
|
| Framework | HTTP Scope | HTTP Messages | WebSocket | Lifespan | Streaming | Total |
|
||||||
|
|-----------|---------|---------|---------|---------|---------|-------|
|
||||||
|
| Django + Channels | 19/19 | 18/19 | 13/19 | 7/8 | 9/9 | 66/74 |
|
||||||
|
| FastAPI | 19/19 | 18/19 | 19/19 | 8/8 | 9/9 | 73/74 |
|
||||||
|
| Starlette | 19/19 | 18/19 | 19/19 | 8/8 | 9/9 | 73/74 |
|
||||||
|
| Quart | 18/19 | 17/19 | 11/19 | 8/8 | 9/9 | 63/74 |
|
||||||
|
| Litestar | 18/19 | 11/19 | 17/19 | 8/8 | 9/9 | 63/74 |
|
||||||
|
| BlackSheep | 19/19 | 18/19 | 19/19 | 8/8 | 1/9 | 65/74 |
|
||||||
|
|
||||||
|
**Overall:** 403/444 tests passed (90%)
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
The compatibility test suite is located in `tests/docker/asgi_framework_compat/`.
|
||||||
|
Run `docker compose up -d --build` followed by `pytest tests/ -v` to execute the tests.
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
- [Settings Reference](reference/settings.md#asgi_loop) - All ASGI-related settings
|
- [Settings Reference](reference/settings.md#asgi_loop) - All ASGI-related settings
|
||||||
|
|||||||
116
tests/docker/asgi_framework_compat/README.md
Normal file
116
tests/docker/asgi_framework_compat/README.md
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# ASGI Framework Compatibility Test Suite
|
||||||
|
|
||||||
|
This test suite validates gunicorn's native ASGI worker (`-k asgi`) against
|
||||||
|
multiple ASGI frameworks to ensure protocol compliance.
|
||||||
|
|
||||||
|
## Frameworks Tested
|
||||||
|
|
||||||
|
| Framework | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Django + Channels | Django with Channels for WebSocket |
|
||||||
|
| FastAPI | Modern, fast API framework (Starlette-based) |
|
||||||
|
| Starlette | Pure ASGI framework |
|
||||||
|
| Quart | Flask-like async framework |
|
||||||
|
| Litestar | Modern ASGI framework |
|
||||||
|
| BlackSheep | High-performance ASGI framework |
|
||||||
|
|
||||||
|
## Test Categories
|
||||||
|
|
||||||
|
- **HTTP Scope**: ASGI 3.0 HTTP scope compliance
|
||||||
|
- **HTTP Messages**: Request/response message handling
|
||||||
|
- **WebSocket**: WebSocket protocol compliance
|
||||||
|
- **Lifespan**: Startup/shutdown lifecycle
|
||||||
|
- **Streaming**: Chunked responses and SSE
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and start all framework containers
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# Generate compatibility grid
|
||||||
|
python scripts/generate_grid.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Event Loop Variants
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test with auto-detection (uvloop if available)
|
||||||
|
ASGI_LOOP=auto docker compose up -d --build
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# Test with asyncio only
|
||||||
|
ASGI_LOOP=asyncio docker compose up -d --build
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# Test with uvloop explicitly
|
||||||
|
ASGI_LOOP=uvloop docker compose up -d --build
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# Generate combined report for both loop types
|
||||||
|
python scripts/generate_grid.py --loop both
|
||||||
|
```
|
||||||
|
|
||||||
|
## Single Framework Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test only FastAPI
|
||||||
|
pytest tests/ -v --framework fastapi
|
||||||
|
|
||||||
|
# Test only Django
|
||||||
|
pytest tests/ -v --framework django
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
asgi_framework_compat/
|
||||||
|
├── conftest.py # Test fixtures
|
||||||
|
├── docker-compose.yml # Container orchestration
|
||||||
|
├── requirements.txt # Test dependencies
|
||||||
|
├── frameworks/
|
||||||
|
│ ├── contract.py # Endpoint contract
|
||||||
|
│ ├── django_app/ # Django implementation
|
||||||
|
│ ├── fastapi_app/ # FastAPI implementation
|
||||||
|
│ ├── starlette_app/ # Starlette implementation
|
||||||
|
│ ├── quart_app/ # Quart implementation
|
||||||
|
│ ├── litestar_app/ # Litestar implementation
|
||||||
|
│ └── blacksheep_app/ # BlackSheep implementation
|
||||||
|
├── tests/
|
||||||
|
│ ├── test_http_scope.py
|
||||||
|
│ ├── test_http_messages.py
|
||||||
|
│ ├── test_websocket_scope.py
|
||||||
|
│ ├── test_lifespan_scope.py
|
||||||
|
│ └── test_streaming.py
|
||||||
|
├── scripts/
|
||||||
|
│ └── generate_grid.py # Compatibility matrix
|
||||||
|
└── results/ # Generated reports
|
||||||
|
```
|
||||||
|
|
||||||
|
## Container Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start containers
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Stop containers
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Rebuild specific framework
|
||||||
|
docker compose build fastapi
|
||||||
|
docker compose up -d fastapi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
After running `generate_grid.py`, check the `results/` directory for:
|
||||||
|
|
||||||
|
- `compatibility_grid_*.md` - Markdown compatibility matrices
|
||||||
|
- `compatibility_grid_*.json` - JSON data for programmatic access
|
||||||
211
tests/docker/asgi_framework_compat/conftest.py
Normal file
211
tests/docker/asgi_framework_compat/conftest.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
Pytest configuration for ASGI Framework Compatibility Tests
|
||||||
|
|
||||||
|
This module provides fixtures for parameterized testing across multiple
|
||||||
|
ASGI frameworks running in Docker containers with gunicorn's ASGI worker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
# Framework configuration
|
||||||
|
FRAMEWORKS = {
|
||||||
|
"django": {"port": 8001, "websocket_support": True},
|
||||||
|
"fastapi": {"port": 8002, "websocket_support": True},
|
||||||
|
"starlette": {"port": 8003, "websocket_support": True},
|
||||||
|
"quart": {"port": 8004, "websocket_support": True},
|
||||||
|
"litestar": {"port": 8005, "websocket_support": True},
|
||||||
|
"blacksheep": {"port": 8006, "websocket_support": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Host for docker containers
|
||||||
|
DOCKER_HOST = os.environ.get("DOCKER_HOST_IP", "127.0.0.1")
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_addoption(parser):
|
||||||
|
"""Add command line options for framework selection."""
|
||||||
|
parser.addoption(
|
||||||
|
"--framework",
|
||||||
|
action="store",
|
||||||
|
default=None,
|
||||||
|
help="Run tests only for specific framework (django, fastapi, etc.)",
|
||||||
|
)
|
||||||
|
parser.addoption(
|
||||||
|
"--skip-docker-check",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Skip Docker container health checks",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""Register custom markers."""
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers", "framework(name): mark test to run only for specific framework"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_collection_modifyitems(config, items):
|
||||||
|
"""Filter tests based on framework selection."""
|
||||||
|
framework_filter = config.getoption("--framework")
|
||||||
|
if framework_filter:
|
||||||
|
skip_other = pytest.mark.skip(
|
||||||
|
reason=f"Only running tests for {framework_filter}"
|
||||||
|
)
|
||||||
|
for item in items:
|
||||||
|
markers = [m for m in item.iter_markers(name="framework")]
|
||||||
|
if markers:
|
||||||
|
framework_names = [m.args[0] for m in markers]
|
||||||
|
if framework_filter not in framework_names:
|
||||||
|
item.add_marker(skip_other)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def docker_compose_file():
|
||||||
|
"""Return path to docker-compose file."""
|
||||||
|
return os.path.join(os.path.dirname(__file__), "docker-compose.yml")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_service(url: str, timeout: int = 60) -> bool:
|
||||||
|
"""Wait for a service to become healthy."""
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
try:
|
||||||
|
response = httpx.get(f"{url}/health", timeout=5.0)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return True
|
||||||
|
except (httpx.ConnectError, httpx.TimeoutException):
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def docker_services(docker_compose_file, request):
|
||||||
|
"""Start Docker services for testing."""
|
||||||
|
if request.config.getoption("--skip-docker-check"):
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if containers are already running
|
||||||
|
all_healthy = True
|
||||||
|
for name, config in FRAMEWORKS.items():
|
||||||
|
url = f"http://{DOCKER_HOST}:{config['port']}"
|
||||||
|
try:
|
||||||
|
response = httpx.get(f"{url}/health", timeout=2.0)
|
||||||
|
if response.status_code != 200:
|
||||||
|
all_healthy = False
|
||||||
|
break
|
||||||
|
except (httpx.ConnectError, httpx.TimeoutException):
|
||||||
|
all_healthy = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if all_healthy:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
# Start containers
|
||||||
|
compose_dir = os.path.dirname(docker_compose_file)
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "compose", "up", "-d", "--build"],
|
||||||
|
cwd=compose_dir,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for all services to be healthy
|
||||||
|
for name, config in FRAMEWORKS.items():
|
||||||
|
url = f"http://{DOCKER_HOST}:{config['port']}"
|
||||||
|
if not wait_for_service(url):
|
||||||
|
pytest.fail(f"Service {name} failed to start")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Optionally stop containers after tests
|
||||||
|
if os.environ.get("CLEANUP_DOCKER", "0") == "1":
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "compose", "down"],
|
||||||
|
cwd=compose_dir,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=list(FRAMEWORKS.keys()))
|
||||||
|
def framework(request, docker_services) -> str:
|
||||||
|
"""Parameterized fixture that yields each framework name."""
|
||||||
|
return request.param
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def framework_config(framework) -> dict:
|
||||||
|
"""Return configuration for current framework."""
|
||||||
|
return FRAMEWORKS[framework]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def framework_url(framework) -> str:
|
||||||
|
"""Return HTTP URL for current framework."""
|
||||||
|
port = FRAMEWORKS[framework]["port"]
|
||||||
|
return f"http://{DOCKER_HOST}:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def framework_ws_url(framework) -> str:
|
||||||
|
"""Return WebSocket URL for current framework."""
|
||||||
|
port = FRAMEWORKS[framework]["port"]
|
||||||
|
return f"ws://{DOCKER_HOST}:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def http_client(framework_url) -> AsyncGenerator[httpx.AsyncClient, None]:
|
||||||
|
"""Async HTTP client for testing."""
|
||||||
|
async with httpx.AsyncClient(base_url=framework_url, timeout=30.0) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ws_client(framework_ws_url):
|
||||||
|
"""WebSocket client factory for testing."""
|
||||||
|
|
||||||
|
async def connect(path: str, **kwargs):
|
||||||
|
uri = f"{framework_ws_url}{path}"
|
||||||
|
return await websockets.connect(uri, **kwargs)
|
||||||
|
|
||||||
|
return connect
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||||
|
def pytest_runtest_makereport(item, call):
|
||||||
|
"""Store test report for result recording."""
|
||||||
|
outcome = yield
|
||||||
|
rep = outcome.get_result()
|
||||||
|
setattr(item, f"rep_{rep.when}", rep)
|
||||||
|
|
||||||
|
|
||||||
|
# Utility fixtures
|
||||||
|
@pytest.fixture
|
||||||
|
def random_bytes():
|
||||||
|
"""Generate random bytes for testing."""
|
||||||
|
|
||||||
|
def _generate(size: int) -> bytes:
|
||||||
|
return os.urandom(size)
|
||||||
|
|
||||||
|
return _generate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def large_body():
|
||||||
|
"""Generate large request/response body."""
|
||||||
|
|
||||||
|
def _generate(size: int) -> bytes:
|
||||||
|
return b"x" * size
|
||||||
|
|
||||||
|
return _generate
|
||||||
86
tests/docker/asgi_framework_compat/docker-compose.yml
Normal file
86
tests/docker/asgi_framework_compat/docker-compose.yml
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# ASGI Framework Compatibility Test Suite
|
||||||
|
# Tests gunicorn's native ASGI worker with multiple frameworks
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose up -d --build
|
||||||
|
# ASGI_LOOP=asyncio docker compose up -d --build
|
||||||
|
# ASGI_LOOP=uvloop docker compose up -d --build
|
||||||
|
|
||||||
|
x-healthcheck: &healthcheck
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
services:
|
||||||
|
django:
|
||||||
|
build:
|
||||||
|
context: ./frameworks/django_app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
|
command: ["gunicorn", "asgi:application", "-k", "asgi", "-b", "0.0.0.0:8000", "--workers", "1", "--worker-connections", "100", "--asgi-loop", "${ASGI_LOOP:-auto}"]
|
||||||
|
networks:
|
||||||
|
- asgi_test_network
|
||||||
|
<<: *healthcheck
|
||||||
|
|
||||||
|
fastapi:
|
||||||
|
build:
|
||||||
|
context: ./frameworks/fastapi_app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8002:8000"
|
||||||
|
command: ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000", "--workers", "1", "--worker-connections", "100", "--asgi-loop", "${ASGI_LOOP:-auto}"]
|
||||||
|
networks:
|
||||||
|
- asgi_test_network
|
||||||
|
<<: *healthcheck
|
||||||
|
|
||||||
|
starlette:
|
||||||
|
build:
|
||||||
|
context: ./frameworks/starlette_app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8003:8000"
|
||||||
|
command: ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000", "--workers", "1", "--worker-connections", "100", "--asgi-loop", "${ASGI_LOOP:-auto}"]
|
||||||
|
networks:
|
||||||
|
- asgi_test_network
|
||||||
|
<<: *healthcheck
|
||||||
|
|
||||||
|
quart:
|
||||||
|
build:
|
||||||
|
context: ./frameworks/quart_app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8004:8000"
|
||||||
|
command: ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000", "--workers", "1", "--worker-connections", "100", "--asgi-loop", "${ASGI_LOOP:-auto}"]
|
||||||
|
networks:
|
||||||
|
- asgi_test_network
|
||||||
|
<<: *healthcheck
|
||||||
|
|
||||||
|
litestar:
|
||||||
|
build:
|
||||||
|
context: ./frameworks/litestar_app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8005:8000"
|
||||||
|
command: ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000", "--workers", "1", "--worker-connections", "100", "--asgi-loop", "${ASGI_LOOP:-auto}"]
|
||||||
|
networks:
|
||||||
|
- asgi_test_network
|
||||||
|
<<: *healthcheck
|
||||||
|
|
||||||
|
blacksheep:
|
||||||
|
build:
|
||||||
|
context: ./frameworks/blacksheep_app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8006:8000"
|
||||||
|
command: ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000", "--workers", "1", "--worker-connections", "100", "--asgi-loop", "${ASGI_LOOP:-auto}"]
|
||||||
|
networks:
|
||||||
|
- asgi_test_network
|
||||||
|
<<: *healthcheck
|
||||||
|
|
||||||
|
networks:
|
||||||
|
asgi_test_network:
|
||||||
|
driver: bridge
|
||||||
@ -0,0 +1 @@
|
|||||||
|
"""ASGI Framework implementations for compatibility testing."""
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for healthcheck and git for pip install from git
|
||||||
|
RUN apt-get update && apt-get install -y curl git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Command specified in docker-compose.yml
|
||||||
|
CMD ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000"]
|
||||||
@ -0,0 +1,227 @@
|
|||||||
|
"""
|
||||||
|
BlackSheep ASGI Application for Compatibility Testing
|
||||||
|
|
||||||
|
Implements the contract endpoints for ASGI 3.0 compliance testing.
|
||||||
|
BlackSheep is a high-performance ASGI framework.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from blacksheep import Application, Request, WebSocket, StreamedContent, Content
|
||||||
|
from blacksheep.server.responses import Response, text, json as json_resp
|
||||||
|
|
||||||
|
|
||||||
|
app = Application()
|
||||||
|
|
||||||
|
# Lifespan state
|
||||||
|
lifespan_state = {
|
||||||
|
"startup_called": False,
|
||||||
|
"startup_time": None,
|
||||||
|
"counter": 0,
|
||||||
|
"custom_data": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_start
|
||||||
|
async def on_startup(application: Application) -> None:
|
||||||
|
"""Startup handler."""
|
||||||
|
lifespan_state["startup_called"] = True
|
||||||
|
lifespan_state["startup_time"] = time.time()
|
||||||
|
lifespan_state["custom_data"]["initialized"] = True
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_stop
|
||||||
|
async def on_shutdown(application: Application) -> None:
|
||||||
|
"""Shutdown handler."""
|
||||||
|
lifespan_state["shutdown_called"] = True
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_scope(scope: dict) -> dict:
|
||||||
|
"""Convert ASGI scope to JSON-serializable dict."""
|
||||||
|
result = {}
|
||||||
|
for key, value in scope.items():
|
||||||
|
if key == "headers":
|
||||||
|
result[key] = [
|
||||||
|
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
|
||||||
|
]
|
||||||
|
elif key == "query_string":
|
||||||
|
result[key] = value.decode("latin-1") if value else ""
|
||||||
|
elif key == "server":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "client":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "asgi":
|
||||||
|
result[key] = dict(value)
|
||||||
|
elif key in ("state", "app", "_blacksheep"):
|
||||||
|
continue
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
result[key] = value.decode("latin-1")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
json.dumps(value)
|
||||||
|
result[key] = value
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# HTTP Endpoints
|
||||||
|
@app.router.get("/health")
|
||||||
|
async def health(request: Request) -> Response:
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return text("OK")
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.get("/scope")
|
||||||
|
async def scope_endpoint(request: Request) -> Response:
|
||||||
|
"""Return full ASGI scope as JSON."""
|
||||||
|
scope_data = serialize_scope(request.scope)
|
||||||
|
return json_resp(scope_data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.post("/echo")
|
||||||
|
async def echo(request: Request) -> Response:
|
||||||
|
"""Echo request body back."""
|
||||||
|
body = await request.read()
|
||||||
|
content_type = request.get_first_header(b"content-type")
|
||||||
|
if content_type:
|
||||||
|
ct = content_type
|
||||||
|
else:
|
||||||
|
ct = b"application/octet-stream"
|
||||||
|
return Response(200, content=Content(ct, body))
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.get("/headers")
|
||||||
|
async def headers_endpoint(request: Request) -> Response:
|
||||||
|
"""Return request headers as JSON."""
|
||||||
|
headers_dict = {
|
||||||
|
h[0].decode("latin-1"): h[1].decode("latin-1") for h in request.headers
|
||||||
|
}
|
||||||
|
return json_resp(headers_dict)
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.get("/status/{code}")
|
||||||
|
async def status_endpoint(request: Request, code: int) -> Response:
|
||||||
|
"""Return specific HTTP status code."""
|
||||||
|
return Response(code, content=Content(b"text/plain", f"Status: {code}".encode()))
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.get("/streaming")
|
||||||
|
async def streaming(request: Request) -> Response:
|
||||||
|
"""Chunked streaming response."""
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
for i in range(10):
|
||||||
|
yield f"chunk-{i}\n".encode()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
return Response(200, content=StreamedContent(b"text/plain", generate))
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.get("/sse")
|
||||||
|
async def sse(request: Request) -> Response:
|
||||||
|
"""Server-Sent Events endpoint."""
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
for i in range(5):
|
||||||
|
yield f"event: message\ndata: {json.dumps({'count': i})}\n\n".encode()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
yield b"event: done\ndata: {}\n\n"
|
||||||
|
|
||||||
|
response = Response(200, content=StreamedContent(b"text/event-stream", generate))
|
||||||
|
response.add_header(b"Cache-Control", b"no-cache")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.get("/large")
|
||||||
|
async def large(request: Request) -> Response:
|
||||||
|
"""Large response body."""
|
||||||
|
size_param = request.query.get("size")
|
||||||
|
size = int(size_param[0]) if size_param else 1024
|
||||||
|
# Cap at 10MB for safety
|
||||||
|
size = min(size, 10 * 1024 * 1024)
|
||||||
|
return Response(200, content=Content(b"application/octet-stream", b"x" * size))
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.get("/delay")
|
||||||
|
async def delay(request: Request) -> Response:
|
||||||
|
"""Delayed response."""
|
||||||
|
seconds_param = request.query.get("seconds")
|
||||||
|
seconds = float(seconds_param[0]) if seconds_param else 1.0
|
||||||
|
# Cap at 30 seconds
|
||||||
|
seconds = min(seconds, 30)
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
return text(f"Delayed {seconds} seconds")
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.get("/lifespan/state")
|
||||||
|
async def lifespan_state_endpoint(request: Request) -> Response:
|
||||||
|
"""Return lifespan startup state."""
|
||||||
|
return json_resp(lifespan_state)
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.get("/lifespan/counter")
|
||||||
|
async def lifespan_counter(request: Request) -> Response:
|
||||||
|
"""Increment and return counter."""
|
||||||
|
lifespan_state["counter"] += 1
|
||||||
|
return json_resp({"counter": lifespan_state["counter"]})
|
||||||
|
|
||||||
|
|
||||||
|
# WebSocket Endpoints
|
||||||
|
@app.router.ws("/ws/echo")
|
||||||
|
async def ws_echo(websocket: WebSocket) -> None:
|
||||||
|
"""Echo text messages."""
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_text()
|
||||||
|
await websocket.send_text(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.ws("/ws/echo-binary")
|
||||||
|
async def ws_echo_binary(websocket: WebSocket) -> None:
|
||||||
|
"""Echo binary messages."""
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_bytes()
|
||||||
|
await websocket.send_bytes(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.ws("/ws/scope")
|
||||||
|
async def ws_scope(websocket: WebSocket) -> None:
|
||||||
|
"""Send WebSocket scope on connect."""
|
||||||
|
await websocket.accept()
|
||||||
|
scope_data = serialize_scope(websocket.scope)
|
||||||
|
await websocket.send_text(json.dumps(scope_data))
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.ws("/ws/subprotocol")
|
||||||
|
async def ws_subprotocol(websocket: WebSocket) -> None:
|
||||||
|
"""Subprotocol negotiation."""
|
||||||
|
requested = websocket.scope.get("subprotocols", [])
|
||||||
|
selected = requested[0] if requested else None
|
||||||
|
await websocket.accept(subprotocol=selected)
|
||||||
|
await websocket.send_text(json.dumps({"requested": requested, "selected": selected}))
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.router.ws("/ws/close")
|
||||||
|
async def ws_close(websocket: WebSocket) -> None:
|
||||||
|
"""Close with specific code."""
|
||||||
|
await websocket.accept()
|
||||||
|
query_string = websocket.scope.get("query_string", b"").decode()
|
||||||
|
code = 1000
|
||||||
|
for param in query_string.split("&"):
|
||||||
|
if param.startswith("code="):
|
||||||
|
code = int(param.split("=")[1])
|
||||||
|
break
|
||||||
|
await websocket.close(code=code)
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
gunicorn @ git+https://github.com/benoitc/gunicorn.git@master
|
||||||
|
blacksheep>=2.0.0
|
||||||
|
uvloop>=0.19.0
|
||||||
|
websockets>=12.0
|
||||||
|
httptools>=0.6.0
|
||||||
143
tests/docker/asgi_framework_compat/frameworks/contract.py
Normal file
143
tests/docker/asgi_framework_compat/frameworks/contract.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
ASGI Framework Contract Definition
|
||||||
|
|
||||||
|
This module defines the required endpoints that each framework must implement
|
||||||
|
for compatibility testing with gunicorn's ASGI worker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# HTTP Endpoints Contract
|
||||||
|
HTTP_ENDPOINTS = {
|
||||||
|
"health": {
|
||||||
|
"path": "/health",
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Health check endpoint",
|
||||||
|
"expected_status": 200,
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"path": "/scope",
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Return full ASGI scope as JSON",
|
||||||
|
"expected_status": 200,
|
||||||
|
"expected_content_type": "application/json",
|
||||||
|
},
|
||||||
|
"echo": {
|
||||||
|
"path": "/echo",
|
||||||
|
"method": "POST",
|
||||||
|
"description": "Echo request body back",
|
||||||
|
"expected_status": 200,
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"path": "/headers",
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Return request headers as JSON",
|
||||||
|
"expected_status": 200,
|
||||||
|
"expected_content_type": "application/json",
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"path": "/status/{code}",
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Return specific HTTP status code",
|
||||||
|
},
|
||||||
|
"streaming": {
|
||||||
|
"path": "/streaming",
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Chunked streaming response",
|
||||||
|
"expected_status": 200,
|
||||||
|
},
|
||||||
|
"sse": {
|
||||||
|
"path": "/sse",
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Server-Sent Events stream",
|
||||||
|
"expected_status": 200,
|
||||||
|
"expected_content_type": "text/event-stream",
|
||||||
|
},
|
||||||
|
"large": {
|
||||||
|
"path": "/large",
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Large response body (size in query param)",
|
||||||
|
"expected_status": 200,
|
||||||
|
},
|
||||||
|
"delay": {
|
||||||
|
"path": "/delay",
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Delayed response (seconds in query param)",
|
||||||
|
"expected_status": 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket Endpoints Contract
|
||||||
|
WEBSOCKET_ENDPOINTS = {
|
||||||
|
"echo": {
|
||||||
|
"path": "/ws/echo",
|
||||||
|
"description": "Echo text messages",
|
||||||
|
},
|
||||||
|
"echo_binary": {
|
||||||
|
"path": "/ws/echo-binary",
|
||||||
|
"description": "Echo binary messages",
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"path": "/ws/scope",
|
||||||
|
"description": "Send WebSocket scope on connect",
|
||||||
|
},
|
||||||
|
"subprotocol": {
|
||||||
|
"path": "/ws/subprotocol",
|
||||||
|
"description": "Subprotocol negotiation",
|
||||||
|
},
|
||||||
|
"close": {
|
||||||
|
"path": "/ws/close",
|
||||||
|
"description": "Close with specific code (code in query param)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lifespan Endpoints Contract
|
||||||
|
LIFESPAN_ENDPOINTS = {
|
||||||
|
"state": {
|
||||||
|
"path": "/lifespan/state",
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Return startup state",
|
||||||
|
"expected_status": 200,
|
||||||
|
},
|
||||||
|
"counter": {
|
||||||
|
"path": "/lifespan/counter",
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Increment and return counter (state persistence test)",
|
||||||
|
"expected_status": 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ASGI 3.0 Scope Required Keys
|
||||||
|
ASGI_HTTP_SCOPE_REQUIRED_KEYS = [
|
||||||
|
"type",
|
||||||
|
"asgi",
|
||||||
|
"http_version",
|
||||||
|
"method",
|
||||||
|
"scheme",
|
||||||
|
"path",
|
||||||
|
"query_string",
|
||||||
|
"headers",
|
||||||
|
"server",
|
||||||
|
]
|
||||||
|
|
||||||
|
ASGI_WEBSOCKET_SCOPE_REQUIRED_KEYS = [
|
||||||
|
"type",
|
||||||
|
"asgi",
|
||||||
|
"http_version",
|
||||||
|
"scheme",
|
||||||
|
"path",
|
||||||
|
"query_string",
|
||||||
|
"headers",
|
||||||
|
"server",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Valid WebSocket close codes per RFC 6455
|
||||||
|
VALID_WEBSOCKET_CLOSE_CODES = [
|
||||||
|
1000, # Normal closure
|
||||||
|
1001, # Going away
|
||||||
|
1002, # Protocol error
|
||||||
|
1003, # Unsupported data
|
||||||
|
1007, # Invalid frame payload data
|
||||||
|
1008, # Policy violation
|
||||||
|
1009, # Message too big
|
||||||
|
1010, # Mandatory extension
|
||||||
|
1011, # Internal server error
|
||||||
|
]
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for healthcheck and git for pip install from git
|
||||||
|
RUN apt-get update && apt-get install -y curl git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV DJANGO_SETTINGS_MODULE=settings
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Command specified in docker-compose.yml
|
||||||
|
CMD ["gunicorn", "asgi:application", "-k", "asgi", "-b", "0.0.0.0:8000"]
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for Django compatibility testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
|
||||||
|
|
||||||
|
import django
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
from routing import websocket_urlpatterns
|
||||||
|
|
||||||
|
# Lifespan state - shared across the application
|
||||||
|
lifespan_state = {
|
||||||
|
"startup_called": False,
|
||||||
|
"startup_time": None,
|
||||||
|
"counter": 0,
|
||||||
|
"custom_data": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LifespanMiddleware:
|
||||||
|
"""Custom lifespan handler for Django."""
|
||||||
|
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] == "lifespan":
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "lifespan.startup":
|
||||||
|
lifespan_state["startup_called"] = True
|
||||||
|
lifespan_state["startup_time"] = time.time()
|
||||||
|
lifespan_state["custom_data"]["initialized"] = True
|
||||||
|
await send({"type": "lifespan.startup.complete"})
|
||||||
|
elif message["type"] == "lifespan.shutdown":
|
||||||
|
lifespan_state["shutdown_called"] = True
|
||||||
|
await send({"type": "lifespan.shutdown.complete"})
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# Make lifespan_state available to views
|
||||||
|
scope["lifespan_state"] = lifespan_state
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
# Get Django ASGI application
|
||||||
|
django_asgi_app = get_asgi_application()
|
||||||
|
|
||||||
|
# Combine HTTP and WebSocket routing
|
||||||
|
application = LifespanMiddleware(
|
||||||
|
ProtocolTypeRouter({
|
||||||
|
"http": django_asgi_app,
|
||||||
|
"websocket": URLRouter(websocket_urlpatterns),
|
||||||
|
})
|
||||||
|
)
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
Django Channels WebSocket consumers for ASGI compatibility testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_scope(scope: dict) -> dict:
|
||||||
|
"""Convert ASGI scope to JSON-serializable dict."""
|
||||||
|
result = {}
|
||||||
|
for key, value in scope.items():
|
||||||
|
if key == "headers":
|
||||||
|
result[key] = [
|
||||||
|
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
|
||||||
|
]
|
||||||
|
elif key == "query_string":
|
||||||
|
result[key] = value.decode("latin-1") if value else ""
|
||||||
|
elif key == "server":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "client":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "asgi":
|
||||||
|
result[key] = dict(value)
|
||||||
|
elif key in ("state", "app", "url_route", "path_remaining"):
|
||||||
|
continue
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
result[key] = value.decode("latin-1")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
json.dumps(value)
|
||||||
|
result[key] = value
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class EchoConsumer(AsyncWebsocketConsumer):
|
||||||
|
"""Echo text messages."""
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
async def receive(self, text_data=None, bytes_data=None):
|
||||||
|
if text_data:
|
||||||
|
await self.send(text_data=text_data)
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EchoBinaryConsumer(AsyncWebsocketConsumer):
|
||||||
|
"""Echo binary messages."""
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
async def receive(self, text_data=None, bytes_data=None):
|
||||||
|
if bytes_data:
|
||||||
|
await self.send(bytes_data=bytes_data)
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ScopeConsumer(AsyncWebsocketConsumer):
|
||||||
|
"""Send WebSocket scope on connect."""
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
await self.accept()
|
||||||
|
scope_data = serialize_scope(self.scope)
|
||||||
|
await self.send(text_data=json.dumps(scope_data))
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SubprotocolConsumer(AsyncWebsocketConsumer):
|
||||||
|
"""Subprotocol negotiation."""
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
requested = self.scope.get("subprotocols", [])
|
||||||
|
selected = requested[0] if requested else None
|
||||||
|
await self.accept(subprotocol=selected)
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
"requested": requested,
|
||||||
|
"selected": selected
|
||||||
|
}))
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CloseConsumer(AsyncWebsocketConsumer):
|
||||||
|
"""Close with specific code."""
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
await self.accept()
|
||||||
|
query_string = self.scope.get("query_string", b"").decode()
|
||||||
|
code = 1000
|
||||||
|
for param in query_string.split("&"):
|
||||||
|
if param.startswith("code="):
|
||||||
|
code = int(param.split("=")[1])
|
||||||
|
break
|
||||||
|
await self.close(code=code)
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
pass
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
gunicorn @ git+https://github.com/benoitc/gunicorn.git@master
|
||||||
|
Django>=5.0
|
||||||
|
channels>=4.0.0
|
||||||
|
uvloop>=0.19.0
|
||||||
|
websockets>=12.0
|
||||||
|
httptools>=0.6.0
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
WebSocket routing for Django Channels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from consumers import (
|
||||||
|
EchoConsumer,
|
||||||
|
EchoBinaryConsumer,
|
||||||
|
ScopeConsumer,
|
||||||
|
SubprotocolConsumer,
|
||||||
|
CloseConsumer,
|
||||||
|
)
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
path("ws/echo", EchoConsumer.as_asgi()),
|
||||||
|
path("ws/echo-binary", EchoBinaryConsumer.as_asgi()),
|
||||||
|
path("ws/scope", ScopeConsumer.as_asgi()),
|
||||||
|
path("ws/subprotocol", SubprotocolConsumer.as_asgi()),
|
||||||
|
path("ws/close", CloseConsumer.as_asgi()),
|
||||||
|
]
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Django settings for ASGI compatibility testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = "django-insecure-test-key-for-asgi-compat"
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ["*"]
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"channels",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = []
|
||||||
|
|
||||||
|
ROOT_URLCONF = "urls"
|
||||||
|
|
||||||
|
TEMPLATES = []
|
||||||
|
|
||||||
|
# ASGI application
|
||||||
|
ASGI_APPLICATION = "asgi.application"
|
||||||
|
|
||||||
|
# Channel layers - use in-memory for testing
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "channels.layers.InMemoryChannelLayer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Database - not needed for testing
|
||||||
|
DATABASES = {}
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
# Disable CSRF for testing
|
||||||
|
CSRF_TRUSTED_ORIGINS = ["http://localhost:*", "http://127.0.0.1:*"]
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
URL configuration for Django compatibility testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from views import (
|
||||||
|
health,
|
||||||
|
scope_view,
|
||||||
|
echo,
|
||||||
|
headers_view,
|
||||||
|
status_view,
|
||||||
|
streaming_view,
|
||||||
|
sse_view,
|
||||||
|
large_view,
|
||||||
|
delay_view,
|
||||||
|
lifespan_state_view,
|
||||||
|
lifespan_counter_view,
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("health", health, name="health"),
|
||||||
|
path("scope", scope_view, name="scope"),
|
||||||
|
path("echo", echo, name="echo"),
|
||||||
|
path("headers", headers_view, name="headers"),
|
||||||
|
path("status/<int:code>", status_view, name="status"),
|
||||||
|
path("streaming", streaming_view, name="streaming"),
|
||||||
|
path("sse", sse_view, name="sse"),
|
||||||
|
path("large", large_view, name="large"),
|
||||||
|
path("delay", delay_view, name="delay"),
|
||||||
|
path("lifespan/state", lifespan_state_view, name="lifespan_state"),
|
||||||
|
path("lifespan/counter", lifespan_counter_view, name="lifespan_counter"),
|
||||||
|
]
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Django views for ASGI compatibility testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.http import (
|
||||||
|
HttpRequest,
|
||||||
|
HttpResponse,
|
||||||
|
JsonResponse,
|
||||||
|
StreamingHttpResponse,
|
||||||
|
)
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_scope(scope: dict) -> dict:
|
||||||
|
"""Convert ASGI scope to JSON-serializable dict."""
|
||||||
|
result = {}
|
||||||
|
for key, value in scope.items():
|
||||||
|
if key == "headers":
|
||||||
|
result[key] = [
|
||||||
|
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
|
||||||
|
]
|
||||||
|
elif key == "query_string":
|
||||||
|
result[key] = value.decode("latin-1") if value else ""
|
||||||
|
elif key == "server":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "client":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "asgi":
|
||||||
|
result[key] = dict(value)
|
||||||
|
elif key in ("state", "app", "lifespan_state", "url_route", "resolver_match"):
|
||||||
|
continue
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
result[key] = value.decode("latin-1")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
json.dumps(value)
|
||||||
|
result[key] = value
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def health(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return HttpResponse("OK")
|
||||||
|
|
||||||
|
|
||||||
|
async def scope_view(request: HttpRequest) -> JsonResponse:
|
||||||
|
"""Return full ASGI scope as JSON."""
|
||||||
|
# Access ASGI scope from request
|
||||||
|
scope = request.scope if hasattr(request, "scope") else {}
|
||||||
|
scope_data = serialize_scope(scope)
|
||||||
|
return JsonResponse(scope_data)
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
async def echo(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Echo request body back."""
|
||||||
|
body = request.body
|
||||||
|
content_type = request.content_type or "application/octet-stream"
|
||||||
|
return HttpResponse(body, content_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
|
async def headers_view(request: HttpRequest) -> JsonResponse:
|
||||||
|
"""Return request headers as JSON."""
|
||||||
|
headers_dict = {}
|
||||||
|
for key, value in request.headers.items():
|
||||||
|
headers_dict[key.lower()] = value
|
||||||
|
return JsonResponse(headers_dict)
|
||||||
|
|
||||||
|
|
||||||
|
async def status_view(request: HttpRequest, code: int) -> HttpResponse:
|
||||||
|
"""Return specific HTTP status code."""
|
||||||
|
return HttpResponse(f"Status: {code}", status=code)
|
||||||
|
|
||||||
|
|
||||||
|
async def streaming_view(request: HttpRequest) -> StreamingHttpResponse:
|
||||||
|
"""Chunked streaming response."""
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
for i in range(10):
|
||||||
|
yield f"chunk-{i}\n"
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
return StreamingHttpResponse(generate(), content_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
async def sse_view(request: HttpRequest) -> StreamingHttpResponse:
|
||||||
|
"""Server-Sent Events endpoint."""
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
for i in range(5):
|
||||||
|
yield f"event: message\ndata: {json.dumps({'count': i})}\n\n"
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
yield "event: done\ndata: {}\n\n"
|
||||||
|
|
||||||
|
response = StreamingHttpResponse(generate(), content_type="text/event-stream")
|
||||||
|
response["Cache-Control"] = "no-cache"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def large_view(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Large response body."""
|
||||||
|
size = int(request.GET.get("size", 1024))
|
||||||
|
# Cap at 10MB for safety
|
||||||
|
size = min(size, 10 * 1024 * 1024)
|
||||||
|
return HttpResponse(b"x" * size, content_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
async def delay_view(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Delayed response."""
|
||||||
|
seconds = float(request.GET.get("seconds", 1))
|
||||||
|
# Cap at 30 seconds
|
||||||
|
seconds = min(seconds, 30)
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
return HttpResponse(f"Delayed {seconds} seconds")
|
||||||
|
|
||||||
|
|
||||||
|
async def lifespan_state_view(request: HttpRequest) -> JsonResponse:
|
||||||
|
"""Return lifespan startup state."""
|
||||||
|
# Get lifespan_state from scope
|
||||||
|
lifespan_state = getattr(request, "scope", {}).get("lifespan_state", {})
|
||||||
|
return JsonResponse(lifespan_state)
|
||||||
|
|
||||||
|
|
||||||
|
async def lifespan_counter_view(request: HttpRequest) -> JsonResponse:
|
||||||
|
"""Increment and return counter."""
|
||||||
|
lifespan_state = getattr(request, "scope", {}).get("lifespan_state", {})
|
||||||
|
if lifespan_state:
|
||||||
|
lifespan_state["counter"] = lifespan_state.get("counter", 0) + 1
|
||||||
|
return JsonResponse({"counter": lifespan_state.get("counter", 0)})
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for healthcheck and git for pip install from git
|
||||||
|
RUN apt-get update && apt-get install -y curl git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Command specified in docker-compose.yml
|
||||||
|
CMD ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000"]
|
||||||
263
tests/docker/asgi_framework_compat/frameworks/fastapi_app/app.py
Normal file
263
tests/docker/asgi_framework_compat/frameworks/fastapi_app/app.py
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
"""
|
||||||
|
FastAPI ASGI Application for Compatibility Testing
|
||||||
|
|
||||||
|
Implements the contract endpoints for ASGI 3.0 compliance testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import PlainTextResponse, Response, StreamingResponse, JSONResponse
|
||||||
|
|
||||||
|
|
||||||
|
# Lifespan state
|
||||||
|
lifespan_state = {
|
||||||
|
"startup_called": False,
|
||||||
|
"startup_time": None,
|
||||||
|
"counter": 0,
|
||||||
|
"custom_data": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Lifespan context manager for startup/shutdown."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
lifespan_state["startup_called"] = True
|
||||||
|
lifespan_state["startup_time"] = time.time()
|
||||||
|
lifespan_state["custom_data"]["initialized"] = True
|
||||||
|
yield
|
||||||
|
lifespan_state["shutdown_called"] = True
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_json_serialize(obj: Any) -> Any:
|
||||||
|
"""Recursively convert an object to JSON-serializable form."""
|
||||||
|
if obj is None or isinstance(obj, (str, int, float, bool)):
|
||||||
|
return obj
|
||||||
|
elif isinstance(obj, bytes):
|
||||||
|
return obj.decode("latin-1")
|
||||||
|
elif isinstance(obj, (list, tuple)):
|
||||||
|
return [safe_json_serialize(item) for item in obj]
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
result = {}
|
||||||
|
for k, v in obj.items():
|
||||||
|
# Only include string keys
|
||||||
|
if isinstance(k, str):
|
||||||
|
result[k] = safe_json_serialize(v)
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
# Skip non-serializable types
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_scope(scope: dict) -> dict:
|
||||||
|
"""Convert ASGI scope to JSON-serializable dict."""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Keys to explicitly skip (non-serializable objects)
|
||||||
|
skip_keys = {"state", "app", "router", "endpoint", "path_params", "route",
|
||||||
|
"extensions", "_cookies", "fastapi_astack"}
|
||||||
|
|
||||||
|
for key, value in scope.items():
|
||||||
|
if key in skip_keys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if key == "headers":
|
||||||
|
result[key] = [
|
||||||
|
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
|
||||||
|
]
|
||||||
|
elif key == "query_string":
|
||||||
|
result[key] = value.decode("latin-1") if value else ""
|
||||||
|
elif key == "raw_path":
|
||||||
|
result[key] = value.decode("latin-1") if value else ""
|
||||||
|
elif key == "server":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "client":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "asgi":
|
||||||
|
# Only serialize simple values from asgi dict
|
||||||
|
result[key] = {
|
||||||
|
k: v for k, v in value.items()
|
||||||
|
if isinstance(k, str) and isinstance(v, (str, int, float, bool, type(None)))
|
||||||
|
}
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
result[key] = value.decode("latin-1")
|
||||||
|
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||||
|
result[key] = value
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
serialized = safe_json_serialize(value)
|
||||||
|
if serialized is not None:
|
||||||
|
result[key] = serialized
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
serialized = safe_json_serialize(value)
|
||||||
|
if serialized is not None:
|
||||||
|
result[key] = serialized
|
||||||
|
# Skip other types
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error serializing key {key}: {e}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# HTTP Endpoints
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return PlainTextResponse("OK")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/scope")
|
||||||
|
async def scope_endpoint(request: Request):
|
||||||
|
"""Return full ASGI scope as JSON."""
|
||||||
|
try:
|
||||||
|
scope_data = serialize_scope(request.scope)
|
||||||
|
return JSONResponse(scope_data)
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return PlainTextResponse(f"Error: {e}", status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/echo")
|
||||||
|
async def echo(request: Request):
|
||||||
|
"""Echo request body back."""
|
||||||
|
body = await request.body()
|
||||||
|
content_type = request.headers.get("content-type", "application/octet-stream")
|
||||||
|
return Response(content=body, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/headers")
|
||||||
|
async def headers_endpoint(request: Request):
|
||||||
|
"""Return request headers as JSON."""
|
||||||
|
headers_dict = dict(request.headers)
|
||||||
|
return headers_dict
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/status/{code}")
|
||||||
|
async def status_endpoint(code: int):
|
||||||
|
"""Return specific HTTP status code."""
|
||||||
|
return PlainTextResponse(f"Status: {code}", status_code=code)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/streaming")
|
||||||
|
async def streaming():
|
||||||
|
"""Chunked streaming response."""
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
for i in range(10):
|
||||||
|
yield f"chunk-{i}\n"
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sse")
|
||||||
|
async def sse():
|
||||||
|
"""Server-Sent Events endpoint."""
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
for i in range(5):
|
||||||
|
yield f"event: message\ndata: {json.dumps({'count': i})}\n\n"
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
yield "event: done\ndata: {}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/large")
|
||||||
|
async def large(size: int = 1024):
|
||||||
|
"""Large response body."""
|
||||||
|
# Cap at 10MB for safety
|
||||||
|
size = min(size, 10 * 1024 * 1024)
|
||||||
|
return Response(content=b"x" * size, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/delay")
|
||||||
|
async def delay(seconds: float = 1.0):
|
||||||
|
"""Delayed response."""
|
||||||
|
# Cap at 30 seconds
|
||||||
|
seconds = min(seconds, 30)
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
return PlainTextResponse(f"Delayed {seconds} seconds")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/lifespan/state")
|
||||||
|
async def lifespan_state_endpoint():
|
||||||
|
"""Return lifespan startup state."""
|
||||||
|
return lifespan_state
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/lifespan/counter")
|
||||||
|
async def lifespan_counter():
|
||||||
|
"""Increment and return counter."""
|
||||||
|
lifespan_state["counter"] += 1
|
||||||
|
return {"counter": lifespan_state["counter"]}
|
||||||
|
|
||||||
|
|
||||||
|
# WebSocket Endpoints
|
||||||
|
@app.websocket("/ws/echo")
|
||||||
|
async def ws_echo(websocket: WebSocket):
|
||||||
|
"""Echo text messages."""
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_text()
|
||||||
|
await websocket.send_text(message)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/echo-binary")
|
||||||
|
async def ws_echo_binary(websocket: WebSocket):
|
||||||
|
"""Echo binary messages."""
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_bytes()
|
||||||
|
await websocket.send_bytes(message)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/scope")
|
||||||
|
async def ws_scope(websocket: WebSocket):
|
||||||
|
"""Send WebSocket scope on connect."""
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
scope_data = serialize_scope(websocket.scope)
|
||||||
|
await websocket.send_json(scope_data)
|
||||||
|
except Exception as e:
|
||||||
|
await websocket.send_text(f"Error: {e}")
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/subprotocol")
|
||||||
|
async def ws_subprotocol(websocket: WebSocket):
|
||||||
|
"""Subprotocol negotiation."""
|
||||||
|
requested = websocket.scope.get("subprotocols", [])
|
||||||
|
selected = requested[0] if requested else None
|
||||||
|
await websocket.accept(subprotocol=selected)
|
||||||
|
await websocket.send_json({"requested": requested, "selected": selected})
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/close")
|
||||||
|
async def ws_close(websocket: WebSocket):
|
||||||
|
"""Close with specific code."""
|
||||||
|
await websocket.accept()
|
||||||
|
query_string = websocket.scope.get("query_string", b"").decode()
|
||||||
|
code = 1000
|
||||||
|
for param in query_string.split("&"):
|
||||||
|
if param.startswith("code="):
|
||||||
|
code = int(param.split("=")[1])
|
||||||
|
break
|
||||||
|
await websocket.close(code=code)
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
gunicorn @ git+https://github.com/benoitc/gunicorn.git@master
|
||||||
|
fastapi>=0.110.0
|
||||||
|
uvloop>=0.19.0
|
||||||
|
websockets>=12.0
|
||||||
|
httptools>=0.6.0
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for healthcheck and git for pip install from git
|
||||||
|
RUN apt-get update && apt-get install -y curl git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Command specified in docker-compose.yml
|
||||||
|
CMD ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000"]
|
||||||
@ -0,0 +1,237 @@
|
|||||||
|
"""
|
||||||
|
Litestar ASGI Application for Compatibility Testing
|
||||||
|
|
||||||
|
Implements the contract endpoints for ASGI 3.0 compliance testing.
|
||||||
|
Litestar is a modern ASGI framework with extensive feature support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from litestar import Litestar, Request, get, post
|
||||||
|
from litestar.connection import ASGIConnection
|
||||||
|
from litestar.handlers import websocket
|
||||||
|
from litestar.response import Response, Stream
|
||||||
|
|
||||||
|
|
||||||
|
# Lifespan state
|
||||||
|
lifespan_state = {
|
||||||
|
"startup_called": False,
|
||||||
|
"startup_time": None,
|
||||||
|
"counter": 0,
|
||||||
|
"custom_data": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def on_startup(app: Litestar) -> None:
|
||||||
|
"""Startup handler."""
|
||||||
|
lifespan_state["startup_called"] = True
|
||||||
|
lifespan_state["startup_time"] = time.time()
|
||||||
|
lifespan_state["custom_data"]["initialized"] = True
|
||||||
|
|
||||||
|
|
||||||
|
async def on_shutdown(app: Litestar) -> None:
|
||||||
|
"""Shutdown handler."""
|
||||||
|
lifespan_state["shutdown_called"] = True
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_scope(scope: dict) -> dict:
|
||||||
|
"""Convert ASGI scope to JSON-serializable dict."""
|
||||||
|
result = {}
|
||||||
|
for key, value in scope.items():
|
||||||
|
if key == "headers":
|
||||||
|
result[key] = [
|
||||||
|
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
|
||||||
|
]
|
||||||
|
elif key == "query_string":
|
||||||
|
result[key] = value.decode("latin-1") if value else ""
|
||||||
|
elif key == "server":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "client":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "asgi":
|
||||||
|
result[key] = dict(value)
|
||||||
|
elif key in ("state", "app", "_litestar", "route_handler", "path_params"):
|
||||||
|
continue
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
result[key] = value.decode("latin-1")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
json.dumps(value)
|
||||||
|
result[key] = value
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# HTTP Endpoints
|
||||||
|
@get("/health")
|
||||||
|
async def health() -> str:
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
@get("/scope")
|
||||||
|
async def scope_endpoint(request: Request) -> Dict[str, Any]:
|
||||||
|
"""Return full ASGI scope as JSON."""
|
||||||
|
scope_data = serialize_scope(request.scope)
|
||||||
|
return scope_data
|
||||||
|
|
||||||
|
|
||||||
|
@post("/echo")
|
||||||
|
async def echo(request: Request) -> Response:
|
||||||
|
"""Echo request body back."""
|
||||||
|
body = await request.body()
|
||||||
|
content_type = request.headers.get("content-type", "application/octet-stream")
|
||||||
|
return Response(content=body, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
|
@get("/headers")
|
||||||
|
async def headers_endpoint(request: Request) -> Dict[str, str]:
|
||||||
|
"""Return request headers as JSON."""
|
||||||
|
return dict(request.headers)
|
||||||
|
|
||||||
|
|
||||||
|
@get("/status/{code:int}")
|
||||||
|
async def status_endpoint(code: int) -> Response:
|
||||||
|
"""Return specific HTTP status code."""
|
||||||
|
return Response(content=f"Status: {code}", status_code=code)
|
||||||
|
|
||||||
|
|
||||||
|
@get("/streaming")
|
||||||
|
async def streaming() -> Stream:
|
||||||
|
"""Chunked streaming response."""
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
for i in range(10):
|
||||||
|
yield f"chunk-{i}\n".encode()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
return Stream(generate(), media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
@get("/sse")
|
||||||
|
async def sse() -> Stream:
|
||||||
|
"""Server-Sent Events endpoint."""
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
for i in range(5):
|
||||||
|
yield f"event: message\ndata: {json.dumps({'count': i})}\n\n".encode()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
yield b"event: done\ndata: {}\n\n"
|
||||||
|
|
||||||
|
return Stream(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@get("/large")
|
||||||
|
async def large(size: int = 1024) -> Response:
|
||||||
|
"""Large response body."""
|
||||||
|
# Cap at 10MB for safety
|
||||||
|
size = min(size, 10 * 1024 * 1024)
|
||||||
|
return Response(content=b"x" * size, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@get("/delay")
|
||||||
|
async def delay(seconds: float = 1.0) -> str:
|
||||||
|
"""Delayed response."""
|
||||||
|
# Cap at 30 seconds
|
||||||
|
seconds = min(seconds, 30)
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
return f"Delayed {seconds} seconds"
|
||||||
|
|
||||||
|
|
||||||
|
@get("/lifespan/state")
|
||||||
|
async def lifespan_state_endpoint() -> Dict[str, Any]:
|
||||||
|
"""Return lifespan startup state."""
|
||||||
|
return lifespan_state
|
||||||
|
|
||||||
|
|
||||||
|
@get("/lifespan/counter")
|
||||||
|
async def lifespan_counter() -> Dict[str, int]:
|
||||||
|
"""Increment and return counter."""
|
||||||
|
lifespan_state["counter"] += 1
|
||||||
|
return {"counter": lifespan_state["counter"]}
|
||||||
|
|
||||||
|
|
||||||
|
# WebSocket Endpoints using raw websocket handler
|
||||||
|
@websocket("/ws/echo")
|
||||||
|
async def ws_echo(socket: ASGIConnection) -> None:
|
||||||
|
"""Echo text messages."""
|
||||||
|
await socket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await socket.receive_text()
|
||||||
|
await socket.send_text(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@websocket("/ws/echo-binary")
|
||||||
|
async def ws_echo_binary(socket: ASGIConnection) -> None:
|
||||||
|
"""Echo binary messages."""
|
||||||
|
await socket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await socket.receive_bytes()
|
||||||
|
await socket.send_bytes(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@websocket("/ws/scope")
|
||||||
|
async def ws_scope_handler(socket: ASGIConnection) -> None:
|
||||||
|
"""Send WebSocket scope on connect."""
|
||||||
|
await socket.accept()
|
||||||
|
scope_data = serialize_scope(socket.scope)
|
||||||
|
await socket.send_json(scope_data)
|
||||||
|
await socket.close()
|
||||||
|
|
||||||
|
|
||||||
|
@websocket("/ws/subprotocol")
|
||||||
|
async def ws_subprotocol_handler(socket: ASGIConnection) -> None:
|
||||||
|
"""Subprotocol negotiation."""
|
||||||
|
requested = socket.scope.get("subprotocols", [])
|
||||||
|
selected = requested[0] if requested else None
|
||||||
|
await socket.accept(subprotocols=selected)
|
||||||
|
await socket.send_json({"requested": requested, "selected": selected})
|
||||||
|
await socket.close()
|
||||||
|
|
||||||
|
|
||||||
|
@websocket("/ws/close")
|
||||||
|
async def ws_close_handler(socket: ASGIConnection) -> None:
|
||||||
|
"""Close with specific code."""
|
||||||
|
await socket.accept()
|
||||||
|
query_string = socket.scope.get("query_string", b"").decode()
|
||||||
|
code = 1000
|
||||||
|
for param in query_string.split("&"):
|
||||||
|
if param.startswith("code="):
|
||||||
|
code = int(param.split("=")[1])
|
||||||
|
break
|
||||||
|
await socket.close(code=code)
|
||||||
|
|
||||||
|
|
||||||
|
# Create app with lifespan handlers
|
||||||
|
app = Litestar(
|
||||||
|
route_handlers=[
|
||||||
|
health,
|
||||||
|
scope_endpoint,
|
||||||
|
echo,
|
||||||
|
headers_endpoint,
|
||||||
|
status_endpoint,
|
||||||
|
streaming,
|
||||||
|
sse,
|
||||||
|
large,
|
||||||
|
delay,
|
||||||
|
lifespan_state_endpoint,
|
||||||
|
lifespan_counter,
|
||||||
|
ws_echo,
|
||||||
|
ws_echo_binary,
|
||||||
|
ws_scope_handler,
|
||||||
|
ws_subprotocol_handler,
|
||||||
|
ws_close_handler,
|
||||||
|
],
|
||||||
|
on_startup=[on_startup],
|
||||||
|
on_shutdown=[on_shutdown],
|
||||||
|
)
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
gunicorn @ git+https://github.com/benoitc/gunicorn.git@master
|
||||||
|
litestar>=2.7.0
|
||||||
|
uvloop>=0.19.0
|
||||||
|
websockets>=12.0
|
||||||
|
httptools>=0.6.0
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for healthcheck and git for pip install from git
|
||||||
|
RUN apt-get update && apt-get install -y curl git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Command specified in docker-compose.yml
|
||||||
|
CMD ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000"]
|
||||||
210
tests/docker/asgi_framework_compat/frameworks/quart_app/app.py
Normal file
210
tests/docker/asgi_framework_compat/frameworks/quart_app/app.py
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
"""
|
||||||
|
Quart ASGI Application for Compatibility Testing
|
||||||
|
|
||||||
|
Implements the contract endpoints for ASGI 3.0 compliance testing.
|
||||||
|
Quart is a Flask-like async framework built on ASGI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from quart import Quart, request, websocket, Response, make_response
|
||||||
|
|
||||||
|
|
||||||
|
app = Quart(__name__)
|
||||||
|
|
||||||
|
# Lifespan state
|
||||||
|
lifespan_state = {
|
||||||
|
"startup_called": False,
|
||||||
|
"startup_time": None,
|
||||||
|
"counter": 0,
|
||||||
|
"custom_data": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_serving
|
||||||
|
async def startup():
|
||||||
|
"""Startup handler."""
|
||||||
|
lifespan_state["startup_called"] = True
|
||||||
|
lifespan_state["startup_time"] = time.time()
|
||||||
|
lifespan_state["custom_data"]["initialized"] = True
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_serving
|
||||||
|
async def shutdown():
|
||||||
|
"""Shutdown handler."""
|
||||||
|
lifespan_state["shutdown_called"] = True
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_scope(scope: dict) -> dict:
|
||||||
|
"""Convert ASGI scope to JSON-serializable dict."""
|
||||||
|
result = {}
|
||||||
|
for key, value in scope.items():
|
||||||
|
if key == "headers":
|
||||||
|
result[key] = [
|
||||||
|
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
|
||||||
|
]
|
||||||
|
elif key == "query_string":
|
||||||
|
result[key] = value.decode("latin-1") if value else ""
|
||||||
|
elif key == "server":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "client":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "asgi":
|
||||||
|
result[key] = dict(value)
|
||||||
|
elif key in ("state", "app", "_quart"):
|
||||||
|
continue
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
result[key] = value.decode("latin-1")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
json.dumps(value)
|
||||||
|
result[key] = value
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# HTTP Endpoints
|
||||||
|
@app.route("/health")
|
||||||
|
async def health():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return "OK", 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/scope")
|
||||||
|
async def scope_endpoint():
|
||||||
|
"""Return full ASGI scope as JSON."""
|
||||||
|
# Access the ASGI scope via request
|
||||||
|
scope = request.scope
|
||||||
|
scope_data = serialize_scope(scope)
|
||||||
|
return scope_data
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/echo", methods=["POST"])
|
||||||
|
async def echo():
|
||||||
|
"""Echo request body back."""
|
||||||
|
body = await request.get_data()
|
||||||
|
content_type = request.headers.get("content-type", "application/octet-stream")
|
||||||
|
response = await make_response(body)
|
||||||
|
response.headers["Content-Type"] = content_type
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/headers")
|
||||||
|
async def headers_endpoint():
|
||||||
|
"""Return request headers as JSON."""
|
||||||
|
headers_dict = dict(request.headers)
|
||||||
|
return headers_dict
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/status/<int:code>")
|
||||||
|
async def status_endpoint(code: int):
|
||||||
|
"""Return specific HTTP status code."""
|
||||||
|
return f"Status: {code}", code
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/streaming")
|
||||||
|
async def streaming():
|
||||||
|
"""Chunked streaming response."""
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
for i in range(10):
|
||||||
|
yield f"chunk-{i}\n"
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
return generate(), 200, {"Content-Type": "text/plain"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/sse")
|
||||||
|
async def sse():
|
||||||
|
"""Server-Sent Events endpoint."""
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
for i in range(5):
|
||||||
|
yield f"event: message\ndata: {json.dumps({'count': i})}\n\n"
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
yield "event: done\ndata: {}\n\n"
|
||||||
|
|
||||||
|
return generate(), 200, {"Content-Type": "text/event-stream", "Cache-Control": "no-cache"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/large")
|
||||||
|
async def large():
|
||||||
|
"""Large response body."""
|
||||||
|
size = request.args.get("size", 1024, type=int)
|
||||||
|
# Cap at 10MB for safety
|
||||||
|
size = min(size, 10 * 1024 * 1024)
|
||||||
|
response = await make_response(b"x" * size)
|
||||||
|
response.headers["Content-Type"] = "application/octet-stream"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/delay")
|
||||||
|
async def delay():
|
||||||
|
"""Delayed response."""
|
||||||
|
seconds = request.args.get("seconds", 1.0, type=float)
|
||||||
|
# Cap at 30 seconds
|
||||||
|
seconds = min(seconds, 30)
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
return f"Delayed {seconds} seconds"
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/lifespan/state")
|
||||||
|
async def lifespan_state_endpoint():
|
||||||
|
"""Return lifespan startup state."""
|
||||||
|
return lifespan_state
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/lifespan/counter")
|
||||||
|
async def lifespan_counter():
|
||||||
|
"""Increment and return counter."""
|
||||||
|
lifespan_state["counter"] += 1
|
||||||
|
return {"counter": lifespan_state["counter"]}
|
||||||
|
|
||||||
|
|
||||||
|
# WebSocket Endpoints
|
||||||
|
@app.websocket("/ws/echo")
|
||||||
|
async def ws_echo():
|
||||||
|
"""Echo text messages."""
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive()
|
||||||
|
await websocket.send(message)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/echo-binary")
|
||||||
|
async def ws_echo_binary():
|
||||||
|
"""Echo binary messages."""
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive()
|
||||||
|
await websocket.send(message)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/scope")
|
||||||
|
async def ws_scope():
|
||||||
|
"""Send WebSocket scope on connect."""
|
||||||
|
scope_data = serialize_scope(websocket.scope)
|
||||||
|
await websocket.send_json(scope_data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/subprotocol")
|
||||||
|
async def ws_subprotocol():
|
||||||
|
"""Subprotocol negotiation."""
|
||||||
|
requested = websocket.scope.get("subprotocols", [])
|
||||||
|
selected = requested[0] if requested else None
|
||||||
|
# Note: Quart handles subprotocol via accept() but we need to check how
|
||||||
|
await websocket.send_json({"requested": requested, "selected": selected})
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/close")
|
||||||
|
async def ws_close():
|
||||||
|
"""Close with specific code."""
|
||||||
|
query_string = websocket.scope.get("query_string", b"").decode()
|
||||||
|
code = 1000
|
||||||
|
for param in query_string.split("&"):
|
||||||
|
if param.startswith("code="):
|
||||||
|
code = int(param.split("=")[1])
|
||||||
|
break
|
||||||
|
# Quart uses a different close mechanism
|
||||||
|
await websocket.close(code)
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
gunicorn @ git+https://github.com/benoitc/gunicorn.git@master
|
||||||
|
quart>=0.19.0
|
||||||
|
uvloop>=0.19.0
|
||||||
|
websockets>=12.0
|
||||||
|
httptools>=0.6.0
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for healthcheck and git for pip install from git
|
||||||
|
RUN apt-get update && apt-get install -y curl git && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Command specified in docker-compose.yml
|
||||||
|
CMD ["gunicorn", "app:app", "-k", "asgi", "-b", "0.0.0.0:8000"]
|
||||||
@ -0,0 +1,284 @@
|
|||||||
|
"""
|
||||||
|
Starlette ASGI Application for Compatibility Testing
|
||||||
|
|
||||||
|
Implements the contract endpoints for ASGI 3.0 compliance testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.responses import (
|
||||||
|
JSONResponse,
|
||||||
|
PlainTextResponse,
|
||||||
|
Response,
|
||||||
|
StreamingResponse,
|
||||||
|
)
|
||||||
|
from starlette.routing import Route, WebSocketRoute
|
||||||
|
from starlette.websockets import WebSocket
|
||||||
|
|
||||||
|
|
||||||
|
# Lifespan state
|
||||||
|
lifespan_state = {
|
||||||
|
"startup_called": False,
|
||||||
|
"startup_time": None,
|
||||||
|
"counter": 0,
|
||||||
|
"custom_data": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
"""Lifespan context manager for startup/shutdown."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
lifespan_state["startup_called"] = True
|
||||||
|
lifespan_state["startup_time"] = time.time()
|
||||||
|
lifespan_state["custom_data"]["initialized"] = True
|
||||||
|
yield
|
||||||
|
lifespan_state["shutdown_called"] = True
|
||||||
|
|
||||||
|
|
||||||
|
def safe_json_serialize(obj: Any) -> Any:
|
||||||
|
"""Recursively convert an object to JSON-serializable form."""
|
||||||
|
if obj is None or isinstance(obj, (str, int, float, bool)):
|
||||||
|
return obj
|
||||||
|
elif isinstance(obj, bytes):
|
||||||
|
return obj.decode("latin-1")
|
||||||
|
elif isinstance(obj, (list, tuple)):
|
||||||
|
return [safe_json_serialize(item) for item in obj]
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
result = {}
|
||||||
|
for k, v in obj.items():
|
||||||
|
# Only include string keys
|
||||||
|
if isinstance(k, str):
|
||||||
|
result[k] = safe_json_serialize(v)
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
# Skip non-serializable types
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_scope(scope: dict) -> dict:
|
||||||
|
"""Convert ASGI scope to JSON-serializable dict."""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Keys to explicitly skip (non-serializable objects)
|
||||||
|
skip_keys = {"state", "app", "router", "endpoint", "path_params", "route",
|
||||||
|
"extensions", "_cookies"}
|
||||||
|
|
||||||
|
for key, value in scope.items():
|
||||||
|
if key in skip_keys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if key == "headers":
|
||||||
|
result[key] = [
|
||||||
|
[h[0].decode("latin-1"), h[1].decode("latin-1")] for h in value
|
||||||
|
]
|
||||||
|
elif key == "query_string":
|
||||||
|
result[key] = value.decode("latin-1") if value else ""
|
||||||
|
elif key == "raw_path":
|
||||||
|
result[key] = value.decode("latin-1") if value else ""
|
||||||
|
elif key == "server":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "client":
|
||||||
|
result[key] = list(value) if value else None
|
||||||
|
elif key == "asgi":
|
||||||
|
# Only serialize simple values from asgi dict
|
||||||
|
result[key] = {
|
||||||
|
k: v for k, v in value.items()
|
||||||
|
if isinstance(k, str) and isinstance(v, (str, int, float, bool, type(None)))
|
||||||
|
}
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
result[key] = value.decode("latin-1")
|
||||||
|
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||||
|
result[key] = value
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
serialized = safe_json_serialize(value)
|
||||||
|
if serialized is not None:
|
||||||
|
result[key] = serialized
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
serialized = safe_json_serialize(value)
|
||||||
|
if serialized is not None:
|
||||||
|
result[key] = serialized
|
||||||
|
# Skip other types
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error serializing key {key}: {e}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# HTTP Endpoints
|
||||||
|
async def health(request):
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return PlainTextResponse("OK")
|
||||||
|
|
||||||
|
|
||||||
|
async def scope_endpoint(request):
|
||||||
|
"""Return full ASGI scope as JSON."""
|
||||||
|
try:
|
||||||
|
scope_data = serialize_scope(request.scope)
|
||||||
|
return JSONResponse(scope_data)
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return PlainTextResponse(f"Error: {e}", status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
async def echo(request):
|
||||||
|
"""Echo request body back."""
|
||||||
|
body = await request.body()
|
||||||
|
content_type = request.headers.get("content-type", "application/octet-stream")
|
||||||
|
return Response(content=body, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
|
async def headers_endpoint(request):
|
||||||
|
"""Return request headers as JSON."""
|
||||||
|
headers_dict = dict(request.headers)
|
||||||
|
return JSONResponse(headers_dict)
|
||||||
|
|
||||||
|
|
||||||
|
async def status_endpoint(request):
|
||||||
|
"""Return specific HTTP status code."""
|
||||||
|
code = int(request.path_params["code"])
|
||||||
|
return PlainTextResponse(f"Status: {code}", status_code=code)
|
||||||
|
|
||||||
|
|
||||||
|
async def streaming(request):
|
||||||
|
"""Chunked streaming response."""
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
for i in range(10):
|
||||||
|
yield f"chunk-{i}\n"
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
async def sse(request):
|
||||||
|
"""Server-Sent Events endpoint."""
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
for i in range(5):
|
||||||
|
yield f"event: message\ndata: {json.dumps({'count': i})}\n\n"
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
yield "event: done\ndata: {}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
async def large(request):
|
||||||
|
"""Large response body."""
|
||||||
|
size = int(request.query_params.get("size", 1024))
|
||||||
|
# Cap at 10MB for safety
|
||||||
|
size = min(size, 10 * 1024 * 1024)
|
||||||
|
return Response(content=b"x" * size, media_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
async def delay(request):
|
||||||
|
"""Delayed response."""
|
||||||
|
seconds = float(request.query_params.get("seconds", 1))
|
||||||
|
# Cap at 30 seconds
|
||||||
|
seconds = min(seconds, 30)
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
return PlainTextResponse(f"Delayed {seconds} seconds")
|
||||||
|
|
||||||
|
|
||||||
|
async def lifespan_state_endpoint(request):
|
||||||
|
"""Return lifespan startup state."""
|
||||||
|
return JSONResponse(lifespan_state)
|
||||||
|
|
||||||
|
|
||||||
|
async def lifespan_counter(request):
|
||||||
|
"""Increment and return counter."""
|
||||||
|
lifespan_state["counter"] += 1
|
||||||
|
return JSONResponse({"counter": lifespan_state["counter"]})
|
||||||
|
|
||||||
|
|
||||||
|
# WebSocket Endpoints
|
||||||
|
async def ws_echo(websocket: WebSocket):
|
||||||
|
"""Echo text messages."""
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_text()
|
||||||
|
await websocket.send_text(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def ws_echo_binary(websocket: WebSocket):
|
||||||
|
"""Echo binary messages."""
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_bytes()
|
||||||
|
await websocket.send_bytes(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def ws_scope(websocket: WebSocket):
|
||||||
|
"""Send WebSocket scope on connect."""
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
scope_data = serialize_scope(websocket.scope)
|
||||||
|
await websocket.send_json(scope_data)
|
||||||
|
except Exception as e:
|
||||||
|
await websocket.send_text(f"Error: {e}")
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def ws_subprotocol(websocket: WebSocket):
|
||||||
|
"""Subprotocol negotiation."""
|
||||||
|
# Get requested subprotocols from scope
|
||||||
|
requested = websocket.scope.get("subprotocols", [])
|
||||||
|
# Select first one if available
|
||||||
|
selected = requested[0] if requested else None
|
||||||
|
await websocket.accept(subprotocol=selected)
|
||||||
|
await websocket.send_json(
|
||||||
|
{"requested": requested, "selected": selected}
|
||||||
|
)
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def ws_close(websocket: WebSocket):
|
||||||
|
"""Close with specific code."""
|
||||||
|
await websocket.accept()
|
||||||
|
# Get close code from query string
|
||||||
|
query_string = websocket.scope.get("query_string", b"").decode()
|
||||||
|
code = 1000
|
||||||
|
for param in query_string.split("&"):
|
||||||
|
if param.startswith("code="):
|
||||||
|
code = int(param.split("=")[1])
|
||||||
|
break
|
||||||
|
await websocket.close(code=code)
|
||||||
|
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
routes = [
|
||||||
|
# HTTP endpoints
|
||||||
|
Route("/health", health),
|
||||||
|
Route("/scope", scope_endpoint),
|
||||||
|
Route("/echo", echo, methods=["POST"]),
|
||||||
|
Route("/headers", headers_endpoint),
|
||||||
|
Route("/status/{code:int}", status_endpoint),
|
||||||
|
Route("/streaming", streaming),
|
||||||
|
Route("/sse", sse),
|
||||||
|
Route("/large", large),
|
||||||
|
Route("/delay", delay),
|
||||||
|
Route("/lifespan/state", lifespan_state_endpoint),
|
||||||
|
Route("/lifespan/counter", lifespan_counter),
|
||||||
|
# WebSocket endpoints
|
||||||
|
WebSocketRoute("/ws/echo", ws_echo),
|
||||||
|
WebSocketRoute("/ws/echo-binary", ws_echo_binary),
|
||||||
|
WebSocketRoute("/ws/scope", ws_scope),
|
||||||
|
WebSocketRoute("/ws/subprotocol", ws_subprotocol),
|
||||||
|
WebSocketRoute("/ws/close", ws_close),
|
||||||
|
]
|
||||||
|
|
||||||
|
app = Starlette(routes=routes, lifespan=lifespan)
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
gunicorn @ git+https://github.com/benoitc/gunicorn.git@master
|
||||||
|
starlette>=0.37.0
|
||||||
|
uvloop>=0.19.0
|
||||||
|
websockets>=12.0
|
||||||
|
httptools>=0.6.0
|
||||||
18
tests/docker/asgi_framework_compat/pytest.ini
Normal file
18
tests/docker/asgi_framework_compat/pytest.ini
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
asyncio_mode = auto
|
||||||
|
|
||||||
|
markers =
|
||||||
|
http: HTTP protocol tests
|
||||||
|
websocket: WebSocket protocol tests
|
||||||
|
lifespan: Lifespan protocol tests
|
||||||
|
streaming: Streaming response tests
|
||||||
|
slow: Slow running tests
|
||||||
|
framework(name): Test specific framework
|
||||||
|
|
||||||
|
filterwarnings =
|
||||||
|
ignore::DeprecationWarning
|
||||||
|
ignore::pytest.PytestUnraisableExceptionWarning
|
||||||
6
tests/docker/asgi_framework_compat/requirements.txt
Normal file
6
tests/docker/asgi_framework_compat/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Test dependencies for running the compatibility suite
|
||||||
|
pytest>=8.0.0
|
||||||
|
pytest-asyncio>=0.23.0
|
||||||
|
pytest-json-report>=1.5.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
websockets>=12.0
|
||||||
@ -0,0 +1,198 @@
|
|||||||
|
{
|
||||||
|
"generated": "2026-04-03T11:06:45.300191",
|
||||||
|
"worker": "gunicorn.workers.gasgi.ASGIWorker",
|
||||||
|
"frameworks": {
|
||||||
|
"django": {
|
||||||
|
"name": "Django + Channels",
|
||||||
|
"categories": {
|
||||||
|
"http_scope": {
|
||||||
|
"passed": 19,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"http_messages": {
|
||||||
|
"passed": 18,
|
||||||
|
"failed": 1,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"websocket": {
|
||||||
|
"passed": 13,
|
||||||
|
"failed": 6,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"lifespan": {
|
||||||
|
"passed": 7,
|
||||||
|
"failed": 1,
|
||||||
|
"total": 8
|
||||||
|
},
|
||||||
|
"streaming": {
|
||||||
|
"passed": 9,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_passed": 66,
|
||||||
|
"total_tests": 74
|
||||||
|
},
|
||||||
|
"fastapi": {
|
||||||
|
"name": "FastAPI",
|
||||||
|
"categories": {
|
||||||
|
"http_scope": {
|
||||||
|
"passed": 19,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"http_messages": {
|
||||||
|
"passed": 18,
|
||||||
|
"failed": 1,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"websocket": {
|
||||||
|
"passed": 19,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"lifespan": {
|
||||||
|
"passed": 8,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 8
|
||||||
|
},
|
||||||
|
"streaming": {
|
||||||
|
"passed": 9,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_passed": 73,
|
||||||
|
"total_tests": 74
|
||||||
|
},
|
||||||
|
"starlette": {
|
||||||
|
"name": "Starlette",
|
||||||
|
"categories": {
|
||||||
|
"http_scope": {
|
||||||
|
"passed": 19,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"http_messages": {
|
||||||
|
"passed": 18,
|
||||||
|
"failed": 1,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"websocket": {
|
||||||
|
"passed": 19,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"lifespan": {
|
||||||
|
"passed": 8,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 8
|
||||||
|
},
|
||||||
|
"streaming": {
|
||||||
|
"passed": 9,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_passed": 73,
|
||||||
|
"total_tests": 74
|
||||||
|
},
|
||||||
|
"quart": {
|
||||||
|
"name": "Quart",
|
||||||
|
"categories": {
|
||||||
|
"http_scope": {
|
||||||
|
"passed": 18,
|
||||||
|
"failed": 1,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"http_messages": {
|
||||||
|
"passed": 17,
|
||||||
|
"failed": 2,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"websocket": {
|
||||||
|
"passed": 11,
|
||||||
|
"failed": 8,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"lifespan": {
|
||||||
|
"passed": 8,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 8
|
||||||
|
},
|
||||||
|
"streaming": {
|
||||||
|
"passed": 9,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_passed": 63,
|
||||||
|
"total_tests": 74
|
||||||
|
},
|
||||||
|
"litestar": {
|
||||||
|
"name": "Litestar",
|
||||||
|
"categories": {
|
||||||
|
"http_scope": {
|
||||||
|
"passed": 18,
|
||||||
|
"failed": 1,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"http_messages": {
|
||||||
|
"passed": 11,
|
||||||
|
"failed": 8,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"websocket": {
|
||||||
|
"passed": 17,
|
||||||
|
"failed": 2,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"lifespan": {
|
||||||
|
"passed": 8,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 8
|
||||||
|
},
|
||||||
|
"streaming": {
|
||||||
|
"passed": 9,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_passed": 63,
|
||||||
|
"total_tests": 74
|
||||||
|
},
|
||||||
|
"blacksheep": {
|
||||||
|
"name": "BlackSheep",
|
||||||
|
"categories": {
|
||||||
|
"http_scope": {
|
||||||
|
"passed": 19,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"http_messages": {
|
||||||
|
"passed": 18,
|
||||||
|
"failed": 1,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"websocket": {
|
||||||
|
"passed": 19,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 19
|
||||||
|
},
|
||||||
|
"lifespan": {
|
||||||
|
"passed": 8,
|
||||||
|
"failed": 0,
|
||||||
|
"total": 8
|
||||||
|
},
|
||||||
|
"streaming": {
|
||||||
|
"passed": 1,
|
||||||
|
"failed": 8,
|
||||||
|
"total": 9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_passed": 65,
|
||||||
|
"total_tests": 74
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
# ASGI Framework Compatibility Grid
|
||||||
|
|
||||||
|
**Generated:** 2026-04-03 11:06:45
|
||||||
|
**Worker:** gunicorn ASGI worker (`-k asgi`)
|
||||||
|
**Event Loop:** auto (uvloop if available)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Framework | HTTP Scope | HTTP Messages | WebSocket | Lifespan | Streaming | Total |
|
||||||
|
|-----------|---------|---------|---------|---------|---------|-------|
|
||||||
|
| Django + Channels | 19/19 | **18/19** | **13/19** | **7/8** | 9/9 | **66/74** |
|
||||||
|
| FastAPI | 19/19 | **18/19** | 19/19 | 8/8 | 9/9 | **73/74** |
|
||||||
|
| Starlette | 19/19 | **18/19** | 19/19 | 8/8 | 9/9 | **73/74** |
|
||||||
|
| Quart | **18/19** | **17/19** | **11/19** | 8/8 | 9/9 | **63/74** |
|
||||||
|
| Litestar | **18/19** | **11/19** | **17/19** | 8/8 | 9/9 | **63/74** |
|
||||||
|
| BlackSheep | 19/19 | **18/19** | 19/19 | 8/8 | **1/9** | **65/74** |
|
||||||
|
|
||||||
|
*Bold indicates failures*
|
||||||
|
|
||||||
|
**Overall:** 403/444 tests passed (90%)
|
||||||
1
tests/docker/asgi_framework_compat/scripts/__init__.py
Normal file
1
tests/docker/asgi_framework_compat/scripts/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Scripts for running tests and generating reports."""
|
||||||
198
tests/docker/asgi_framework_compat/scripts/generate_grid.py
Executable file
198
tests/docker/asgi_framework_compat/scripts/generate_grid.py
Executable file
@ -0,0 +1,198 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Compatibility Grid Generator
|
||||||
|
|
||||||
|
Generates a compatibility matrix showing test results for each
|
||||||
|
ASGI framework tested with gunicorn's native ASGI worker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# Framework configuration
|
||||||
|
FRAMEWORKS = ["django", "fastapi", "starlette", "quart", "litestar", "blacksheep"]
|
||||||
|
|
||||||
|
FRAMEWORK_NAMES = {
|
||||||
|
"django": "Django + Channels",
|
||||||
|
"fastapi": "FastAPI",
|
||||||
|
"starlette": "Starlette",
|
||||||
|
"quart": "Quart",
|
||||||
|
"litestar": "Litestar",
|
||||||
|
"blacksheep": "BlackSheep",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test categories based on file names
|
||||||
|
CATEGORIES = {
|
||||||
|
"http_scope": "HTTP Scope",
|
||||||
|
"http_messages": "HTTP Messages",
|
||||||
|
"websocket": "WebSocket",
|
||||||
|
"lifespan": "Lifespan",
|
||||||
|
"streaming": "Streaming",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_results(results_file: Path) -> dict:
|
||||||
|
"""Parse pytest JSON results into framework/category structure."""
|
||||||
|
with open(results_file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
results = {fw: {cat: {"passed": 0, "failed": 0, "total": 0}
|
||||||
|
for cat in CATEGORIES} for fw in FRAMEWORKS}
|
||||||
|
|
||||||
|
tests = data.get("tests", [])
|
||||||
|
for test in tests:
|
||||||
|
nodeid = test.get("nodeid", "")
|
||||||
|
outcome = test.get("outcome", "")
|
||||||
|
|
||||||
|
# Extract framework from test parameters
|
||||||
|
framework = None
|
||||||
|
for fw in FRAMEWORKS:
|
||||||
|
if f"[{fw}]" in nodeid or f"[{fw}-" in nodeid:
|
||||||
|
framework = fw
|
||||||
|
break
|
||||||
|
|
||||||
|
if not framework:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine category from file name
|
||||||
|
category = None
|
||||||
|
for cat_key in CATEGORIES:
|
||||||
|
if f"test_{cat_key}" in nodeid:
|
||||||
|
category = cat_key
|
||||||
|
break
|
||||||
|
|
||||||
|
if not category:
|
||||||
|
continue
|
||||||
|
|
||||||
|
results[framework][category]["total"] += 1
|
||||||
|
if outcome == "passed":
|
||||||
|
results[framework][category]["passed"] += 1
|
||||||
|
elif outcome == "failed":
|
||||||
|
results[framework][category]["failed"] += 1
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def generate_markdown(results: dict) -> str:
|
||||||
|
"""Generate markdown compatibility grid."""
|
||||||
|
lines = []
|
||||||
|
lines.append("# ASGI Framework Compatibility Grid")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
lines.append("**Worker:** gunicorn ASGI worker (`-k asgi`)")
|
||||||
|
lines.append("**Event Loop:** auto (uvloop if available)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Main compatibility table
|
||||||
|
lines.append("## Summary")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
header = "| Framework |"
|
||||||
|
separator = "|-----------|"
|
||||||
|
for cat in CATEGORIES.values():
|
||||||
|
header += f" {cat} |"
|
||||||
|
separator += "---------|"
|
||||||
|
header += " Total |"
|
||||||
|
separator += "-------|"
|
||||||
|
|
||||||
|
lines.append(header)
|
||||||
|
lines.append(separator)
|
||||||
|
|
||||||
|
for fw in FRAMEWORKS:
|
||||||
|
fw_results = results.get(fw, {})
|
||||||
|
row = f"| {FRAMEWORK_NAMES[fw]} |"
|
||||||
|
|
||||||
|
total_passed = 0
|
||||||
|
total_tests = 0
|
||||||
|
|
||||||
|
for cat_key in CATEGORIES:
|
||||||
|
cat_data = fw_results.get(cat_key, {"passed": 0, "total": 0})
|
||||||
|
passed = cat_data["passed"]
|
||||||
|
total = cat_data["total"]
|
||||||
|
total_passed += passed
|
||||||
|
total_tests += total
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
row += " - |"
|
||||||
|
elif passed == total:
|
||||||
|
row += f" {passed}/{total} |"
|
||||||
|
else:
|
||||||
|
row += f" **{passed}/{total}** |"
|
||||||
|
|
||||||
|
if total_tests == 0:
|
||||||
|
row += " - |"
|
||||||
|
elif total_passed == total_tests:
|
||||||
|
row += f" {total_passed}/{total_tests} |"
|
||||||
|
else:
|
||||||
|
row += f" **{total_passed}/{total_tests}** |"
|
||||||
|
|
||||||
|
lines.append(row)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("*Bold indicates failures*")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Calculate overall pass rate
|
||||||
|
all_passed = sum(
|
||||||
|
results[fw][cat]["passed"]
|
||||||
|
for fw in FRAMEWORKS
|
||||||
|
for cat in CATEGORIES
|
||||||
|
)
|
||||||
|
all_total = sum(
|
||||||
|
results[fw][cat]["total"]
|
||||||
|
for fw in FRAMEWORKS
|
||||||
|
for cat in CATEGORIES
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append(f"**Overall:** {all_passed}/{all_total} tests passed ({100*all_passed//all_total}%)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
base_dir = Path(__file__).parent.parent
|
||||||
|
results_dir = base_dir / "results"
|
||||||
|
results_file = results_dir / "pytest_results.json"
|
||||||
|
|
||||||
|
if not results_file.exists():
|
||||||
|
print(f"Results file not found: {results_file}")
|
||||||
|
return
|
||||||
|
|
||||||
|
results = parse_results(results_file)
|
||||||
|
md_content = generate_markdown(results)
|
||||||
|
|
||||||
|
# Write to results directory
|
||||||
|
md_file = results_dir / "compatibility_grid.md"
|
||||||
|
with open(md_file, "w") as f:
|
||||||
|
f.write(md_content)
|
||||||
|
print(f"Written: {md_file}")
|
||||||
|
|
||||||
|
# Also write JSON summary
|
||||||
|
json_file = results_dir / "compatibility_grid.json"
|
||||||
|
summary = {
|
||||||
|
"generated": datetime.now().isoformat(),
|
||||||
|
"worker": "gunicorn.workers.gasgi.ASGIWorker",
|
||||||
|
"frameworks": {
|
||||||
|
fw: {
|
||||||
|
"name": FRAMEWORK_NAMES[fw],
|
||||||
|
"categories": results[fw],
|
||||||
|
"total_passed": sum(results[fw][c]["passed"] for c in CATEGORIES),
|
||||||
|
"total_tests": sum(results[fw][c]["total"] for c in CATEGORIES),
|
||||||
|
}
|
||||||
|
for fw in FRAMEWORKS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with open(json_file, "w") as f:
|
||||||
|
json.dump(summary, indent=2, fp=f)
|
||||||
|
print(f"Written: {json_file}")
|
||||||
|
|
||||||
|
# Print the markdown
|
||||||
|
print("\n" + md_content)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
66
tests/docker/asgi_framework_compat/scripts/run_tests.sh
Executable file
66
tests/docker/asgi_framework_compat/scripts/run_tests.sh
Executable file
@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run ASGI Framework Compatibility Tests
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/run_tests.sh # Run with auto loop detection
|
||||||
|
# ./scripts/run_tests.sh asyncio # Run with asyncio loop
|
||||||
|
# ./scripts/run_tests.sh uvloop # Run with uvloop
|
||||||
|
# ./scripts/run_tests.sh both # Run both and generate combined report
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BASE_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
cd "$BASE_DIR"
|
||||||
|
|
||||||
|
LOOP_TYPE="${1:-auto}"
|
||||||
|
|
||||||
|
echo "=== ASGI Framework Compatibility Test Suite ==="
|
||||||
|
echo "Loop type: $LOOP_TYPE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Install test dependencies if needed
|
||||||
|
if ! python -c "import pytest" 2>/dev/null; then
|
||||||
|
echo "Installing test dependencies..."
|
||||||
|
pip install -r requirements.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$LOOP_TYPE" = "both" ]; then
|
||||||
|
echo "Running tests with asyncio loop..."
|
||||||
|
ASGI_LOOP=asyncio docker compose up -d --build
|
||||||
|
sleep 10 # Wait for services
|
||||||
|
pytest tests/ -v --tb=short || true
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Running tests with uvloop..."
|
||||||
|
ASGI_LOOP=uvloop docker compose up -d --build
|
||||||
|
sleep 10 # Wait for services
|
||||||
|
pytest tests/ -v --tb=short || true
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Generating combined report..."
|
||||||
|
python scripts/generate_grid.py --loop both --skip-tests
|
||||||
|
else
|
||||||
|
echo "Starting containers with $LOOP_TYPE loop..."
|
||||||
|
ASGI_LOOP="$LOOP_TYPE" docker compose up -d --build
|
||||||
|
|
||||||
|
echo "Waiting for services to be healthy..."
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Running tests..."
|
||||||
|
pytest tests/ -v --tb=short
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Generating compatibility grid..."
|
||||||
|
python scripts/generate_grid.py --loop "$LOOP_TYPE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Results saved to results/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done!"
|
||||||
1
tests/docker/asgi_framework_compat/tests/__init__.py
Normal file
1
tests/docker/asgi_framework_compat/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""ASGI Framework Compatibility Tests"""
|
||||||
127
tests/docker/asgi_framework_compat/tests/test_http_messages.py
Normal file
127
tests/docker/asgi_framework_compat/tests/test_http_messages.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
HTTP Message Type Tests
|
||||||
|
|
||||||
|
Tests ASGI 3.0 HTTP request/response message handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.http
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpRequestBody:
|
||||||
|
"""Test HTTP request body handling."""
|
||||||
|
|
||||||
|
async def test_echo_empty_body(self, http_client):
|
||||||
|
"""Echo endpoint handles empty body."""
|
||||||
|
response = await http_client.post("/echo", content=b"")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content == b""
|
||||||
|
|
||||||
|
async def test_echo_text_body(self, http_client):
|
||||||
|
"""Echo endpoint returns text body."""
|
||||||
|
body = "Hello, World!"
|
||||||
|
response = await http_client.post(
|
||||||
|
"/echo",
|
||||||
|
content=body,
|
||||||
|
headers={"Content-Type": "text/plain"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == body
|
||||||
|
|
||||||
|
async def test_echo_binary_body(self, http_client):
|
||||||
|
"""Echo endpoint returns binary body."""
|
||||||
|
body = b"\x00\x01\x02\x03\xff\xfe"
|
||||||
|
response = await http_client.post(
|
||||||
|
"/echo",
|
||||||
|
content=body,
|
||||||
|
headers={"Content-Type": "application/octet-stream"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content == body
|
||||||
|
|
||||||
|
async def test_echo_json_body(self, http_client):
|
||||||
|
"""Echo endpoint returns JSON body."""
|
||||||
|
body = '{"key": "value", "number": 42}'
|
||||||
|
response = await http_client.post(
|
||||||
|
"/echo",
|
||||||
|
content=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"key": "value", "number": 42}
|
||||||
|
|
||||||
|
async def test_echo_large_body(self, http_client, large_body):
|
||||||
|
"""Echo endpoint handles large body."""
|
||||||
|
body = large_body(100 * 1024) # 100KB
|
||||||
|
response = await http_client.post(
|
||||||
|
"/echo",
|
||||||
|
content=body,
|
||||||
|
headers={"Content-Type": "application/octet-stream"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.content) == len(body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpResponseStatus:
|
||||||
|
"""Test HTTP response status codes."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code", [200, 201, 204, 301, 400, 404, 500, 503])
|
||||||
|
async def test_status_codes(self, http_client, code):
|
||||||
|
"""Status endpoint returns correct status code."""
|
||||||
|
response = await http_client.get(f"/status/{code}")
|
||||||
|
assert response.status_code == code
|
||||||
|
|
||||||
|
async def test_status_100_continue(self, http_client):
|
||||||
|
"""Handle 100 status (may not be supported by all frameworks)."""
|
||||||
|
# Some frameworks may not support 100, so we just check it doesn't crash
|
||||||
|
response = await http_client.get("/status/100")
|
||||||
|
# 100 is special - some servers transform it
|
||||||
|
assert response.status_code in (100, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpResponseHeaders:
|
||||||
|
"""Test HTTP response header handling."""
|
||||||
|
|
||||||
|
async def test_content_type_header(self, http_client):
|
||||||
|
"""Response has Content-Type header."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
assert "content-type" in response.headers
|
||||||
|
assert "application/json" in response.headers["content-type"]
|
||||||
|
|
||||||
|
async def test_headers_preserved(self, http_client):
|
||||||
|
"""Custom headers in request are accessible."""
|
||||||
|
response = await http_client.get("/headers", headers={"X-Custom": "test123"})
|
||||||
|
data = response.json()
|
||||||
|
assert data.get("x-custom") == "test123"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpDisconnect:
|
||||||
|
"""Test HTTP disconnect handling."""
|
||||||
|
|
||||||
|
async def test_delay_can_be_cancelled(self, http_client):
|
||||||
|
"""Long delay can be interrupted (timeout behavior)."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# This tests that the server handles client disconnects gracefully
|
||||||
|
with pytest.raises(httpx.TimeoutException):
|
||||||
|
await http_client.get("/delay?seconds=30", timeout=0.5)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpResponseBody:
|
||||||
|
"""Test HTTP response body handling."""
|
||||||
|
|
||||||
|
async def test_large_response_body(self, http_client):
|
||||||
|
"""Large response body endpoint works."""
|
||||||
|
size = 100 * 1024 # 100KB
|
||||||
|
response = await http_client.get(f"/large?size={size}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.content) == size
|
||||||
|
|
||||||
|
async def test_very_large_response_body(self, http_client):
|
||||||
|
"""Very large response body endpoint works."""
|
||||||
|
size = 1024 * 1024 # 1MB
|
||||||
|
response = await http_client.get(f"/large?size={size}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.content) == size
|
||||||
168
tests/docker/asgi_framework_compat/tests/test_http_scope.py
Normal file
168
tests/docker/asgi_framework_compat/tests/test_http_scope.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
HTTP Scope Compliance Tests
|
||||||
|
|
||||||
|
Tests ASGI 3.0 HTTP scope compliance across frameworks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from frameworks.contract import ASGI_HTTP_SCOPE_REQUIRED_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.http
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpScopeBasics:
|
||||||
|
"""Test basic HTTP scope attributes."""
|
||||||
|
|
||||||
|
async def test_scope_endpoint_returns_json(self, http_client):
|
||||||
|
"""Scope endpoint returns valid JSON."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
|
async def test_scope_has_type_http(self, http_client):
|
||||||
|
"""Scope type is 'http'."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
assert data.get("type") == "http"
|
||||||
|
|
||||||
|
async def test_scope_has_asgi_dict(self, http_client):
|
||||||
|
"""Scope has 'asgi' dict with version info."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
assert "asgi" in data
|
||||||
|
assert isinstance(data["asgi"], dict)
|
||||||
|
assert "version" in data["asgi"]
|
||||||
|
|
||||||
|
async def test_scope_asgi_version_is_3(self, http_client):
|
||||||
|
"""ASGI version should be 3.x."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
version = data["asgi"]["version"]
|
||||||
|
assert version.startswith("3.")
|
||||||
|
|
||||||
|
async def test_scope_has_http_version(self, http_client):
|
||||||
|
"""Scope has http_version field."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
assert "http_version" in data
|
||||||
|
assert data["http_version"] in ("1.0", "1.1", "2", "3")
|
||||||
|
|
||||||
|
async def test_scope_has_method(self, http_client):
|
||||||
|
"""Scope has method field matching request method."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
assert data.get("method") == "GET"
|
||||||
|
|
||||||
|
async def test_scope_has_scheme(self, http_client):
|
||||||
|
"""Scope has scheme field."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
assert "scheme" in data
|
||||||
|
assert data["scheme"] in ("http", "https")
|
||||||
|
|
||||||
|
async def test_scope_has_path(self, http_client):
|
||||||
|
"""Scope has path field matching request path."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
assert data.get("path") == "/scope"
|
||||||
|
|
||||||
|
async def test_scope_has_query_string(self, http_client):
|
||||||
|
"""Scope has query_string field."""
|
||||||
|
response = await http_client.get("/scope?foo=bar")
|
||||||
|
data = response.json()
|
||||||
|
assert "query_string" in data
|
||||||
|
assert "foo=bar" in data["query_string"]
|
||||||
|
|
||||||
|
async def test_scope_empty_query_string(self, http_client):
|
||||||
|
"""Empty query string handled correctly."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
assert "query_string" in data
|
||||||
|
assert data["query_string"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpScopeHeaders:
|
||||||
|
"""Test HTTP scope header handling."""
|
||||||
|
|
||||||
|
async def test_scope_has_headers(self, http_client):
|
||||||
|
"""Scope has headers field."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
assert "headers" in data
|
||||||
|
assert isinstance(data["headers"], list)
|
||||||
|
|
||||||
|
async def test_scope_headers_are_lists(self, http_client):
|
||||||
|
"""Each header is a list of [name, value]."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
for header in data["headers"]:
|
||||||
|
assert isinstance(header, list)
|
||||||
|
assert len(header) == 2
|
||||||
|
|
||||||
|
async def test_scope_header_names_lowercase(self, http_client):
|
||||||
|
"""Header names should be lowercase."""
|
||||||
|
response = await http_client.get("/scope", headers={"X-Custom-Header": "test"})
|
||||||
|
data = response.json()
|
||||||
|
custom_headers = [h for h in data["headers"] if h[0] == "x-custom-header"]
|
||||||
|
assert len(custom_headers) > 0
|
||||||
|
|
||||||
|
async def test_headers_endpoint_returns_all_headers(self, http_client):
|
||||||
|
"""Headers endpoint returns all sent headers."""
|
||||||
|
custom_headers = {
|
||||||
|
"X-Test-One": "value1",
|
||||||
|
"X-Test-Two": "value2",
|
||||||
|
}
|
||||||
|
response = await http_client.get("/headers", headers=custom_headers)
|
||||||
|
data = response.json()
|
||||||
|
assert data.get("x-test-one") == "value1"
|
||||||
|
assert data.get("x-test-two") == "value2"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpScopeServer:
|
||||||
|
"""Test HTTP scope server and client fields."""
|
||||||
|
|
||||||
|
async def test_scope_has_server(self, http_client):
|
||||||
|
"""Scope has server field."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
assert "server" in data
|
||||||
|
|
||||||
|
async def test_scope_server_is_tuple(self, http_client):
|
||||||
|
"""Server is [host, port] list."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
if data["server"] is not None:
|
||||||
|
assert isinstance(data["server"], list)
|
||||||
|
assert len(data["server"]) == 2
|
||||||
|
|
||||||
|
async def test_scope_has_client(self, http_client):
|
||||||
|
"""Scope has client field (may be None)."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
# client is optional but should be present
|
||||||
|
assert "client" in data or data.get("client") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpScopeRequired:
|
||||||
|
"""Test all required scope keys are present."""
|
||||||
|
|
||||||
|
async def test_all_required_keys_present(self, http_client):
|
||||||
|
"""All ASGI 3.0 required HTTP scope keys are present."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
for key in ASGI_HTTP_SCOPE_REQUIRED_KEYS:
|
||||||
|
assert key in data, f"Missing required scope key: {key}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpScopeRootPath:
|
||||||
|
"""Test root_path handling."""
|
||||||
|
|
||||||
|
async def test_scope_has_root_path(self, http_client):
|
||||||
|
"""Scope has root_path field (may be empty)."""
|
||||||
|
response = await http_client.get("/scope")
|
||||||
|
data = response.json()
|
||||||
|
# root_path should be present, defaults to ""
|
||||||
|
assert "root_path" in data or data.get("root_path", "") == ""
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Lifespan Protocol Tests
|
||||||
|
|
||||||
|
Tests ASGI 3.0 lifespan protocol compliance across frameworks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.lifespan
|
||||||
|
|
||||||
|
|
||||||
|
class TestLifespanStartup:
|
||||||
|
"""Test lifespan startup handling."""
|
||||||
|
|
||||||
|
async def test_startup_was_called(self, http_client):
|
||||||
|
"""Startup handler was called."""
|
||||||
|
response = await http_client.get("/lifespan/state")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data.get("startup_called") is True
|
||||||
|
|
||||||
|
async def test_startup_time_set(self, http_client):
|
||||||
|
"""Startup time was recorded."""
|
||||||
|
response = await http_client.get("/lifespan/state")
|
||||||
|
data = response.json()
|
||||||
|
assert data.get("startup_time") is not None
|
||||||
|
assert isinstance(data["startup_time"], (int, float))
|
||||||
|
|
||||||
|
async def test_startup_custom_data(self, http_client):
|
||||||
|
"""Custom data set during startup is available."""
|
||||||
|
response = await http_client.get("/lifespan/state")
|
||||||
|
data = response.json()
|
||||||
|
custom_data = data.get("custom_data", {})
|
||||||
|
assert custom_data.get("initialized") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestLifespanState:
|
||||||
|
"""Test lifespan state persistence."""
|
||||||
|
|
||||||
|
async def test_counter_initial_value(self, http_client):
|
||||||
|
"""Counter starts at expected initial value."""
|
||||||
|
# First get the state to see current counter
|
||||||
|
response = await http_client.get("/lifespan/state")
|
||||||
|
initial = response.json().get("counter", 0)
|
||||||
|
|
||||||
|
# Increment once
|
||||||
|
response = await http_client.get("/lifespan/counter")
|
||||||
|
data = response.json()
|
||||||
|
assert data["counter"] == initial + 1
|
||||||
|
|
||||||
|
async def test_counter_increments(self, http_client):
|
||||||
|
"""Counter increments on each request."""
|
||||||
|
# Get first value
|
||||||
|
response1 = await http_client.get("/lifespan/counter")
|
||||||
|
value1 = response1.json()["counter"]
|
||||||
|
|
||||||
|
# Get second value
|
||||||
|
response2 = await http_client.get("/lifespan/counter")
|
||||||
|
value2 = response2.json()["counter"]
|
||||||
|
|
||||||
|
# Should have incremented
|
||||||
|
assert value2 == value1 + 1
|
||||||
|
|
||||||
|
async def test_state_persists_across_requests(self, http_client):
|
||||||
|
"""State persists across multiple requests."""
|
||||||
|
# Make several requests
|
||||||
|
values = []
|
||||||
|
for _ in range(3):
|
||||||
|
response = await http_client.get("/lifespan/counter")
|
||||||
|
values.append(response.json()["counter"])
|
||||||
|
|
||||||
|
# Each should be incrementing
|
||||||
|
assert values[1] == values[0] + 1
|
||||||
|
assert values[2] == values[1] + 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestLifespanStateSharing:
|
||||||
|
"""Test state sharing between lifespan and request handlers."""
|
||||||
|
|
||||||
|
async def test_lifespan_state_accessible(self, http_client):
|
||||||
|
"""Lifespan state is accessible from request handlers."""
|
||||||
|
response = await http_client.get("/lifespan/state")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
# Should have the startup marker
|
||||||
|
assert "startup_called" in data
|
||||||
|
|
||||||
|
async def test_state_modifications_persist(self, http_client):
|
||||||
|
"""Modifications to state persist."""
|
||||||
|
# Increment counter
|
||||||
|
await http_client.get("/lifespan/counter")
|
||||||
|
|
||||||
|
# Check state still shows startup was called
|
||||||
|
response = await http_client.get("/lifespan/state")
|
||||||
|
data = response.json()
|
||||||
|
assert data.get("startup_called") is True
|
||||||
|
# Counter should be > 0
|
||||||
|
assert data.get("counter", 0) > 0
|
||||||
98
tests/docker/asgi_framework_compat/tests/test_streaming.py
Normal file
98
tests/docker/asgi_framework_compat/tests/test_streaming.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
Streaming Response Tests
|
||||||
|
|
||||||
|
Tests chunked streaming and Server-Sent Events across frameworks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.streaming
|
||||||
|
|
||||||
|
|
||||||
|
class TestChunkedStreaming:
|
||||||
|
"""Test chunked transfer encoding responses."""
|
||||||
|
|
||||||
|
async def test_streaming_response(self, http_client):
|
||||||
|
"""Streaming endpoint returns chunked response."""
|
||||||
|
response = await http_client.get("/streaming")
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Check we got all chunks
|
||||||
|
content = response.text
|
||||||
|
for i in range(10):
|
||||||
|
assert f"chunk-{i}" in content
|
||||||
|
|
||||||
|
async def test_streaming_content_type(self, http_client):
|
||||||
|
"""Streaming response has correct content type."""
|
||||||
|
response = await http_client.get("/streaming")
|
||||||
|
assert "text/plain" in response.headers.get("content-type", "")
|
||||||
|
|
||||||
|
async def test_streaming_order_preserved(self, http_client):
|
||||||
|
"""Chunks arrive in correct order."""
|
||||||
|
response = await http_client.get("/streaming")
|
||||||
|
lines = [l for l in response.text.strip().split("\n") if l]
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
assert line == f"chunk-{i}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestServerSentEvents:
|
||||||
|
"""Test Server-Sent Events (SSE) responses."""
|
||||||
|
|
||||||
|
async def test_sse_response(self, http_client):
|
||||||
|
"""SSE endpoint returns event stream."""
|
||||||
|
response = await http_client.get("/sse")
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.text
|
||||||
|
assert "event:" in content
|
||||||
|
assert "data:" in content
|
||||||
|
|
||||||
|
async def test_sse_content_type(self, http_client):
|
||||||
|
"""SSE response has correct content type."""
|
||||||
|
response = await http_client.get("/sse")
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
|
assert "text/event-stream" in content_type
|
||||||
|
|
||||||
|
async def test_sse_event_format(self, http_client):
|
||||||
|
"""SSE events have correct format."""
|
||||||
|
response = await http_client.get("/sse")
|
||||||
|
content = response.text
|
||||||
|
|
||||||
|
# Check for message events
|
||||||
|
assert "event: message" in content
|
||||||
|
|
||||||
|
# Check for done event
|
||||||
|
assert "event: done" in content
|
||||||
|
|
||||||
|
async def test_sse_data_is_json(self, http_client):
|
||||||
|
"""SSE data fields contain valid JSON."""
|
||||||
|
response = await http_client.get("/sse")
|
||||||
|
lines = response.text.split("\n")
|
||||||
|
|
||||||
|
data_lines = [l for l in lines if l.startswith("data:")]
|
||||||
|
for line in data_lines:
|
||||||
|
data_str = line[5:].strip() # Remove "data:" prefix
|
||||||
|
data = json.loads(data_str)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
|
async def test_sse_message_count(self, http_client):
|
||||||
|
"""Correct number of SSE messages received."""
|
||||||
|
response = await http_client.get("/sse")
|
||||||
|
lines = response.text.split("\n")
|
||||||
|
|
||||||
|
message_events = [l for l in lines if l == "event: message"]
|
||||||
|
# Should have 5 message events
|
||||||
|
assert len(message_events) == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamingLargeData:
|
||||||
|
"""Test streaming with large data."""
|
||||||
|
|
||||||
|
async def test_large_streaming_response(self, http_client):
|
||||||
|
"""Large response body streams correctly."""
|
||||||
|
size = 5 * 1024 * 1024 # 5MB
|
||||||
|
response = await http_client.get(f"/large?size={size}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.content) == size
|
||||||
193
tests/docker/asgi_framework_compat/tests/test_websocket_scope.py
Normal file
193
tests/docker/asgi_framework_compat/tests/test_websocket_scope.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
"""
|
||||||
|
WebSocket Scope Compliance Tests
|
||||||
|
|
||||||
|
Tests ASGI 3.0 WebSocket scope compliance across frameworks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import websockets
|
||||||
|
from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError
|
||||||
|
|
||||||
|
from frameworks.contract import (
|
||||||
|
ASGI_WEBSOCKET_SCOPE_REQUIRED_KEYS,
|
||||||
|
VALID_WEBSOCKET_CLOSE_CODES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.websocket
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketConnection:
|
||||||
|
"""Test WebSocket connection handling."""
|
||||||
|
|
||||||
|
async def test_websocket_connect(self, ws_client):
|
||||||
|
"""WebSocket connection can be established."""
|
||||||
|
ws = await ws_client("/ws/echo")
|
||||||
|
# websockets v16+ uses state instead of open
|
||||||
|
from websockets.protocol import State
|
||||||
|
assert ws.state == State.OPEN
|
||||||
|
await ws.close()
|
||||||
|
|
||||||
|
async def test_websocket_echo_text(self, ws_client):
|
||||||
|
"""WebSocket echo endpoint echoes text messages."""
|
||||||
|
ws = await ws_client("/ws/echo")
|
||||||
|
try:
|
||||||
|
await ws.send("Hello, WebSocket!")
|
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
assert response == "Hello, WebSocket!"
|
||||||
|
finally:
|
||||||
|
await ws.close()
|
||||||
|
|
||||||
|
async def test_websocket_echo_multiple_messages(self, ws_client):
|
||||||
|
"""WebSocket echo handles multiple messages."""
|
||||||
|
ws = await ws_client("/ws/echo")
|
||||||
|
try:
|
||||||
|
messages = ["msg1", "msg2", "msg3"]
|
||||||
|
for msg in messages:
|
||||||
|
await ws.send(msg)
|
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
assert response == msg
|
||||||
|
finally:
|
||||||
|
await ws.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketBinary:
|
||||||
|
"""Test WebSocket binary message handling."""
|
||||||
|
|
||||||
|
async def test_websocket_echo_binary(self, ws_client):
|
||||||
|
"""WebSocket binary echo endpoint echoes binary messages."""
|
||||||
|
ws = await ws_client("/ws/echo-binary")
|
||||||
|
try:
|
||||||
|
data = b"\x00\x01\x02\x03\xff\xfe"
|
||||||
|
await ws.send(data)
|
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
assert response == data
|
||||||
|
finally:
|
||||||
|
await ws.close()
|
||||||
|
|
||||||
|
async def test_websocket_echo_large_binary(self, ws_client, random_bytes):
|
||||||
|
"""WebSocket handles large binary messages."""
|
||||||
|
ws = await ws_client("/ws/echo-binary")
|
||||||
|
try:
|
||||||
|
data = random_bytes(64 * 1024) # 64KB
|
||||||
|
await ws.send(data)
|
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=10.0)
|
||||||
|
assert response == data
|
||||||
|
finally:
|
||||||
|
await ws.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketScope:
|
||||||
|
"""Test WebSocket scope attributes."""
|
||||||
|
|
||||||
|
async def test_websocket_scope_endpoint(self, ws_client):
|
||||||
|
"""WebSocket scope endpoint returns scope JSON."""
|
||||||
|
ws = await ws_client("/ws/scope")
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
data = json.loads(response)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
except ConnectionClosedOK:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def test_websocket_scope_type(self, ws_client):
|
||||||
|
"""WebSocket scope type is 'websocket'."""
|
||||||
|
ws = await ws_client("/ws/scope")
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
data = json.loads(response)
|
||||||
|
assert data.get("type") == "websocket"
|
||||||
|
except ConnectionClosedOK:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def test_websocket_scope_has_path(self, ws_client):
|
||||||
|
"""WebSocket scope has path field."""
|
||||||
|
ws = await ws_client("/ws/scope")
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
data = json.loads(response)
|
||||||
|
assert "/ws/scope" in data.get("path", "")
|
||||||
|
except ConnectionClosedOK:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def test_websocket_scope_has_headers(self, ws_client):
|
||||||
|
"""WebSocket scope has headers field."""
|
||||||
|
ws = await ws_client("/ws/scope")
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
data = json.loads(response)
|
||||||
|
assert "headers" in data
|
||||||
|
assert isinstance(data["headers"], list)
|
||||||
|
except ConnectionClosedOK:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def test_websocket_scope_required_keys(self, ws_client):
|
||||||
|
"""WebSocket scope has all required keys."""
|
||||||
|
ws = await ws_client("/ws/scope")
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
data = json.loads(response)
|
||||||
|
for key in ASGI_WEBSOCKET_SCOPE_REQUIRED_KEYS:
|
||||||
|
assert key in data, f"Missing required WebSocket scope key: {key}"
|
||||||
|
except ConnectionClosedOK:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketSubprotocol:
|
||||||
|
"""Test WebSocket subprotocol negotiation."""
|
||||||
|
|
||||||
|
async def test_subprotocol_negotiation(self, ws_client):
|
||||||
|
"""WebSocket subprotocol negotiation works."""
|
||||||
|
ws = await ws_client("/ws/subprotocol", subprotocols=["proto1", "proto2"])
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
data = json.loads(response)
|
||||||
|
assert "requested" in data
|
||||||
|
assert "proto1" in data["requested"]
|
||||||
|
assert "proto2" in data["requested"]
|
||||||
|
except ConnectionClosedOK:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def test_subprotocol_selection(self, ws_client):
|
||||||
|
"""First requested subprotocol is selected."""
|
||||||
|
ws = await ws_client("/ws/subprotocol", subprotocols=["myproto"])
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
data = json.loads(response)
|
||||||
|
assert data.get("selected") == "myproto"
|
||||||
|
except ConnectionClosedOK:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketClose:
|
||||||
|
"""Test WebSocket close handling."""
|
||||||
|
|
||||||
|
async def test_close_normal(self, ws_client):
|
||||||
|
"""WebSocket closes with normal code 1000."""
|
||||||
|
ws = await ws_client("/ws/close?code=1000")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
except (ConnectionClosedOK, ConnectionClosedError) as e:
|
||||||
|
assert e.code == 1000
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code", [1001, 1002, 1003, 1008, 1011])
|
||||||
|
async def test_close_codes(self, ws_client, code):
|
||||||
|
"""WebSocket closes with various codes."""
|
||||||
|
ws = await ws_client(f"/ws/close?code={code}")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
except (ConnectionClosedOK, ConnectionClosedError) as e:
|
||||||
|
assert e.code == code
|
||||||
|
|
||||||
|
async def test_client_close(self, ws_client):
|
||||||
|
"""Server handles client-initiated close."""
|
||||||
|
ws = await ws_client("/ws/echo")
|
||||||
|
await ws.send("test")
|
||||||
|
await ws.recv()
|
||||||
|
await ws.close(code=1000)
|
||||||
|
# Connection should be closed cleanly
|
||||||
|
from websockets.protocol import State
|
||||||
|
assert ws.state == State.CLOSED
|
||||||
Loading…
x
Reference in New Issue
Block a user