Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/examples-latest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Examples (latest dependencies)

on:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
pull_request:
branches: [main]
paths:
- 'examples/**'
- 'tests/examples/**'
- 'src/**'
- 'pyproject.toml'
- '.github/workflows/examples-latest.yml'

concurrency:
group: examples-latest-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: true

permissions:
contents: read

jobs:
examples-latest:
name: Examples - Python ${{ matrix.python-version }} (latest deps)
runs-on: ubuntu-latest
env:
UV_PYTHON: ${{ matrix.python-version }}
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.15"]

steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v6
with:
enable-cache: true
version: "0.9.12"

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies with latest allowed versions
run: uv sync --all-extras --upgrade

- name: Print resolved versions of key frameworks
run: |
uv pip list | grep -Ei '^(litestar|starlette|fastapi|strawberry-graphql|advanced-alchemy|sqlalchemy|aiosqlite|mcp|uvicorn|jinja2|httpx) '

- name: Run example tests
run: uv run pytest tests/examples -v
shell: bash
env:
PYTHONDONTWRITEBYTECODE: 1
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.DEFAULT_GOAL := help
.PHONY: help install dev install-uv lint lint-fix fmt fmt-check type-check test test-cov test-fast test-parallel test-parallel-fast test-debug test-failed ci docs docs-serve docs-clean build clean destroy prek-install prek-run prek-update lock upgrade wt worktree wt-ls worktree-list wt-j worktree-jump worktree-prune ci-install example example-asgi example-litestar example-aa example-graphql example-websocket example-mcp example-mcp-server act act-ci act-test
.PHONY: help install dev install-uv lint lint-fix fmt fmt-check type-check test test-cov test-fast test-examples test-parallel test-parallel-fast test-debug test-failed ci docs docs-serve docs-clean build clean destroy prek-install prek-run prek-update lock upgrade wt worktree wt-ls worktree-list wt-j worktree-jump worktree-prune ci-install example example-asgi example-litestar example-aa example-graphql example-websocket example-mcp example-mcp-server act act-ci act-test

# ==================================================================================== #
# VARIABLES
Expand Down Expand Up @@ -58,6 +58,9 @@ test-cov: ## Run tests with coverage report
test-fast: ## Run tests without slow/integration tests
@PYTHONDONTWRITEBYTECODE=1 uv run pytest -m "not integration"

test-examples: ## Run example app tests only
@PYTHONDONTWRITEBYTECODE=1 uv run pytest tests/examples -v

test-parallel: ## Run tests in parallel with pytest-xdist
@PYTHONDONTWRITEBYTECODE=1 uv run pytest -n auto

Expand Down
2 changes: 1 addition & 1 deletion examples/litestar_advanced_alchemy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class User(UUIDAuditBase):

name: Mapped[str] = mapped_column(String(100))
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
posts: Mapped[list[Post]] = relationship(back_populates="author")
posts: Mapped[list[Post]] = relationship(back_populates="author", cascade="all, delete-orphan")


class Post(UUIDAuditBase):
Expand Down
1 change: 1 addition & 0 deletions tests/examples/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests exercising the applications under ``examples/``."""
52 changes: 52 additions & 0 deletions tests/examples/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Shared fixtures for testing the applications under ``examples/``.

``examples`` is a real (namespace) package, but its submodules build module-level
singletons (toolbars, Litestar/Starlette/FastAPI apps, on-disk storage files) at
import time. Importing the same example twice in a test session would reuse that
stale state, so every test must import examples through the ``import_example``
fixture below, which evicts any cached module (and its submodules) before and
after each import to guarantee a fresh app per test.
"""

from __future__ import annotations

import importlib
import sys
from collections.abc import Callable
from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
from collections.abc import Iterator
from types import ModuleType


def _evict(dotted_path: str) -> None:
"""Remove ``dotted_path`` and any of its submodules from ``sys.modules``."""
for name in [n for n in sys.modules if n == dotted_path or n.startswith(f"{dotted_path}.")]:
del sys.modules[name]


@pytest.fixture
def import_example() -> Iterator[Callable[[str], ModuleType]]:
"""Import an example module fresh, isolating module-level state between tests.

Usage::

def test_something(import_example):
app_module = import_example("examples.litestar_basic.app")
client = TestClient(app_module.app)
"""
imported: list[str] = []

def _import(dotted_path: str) -> ModuleType:
_evict(dotted_path)
module = importlib.import_module(dotted_path)
imported.append(dotted_path)
return module

yield _import

for dotted_path in imported:
_evict(dotted_path)
57 changes: 57 additions & 0 deletions tests/examples/test_alerts_panel_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Tests for the alerts panel example script."""

from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
from types import ModuleType


@pytest.mark.asyncio
async def test_main_reports_real_alerts_for_every_scenario(
import_example: Callable[[str], ModuleType],
capsys: pytest.CaptureFixture[str],
) -> None:
"""Running the real script should produce real, scenario-specific alerts."""
module = import_example("examples.alerts_panel_example")

await module.main()

output = capsys.readouterr().out

# Example 1: missing security headers.
assert "Total Alerts: 3" in output
assert "Content-Security-Policy" in output
assert "X-Content-Type-Options" in output
assert "Strict-Transport-Security" in output

# Example 2: insecure cookie.
assert "Cookie Security Alerts:" in output

# Example 3: missing CSRF protection on a POST request.
assert "CSRF Alerts:" in output

# Example 4: debug mode enabled in production really triggers a configuration alert.
assert "Configuration Alerts:" in output

# Example 5: oversized response body.
assert "Performance Alerts:" in output

# Example 6: slow database queries.
assert "Database Performance Alerts:" in output

# Example 7: N+1 query pattern really triggers an alert naming the real suggestion.
assert "N+1 Query Alerts:" in output
assert "joinedload" in output

# Example 8: combined scenario really spans multiple alert categories.
assert "Summary by Category:" in output
assert "Security:" in output
assert "Database:" in output
assert "All Alerts:" in output

assert "Examples completed successfully!" in output
137 changes: 137 additions & 0 deletions tests/examples/test_asgi_basic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Tests for the raw-ASGI ``examples/asgi_basic`` example.

This example hand-rolls its own debug-toolbar middleware directly against the
``DebugToolbar`` core API. It injects the toolbar markup into HTML responses,
serves ``/_debug_toolbar/static/*`` assets, and exposes JSON endpoints at
``/_debug_toolbar/api/requests`` and ``/_debug_toolbar/api/requests/<id>``.
"""

from __future__ import annotations

from typing import TYPE_CHECKING
from uuid import uuid4

import httpx
import pytest

if TYPE_CHECKING:
from collections.abc import Callable
from types import ModuleType


@pytest.fixture
def asgi_basic(import_example: Callable[[str], ModuleType]) -> ModuleType:
return import_example("examples.asgi_basic.app")


def _client(asgi_basic: ModuleType) -> httpx.AsyncClient:
transport = httpx.ASGITransport(app=asgi_basic.app)
return httpx.AsyncClient(transport=transport, base_url="http://testserver")


async def test_home_page_injects_toolbar_markup(asgi_basic: ModuleType) -> None:
async with _client(asgi_basic) as client:
response = await client.get("/")

assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
assert "ASGI Debug Toolbar Example" in response.text
assert 'id="debug-toolbar"' in response.text
assert '<link rel="stylesheet" href="/_debug_toolbar/static/toolbar.css">' in response.text
assert '<script src="/_debug_toolbar/static/toolbar.js"></script>' in response.text


async def test_about_page_injects_toolbar_markup(asgi_basic: ModuleType) -> None:
async with _client(asgi_basic) as client:
response = await client.get("/about")

assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
assert "About" in response.text
assert 'id="debug-toolbar"' in response.text


async def test_api_data_returns_json_without_toolbar_markup(asgi_basic: ModuleType) -> None:
async with _client(asgi_basic) as client:
response = await client.get("/api/data")

assert response.status_code == 200
assert response.headers["content-type"].startswith("application/json")
data = response.json()
assert data["message"] == "Hello from the API"
assert 'id="debug-toolbar"' not in response.text


async def test_unknown_path_returns_404(asgi_basic: ModuleType) -> None:
async with _client(asgi_basic) as client:
response = await client.get("/no-such-page")

assert response.status_code == 404


async def test_server_timing_header_present_after_request(asgi_basic: ModuleType) -> None:
async with _client(asgi_basic) as client:
response = await client.get("/")

assert "server-timing" in response.headers


async def test_static_css_asset(asgi_basic: ModuleType) -> None:
async with _client(asgi_basic) as client:
response = await client.get("/_debug_toolbar/static/toolbar.css")

assert response.status_code == 200
assert response.headers["content-type"].startswith("text/css")
assert response.text


async def test_static_js_asset(asgi_basic: ModuleType) -> None:
async with _client(asgi_basic) as client:
response = await client.get("/_debug_toolbar/static/toolbar.js")

assert response.status_code == 200
assert "javascript" in response.headers["content-type"]
assert response.text


async def test_debug_toolbar_api_requests_lists_recorded_request(asgi_basic: ModuleType) -> None:
async with _client(asgi_basic) as client:
await client.get("/")
response = await client.get("/_debug_toolbar/api/requests")

assert response.status_code == 200
assert response.headers["content-type"].startswith("application/json")
requests_list = response.json()
assert len(requests_list) >= 1
assert "request_id" in requests_list[0]
assert requests_list[0]["path"] == "/"


async def test_debug_toolbar_api_request_detail_contains_panel_data(asgi_basic: ModuleType) -> None:
async with _client(asgi_basic) as client:
await client.get("/")
requests_response = await client.get("/_debug_toolbar/api/requests")
request_id = requests_response.json()[0]["request_id"]
detail_response = await client.get(f"/_debug_toolbar/api/requests/{request_id}")

assert detail_response.status_code == 200
assert detail_response.headers["content-type"].startswith("application/json")
detail = detail_response.json()

assert detail["request_id"] == request_id
panel_data = detail["panel_data"]
assert set(panel_data) >= {"TimerPanel", "RequestPanel", "ResponsePanel", "LoggingPanel", "VersionsPanel"}

timer_stats = panel_data["TimerPanel"]
assert timer_stats["total_time_ms"] >= 0

request_stats = panel_data["RequestPanel"]
assert request_stats["method"] == "GET"
assert request_stats["path"] == "/"


async def test_debug_toolbar_api_request_detail_unknown_id_returns_404(asgi_basic: ModuleType) -> None:
async with _client(asgi_basic) as client:
response = await client.get(f"/_debug_toolbar/api/requests/{uuid4()}")

assert response.status_code == 404
Loading
Loading