Skip to content

A FastAPI app

This recipe takes a web API from nothing to a running server and a passing CI workflow: install the tools, create the project, add a dependency, run the development server, add a route and its test, run the quality gate, define tasks, build distributions and check everything on GitHub Actions.

The commands and outputs below come from a real run on Linux. The Windows variants differ only where the shell does; they are shown in tabs.

Prerequisites and tested versions

Tool Version Role
uv 0.9.26 or newer Environments, locking, building
Python 3.10 or newer The project's interpreter
FastAPI >=0.161.1 The web framework (resolved 0.161.1 in this guide)
Uvicorn uvicorn[standard]>=0.54.0 The ASGI server run by pyfaro dev (resolved 0.54.0)
Ruff 0.16.10 check (format and lint)
ty 0.0.85 check (types)
pytest 9.1.1 test
  • The generated project pins Ruff, ty and pytest in its dev dependency group and declares FastAPI and Uvicorn as runtime dependencies.
  • FastAPI's TestClient needs an HTTP client. The dev group provides httpx2 on Python 3.11 and newer, and httpx on Python 3.10.
  • Pyfaro needs uv but never installs it, and never downloads Python unless you run pyfaro python install.
  • The guide was run with Python 3.13.11. See also the tested versions and platform support.

1. Install uv and Pyfaro

$ curl -LsSf https://astral.sh/uv/install.sh | sh
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

This guide needs Pyfaro 0.4.0-alpha.1 or newer

create fastapi, dev and named tasks are new in 0.4.0-alpha.1. Install that version or a newer one as described in Installation.

Check both tools:

$ pyfaro --version
pyfaro 0.5.0-beta.1
$ pyfaro tools uv
uv: /home/you/.local/bin/uv (uv 0.9.26)

2. Create the project

$ pyfaro create fastapi demo-api
Created FastAPI app at ~/projects/demo-api
$ cd demo-api

create fastapi writes files only; it does not run uv or touch the network. The project is a src/ layout package with two routes and their tests:

demo-api/
├── README.md
├── pyproject.toml
├── src/demo_api/
│   ├── __init__.py
│   ├── main.py
│   └── py.typed
└── tests/test_main.py

pyproject.toml already carries everything the next commands need, including the entry point that pyfaro dev runs:

pyproject.toml
[project]
name = "demo-api"
version = "0.1.0"
readme = "README.md"
requires-python = ">=3.10"
dependencies = ["fastapi>=0.161.1", "uvicorn[standard]>=0.54.0"]

[dependency-groups]
dev = ["pytest==9.1.1", "ruff==0.16.10", "ty==0.0.85", "httpx2==2.16.0; python_full_version >= '3.11'", "httpx==0.28.1; python_full_version < '3.11'"]

[tool.pyfaro]
schema-version = 1

[tool.pyfaro.dev]
preset = "fastapi"
entry-point = "demo_api.main:app"

(The build system, Ruff and pytest tables are left out here.) src/demo_api/main.py holds the application:

src/demo_api/main.py
"""HTTP application for demo-api."""

from fastapi import FastAPI

app = FastAPI(title="demo-api")


@app.get("/")
def read_root() -> dict[str, str]:
    return {"message": "Hello from demo-api!"}


@app.get("/health")
def read_health() -> dict[str, str]:
    return {"status": "ok"}

3. Sync the environment

$ pyfaro sync
Using CPython 3.13.11
Creating virtual environment at: .venv
Resolved 40 packages in 33ms
   Building demo-api @ file://~/projects/demo-api
      Built demo-api @ file://~/projects/demo-api
Prepared 1 package in 3ms
Installed 31 packages in 27ms
 + annotated-doc==0.0.5
 + annotated-types==0.8.0
 + anyio==4.15.1
 ...
 + fastapi==0.161.1
 ...
 + uvicorn==0.54.0
 ...

This creates .venv and uv.lock. The generated project already passes the gate, so you can run pyfaro check and pyfaro test right away.

4. Add a dependency

The API will read its greeting from the environment, which is what pydantic-settings is for. pyfaro add edits pyproject.toml and the lock file but does not install, so sync afterwards:

$ pyfaro add pydantic-settings
Resolved 42 packages in 25ms
$ pyfaro sync
Resolved 42 packages in 0.70ms
   Building demo-api @ file://~/projects/demo-api
      Built demo-api @ file://~/projects/demo-api
Prepared 1 package in 3ms
Uninstalled 1 package in 0.13ms
Installed 2 packages in 3ms
 ~ demo-api==0.1.0 (from file://~/projects/demo-api)
 + pydantic-settings==2.16.1

pyproject.toml now lists it among the runtime dependencies; the exact lower bound is chosen by uv:

dependencies = [
    "fastapi>=0.161.1",
    "pydantic-settings>=2.15.0",
    "uvicorn[standard]>=0.54.0",
]

5. Run the development server

$ pyfaro dev
pyfaro dev: starting demo_api.main:app with uvicorn (reload on) at http://127.0.0.1:8000 - press Ctrl-C to stop
INFO:     Will watch for changes in these directories: ['~/projects/demo-api/src']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [518945] using WatchFiles
INFO:     Started server process [519644]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

Lines starting with pyfaro dev: are Pyfaro's, on standard error; everything else is Uvicorn's own output, passed through unchanged. Pyfaro adds no file watcher: reloading is Uvicorn's.

Leave it running and, in a second terminal, call the app:

$ curl http://127.0.0.1:8000/health
{"status":"ok"}
PS> Invoke-RestMethod http://127.0.0.1:8000/health

status
------
ok

The interactive documentation is at http://127.0.0.1:8000/docs.

A port that is busy. If something already listens on port 8000, Pyfaro stops before launching:

$ pyfaro dev
pyfaro dev: port 8000 on 127.0.0.1 is already in use
pyfaro dev: stop the process using it, run pyfaro dev -- --port 8001, or set args = ["--port", "8001"] in [tool.pyfaro.dev]
$ echo $?
1

(In PowerShell the exit status is $LASTEXITCODE.) Arguments after -- go to Uvicorn, so pyfaro dev -- --port 8001 serves on another port for this run. To change it for good, set args = ["--port", "8001"] in [tool.pyfaro.dev]. See pyfaro dev for every setting.

Stopping. Press Ctrl-C in the terminal running pyfaro dev. Pyfaro stops the whole process tree (Uvicorn's reloader and its worker), prints pyfaro dev: stopped and exits with status 130. On Linux and macOS it waits about five seconds for a clean shutdown; on Windows the tree is terminated at once. No process is left behind, and the port is free again.

6. Add a route and its test

Keep pyfaro dev running while you edit: the server reloads when you save.

Create src/demo_api/settings.py:

src/demo_api/settings.py
"""Application settings, read from DEMO_API_* environment variables."""

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="DEMO_API_")

    greeting: str = "Hello"


def get_settings() -> Settings:
    return Settings()

and replace src/demo_api/main.py with:

src/demo_api/main.py
"""HTTP application for demo-api."""

from typing import Annotated

from fastapi import Depends, FastAPI

from demo_api.settings import Settings, get_settings

app = FastAPI(title="demo-api")


@app.get("/")
def read_root() -> dict[str, str]:
    return {"message": "Hello from demo-api!"}


@app.get("/health")
def read_health() -> dict[str, str]:
    return {"status": "ok"}


@app.get("/hello/{name}")
def read_hello(
    name: str, settings: Annotated[Settings, Depends(get_settings)]
) -> dict[str, str]:
    return {"message": f"{settings.greeting}, {name}!"}

In the pyfaro dev terminal, Uvicorn notices the change and restarts the worker:

WARNING:  WatchFiles detected changes in 'src/demo_api/settings.py', 'src/demo_api/main.py'. Reloading...
INFO:     Shutting down
INFO:     Waiting for application shutdown.
INFO:     Application shutdown complete.
INFO:     Finished server process [519644]
INFO:     Started server process [544186]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
$ curl http://127.0.0.1:8000/hello/Ada
{"message":"Hello, Ada!"}
PS> Invoke-RestMethod http://127.0.0.1:8000/hello/Ada

message
-------
Hello, Ada!

Now the tests. Replace tests/test_main.py:

tests/test_main.py
"""Smoke tests for the HTTP routes."""

import pytest
from fastapi.testclient import TestClient

from demo_api.main import app

client = TestClient(app)


def test_read_root() -> None:
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Hello from demo-api!"}


def test_read_health() -> None:
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}


def test_read_hello() -> None:
    response = client.get("/hello/Ada")
    assert response.status_code == 200
    assert response.json() == {"message": "Hello, Ada!"}


def test_read_hello_with_custom_greeting(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("DEMO_API_GREETING", "Bonjour")
    response = client.get("/hello/Ada")
    assert response.json() == {"message": "Bonjour, Ada!"}

Stop the server with Ctrl-C before moving on, or open another terminal.

7. Check and test

pyfaro check runs the format check, Ruff and ty; pyfaro test runs pytest:

$ pyfaro check
5 files already formatted
All checks passed!
All checks passed!

== pyfaro check summary ==
format: ok
lint: ok
type: ok
check: ok
$ pyfaro test
============================= test session starts ==============================
platform linux -- Python 3.13.11, pytest-9.1.1, pluggy-1.7.0
rootdir: ~/projects/demo-api
configfile: pyproject.toml
testpaths: tests
plugins: anyio-4.15.1
collected 4 items

tests/test_main.py ....                                                  [100%]

============================== 4 passed in 0.33s ===============================

Both commands are the ones CI runs later. If check reports formatting or lint problems, pyfaro check --fix applies the safe fixes; see The quality gate.

8. Define tasks

A named task gives a short name to a command you run often. Add this to pyproject.toml:

pyproject.toml
[tool.pyfaro.tasks]
openapi = ["python", "-c", "import json; from demo_api.main import app; print(json.dumps(app.openapi(), indent=2))"]
serve = { command = ["uvicorn", "demo_api.main:app", "--port", "8080"], env = { DEMO_API_GREETING = "Welcome" }, description = "Serve without reload, as in production" }

An array is the program followed by its arguments. A table can also set env, cwd and a description. No shell is involved, so the same definitions work on Linux, macOS and Windows.

Run a task with pyfaro run <task>. The first task prints the OpenAPI schema of the app on standard output (uv's own messages go to standard error):

$ pyfaro run openapi > openapi.json
$ head -n 8 openapi.json
{
  "openapi": "3.1.0",
  "info": {
    "title": "demo-api",
    "version": "0.1.0"
  },
  "paths": {
    "/": {
PS> pyfaro run openapi > openapi.json
PS> Get-Content openapi.json -TotalCount 8

Windows PowerShell 5.1 writes > redirections as UTF-16; use | Set-Content -Encoding utf8 openapi.json there (PowerShell 7 writes UTF-8).

The second starts Uvicorn without the reloader and with the task's env. Extra arguments after -- are appended, here to pick another port:

$ pyfaro run serve -- --port 8081
INFO:     Started server process [608218]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8081 (Press CTRL+C to quit)
$ curl http://127.0.0.1:8081/hello/Ada
{"message":"Welcome, Ada!"}
PS> Invoke-RestMethod http://127.0.0.1:8081/hello/Ada

message
-------
Welcome, Ada!

Ctrl-C stops it (error: operation cancelled, exit status 130). A name before -- is always a task and a program always follows --; an unknown task lists the defined ones. The rules are in pyfaro run.

9. Build distributions

pyfaro build calls uv build with the backend the project declares:

$ pyfaro build
Building source distribution (uv build backend)...
Building wheel from source distribution (uv build backend)...
Successfully built dist/demo_api-0.1.0.tar.gz
Successfully built dist/demo_api-0.1.0-py3-none-any.whl
built dist/demo_api-0.1.0.tar.gz
built dist/demo_api-0.1.0-py3-none-any.whl

dist/ now holds the source distribution and the wheel. Publish them with uv publish, or ship the app another way, for example in a container image.

10. Continuous integration

Commit the project with its uv.lock, and add a .gitignore (create does not write one) that excludes at least .venv/, dist/ and __pycache__/. Then save this as .github/workflows/ci.yml. It runs sync --locked, check and test on Linux and Windows, exactly the commands you ran above. The Continuous integration page explains each step and the checksum verification in more detail.

Builds Pyfaro from crates.io with Rust, so it takes longer than the release archive. Pin the version for a reproducible build.

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  quality:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false

      - name: Install uv
        uses: astral-sh/setup-uv@v7
        with:
          version: "0.9.26"

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

      - name: Install pyfaro
        run: cargo install pyfaro --version 0.5.0-beta.1 --locked

      - name: Show tool versions
        run: |
          pyfaro --version
          uv --version

      - name: Sync the locked environment
        run: pyfaro sync --locked

      - name: Check formatting, lint and types
        run: pyfaro check

      - name: Run the tests
        run: pyfaro test

For 0.4.0-alpha.1 or newer. Set PYFARO_VERSION to the version you pin. Linux and Windows archives differ, so each platform has its own install step.

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  quality:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
    env:
      PYFARO_VERSION: 0.5.0-beta.1
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false

      - name: Install uv
        uses: astral-sh/setup-uv@v7
        with:
          version: "0.9.26"

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

      - name: Install pyfaro (Linux)
        if: runner.os == 'Linux'
        shell: bash
        run: |
          set -euo pipefail
          name="pyfaro-${PYFARO_VERSION}-x86_64-unknown-linux-gnu"
          url="https://github.com/pyfaro/pyfaro/releases/download/v${PYFARO_VERSION}"
          curl -fsSLO "${url}/${name}.tar.gz"
          curl -fsSLO "${url}/SHA256SUMS"
          grep " ${name}.tar.gz\$" SHA256SUMS | sha256sum --check -
          tar -xzf "${name}.tar.gz"
          mkdir -p "$HOME/.local/bin"
          install "${name}/pyfaro" "$HOME/.local/bin/pyfaro"
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
          rm -rf SHA256SUMS "${name}" "${name}.tar.gz"

      - name: Install pyfaro (Windows)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          $name = "pyfaro-$env:PYFARO_VERSION-x86_64-pc-windows-msvc"
          $url = "https://github.com/pyfaro/pyfaro/releases/download/v$env:PYFARO_VERSION"
          Invoke-WebRequest "$url/$name.zip" -OutFile "$name.zip"
          Invoke-WebRequest "$url/SHA256SUMS" -OutFile SHA256SUMS
          $expected = (Select-String -Path SHA256SUMS -Pattern " $name.zip$").Line.Split(" ")[0]
          $actual = (Get-FileHash "$name.zip" -Algorithm SHA256).Hash.ToLower()
          if ($actual -ne $expected) { throw "SHA-256 mismatch for $name.zip" }
          Expand-Archive "$name.zip" -DestinationPath .
          $bin = Join-Path $env:RUNNER_TEMP "pyfaro-bin"
          New-Item -ItemType Directory -Path $bin -Force | Out-Null
          Copy-Item "$name\pyfaro.exe" $bin
          Add-Content -Path $env:GITHUB_PATH -Value $bin
          Remove-Item -Recurse -Force SHA256SUMS, $name, "$name.zip"

      - name: Show tool versions
        run: |
          pyfaro --version
          uv --version

      - name: Sync the locked environment
        run: pyfaro sync --locked

      - name: Check formatting, lint and types
        run: pyfaro check

      - name: Run the tests
        run: pyfaro test

pyfaro sync --locked fails when uv.lock is missing or out of date, so a stale lock is caught in CI instead of papered over. check and test do not sync on their own, which is why sync comes first. The dev table and the tasks are not needed in CI.

Troubleshooting

Symptom Fix
pyfaro dev: no [tool.pyfaro.dev] table The project was not created with create fastapi. Add the table shown in step 2.
pyfaro dev: uvicorn is not declared in the project dependencies Run pyfaro add "uvicorn[standard]", then pyfaro sync.
port 8000 on 127.0.0.1 is already in use Stop the other process or use pyfaro dev -- --port 8001.
unknown [tool.pyfaro] setting: dev (or tasks) Pyfaro older than 0.4.0-alpha.1 does not know [tool.pyfaro.dev] or [tool.pyfaro.tasks]; upgrade it.
check or test cannot import a package Run pyfaro sync after pyfaro add.

More in Troubleshooting.

Next steps