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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/auto-tag-release.yml
Original file line number Diff line number Diff line change
@@ -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"
210 changes: 77 additions & 133 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
name: Benchmark

on:
push:
branches: [stage]
pull_request:
branches: [master, stage]

Expand All @@ -14,187 +16,129 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "1.22"

- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install dependencies
- name: Install Python dependencies
run: |
pip install --upgrade pip
pip install -e ".[dev,benchmark]"

- name: Run ASGI benchmark
id: bench
run: |
python -c "
import asyncio, time, sys, json as _json
sys.path.insert(0, '.')
N = 50_000

import msgspec
from FasterAPI.app import Faster

class UserF(msgspec.Struct):
name: str
email: str

fapp = Faster(openapi_url=None, docs_url=None, redoc_url=None)

@fapp.get('/health')
async def fh(): return {'status': 'ok'}

@fapp.get('/users/{user_id}')
async def fg(user_id: str): return {'id': user_id, 'name': 'test'}

@fapp.post('/users')
async def fp(user: UserF): return {'name': user.name, 'email': user.email}

from fastapi import FastAPI
from pydantic import BaseModel

class UserP(BaseModel):
name: str
email: str

faapp = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)

@faapp.get('/health')
async def fah(): return {'status': 'ok'}

@faapp.get('/users/{user_id}')
async def fag(user_id: str): return {'id': user_id, 'name': 'test'}
- name: Build Go Fiber benchmark server
working-directory: benchmarks/fiber
run: go mod tidy && go build -o fiberbench .

@faapp.post('/users')
async def fap(user: UserP): return {'name': user.name, 'email': user.email}
- name: Enforce benchmark floors (ASGI + routing)
run: python benchmarks/check_regressions.py

async def make_scope(method, path, body=None):
scope = {'type':'http','method':method,'path':path,'query_string':b'',
'headers':[(b'content-type',b'application/json'),(b'host',b'localhost')],
'client':('127.0.0.1',9999)}
body_bytes = _json.dumps(body).encode() if body else b''
sent = []
async def receive(): return {'type':'http.request','body':body_bytes,'more_body':False}
async def send(msg): sent.append(msg)
return scope, receive, send
- name: Export HTTP / ASGI / routing JSON for PR comment
run: python benchmarks/export_pr_benchmarks.py

async def bench(app, method, path, body=None):
for _ in range(500): s,r,sn = await make_scope(method,path,body); await app(s,r,sn)
start = time.perf_counter()
for _ in range(N): s,r,sn = await make_scope(method,path,body); await app(s,r,sn)
return round(N/(time.perf_counter()-start))

async def main():
body = {'name':'Alice','email':'alice@test.com'}
results = {}
for label,m,p,b in [('health','GET','/health',None),('users_get','GET','/users/42',None),('users_post','POST','/users',body)]:
f_rps = await bench(fapp,m,p,b)
fa_rps = await bench(faapp,m,p,b)
results[label] = {'fasterapi': f_rps, 'fastapi': fa_rps, 'speedup': round(f_rps/fa_rps, 2)}
print(_json.dumps(results))

asyncio.run(main())
" > bench_results.json

- name: Run routing benchmark
id: routing
- name: Fail if Fiber HTTP server did not start
run: |
python -c "
import time, re, sys, json
sys.path.insert(0, '.')
from FasterAPI.router import RadixRouter

router = RadixRouter()
for i in range(50): router.add_route('GET', f'/static/route{i}', lambda: None, {})
for i in range(30): router.add_route('GET', f'/users/{{id}}/action{i}', lambda: None, {})
for i in range(20): router.add_route('GET', f'/org/{{org_id}}/team/{{team_id}}/member{i}', lambda: None, {})

paths = ['/static/route25','/users/42/action15','/org/abc/team/xyz/member10']
N = 500_000
start = time.perf_counter()
for _ in range(N):
for p in paths: router.resolve('GET', p)
radix_ops = round((N*3)/(time.perf_counter()-start))

regex_routes = []
for i in range(50): regex_routes.append((re.compile(f'^/static/route{i}\$'), None))
for i in range(30): regex_routes.append((re.compile(r'^/users/(\w+)/action' + str(i) + r'\$'), None))
for i in range(20): regex_routes.append((re.compile(r'^/org/(\w+)/team/(\w+)/member' + str(i) + r'\$'), None))

def regex_resolve(path):
for p, h in regex_routes:
m = p.match(path)
if m: return h, m.groups()
return None, ()

start = time.perf_counter()
for _ in range(N):
for p in paths: regex_resolve(p)
regex_ops = round((N*3)/(time.perf_counter()-start))

print(json.dumps({'radix': radix_ops, 'regex': regex_ops, 'speedup': round(radix_ops/regex_ops, 1)}))
" > routing_results.json
if [[ -f fiber_warn.txt ]]; then
echo "::error::Fiber benchmark server failed to start"
cat fiber_warn.txt
exit 1
fi

- name: Comment benchmark results on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const bench = JSON.parse(fs.readFileSync('bench_results.json', 'utf8'));
let asgi = {};
try {
asgi = JSON.parse(fs.readFileSync('asgi_micro.json', 'utf8'));
} catch (e) { /* optional file */ }
const routing = JSON.parse(fs.readFileSync('routing_results.json', 'utf8'));

// Baseline numbers from README (Python 3.13.7, Apple Silicon)
const baseline = {
health: { fasterapi: 335612, fastapi: 49005, speedup: 6.85 },
users_get: { fasterapi: 282835, fastapi: 32391, speedup: 8.73 },
users_post: { fasterapi: 193225, fastapi: 27031, speedup: 7.15 },
routing: { radix: 1104318, speedup: 7.6 },
asgi_speedup: { health: 6.85, users_get: 8.73, users_post: 7.15 },
routing_speedup: 7.6,
};

function delta(current, base) {
if (base === undefined || base === 0) return '—';
const pct = ((current - base) / base * 100).toFixed(1);
if (pct > 2) return `🟢 +${pct}%`;
if (pct < -5) return `🔴 ${pct}%`;
return `⚪ ${pct}%`;
if (pct > 2) return `+${pct}%`;
if (pct < -5) return `${pct}%`;
return `${pct}%`;
}

function reqCell(n) {
if (n === undefined || n === null) return '—';
return `${Math.round(n).toLocaleString()}/s`;
}

function fiberCell(n) {
if (n === undefined || n === null) return '—';
return `**${Math.round(n).toLocaleString()}/s**`;
}

const body = `## Benchmark Results
const rows = ['health', 'users_get', 'users_post'];
const labels = { health: 'GET /health', users_get: 'GET /users/{id}', users_post: 'POST /users' };

> Automated benchmark on \`ubuntu-latest\`, Python 3.13. Baseline is from README (Apple Silicon).
> CI runners are slower than local machines — **compare the speedup ratio, not raw req/s.**
let httpTable = '| Endpoint | FasterAPI | FastAPI | Fiber (Go) | F / Fast |\n|---|---|---|---|---|\n';
for (const k of rows) {
const b = bench[k];
const sp = b.speedup !== undefined ? `${b.speedup.toFixed(2)}x` : '—';
httpTable += `| \`${labels[k]}\` | **${reqCell(b.fasterapi)}** | ${reqCell(b.fastapi)} | ${fiberCell(b.fiber)} | **${sp}** |\n`;
}

### Framework-Level (Direct ASGI, ${(50000).toLocaleString()} requests)
let asgiBlock = '';
if (asgi.health) {
asgiBlock = `
#### Direct ASGI (no HTTP; ${(50000).toLocaleString()} iterations)

| Endpoint | FasterAPI | FastAPI | Speedup | vs Baseline Speedup |
| Endpoint | FasterAPI | FastAPI | Speedup | vs README ASGI ratio |
|---|---|---|---|---|
| \`GET /health\` | **${bench.health.fasterapi.toLocaleString()}/s** | ${bench.health.fastapi.toLocaleString()}/s | **${bench.health.speedup}x** | ${delta(bench.health.speedup, baseline.health.speedup)} |
| \`GET /users/{id}\` | **${bench.users_get.fasterapi.toLocaleString()}/s** | ${bench.users_get.fastapi.toLocaleString()}/s | **${bench.users_get.speedup}x** | ${delta(bench.users_get.speedup, baseline.users_get.speedup)} |
| \`POST /users\` | **${bench.users_post.fasterapi.toLocaleString()}/s** | ${bench.users_post.fastapi.toLocaleString()}/s | **${bench.users_post.speedup}x** | ${delta(bench.users_post.speedup, baseline.users_post.speedup)} |
| \`GET /health\` | **${Math.round(asgi.health.fasterapi).toLocaleString()}/s** | ${Math.round(asgi.health.fastapi).toLocaleString()}/s | **${asgi.health.speedup.toFixed(2)}x** | ${delta(asgi.health.speedup, baseline.asgi_speedup.health)} |
| \`GET /users/{id}\` | **${Math.round(asgi.users_get.fasterapi).toLocaleString()}/s** | ${Math.round(asgi.users_get.fastapi).toLocaleString()}/s | **${asgi.users_get.speedup.toFixed(2)}x** | ${delta(asgi.users_get.speedup, baseline.asgi_speedup.users_get)} |
| \`POST /users\` | **${Math.round(asgi.users_post.fasterapi).toLocaleString()}/s** | ${Math.round(asgi.users_post.fastapi).toLocaleString()}/s | **${asgi.users_post.speedup.toFixed(2)}x** | ${delta(asgi.users_post.speedup, baseline.asgi_speedup.users_post)} |
`;
}

const body = `## Benchmark results

> Ubuntu runner, Python 3.13. **HTTP table** uses the same httpx load against uvicorn (Python) and Fiber (Go). **Direct ASGI** (below) is Python-only and excludes network I/O.

### HTTP throughput (FasterAPI vs FastAPI vs Fiber)

${httpTable}

${asgiBlock}

### Routing (100 routes, 1.5M lookups)
### Routing (radix vs regex, ${(500000 * 3).toLocaleString()} lookups)

| Router | Ops/s | Speedup | vs Baseline |
| Router | Ops/s | Speedup | vs README |
|---|---|---|---|
| **Radix tree** | **${routing.radix.toLocaleString()}** | **${routing.speedup}x** | ${delta(routing.speedup, baseline.routing.speedup)} |
| Regex | ${routing.regex.toLocaleString()} | 1.0x | |
| **Radix** | **${Math.round(routing.radix).toLocaleString()}** | **${routing.speedup.toFixed(1)}x** | ${delta(routing.speedup, baseline.routing_speedup)} |
| Regex | ${Math.round(routing.regex).toLocaleString()} | 1.0x | |

<details>
<summary>How to read this</summary>

- **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.

</details>`;

// 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({
Expand Down
14 changes: 12 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,26 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Ruff (lint + format check)
if: matrix.python-version == '3.13'
run: |
ruff format --check FasterAPI tests benchmarks
ruff check FasterAPI tests benchmarks

- name: Mypy (strict on library)
if: matrix.python-version == '3.13'
run: mypy FasterAPI tests

- name: Run tests with coverage
run: |
pytest --cov=FasterAPI --cov-report=xml --cov-report=term-missing
pytest --cov=FasterAPI --cov-report=xml --cov-report=term-missing --cov-fail-under=85

- name: Upload coverage to Codecov
if: matrix.python-version == '3.13'
uses: codecov/codecov-action@v4
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 }}
Loading
Loading