From ec9e76b6aa0fbeb4d8a9ce8287fa3667a3a7b6f0 Mon Sep 17 00:00:00 2001 From: Eshwar Chandra Vidhyasagar Thedla Date: Wed, 8 Apr 2026 23:41:09 -0500 Subject: [PATCH 01/12] Remove Code of Conduct; add docs, benchmarks, CI, and release automation Drop Contributor Covenant file to keep community expectations lightweight. Ship MkDocs site, SECURITY.md, CITATION.cff, Python 3.13-focused guides, Fiber three-way benchmarks, coverage and regression gates, hatch-vcs versioning, expanded tests, and lazy TestClient import. --- .github/workflows/benchmark.yml | 207 +++++++++--------------- .github/workflows/ci.yml | 4 +- .github/workflows/docs.yml | 29 ++++ .github/workflows/release.yml | 6 +- .gitignore | 1 + .python-version | 1 + CHANGELOG.md | 13 ++ CITATION.cff | 21 +++ CODE_OF_CONDUCT.md | 128 --------------- CONTRIBUTING.md | 31 +++- FasterAPI/__init__.py | 29 +++- FasterAPI/_version.py | 13 ++ FasterAPI/app.py | 5 +- FasterAPI/openapi/generator.py | 5 +- README.md | 134 +++++++++++----- SECURITY.md | 29 ++++ benchmarks/baseline.json | 9 ++ benchmarks/check_regressions.py | 56 +++++++ benchmarks/compare.py | 246 ++++++++++++++++++++++++++++- benchmarks/export_pr_benchmarks.py | 48 ++++++ benchmarks/fiber/.gitignore | 2 + benchmarks/fiber/go.mod | 5 + benchmarks/fiber/main.go | 53 +++++++ codecov.yml | 14 ++ docs/acknowledgments.md | 16 ++ docs/api-reference.md | 6 + docs/benchmarks.md | 49 ++++++ docs/getting-started.md | 67 ++++++++ docs/index.md | 34 ++++ docs/migration-from-fastapi.md | 76 +++++++++ docs/python-313.md | 32 ++++ docs/tutorial-crud.md | 89 +++++++++++ mkdocs.yml | 59 +++++++ pyproject.toml | 18 ++- tests/test_app_lifecycle.py | 167 ++++++++++++++++++++ tests/test_background.py | 48 ++++++ tests/test_concurrency.py | 47 ++++++ tests/test_datastructures.py | 30 ++++ tests/test_exceptions.py | 44 ++++++ tests/test_response.py | 126 +++++++++++++++ tests/test_testclient.py | 56 +++++++ 41 files changed, 1722 insertions(+), 331 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 .python-version create mode 100644 CHANGELOG.md create mode 100644 CITATION.cff delete mode 100644 CODE_OF_CONDUCT.md create mode 100644 FasterAPI/_version.py create mode 100644 SECURITY.md create mode 100644 benchmarks/baseline.json create mode 100644 benchmarks/check_regressions.py create mode 100644 benchmarks/export_pr_benchmarks.py create mode 100644 benchmarks/fiber/.gitignore create mode 100644 benchmarks/fiber/go.mod create mode 100644 benchmarks/fiber/main.go create mode 100644 codecov.yml create mode 100644 docs/acknowledgments.md create mode 100644 docs/api-reference.md create mode 100644 docs/benchmarks.md create mode 100644 docs/getting-started.md create mode 100644 docs/index.md create mode 100644 docs/migration-from-fastapi.md create mode 100644 docs/python-313.md create mode 100644 docs/tutorial-crud.md create mode 100644 mkdocs.yml create mode 100644 tests/test_app_lifecycle.py create mode 100644 tests/test_background.py create mode 100644 tests/test_concurrency.py create mode 100644 tests/test_datastructures.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_response.py create mode 100644 tests/test_testclient.py diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index a92a50b..398f1e2 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -14,126 +14,37 @@ 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 download && 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 uses: actions/github-script@v7 @@ -141,60 +52,90 @@ jobs: 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..935e04b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - 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 +40,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..7036773 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,29 @@ +name: Docs + +on: + push: + branches: [master] + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + 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: Deploy to GitHub Pages + run: mkdocs gh-deploy --force diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ff00cb..ec12ca5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,11 +37,13 @@ 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: @@ -49,6 +51,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-python@v5 with: 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..418b08e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,20 +17,23 @@ 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. --- @@ -58,7 +61,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 +96,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..570dcb5 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 + +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): + 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..b253e4a 100644 --- a/FasterAPI/app.py +++ b/FasterAPI/app.py @@ -14,6 +14,7 @@ import msgspec.json +from ._version import get_version from .concurrency import install_event_loop from .dependencies import _resolve_handler, compile_handler from .exceptions import ( @@ -56,14 +57,14 @@ 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 diff --git a/FasterAPI/openapi/generator.py b/FasterAPI/openapi/generator.py index 315d7db..68a36d7 100644 --- a/FasterAPI/openapi/generator.py +++ b/FasterAPI/openapi/generator.py @@ -8,6 +8,7 @@ import msgspec +from .._version import get_version from ..params import Body, Cookie, Header, Path, Query @@ -15,10 +16,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 diff --git a/README.md b/README.md index 790bee0..8bcd817 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,31 @@ # FasterAPI [![PyPI version](https://img.shields.io/pypi/v/faster-api-web.svg)](https://pypi.org/project/faster-api-web/) +[![PyPI - Python](https://img.shields.io/pypi/pyversions/faster-api-web.svg)](https://pypi.org/project/faster-api-web/) +[![PyPI - Downloads](https://img.shields.io/pypi/dm/faster-api-web.svg)](https://pypi.org/project/faster-api-web/) [![CI](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/ci.yml) [![Benchmark](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/benchmark.yml/badge.svg)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/benchmark.yml) +[![Docs](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/docs.yml/badge.svg)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/docs.yml) +[![Documentation site](https://img.shields.io/badge/docs-GitHub%20Pages-5c6bc0)](https://fasterapiweb.github.io/FasterAPI/) [![codecov](https://codecov.io/gh/FasterApiWeb/FasterAPI/branch/master/graph/badge.svg)](https://codecov.io/gh/FasterApiWeb/FasterAPI) -[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) -[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) +[![License: MIT](https://img.shields.io/github/license/FasterApiWeb/FasterAPI)](LICENSE) [![Docker](https://img.shields.io/badge/docker-ghcr.io-blue?logo=docker)](https://ghcr.io/fasterapiweb/fasterapi) +[![TestPyPI](https://img.shields.io/badge/TestPyPI-faster--api--web-informational)](https://test.pypi.org/project/faster-api-web/) +[![Hatch build](https://img.shields.io/badge/packaging-hatch-3775A9?logo=python)](https://github.com/pypa/hatch) +[![GitHub contributors](https://img.shields.io/github/contributors/FasterApiWeb/FasterAPI)](https://github.com/FasterApiWeb/FasterAPI/graphs/contributors) +[![GitHub last commit](https://img.shields.io/github/last-commit/FasterApiWeb/FasterAPI/master)](https://github.com/FasterApiWeb/FasterAPI/commits/master) +[![uvloop](https://img.shields.io/badge/uvloop-supported-2ea44f)](https://github.com/MagicStack/uvloop) +[![msgspec](https://img.shields.io/badge/msgspec-models-blue)](https://jcristharif.com/msgspec/) +[![ASGI](https://img.shields.io/badge/ASGI-3.0-lightgrey)](https://asgi.readthedocs.io/en/latest/) +[![GitHub stars](https://img.shields.io/github/stars/FasterApiWeb/FasterAPI?style=social)](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 +41,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 +101,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 +127,43 @@ 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. + +--- + +## 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 +708,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 +715,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 +732,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 +771,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..7c17d29 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, Optional -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) @@ -36,6 +39,12 @@ def _find_free_port() -> int: return s.getsockname()[1] +def _fiber_binary_path() -> Optional[str]: + 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 @@ -98,6 +107,8 @@ async def create_user(user: User): # ─────────────────────────────────────────────── 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 +123,15 @@ 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, ) -> dict[str, Any]: + import httpx + latencies: list[float] = [] errors = 0 semaphore = asyncio.Semaphore(concurrency) @@ -159,9 +172,102 @@ async def _fire() -> None: } +async def measure_http_rps_three_way( + total: int, + concurrency: int, + fiber_executable: Optional[str] = None, +) -> tuple[dict[str, dict[str, float]], Optional[str]]: + """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: Optional[subprocess.Popen] = 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: Optional[str] = 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, ) -> 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: @@ -277,8 +383,8 @@ 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.""" +def _build_asgi_pair(): + """Return (faster_app, fastapi_app) for micro-benchmarks.""" import json as _json import msgspec as _msgspec @@ -305,9 +411,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,6 +432,131 @@ 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): 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..9cfd1c6 --- /dev/null +++ b/benchmarks/fiber/go.mod @@ -0,0 +1,5 @@ +module github.com/FasterApiWeb/FasterAPI/benchmarks/fiber + +go 1.22 + +require github.com/gofiber/fiber/v2 v2.52.5 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..26b2fb0 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,59 @@ +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/ + +theme: + name: material + 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 + +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..c8a6008 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" @@ -53,11 +53,18 @@ benchmark = [ "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,7 +73,7 @@ asyncio_mode = "auto" testpaths = ["tests"] [tool.mypy] -python_version = "3.10" +python_version = "3.13" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = false @@ -75,3 +82,6 @@ ignore_missing_imports = true [tool.hatch.build.targets.wheel] packages = ["FasterAPI"] + +[tool.hatch.version] +source = "vcs" diff --git a/tests/test_app_lifecycle.py b/tests/test_app_lifecycle.py new file mode 100644 index 0000000..0f139a7 --- /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..978a526 --- /dev/null +++ b/tests/test_background.py @@ -0,0 +1,48 @@ +"""Background task execution.""" + +import asyncio + +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..6397d77 --- /dev/null +++ b/tests/test_concurrency.py @@ -0,0 +1,47 @@ +"""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..b810bd9 --- /dev/null +++ b/tests/test_datastructures.py @@ -0,0 +1,30 @@ +"""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_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..cb4998b --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,44 @@ +"""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_response.py b/tests/test_response.py new file mode 100644 index 0000000..77a3af0 --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,126 @@ +"""Tests for response classes and ASGI emitters.""" + +import asyncio +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_testclient.py b/tests/test_testclient.py new file mode 100644 index 0000000..aa574fa --- /dev/null +++ b/tests/test_testclient.py @@ -0,0 +1,56 @@ +"""TestClient HTTP and WebSocket paths.""" + +import msgspec +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 From 2b3f864f1d52379feb521b5f1232c6755be8f775 Mon Sep 17 00:00:00 2001 From: Eshwar Chandra Vidhyasagar Thedla Date: Wed, 8 Apr 2026 23:54:01 -0500 Subject: [PATCH 02/12] Updated the benchmark workflow --- .github/workflows/benchmark.yml | 2 +- benchmarks/fiber/go.mod | 14 ++++++++++++++ benchmarks/fiber/go.sum | 27 +++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 benchmarks/fiber/go.sum diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 398f1e2..6a64293 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -30,7 +30,7 @@ jobs: - name: Build Go Fiber benchmark server working-directory: benchmarks/fiber - run: go mod download && go build -o fiberbench . + run: go mod tidy && go build -o fiberbench . - name: Enforce benchmark floors (ASGI + routing) run: python benchmarks/check_regressions.py diff --git a/benchmarks/fiber/go.mod b/benchmarks/fiber/go.mod index 9cfd1c6..a234988 100644 --- a/benchmarks/fiber/go.mod +++ b/benchmarks/fiber/go.mod @@ -3,3 +3,17 @@ 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= From 4c487001285b6ffcb59254bb65ede4fb3b35f52a Mon Sep 17 00:00:00 2001 From: Eshwar Chandra Vidhyasagar Thedla Date: Thu, 9 Apr 2026 00:31:38 -0500 Subject: [PATCH 03/12] docs: GitHub Actions Pages deploy, MkDocs metadata, README badges - Deploy docs via Pages artifact (configure/upload/deploy) with .nojekyll; document Settings requirement. - MkDocs: site_author, social links, Material repo icon; strict build verified. - README: PyPI/GitHub release badges, docs workflow + branch, website liveness, GitHub Pages link. - CI: ruff/mypy alignment; library typing (ASGIApp), tests override; tox; benchmark compare tweaks. --- .github/workflows/ci.yml | 10 ++ .github/workflows/docs.yml | 26 ++++- CONTRIBUTING.md | 10 +- FasterAPI/__init__.py | 4 +- FasterAPI/app.py | 190 +++++++++++++++++++++++---------- FasterAPI/background.py | 7 +- FasterAPI/concurrency.py | 24 +++-- FasterAPI/datastructures.py | 7 +- FasterAPI/dependencies.py | 47 +++++--- FasterAPI/exceptions.py | 24 +++-- FasterAPI/middleware.py | 140 ++++++++++++++---------- FasterAPI/openapi/generator.py | 28 +++-- FasterAPI/request.py | 53 +++++---- FasterAPI/response.py | 84 +++++++++------ FasterAPI/router.py | 69 +++++++----- FasterAPI/testclient.py | 28 ++--- FasterAPI/types.py | 11 ++ FasterAPI/websocket.py | 29 ++--- README.md | 14 ++- benchmarks/compare.py | 90 ++++++++++------ mkdocs.yml | 15 +++ pyproject.toml | 29 ++++- tests/__init__.py | 1 + tests/test_app_lifecycle.py | 2 +- tests/test_background.py | 3 - tests/test_concurrency.py | 1 - tests/test_datastructures.py | 1 - tests/test_deps.py | 16 ++- tests/test_exceptions.py | 11 +- tests/test_middleware.py | 18 ++-- tests/test_openapi.py | 52 ++++++--- tests/test_params.py | 49 +++++---- tests/test_response.py | 3 - tests/test_routing.py | 25 +++-- tests/test_testclient.py | 2 - tests/test_websocket.py | 90 ++++++++++------ tox.ini | 10 ++ 37 files changed, 805 insertions(+), 418 deletions(-) create mode 100644 FasterAPI/types.py create mode 100644 tests/__init__.py create mode 100644 tox.ini diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 935e04b..981276d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,16 @@ 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 --cov-fail-under=85 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7036773..2a009d2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,15 +1,26 @@ +# Deploy static site to GitHub Pages (repo Settings β†’ Pages β†’ Source: GitHub Actions). name: Docs on: push: branches: [master] + workflow_dispatch: permissions: - contents: write + 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 @@ -25,5 +36,14 @@ jobs: - name: Build MkDocs (strict) run: mkdocs build --strict - - name: Deploy to GitHub Pages - run: mkdocs gh-deploy --force + - 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/CONTRIBUTING.md b/CONTRIBUTING.md index 418b08e..f8959ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,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]" diff --git a/FasterAPI/__init__.py b/FasterAPI/__init__.py index 570dcb5..da3cbc4 100644 --- a/FasterAPI/__init__.py +++ b/FasterAPI/__init__.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from ._version import get_version @@ -94,7 +94,7 @@ ] -def __getattr__(name: str): +def __getattr__(name: str) -> Any: if name == "TestClient": try: from .testclient import TestClient as _TestClient diff --git a/FasterAPI/app.py b/FasterAPI/app.py index b253e4a..028917f 100644 --- a/FasterAPI/app.py +++ b/FasterAPI/app.py @@ -10,7 +10,8 @@ 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 @@ -26,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"] @@ -46,11 +48,21 @@ 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__( @@ -70,14 +82,14 @@ def __init__( 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: @@ -94,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: @@ -112,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: @@ -124,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() @@ -141,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) @@ -150,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"]) @@ -160,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") @@ -175,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) @@ -204,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 "/") @@ -233,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() @@ -263,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"), @@ -287,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 @@ -330,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 # ------------------------------------------------------------------ @@ -338,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: @@ -355,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 @@ -375,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 68a36d7..df23e4b 100644 --- a/FasterAPI/openapi/generator.py +++ b/FasterAPI/openapi/generator.py @@ -4,7 +4,8 @@ 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 @@ -59,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] = {} @@ -144,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]] = [] @@ -172,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 @@ -211,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", @@ -267,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"} @@ -275,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"} @@ -321,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"} @@ -333,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__ @@ -344,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 8bcd817..93d2ead 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # FasterAPI -[![PyPI version](https://img.shields.io/pypi/v/faster-api-web.svg)](https://pypi.org/project/faster-api-web/) +[![PyPI version](https://img.shields.io/pypi/v/faster-api-web.svg?logo=pypi&logoColor=white)](https://pypi.org/project/faster-api-web/) +[![GitHub release](https://img.shields.io/github/v/release/FasterApiWeb/FasterAPI?include_prereleases&sort=semver&logo=github&label=release)](https://github.com/FasterApiWeb/FasterAPI/releases) [![PyPI - Python](https://img.shields.io/pypi/pyversions/faster-api-web.svg)](https://pypi.org/project/faster-api-web/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/faster-api-web.svg)](https://pypi.org/project/faster-api-web/) -[![CI](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/ci.yml) -[![Benchmark](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/benchmark.yml/badge.svg)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/benchmark.yml) -[![Docs](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/docs.yml/badge.svg)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/docs.yml) -[![Documentation site](https://img.shields.io/badge/docs-GitHub%20Pages-5c6bc0)](https://fasterapiweb.github.io/FasterAPI/) +[![CI](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/ci.yml?query=branch%3Amaster) +[![Benchmark](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/benchmark.yml/badge.svg?branch=master)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/benchmark.yml?query=branch%3Amaster) +[![Docs workflow](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/docs.yml/badge.svg?branch=master)](https://github.com/FasterApiWeb/FasterAPI/actions/workflows/docs.yml?query=branch%3Amaster) +[![Docs site live](https://img.shields.io/website?url=https%3A%2F%2Ffasterapiweb.github.io%2FFasterAPI%2F&up_message=online&down_message=offline&label=docs%20site)](https://fasterapiweb.github.io/FasterAPI/) +[![Documentation](https://img.shields.io/badge/docs-GitHub%20Pages-5c6bc0?logo=githubpages)](https://fasterapiweb.github.io/FasterAPI/) [![codecov](https://codecov.io/gh/FasterApiWeb/FasterAPI/branch/master/graph/badge.svg)](https://codecov.io/gh/FasterApiWeb/FasterAPI) [![License: MIT](https://img.shields.io/github/license/FasterApiWeb/FasterAPI)](LICENSE) [![Docker](https://img.shields.io/badge/docker-ghcr.io-blue?logo=docker)](https://ghcr.io/fasterapiweb/fasterapi) @@ -155,6 +157,8 @@ pip install -e ".[dev]" 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 diff --git a/benchmarks/compare.py b/benchmarks/compare.py index 7c17d29..7fe1f70 100644 --- a/benchmarks/compare.py +++ b/benchmarks/compare.py @@ -17,7 +17,7 @@ import subprocess import sys import time -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: import httpx @@ -33,13 +33,14 @@ # 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() -> Optional[str]: +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 @@ -47,9 +48,8 @@ def _fiber_binary_path() -> Optional[str]: 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): @@ -106,6 +106,7 @@ async def create_user(user: User): # Benchmark runner # ─────────────────────────────────────────────── + async def _wait_for_server(url: str, timeout: float = 10.0) -> None: import httpx @@ -128,9 +129,8 @@ async def _benchmark_endpoint( path: str, total: int, concurrency: int, - json_body: Optional[dict] = None, + json_body: dict | None = None, ) -> dict[str, Any]: - import httpx latencies: list[float] = [] errors = 0 @@ -175,8 +175,8 @@ async def _fire() -> None: async def measure_http_rps_three_way( total: int, concurrency: int, - fiber_executable: Optional[str] = None, -) -> tuple[dict[str, dict[str, float]], Optional[str]]: + 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() @@ -189,12 +189,16 @@ async def measure_http_rps_three_way( 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_fiber: Optional[subprocess.Popen] = None + proc_fiber: subprocess.Popen | None = None if fiber_exe: env = os.environ.copy() env["PORT"] = str(port_fiber) @@ -209,7 +213,7 @@ async def measure_http_rps_three_way( proc_faster.start() proc_fastapi.start() - fiber_err: Optional[str] = None + fiber_err: str | None = None try: ready_faster.wait(timeout=15) ready_fastapi.wait(timeout=15) @@ -233,7 +237,9 @@ async def measure_http_rps_three_way( 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, + f"http://127.0.0.1:{port_fiber}", + total, + concurrency, ) out: dict[str, dict[str, float]] = {} @@ -264,7 +270,9 @@ async def measure_http_rps_three_way( 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 @@ -286,6 +294,7 @@ async def _run_all_benchmarks( # Comparison table # ─────────────────────────────────────────────── + def _print_header(total: int, concurrency: int) -> None: print() print("=" * 78) @@ -318,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") @@ -334,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() @@ -343,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() @@ -371,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) @@ -385,9 +402,7 @@ def main(total: int = 10_000, concurrency: int = 100) -> None: def _build_asgi_pair(): """Return (faster_app, fastapi_app) for micro-benchmarks.""" - import json as _json import msgspec as _msgspec - from FasterAPI.app import Faster class UserF(_msgspec.Struct): @@ -444,9 +459,13 @@ def measure_direct_asgi_rps(iterations: int = 50_000) -> dict[str, dict[str, flo 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), } @@ -504,7 +523,10 @@ def measure_routing_ops() -> dict[str, float]: 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, {}, + "GET", + f"/org/{{org_id}}/team/{{team_id}}/member{i}", + lambda: None, + {}, ) paths = ["/static/route25", "/users/42/action15", "/org/abc/team/xyz/member10"] @@ -561,9 +583,13 @@ def direct_benchmark() -> None: 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), } @@ -603,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() @@ -616,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/mkdocs.yml b/mkdocs.yml index 26b2fb0..6d21d50 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,9 +4,15 @@ 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 @@ -46,6 +52,15 @@ markdown_extensions: - 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: diff --git a/pyproject.toml b/pyproject.toml index c8a6008..d0af1e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,8 @@ 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", @@ -74,12 +76,33 @@ testpaths = ["tests"] [tool.mypy] python_version = "3.13" -warn_return_any = true +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"] 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 index 0f139a7..748802f 100644 --- a/tests/test_app_lifecycle.py +++ b/tests/test_app_lifecycle.py @@ -1,7 +1,6 @@ """Lifespan, errors, and middleware wiring.""" import pytest - from FasterAPI.app import Faster from FasterAPI.exceptions import HTTPException @@ -78,6 +77,7 @@ async def x(): def handle(request, exc: Boom): from FasterAPI.response import PlainTextResponse + return PlainTextResponse("handled", 418) app.add_exception_handler(Boom, handle) diff --git a/tests/test_background.py b/tests/test_background.py index 978a526..e1e6dd2 100644 --- a/tests/test_background.py +++ b/tests/test_background.py @@ -1,9 +1,6 @@ """Background task execution.""" -import asyncio - import pytest - from FasterAPI.background import BackgroundTask, BackgroundTasks diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index 6397d77..627b588 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -1,7 +1,6 @@ """Concurrency helpers (thread pool, process pool fallbacks).""" import pytest - from FasterAPI import concurrency as c diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py index b810bd9..0aca23f 100644 --- a/tests/test_datastructures.py +++ b/tests/test_datastructures.py @@ -1,7 +1,6 @@ """UploadFile and FormData helpers.""" import pytest - from FasterAPI.datastructures import FormData, UploadFile 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 index cb4998b..122fab9 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,7 +1,6 @@ """HTTP and validation exception types and default handlers.""" import pytest - from FasterAPI.exceptions import ( HTTPException, RequestValidationError, @@ -35,10 +34,12 @@ async def test_default_http_handler_with_headers(): @pytest.mark.asyncio async def test_validation_handler_shapes_errors(): - exc = RequestValidationError([ - {"loc": ["query", "q"], "msg": "missing", "type": "value_error"}, - {"loc": [], "msg": "x"}, - ]) + 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 index 77a3af0..a33b8bc 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -1,10 +1,8 @@ """Tests for response classes and ASGI emitters.""" -import asyncio from pathlib import Path import pytest - from FasterAPI.response import ( FileResponse, HTMLResponse, @@ -123,4 +121,3 @@ async def send(msg: dict) -> None: 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 index aa574fa..fe76f81 100644 --- a/tests/test_testclient.py +++ b/tests/test_testclient.py @@ -1,8 +1,6 @@ """TestClient HTTP and WebSocket paths.""" -import msgspec import pytest - from FasterAPI import Faster from FasterAPI.testclient import TestClient 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 From c7c0dc85adb61dabfbb494e15b907aeb7e135551 Mon Sep 17 00:00:00 2001 From: Eshwar Chandra Vidhyasagar Thedla Date: Thu, 9 Apr 2026 00:44:43 -0500 Subject: [PATCH 04/12] Updating the docs workflow. --- .github/workflows/docs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2a009d2..230b505 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -40,6 +40,8 @@ jobs: run: touch site/.nojekyll - uses: actions/configure-pages@v4 + with: + enablement: true - uses: actions/upload-pages-artifact@v3 with: From ed357d1925f4dc31ae0c8ae70493bf2fd46f4cec Mon Sep 17 00:00:00 2001 From: Eshwar Chandra Vidhyasagar Thedla Date: Thu, 9 Apr 2026 01:00:05 -0500 Subject: [PATCH 05/12] ci: improve Pages deploy diagnostics; publish TestPyPI on master - Docs: detect whether Pages is enabled and fail with clear instructions instead of integration errors. - Release: publish dev builds to TestPyPI on master merges; keep real PyPI + Docker + GitHub Release on v* tags. --- .github/workflows/docs.yml | 25 +++++++++++++++++++++-- .github/workflows/release.yml | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 230b505..86f40db 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -39,9 +39,30 @@ jobs: - name: Disable Jekyll (avoid 404 on paths with _) run: touch site/.nojekyll - - uses: actions/configure-pages@v4 + - name: Check Pages is enabled (required once) + id: pages_check + uses: actions/github-script@v7 with: - enablement: true + script: | + try { + await github.rest.repos.getPagesSite({ + owner: context.repo.owner, + repo: context.repo.repo, + }) + core.setOutput("enabled", "true") + } catch (e) { + // GitHub returns 404 when Pages isn't enabled for the repo. + core.setOutput("enabled", "false") + } + + - name: Fail with instructions if Pages disabled + if: steps.pages_check.outputs.enabled != 'true' + run: | + echo "::error::GitHub Pages is not enabled for this repository yet." + echo "Enable it once at: Settings β†’ Pages β†’ Build and deployment β†’ Source = GitHub Actions" + exit 1 + + - uses: actions/configure-pages@v4 - uses: actions/upload-pages-artifact@v3 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec12ca5..10ef50f 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,38 @@ 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: + id-token: write + contents: read + steps: + - 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/ + # ── 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 +62,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 @@ -48,6 +81,7 @@ jobs: # ── Step 2: Build wheel + sdist ──────────────────────────────────── build: needs: test + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -73,6 +107,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: @@ -90,6 +125,7 @@ jobs: # ── 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 @@ -124,6 +160,7 @@ jobs: # ── 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 From 04b41735591bdfe4c69cd083b4f90773c50b2d54 Mon Sep 17 00:00:00 2001 From: Eshwar Chandra Vidhyasagar Thedla Date: Thu, 9 Apr 2026 01:15:49 -0500 Subject: [PATCH 06/12] ci(docs): remove brittle Pages preflight The Pages API check can fail under token restrictions even when Pages is configured; rely on configure-pages/deploy-pages instead. --- .github/workflows/docs.yml | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 86f40db..2a009d2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -39,29 +39,6 @@ jobs: - name: Disable Jekyll (avoid 404 on paths with _) run: touch site/.nojekyll - - name: Check Pages is enabled (required once) - id: pages_check - uses: actions/github-script@v7 - with: - script: | - try { - await github.rest.repos.getPagesSite({ - owner: context.repo.owner, - repo: context.repo.repo, - }) - core.setOutput("enabled", "true") - } catch (e) { - // GitHub returns 404 when Pages isn't enabled for the repo. - core.setOutput("enabled", "false") - } - - - name: Fail with instructions if Pages disabled - if: steps.pages_check.outputs.enabled != 'true' - run: | - echo "::error::GitHub Pages is not enabled for this repository yet." - echo "Enable it once at: Settings β†’ Pages β†’ Build and deployment β†’ Source = GitHub Actions" - exit 1 - - uses: actions/configure-pages@v4 - uses: actions/upload-pages-artifact@v3 From 26e6f561e58d848fb89890fa2d752c95f1d4eba2 Mon Sep 17 00:00:00 2001 From: Eshwar Chandra Vidhyasagar Thedla Date: Thu, 9 Apr 2026 01:22:26 -0500 Subject: [PATCH 07/12] ci(release): publish TestPyPI using API token Trusted publishing requires PyPI/TestPyPI 'publisher' configuration; use TEST_PYPI_API_TOKEN for TestPyPI dev publishes while keeping OIDC trusted publishing for real PyPI tags. --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10ef50f..f327d68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,6 @@ jobs: runs-on: ubuntu-latest environment: testpypi permissions: - id-token: write contents: read steps: - uses: actions/checkout@v4 @@ -41,6 +40,8 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ + user: __token__ + password: ${{ secrets.TEST_PYPI_API_TOKEN }} # ── Gate: only release from master ────────────────────────────────── check-branch: From 86664690d4c624ba71e6dbb6b663e28dc78d7fb3 Mon Sep 17 00:00:00 2001 From: Eshwar Chandra Vidhyasagar Thedla Date: Thu, 9 Apr 2026 01:25:03 -0500 Subject: [PATCH 08/12] ci(release): fail fast if TEST_PYPI_API_TOKEN missing Avoid fallback to OIDC trusted publishing when the token secret isn't configured. --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f327d68..1e80f0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,14 @@ jobs: 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 From 4643703f7657534379a5affbfcff6045238beb06 Mon Sep 17 00:00:00 2001 From: Eshwar Chandra Vidhyasagar Thedla Date: Thu, 9 Apr 2026 01:36:45 -0500 Subject: [PATCH 09/12] build: make vcs dev versions TestPyPI-compatible Disable local version identifiers (+g) for hatch-vcs so dev builds can be uploaded to TestPyPI. --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d0af1e2..8fedbca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,3 +108,8 @@ 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" From ae9fc3f613d189b4d85b9f68f9478a681eef351b Mon Sep 17 00:00:00 2001 From: Eshwar Chandra Vidhyasagar Thedla Date: Thu, 9 Apr 2026 08:28:34 -0500 Subject: [PATCH 10/12] ci: run benchmarks on stage pushes; add label-driven auto-tag releases - Benchmark workflow now runs on stage pushes as well as PRs. - New auto-tag workflow creates vX.Y.Z tags on merged master PRs with release:patch|minor|major labels. - CONTRIBUTING documents release label behavior. --- .github/workflows/auto-tag-release.yml | 73 ++++++++++++++++++++++++++ .github/workflows/benchmark.yml | 2 + CONTRIBUTING.md | 1 + 3 files changed, 76 insertions(+) create mode 100644 .github/workflows/auto-tag-release.yml 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 6a64293..9edc716 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] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8959ce..15601bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,7 @@ For **security-sensitive** reports, use the process in [SECURITY.md](SECURITY.md 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. --- From bafc89842e9b7902e93f9e55eb6ab64aac6d17e4 Mon Sep 17 00:00:00 2001 From: Eshwar Chandra Vidhyasagar Thedla Date: Thu, 9 Apr 2026 09:31:54 -0500 Subject: [PATCH 11/12] ci(release): fix ghcr lowercase tags and make publish idempotent normalize both owner and repo to lowercase for ghcr tags. use skip-existing for TestPyPI and PyPI publishes to avoid rerun failures. Made-with: Cursor --- .github/workflows/release.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e80f0e..fc0ae14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,7 @@ jobs: 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: @@ -130,6 +131,8 @@ 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: @@ -157,14 +160,18 @@ 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: From 5e6c5aba20f85ad0858fee925c60341111c17ed7 Mon Sep 17 00:00:00 2001 From: Eshwar Chandra Vidhyasagar Thedla Date: Thu, 9 Apr 2026 09:38:30 -0500 Subject: [PATCH 12/12] ci(benchmark): only comment results on PR events Avoid calling the Issues comments API on stage push runs where no PR number exists. Made-with: Cursor --- .github/workflows/benchmark.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 9edc716..9c13c94 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -49,6 +49,7 @@ jobs: fi - name: Comment benchmark results on PR + if: github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: |