diff --git a/.github/workflows/auto-tag-release.yml b/.github/workflows/auto-tag-release.yml
new file mode 100644
index 0000000..45352f2
--- /dev/null
+++ b/.github/workflows/auto-tag-release.yml
@@ -0,0 +1,73 @@
+name: Auto Tag Release
+
+on:
+ pull_request:
+ types: [closed]
+ branches: [master]
+
+permissions:
+ contents: write
+
+jobs:
+ tag:
+ if: >
+ github.event.pull_request.merged == true &&
+ (
+ contains(github.event.pull_request.labels.*.name, 'release:patch') ||
+ contains(github.event.pull_request.labels.*.name, 'release:minor') ||
+ contains(github.event.pull_request.labels.*.name, 'release:major')
+ )
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Determine next version tag
+ id: bump
+ env:
+ LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }}
+ run: |
+ LABELS="${LABELS_JSON}"
+ BUMP="patch"
+ if [[ "$LABELS" == *"release:major"* ]]; then
+ BUMP="major"
+ elif [[ "$LABELS" == *"release:minor"* ]]; then
+ BUMP="minor"
+ fi
+
+ LAST_TAG="$(git tag -l 'v*' --sort=-v:refname | sed -n '1p')"
+ if [ -z "$LAST_TAG" ]; then
+ LAST_TAG="v0.0.0"
+ fi
+
+ VERSION="${LAST_TAG#v}"
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
+
+ if [ "$BUMP" = "major" ]; then
+ MAJOR=$((MAJOR + 1))
+ MINOR=0
+ PATCH=0
+ elif [ "$BUMP" = "minor" ]; then
+ MINOR=$((MINOR + 1))
+ PATCH=0
+ else
+ PATCH=$((PATCH + 1))
+ fi
+
+ NEXT_TAG="v${MAJOR}.${MINOR}.${PATCH}"
+ echo "last_tag=$LAST_TAG" >> "$GITHUB_OUTPUT"
+ echo "next_tag=$NEXT_TAG" >> "$GITHUB_OUTPUT"
+
+ - name: Create and push tag
+ env:
+ NEXT_TAG: ${{ steps.bump.outputs.next_tag }}
+ MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
+ run: |
+ if git rev-parse "$NEXT_TAG" >/dev/null 2>&1; then
+ echo "Tag already exists: $NEXT_TAG"
+ exit 0
+ fi
+
+ git tag "$NEXT_TAG" "$MERGE_SHA"
+ git push origin "$NEXT_TAG"
diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
index a92a50b..9c13c94 100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@ -1,6 +1,8 @@
name: Benchmark
on:
+ push:
+ branches: [stage]
pull_request:
branches: [master, stage]
@@ -14,187 +16,129 @@ jobs:
steps:
- uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.22"
+
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
- - name: Install dependencies
+ - name: Install Python dependencies
run: |
pip install --upgrade pip
pip install -e ".[dev,benchmark]"
- - name: Run ASGI benchmark
- id: bench
- run: |
- python -c "
- import asyncio, time, sys, json as _json
- sys.path.insert(0, '.')
- N = 50_000
-
- import msgspec
- from FasterAPI.app import Faster
-
- class UserF(msgspec.Struct):
- name: str
- email: str
-
- fapp = Faster(openapi_url=None, docs_url=None, redoc_url=None)
-
- @fapp.get('/health')
- async def fh(): return {'status': 'ok'}
-
- @fapp.get('/users/{user_id}')
- async def fg(user_id: str): return {'id': user_id, 'name': 'test'}
-
- @fapp.post('/users')
- async def fp(user: UserF): return {'name': user.name, 'email': user.email}
-
- from fastapi import FastAPI
- from pydantic import BaseModel
-
- class UserP(BaseModel):
- name: str
- email: str
-
- faapp = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
-
- @faapp.get('/health')
- async def fah(): return {'status': 'ok'}
-
- @faapp.get('/users/{user_id}')
- async def fag(user_id: str): return {'id': user_id, 'name': 'test'}
+ - name: Build Go Fiber benchmark server
+ working-directory: benchmarks/fiber
+ run: go mod tidy && go build -o fiberbench .
- @faapp.post('/users')
- async def fap(user: UserP): return {'name': user.name, 'email': user.email}
+ - name: Enforce benchmark floors (ASGI + routing)
+ run: python benchmarks/check_regressions.py
- async def make_scope(method, path, body=None):
- scope = {'type':'http','method':method,'path':path,'query_string':b'',
- 'headers':[(b'content-type',b'application/json'),(b'host',b'localhost')],
- 'client':('127.0.0.1',9999)}
- body_bytes = _json.dumps(body).encode() if body else b''
- sent = []
- async def receive(): return {'type':'http.request','body':body_bytes,'more_body':False}
- async def send(msg): sent.append(msg)
- return scope, receive, send
+ - name: Export HTTP / ASGI / routing JSON for PR comment
+ run: python benchmarks/export_pr_benchmarks.py
- async def bench(app, method, path, body=None):
- for _ in range(500): s,r,sn = await make_scope(method,path,body); await app(s,r,sn)
- start = time.perf_counter()
- for _ in range(N): s,r,sn = await make_scope(method,path,body); await app(s,r,sn)
- return round(N/(time.perf_counter()-start))
-
- async def main():
- body = {'name':'Alice','email':'alice@test.com'}
- results = {}
- for label,m,p,b in [('health','GET','/health',None),('users_get','GET','/users/42',None),('users_post','POST','/users',body)]:
- f_rps = await bench(fapp,m,p,b)
- fa_rps = await bench(faapp,m,p,b)
- results[label] = {'fasterapi': f_rps, 'fastapi': fa_rps, 'speedup': round(f_rps/fa_rps, 2)}
- print(_json.dumps(results))
-
- asyncio.run(main())
- " > bench_results.json
-
- - name: Run routing benchmark
- id: routing
+ - name: Fail if Fiber HTTP server did not start
run: |
- python -c "
- import time, re, sys, json
- sys.path.insert(0, '.')
- from FasterAPI.router import RadixRouter
-
- router = RadixRouter()
- for i in range(50): router.add_route('GET', f'/static/route{i}', lambda: None, {})
- for i in range(30): router.add_route('GET', f'/users/{{id}}/action{i}', lambda: None, {})
- for i in range(20): router.add_route('GET', f'/org/{{org_id}}/team/{{team_id}}/member{i}', lambda: None, {})
-
- paths = ['/static/route25','/users/42/action15','/org/abc/team/xyz/member10']
- N = 500_000
- start = time.perf_counter()
- for _ in range(N):
- for p in paths: router.resolve('GET', p)
- radix_ops = round((N*3)/(time.perf_counter()-start))
-
- regex_routes = []
- for i in range(50): regex_routes.append((re.compile(f'^/static/route{i}\$'), None))
- for i in range(30): regex_routes.append((re.compile(r'^/users/(\w+)/action' + str(i) + r'\$'), None))
- for i in range(20): regex_routes.append((re.compile(r'^/org/(\w+)/team/(\w+)/member' + str(i) + r'\$'), None))
-
- def regex_resolve(path):
- for p, h in regex_routes:
- m = p.match(path)
- if m: return h, m.groups()
- return None, ()
-
- start = time.perf_counter()
- for _ in range(N):
- for p in paths: regex_resolve(p)
- regex_ops = round((N*3)/(time.perf_counter()-start))
-
- print(json.dumps({'radix': radix_ops, 'regex': regex_ops, 'speedup': round(radix_ops/regex_ops, 1)}))
- " > routing_results.json
+ if [[ -f fiber_warn.txt ]]; then
+ echo "::error::Fiber benchmark server failed to start"
+ cat fiber_warn.txt
+ exit 1
+ fi
- name: Comment benchmark results on PR
+ if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const bench = JSON.parse(fs.readFileSync('bench_results.json', 'utf8'));
+ let asgi = {};
+ try {
+ asgi = JSON.parse(fs.readFileSync('asgi_micro.json', 'utf8'));
+ } catch (e) { /* optional file */ }
const routing = JSON.parse(fs.readFileSync('routing_results.json', 'utf8'));
- // Baseline numbers from README (Python 3.13.7, Apple Silicon)
const baseline = {
- health: { fasterapi: 335612, fastapi: 49005, speedup: 6.85 },
- users_get: { fasterapi: 282835, fastapi: 32391, speedup: 8.73 },
- users_post: { fasterapi: 193225, fastapi: 27031, speedup: 7.15 },
- routing: { radix: 1104318, speedup: 7.6 },
+ asgi_speedup: { health: 6.85, users_get: 8.73, users_post: 7.15 },
+ routing_speedup: 7.6,
};
function delta(current, base) {
+ if (base === undefined || base === 0) return 'β';
const pct = ((current - base) / base * 100).toFixed(1);
- if (pct > 2) return `π’ +${pct}%`;
- if (pct < -5) return `π΄ ${pct}%`;
- return `βͺ ${pct}%`;
+ if (pct > 2) return `+${pct}%`;
+ if (pct < -5) return `${pct}%`;
+ return `${pct}%`;
+ }
+
+ function reqCell(n) {
+ if (n === undefined || n === null) return 'β';
+ return `${Math.round(n).toLocaleString()}/s`;
+ }
+
+ function fiberCell(n) {
+ if (n === undefined || n === null) return 'β';
+ return `**${Math.round(n).toLocaleString()}/s**`;
}
- const body = `## Benchmark Results
+ const rows = ['health', 'users_get', 'users_post'];
+ const labels = { health: 'GET /health', users_get: 'GET /users/{id}', users_post: 'POST /users' };
- > Automated benchmark on \`ubuntu-latest\`, Python 3.13. Baseline is from README (Apple Silicon).
- > CI runners are slower than local machines β **compare the speedup ratio, not raw req/s.**
+ let httpTable = '| Endpoint | FasterAPI | FastAPI | Fiber (Go) | F / Fast |\n|---|---|---|---|---|\n';
+ for (const k of rows) {
+ const b = bench[k];
+ const sp = b.speedup !== undefined ? `${b.speedup.toFixed(2)}x` : 'β';
+ httpTable += `| \`${labels[k]}\` | **${reqCell(b.fasterapi)}** | ${reqCell(b.fastapi)} | ${fiberCell(b.fiber)} | **${sp}** |\n`;
+ }
- ### Framework-Level (Direct ASGI, ${(50000).toLocaleString()} requests)
+ let asgiBlock = '';
+ if (asgi.health) {
+ asgiBlock = `
+ #### Direct ASGI (no HTTP; ${(50000).toLocaleString()} iterations)
- | Endpoint | FasterAPI | FastAPI | Speedup | vs Baseline Speedup |
+ | Endpoint | FasterAPI | FastAPI | Speedup | vs README ASGI ratio |
|---|---|---|---|---|
- | \`GET /health\` | **${bench.health.fasterapi.toLocaleString()}/s** | ${bench.health.fastapi.toLocaleString()}/s | **${bench.health.speedup}x** | ${delta(bench.health.speedup, baseline.health.speedup)} |
- | \`GET /users/{id}\` | **${bench.users_get.fasterapi.toLocaleString()}/s** | ${bench.users_get.fastapi.toLocaleString()}/s | **${bench.users_get.speedup}x** | ${delta(bench.users_get.speedup, baseline.users_get.speedup)} |
- | \`POST /users\` | **${bench.users_post.fasterapi.toLocaleString()}/s** | ${bench.users_post.fastapi.toLocaleString()}/s | **${bench.users_post.speedup}x** | ${delta(bench.users_post.speedup, baseline.users_post.speedup)} |
+ | \`GET /health\` | **${Math.round(asgi.health.fasterapi).toLocaleString()}/s** | ${Math.round(asgi.health.fastapi).toLocaleString()}/s | **${asgi.health.speedup.toFixed(2)}x** | ${delta(asgi.health.speedup, baseline.asgi_speedup.health)} |
+ | \`GET /users/{id}\` | **${Math.round(asgi.users_get.fasterapi).toLocaleString()}/s** | ${Math.round(asgi.users_get.fastapi).toLocaleString()}/s | **${asgi.users_get.speedup.toFixed(2)}x** | ${delta(asgi.users_get.speedup, baseline.asgi_speedup.users_get)} |
+ | \`POST /users\` | **${Math.round(asgi.users_post.fasterapi).toLocaleString()}/s** | ${Math.round(asgi.users_post.fastapi).toLocaleString()}/s | **${asgi.users_post.speedup.toFixed(2)}x** | ${delta(asgi.users_post.speedup, baseline.asgi_speedup.users_post)} |
+ `;
+ }
+
+ const body = `## Benchmark results
+
+ > Ubuntu runner, Python 3.13. **HTTP table** uses the same httpx load against uvicorn (Python) and Fiber (Go). **Direct ASGI** (below) is Python-only and excludes network I/O.
+
+ ### HTTP throughput (FasterAPI vs FastAPI vs Fiber)
+
+ ${httpTable}
+
+ ${asgiBlock}
- ### Routing (100 routes, 1.5M lookups)
+ ### Routing (radix vs regex, ${(500000 * 3).toLocaleString()} lookups)
- | Router | Ops/s | Speedup | vs Baseline |
+ | Router | Ops/s | Speedup | vs README |
|---|---|---|---|
- | **Radix tree** | **${routing.radix.toLocaleString()}** | **${routing.speedup}x** | ${delta(routing.speedup, baseline.routing.speedup)} |
- | Regex | ${routing.regex.toLocaleString()} | 1.0x | |
+ | **Radix** | **${Math.round(routing.radix).toLocaleString()}** | **${routing.speedup.toFixed(1)}x** | ${delta(routing.speedup, baseline.routing_speedup)} |
+ | Regex | ${Math.round(routing.regex).toLocaleString()} | 1.0x | |
How to read this
- - **Speedup** = FasterAPI req/s Γ· FastAPI req/s (higher is better)
- - **vs Baseline** compares the speedup ratio against the README baseline
- - π’ = improved, βͺ = within noise, π΄ = regression (>5% drop in speedup)
- - Raw req/s will differ from README (CI runner vs Apple Silicon) β the ratio is what matters
+ - **F / Fast** = FasterAPI req/s Γ· FastAPI req/s on the same HTTP harness (higher is better).
+ - **Fiber** uses the Go app in \`benchmarks/fiber\` (same routes). Go is often several times faster than Python here; the important guard for regressions is **\`check_regressions.py\`** (ASGI + routing floors), which must pass in this workflow.
+ - **vs README** compares combined speedups to documented reference numbers (local machine); CI absolute req/s differs by hardware.
`;
- // Find existing benchmark comment to update instead of creating duplicates
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
- const existing = comments.find(c => c.body.includes('## Benchmark Results'));
+ const existing = comments.find(c => c.body.includes('## Benchmark results'));
if (existing) {
await github.rest.issues.updateComment({
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d2f4f93..981276d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,9 +30,19 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[dev]"
+ - name: Ruff (lint + format check)
+ if: matrix.python-version == '3.13'
+ run: |
+ ruff format --check FasterAPI tests benchmarks
+ ruff check FasterAPI tests benchmarks
+
+ - name: Mypy (strict on library)
+ if: matrix.python-version == '3.13'
+ run: mypy FasterAPI tests
+
- name: Run tests with coverage
run: |
- pytest --cov=FasterAPI --cov-report=xml --cov-report=term-missing
+ pytest --cov=FasterAPI --cov-report=xml --cov-report=term-missing --cov-fail-under=85
- name: Upload coverage to Codecov
if: matrix.python-version == '3.13'
@@ -40,6 +50,6 @@ jobs:
with:
files: coverage.xml
flags: python${{ matrix.python-version }}
- fail_ci_if_error: false
+ fail_ci_if_error: true
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..2a009d2
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,49 @@
+# Deploy static site to GitHub Pages (repo Settings β Pages β Source: GitHub Actions).
+name: Docs
+
+on:
+ push:
+ branches: [master]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: github-pages
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install dependencies
+ run: |
+ pip install --upgrade pip
+ pip install -e ".[docs]"
+
+ - name: Build MkDocs (strict)
+ run: mkdocs build --strict
+
+ - name: Disable Jekyll (avoid 404 on paths with _)
+ run: touch site/.nojekyll
+
+ - uses: actions/configure-pages@v4
+
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: site
+
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5ff00cb..fc0ae14 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,6 +2,8 @@ name: Release
on:
push:
+ branches:
+ - master
tags:
- "v*"
@@ -11,8 +13,48 @@ permissions:
packages: write
jobs:
+ # ββ Publish dev builds to TestPyPI on every master merge βββββββββββββ
+ # (Real PyPI publishes remain tag-based: push tag vX.Y.Z on master.)
+ publish-testpypi:
+ if: startsWith(github.ref, 'refs/heads/master')
+ runs-on: ubuntu-latest
+ environment: testpypi
+ permissions:
+ contents: read
+ steps:
+ - name: Verify TestPyPI token is configured
+ run: |
+ if [ -z "${{ secrets.TEST_PYPI_API_TOKEN }}" ]; then
+ echo "::error::Missing secret TEST_PYPI_API_TOKEN (Settings β Secrets and variables β Actions)."
+ echo "Create an API token on TestPyPI for the `faster-api-web` project and add it as TEST_PYPI_API_TOKEN."
+ exit 1
+ fi
+
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install build tools
+ run: pip install build
+
+ - name: Build wheel and sdist
+ run: python -m build
+
+ - name: Publish to TestPyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ repository-url: https://test.pypi.org/legacy/
+ user: __token__
+ password: ${{ secrets.TEST_PYPI_API_TOKEN }}
+ skip-existing: true
+
# ββ Gate: only release from master ββββββββββββββββββββββββββββββββββ
check-branch:
+ if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -30,6 +72,7 @@ jobs:
# ββ Step 1: Run tests ββββββββββββββββββββββββββββββββββββββββββββββ
test:
needs: check-branch
+ if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -37,18 +80,23 @@ jobs:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- - run: pytest --cov=FasterAPI --cov-report=term-missing
+ - run: pytest --cov=FasterAPI --cov-report=term-missing --cov-fail-under=85
# ββ Step 2: Build wheel + sdist ββββββββββββββββββββββββββββββββββββ
build:
needs: test
+ if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
- uses: actions/setup-python@v5
with:
@@ -69,6 +117,7 @@ jobs:
# ββ Step 3: Publish to PyPI via trusted publishing (OIDC) ββββββββββ
publish-pypi:
needs: build
+ if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment: pypi
permissions:
@@ -82,10 +131,13 @@ jobs:
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ skip-existing: true
# ββ Step 4: Publish Docker image to GitHub Packages (ghcr.io) ββββββ
publish-docker:
needs: test
+ if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
@@ -108,18 +160,23 @@ jobs:
id: owner
run: echo "owner_lc=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
+ - name: Normalize repo name to lowercase
+ id: repo
+ run: echo "repo_lc=${GITHUB_REPOSITORY#*/}" | tr '[:upper:]' '[:lower:]' | awk '{print "repo_lc="$0}' >> "$GITHUB_OUTPUT"
+
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
- ghcr.io/${{ steps.owner.outputs.owner_lc }}/fasterapi:${{ steps.version.outputs.tag }}
- ghcr.io/${{ steps.owner.outputs.owner_lc }}/fasterapi:latest
+ ghcr.io/${{ steps.owner.outputs.owner_lc }}/${{ steps.repo.outputs.repo_lc }}:${{ steps.version.outputs.tag }}
+ ghcr.io/${{ steps.owner.outputs.owner_lc }}/${{ steps.repo.outputs.repo_lc }}:latest
# ββ Step 5: Create GitHub Release with artifacts βββββββββββββββββββ
github-release:
needs: [build, publish-pypi, publish-docker]
+ if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
diff --git a/.gitignore b/.gitignore
index 3eb7fdb..6e5965a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ env/
.mypy_cache/
.ruff_cache/
htmlcov/
+site/
.coverage
.coverage.*
coverage.xml
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..24ee5b1
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.13
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..806bb6c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,13 @@
+# Changelog
+
+PyPI package **`faster-api-web`** versions match **git tags** on `master` (see the Release workflow). Runtime `FasterAPI.__version__` comes from installed package metadata.
+
+## 0.1.2 (2026-04-08)
+
+- Documentation site (MkDocs), migration guide, benchmark methodology, and expanded CI gates (coverage β₯ 85%, benchmark floors, Codecov strict upload).
+- PR benchmarks now include **HTTP comparison** of FasterAPI, FastAPI, and **Go Fiber** (`benchmarks/fiber`).
+- **`TestClient`** is loaded lazily so a minimal `pip install faster-api-web` does not require **httpx** until you import `TestClient` (then install httpx).
+
+## 0.1.1
+
+- Earlier alpha releases and performance baselines.
diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 0000000..8107129
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,21 @@
+cff-version: 1.2.0
+title: "faster-api-web (FasterAPI)"
+message: "If you use this software, please cite it using these metadata."
+type: software
+authors:
+ - family-names: Thedla
+ given-names: Eshwar Chandra Vidhyasagar
+ email: "thedla.ecvs@gmail.com"
+ affiliation: "GitHub: EshwarCVS"
+repository-code: "https://github.com/FasterApiWeb/FasterAPI"
+url: "https://pypi.org/project/faster-api-web/"
+abstract: >-
+ High-performance ASGI web framework for Python. PyPI package name: faster-api-web.
+ Documentation assumes Python 3.13; older releases (3.10β3.12) are supported with documented fallbacks.
+license: MIT
+keywords:
+ - "asgi"
+ - "web"
+ - "api"
+ - "fastapi"
+ - "msgspec"
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
deleted file mode 100644
index e6df924..0000000
--- a/CODE_OF_CONDUCT.md
+++ /dev/null
@@ -1,128 +0,0 @@
-# Contributor Covenant Code of Conduct
-
-## Our Pledge
-
-We as members, contributors, and leaders pledge to make participation in our
-community a harassment-free experience for everyone, regardless of age, body
-size, visible or invisible disability, ethnicity, sex characteristics, gender
-identity and expression, level of experience, education, socio-economic status,
-nationality, personal appearance, race, religion, or sexual identity
-and orientation.
-
-We pledge to act and interact in ways that contribute to an open, welcoming,
-diverse, inclusive, and healthy community.
-
-## Our Standards
-
-Examples of behavior that contributes to a positive environment for our
-community include:
-
-* Demonstrating empathy and kindness toward other people
-* Being respectful of differing opinions, viewpoints, and experiences
-* Giving and gracefully accepting constructive feedback
-* Accepting responsibility and apologizing to those affected by our mistakes,
- and learning from the experience
-* Focusing on what is best not just for us as individuals, but for the
- overall community
-
-Examples of unacceptable behavior include:
-
-* The use of sexualized language or imagery, and sexual attention or
- advances of any kind
-* Trolling, insulting or derogatory comments, and personal or political attacks
-* Public or private harassment
-* Publishing others' private information, such as a physical or email
- address, without their explicit permission
-* Other conduct which could reasonably be considered inappropriate in a
- professional setting
-
-## Enforcement Responsibilities
-
-Community leaders are responsible for clarifying and enforcing our standards of
-acceptable behavior and will take appropriate and fair corrective action in
-response to any behavior that they deem inappropriate, threatening, offensive,
-or harmful.
-
-Community leaders have the right and responsibility to remove, edit, or reject
-comments, commits, code, wiki edits, issues, and other contributions that are
-not aligned to this Code of Conduct, and will communicate reasons for moderation
-decisions when appropriate.
-
-## Scope
-
-This Code of Conduct applies within all community spaces, and also applies when
-an individual is officially representing the community in public spaces.
-Examples of representing our community include using an official e-mail address,
-posting via an official social media account, or acting as an appointed
-representative at an online or offline event.
-
-## Enforcement
-
-Instances of abusive, harassing, or otherwise unacceptable behavior may be
-reported to the community leaders responsible for enforcement at
-thedla.ecvs@gmail.com.
-All complaints will be reviewed and investigated promptly and fairly.
-
-All community leaders are obligated to respect the privacy and security of the
-reporter of any incident.
-
-## Enforcement Guidelines
-
-Community leaders will follow these Community Impact Guidelines in determining
-the consequences for any action they deem in violation of this Code of Conduct:
-
-### 1. Correction
-
-**Community Impact**: Use of inappropriate language or other behavior deemed
-unprofessional or unwelcome in the community.
-
-**Consequence**: A private, written warning from community leaders, providing
-clarity around the nature of the violation and an explanation of why the
-behavior was inappropriate. A public apology may be requested.
-
-### 2. Warning
-
-**Community Impact**: A violation through a single incident or series
-of actions.
-
-**Consequence**: A warning with consequences for continued behavior. No
-interaction with the people involved, including unsolicited interaction with
-those enforcing the Code of Conduct, for a specified period of time. This
-includes avoiding interactions in community spaces as well as external channels
-like social media. Violating these terms may lead to a temporary or
-permanent ban.
-
-### 3. Temporary Ban
-
-**Community Impact**: A serious violation of community standards, including
-sustained inappropriate behavior.
-
-**Consequence**: A temporary ban from any sort of interaction or public
-communication with the community for a specified period of time. No public or
-private interaction with the people involved, including unsolicited interaction
-with those enforcing the Code of Conduct, is allowed during this period.
-Violating these terms may lead to a permanent ban.
-
-### 4. Permanent Ban
-
-**Community Impact**: Demonstrating a pattern of violation of community
-standards, including sustained inappropriate behavior, harassment of an
-individual, or aggression toward or disparagement of classes of individuals.
-
-**Consequence**: A permanent ban from any sort of public interaction within
-the community.
-
-## Attribution
-
-This Code of Conduct is adapted from the [Contributor Covenant][homepage],
-version 2.1, available at
-https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
-
-Community Impact Guidelines were inspired by [Mozilla's code of conduct
-enforcement ladder](https://github.com/mozilla/diversity).
-
-For answers to common questions about this code of conduct, see the FAQ at
-https://www.contributor-covenant.org/faq. Translations are available at
-https://www.contributor-covenant.org/translations.
-
-[homepage]: https://www.contributor-covenant.org
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ce77d50..15601bd 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,20 +17,24 @@ Thank you for your interest in contributing! This document explains how we work.
| `stage` | Integration / pre-release | **Maintainer only** (`@EshwarCVS` / `@FasterApiWeb`) |
| `dev/*` | Your feature or bugfix branch | You |
+For **security-sensitive** reports, use the process in [SECURITY.md](SECURITY.md) instead of a public issue.
+
### Rules
1. **Never push directly to `master` or `stage`.**
-2. Create your branch from `stage`:
+2. Create your branch from **`stage`** (never from an outdated `master` without syncing):
```bash
git checkout stage
git pull origin stage
git checkout -b dev/my-feature
```
-3. Open a PR from your branch β `stage`.
-4. CI (tests on Python 3.10β3.13 + benchmarks) must pass.
-5. At least 1 approval is required before merging to `stage`.
-6. Periodically, the maintainer opens a PR from `stage` β `master` for releases.
-7. Releases are tagged on `master` (`v0.2.0`, etc.), which triggers PyPI + Docker publishing.
+3. Commit with **clear messages** (what changed and why in one line; optional scope prefix, e.g. `docs:`, `bench:`).
+4. Open a **pull request from your branch β `stage`** (that is the default integration flow for new code).
+5. CI (tests on Python 3.10β3.13 + benchmarks on PRs) must pass.
+6. At least **one approval** is required before merging to `stage`, when reviewers are available.
+7. Periodically, a maintainer opens a PR from **`stage` β `master`** to cut a release.
+8. **Releases** are **git tags** on `master` (`v0.2.0`, β¦), which trigger PyPI + Docker + GitHub Releases. The **PyPI version is taken from the tag** (see `hatch-vcs` in `pyproject.toml`) β **do not** rely on editing a static `version =` in `pyproject.toml` for releases.
+9. If you want a merge to `master` to cut a release automatically, add one label on the `stage` β `master` PR: `release:patch`, `release:minor`, or `release:major`. The auto-tag workflow will create the next `vX.Y.Z` tag from that label.
---
@@ -45,7 +49,15 @@ source .venv/bin/activate
pip install -e ".[dev]"
# Run tests
-pytest --cov=FasterAPI
+pytest --cov=FasterAPI --cov-fail-under=85
+
+# Lint and types (matches CI on Python 3.13)
+ruff format --check FasterAPI tests benchmarks
+ruff check FasterAPI tests benchmarks
+mypy FasterAPI tests
+
+# Multi-version tests (requires Python 3.11β3.13 on PATH)
+tox
# Run benchmarks locally
pip install -e ".[benchmark]"
@@ -58,7 +70,7 @@ python benchmarks/compare.py --direct
Before opening a PR, verify:
-- [ ] All tests pass: `pytest`
+- [ ] All tests pass: `pytest --cov=FasterAPI --cov-report=term-missing --cov-fail-under=85`
- [ ] No regressions in benchmark speedup ratios
- [ ] New features include tests
- [ ] Code follows existing patterns (`__slots__`, type hints, no unnecessary comments)
@@ -93,7 +105,19 @@ A PR with a π΄ benchmark regression will need justification before merging.
```
3. The release workflow automatically:
- Runs full test suite
- - Builds wheel + sdist
+ - Builds wheel + sdist (**version = git tag**, via **hatch-vcs**)
- Publishes to PyPI (`faster-api-web`)
- Pushes Docker image to `ghcr.io`
- Creates a GitHub Release with artifacts
+
+---
+
+## Listing on Awesome Python
+
+The [awesome-python](https://github.com/vinta/awesome-python) list has **strict** entry rules (activity, documentation, uniqueness). When the project meets [their CONTRIBUTING criteria](https://github.com/vinta/awesome-python/blob/master/CONTRIBUTING.md), a maintainer can propose a PR under **Web Frameworks** using the **PyPI name** as the title:
+
+```markdown
+- [faster-api-web](https://github.com/FasterApiWeb/FasterAPI) - High-performance ASGI web framework; FastAPI-like API with msgspec and radix routing.
+```
+
+One project per PR; follow their alphabetical order and description style (ends with a period). If a submission is premature, wait until the repo satisfies **Stable** / **Established** / stars thresholds in their guide.
diff --git a/FasterAPI/__init__.py b/FasterAPI/__init__.py
index d1163bf..da3cbc4 100644
--- a/FasterAPI/__init__.py
+++ b/FasterAPI/__init__.py
@@ -1,10 +1,16 @@
"""FasterAPI β A high-performance ASGI web framework.
-Drop-in FastAPI replacement powered by msgspec (Rust-backed JSON),
+Drop-in FastAPI replacement powered by msgspec (C extension JSON),
radix-tree routing, uvloop, and Python 3.13 sub-interpreters.
"""
-__version__ = "0.1.1"
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from ._version import get_version
+
+__version__ = get_version()
from .app import Faster
from .background import BackgroundTask, BackgroundTasks
@@ -31,10 +37,13 @@
StreamingResponse,
)
from .router import FasterRouter, RadixRouter
-from .testclient import TestClient
from .websocket import WebSocket, WebSocketDisconnect, WebSocketState
+if TYPE_CHECKING:
+ from .testclient import TestClient as TestClient
+
__all__ = [
+ "__version__",
# Core
"Faster",
"FasterRouter",
@@ -83,3 +92,17 @@
# Testing
"TestClient",
]
+
+
+def __getattr__(name: str) -> Any:
+ if name == "TestClient":
+ try:
+ from .testclient import TestClient as _TestClient
+ except ModuleNotFoundError as e:
+ if getattr(e, "name", None) == "httpx":
+ raise ImportError(
+ "TestClient requires httpx. Install with: pip install httpx",
+ ) from e
+ raise
+ return _TestClient
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/FasterAPI/_version.py b/FasterAPI/_version.py
new file mode 100644
index 0000000..253632c
--- /dev/null
+++ b/FasterAPI/_version.py
@@ -0,0 +1,13 @@
+"""Single place to resolve the installed distribution version (matches PyPI / git tags)."""
+
+from __future__ import annotations
+
+
+def get_version() -> str:
+ """Return ``faster-api-web`` version from package metadata (set at build from git tags)."""
+ try:
+ from importlib.metadata import PackageNotFoundError, version
+
+ return version("faster-api-web")
+ except PackageNotFoundError:
+ return "0.0.0"
diff --git a/FasterAPI/app.py b/FasterAPI/app.py
index f96fd7b..028917f 100644
--- a/FasterAPI/app.py
+++ b/FasterAPI/app.py
@@ -10,10 +10,12 @@
from __future__ import annotations
import asyncio
-from typing import Any, Callable, Sequence
+from collections.abc import Callable, Sequence
+from typing import Any, cast
import msgspec.json
+from ._version import get_version
from .concurrency import install_event_loop
from .dependencies import _resolve_handler, compile_handler
from .exceptions import (
@@ -25,8 +27,9 @@
from .openapi.generator import generate_openapi
from .openapi.ui import redoc_html, swagger_ui_html
from .request import Request
-from .response import HTMLResponse, JSONResponse, Response
+from .response import HTMLResponse, JSONResponse
from .router import RadixRouter
+from .types import ASGIApp
from .websocket import WebSocket
__all__ = ["Faster"]
@@ -45,38 +48,48 @@ class Faster:
"""The main FasterAPI application class, implementing the ASGI interface."""
__slots__ = (
- "title", "version", "description",
- "openapi_url", "docs_url", "redoc_url",
- "routes", "startup_handlers", "shutdown_handlers",
- "middleware", "exception_handlers",
- "_router", "_openapi_cache", "_middleware_app", "_ws_routes",
+ "title",
+ "version",
+ "description",
+ "openapi_url",
+ "docs_url",
+ "redoc_url",
+ "routes",
+ "startup_handlers",
+ "shutdown_handlers",
+ "middleware",
+ "exception_handlers",
+ "_router",
+ "_openapi_cache",
+ "_middleware_app",
+ "_ws_routes",
)
def __init__(
self,
*,
title: str = "FasterAPI",
- version: str = "0.1.1",
+ version: str | None = None,
description: str = "",
openapi_url: str | None = "/openapi.json",
docs_url: str | None = "/docs",
redoc_url: str | None = "/redoc",
) -> None:
self.title = title
- self.version = version
+ self.version = version if version is not None else get_version()
self.description = description
self.openapi_url = openapi_url
self.docs_url = docs_url
self.redoc_url = redoc_url
self.routes: list[dict[str, Any]] = []
- self.startup_handlers: list[Callable] = []
- self.shutdown_handlers: list[Callable] = []
+ self.startup_handlers: list[ASGIApp] = []
+ self.shutdown_handlers: list[ASGIApp] = []
self.middleware: list[dict[str, Any]] = []
- self.exception_handlers: dict[type, Callable] = {}
+ self.exception_handlers: dict[type, ASGIApp] = {}
self._router = RadixRouter()
self._openapi_cache: dict[str, Any] | None = None
- self._middleware_app: Callable | None = None
- self._ws_routes: dict[str, Callable] = {}
+ self._middleware_app: ASGIApp | None = None
+ self._ws_routes: dict[str, ASGIApp] = {}
self._setup_openapi_routes()
def __repr__(self) -> str:
@@ -93,15 +106,22 @@ def _setup_openapi_routes(self) -> None:
async def openapi_schema() -> JSONResponse:
spec = generate_openapi(
- app_ref, title=app_ref.title,
- version=app_ref.version, description=app_ref.description,
+ app_ref,
+ title=app_ref.title,
+ version=app_ref.version,
+ description=app_ref.description,
)
return JSONResponse(spec)
self._add_route(
- "GET", api_url, openapi_schema,
- tags=["openapi"], summary="OpenAPI Schema",
- response_model=None, status_code=200, deprecated=False,
+ "GET",
+ api_url,
+ openapi_schema,
+ tags=["openapi"],
+ summary="OpenAPI Schema",
+ response_model=None,
+ status_code=200,
+ deprecated=False,
)
if self.docs_url is not None and self.openapi_url is not None:
@@ -111,9 +131,14 @@ async def swagger_docs() -> HTMLResponse:
return HTMLResponse(swagger_ui_html(ourl, title=f"{t} - Swagger UI"))
self._add_route(
- "GET", self.docs_url, swagger_docs,
- tags=["openapi"], summary="Swagger UI",
- response_model=None, status_code=200, deprecated=False,
+ "GET",
+ self.docs_url,
+ swagger_docs,
+ tags=["openapi"],
+ summary="Swagger UI",
+ response_model=None,
+ status_code=200,
+ deprecated=False,
)
if self.redoc_url is not None and self.openapi_url is not None:
@@ -123,16 +148,26 @@ async def redoc_docs() -> HTMLResponse:
return HTMLResponse(redoc_html(ourl, title=f"{t} - ReDoc"))
self._add_route(
- "GET", self.redoc_url, redoc_docs,
- tags=["openapi"], summary="ReDoc",
- response_model=None, status_code=200, deprecated=False,
+ "GET",
+ self.redoc_url,
+ redoc_docs,
+ tags=["openapi"],
+ summary="ReDoc",
+ response_model=None,
+ status_code=200,
+ deprecated=False,
)
# ------------------------------------------------------------------
# ASGI interface
# ------------------------------------------------------------------
- async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None:
+ async def __call__(
+ self,
+ scope: dict[str, Any],
+ receive: ASGIApp,
+ send: ASGIApp,
+ ) -> None:
if self.middleware:
if self._middleware_app is None:
self._middleware_app = self._build_middleware_chain()
@@ -140,7 +175,12 @@ async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None
else:
await self._asgi_app(scope, receive, send)
- async def _asgi_app(self, scope: dict, receive: Callable, send: Callable) -> None:
+ async def _asgi_app(
+ self,
+ scope: dict[str, Any],
+ receive: ASGIApp,
+ send: ASGIApp,
+ ) -> None:
scope_type = scope["type"]
if scope_type == "http":
await self._handle_http(scope, receive, send)
@@ -149,7 +189,7 @@ async def _asgi_app(self, scope: dict, receive: Callable, send: Callable) -> Non
elif scope_type == "lifespan":
await self._handle_lifespan(scope, receive, send)
- def _build_middleware_chain(self) -> Callable:
+ def _build_middleware_chain(self) -> ASGIApp:
app = self._asgi_app
for entry in reversed(self.middleware):
app = entry["class"](app, **entry["kwargs"])
@@ -159,7 +199,12 @@ def _build_middleware_chain(self) -> Callable:
# HTTP dispatch β hot path
# ------------------------------------------------------------------
- async def _handle_http(self, scope: dict, receive: Callable, send: Callable) -> None:
+ async def _handle_http(
+ self,
+ scope: dict[str, Any],
+ receive: ASGIApp,
+ send: ASGIApp,
+ ) -> None:
result = self._router.resolve(scope["method"], scope["path"])
if result is None:
await _send_error(send, 404, "Not Found")
@@ -174,14 +219,18 @@ async def _handle_http(self, scope: dict, receive: Callable, send: Callable) ->
response, bg_tasks = await _resolve_handler(handler, request, path_params)
except RequestValidationError as exc:
status, body, headers = await self._handle_exc(
- request, exc, RequestValidationError,
+ request,
+ exc,
+ RequestValidationError,
_default_validation_exception_handler,
)
await _send_raw(send, status, body, headers)
return
except HTTPException as exc:
status, body, headers = await self._handle_exc(
- request, exc, HTTPException,
+ request,
+ exc,
+ HTTPException,
_default_http_exception_handler,
)
await _send_raw(send, status, body, headers)
@@ -203,21 +252,27 @@ async def _handle_http(self, scope: dict, receive: Callable, send: Callable) ->
await bg_tasks.run()
async def _handle_exc(
- self, request: Request, exc: Exception, exc_class: type,
- default_handler: Callable,
+ self,
+ request: Request,
+ exc: Exception,
+ exc_class: type,
+ default_handler: ASGIApp,
) -> tuple[int, bytes, list[tuple[bytes, bytes]]]:
handler = self.exception_handlers.get(exc_class, default_handler)
result = handler(request, exc)
if asyncio.iscoroutine(result):
result = await result
- return result # type: ignore[return-value]
+ return cast(tuple[int, bytes, list[tuple[bytes, bytes]]], result)
# ------------------------------------------------------------------
# WebSocket dispatch
# ------------------------------------------------------------------
async def _handle_websocket(
- self, scope: dict, receive: Callable, send: Callable,
+ self,
+ scope: dict[str, Any],
+ receive: ASGIApp,
+ send: ASGIApp,
) -> None:
path = scope.get("path", "/")
handler = self._ws_routes.get(path.rstrip("/") or "/")
@@ -232,7 +287,10 @@ async def _handle_websocket(
# ------------------------------------------------------------------
async def _handle_lifespan(
- self, scope: dict, receive: Callable, send: Callable,
+ self,
+ scope: dict[str, Any],
+ receive: ASGIApp,
+ send: ASGIApp,
) -> None:
while True:
message = await receive()
@@ -262,23 +320,34 @@ async def _handle_lifespan(
# ------------------------------------------------------------------
def _add_route(
- self, method: str, path: str, handler: Callable, *,
- tags: list[str], summary: str, response_model: Any,
- status_code: int, deprecated: bool,
+ self,
+ method: str,
+ path: str,
+ handler: ASGIApp,
+ *,
+ tags: list[str],
+ summary: str,
+ response_model: Any,
+ status_code: int,
+ deprecated: bool,
) -> None:
metadata = {
- "tags": tags, "summary": summary,
+ "tags": tags,
+ "summary": summary,
"response_model": response_model,
- "status_code": status_code, "deprecated": deprecated,
+ "status_code": status_code,
+ "deprecated": deprecated,
}
self.routes.append({"method": method, "path": path, "handler": handler, **metadata})
self._router.add_route(method, path, handler, metadata)
compile_handler(handler) # pre-compile at registration time
- def _route_decorator(self, method: str, path: str, **kw: Any) -> Callable:
- def decorator(handler: Callable) -> Callable:
+ def _route_decorator(self, method: str, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]:
+ def decorator(handler: ASGIApp) -> ASGIApp:
self._add_route(
- method, path, handler,
+ method,
+ path,
+ handler,
tags=kw.get("tags") or [],
summary=kw.get("summary", ""),
response_model=kw.get("response_model"),
@@ -286,38 +355,40 @@ def decorator(handler: Callable) -> Callable:
deprecated=kw.get("deprecated", False),
)
return handler
+
return decorator
- def get(self, path: str, **kw: Any) -> Callable:
+ def get(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]:
return self._route_decorator("GET", path, **kw)
- def post(self, path: str, **kw: Any) -> Callable:
+ def post(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]:
return self._route_decorator("POST", path, **kw)
- def put(self, path: str, **kw: Any) -> Callable:
+ def put(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]:
return self._route_decorator("PUT", path, **kw)
- def delete(self, path: str, **kw: Any) -> Callable:
+ def delete(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]:
return self._route_decorator("DELETE", path, **kw)
- def patch(self, path: str, **kw: Any) -> Callable:
+ def patch(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]:
return self._route_decorator("PATCH", path, **kw)
- def websocket(self, path: str) -> Callable:
- def decorator(handler: Callable) -> Callable:
+ def websocket(self, path: str) -> Callable[[ASGIApp], ASGIApp]:
+ def decorator(handler: ASGIApp) -> ASGIApp:
self._ws_routes[path.rstrip("/") or "/"] = handler
return handler
+
return decorator
# ------------------------------------------------------------------
# Lifecycle hooks
# ------------------------------------------------------------------
- def on_startup(self, handler: Callable) -> Callable:
+ def on_startup(self, handler: ASGIApp) -> ASGIApp:
self.startup_handlers.append(handler)
return handler
- def on_shutdown(self, handler: Callable) -> Callable:
+ def on_shutdown(self, handler: ASGIApp) -> ASGIApp:
self.shutdown_handlers.append(handler)
return handler
@@ -329,7 +400,7 @@ def add_middleware(self, middleware_class: type, **kwargs: Any) -> None:
self.middleware.append({"class": middleware_class, "kwargs": kwargs})
self._middleware_app = None # invalidate cached chain
- def add_exception_handler(self, exc_class: type, handler: Callable) -> None:
+ def add_exception_handler(self, exc_class: type, handler: ASGIApp) -> None:
self.exception_handlers[exc_class] = handler
# ------------------------------------------------------------------
@@ -337,7 +408,11 @@ def add_exception_handler(self, exc_class: type, handler: Callable) -> None:
# ------------------------------------------------------------------
def include_router(
- self, router: Any, *, prefix: str = "", tags: Sequence[str] = (),
+ self,
+ router: Any,
+ *,
+ prefix: str = "",
+ tags: Sequence[str] = (),
) -> None:
pfx = prefix.rstrip("/")
for route in router.routes:
@@ -354,7 +429,8 @@ def include_router(
# Module-level send helpers (avoid method lookup on self)
# ------------------------------------------------------------------
-async def _send_response(send: Callable, status_code: int, body: Any) -> None:
+
+async def _send_response(send: ASGIApp, status_code: int, body: Any) -> None:
if hasattr(body, "to_asgi"):
await body.to_asgi(send)
return
@@ -374,13 +450,16 @@ async def _send_response(send: Callable, status_code: int, body: Any) -> None:
async def _send_raw(
- send: Callable, status: int, body: bytes, headers: list[tuple[bytes, bytes]],
+ send: ASGIApp,
+ status: int,
+ body: bytes,
+ headers: list[tuple[bytes, bytes]],
) -> None:
await send({"type": "http.response.start", "status": status, "headers": headers})
await send({"type": "http.response.body", "body": body})
-async def _send_error(send: Callable, status: int, message: str) -> None:
+async def _send_error(send: ASGIApp, status: int, message: str) -> None:
body = msgspec.json.encode({"detail": message})
await send({"type": "http.response.start", "status": status, "headers": [(_HEADER_CT, _CT_JSON)]})
await send({"type": "http.response.body", "body": body})
diff --git a/FasterAPI/background.py b/FasterAPI/background.py
index 63aeeb5..da2b280 100644
--- a/FasterAPI/background.py
+++ b/FasterAPI/background.py
@@ -1,7 +1,8 @@
from __future__ import annotations
import asyncio
-from typing import Any, Callable
+from collections.abc import Callable
+from typing import Any
from .concurrency import is_coroutine
@@ -11,7 +12,7 @@ class BackgroundTask:
__slots__ = ("func", "args", "kwargs")
- def __init__(self, func: Callable, *args: Any, **kwargs: Any) -> None:
+ def __init__(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
self.func = func
self.args = args
self.kwargs = kwargs
@@ -34,7 +35,7 @@ class BackgroundTasks:
def __init__(self) -> None:
self._tasks: list[BackgroundTask] = []
- def add_task(self, func: Callable, *args: Any, **kwargs: Any) -> None:
+ def add_task(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
"""Add a new background task to the collection."""
self._tasks.append(BackgroundTask(func, *args, **kwargs))
diff --git a/FasterAPI/concurrency.py b/FasterAPI/concurrency.py
index 7b2b777..f32fa4e 100644
--- a/FasterAPI/concurrency.py
+++ b/FasterAPI/concurrency.py
@@ -27,12 +27,14 @@
from __future__ import annotations
import asyncio
+import contextlib
import inspect
import os
import sys
+from collections.abc import Callable
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from functools import partial
-from typing import Any, Callable
+from typing import Any
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Version detection
@@ -75,6 +77,7 @@ def _get_thread_pool() -> ThreadPoolExecutor:
# Core helpers
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
def is_coroutine(func: Callable) -> bool: # type: ignore[type-arg]
"""Return True if *func* is a coroutine function."""
return inspect.iscoroutinefunction(func)
@@ -121,8 +124,9 @@ async def run_in_threadpool(func: Callable, *args: Any) -> Any: # type: ignore[
_HAS_INTERPRETERS = False
if _PY313_PLUS:
try:
- import interpreters # type: ignore[import-not-found]
- import interpreters.channels # type: ignore[import-not-found]
+ import interpreters
+ import interpreters.channels
+
_HAS_INTERPRETERS = True
except ImportError:
pass
@@ -171,9 +175,7 @@ async def run(self, func: Callable, *args: Any) -> Any: # type: ignore[type-arg
assert self._semaphore is not None
async with self._semaphore:
# Round-robin: pick the next free interpreter
- interp = self._interpreters[
- id(asyncio.current_task()) % len(self._interpreters)
- ]
+ interp = self._interpreters[id(asyncio.current_task()) % len(self._interpreters)]
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
self._thread_pool,
@@ -184,10 +186,8 @@ def shutdown(self) -> None:
"""Destroy all sub-interpreters and the backing thread pool."""
self._thread_pool.shutdown(wait=False)
for interp in self._interpreters:
- try:
+ with contextlib.suppress(Exception):
interp.close()
- except Exception:
- pass
self._interpreters.clear()
self._initialized = False
@@ -219,7 +219,8 @@ async def run(self, func: Callable, *args: Any) -> Any: # type: ignore[type-arg
"""Execute *func* in a worker process (pickle-based)."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
- self._executor, partial(func, *args),
+ self._executor,
+ partial(func, *args),
)
def shutdown(self) -> None:
@@ -276,6 +277,7 @@ async def run_in_subinterpreter(func: Callable, *args: Any) -> Any: # type: ign
# older β uvloop if available, else stdlib
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
def install_event_loop() -> str:
"""Install the fastest available event loop and return its name.
@@ -283,9 +285,11 @@ def install_event_loop() -> str:
"""
try:
import uvloop
+
if _PY312_PLUS:
# uvloop.install() is deprecated on 3.12+; set the policy instead
import asyncio
+
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
else:
uvloop.install()
diff --git a/FasterAPI/datastructures.py b/FasterAPI/datastructures.py
index 9dcb017..0706158 100644
--- a/FasterAPI/datastructures.py
+++ b/FasterAPI/datastructures.py
@@ -49,13 +49,10 @@ def size(self) -> int | None:
return self._size
def __repr__(self) -> str:
- return (
- f"UploadFile(filename={self.filename!r}, "
- f"content_type={self.content_type!r})"
- )
+ return f"UploadFile(filename={self.filename!r}, content_type={self.content_type!r})"
-class FormData(dict):
+class FormData(dict[str, Any]):
"""Dict subclass for form data that may contain UploadFile values."""
async def close(self) -> None:
diff --git a/FasterAPI/dependencies.py b/FasterAPI/dependencies.py
index 63bbcd8..3c9bdbb 100644
--- a/FasterAPI/dependencies.py
+++ b/FasterAPI/dependencies.py
@@ -10,8 +10,9 @@
import inspect
import typing
+from collections.abc import Callable
from functools import lru_cache
-from typing import Any, Callable
+from typing import Any
import msgspec
@@ -19,7 +20,7 @@
from .concurrency import is_coroutine
from .datastructures import UploadFile
from .exceptions import RequestValidationError
-from .params import Body, Cookie, File, Form, Header, Path, Query, _MISSING
+from .params import _MISSING, Body, Cookie, File, Form, Header, Path, Query
from .request import Request
__all__ = ["Depends", "compile_handler", "_resolve_handler"]
@@ -28,12 +29,13 @@
# Depends marker
# ---------------------------------------------------------------------------
+
class Depends:
"""Declare a dependency to be resolved and injected into a route handler."""
__slots__ = ("dependency", "use_cache")
- def __init__(self, dependency: Callable, *, use_cache: bool = True) -> None:
+ def __init__(self, dependency: Callable[..., Any], *, use_cache: bool = True) -> None:
self.dependency = dependency
self.use_cache = use_cache
@@ -65,7 +67,12 @@ class _ParamSpec:
__slots__ = ("name", "kind", "annotation", "default", "marker")
def __init__(
- self, name: str, kind: int, annotation: Any, default: Any, marker: Any,
+ self,
+ name: str,
+ kind: int,
+ annotation: Any,
+ default: Any,
+ marker: Any,
) -> None:
self.name = name
self.kind = kind
@@ -78,8 +85,9 @@ def __init__(
# Compile handler (called once at route registration)
# ---------------------------------------------------------------------------
+
@lru_cache(maxsize=512)
-def compile_handler(func: Callable) -> tuple[tuple[_ParamSpec, ...], bool]:
+def compile_handler(func: Callable[..., Any]) -> tuple[tuple[_ParamSpec, ...], bool]:
"""Introspect *func* once and return a tuple of _ParamSpec plus is-async flag.
This replaces per-request inspect.signature + get_type_hints calls.
@@ -127,14 +135,15 @@ def compile_handler(func: Callable) -> tuple[tuple[_ParamSpec, ...], bool]:
# Hot-path resolver (called on every request)
# ---------------------------------------------------------------------------
+
async def _resolve_handler(
- handler: Callable,
+ handler: Callable[..., Any],
request: Request,
path_params: dict[str, str],
) -> tuple[Any, BackgroundTasks | None]:
"""Resolve dependencies, call handler, return (result, bg_tasks|None)."""
specs, is_async = compile_handler(handler)
- cache: dict[Callable, Any] = {}
+ cache: dict[Callable[..., Any], Any] = {}
bg_tasks = BackgroundTasks()
kwargs = await _resolve_from_specs(specs, request, path_params, cache, bg_tasks)
@@ -146,7 +155,7 @@ async def _resolve_from_specs(
specs: tuple[_ParamSpec, ...],
request: Request,
path_params: dict[str, str],
- cache: dict[Callable, Any],
+ cache: dict[Callable[..., Any], Any],
bg_tasks: BackgroundTasks,
) -> dict[str, Any]:
"""Build kwargs dict from pre-compiled param specs β no introspection."""
@@ -161,11 +170,17 @@ async def _resolve_from_specs(
kwargs[spec.name] = bg_tasks
elif kind == _KIND_DEPENDS:
kwargs[spec.name] = await _resolve_dependency(
- spec.marker, request, path_params, cache, bg_tasks,
+ spec.marker,
+ request,
+ path_params,
+ cache,
+ bg_tasks,
)
elif kind == _KIND_STRUCT:
kwargs[spec.name] = await _resolve_struct(
- spec.annotation, request, spec.default,
+ spec.annotation,
+ request,
+ spec.default,
)
elif kind == _KIND_PATH:
kwargs[spec.name] = _resolve_path(spec.name, path_params, spec.marker)
@@ -179,7 +194,9 @@ async def _resolve_from_specs(
kwargs[spec.name] = await _resolve_file(spec.name, request)
elif kind == _KIND_FORM:
kwargs[spec.name] = await _resolve_form_field(
- spec.name, request, spec.marker,
+ spec.name,
+ request,
+ spec.marker,
)
elif kind == _KIND_BODY:
kwargs[spec.name] = await _resolve_body(request, spec.marker)
@@ -196,11 +213,12 @@ async def _resolve_from_specs(
# Dependency resolution
# ---------------------------------------------------------------------------
+
async def _resolve_dependency(
dep: Depends,
request: Request,
path_params: dict[str, str],
- cache: dict[Callable, Any],
+ cache: dict[Callable[..., Any], Any],
bg_tasks: BackgroundTasks,
) -> Any:
func = dep.dependency
@@ -220,6 +238,7 @@ async def _resolve_dependency(
# Individual param resolvers (kept lean)
# ---------------------------------------------------------------------------
+
def _is_struct_type(annotation: Any) -> bool:
return (
annotation is not inspect.Parameter.empty
@@ -237,7 +256,9 @@ def _is_upload_file_type(annotation: Any) -> bool:
async def _resolve_struct(
- struct_type: type, request: Request, default: Any,
+ struct_type: type,
+ request: Request,
+ default: Any,
) -> Any:
try:
raw = await request._read_body()
diff --git a/FasterAPI/exceptions.py b/FasterAPI/exceptions.py
index bc70933..aa000b6 100644
--- a/FasterAPI/exceptions.py
+++ b/FasterAPI/exceptions.py
@@ -34,29 +34,31 @@ def __repr__(self) -> str:
# --- Default exception handlers ---
+
async def _default_http_exception_handler(
- request: Any, exc: HTTPException,
+ request: Any,
+ exc: HTTPException,
) -> tuple[int, bytes, list[tuple[bytes, bytes]]]:
body = msgspec.json.encode({"detail": exc.detail})
headers: list[tuple[bytes, bytes]] = [(b"content-type", b"application/json")]
if exc.headers:
- headers.extend(
- (k.lower().encode("latin-1"), v.encode("latin-1"))
- for k, v in exc.headers.items()
- )
+ headers.extend((k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in exc.headers.items())
return exc.status_code, body, headers
async def _default_validation_exception_handler(
- request: Any, exc: RequestValidationError,
+ request: Any,
+ exc: RequestValidationError,
) -> tuple[int, bytes, list[tuple[bytes, bytes]]]:
detail = []
for err in exc.errors:
- detail.append({
- "loc": err.get("loc", []),
- "msg": err.get("msg", ""),
- "type": err.get("type", "value_error"),
- })
+ detail.append(
+ {
+ "loc": err.get("loc", []),
+ "msg": err.get("msg", ""),
+ "type": err.get("type", "value_error"),
+ }
+ )
body = msgspec.json.encode({"detail": detail})
headers: list[tuple[bytes, bytes]] = [(b"content-type", b"application/json")]
return 422, body, headers
diff --git a/FasterAPI/middleware.py b/FasterAPI/middleware.py
index 0dc850b..ba8fd6b 100644
--- a/FasterAPI/middleware.py
+++ b/FasterAPI/middleware.py
@@ -1,26 +1,32 @@
from __future__ import annotations
-import asyncio
import gzip
-from typing import Any, Callable, Sequence
+from collections.abc import Sequence
+from typing import Any
+
+from .types import ASGIApp
class BaseHTTPMiddleware:
"""Base class for HTTP middleware that wraps an ASGI application."""
- def __init__(self, app: Callable) -> None:
+ def __init__(self, app: ASGIApp) -> None:
self.app = app
- async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None:
+ async def __call__(self, scope: dict[str, Any], receive: ASGIApp, send: ASGIApp) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
await self.dispatch(scope, receive, send)
async def dispatch(
- self, scope: dict, receive: Callable, send: Callable,
+ self,
+ scope: dict[str, Any],
+ receive: ASGIApp,
+ send: ASGIApp,
) -> None:
"""Process the request. Override this method in subclasses."""
+
async def call_next() -> None:
await self.app(scope, receive, send)
@@ -32,7 +38,7 @@ class CORSMiddleware(BaseHTTPMiddleware):
def __init__(
self,
- app: Callable,
+ app: ASGIApp,
*,
allow_origins: Sequence[str] = ("*",),
allow_methods: Sequence[str] = ("*",),
@@ -53,7 +59,10 @@ def __init__(
self.allow_all_headers = "*" in self.allow_headers
async def dispatch(
- self, scope: dict, receive: Callable, send: Callable,
+ self,
+ scope: dict[str, Any],
+ receive: ASGIApp,
+ send: ASGIApp,
) -> None:
"""Handle CORS preflight requests and inject CORS headers into responses."""
headers_raw: list[tuple[bytes, bytes]] = scope.get("headers", [])
@@ -69,7 +78,7 @@ async def dispatch(
# Normal request β intercept send to inject CORS headers
cors_headers = self._build_cors_headers(origin)
- async def send_with_cors(message: dict) -> None:
+ async def send_with_cors(message: dict[str, Any]) -> None:
if message["type"] == "http.response.start":
existing = list(message.get("headers", []))
existing.extend(cors_headers)
@@ -100,15 +109,17 @@ def _build_cors_headers(self, origin: str | None) -> list[tuple[bytes, bytes]]:
headers.append((b"access-control-allow-credentials", b"true"))
if self.expose_headers:
- headers.append((
- b"access-control-expose-headers",
- ", ".join(self.expose_headers).encode("latin-1"),
- ))
+ headers.append(
+ (
+ b"access-control-expose-headers",
+ ", ".join(self.expose_headers).encode("latin-1"),
+ )
+ )
return headers
async def _preflight_response(
self,
- send: Callable,
+ send: ASGIApp,
origin: str | None,
request_headers: dict[str, str],
) -> None:
@@ -126,43 +137,52 @@ async def _preflight_response(
req_method = request_headers.get("access-control-request-method", "")
headers.append((b"access-control-allow-methods", req_method.encode("latin-1")))
else:
- headers.append((
- b"access-control-allow-methods",
- ", ".join(self.allow_methods).encode("latin-1"),
- ))
+ headers.append(
+ (
+ b"access-control-allow-methods",
+ ", ".join(self.allow_methods).encode("latin-1"),
+ )
+ )
# Headers
if self.allow_all_headers:
req_headers = request_headers.get("access-control-request-headers", "")
headers.append((b"access-control-allow-headers", req_headers.encode("latin-1")))
else:
- headers.append((
- b"access-control-allow-headers",
- ", ".join(self.allow_headers).encode("latin-1"),
- ))
+ headers.append(
+ (
+ b"access-control-allow-headers",
+ ", ".join(self.allow_headers).encode("latin-1"),
+ )
+ )
if self.allow_credentials:
headers.append((b"access-control-allow-credentials", b"true"))
headers.append((b"access-control-max-age", str(self.max_age).encode()))
- await send({
- "type": "http.response.start",
- "status": 200,
- "headers": headers,
- })
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 200,
+ "headers": headers,
+ }
+ )
await send({"type": "http.response.body", "body": b""})
class GZipMiddleware(BaseHTTPMiddleware):
"""Middleware that compresses responses using gzip when the client supports it."""
- def __init__(self, app: Callable, *, minimum_size: int = 1000) -> None:
+ def __init__(self, app: ASGIApp, *, minimum_size: int = 1000) -> None:
super().__init__(app)
self.minimum_size = minimum_size
async def dispatch(
- self, scope: dict, receive: Callable, send: Callable,
+ self,
+ scope: dict[str, Any],
+ receive: ASGIApp,
+ send: ASGIApp,
) -> None:
"""Compress the response body with gzip if it exceeds the minimum size."""
headers_raw: list[tuple[bytes, bytes]] = scope.get("headers", [])
@@ -177,10 +197,10 @@ async def dispatch(
return
# Collect response to potentially compress
- initial_message: dict | None = None
+ initial_message: dict[str, Any] | None = None
body_parts: list[bytes] = []
- async def buffered_send(message: dict) -> None:
+ async def buffered_send(message: dict[str, Any]) -> None:
nonlocal initial_message
if message["type"] == "http.response.start":
initial_message = message
@@ -195,10 +215,7 @@ async def buffered_send(message: dict) -> None:
headers.append((b"content-encoding", b"gzip"))
headers.append((b"vary", b"Accept-Encoding"))
# Update content-length
- headers = [
- (k, v) for k, v in headers
- if k.lower() != b"content-length"
- ]
+ headers = [(k, v) for k, v in headers if k.lower() != b"content-length"]
headers.append((b"content-length", str(len(compressed)).encode()))
await send({**initial_message, "headers": headers})
await send({"type": "http.response.body", "body": compressed})
@@ -214,14 +231,20 @@ class TrustedHostMiddleware(BaseHTTPMiddleware):
"""Middleware that validates the Host header against a list of allowed hosts."""
def __init__(
- self, app: Callable, *, allowed_hosts: Sequence[str] = ("*",),
+ self,
+ app: ASGIApp,
+ *,
+ allowed_hosts: Sequence[str] = ("*",),
) -> None:
super().__init__(app)
self.allowed_hosts = set(allowed_hosts)
self.allow_all = "*" in self.allowed_hosts
async def dispatch(
- self, scope: dict, receive: Callable, send: Callable,
+ self,
+ scope: dict[str, Any],
+ receive: ASGIApp,
+ send: ASGIApp,
) -> None:
if self.allow_all:
await self.app(scope, receive, send)
@@ -235,15 +258,19 @@ async def dispatch(
break
if host not in self.allowed_hosts:
- await send({
- "type": "http.response.start",
- "status": 400,
- "headers": [(b"content-type", b"text/plain")],
- })
- await send({
- "type": "http.response.body",
- "body": b"Invalid host header",
- })
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 400,
+ "headers": [(b"content-type", b"text/plain")],
+ }
+ )
+ await send(
+ {
+ "type": "http.response.body",
+ "body": b"Invalid host header",
+ }
+ )
return
await self.app(scope, receive, send)
@@ -253,7 +280,10 @@ class HTTPSRedirectMiddleware(BaseHTTPMiddleware):
"""Middleware that redirects all HTTP requests to HTTPS."""
async def dispatch(
- self, scope: dict, receive: Callable, send: Callable,
+ self,
+ scope: dict[str, Any],
+ receive: ASGIApp,
+ send: ASGIApp,
) -> None:
if scope.get("scheme", "http") == "https":
await self.app(scope, receive, send)
@@ -273,12 +303,14 @@ async def dispatch(
if qs:
url += f"?{qs.decode('latin-1')}"
- await send({
- "type": "http.response.start",
- "status": 301,
- "headers": [
- (b"location", url.encode("latin-1")),
- (b"content-type", b"text/plain"),
- ],
- })
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 301,
+ "headers": [
+ (b"location", url.encode("latin-1")),
+ (b"content-type", b"text/plain"),
+ ],
+ }
+ )
await send({"type": "http.response.body", "body": b"Redirecting to HTTPS"})
diff --git a/FasterAPI/openapi/generator.py b/FasterAPI/openapi/generator.py
index 315d7db..df23e4b 100644
--- a/FasterAPI/openapi/generator.py
+++ b/FasterAPI/openapi/generator.py
@@ -4,10 +4,12 @@
import re
import types
import typing
-from typing import Any, Callable, Union, get_args, get_origin
+from collections.abc import Callable
+from typing import Any, Union, get_args, get_origin
import msgspec
+from .._version import get_version
from ..params import Body, Cookie, Header, Path, Query
@@ -15,10 +17,12 @@ def generate_openapi(
app: Any,
*,
title: str = "FasterAPI",
- version: str = "0.1.1",
+ version: str | None = None,
description: str = "",
) -> dict[str, Any]:
"""Generate an OpenAPI 3.0.3 spec dict from a Faster app instance."""
+ if version is None:
+ version = get_version()
if hasattr(app, "_openapi_cache") and app._openapi_cache is not None:
result: dict[str, Any] = app._openapi_cache
return result
@@ -56,7 +60,7 @@ def generate_openapi(
def _build_operation(
route: dict[str, Any],
- handler: Callable,
+ handler: Callable[..., Any],
schemas: dict[str, Any],
) -> dict[str, Any]:
operation: dict[str, Any] = {}
@@ -141,7 +145,7 @@ def _build_operation(
def _extract_params(
route: dict[str, Any],
- handler: Callable,
+ handler: Callable[..., Any],
schemas: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
parameters: list[dict[str, Any]] = []
@@ -169,11 +173,13 @@ def _extract_params(
# Skip Request injection
from ..request import Request as RequestClass
+
if annotation is RequestClass:
continue
# Skip Depends
from ..dependencies import Depends
+
if isinstance(default, Depends):
continue
@@ -208,9 +214,7 @@ def _extract_params(
# Header parameter
if isinstance(default, Header):
- header_name = default.alias or (
- name.replace("_", "-") if default.convert_underscores else name
- )
+ header_name = default.alias or (name.replace("_", "-") if default.convert_underscores else name)
p = {
"name": header_name,
"in": "header",
@@ -264,7 +268,8 @@ def _is_optional(annotation: Any) -> bool:
def _annotation_to_schema(
- annotation: Any, schemas: dict[str, Any] | None = None,
+ annotation: Any,
+ schemas: dict[str, Any] | None = None,
) -> dict[str, Any]:
if annotation is inspect.Parameter.empty or annotation is Any:
return {"type": "string"}
@@ -272,7 +277,8 @@ def _annotation_to_schema(
def _python_type_to_schema(
- tp: Any, schemas: dict[str, Any] | None = None,
+ tp: Any,
+ schemas: dict[str, Any] | None = None,
) -> dict[str, Any]:
if tp is str:
return {"type": "string"}
@@ -318,7 +324,8 @@ def _python_type_to_schema(
def _type_to_schema(
- tp: Any, schemas: dict[str, Any],
+ tp: Any,
+ schemas: dict[str, Any],
) -> dict[str, Any]:
if tp is None or tp is inspect.Parameter.empty:
return {"type": "string"}
@@ -330,7 +337,8 @@ def _type_to_schema(
def _struct_to_ref(
- struct_type: type, schemas: dict[str, Any],
+ struct_type: type,
+ schemas: dict[str, Any],
) -> dict[str, Any]:
name = struct_type.__name__
@@ -341,7 +349,8 @@ def _struct_to_ref(
def _struct_to_schema(
- struct_type: type, schemas: dict[str, Any],
+ struct_type: type,
+ schemas: dict[str, Any],
) -> dict[str, Any]:
properties: dict[str, Any] = {}
required: list[str] = []
diff --git a/FasterAPI/request.py b/FasterAPI/request.py
index ca27bcb..ab395a2 100644
--- a/FasterAPI/request.py
+++ b/FasterAPI/request.py
@@ -10,13 +10,14 @@
from __future__ import annotations
from http.cookies import SimpleCookie
-from typing import Any
+from typing import Any, cast
from urllib.parse import parse_qs
import msgspec.json
-from python_multipart.multipart import parse_options_header, MultipartParser
+from python_multipart.multipart import MultipartParser, parse_options_header
from .datastructures import FormData, UploadFile
+from .types import ASGIApp
__all__ = ["Request"]
@@ -25,12 +26,20 @@ class Request:
"""Represents an incoming HTTP request with lazy attribute parsing."""
__slots__ = (
- "_scope", "_receive", "_body", "_body_read", "_form_cache",
- "_headers", "_query_params", "_cookies",
- "method", "path", "path_params",
+ "_scope",
+ "_receive",
+ "_body",
+ "_body_read",
+ "_form_cache",
+ "_headers",
+ "_query_params",
+ "_cookies",
+ "method",
+ "path",
+ "path_params",
)
- def __init__(self, scope: dict, receive: Any) -> None:
+ def __init__(self, scope: dict[str, Any], receive: ASGIApp) -> None:
self._scope = scope
self._receive = receive
self._body: bytes = b""
@@ -136,6 +145,7 @@ async def form(self) -> FormData:
# Form parsing helpers
# ------------------------------------------------------------------
+
def _parse_urlencoded(raw: bytes) -> FormData:
text = raw.decode("latin-1")
parsed = parse_qs(text, keep_blank_values=True)
@@ -168,9 +178,7 @@ def on_header_value(data: bytes, start: int, end: int) -> None:
header_value.extend(data[start:end])
def on_header_end() -> None:
- current_headers[bytes(header_field).decode("latin-1").lower()] = (
- bytes(header_value).decode("latin-1")
- )
+ current_headers[bytes(header_field).decode("latin-1").lower()] = bytes(header_value).decode("latin-1")
header_field.clear()
header_value.clear()
@@ -182,7 +190,8 @@ def on_headers_finished() -> None:
if filename is not None:
part_info["filename"] = filename.decode("utf-8")
part_info["content_type"] = current_headers.get(
- "content-type", "application/octet-stream",
+ "content-type",
+ "application/octet-stream",
)
part_info["headers"] = dict(current_headers)
@@ -207,15 +216,21 @@ def on_part_end() -> None:
else:
fields[name] = bytes(current_data).decode("utf-8")
- parser = MultipartParser(boundary, {
- "on_part_begin": on_part_begin,
- "on_header_field": on_header_field,
- "on_header_value": on_header_value,
- "on_header_end": on_header_end,
- "on_headers_finished": on_headers_finished,
- "on_part_data": on_part_data,
- "on_part_end": on_part_end,
- }) # type: ignore[arg-type]
+ parser = MultipartParser(
+ boundary,
+ cast(
+ Any,
+ {
+ "on_part_begin": on_part_begin,
+ "on_header_field": on_header_field,
+ "on_header_value": on_header_value,
+ "on_header_end": on_header_end,
+ "on_headers_finished": on_headers_finished,
+ "on_part_data": on_part_data,
+ "on_part_end": on_part_end,
+ },
+ ),
+ )
parser.write(raw)
parser.finalize()
return FormData(fields)
diff --git a/FasterAPI/response.py b/FasterAPI/response.py
index e3b92c5..b87fbd5 100644
--- a/FasterAPI/response.py
+++ b/FasterAPI/response.py
@@ -2,11 +2,14 @@
import asyncio
import mimetypes
+from collections.abc import AsyncIterator, Iterator
from pathlib import Path
-from typing import Any, AsyncIterator, Callable, Iterator
+from typing import Any
import msgspec.json
+from .types import ASGIApp
+
class Response:
"""Base HTTP response class."""
@@ -45,17 +48,21 @@ def _build_headers(self) -> list[tuple[bytes, bytes]]:
raw.append((key.lower().encode("latin-1"), value.encode("latin-1")))
return raw
- async def to_asgi(self, send: Callable) -> None:
+ async def to_asgi(self, send: ASGIApp) -> None:
"""Send the response through the ASGI interface."""
- await send({
- "type": "http.response.start",
- "status": self.status_code,
- "headers": self._build_headers(),
- })
- await send({
- "type": "http.response.body",
- "body": self.body,
- })
+ await send(
+ {
+ "type": "http.response.start",
+ "status": self.status_code,
+ "headers": self._build_headers(),
+ }
+ )
+ await send(
+ {
+ "type": "http.response.body",
+ "body": self.body,
+ }
+ )
class JSONResponse(Response):
@@ -136,27 +143,33 @@ def _build_headers(self) -> list[tuple[bytes, bytes]]:
raw.append((key.lower().encode("latin-1"), value.encode("latin-1")))
return raw
- async def to_asgi(self, send: Callable) -> None:
+ async def to_asgi(self, send: ASGIApp) -> None:
"""Stream the response body through the ASGI interface."""
- await send({
- "type": "http.response.start",
- "status": self.status_code,
- "headers": self._build_headers(),
- })
+ await send(
+ {
+ "type": "http.response.start",
+ "status": self.status_code,
+ "headers": self._build_headers(),
+ }
+ )
if hasattr(self.content, "__aiter__"):
async for chunk in self.content:
- await send({
- "type": "http.response.body",
- "body": chunk if isinstance(chunk, bytes) else chunk.encode(),
- "more_body": True,
- })
+ await send(
+ {
+ "type": "http.response.body",
+ "body": chunk if isinstance(chunk, bytes) else chunk.encode(),
+ "more_body": True,
+ }
+ )
else:
for chunk in self.content:
- await send({
- "type": "http.response.body",
- "body": chunk if isinstance(chunk, bytes) else chunk.encode(),
- "more_body": True,
- })
+ await send(
+ {
+ "type": "http.response.body",
+ "body": chunk if isinstance(chunk, bytes) else chunk.encode(),
+ "more_body": True,
+ }
+ )
await send({"type": "http.response.body", "body": b"", "more_body": False})
@@ -193,14 +206,17 @@ def _build_headers(self) -> list[tuple[bytes, bytes]]:
raw.append((key.lower().encode("latin-1"), value.encode("latin-1")))
return raw
- async def to_asgi(self, send: Callable) -> None:
+ async def to_asgi(self, send: ASGIApp) -> None:
"""Read the file and send it through the ASGI interface."""
content = await asyncio.get_running_loop().run_in_executor(
- None, self.path.read_bytes,
+ None,
+ self.path.read_bytes,
+ )
+ await send(
+ {
+ "type": "http.response.start",
+ "status": self.status_code,
+ "headers": self._build_headers(),
+ }
)
- await send({
- "type": "http.response.start",
- "status": self.status_code,
- "headers": self._build_headers(),
- })
await send({"type": "http.response.body", "body": content})
diff --git a/FasterAPI/router.py b/FasterAPI/router.py
index 03fdbd3..fd8f69e 100644
--- a/FasterAPI/router.py
+++ b/FasterAPI/router.py
@@ -9,7 +9,10 @@
from __future__ import annotations
-from typing import Any, Callable
+from collections.abc import Callable
+from typing import Any
+
+from .types import ASGIApp
__all__ = ["RadixRouter", "FasterRouter"]
@@ -21,7 +24,7 @@ class RadixNode:
def __init__(self) -> None:
self.children: dict[str, RadixNode] = {}
- self.handlers: dict[str, tuple[Callable, dict[str, Any]]] = {}
+ self.handlers: dict[str, tuple[ASGIApp, dict[str, Any]]] = {}
self.param_name: str | None = None
self.is_param: bool = False
@@ -42,7 +45,7 @@ def add_route(
self,
method: str,
path: str,
- handler: Callable,
+ handler: ASGIApp,
metadata: dict[str, Any] | None = None,
) -> None:
"""Register a handler for the given HTTP method and path pattern."""
@@ -71,8 +74,10 @@ def add_route(
# ------------------------------------------------------------------
def resolve(
- self, method: str, path: str,
- ) -> tuple[Callable, dict[str, str], dict[str, Any]] | None:
+ self,
+ method: str,
+ path: str,
+ ) -> tuple[ASGIApp, dict[str, str], dict[str, Any]] | None:
"""Resolve a path to (handler, path_params, metadata) or None."""
segments = _split(path)
params: dict[str, str] = {}
@@ -107,7 +112,8 @@ def _walk(
# Try param child
param_child = node.children.get("*")
if param_child is not None:
- params[param_child.param_name] = seg # type: ignore[index]
+ assert param_child.param_name is not None
+ params[param_child.param_name] = seg
node = param_child
idx += 1
continue
@@ -120,6 +126,7 @@ def _walk(
# Shared helpers
# ------------------------------------------------------------------
+
def _split(path: str) -> list[str]:
"""Split a URL path into non-empty segments, stripping trailing slashes."""
return [s for s in path.split("/") if s]
@@ -129,6 +136,7 @@ def _split(path: str) -> list[str]:
# FasterRouter (sub-router / blueprint)
# ------------------------------------------------------------------
+
class FasterRouter:
"""API router for grouping routes with a common prefix and tags."""
@@ -143,7 +151,7 @@ def _add_route(
self,
method: str,
path: str,
- handler: Callable,
+ handler: ASGIApp,
*,
tags: list[str],
summary: str,
@@ -152,47 +160,54 @@ def _add_route(
deprecated: bool,
) -> None:
full_path = self.prefix + path
- self.routes.append({
- "method": method,
- "path": full_path,
- "handler": handler,
- "tags": self.tags + tags,
- "summary": summary,
- "response_model": response_model,
- "status_code": status_code,
- "deprecated": deprecated,
- })
+ self.routes.append(
+ {
+ "method": method,
+ "path": full_path,
+ "handler": handler,
+ "tags": self.tags + tags,
+ "summary": summary,
+ "response_model": response_model,
+ "status_code": status_code,
+ "deprecated": deprecated,
+ }
+ )
# Decorator factories β identical API to Faster app
- def get(self, path: str, **kw: Any) -> Callable:
- def decorator(handler: Callable) -> Callable:
+ def get(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]:
+ def decorator(handler: ASGIApp) -> ASGIApp:
self._add_route("GET", path, handler, **_route_kw(kw))
return handler
+
return decorator
- def post(self, path: str, **kw: Any) -> Callable:
- def decorator(handler: Callable) -> Callable:
+ def post(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]:
+ def decorator(handler: ASGIApp) -> ASGIApp:
self._add_route("POST", path, handler, **_route_kw(kw))
return handler
+
return decorator
- def put(self, path: str, **kw: Any) -> Callable:
- def decorator(handler: Callable) -> Callable:
+ def put(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]:
+ def decorator(handler: ASGIApp) -> ASGIApp:
self._add_route("PUT", path, handler, **_route_kw(kw))
return handler
+
return decorator
- def delete(self, path: str, **kw: Any) -> Callable:
- def decorator(handler: Callable) -> Callable:
+ def delete(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]:
+ def decorator(handler: ASGIApp) -> ASGIApp:
self._add_route("DELETE", path, handler, **_route_kw(kw))
return handler
+
return decorator
- def patch(self, path: str, **kw: Any) -> Callable:
- def decorator(handler: Callable) -> Callable:
+ def patch(self, path: str, **kw: Any) -> Callable[[ASGIApp], ASGIApp]:
+ def decorator(handler: ASGIApp) -> ASGIApp:
self._add_route("PATCH", path, handler, **_route_kw(kw))
return handler
+
return decorator
diff --git a/FasterAPI/testclient.py b/FasterAPI/testclient.py
index 9225d9e..7c61278 100644
--- a/FasterAPI/testclient.py
+++ b/FasterAPI/testclient.py
@@ -1,27 +1,29 @@
from __future__ import annotations
import asyncio
+from collections.abc import Generator
from contextlib import contextmanager
-from typing import Any, Callable, Generator
+from typing import Any
import httpx
-from .websocket import WebSocket, WebSocketDisconnect
+from .types import ASGIApp
+from .websocket import WebSocketDisconnect
class _WebSocketSession:
"""Test WebSocket session that communicates through in-memory queues."""
def __init__(self) -> None:
- self._send_queue: asyncio.Queue[dict] = asyncio.Queue()
- self._receive_queue: asyncio.Queue[dict] = asyncio.Queue()
+ self._send_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
+ self._receive_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
self._accepted = False
self._closed = False
- async def _asgi_receive(self) -> dict:
+ async def _asgi_receive(self) -> dict[str, Any]:
return await self._receive_queue.get()
- async def _asgi_send(self, message: dict) -> None:
+ async def _asgi_send(self, message: dict[str, Any]) -> None:
await self._send_queue.put(message)
def send_text(self, data: str) -> None:
@@ -32,6 +34,7 @@ def send_bytes(self, data: bytes) -> None:
def send_json(self, data: Any) -> None:
import msgspec.json
+
self.send_text(msgspec.json.encode(data).decode())
def receive_text(self) -> str:
@@ -44,6 +47,7 @@ def receive_bytes(self) -> bytes:
def receive_json(self) -> Any:
import msgspec.json
+
text = self.receive_text()
return msgspec.json.decode(text.encode())
@@ -51,12 +55,12 @@ def close(self, code: int = 1000) -> None:
self._receive_queue.put_nowait({"type": "websocket.disconnect", "code": code})
self._closed = True
- def _drain_one(self) -> dict:
+ def _drain_one(self) -> dict[str, Any]:
"""Get the next message from the send queue (blocks briefly)."""
try:
msg = self._send_queue.get_nowait()
except asyncio.QueueEmpty:
- raise RuntimeError("No message available from server")
+ raise RuntimeError("No message available from server") from None
if msg.get("type") == "websocket.accept":
self._accepted = True
return self._drain_one()
@@ -77,7 +81,7 @@ class TestClient:
def __init__(
self,
- app: Callable,
+ app: ASGIApp,
base_url: str = "http://testserver",
) -> None:
self.app = app
@@ -102,6 +106,7 @@ def _run(self, coro: Any) -> Any:
if loop and loop.is_running():
import concurrent.futures
+
with concurrent.futures.ThreadPoolExecutor() as pool:
return pool.submit(asyncio.run, coro).result()
return asyncio.run(coro)
@@ -158,10 +163,7 @@ def websocket_connect(
scope = {
"type": "websocket",
"path": path,
- "headers": [
- (k.lower().encode(), v.encode())
- for k, v in (headers or {}).items()
- ],
+ "headers": [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()],
"query_string": query_string.encode() if query_string else b"",
"client": ("testclient", 0),
}
diff --git a/FasterAPI/types.py b/FasterAPI/types.py
new file mode 100644
index 0000000..9025c7b
--- /dev/null
+++ b/FasterAPI/types.py
@@ -0,0 +1,11 @@
+"""Shared typing aliases for ASGI callables."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+# ASGI application / handler (scope, receive, send) or route endpoint
+ASGIApp = Callable[..., Any]
+
+__all__ = ["ASGIApp"]
diff --git a/FasterAPI/websocket.py b/FasterAPI/websocket.py
index 265e09f..0ac08d5 100644
--- a/FasterAPI/websocket.py
+++ b/FasterAPI/websocket.py
@@ -1,10 +1,12 @@
from __future__ import annotations
-from typing import Any, Callable
+from typing import Any
from urllib.parse import parse_qs
import msgspec.json
+from .types import ASGIApp
+
class WebSocketState:
"""Enumeration of WebSocket connection states."""
@@ -25,11 +27,18 @@ class WebSocket:
"""Represents a WebSocket connection."""
__slots__ = (
- "_scope", "_receive", "_send", "path", "path_params",
- "client", "headers", "query_params", "_state",
+ "_scope",
+ "_receive",
+ "_send",
+ "path",
+ "path_params",
+ "client",
+ "headers",
+ "query_params",
+ "_state",
)
- def __init__(self, scope: dict, receive: Callable, send: Callable) -> None:
+ def __init__(self, scope: dict[str, Any], receive: ASGIApp, send: ASGIApp) -> None:
self._scope = scope
self._receive = receive
self._send = send
@@ -39,17 +48,11 @@ def __init__(self, scope: dict, receive: Callable, send: Callable) -> None:
self.client: tuple[str, int] | None = scope.get("client")
raw_headers: list[tuple[bytes, bytes]] = scope.get("headers", [])
- self.headers: dict[str, str] = {
- k.decode("latin-1").lower(): v.decode("latin-1")
- for k, v in raw_headers
- }
+ self.headers: dict[str, str] = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in raw_headers}
qs = scope.get("query_string", b"")
parsed = parse_qs(qs.decode("latin-1") if isinstance(qs, bytes) else qs)
- self.query_params: dict[str, Any] = {
- k: v[0] if len(v) == 1 else v
- for k, v in parsed.items()
- }
+ self.query_params: dict[str, Any] = {k: v[0] if len(v) == 1 else v for k, v in parsed.items()}
async def accept(self, subprotocol: str | None = None) -> None:
"""Accept the WebSocket connection, optionally selecting a subprotocol."""
@@ -82,7 +85,7 @@ async def receive_bytes(self) -> bytes:
async def receive_json(self) -> Any:
"""Receive a message from the WebSocket and parse it as JSON."""
text = await self.receive_text()
- return msgspec.json.decode(text.encode()) # type: ignore[return-value]
+ return msgspec.json.decode(text.encode())
async def send_text(self, data: str) -> None:
"""Send a text message through the WebSocket."""
diff --git a/README.md b/README.md
index 790bee0..93d2ead 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,33 @@
# FasterAPI
-[](https://pypi.org/project/faster-api-web/)
-[](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/ci.yml)
-[](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/benchmark.yml)
+[](https://pypi.org/project/faster-api-web/)
+[](https://github.com/FasterApiWeb/FasterAPI/releases)
+[](https://pypi.org/project/faster-api-web/)
+[](https://pypi.org/project/faster-api-web/)
+[](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/ci.yml?query=branch%3Amaster)
+[](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/benchmark.yml?query=branch%3Amaster)
+[](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/docs.yml?query=branch%3Amaster)
+[](https://fasterapiweb.github.io/FasterAPI/)
+[](https://fasterapiweb.github.io/FasterAPI/)
[](https://codecov.io/gh/FasterApiWeb/FasterAPI)
-[](https://www.python.org/downloads/)
-[](LICENSE)
+[](LICENSE)
[](https://ghcr.io/fasterapiweb/fasterapi)
+[](https://test.pypi.org/project/faster-api-web/)
+[](https://github.com/pypa/hatch)
+[](https://github.com/FasterApiWeb/FasterAPI/graphs/contributors)
+[](https://github.com/FasterApiWeb/FasterAPI/commits/master)
+[](https://github.com/MagicStack/uvloop)
+[](https://jcristharif.com/msgspec/)
+[](https://asgi.readthedocs.io/en/latest/)
+[](https://github.com/FasterApiWeb/FasterAPI)
+
+---
+
+**Documentation:** [fasterapiweb.github.io/FasterAPI](https://fasterapiweb.github.io/FasterAPI/) (Python **3.13** first; see [Python 3.13 & compatibility](https://fasterapiweb.github.io/FasterAPI/python-313/))
+**Source code:** [github.com/FasterApiWeb/FasterAPI](https://github.com/FasterApiWeb/FasterAPI)
+**PyPI package:** [`faster-api-web`](https://pypi.org/project/faster-api-web/) β `pip install faster-api-web`
+
+---
**FasterAPI** is a high-performance ASGI web framework for Python,
written for **Python 3.13** first and with graceful fallbacks to 3.12,
@@ -22,6 +43,31 @@ If you already know FastAPI, you already know FasterAPI.
---
+## Acknowledgments
+
+**FastAPI** ([github.com/fastapi/fastapi](https://github.com/fastapi/fastapi)) showed what a modern Python API framework can be. **SebastiΓ‘n RamΓrez** ([@tiangolo](https://github.com/tiangolo)), creator of FastAPI, inspired this project. FasterAPI is independent software with different internals; it is not affiliated with the FastAPI team.
+
+Full credit and links: [Acknowledgments](https://fasterapiweb.github.io/FasterAPI/acknowledgments/) in the docs.
+
+---
+
+## Cite this repository
+
+**Author:** Eshwar Chandra Vidhyasagar Thedla Β· **GitHub:** [@EshwarCVS](https://github.com/EshwarCVS) Β· **Repository:** [FasterApiWeb/FasterAPI](https://github.com/FasterApiWeb/FasterAPI)
+
+GitHub shows a **Cite this repository** button when [`CITATION.cff`](CITATION.cff) is on the default branch. You can also use:
+
+```bibtex
+@software{faster_api_web,
+ author = {Thedla, Eshwar Chandra Vidhyasagar},
+ title = {faster-api-web (FasterAPI): high-performance ASGI web framework for Python},
+ url = {https://github.com/FasterApiWeb/FasterAPI},
+ year = {2026},
+}
+```
+
+---
+
## Why FasterAPI?
FastAPI is excellent, but it carries inherited overhead from Pydantic
@@ -57,7 +103,7 @@ identical developer-facing API:
| **Middleware** | CORS, GZip, TrustedHost, HTTPS | CORS, GZip, TrustedHost, HTTPS |
| **Background tasks** | Built-in `BackgroundTasks` | Built-in `BackgroundTasks` |
| **Test client** | Built-in `TestClient` (httpx) | Via Starlette `TestClient` |
-| **Python version** | 3.13 first, 3.11+ supported | 3.8+ |
+| **Python version** | 3.13 first, 3.10+ supported | 3.8+ |
---
@@ -83,23 +129,45 @@ pip install -e ".[dev]"
### Requirements
+**FasterAPI stands on the shoulders of these libraries** (see also [FastAPIβs βRequirementsβ idea](https://github.com/fastapi/fastapi#requirements)):
+
+- **[msgspec](https://jcristharif.com/msgspec/)** β structs, validation, and JSON encoding.
+- **[uvicorn](https://www.uvicorn.org/)** `[standard]` β ASGI server (pulled in by this package).
+- **[python-multipart](https://github.com/Kludex/python-multipart)** β forms and uploads.
+- Optional: **[uvloop](https://github.com/MagicStack/uvloop)** via `faster-api-web[all]` for lower event-loop overhead on Linux.
+
+**Python:**
+
- **Python 3.13** (recommended) β full sub-interpreter support, faster asyncio
- **Python 3.12** β partial per-interpreter GIL support, ProcessPool fallback
-- **Python 3.11** β minimum supported version, ProcessPool fallback
-- **uvloop** β optional; auto-detected at startup. If not installed,
- stdlib asyncio is used (fast enough on 3.13+)
-- **msgspec** β required; used for validation & JSON encoding
-- **uvicorn** β required; ASGI server
-- **python-multipart** β required; for file uploads and form data
+- **Python 3.10** β minimum supported version, ProcessPool fallback
### Python Version Compatibility
-| Feature | 3.13+ | 3.12 | 3.11 |
+| Feature | 3.13+ | 3.12 | 3.10β3.11 |
|---|---|---|---|
| Sub-interpreters (own GIL) | Native | ProcessPool fallback | ProcessPool fallback |
| asyncio performance | Excellent (PEP 703 prep) | Good | Good |
| uvloop benefit | Optional (~10-15% faster) | Recommended (~2-3x faster) | Recommended (~2-3x faster) |
-| Type syntax (`X \| Y`) | Native | Native | Via `__future__` |
+| Type syntax (`X \| Y`) | Native | Native | Via `__future__` on 3.10 |
+
+---
+
+## Documentation
+
+Tutorials and reference are published from the `docs/` folder with **MkDocs** β same topics as in this README, with **Python 3.13** as the primary target and a dedicated **[compatibility](https://fasterapiweb.github.io/FasterAPI/python-313/)** page for 3.10β3.12.
+
+The live site is deployed by the **[Docs workflow](.github/workflows/docs.yml)** to **GitHub Pages**. In the repository **Settings β Pages β Build and deployment**, the **source must be βGitHub Actionsβ** (not the legacy `gh-pages` branch); otherwise the published URL can 404 even when the workflow succeeds.
+
+---
+
+## Releases and PyPI versions
+
+The **PyPI** package name is **`faster-api-web`**. Published versions are tied to **git tags** on
+`master` (`v0.1.2`, β¦): pushing a tag runs the [Release workflow](.github/workflows/release.yml),
+which builds the wheel/sdist (version comes from the tag via **hatch-vcs**), publishes to **PyPI**,
+and creates a GitHub Release. **You do not bump a hardcoded version in `pyproject.toml` for releases**
+β tag the commit you want to ship.
---
@@ -644,31 +712,6 @@ FasterAPI/
---
-## Contributing
-
-Contributions are welcome.
-
-```bash
-# Clone and install
-git clone https://github.com/FasterApiWeb/FasterAPI.git
-cd FasterAPI
-pip install -e ".[dev]"
-
-# Run tests
-pytest
-
-# Type check
-mypy FasterAPI/
-
-# Run benchmarks
-python benchmarks/compare.py
-```
-
-Please ensure all tests pass and mypy reports no errors before submitting
-a pull request.
-
----
-
## Performance Innovations
FasterAPI achieves its speed through five key architectural decisions:
@@ -676,7 +719,7 @@ FasterAPI achieves its speed through five key architectural decisions:
| Innovation | What It Does | Speedup Source |
|---|---|---|
| **uvloop** | Replaces stdlib asyncio with libuv-backed C event loop | 2-4x faster I/O scheduling |
-| **msgspec** | Rust-backed JSON encode/decode + validation in one pass | 10-20x faster than Pydantic v1 |
+| **msgspec** | C extension JSON encode/decode + validation in one pass | 10-20x faster than Pydantic v1 |
| **Radix tree router** | O(k) path lookup (k = segments) instead of O(n) regex scan | 7.6x faster with 100+ routes |
| **Compiled DI** | Handler signatures introspected once at startup, not per-request | Eliminates ~80% of per-request overhead |
| **Zero-copy responses** | `msgspec.json.encode()` β bytes directly, no intermediate str | 50% fewer memory allocations |
@@ -693,13 +736,13 @@ Incoming Request
Pre-compiled DI Resolver β No inspect.signature() per request
β
βΌ
- msgspec.json.decode() β Rust, one-pass validate + parse
+ msgspec.json.decode() β C extension, one-pass validate + parse
β
βΌ
Handler (uvloop-scheduled) β C event loop, minimal overhead
β
βΌ
- msgspec.json.encode() β Rust, zero-copy to bytes
+ msgspec.json.encode() β C extension, zero-copy to bytes
β
βΌ
Raw bytes β Client
@@ -732,11 +775,24 @@ git push -u origin dev/my-feature
# Open PR β stage
```
-CI automatically runs **tests on Python 3.10β3.13** and **benchmarks** on every PR.
-The benchmark bot comments with a comparison table β π’ improved, βͺ neutral, π΄ regression.
+CI automatically runs **tests on Python 3.10β3.13** (coverage must stay **β₯ 85%**) and **benchmarks**
+on every PR. The benchmark workflow enforces **ASGI and routing floors** from `benchmarks/baseline.json`
+and posts a table comparing **FasterAPI, FastAPI, and Fiber (Go)**.
After approval β merge to `stage`.
+### Local checks
+
+```bash
+git clone https://github.com/FasterApiWeb/FasterAPI.git && cd FasterAPI
+pip install -e ".[dev]"
+pytest --cov=FasterAPI --cov-report=term-missing --cov-fail-under=85
+mypy FasterAPI/
+pip install -e ".[benchmark]" && python benchmarks/compare.py --direct
+```
+
+For docs: `pip install -e ".[docs]" && mkdocs serve`. Security reports: see [SECURITY.md](SECURITY.md).
+
**As the maintainer (release cycle):**
```bash
# Open PR: stage β master
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..b0bc5f8
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,29 @@
+# Security policy
+
+## Supported versions
+
+Security fixes are applied to the **latest minor release** on the **`master`** branch.
+Older tags are best-effort unless a severe issue affects long-term users.
+
+| Version | Supported |
+|---------|-----------|
+| 0.1.x | Yes |
+| < 0.1 | No |
+
+## Reporting a vulnerability
+
+**Please do not open a public GitHub issue** for undisclosed security problems.
+
+Instead, email **thedla.ecvs@gmail.com** with:
+
+- A short description of the issue and its impact
+- Steps to reproduce or a proof-of-concept, if you can share one
+- Affected versions or commit hashes, if known
+
+We aim to acknowledge reports within **a few business days** and to coordinate a fix and
+release before public disclosure when appropriate.
+
+## Disclosure
+
+We follow responsible disclosure: we will credit reporters who wish to be named in the
+changelog or release notes unless they prefer to stay anonymous.
diff --git a/benchmarks/baseline.json b/benchmarks/baseline.json
new file mode 100644
index 0000000..807260f
--- /dev/null
+++ b/benchmarks/baseline.json
@@ -0,0 +1,9 @@
+{
+ "_comment": "CI fails if measured speedups drop below these floors (Ubuntu runner). Update when intentional perf work lands.",
+ "min_speedup_vs_fastapi": {
+ "health": 3.8,
+ "users_get": 4.5,
+ "users_post": 3.8
+ },
+ "min_radix_speedup_vs_regex": 4.0
+}
diff --git a/benchmarks/check_regressions.py b/benchmarks/check_regressions.py
new file mode 100644
index 0000000..71a2035
--- /dev/null
+++ b/benchmarks/check_regressions.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""Fail if ASGI or routing benchmarks regress below baselines/baseline.json."""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parent.parent
+if str(_ROOT) not in sys.path:
+ sys.path.insert(0, str(_ROOT))
+
+from benchmarks.compare import measure_direct_asgi_rps, measure_routing_ops
+
+
+def main() -> int:
+ base_path = Path(__file__).parent / "baseline.json"
+ baseline = json.loads(base_path.read_text(encoding="utf-8"))
+ mins = baseline["min_speedup_vs_fastapi"]
+ min_route = baseline["min_radix_speedup_vs_regex"]
+
+ asgi = measure_direct_asgi_rps(iterations=25_000)
+ routing = measure_routing_ops()
+
+ errors: list[str] = []
+ for key, label in [
+ ("health", "GET /health"),
+ ("users_get", "GET /users/{id}"),
+ ("users_post", "POST /users"),
+ ]:
+ sp = asgi[key]["speedup"]
+ need = mins[key]
+ if sp + 1e-9 < need:
+ errors.append(f"{label}: speedup {sp:.2f}x < floor {need}x")
+
+ if routing["speedup"] + 1e-9 < min_route:
+ errors.append(
+ f"Routing: radix/regex speedup {routing['speedup']:.2f}x < floor {min_route}x",
+ )
+
+ if errors:
+ print("Benchmark regression guard FAILED:", file=sys.stderr)
+ for e in errors:
+ print(f" - {e}", file=sys.stderr)
+ return 1
+
+ print("Benchmark regression guard OK")
+ for key in mins:
+ print(f" {key}: {asgi[key]['speedup']:.2f}x (floor {mins[key]}x)")
+ print(f" routing: {routing['speedup']:.2f}x (floor {min_route}x)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/compare.py b/benchmarks/compare.py
index 766be93..7fe1f70 100644
--- a/benchmarks/compare.py
+++ b/benchmarks/compare.py
@@ -14,13 +14,16 @@
import multiprocessing
import os
import socket
+import subprocess
import sys
import time
-from typing import Any, Optional
+from typing import TYPE_CHECKING, Any
-import httpx
+if TYPE_CHECKING:
+ import httpx
# Ensure the project root is on sys.path so child processes can import FasterAPI
+# httpx is imported lazily inside HTTP benchmark paths so `check_regressions` can run with dev-only deps.
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
@@ -30,17 +33,23 @@
# App factories (each runs in its own process)
# βββββββββββββββββββββββββββββββββββββββββββββββ
+
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
return s.getsockname()[1]
+def _fiber_binary_path() -> str | None:
+ name = "fiberbench.exe" if os.name == "nt" else "fiberbench"
+ p = os.path.join(_PROJECT_ROOT, "benchmarks", "fiber", name)
+ return p if os.path.isfile(p) else None
+
+
def _run_fasterapi(port: int, ready: multiprocessing.Event) -> None:
"""Launch a FasterAPI (Faster) server in this process."""
- import uvicorn
import msgspec
-
+ import uvicorn
from FasterAPI.app import Faster
class User(msgspec.Struct):
@@ -97,7 +106,10 @@ async def create_user(user: User):
# Benchmark runner
# βββββββββββββββββββββββββββββββββββββββββββββββ
+
async def _wait_for_server(url: str, timeout: float = 10.0) -> None:
+ import httpx
+
deadline = time.monotonic() + timeout
async with httpx.AsyncClient() as client:
while time.monotonic() < deadline:
@@ -112,13 +124,14 @@ async def _wait_for_server(url: str, timeout: float = 10.0) -> None:
async def _benchmark_endpoint(
- client: httpx.AsyncClient,
+ client: "httpx.AsyncClient",
method: str,
path: str,
total: int,
concurrency: int,
- json_body: Optional[dict] = None,
+ json_body: dict | None = None,
) -> dict[str, Any]:
+
latencies: list[float] = []
errors = 0
semaphore = asyncio.Semaphore(concurrency)
@@ -159,9 +172,110 @@ async def _fire() -> None:
}
+async def measure_http_rps_three_way(
+ total: int,
+ concurrency: int,
+ fiber_executable: str | None = None,
+) -> tuple[dict[str, dict[str, float]], str | None]:
+ """Run the same HTTP load against FasterAPI, FastAPI, and (optional) Go Fiber."""
+ fiber_exe = fiber_executable or _fiber_binary_path()
+ port_faster = _find_free_port()
+ port_fastapi = _find_free_port()
+ port_fiber = _find_free_port()
+ if fiber_exe and port_fiber in (port_faster, port_fastapi):
+ port_fiber = _find_free_port()
+
+ ready_faster = multiprocessing.Event()
+ ready_fastapi = multiprocessing.Event()
+
+ proc_faster = multiprocessing.Process(
+ target=_run_fasterapi,
+ args=(port_faster, ready_faster),
+ daemon=True,
+ )
+ proc_fastapi = multiprocessing.Process(
+ target=_run_fastapi,
+ args=(port_fastapi, ready_fastapi),
+ daemon=True,
+ )
+ proc_fiber: subprocess.Popen | None = None
+ if fiber_exe:
+ env = os.environ.copy()
+ env["PORT"] = str(port_fiber)
+ proc_fiber = subprocess.Popen(
+ [fiber_exe],
+ env=env,
+ cwd=os.path.join(_PROJECT_ROOT, "benchmarks", "fiber"),
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+ proc_faster.start()
+ proc_fastapi.start()
+
+ fiber_err: str | None = None
+ try:
+ ready_faster.wait(timeout=15)
+ ready_fastapi.wait(timeout=15)
+
+ base_faster = f"http://127.0.0.1:{port_faster}"
+ base_fastapi = f"http://127.0.0.1:{port_fastapi}"
+
+ await _wait_for_server(f"{base_faster}/health")
+ await _wait_for_server(f"{base_fastapi}/health")
+
+ if proc_fiber:
+ try:
+ await _wait_for_server(f"http://127.0.0.1:{port_fiber}/health")
+ except TimeoutError as e:
+ fiber_err = str(e)
+ proc_fiber.terminate()
+ proc_fiber = None
+
+ faster_res = await _run_all_benchmarks(base_faster, total, concurrency)
+ fastapi_res = await _run_all_benchmarks(base_fastapi, total, concurrency)
+ fiber_res: dict[str, dict[str, Any]] = {}
+ if proc_fiber:
+ fiber_res = await _run_all_benchmarks(
+ f"http://127.0.0.1:{port_fiber}",
+ total,
+ concurrency,
+ )
+
+ out: dict[str, dict[str, float]] = {}
+ for key in ("health", "users_get", "users_post"):
+ f_rps = float(faster_res[key]["rps"])
+ fa_rps = float(fastapi_res[key]["rps"])
+ entry: dict[str, float] = {
+ "fasterapi": f_rps,
+ "fastapi": fa_rps,
+ "speedup": f_rps / fa_rps if fa_rps > 0 else 0.0,
+ }
+ if proc_fiber and key in fiber_res:
+ entry["fiber"] = float(fiber_res[key]["rps"])
+ out[key] = entry
+
+ return out, fiber_err
+ finally:
+ proc_faster.terminate()
+ proc_fastapi.terminate()
+ proc_faster.join(timeout=5)
+ proc_fastapi.join(timeout=5)
+ if proc_fiber:
+ proc_fiber.terminate()
+ try:
+ proc_fiber.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc_fiber.kill()
+
+
async def _run_all_benchmarks(
- base_url: str, total: int, concurrency: int,
+ base_url: str,
+ total: int,
+ concurrency: int,
) -> dict[str, dict[str, Any]]:
+ import httpx
+
body = {"name": "Alice", "email": "alice@test.com"}
results = {}
async with httpx.AsyncClient(base_url=base_url, timeout=30.0) as client:
@@ -180,6 +294,7 @@ async def _run_all_benchmarks(
# Comparison table
# βββββββββββββββββββββββββββββββββββββββββββββββ
+
def _print_header(total: int, concurrency: int) -> None:
print()
print("=" * 78)
@@ -212,8 +327,7 @@ def _print_summary(faster_results: dict, fastapi_results: dict) -> None:
f = faster_results[endpoint]
fa = fastapi_results[endpoint]
speedup = f["rps"] / fa["rps"] if fa["rps"] > 0 else float("inf")
- print(f" {label:<30} {speedup:>6.2f}x faster "
- f"({f['rps']:,.0f} vs {fa['rps']:,.0f} req/s)")
+ print(f" {label:<30} {speedup:>6.2f}x faster ({f['rps']:,.0f} vs {fa['rps']:,.0f} req/s)")
print()
print(" Note: For Fiber (Go) comparison, use wrk/bombardier against")
print(" a Fiber app on the same machine. Typical Fiber numbers are")
@@ -228,6 +342,7 @@ def _print_summary(faster_results: dict, fastapi_results: dict) -> None:
# Main
# βββββββββββββββββββββββββββββββββββββββββββββββ
+
def main(total: int = 10_000, concurrency: int = 100) -> None:
port_faster = _find_free_port()
@@ -237,10 +352,14 @@ def main(total: int = 10_000, concurrency: int = 100) -> None:
ready_fastapi = multiprocessing.Event()
proc_faster = multiprocessing.Process(
- target=_run_fasterapi, args=(port_faster, ready_faster), daemon=True,
+ target=_run_fasterapi,
+ args=(port_faster, ready_faster),
+ daemon=True,
)
proc_fastapi = multiprocessing.Process(
- target=_run_fastapi, args=(port_fastapi, ready_fastapi), daemon=True,
+ target=_run_fastapi,
+ args=(port_fastapi, ready_fastapi),
+ daemon=True,
)
proc_faster.start()
@@ -265,8 +384,12 @@ def main(total: int = 10_000, concurrency: int = 100) -> None:
fastapi_results = asyncio.run(_run_all_benchmarks(base_fastapi, total, concurrency))
_print_table("GET /health β simple JSON response", faster_results["health"], fastapi_results["health"])
- _print_table("GET /users/{id} β path parameter extraction", faster_results["users_get"], fastapi_results["users_get"])
- _print_table("POST /users β JSON body parsing & validation", faster_results["users_post"], fastapi_results["users_post"])
+ _print_table(
+ "GET /users/{id} β path parameter extraction", faster_results["users_get"], fastapi_results["users_get"]
+ )
+ _print_table(
+ "POST /users β JSON body parsing & validation", faster_results["users_post"], fastapi_results["users_post"]
+ )
_print_summary(faster_results, fastapi_results)
@@ -277,11 +400,9 @@ def main(total: int = 10_000, concurrency: int = 100) -> None:
proc_fastapi.join(timeout=3)
-def direct_benchmark() -> None:
- """ASGI-level benchmark β no network, no httpx, pure framework speed."""
- import json as _json
+def _build_asgi_pair():
+ """Return (faster_app, fastapi_app) for micro-benchmarks."""
import msgspec as _msgspec
-
from FasterAPI.app import Faster
class UserF(_msgspec.Struct):
@@ -305,9 +426,8 @@ async def _fp(user: UserF):
try:
from fastapi import FastAPI
from pydantic import BaseModel
- except ImportError:
- print(" FastAPI/Pydantic not installed β skipping direct comparison")
- return
+ except ImportError as e:
+ raise RuntimeError("FastAPI/Pydantic required for comparison") from e
class UserP(BaseModel):
name: str
@@ -327,13 +447,149 @@ async def _fag(user_id: str):
async def _fap(user: UserP):
return {"name": user.name, "email": user.email}
+ return fapp, faapp
+
+
+def measure_direct_asgi_rps(iterations: int = 50_000) -> dict[str, dict[str, float]]:
+ """Run direct ASGI benchmark and return req/s and speedup (for CI guards)."""
+ import json as _json
+
+ fapp, faapp = _build_asgi_pair()
+ N = iterations
+
+ async def _make_scope(method: str, path: str, body: dict | None = None):
+ scope = {
+ "type": "http",
+ "method": method,
+ "path": path,
+ "query_string": b"",
+ "headers": [
+ (b"content-type", b"application/json"),
+ (b"host", b"localhost"),
+ ],
+ "client": ("127.0.0.1", 9999),
+ }
+ body_bytes = _json.dumps(body).encode() if body else b""
+ sent: list[dict] = []
+
+ async def receive():
+ return {"type": "http.request", "body": body_bytes, "more_body": False}
+
+ async def send(msg: dict):
+ sent.append(msg)
+
+ return scope, receive, send
+
+ async def _bench(app, method: str, path: str, body=None) -> float:
+ for _ in range(200):
+ s, r, sn = await _make_scope(method, path, body)
+ await app(s, r, sn)
+ start = time.perf_counter()
+ for _ in range(N):
+ s, r, sn = await _make_scope(method, path, body)
+ await app(s, r, sn)
+ return N / (time.perf_counter() - start)
+
+ async def _run():
+ body = {"name": "Alice", "email": "alice@test.com"}
+ out: dict[str, dict[str, float]] = {}
+ for key, m, p, b in [
+ ("health", "GET", "/health", None),
+ ("users_get", "GET", "/users/42", None),
+ ("users_post", "POST", "/users", body),
+ ]:
+ f_rps = await _bench(fapp, m, p, b)
+ fa_rps = await _bench(faapp, m, p, b)
+ out[key] = {
+ "fasterapi": f_rps,
+ "fastapi": fa_rps,
+ "speedup": f_rps / fa_rps if fa_rps > 0 else 0.0,
+ }
+ return out
+
+ return asyncio.run(_run())
+
+
+def measure_routing_ops() -> dict[str, float]:
+ """Radix vs regex routing throughput (same workload as CI benchmark)."""
+ import re
+
+ from FasterAPI.router import RadixRouter
+
+ router = RadixRouter()
+ for i in range(50):
+ router.add_route("GET", f"/static/route{i}", lambda: None, {})
+ for i in range(30):
+ router.add_route("GET", f"/users/{{id}}/action{i}", lambda: None, {})
+ for i in range(20):
+ router.add_route(
+ "GET",
+ f"/org/{{org_id}}/team/{{team_id}}/member{i}",
+ lambda: None,
+ {},
+ )
+
+ paths = ["/static/route25", "/users/42/action15", "/org/abc/team/xyz/member10"]
+ N = 500_000
+ start = time.perf_counter()
+ for _ in range(N):
+ for p in paths:
+ router.resolve("GET", p)
+ radix_ops = (N * 3) / (time.perf_counter() - start)
+
+ regex_routes = []
+ for i in range(50):
+ regex_routes.append((re.compile(f"^/static/route{i}$"), None))
+ for i in range(30):
+ regex_routes.append(
+ (re.compile(r"^/users/(\w+)/action" + str(i) + r"$"), None),
+ )
+ for i in range(20):
+ regex_routes.append(
+ (re.compile(r"^/org/(\w+)/team/(\w+)/member" + str(i) + r"$"), None),
+ )
+
+ def regex_resolve(path: str):
+ for p, h in regex_routes:
+ m = p.match(path)
+ if m:
+ return h, m.groups()
+ return None, ()
+
+ start = time.perf_counter()
+ for _ in range(N):
+ for p in paths:
+ regex_resolve(p)
+ regex_ops = (N * 3) / (time.perf_counter() - start)
+
+ return {
+ "radix": radix_ops,
+ "regex": regex_ops,
+ "speedup": radix_ops / regex_ops if regex_ops > 0 else 0.0,
+ }
+
+
+def direct_benchmark() -> None:
+ """ASGI-level benchmark β no network, no httpx, pure framework speed."""
+ try:
+ fapp, faapp = _build_asgi_pair()
+ except RuntimeError as e:
+ print(f" {e}")
+ return
+
+ import json as _json
+
N = 50_000
async def _make_scope(method: str, path: str, body: dict | None = None):
scope = {
- "type": "http", "method": method, "path": path,
- "query_string": b"", "headers": [
- (b"content-type", b"application/json"), (b"host", b"localhost"),
+ "type": "http",
+ "method": method,
+ "path": path,
+ "query_string": b"",
+ "headers": [
+ (b"content-type", b"application/json"),
+ (b"host", b"localhost"),
],
"client": ("127.0.0.1", 9999),
}
@@ -373,8 +629,7 @@ async def _run():
]:
f_rps = await _bench(fapp, m, p, b)
fa_rps = await _bench(faapp, m, p, b)
- print(f" {label:<28} {f_rps:>10,.0f}/s {fa_rps:>10,.0f}/s"
- f" {f_rps / fa_rps:>9.2f}x")
+ print(f" {label:<28} {f_rps:>10,.0f}/s {fa_rps:>10,.0f}/s {f_rps / fa_rps:>9.2f}x")
print()
print("=" * 72)
print()
@@ -386,8 +641,7 @@ async def _run():
parser = argparse.ArgumentParser(description="FasterAPI vs FastAPI benchmark")
parser.add_argument("--requests", type=int, default=10_000)
parser.add_argument("--concurrency", type=int, default=100)
- parser.add_argument("--direct", action="store_true",
- help="Run direct ASGI benchmark (no HTTP server)")
+ parser.add_argument("--direct", action="store_true", help="Run direct ASGI benchmark (no HTTP server)")
args = parser.parse_args()
if args.direct:
diff --git a/benchmarks/export_pr_benchmarks.py b/benchmarks/export_pr_benchmarks.py
new file mode 100644
index 0000000..4da62b8
--- /dev/null
+++ b/benchmarks/export_pr_benchmarks.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+"""Write benchmark JSON files for the PR comment workflow."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parent.parent
+if str(_ROOT) not in sys.path:
+ sys.path.insert(0, str(_ROOT))
+
+from benchmarks.compare import (
+ measure_direct_asgi_rps,
+ measure_http_rps_three_way,
+ measure_routing_ops,
+)
+
+
+def main() -> None:
+ cwd = Path.cwd()
+ asgi = measure_direct_asgi_rps(iterations=50_000)
+ routing = measure_routing_ops()
+ http_three, fiber_err = asyncio.run(measure_http_rps_three_way(5_000, 50))
+
+ (cwd / "bench_results.json").write_text(
+ json.dumps(http_three, indent=0),
+ encoding="utf-8",
+ )
+ (cwd / "asgi_micro.json").write_text(
+ json.dumps(asgi, indent=0),
+ encoding="utf-8",
+ )
+ (cwd / "routing_results.json").write_text(
+ json.dumps(routing, indent=0),
+ encoding="utf-8",
+ )
+ warn_path = cwd / "fiber_warn.txt"
+ if fiber_err:
+ warn_path.write_text(fiber_err, encoding="utf-8")
+ elif warn_path.exists():
+ warn_path.unlink()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/benchmarks/fiber/.gitignore b/benchmarks/fiber/.gitignore
new file mode 100644
index 0000000..a56a90f
--- /dev/null
+++ b/benchmarks/fiber/.gitignore
@@ -0,0 +1,2 @@
+fiberbench
+fiberbench.exe
diff --git a/benchmarks/fiber/go.mod b/benchmarks/fiber/go.mod
new file mode 100644
index 0000000..a234988
--- /dev/null
+++ b/benchmarks/fiber/go.mod
@@ -0,0 +1,19 @@
+module github.com/FasterApiWeb/FasterAPI/benchmarks/fiber
+
+go 1.22
+
+require github.com/gofiber/fiber/v2 v2.52.5
+
+require (
+ github.com/andybalholm/brotli v1.0.5 // indirect
+ github.com/google/uuid v1.5.0 // indirect
+ github.com/klauspost/compress v1.17.0 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.15 // indirect
+ github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasthttp v1.51.0 // indirect
+ github.com/valyala/tcplisten v1.0.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+)
diff --git a/benchmarks/fiber/go.sum b/benchmarks/fiber/go.sum
new file mode 100644
index 0000000..fab6978
--- /dev/null
+++ b/benchmarks/fiber/go.sum
@@ -0,0 +1,27 @@
+github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
+github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
+github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
+github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
+github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
+github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
+github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
+github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
+github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
+github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
diff --git a/benchmarks/fiber/main.go b/benchmarks/fiber/main.go
new file mode 100644
index 0000000..bba1d2e
--- /dev/null
+++ b/benchmarks/fiber/main.go
@@ -0,0 +1,53 @@
+// HTTP server matching Python benchmark routes (health, user get, user create).
+package main
+
+import (
+ "os"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+type userBody struct {
+ Name string `json:"name"`
+ Email string `json:"email"`
+}
+
+func main() {
+ app := fiber.Config{
+ Prefork: false,
+ CaseSensitive: true,
+ StrictRouting: true,
+ }
+ f := fiber.New(app)
+
+ f.Get("/health", func(c *fiber.Ctx) error {
+ return c.JSON(fiber.Map{"status": "ok"})
+ })
+
+ f.Get("/users/:user_id", func(c *fiber.Ctx) error {
+ id := c.Params("user_id")
+ return c.JSON(fiber.Map{"id": id, "name": "test"})
+ })
+
+ f.Post("/users", func(c *fiber.Ctx) error {
+ var body userBody
+ if err := c.BodyParser(&body); err != nil {
+ return c.Status(fiber.StatusBadRequest).SendString(err.Error())
+ }
+ return c.JSON(fiber.Map{"name": body.Name, "email": body.Email})
+ })
+
+ _ = f.Listen(":" + listenPort())
+}
+
+func listenPort() string {
+ p := getenv("PORT", "3099")
+ return p
+}
+
+func getenv(k, def string) string {
+ if v := os.Getenv(k); v != "" {
+ return v
+ }
+ return def
+}
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000..88fc6c6
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,14 @@
+# https://docs.codecov.com/docs/codecovyml-reference
+coverage:
+ range: 70..100
+ precision: 2
+ status:
+ project:
+ default:
+ target: auto
+ threshold: 0%
+ patch:
+ default:
+ informational: true
+
+comment: false
diff --git a/docs/acknowledgments.md b/docs/acknowledgments.md
new file mode 100644
index 0000000..3bd7ba0
--- /dev/null
+++ b/docs/acknowledgments.md
@@ -0,0 +1,16 @@
+# Acknowledgments
+
+## FastAPI and SebastiΓ‘n RamΓrez ([@tiangolo](https://github.com/tiangolo))
+
+**FasterAPI** ([`faster-api-web` on PyPI](https://pypi.org/project/faster-api-web/)) takes direct inspiration from **[FastAPI](https://github.com/fastapi/fastapi)** β the API style, the focus on OpenAPI, and the idea that Python web APIs can be both ergonomic and fast.
+
+**SebastiΓ‘n RamΓrez** created and leads **FastAPI**. This project exists in that lineage: a thank-you for showing what a modern Python API framework can look like, and for the ecosystem around it.
+
+- FastAPI repository: [github.com/fastapi/fastapi](https://github.com/fastapi/fastapi)
+- FastAPI documentation: [fastapi.tiangolo.com](https://fastapi.tiangolo.com)
+
+FasterAPI is a **separate** project with different internals (e.g. **msgspec**, radix routing); it is **not** affiliated with or endorsed by the FastAPI maintainers.
+
+## Other building blocks
+
+FasterAPI builds on **[msgspec](https://jcristharif.com/msgspec/)**, **[uvicorn](https://www.uvicorn.org/)**, and the **ASGI** spec, among others β see the [README](https://github.com/FasterApiWeb/FasterAPI#installation) for the full dependency picture.
diff --git a/docs/api-reference.md b/docs/api-reference.md
new file mode 100644
index 0000000..2a7eb34
--- /dev/null
+++ b/docs/api-reference.md
@@ -0,0 +1,6 @@
+# API reference
+
+Generated from the installed package. For the full list of re-exports, see
+[`FasterAPI/__init__.py`](https://github.com/FasterApiWeb/FasterAPI/blob/master/FasterAPI/__init__.py).
+
+::: FasterAPI
diff --git a/docs/benchmarks.md b/docs/benchmarks.md
new file mode 100644
index 0000000..6d914ec
--- /dev/null
+++ b/docs/benchmarks.md
@@ -0,0 +1,49 @@
+# Benchmarks
+
+CI publishes numbers from **Python 3.13** on Linux; local runs may differ. Results depend on **hardware**, **Python version**, and **server settings**. Treat absolute
+**requests per second** as indicative; compare **ratios** (e.g. FasterAPI vs FastAPI on the
+same machine) for regressions.
+
+## What we compare
+
+1. **FasterAPI** β this project (`Faster`), ASGI app under uvicorn where noted.
+2. **FastAPI** β same route shapes and payload sizes.
+3. **Fiber (Go)** β a tiny Go service in `benchmarks/fiber` with matching routes, for HTTP-level comparison.
+
+Python frameworks are compared in two ways:
+
+- **Direct ASGI:** invokes the ASGI callable in-process (no TCP stack). Stresses routing,
+ validation, and serialization.
+- **HTTP (httpx):** same client load against **uvicorn** for Python and Fiberβs HTTP server.
+ Includes network stack cost on localhost.
+
+## Routing micro-benchmark
+
+The **radix tree** router is compared to a **regex** approach that mirrors many frameworks:
+many compiled patterns, match until one succeeds. The workload performs a fixed number of
+lookups on representative paths.
+
+## Reproduce locally
+
+```bash
+pip install -e ".[dev,benchmark]"
+# Direct ASGI + optional full HTTP comparison (starts local servers)
+python benchmarks/compare.py --direct
+python benchmarks/compare.py --requests 10000 --concurrency 100
+```
+
+**Fiber (Go):**
+
+```bash
+cd benchmarks/fiber
+go build -o fiberbench .
+PORT=3099 ./fiberbench
+```
+
+Regression floors used in CI live in **`benchmarks/baseline.json`** and are enforced by
+**`benchmarks/check_regressions.py`** (ASGI speedup vs FastAPI and radix-vs-regex speedup).
+
+## CI
+
+Pull requests run the benchmark workflow: it **fails** if those floors are breached and posts
+a comment with the latest numbers including **Fiber** when the Go binary builds successfully.
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..b0a0b8b
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,67 @@
+# Getting started
+
+Use **Python 3.13** if you can (see [Python 3.13 & compatibility](python-313.md)); the minimum
+supported release is **3.10**. Create a **virtual environment** before installing dependencies.
+
+## Install
+
+```bash
+pip install faster-api-web
+```
+
+Optional **uvloop** (recommended on Linux for lower event-loop overhead):
+
+```bash
+pip install faster-api-web[all]
+```
+
+For **`TestClient`** (integration tests), add **httpx**:
+
+```bash
+pip install faster-api-web[test]
+```
+
+## Minimal application
+
+Create `main.py`:
+
+```python
+import msgspec
+from FasterAPI import Faster
+
+app = Faster()
+
+
+class Item(msgspec.Struct):
+ name: str
+ price: float
+
+
+@app.get("/items/{item_id}")
+async def read_item(item_id: str):
+ return {"item_id": item_id}
+
+
+@app.post("/items")
+async def create_item(item: Item):
+ return {"received": item}
+```
+
+## Run with Uvicorn
+
+```bash
+uvicorn main:app --reload
+```
+
+Open `http://127.0.0.1:8000/docs` for the interactive OpenAPI UI (if enabled).
+
+## Imports at a glance
+
+- **Application class:** `from FasterAPI import Faster` (or `from FasterAPI.app import Faster`).
+- **Path/query/body helpers:** `Path`, `Query`, `Body`, β¦ from `FasterAPI`.
+- **Models:** use **`msgspec.Struct`**, not Pydantic, for validated JSON bodies by default.
+
+## Next steps
+
+- Follow the [CRUD tutorial](tutorial-crud.md).
+- If you already use FastAPI, read [Migrating from FastAPI](migration-from-fastapi.md).
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..cb446d0
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,34 @@
+# FasterAPI
+
+**PyPI package:** [`faster-api-web`](https://pypi.org/project/faster-api-web/) β `pip install faster-api-web`
+**Documentation** assumes **Python 3.10+**, with examples tuned for **Python 3.13** (see [Python 3.13 & compatibility](python-313.md) for fallbacks on older versions).
+
+FasterAPI is an **ASGI web framework** that keeps a **FastAPI-like API** while swapping heavy
+internals for faster building blocks: **msgspec** for models and JSON, a **radix router** for paths,
+and **uvloop** where it helps.
+
+Use this site for **installation**, a **CRUD tutorial**, **migration notes** from FastAPI,
+**benchmark methodology**, and the **API reference**. Inspiration and credit for the FastAPI
+ecosystem are on the [Acknowledgments](acknowledgments.md) page.
+
+!!! tip "Install from PyPI"
+
+ ```bash
+ pip install faster-api-web
+ ```
+
+ Package name on PyPI is **`faster-api-web`**; you import **`FasterAPI`** in Python.
+
+## When to choose it
+
+- You want **routing and request handling** that stays fast as you add more routes.
+- You are fine defining request/response bodies with **msgspec.Struct** instead of Pydantic `BaseModel`.
+- You want to stay close to **FastAPI patterns** (`get`/`post`, `Depends`, `HTTPException`, OpenAPI docs).
+
+## Learn next
+
+- [Getting started](getting-started.md) β minimal app and dev server
+- [Tutorial: CRUD app](tutorial-crud.md) β in-memory REST API
+- [Migrating from FastAPI](migration-from-fastapi.md) β practical renames and model changes
+- [Benchmarks](benchmarks.md) β what we measure and how to reproduce results
+- [API reference](api-reference.md) β `Faster`, parameters, responses, middleware
diff --git a/docs/migration-from-fastapi.md b/docs/migration-from-fastapi.md
new file mode 100644
index 0000000..1c3204f
--- /dev/null
+++ b/docs/migration-from-fastapi.md
@@ -0,0 +1,76 @@
+# Migrating from FastAPI
+
+FasterAPI is **not** a line-for-line fork of FastAPI, but many concepts map directly.
+Plan on touching **imports**, **model types**, and any code that assumed **Pydantic** or
+**Starlette** internals.
+
+## 1. Install and import
+
+| FastAPI | FasterAPI |
+|--------|-----------|
+| `pip install fastapi` | `pip install faster-api-web` |
+| `from fastapi import FastAPI` | `from FasterAPI import Faster` |
+| `app = FastAPI()` | `app = Faster()` |
+
+The PyPI distribution name is **`faster-api-web`**. The Python package directory is **`FasterAPI`**
+(capital **F** and **API**).
+
+## 2. Replace Pydantic models with msgspec
+
+Validation and JSON encoding use **msgspec** structs:
+
+```python
+# FastAPI
+from pydantic import BaseModel
+
+class User(BaseModel):
+ name: str
+ email: str
+```
+
+```python
+# FasterAPI
+import msgspec
+
+class User(msgspec.Struct):
+ name: str
+ email: str
+```
+
+**Field defaults**, **optional** fields, and **nested** structs work with msgspecβs usual rules.
+If you relied on Pydantic-specific validators (`@field_validator`) or complex JSON Schema,
+reimplement that logic with plain Python or narrow msgspec types.
+
+## 3. Routing and decorators
+
+`@app.get`, `@app.post`, `APIRouter`-style grouping, path parameters, and `Depends()` are
+intended to feel familiar. Differences tend to be **edge cases** (custom Starlette routes,
+advanced middleware ordering). Port routes one module at a time and run tests.
+
+## 4. Exceptions and responses
+
+`HTTPException`, JSON responses, redirects, and file responses have near-equivalent types
+under `FasterAPI`. Check response **headers** and **status codes** in integration tests after
+migration.
+
+## 5. OpenAPI and docs
+
+OpenAPI generation is supported; field metadata may differ slightly from Pydanticβs schema
+extras. Regenerate your client or inspect `/openapi.json` after switching models.
+
+## 6. Testing
+
+Use **`TestClient`** from **`FasterAPI`** (httpx-based), similar to Starletteβs test client.
+Install **`httpx`** in the environment where you run tests (`pip install httpx` β it is included in
+the projectβs **dev** extras but not in the minimal runtime install). Update imports and rerun your
+suite; fix any tests that imported Starlette types directly.
+
+## Suggested order of work
+
+1. Add **FasterAPI** beside FastAPI in a branch; swap the app factory and deps.
+2. Convert **models** to `msgspec.Struct` and fix type errors.
+3. Run **tests** and fix request/response assumptions.
+4. Measure **latency/throughput** in staging if performance is a goal.
+
+If something you need is missing, open an issue with a minimal reproduction against
+the [GitHub repo](https://github.com/FasterApiWeb/FasterAPI).
diff --git a/docs/python-313.md b/docs/python-313.md
new file mode 100644
index 0000000..cf069d1
--- /dev/null
+++ b/docs/python-313.md
@@ -0,0 +1,32 @@
+# Python 3.13 and compatibility
+
+All **tutorials and examples** in this documentation are written for **Python 3.13**: that is the
+version we recommend for new projects and what CI uses as the primary interpreter.
+
+## Install the `faster-api-web` package
+
+Everything starts from PyPI β the distribution name is **`faster-api-web`**:
+
+```bash
+pip install faster-api-web
+```
+
+Imports use the **`FasterAPI`** package name in code (`from FasterAPI import Faster`).
+
+## Why 3.13 first
+
+- **Better asyncio** performance and ongoing runtime improvements.
+- **Sub-interpreters** (where available) for CPU-bound work with a model closer to multiple GILs;
+ see the main README and [Benchmarks](benchmarks.md) for details.
+
+## Fallbacks (3.10, 3.11, 3.12)
+
+The project supports **`requires-python >= 3.10`**. On older versions:
+
+| Area | Behaviour on 3.10β3.12 |
+|------|-------------------------|
+| **CPU-bound helpers** | Falls back to **process pool** (and similar) instead of sub-interpreters where 3.13 APIs are unavailable. |
+| **uvloop** | Still recommended on Linux for I/O-heavy apps; optional everywhere. |
+| **Syntax in docs** | Examples use modern syntax (e.g. `list[str]`, `str \| None`) that works on 3.10+ with normal imports; on **3.10** you may need `from __future__ import annotations` in some files. |
+
+If something behaves differently on an older interpreter, open an issue with your **exact Python version**.
diff --git a/docs/tutorial-crud.md b/docs/tutorial-crud.md
new file mode 100644
index 0000000..7446525
--- /dev/null
+++ b/docs/tutorial-crud.md
@@ -0,0 +1,89 @@
+# Tutorial: build a CRUD app
+
+A small **in-memory** REST API for items. It shows routing, JSON bodies with **msgspec**,
+and standard HTTP verbs.
+
+## Complete example
+
+```python
+from __future__ import annotations
+
+import msgspec
+from FasterAPI import Faster, HTTPException, Path
+
+app = Faster()
+
+# In-memory store (demo only β data is lost when the process exits)
+_db: dict[int, "Item"] = {}
+_next_id = 1
+
+
+class ItemCreate(msgspec.Struct):
+ name: str
+ description: str = ""
+
+
+class Item(msgspec.Struct):
+ id: int
+ name: str
+ description: str
+
+
+@app.get("/items")
+async def list_items() -> list[Item]:
+ return list(_db.values())
+
+
+@app.get("/items/{item_id}")
+async def get_item(item_id: int = Path()) -> Item:
+ if item_id not in _db:
+ raise HTTPException(status_code=404, detail="Item not found")
+ return _db[item_id]
+
+
+@app.post("/items", status_code=201)
+async def create_item(body: ItemCreate) -> Item:
+ global _next_id
+ item = Item(id=_next_id, name=body.name, description=body.description)
+ _db[_next_id] = item
+ _next_id += 1
+ return item
+
+
+@app.put("/items/{item_id}")
+async def replace_item(item_id: int = Path(), body: ItemCreate | None = None) -> Item:
+ if body is None:
+ raise HTTPException(status_code=400, detail="Body required")
+ if item_id not in _db:
+ raise HTTPException(status_code=404, detail="Item not found")
+ updated = Item(id=item_id, name=body.name, description=body.description)
+ _db[item_id] = updated
+ return updated
+
+
+@app.delete("/items/{item_id}", status_code=204)
+async def delete_item(item_id: int = Path()) -> None:
+ if item_id not in _db:
+ raise HTTPException(status_code=404, detail="Not found")
+ del _db[item_id]
+```
+
+## Run it
+
+```bash
+uvicorn main:app --reload
+```
+
+Try:
+
+```bash
+curl -s localhost:8000/items
+curl -s -X POST localhost:8000/items -H 'Content-Type: application/json' \
+ -d '{"name":"alpha","description":"first"}'
+```
+
+## Ideas to extend
+
+- Swap the dict for a real database and keep handlers thin.
+- Add `Depends()` for shared auth or pagination.
+- Serve OpenAPI from the same app (default `Faster()` enables docs unless you turn them off).
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..6d21d50
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,74 @@
+site_name: FasterAPI
+site_description: High-performance ASGI web framework for Python
+site_url: https://fasterapiweb.github.io/FasterAPI/
+repo_url: https://github.com/FasterApiWeb/FasterAPI
+repo_name: FasterApiWeb/FasterAPI
+edit_uri: edit/master/docs/
+site_author: Eshwar Chandra Vidhyasagar Thedla
+
+# Project pages live under /FasterAPI/; site_url must match Settings β Pages URL.
+use_directory_urls: true
+
+theme:
+ name: material
+ icon:
+ repo: fontawesome/brands/github
+ palette:
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/brightness-7
+ name: Switch to dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/brightness-4
+ name: Switch to light mode
+ features:
+ - navigation.expand
+ - navigation.sections
+ - content.code.copy
+
+nav:
+ - Home: index.md
+ - Getting started: getting-started.md
+ - Python 3.13 & compatibility: python-313.md
+ - Tutorial (CRUD app): tutorial-crud.md
+ - Migrating from FastAPI: migration-from-fastapi.md
+ - Benchmarks: benchmarks.md
+ - API reference: api-reference.md
+ - Acknowledgments: acknowledgments.md
+
+markdown_extensions:
+ - pymdownx.highlight:
+ anchor_linenums: true
+ - pymdownx.superfences
+ - pymdownx.inlinehilite
+ - admonition
+ - toc:
+ permalink: true
+
+extra:
+ social:
+ - icon: fontawesome/brands/github
+ link: https://github.com/FasterApiWeb/FasterAPI
+ name: Source on GitHub
+ - icon: fontawesome/brands/python
+ link: https://pypi.org/project/faster-api-web/
+ name: PyPI faster-api-web
+
+plugins:
+ - search
+ - mkdocstrings:
+ handlers:
+ python:
+ paths: [.]
+ options:
+ docstring_style: google
+ show_source: true
+ show_root_heading: true
+ heading_level: 2
diff --git a/pyproject.toml b/pyproject.toml
index 4fd6480..8fedbca 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,10 +1,10 @@
[build-system]
-requires = ["hatchling"]
+requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "faster-api-web"
-version = "0.1.1"
+dynamic = ["version"]
description = "FasterAPI β A high-performance ASGI web framework. FastAPI speed, without the wait."
readme = "README.md"
license = "MIT"
@@ -47,17 +47,26 @@ dev = [
"pytest-asyncio>=0.23.0",
"pytest-cov>=5.0.0",
"mypy>=1.10.0",
+ "ruff>=0.8.0",
+ "tox>=4.0.0",
]
benchmark = [
"httpx>=0.27.0",
"fastapi>=0.111.0",
"pydantic>=2.7.0",
]
+docs = [
+ "mkdocs-material>=9.5.0",
+ "mkdocstrings[python]>=0.25.0",
+]
+test = [
+ "httpx>=0.27.0",
+]
[project.urls]
Homepage = "https://github.com/FasterApiWeb/FasterAPI"
Repository = "https://github.com/FasterApiWeb/FasterAPI"
-Documentation = "https://github.com/FasterApiWeb/FasterAPI#readme"
+Documentation = "https://fasterapiweb.github.io/FasterAPI/"
Issues = "https://github.com/FasterApiWeb/FasterAPI/issues"
Changelog = "https://github.com/FasterApiWeb/FasterAPI/blob/master/CHANGELOG.md"
@@ -66,12 +75,41 @@ asyncio_mode = "auto"
testpaths = ["tests"]
[tool.mypy]
-python_version = "3.10"
-warn_return_any = true
+python_version = "3.13"
+strict = true
warn_unused_configs = true
-disallow_untyped_defs = false
-check_untyped_defs = true
ignore_missing_imports = true
+# Test modules: strict typing every test would be noisy; library stays strict above.
+[[tool.mypy.overrides]]
+module = "tests.*"
+ignore_errors = true
+
+[tool.ruff]
+target-version = "py310"
+line-length = 120
+src = ["FasterAPI", "tests", "benchmarks"]
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B", "SIM"]
+ignore = [
+ "E501",
+ "B008",
+ "B023",
+ "SIM115",
+]
+
+[tool.ruff.lint.per-file-ignores]
+"benchmarks/check_regressions.py" = ["E402"]
+"benchmarks/export_pr_benchmarks.py" = ["E402"]
+
[tool.hatch.build.targets.wheel]
packages = ["FasterAPI"]
+
+[tool.hatch.version]
+source = "vcs"
+
+# TestPyPI/PyPI reject local version identifiers like "+g".
+# Keep tag-based releases unchanged, but make non-tag builds PEP 440 compliant.
+[tool.hatch.version.raw-options]
+local_scheme = "no-local-version"
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..76eca71
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""Test package for FasterAPI."""
diff --git a/tests/test_app_lifecycle.py b/tests/test_app_lifecycle.py
new file mode 100644
index 0000000..748802f
--- /dev/null
+++ b/tests/test_app_lifecycle.py
@@ -0,0 +1,167 @@
+"""Lifespan, errors, and middleware wiring."""
+
+import pytest
+from FasterAPI.app import Faster
+from FasterAPI.exceptions import HTTPException
+
+
+@pytest.mark.asyncio
+async def test_lifespan_startup_shutdown():
+ app = Faster(openapi_url=None, docs_url=None, redoc_url=None)
+ log: list[str] = []
+
+ @app.on_startup
+ def up():
+ log.append("up")
+
+ @app.on_shutdown
+ async def down():
+ log.append("down")
+
+ sent: list[dict] = []
+ messages = [
+ {"type": "lifespan.startup"},
+ {"type": "lifespan.shutdown"},
+ ]
+ idx = [0]
+
+ async def receive():
+ m = messages[idx[0]]
+ idx[0] += 1
+ return m
+
+ async def send(msg: dict) -> None:
+ sent.append(msg)
+
+ scope = {"type": "lifespan"}
+ await app(scope, receive, send)
+
+ assert log == ["up", "down"]
+ assert any(x.get("type") == "lifespan.startup.complete" for x in sent)
+ assert any(x.get("type") == "lifespan.shutdown.complete" for x in sent)
+
+
+@pytest.mark.asyncio
+async def test_404():
+ app = Faster(openapi_url=None, docs_url=None, redoc_url=None)
+ sent: list[dict] = []
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ async def send(msg: dict) -> None:
+ sent.append(msg)
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/nope",
+ "query_string": b"",
+ "headers": [],
+ "client": ("127.0.0.1", 1),
+ }
+ await app(scope, receive, send)
+ assert sent[0]["status"] == 404
+
+
+@pytest.mark.asyncio
+async def test_custom_exception_handler():
+ app = Faster(openapi_url=None, docs_url=None, redoc_url=None)
+
+ class Boom(Exception):
+ pass
+
+ @app.get("/x")
+ async def x():
+ raise Boom()
+
+ def handle(request, exc: Boom):
+ from FasterAPI.response import PlainTextResponse
+
+ return PlainTextResponse("handled", 418)
+
+ app.add_exception_handler(Boom, handle)
+
+ sent: list[dict] = []
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ async def send(msg: dict) -> None:
+ sent.append(msg)
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/x",
+ "query_string": b"",
+ "headers": [],
+ "client": ("127.0.0.1", 1),
+ }
+ await app(scope, receive, send)
+ assert sent[0]["status"] == 418
+ assert b"handled" in sent[1]["body"]
+
+
+@pytest.mark.asyncio
+async def test_http_exception_handler_path():
+ app = Faster(openapi_url=None, docs_url=None, redoc_url=None)
+
+ @app.get("/e")
+ async def e():
+ raise HTTPException(403, detail="forbidden")
+
+ sent: list[dict] = []
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ async def send(msg: dict) -> None:
+ sent.append(msg)
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/e",
+ "query_string": b"",
+ "headers": [],
+ "client": ("127.0.0.1", 1),
+ }
+ await app(scope, receive, send)
+ assert sent[0]["status"] == 403
+
+
+@pytest.mark.asyncio
+async def test_middleware_chain_builds_once():
+ from FasterAPI.middleware import BaseHTTPMiddleware
+
+ class MW(BaseHTTPMiddleware):
+ async def dispatch(self, scope, receive, send):
+ await self.app(scope, receive, send)
+
+ app = Faster(openapi_url=None, docs_url=None, redoc_url=None)
+ app.add_middleware(MW)
+
+ @app.get("/z")
+ async def z():
+ return {"z": 1}
+
+ sent: list[dict] = []
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ async def send(msg: dict) -> None:
+ sent.append(msg)
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/z",
+ "query_string": b"",
+ "headers": [],
+ "client": ("127.0.0.1", 1),
+ }
+ await app(scope, receive, send)
+ assert app._middleware_app is not None
+ assert sent[0]["status"] == 200
diff --git a/tests/test_background.py b/tests/test_background.py
new file mode 100644
index 0000000..e1e6dd2
--- /dev/null
+++ b/tests/test_background.py
@@ -0,0 +1,45 @@
+"""Background task execution."""
+
+import pytest
+from FasterAPI.background import BackgroundTask, BackgroundTasks
+
+
+@pytest.mark.asyncio
+async def test_background_task_sync():
+ out: list[int] = []
+
+ def work(x: int) -> None:
+ out.append(x)
+
+ t = BackgroundTask(work, 42)
+ await t.run()
+ assert out == [42]
+
+
+@pytest.mark.asyncio
+async def test_background_task_async():
+ out: list[str] = []
+
+ async def work(msg: str) -> None:
+ out.append(msg)
+
+ t = BackgroundTask(work, "hi")
+ await t.run()
+ assert out == ["hi"]
+
+
+@pytest.mark.asyncio
+async def test_background_tasks_sequential():
+ order: list[str] = []
+
+ def a() -> None:
+ order.append("a")
+
+ async def b() -> None:
+ order.append("b")
+
+ bg = BackgroundTasks()
+ bg.add_task(a)
+ bg.add_task(b)
+ await bg.run()
+ assert order == ["a", "b"]
diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py
new file mode 100644
index 0000000..627b588
--- /dev/null
+++ b/tests/test_concurrency.py
@@ -0,0 +1,46 @@
+"""Concurrency helpers (thread pool, process pool fallbacks)."""
+
+import pytest
+from FasterAPI import concurrency as c
+
+
+def _cpu_add_one(x: int) -> int:
+ return x + 1
+
+
+def _work_double(x: int) -> int:
+ return x * 2
+
+
+def test_is_coroutine():
+ async def acoro():
+ return 1
+
+ def sync():
+ return 2
+
+ assert c.is_coroutine(acoro) is True
+ assert c.is_coroutine(sync) is False
+
+
+@pytest.mark.asyncio
+async def test_run_in_threadpool():
+ def blocking() -> str:
+ return "ok"
+
+ assert await c.run_in_threadpool(blocking) == "ok"
+
+
+@pytest.mark.asyncio
+async def test_run_in_executor_runs():
+ assert await c.run_in_executor(_cpu_add_one, 41) == 42
+
+
+@pytest.mark.asyncio
+async def test_run_in_subinterpreter_or_process():
+ assert await c.run_in_subinterpreter(_work_double, 21) == 42
+
+
+def test_install_event_loop_returns_string():
+ name = c.install_event_loop()
+ assert name in ("uvloop", "asyncio")
diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py
new file mode 100644
index 0000000..0aca23f
--- /dev/null
+++ b/tests/test_datastructures.py
@@ -0,0 +1,29 @@
+"""UploadFile and FormData helpers."""
+
+import pytest
+from FasterAPI.datastructures import FormData, UploadFile
+
+
+@pytest.mark.asyncio
+async def test_upload_file_read_write_seek(tmp_path):
+ u = UploadFile(filename="t.bin", content_type="application/octet-stream")
+ n = await u.write(b"hello")
+ assert n == 5
+ await u.seek(0)
+ data = await u.read()
+ assert data == b"hello"
+ await u.close()
+
+
+@pytest.mark.asyncio
+async def test_upload_file_repr():
+ u = UploadFile(filename="a.txt")
+ assert "a.txt" in repr(u)
+
+
+@pytest.mark.asyncio
+async def test_form_data_close_closes_files():
+ u1 = UploadFile(filename="1")
+ await u1.write(b"x")
+ fd = FormData({"f": u1, "k": "v"})
+ await fd.close()
diff --git a/tests/test_deps.py b/tests/test_deps.py
index c8e0847..76f6a80 100644
--- a/tests/test_deps.py
+++ b/tests/test_deps.py
@@ -1,16 +1,13 @@
-import asyncio
-
import msgspec
import pytest
-
from FasterAPI.dependencies import Depends, _resolve_handler
-from FasterAPI.exceptions import HTTPException, RequestValidationError
+from FasterAPI.exceptions import RequestValidationError
from FasterAPI.params import Body, Cookie, Header, Path, Query
from FasterAPI.request import Request
-
# --------------- helpers ---------------
+
def _make_request(
*,
method: str = "GET",
@@ -45,6 +42,7 @@ async def receive():
# Request injection
# ==============================
+
class TestRequestInjection:
@pytest.mark.asyncio
async def test_inject_request(self):
@@ -60,6 +58,7 @@ async def handler(request: Request):
# Path params
# ==============================
+
class TestPathParams:
@pytest.mark.asyncio
async def test_path_param(self):
@@ -94,6 +93,7 @@ async def handler(user_id: str = Path("default_id")):
# Query params
# ==============================
+
class TestQueryParams:
@pytest.mark.asyncio
async def test_query_param(self):
@@ -136,6 +136,7 @@ async def handler(q: str = Query(alias="search_query")):
# Header params
# ==============================
+
class TestHeaderParams:
@pytest.mark.asyncio
async def test_header(self):
@@ -178,6 +179,7 @@ async def handler(x_token: str = Header("fallback")):
# Cookie params
# ==============================
+
class TestCookieParams:
@pytest.mark.asyncio
async def test_cookie(self):
@@ -202,6 +204,7 @@ async def handler(session: str = Cookie("none")):
# Body / msgspec.Struct
# ==============================
+
class Item(msgspec.Struct):
name: str
price: float
@@ -252,6 +255,7 @@ async def handler(data: dict = Body({"fallback": True})):
# Depends()
# ==============================
+
class TestDepends:
@pytest.mark.asyncio
async def test_simple_dependency(self):
@@ -355,6 +359,7 @@ async def handler(version=Depends(get_version)):
# Sync handlers
# ==============================
+
class TestSyncHandlers:
@pytest.mark.asyncio
async def test_sync_handler(self):
@@ -370,6 +375,7 @@ def handler(q: str = Query("hi")):
# Combined params
# ==============================
+
class TestCombinedParams:
@pytest.mark.asyncio
async def test_multiple_param_types(self):
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
new file mode 100644
index 0000000..122fab9
--- /dev/null
+++ b/tests/test_exceptions.py
@@ -0,0 +1,45 @@
+"""HTTP and validation exception types and default handlers."""
+
+import pytest
+from FasterAPI.exceptions import (
+ HTTPException,
+ RequestValidationError,
+ _default_http_exception_handler,
+ _default_validation_exception_handler,
+)
+
+
+def test_http_exception_attrs():
+ e = HTTPException(418, detail="teapot", headers={"X": "y"})
+ assert e.status_code == 418
+ assert e.detail == "teapot"
+ assert "418" in repr(e)
+
+
+def test_validation_repr():
+ e = RequestValidationError([{"loc": ["body"], "msg": "bad", "type": "x"}])
+ assert "errors" in repr(e)
+
+
+@pytest.mark.asyncio
+async def test_default_http_handler_with_headers():
+ exc = HTTPException(401, detail="nope", headers={"WWW-Authenticate": "Bearer"})
+ status, body, hdrs = await _default_http_exception_handler(None, exc)
+ assert status == 401
+ assert b"nope" in body
+ hmap = dict(hdrs)
+ assert hmap[b"content-type"] == b"application/json"
+ assert hmap[b"www-authenticate"] == b"Bearer"
+
+
+@pytest.mark.asyncio
+async def test_validation_handler_shapes_errors():
+ exc = RequestValidationError(
+ [
+ {"loc": ["query", "q"], "msg": "missing", "type": "value_error"},
+ {"loc": [], "msg": "x"},
+ ]
+ )
+ status, body, hdrs = await _default_validation_exception_handler(None, exc)
+ assert status == 422
+ assert b"query" in body
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index e01d12d..caf5370 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -2,7 +2,6 @@
import msgspec
import pytest
-
from FasterAPI.app import Faster
from FasterAPI.middleware import (
CORSMiddleware,
@@ -11,9 +10,9 @@
TrustedHostMiddleware,
)
-
# --------------- helpers ---------------
+
class MockSend:
def __init__(self):
self.messages: list[dict] = []
@@ -91,6 +90,7 @@ async def small_handler():
# CORS Middleware
# ==============================
+
class TestCORSBasic:
@pytest.mark.asyncio
async def test_cors_allow_all_origins(self):
@@ -210,6 +210,7 @@ async def test_credentials(self):
# GZip Middleware
# ==============================
+
class TestGZipMiddleware:
@pytest.mark.asyncio
async def test_gzip_compresses_large_response(self):
@@ -276,6 +277,7 @@ async def test_gzip_vary_header(self):
# TrustedHost Middleware
# ==============================
+
class TestTrustedHostMiddleware:
@pytest.mark.asyncio
async def test_allowed_host(self):
@@ -326,6 +328,7 @@ async def test_host_with_port(self):
# HTTPS Redirect Middleware
# ==============================
+
class TestHTTPSRedirectMiddleware:
@pytest.mark.asyncio
async def test_http_redirects(self):
@@ -358,6 +361,7 @@ async def test_https_passes_through(self):
# Middleware chain
# ==============================
+
class TestMiddlewareChain:
@pytest.mark.asyncio
async def test_chain_is_cached(self):
@@ -383,10 +387,12 @@ async def test_multiple_middleware(self):
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["example.com"])
send = MockSend()
- scope = _make_scope(headers=[
- (b"origin", b"http://example.com"),
- (b"host", b"example.com"),
- ])
+ scope = _make_scope(
+ headers=[
+ (b"origin", b"http://example.com"),
+ (b"host", b"example.com"),
+ ]
+ )
await app(scope, _receive, send)
assert send.status == 200
diff --git a/tests/test_openapi.py b/tests/test_openapi.py
index 7dedf43..a3ddf73 100644
--- a/tests/test_openapi.py
+++ b/tests/test_openapi.py
@@ -1,10 +1,7 @@
from __future__ import annotations
-from typing import Optional
-
import msgspec
import pytest
-
from FasterAPI.app import Faster
from FasterAPI.dependencies import Depends
from FasterAPI.openapi.generator import generate_openapi
@@ -12,11 +9,12 @@
from FasterAPI.params import Body, Cookie, Header, Path, Query
from FasterAPI.request import Request
-
# --------------- models ---------------
+
class Item(msgspec.Struct):
"""An item in the store."""
+
name: str
price: float
in_stock: bool = True
@@ -35,6 +33,7 @@ class User(msgspec.Struct):
# --------------- helpers ---------------
+
def _make_app(**kw) -> Faster:
return Faster(**kw)
@@ -43,6 +42,7 @@ def _make_app(**kw) -> Faster:
# OpenAPI spec structure
# ==============================
+
class TestSpecStructure:
def test_basic_structure(self):
app = _make_app(title="TestApp", version="2.0.0", description="A test app")
@@ -85,6 +85,7 @@ async def create_user():
# Path parameters
# ==============================
+
class TestPathParams:
def test_path_param_in_spec(self):
app = _make_app()
@@ -130,6 +131,7 @@ async def get_user(user_id: str = Path(description="The user ID")):
# Query parameters
# ==============================
+
class TestQueryParams:
def test_query_param(self):
app = _make_app()
@@ -178,6 +180,7 @@ async def list_items(q: str = Query(alias="search_query")):
# Header parameters
# ==============================
+
class TestHeaderParams:
def test_header_param(self):
app = _make_app()
@@ -209,6 +212,7 @@ async def auth(token: str = Header(alias="Authorization")):
# Cookie parameters
# ==============================
+
class TestCookieParams:
def test_cookie_param(self):
app = _make_app()
@@ -229,6 +233,7 @@ async def me(session: str = Cookie()):
# Request body (structs)
# ==============================
+
class TestRequestBody:
def test_struct_body(self):
app = _make_app()
@@ -315,6 +320,7 @@ async def update(id: str = Path(), item: Item = Body()):
# Response model
# ==============================
+
class TestResponseModel:
def test_response_model_ref(self):
app = _make_app()
@@ -344,6 +350,7 @@ async def ping():
# Tags, summary, deprecated
# ==============================
+
class TestMetadata:
def test_tags(self):
app = _make_app()
@@ -412,6 +419,7 @@ async def list_items():
# 422 validation error response
# ==============================
+
class TestValidationResponse:
def test_422_added_when_params_exist(self):
app = _make_app()
@@ -440,6 +448,7 @@ async def ping():
# Caching
# ==============================
+
class TestCaching:
def test_spec_is_cached(self):
app = _make_app()
@@ -457,6 +466,7 @@ async def ping():
# Type mapping
# ==============================
+
class ListModel(msgspec.Struct):
tags: list[str]
@@ -507,6 +517,7 @@ async def create(model: DictModel):
# UI HTML
# ==============================
+
class TestUI:
def test_swagger_html_contains_url(self):
html = swagger_ui_html("/openapi.json", title="My App")
@@ -525,6 +536,7 @@ def test_redoc_html_contains_url(self):
# Auto-registered routes in app
# ==============================
+
class MockSend:
def __init__(self):
self.messages: list[dict] = []
@@ -552,8 +564,11 @@ async def items():
send = MockSend()
scope = {
- "type": "http", "method": "GET", "path": "/openapi.json",
- "headers": [], "query_string": b"",
+ "type": "http",
+ "method": "GET",
+ "path": "/openapi.json",
+ "headers": [],
+ "query_string": b"",
}
async def receive():
@@ -571,8 +586,11 @@ async def test_docs_route(self):
app = _make_app()
send = MockSend()
scope = {
- "type": "http", "method": "GET", "path": "/docs",
- "headers": [], "query_string": b"",
+ "type": "http",
+ "method": "GET",
+ "path": "/docs",
+ "headers": [],
+ "query_string": b"",
}
async def receive():
@@ -587,8 +605,11 @@ async def test_redoc_route(self):
app = _make_app()
send = MockSend()
scope = {
- "type": "http", "method": "GET", "path": "/redoc",
- "headers": [], "query_string": b"",
+ "type": "http",
+ "method": "GET",
+ "path": "/redoc",
+ "headers": [],
+ "query_string": b"",
}
async def receive():
@@ -603,8 +624,11 @@ async def test_disabled_openapi(self):
app = Faster(openapi_url=None)
send = MockSend()
scope = {
- "type": "http", "method": "GET", "path": "/openapi.json",
- "headers": [], "query_string": b"",
+ "type": "http",
+ "method": "GET",
+ "path": "/openapi.json",
+ "headers": [],
+ "query_string": b"",
}
async def receive():
@@ -624,7 +648,8 @@ async def receive():
# /api/schema should work
await app(
{"type": "http", "method": "GET", "path": "/api/schema", "headers": [], "query_string": b""},
- receive, send,
+ receive,
+ send,
)
assert send.status == 200
@@ -633,6 +658,7 @@ async def receive():
# Combined: complex app spec
# ==============================
+
class TestComplexSpec:
def test_full_crud_spec(self):
app = _make_app(title="ItemStore", version="1.0.0")
diff --git a/tests/test_params.py b/tests/test_params.py
index 2a3be4b..3a3a3b0 100644
--- a/tests/test_params.py
+++ b/tests/test_params.py
@@ -1,8 +1,6 @@
-import asyncio
import inspect
import pytest
-
from FasterAPI.params import (
MISSING,
Body,
@@ -15,9 +13,9 @@
)
from FasterAPI.request import Request
-
# --------------- helpers ---------------
+
def _make_scope(
*,
method: str = "GET",
@@ -55,6 +53,7 @@ async def receive():
# Request tests
# ==============================
+
class TestRequestBasics:
def test_method_and_path(self):
scope = _make_scope(method="POST", path="/users")
@@ -63,10 +62,12 @@ def test_method_and_path(self):
assert req.path == "/users"
def test_headers_lowercase(self):
- scope = _make_scope(headers=[
- (b"Content-Type", b"application/json"),
- (b"X-Custom", b"value"),
- ])
+ scope = _make_scope(
+ headers=[
+ (b"Content-Type", b"application/json"),
+ (b"X-Custom", b"value"),
+ ]
+ )
req = Request(scope, None)
assert req.headers["content-type"] == "application/json"
assert req.headers["x-custom"] == "value"
@@ -101,9 +102,11 @@ def test_client_absent(self):
class TestRequestCookies:
def test_cookies_parsed(self):
- scope = _make_scope(headers=[
- (b"cookie", b"session=abc123; theme=dark"),
- ])
+ scope = _make_scope(
+ headers=[
+ (b"cookie", b"session=abc123; theme=dark"),
+ ]
+ )
req = Request(scope, None)
assert req.cookies == {"session": "abc123", "theme": "dark"}
@@ -115,9 +118,11 @@ def test_no_cookies(self):
class TestRequestContentType:
def test_content_type(self):
- scope = _make_scope(headers=[
- (b"content-type", b"application/json"),
- ])
+ scope = _make_scope(
+ headers=[
+ (b"content-type", b"application/json"),
+ ]
+ )
req = Request(scope, None)
assert req.content_type == "application/json"
@@ -165,14 +170,14 @@ async def test_urlencoded_form(self):
async def test_multipart_form(self):
boundary = "----boundary"
body = (
- f"------boundary\r\n"
- f'Content-Disposition: form-data; name="field1"\r\n\r\n'
- f"value1\r\n"
- f"------boundary\r\n"
- f'Content-Disposition: form-data; name="field2"\r\n\r\n'
- f"value2\r\n"
- f"------boundary--\r\n"
- ).encode()
+ b"------boundary\r\n"
+ b'Content-Disposition: form-data; name="field1"\r\n\r\n'
+ b"value1\r\n"
+ b"------boundary\r\n"
+ b'Content-Disposition: form-data; name="field2"\r\n\r\n'
+ b"value2\r\n"
+ b"------boundary--\r\n"
+ )
receive = await _receive_body(body)
scope = _make_scope(
method="POST",
@@ -188,6 +193,7 @@ async def test_multipart_form(self):
# Param descriptor tests
# ==============================
+
class TestPathParam:
def test_defaults(self):
p = Path()
@@ -278,6 +284,7 @@ def test_repr(self):
# Signature-level usage
# ==============================
+
class TestSignatureUsage:
def test_params_as_defaults_in_signature(self):
"""Verify param descriptors work as default values in function signatures."""
diff --git a/tests/test_response.py b/tests/test_response.py
new file mode 100644
index 0000000..a33b8bc
--- /dev/null
+++ b/tests/test_response.py
@@ -0,0 +1,123 @@
+"""Tests for response classes and ASGI emitters."""
+
+from pathlib import Path
+
+import pytest
+from FasterAPI.response import (
+ FileResponse,
+ HTMLResponse,
+ JSONResponse,
+ PlainTextResponse,
+ RedirectResponse,
+ Response,
+ StreamingResponse,
+)
+
+
+@pytest.mark.asyncio
+async def test_response_bytes_and_headers():
+ sent: list[dict] = []
+
+ async def send(msg: dict) -> None:
+ sent.append(msg)
+
+ r = Response(b"hi", status_code=201, headers={"X-Test": "1"}, media_type="application/octet-stream")
+ await r.to_asgi(send)
+ assert sent[0]["type"] == "http.response.start"
+ assert sent[0]["status"] == 201
+ hdrs = dict(sent[0]["headers"])
+ assert hdrs[b"content-type"] == b"application/octet-stream"
+ assert hdrs[b"x-test"] == b"1"
+ assert sent[1]["body"] == b"hi"
+
+
+@pytest.mark.asyncio
+async def test_response_text_charset():
+ sent: list[dict] = []
+
+ async def send(msg: dict) -> None:
+ sent.append(msg)
+
+ r = Response("Γ«", media_type="text/plain")
+ await r.to_asgi(send)
+ hdrs = dict(sent[0]["headers"])
+ assert b"charset=utf-8" in hdrs[b"content-type"]
+
+
+@pytest.mark.asyncio
+async def test_json_response():
+ sent: list[dict] = []
+
+ async def send(msg: dict) -> None:
+ sent.append(msg)
+
+ await JSONResponse({"a": 1}).to_asgi(send)
+ assert b"application/json" in sent[0]["headers"][0][1]
+
+
+@pytest.mark.asyncio
+async def test_html_plain_redirect():
+ for cls, body in [(HTMLResponse, "x
"), (PlainTextResponse, "ok")]:
+ sent: list[dict] = []
+
+ async def send(msg: dict) -> None:
+ sent.append(msg)
+
+ await cls(body).to_asgi(send)
+ assert sent[1]["body"] == body.encode()
+
+ sent2: list[dict] = []
+
+ async def send2(msg: dict) -> None:
+ sent2.append(msg)
+
+ await RedirectResponse("/there", 302).to_asgi(send2)
+ assert sent2[0]["status"] == 302
+ assert dict(sent2[0]["headers"])[b"location"] == b"/there"
+
+
+@pytest.mark.asyncio
+async def test_streaming_response_sync_iter():
+ sent: list[dict] = []
+
+ async def send(msg: dict) -> None:
+ sent.append(msg)
+
+ async def body():
+ yield b"a"
+ yield b"b"
+
+ # async iterator path
+ await StreamingResponse(body(), media_type="text/plain").to_asgi(send)
+ assert any(m.get("body") == b"a" and m.get("more_body") for m in sent)
+
+
+@pytest.mark.asyncio
+async def test_streaming_response_sync_for():
+ sent: list[dict] = []
+
+ async def send(msg: dict) -> None:
+ sent.append(msg)
+
+ def gen():
+ yield b"x"
+ yield b"y"
+
+ await StreamingResponse(gen(), media_type="application/octet-stream").to_asgi(send)
+ parts = [m["body"] for m in sent if m["type"] == "http.response.body" and m.get("body")]
+ assert b"".join(parts) == b"xy"
+
+
+@pytest.mark.asyncio
+async def test_file_response(tmp_path: Path):
+ p = tmp_path / "x.txt"
+ p.write_text("file-content")
+ sent: list[dict] = []
+
+ async def send(msg: dict) -> None:
+ sent.append(msg)
+
+ await FileResponse(p, filename="dl.txt").to_asgi(send)
+ assert sent[1]["body"] == b"file-content"
+ hdrs = dict(sent[0]["headers"])
+ assert b"attachment" in hdrs[b"content-disposition"]
diff --git a/tests/test_routing.py b/tests/test_routing.py
index 79c8afd..171c45b 100644
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -1,8 +1,8 @@
-from FasterAPI.router import RadixRouter, FasterRouter
-
+from FasterAPI.router import FasterRouter, RadixRouter
# --- Helpers ---
+
def _handler():
pass
@@ -13,6 +13,7 @@ def _other():
# --- RadixRouter: static routes ---
+
class TestStaticRoutes:
def test_root(self):
r = RadixRouter()
@@ -53,6 +54,7 @@ def test_multiple_static_routes(self):
# --- RadixRouter: param routes ---
+
class TestParamRoutes:
def test_single_param(self):
r = RadixRouter()
@@ -94,6 +96,7 @@ def test_static_preferred_over_param(self):
# --- RadixRouter: method handling ---
+
class TestMethodHandling:
def test_method_mismatch(self):
r = RadixRouter()
@@ -115,6 +118,7 @@ def test_method_case_insensitive(self):
# --- RadixRouter: trailing slash tolerance ---
+
class TestTrailingSlash:
def test_registered_without_resolved_with(self):
r = RadixRouter()
@@ -138,6 +142,7 @@ def test_root_with_trailing_slash(self):
# --- RadixRouter: metadata ---
+
class TestMetadata:
def test_metadata_returned(self):
r = RadixRouter()
@@ -154,6 +159,7 @@ def test_default_metadata_empty(self):
# --- FasterRouter ---
+
class TestFasterRouter:
def test_prefix_applied(self):
router = FasterRouter(prefix="/api/v1")
@@ -179,19 +185,24 @@ def test_all_methods(self):
router = FasterRouter()
@router.get("/a")
- def a(): pass
+ def a():
+ pass
@router.post("/b")
- def b(): pass
+ def b():
+ pass
@router.put("/c")
- def c(): pass
+ def c():
+ pass
@router.delete("/d")
- def d(): pass
+ def d():
+ pass
@router.patch("/e")
- def e(): pass
+ def e():
+ pass
methods = [r["method"] for r in router.routes]
assert methods == ["GET", "POST", "PUT", "DELETE", "PATCH"]
diff --git a/tests/test_testclient.py b/tests/test_testclient.py
new file mode 100644
index 0000000..fe76f81
--- /dev/null
+++ b/tests/test_testclient.py
@@ -0,0 +1,54 @@
+"""TestClient HTTP and WebSocket paths."""
+
+import pytest
+from FasterAPI import Faster
+from FasterAPI.testclient import TestClient
+
+
+@pytest.fixture
+def simple_app():
+ app = Faster(openapi_url=None, docs_url=None, redoc_url=None)
+
+ @app.get("/hello")
+ async def hello():
+ return {"msg": "ok"}
+
+ @app.post("/echo")
+ async def echo():
+ return {"received": True}
+
+ return app
+
+
+def test_testclient_get_json(simple_app):
+ with TestClient(simple_app) as client:
+ r = client.get("/hello")
+ assert r.status_code == 200
+ assert r.json()["msg"] == "ok"
+
+
+def test_testclient_post(simple_app):
+ with TestClient(simple_app) as client:
+ r = client.post("/echo")
+ assert r.status_code == 200
+
+
+def test_testclient_methods_smoke(simple_app):
+ app = simple_app
+
+ @app.put("/p")
+ async def p():
+ return {}
+
+ @app.delete("/d")
+ async def d():
+ return {}
+
+ @app.patch("/a")
+ async def a():
+ return {}
+
+ with TestClient(app) as c:
+ assert c.put("/p").status_code == 200
+ assert c.delete("/d").status_code == 200
+ assert c.patch("/a").status_code == 200
diff --git a/tests/test_websocket.py b/tests/test_websocket.py
index 8fb787a..dcf0e40 100644
--- a/tests/test_websocket.py
+++ b/tests/test_websocket.py
@@ -1,14 +1,11 @@
-import asyncio
-
import msgspec
import pytest
-
from FasterAPI.app import Faster
from FasterAPI.websocket import WebSocket, WebSocketDisconnect, WebSocketState
-
# --------------- helpers ---------------
+
class MockWebSocketTransport:
"""Simulates the ASGI websocket protocol for testing."""
@@ -58,6 +55,7 @@ def _ws_scope(
# WebSocket constructor & props
# ==============================
+
class TestWebSocketProperties:
def test_path_and_params(self):
scope = _ws_scope(path="/chat")
@@ -101,6 +99,7 @@ def test_initial_state_is_connecting(self):
# accept
# ==============================
+
class TestWebSocketAccept:
@pytest.mark.asyncio
async def test_accept(self):
@@ -130,12 +129,15 @@ async def test_double_accept_raises(self):
# send / receive text
# ==============================
+
class TestWebSocketText:
@pytest.mark.asyncio
async def test_send_receive_text(self):
- transport = MockWebSocketTransport(to_receive=[
- {"type": "websocket.receive", "text": "hello"},
- ])
+ transport = MockWebSocketTransport(
+ to_receive=[
+ {"type": "websocket.receive", "text": "hello"},
+ ]
+ )
ws = WebSocket(_ws_scope(), transport.receive, transport.send)
text = await ws.receive_text()
assert text == "hello"
@@ -145,9 +147,11 @@ async def test_send_receive_text(self):
@pytest.mark.asyncio
async def test_receive_empty_text(self):
- transport = MockWebSocketTransport(to_receive=[
- {"type": "websocket.receive"},
- ])
+ transport = MockWebSocketTransport(
+ to_receive=[
+ {"type": "websocket.receive"},
+ ]
+ )
ws = WebSocket(_ws_scope(), transport.receive, transport.send)
text = await ws.receive_text()
assert text == ""
@@ -157,12 +161,15 @@ async def test_receive_empty_text(self):
# send / receive bytes
# ==============================
+
class TestWebSocketBytes:
@pytest.mark.asyncio
async def test_send_receive_bytes(self):
- transport = MockWebSocketTransport(to_receive=[
- {"type": "websocket.receive", "bytes": b"\x00\x01"},
- ])
+ transport = MockWebSocketTransport(
+ to_receive=[
+ {"type": "websocket.receive", "bytes": b"\x00\x01"},
+ ]
+ )
ws = WebSocket(_ws_scope(), transport.receive, transport.send)
data = await ws.receive_bytes()
assert data == b"\x00\x01"
@@ -175,13 +182,16 @@ async def test_send_receive_bytes(self):
# send / receive json
# ==============================
+
class TestWebSocketJson:
@pytest.mark.asyncio
async def test_send_receive_json(self):
payload = msgspec.json.encode({"key": "value"}).decode()
- transport = MockWebSocketTransport(to_receive=[
- {"type": "websocket.receive", "text": payload},
- ])
+ transport = MockWebSocketTransport(
+ to_receive=[
+ {"type": "websocket.receive", "text": payload},
+ ]
+ )
ws = WebSocket(_ws_scope(), transport.receive, transport.send)
data = await ws.receive_json()
assert data == {"key": "value"}
@@ -203,6 +213,7 @@ async def test_send_json_list(self):
# close & disconnect
# ==============================
+
class TestWebSocketCloseDisconnect:
@pytest.mark.asyncio
async def test_close(self):
@@ -227,9 +238,11 @@ async def test_close_default_code(self):
@pytest.mark.asyncio
async def test_disconnect_raises(self):
- transport = MockWebSocketTransport(to_receive=[
- {"type": "websocket.disconnect", "code": 1001},
- ])
+ transport = MockWebSocketTransport(
+ to_receive=[
+ {"type": "websocket.disconnect", "code": 1001},
+ ]
+ )
ws = WebSocket(_ws_scope(), transport.receive, transport.send)
with pytest.raises(WebSocketDisconnect) as exc_info:
await ws.receive_text()
@@ -238,9 +251,11 @@ async def test_disconnect_raises(self):
@pytest.mark.asyncio
async def test_disconnect_default_code(self):
- transport = MockWebSocketTransport(to_receive=[
- {"type": "websocket.disconnect"},
- ])
+ transport = MockWebSocketTransport(
+ to_receive=[
+ {"type": "websocket.disconnect"},
+ ]
+ )
ws = WebSocket(_ws_scope(), transport.receive, transport.send)
with pytest.raises(WebSocketDisconnect) as exc_info:
await ws.receive_bytes()
@@ -251,6 +266,7 @@ async def test_disconnect_default_code(self):
# App integration
# ==============================
+
class TestWebSocketApp:
@pytest.mark.asyncio
async def test_echo_handler(self):
@@ -263,9 +279,11 @@ async def echo(ws: WebSocket):
await ws.send_text(f"echo: {text}")
await ws.close()
- transport = MockWebSocketTransport(to_receive=[
- {"type": "websocket.receive", "text": "ping"},
- ])
+ transport = MockWebSocketTransport(
+ to_receive=[
+ {"type": "websocket.receive", "text": "ping"},
+ ]
+ )
await app(_ws_scope("/ws"), transport.receive, transport.send)
assert transport.accepted()
@@ -309,9 +327,11 @@ async def api(ws: WebSocket):
await ws.close()
payload = msgspec.json.encode({"msg": "hi"}).decode()
- transport = MockWebSocketTransport(to_receive=[
- {"type": "websocket.receive", "text": payload},
- ])
+ transport = MockWebSocketTransport(
+ to_receive=[
+ {"type": "websocket.receive", "text": payload},
+ ]
+ )
await app(_ws_scope("/api"), transport.receive, transport.send)
sent = msgspec.json.decode(transport.sent_texts()[0].encode())
@@ -355,12 +375,14 @@ async def chat(ws: WebSocket):
except WebSocketDisconnect:
pass
- transport = MockWebSocketTransport(to_receive=[
- {"type": "websocket.receive", "text": "first"},
- {"type": "websocket.receive", "text": "second"},
- {"type": "websocket.receive", "text": "third"},
- {"type": "websocket.disconnect", "code": 1000},
- ])
+ transport = MockWebSocketTransport(
+ to_receive=[
+ {"type": "websocket.receive", "text": "first"},
+ {"type": "websocket.receive", "text": "second"},
+ {"type": "websocket.receive", "text": "third"},
+ {"type": "websocket.disconnect", "code": 1000},
+ ]
+ )
await app(_ws_scope("/chat"), transport.receive, transport.send)
assert transport.sent_texts() == ["reply: first", "reply: second", "reply: third"]
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..4e87ec8
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,10 @@
+[tox]
+env_list = py311, py312, py313
+isolated_build = true
+skip_missing_interpreters = true
+
+[testenv]
+extras = dev
+commands =
+ python -m pytest {posargs} --cov=FasterAPI --cov-report=term-missing --cov-fail-under=85
+ python -m mypy FasterAPI