Skip to content

Commit f018f5f

Browse files
committed
ci: add test/lint matrix and PyPI trusted-publishing workflows
ci.yml runs lint + a Python 3.12/3.13/3.14 test matrix (100% coverage gate) plus a floor job on the lowest supported deps (FastAPI 0.115 / pydantic 2.9) via --resolution lowest-direct, on push/PR/workflow_call. publish.yml reuses it on release and publishes to PyPI via trusted publishing (OIDC). Version- conditional edges are skipped on the floor; adds httpx alongside httpx2 for the old-starlette TestClient, PyPI metadata (classifiers, URLs) and a packaging smoke test.
1 parent 167d885 commit f018f5f

9 files changed

Lines changed: 232 additions & 6 deletions

File tree

.github/workflows/ci.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_call:
8+
9+
jobs:
10+
lint:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: astral-sh/setup-uv@v8
15+
with:
16+
enable-cache: true
17+
python-version: "3.12"
18+
- run: uv sync
19+
- run: uv run ruff format --check
20+
- run: uv run ruff check
21+
- run: uv run ty check
22+
23+
test:
24+
runs-on: ubuntu-latest
25+
strategy:
26+
fail-fast: false
27+
matrix:
28+
python-version: ["3.12", "3.13", "3.14"]
29+
steps:
30+
- uses: actions/checkout@v4
31+
- uses: astral-sh/setup-uv@v8
32+
with:
33+
enable-cache: true
34+
python-version: ${{ matrix.python-version }}
35+
- run: uv sync
36+
- run: uv run pytest --cov
37+
38+
test-floor:
39+
# Lowest supported direct dependencies (FastAPI 0.115, pydantic 2.9): eager routing regime.
40+
# No coverage gate — the iter_route_contexts branch cannot run without that attribute.
41+
runs-on: ubuntu-latest
42+
steps:
43+
- uses: actions/checkout@v4
44+
- uses: astral-sh/setup-uv@v8
45+
with:
46+
enable-cache: true
47+
python-version: "3.12"
48+
- run: uv sync --resolution lowest-direct
49+
- run: uv run --no-sync pytest

.github/workflows/docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
runs-on: ubuntu-latest
2626
steps:
2727
- uses: actions/checkout@v4
28-
- uses: astral-sh/setup-uv@v5
28+
- uses: astral-sh/setup-uv@v8
2929
with:
3030
enable-cache: true
3131
- run: uv sync --no-dev --group docs

.github/workflows/publish.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: publish
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
jobs:
8+
ci:
9+
uses: ./.github/workflows/ci.yml
10+
11+
publish:
12+
needs: ci
13+
runs-on: ubuntu-latest
14+
environment:
15+
name: pypi
16+
url: https://pypi.org/project/fastapi-typed-errors/
17+
permissions:
18+
id-token: write # PyPI trusted publishing (OIDC) — no stored token
19+
contents: read
20+
steps:
21+
- uses: actions/checkout@v4
22+
- uses: astral-sh/setup-uv@v8
23+
with:
24+
python-version: "3.12"
25+
- run: uv build
26+
- name: Smoke test the wheel
27+
run: uv run --isolated --no-project --with dist/*.whl scripts/smoke_test.py
28+
- name: Smoke test the source distribution
29+
run: uv run --isolated --no-project --with dist/*.tar.gz scripts/smoke_test.py
30+
- run: uv publish

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,5 +94,8 @@ src-layout, package `src/fastapi_typed_errors/`:
9494
- Prefer pytest **fixtures** for reusable arrange values (request objects, configured apps, built artifacts) — typed, with a docstring and a `Returns:` section. Module level is only for error classes/enums that must exist at import time (they are used in type positions). Don't name a fixture `request` — it clashes with pytest's built-in.
9595
- Lint everything: `just lint` (ruff format --check, ruff check, ty check). Run `uv run ruff format` to apply formatting. **ruff (preview) also formats Python code blocks inside Markdown** (`README.md`, `docs/**`), so keep doc snippets canonically formatted and re-run `just lint` after editing prose with code.
9696
- Docs: **Material for MkDocs**, bilingual (EN default at `/`, RU at `/ru/`) via `mkdocs-static-i18n` (folder mode: `docs/en/**`, `docs/ru/**`; `nav_translations` localize the nav; language-specific assets like the Swagger SVG live per-language, shared CSS in `docs/stylesheets/`). API reference is auto-generated from Google docstrings via `mkdocstrings[python]` (`:::` blocks). Deps in the `docs` group (`~=`); serve `just docs`, strict build `just docs-build`. Published to GitHub Pages by `.github/workflows/docs.yml` (uv build + `upload-pages-artifact`/`deploy-pages`; Pages source = "GitHub Actions").
97+
- CI/CD (`.github/workflows/`, uv + `astral-sh/setup-uv@v8`): `ci.yml` (on push-main / PR / `workflow_call`) — `lint` + `test` (Python matrix 3.12/3.13/3.14, `pytest --cov` → 100% gate) + `test-floor` (`uv sync --resolution lowest-direct` then **`uv run --no-sync pytest`**`uv run` alone re-syncs to the lockfile; floor = FastAPI 0.115 / pydantic 2.9, **no** coverage gate since the `iter_route_contexts` branch can't run there). `publish.yml` (on release published) reuses `ci.yml` then publishes via **PyPI Trusted Publishing / OIDC** (`environment: pypi`, `id-token: write`, `uv build` + smoke `scripts/smoke_test.py` on wheel+sdist + `uv publish`, no token) — needs a one-time PyPI pending-publisher config.
98+
- Version-conditional tests: edges that behave differently on old FastAPI/pydantic (PEP 695 `type`-alias return annotations, the broken-param-hint tolerance, lazy-include dedup) are guarded by `requires_modern_fastapi = skipif(not hasattr(fastapi.routing, "iter_route_contexts"))` — they run (and count toward 100%) on the default stack, skip on the floor. `monkeypatch.delattr(..., raising=False)` where the attribute is absent on the floor.
99+
- Both `httpx` (old starlette `TestClient`, the FastAPI floor) and `httpx2` (new starlette) are in the dev group.
97100
- Environment and build: uv (`uv sync`, `uv run python ...`), build backend `uv_build`.
98101
- Run smoke checks against a live FastAPI app: `uv run python <script>` (`httpx2` for `TestClient` is in the dev group, so no `--with` is needed anymore).

pyproject.toml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ requires-python = ">=3.12"
1010
license = "MIT"
1111
license-files = ["LICENSE"]
1212
keywords = ["fastapi", "errors", "openapi", "typed", "pydantic"]
13+
classifiers = [
14+
"Development Status :: 4 - Beta",
15+
"Framework :: FastAPI",
16+
"Intended Audience :: Developers",
17+
"Operating System :: OS Independent",
18+
"Programming Language :: Python :: 3 :: Only",
19+
"Programming Language :: Python :: 3.12",
20+
"Programming Language :: Python :: 3.13",
21+
"Programming Language :: Python :: 3.14",
22+
"Topic :: Internet :: WWW/HTTP",
23+
"Typing :: Typed",
24+
]
1325
dependencies = [
1426
# 0.137/0.138 introduced the lazy _IncludedRouter tree but not the supported
1527
# iter_route_contexts iterator — nested includes cannot be walked there; skip that gap.
@@ -23,6 +35,12 @@ cli = ["typer>=0.15"]
2335
[project.scripts]
2436
fastapi-typed-errors = "fastapi_typed_errors.analysis.cli:main"
2537

38+
[project.urls]
39+
Homepage = "https://feodor-ra.github.io/fastapi-typed-errors/"
40+
Documentation = "https://feodor-ra.github.io/fastapi-typed-errors/"
41+
Repository = "https://github.com/feodor-ra/fastapi-typed-errors"
42+
Issues = "https://github.com/feodor-ra/fastapi-typed-errors/issues"
43+
2644
[build-system]
2745
requires = ["uv_build>=0.11.30,<0.12.0"]
2846
build-backend = "uv_build"
@@ -54,6 +72,11 @@ convention = "google"
5472
"magic-value-comparison", # PLR2004: comparing to literal counts/exit codes is normal in tests
5573
"import-private-name", # PLC2701: tests exercise the package's private internals
5674
]
75+
"scripts/**" = [
76+
"assert", # S101: the packaging smoke test asserts
77+
"implicit-namespace-package", # INP001: scripts is not a package
78+
"raw-string-in-exception", # EM101: literal details mirror the package's own pattern
79+
]
5780

5881
[tool.ty.rules]
5982
all = "error"
@@ -73,6 +96,8 @@ fail_under = 100
7396
[dependency-groups]
7497
dev = [
7598
"anyio~=4.14.2",
99+
# Both TestClient transports: old starlette (fastapi floor) imports httpx, new starlette imports httpx2.
100+
"httpx~=0.28.0",
76101
"httpx2~=2.7.0",
77102
"pytest~=9.1.1",
78103
"pytest-cov~=7.1.0",

scripts/smoke_test.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Packaging smoke test — run against the built wheel/sdist before publishing.
2+
3+
Exercises the public API end to end (metaclass derivation, the router patch,
4+
OpenAPI generation and the checker) from an installed distribution, so a broken
5+
build or a missing ``py.typed``/module is caught before it reaches PyPI.
6+
"""
7+
8+
from enum import StrEnum
9+
from http import HTTPStatus
10+
from typing import Annotated, Literal
11+
12+
from fastapi import APIRouter, FastAPI
13+
from pydantic import BaseModel
14+
15+
from fastapi_typed_errors import (
16+
BaseError,
17+
Raises,
18+
check_raises,
19+
error_models,
20+
handle_base_error,
21+
with_errors,
22+
)
23+
24+
25+
class Code(StrEnum):
26+
"""Smoke error codes."""
27+
28+
NOT_FOUND = "NOT_FOUND"
29+
30+
31+
class NotFoundError(BaseError[Literal[Code.NOT_FOUND]]):
32+
"""Smoke error."""
33+
34+
http_status = HTTPStatus.NOT_FOUND
35+
36+
37+
class Item(BaseModel):
38+
"""Smoke payload."""
39+
40+
item_id: int
41+
42+
43+
def main() -> None:
44+
"""Register a typed error end to end and assert the OpenAPI is typed."""
45+
app = FastAPI()
46+
app.add_exception_handler(BaseError, handle_base_error)
47+
router = with_errors(APIRouter())
48+
49+
@router.get("/items/{item_id}")
50+
def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError]]:
51+
"""Raise the typed error (a registration and analysis target).
52+
53+
Args:
54+
item_id: Ignored.
55+
56+
Raises:
57+
NotFoundError: Always.
58+
"""
59+
raise NotFoundError(str(item_id))
60+
61+
app.include_router(router)
62+
63+
responses = app.openapi()["paths"]["/items/{item_id}"]["get"]["responses"]
64+
assert "404" in responses
65+
assert responses["200"]["content"]["application/json"]["schema"]["$ref"].endswith("Item")
66+
assert error_models(NotFoundError) is NotFoundError.model
67+
assert check_raises(app).ok
68+
69+
70+
if __name__ == "__main__":
71+
main()

tests/analysis/test_checker.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111

1212
from fastapi_typed_errors import Raises, check_raises, with_errors
1313

14+
# The lazy include model (with per-route dedup) is FastAPI ≥ 0.139; older versions copy routes eagerly.
15+
requires_modern_fastapi = pytest.mark.skipif(
16+
not hasattr(fastapi.routing, "iter_route_contexts"),
17+
reason="requires FastAPI >= 0.139 (lazy include_router)",
18+
)
19+
1420
oauth = OAuth2PasswordBearer(tokenUrl="token")
1521

1622

@@ -227,6 +233,7 @@ def test_websocket_route_ignored() -> None:
227233
assert report.checked == 1
228234

229235

236+
@requires_modern_fastapi
230237
def test_double_include_deduplicated() -> None:
231238
"""A router included under two prefixes is checked once."""
232239
child = APIRouter()
@@ -258,7 +265,7 @@ def test_wrapped_router_still_checked() -> None:
258265

259266
def test_fallback_without_iter_route_contexts(monkeypatch: pytest.MonkeyPatch) -> None:
260267
"""Without ``iter_route_contexts`` a flat ``routes`` walk is used."""
261-
monkeypatch.delattr(fastapi.routing, "iter_route_contexts")
268+
monkeypatch.delattr(fastapi.routing, "iter_route_contexts", raising=False)
262269
router = APIRouter()
263270
router.add_api_route("/u", ep_undeclared, methods=["GET"])
264271

tests/decorator/test_wrapper.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Annotated, Any, Literal, cast
88

99
import fastapi.dependencies.utils
10+
import fastapi.routing
1011
import pytest
1112
from fastapi import APIRouter, Depends, FastAPI
1213
from fastapi.responses import Response, StreamingResponse
@@ -16,6 +17,13 @@
1617

1718
from fastapi_typed_errors import BaseError, Raises, with_errors
1819

20+
# FastAPI ≥ 0.139 ships iter_route_contexts and the newer pydantic that resolves PEP 695 aliases /
21+
# tolerates unresolvable parameter hints. Older stacks handle those edges differently upstream.
22+
requires_modern_fastapi = pytest.mark.skipif(
23+
not hasattr(fastapi.routing, "iter_route_contexts"),
24+
reason="requires FastAPI >= 0.139 (lazy routing + modern pydantic)",
25+
)
26+
1927

2028
class Code(StrEnum):
2129
"""Error codes used by the test error classes."""
@@ -243,6 +251,7 @@ def endpoint() -> Annotated[None, Raises[ForbiddenError]]:
243251
assert "content" not in responses["204"]
244252

245253

254+
@requires_modern_fastapi
246255
def test_alias_return_annotation(router: APIRouter) -> None:
247256
"""A PEP 695 ``type`` alias is unwrapped before marker extraction."""
248257

@@ -253,6 +262,7 @@ def endpoint() -> AliasedItem:
253262
assert "404" in _responses(router, "/alias")
254263

255264

265+
@requires_modern_fastapi
256266
def test_alias_nested_inside_annotated(router: APIRouter) -> None:
257267
"""Markers inside an aliased ``Annotated`` base are merged with outer ones."""
258268

@@ -301,6 +311,7 @@ def endpoint() -> "MissingModel": # ruff:ignore[undefined-name] # ty: ignore[u
301311
assert responses["200"]["content"]["application/json"]["schema"]["$ref"].endswith("Item")
302312

303313

314+
@requires_modern_fastapi
304315
def test_unresolvable_parameter_hints_pass_through(router: APIRouter) -> None:
305316
"""Broken parameter hints with no return annotation register exactly like on stock FastAPI."""
306317

@@ -548,7 +559,7 @@ def test_auto_falls_back_without_dependency_helper(
548559
) -> None:
549560
"""If FastAPI's dependency helpers move, ``auto`` degrades to the endpoint only."""
550561
# Removing this name fails only our lazy import; FastAPI's routing holds its own binding.
551-
monkeypatch.delattr(fastapi.dependencies.utils, "get_parameterless_sub_dependant")
562+
monkeypatch.delattr(fastapi.dependencies.utils, "get_parameterless_sub_dependant", raising=False)
552563

553564
@auto_router.get("/fallback")
554565
def endpoint(_: Annotated[None, Depends(raise_forbidden_dep)]) -> Item:

uv.lock

Lines changed: 33 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)