Skip to content

PACE with Python

This tutorial shows how to configure PACE for a Python project. FORGE will write idiomatic Python code, GATE will run pytest, and SENTINEL will audit for Python-specific security risks. Note: the Quickstart uses Python too — this tutorial goes deeper into FastAPI, test coverage, linting, and database testing.

Prerequisites

  • Python 3.11 or 3.12
  • An existing project with at least a minimal test suite
  • pytest 7+ (recommended)
  • An API key for your LLM provider

Project layout assumed by this tutorial

my-api/
├── pace/ ← PACE subdirectory
│ └── pace.config.yaml
├── src/
│ ├── api/
│ ├── services/
│ └── models/
├── tests/
├── pyproject.toml ← or requirements.txt
└── .pace/

1 — Install PACE

Terminal window
# From your repo root
git clone https://github.com/pace-framework-org/pace-framework-starter pace
cd pace
python -m venv .venv && source .venv/bin/activate
pip install PyYAML jsonschema anthropic

2 — Configure for Python

Edit pace/pace.config.yaml:

framework_version: "1.0"
product:
name: "My API"
description: >
A FastAPI REST API using Pydantic v2 for all request and response schemas.
Business logic lives in service classes; route handlers delegate immediately
to services. All database access goes through repository classes — no raw SQL
in services or routes. Uses pytest-mock (mocker fixture) for all mocking.
Follows the repository pattern with an ABC interface and concrete implementations.
github_org: "my-org"
sprint:
duration_days: 14
source:
dirs:
- name: "src"
path: "src/"
language: "Python"
description: "FastAPI routes, service layer, and domain models"
- name: "tests"
path: "tests/"
language: "Python"
description: "pytest unit and integration tests"
tech:
primary_language: "Python 3.12"
ci_system: "GitHub Actions"
test_command: "pytest -v --tb=short"
build_command: "ruff check src/ && ruff format --check src/"
platform:
ci: local
llm:
provider: anthropic
model: claude-sonnet-4-6

Test runner variants

Setuptest_command
pytest (default)pytest -v --tb=short
With coveragepytest -v --tb=short --cov=src --cov-fail-under=80
Specific directorypytest tests/unit/ -v --tb=short
Parallel (pytest-xdist)pytest -n auto -v --tb=short
Skip slow testspytest -m "not slow" -v --tb=short
Djangopython manage.py test --verbosity=2

3 — FORGE hints for Python

The most effective way to guide FORGE is to describe your conventions in product.description. FORGE reads this before writing any code.

FastAPI projects

product:
description: >
A FastAPI REST API. Use Pydantic v2 models for all request/response schemas.
Business logic in service classes, not route handlers.
All database access through repository classes — no raw SQL in services.
Use pytest-mock (mocker fixture) for mocking, never unittest.mock directly.
Type-annotate every function. Use X | None not Optional[X] (Python 3.10+).

Django projects

product:
description: >
A Django REST Framework API. Class-based views with DRF serializers.
All test classes extend TestCase or APITestCase. Use factory_boy for fixtures.
No business logic in models — use services/. Follow Google-style docstrings.

4 — Set credentials

Terminal window
export ANTHROPIC_API_KEY="sk-ant-..."

5 — Write a Python sprint plan

Create plan.yaml at your repo root:

release: v1.0
stories:
- id: story-1
title: "Subscription Pydantic model, repository interface, and in-memory implementation"
status: pending
acceptance_criteria:
- "Subscription Pydantic model has id, user_id, plan, status, and created_at fields"
- "SubscriptionRepository ABC defines create(), get_by_id(), and list_by_user() methods"
- "InMemorySubscriptionRepository implements SubscriptionRepository"
- "Unit tests cover save-and-retrieve round-trip and list_by_user filtering"
- "pytest exits 0"
out_of_scope:
- "Database persistence"
- "REST endpoints"
- id: story-2
title: "SubscriptionService with plan upgrade validation"
status: pending
acceptance_criteria:
- "upgrade_plan() raises InvalidUpgradeError when downgrading to a lower tier"
- "upgrade_plan() raises SubscriptionNotFoundError for an unknown subscription id"
- "Unit tests use pytest-mock to mock SubscriptionRepository"
- "pytest exits 0"
- id: story-3
title: "POST /subscriptions and GET /subscriptions/{id} FastAPI endpoints"
status: pending
acceptance_criteria:
- "POST /subscriptions returns 201 with subscription JSON"
- "GET /subscriptions/{id} returns 200 with subscription JSON or 404 if not found"
- "Integration tests use FastAPI TestClient with no real database"
- "pytest exits 0"

6 — Run Day 1

Terminal window
cd pace
python pace/orchestrator.py --day 1

What FORGE does with Python

FORGE’s tool-calling loop will:

  1. Read pyproject.toml or requirements.txt to understand dependencies
  2. Read existing source files to learn module structure, import style, and patterns
  3. Write new .py files using type annotations, Pydantic models, and pytest fixtures
  4. Run ruff check (build_command) to catch style and import errors
  5. Run pytest and read failure tracebacks to self-correct

A typical FORGE run for Day 1:

[FORGE] Reading pyproject.toml ...
[FORGE] Reading src/ (directory listing) ...
[FORGE] Reading tests/conftest.py ...
[FORGE] Writing src/subscriptions/models.py ...
[FORGE] Writing src/subscriptions/repository.py ...
[FORGE] Writing tests/subscriptions/test_repository.py ...
[FORGE] Running build: ruff check src/ && ruff format --check src/ ...
[FORGE] Running tests: pytest -v --tb=short ...
[FORGE] All tests pass. Calling complete_handoff.

What SENTINEL checks for Python

SENTINEL applies Python-specific security checks:

  • Injection: SQL injection via f-string queries, shell injection in subprocess.run(shell=True)
  • Deserialization: unsafe pickle.loads with untrusted data, yaml.load without Loader=yaml.SafeLoader
  • Secrets: hardcoded API keys or passwords in source files or .env files committed to git
  • Path traversal: open() or os.path.join with unvalidated user-supplied paths
  • Dependency risks: eval() with dynamic strings, exec() with user input
  • Authentication: missing auth on sensitive endpoints, weak JWT configuration
  • SSRF: requests.get(url) where url comes from user input without validation

7 — Test coverage

Configure coverage enforcement in pyproject.toml:

[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing --cov-fail-under=80"
[tool.coverage.run]
source = ["src"]
omit = ["src/migrations/*"]
[tool.coverage.report]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]

When --cov-fail-under=80 is set, pytest exits non-zero if coverage drops below 80% — GATE will issue a HOLD and FORGE will write additional tests until the threshold is met.

8 — Linter and formatter

Ruff is the recommended linter and formatter. It replaces flake8, isort, and pyupgrade in a single fast tool.

[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]

Set the corresponding build_command:

tech:
build_command: "ruff check src/ tests/ && ruff format --check src/ tests/"

For projects still using flake8, black, and isort:

tech:
build_command: "flake8 src/ tests/ && black --check src/ tests/ && isort --check src/ tests/"

Add a lint hint to product.description so FORGE writes conforming code on the first attempt:

product:
description: >
... Code must pass ruff check with E, F, I, UP, B, SIM rules. Max line length 100.

9 — Database testing patterns

SQLite in-memory (SQLAlchemy)

Use a db_session fixture in tests/conftest.py so FORGE can reference it by name:

tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.database import Base
@pytest.fixture(scope="function")
def db_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
Base.metadata.drop_all(engine)

Tell FORGE about it in product.description:

product:
description: >
Tests use SQLite in-memory via the db_session fixture in tests/conftest.py.
Never use the production PostgreSQL URL in tests.

Testcontainers (PostgreSQL)

For integration tests that require a real PostgreSQL instance:

tests/conftest.py
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine
@pytest.fixture(scope="session")
def pg_engine():
with PostgresContainer("postgres:16") as pg:
engine = create_engine(pg.get_connection_url())
yield engine

Mark integration tests so the standard PACE run stays fast:

tech:
test_command: "pytest -m 'not integration' -v --tb=short"

10 — Mock patterns

def test_upgrade_calls_repository(mocker):
mock_repo = mocker.MagicMock(spec=SubscriptionRepository)
service = SubscriptionService(repository=mock_repo)
service.upgrade_plan("sub-1", Plan.PRO)
mock_repo.save.assert_called_once()

Add a hint to product.description so FORGE never reaches for unittest.mock:

product:
description: >
Use mocker fixture from pytest-mock. Never import unittest.mock directly.
Inject dependencies via constructor.

unittest.mock (fallback)

If pytest-mock is not available, standard unittest.mock works with a minor import:

from unittest.mock import MagicMock, patch
def test_upgrade_calls_repository():
mock_repo = MagicMock(spec=SubscriptionRepository)
service = SubscriptionService(repository=mock_repo)
service.upgrade_plan("sub-1", Plan.PRO)
mock_repo.save.assert_called_once()

11 — Multi-day sprint

After Day 1 completes, SCRIBE writes a summary to .pace/context/engineering.md. When Day 2 starts, FORGE reads that file before writing any new code:

[FORGE] Reading .pace/context/engineering.md ...
[FORGE] Found: src/subscriptions/models.py (Subscription, SubscriptionStatus, Plan)
[FORGE] Found: src/subscriptions/repository.py (SubscriptionRepository ABC, InMemorySubscriptionRepository)
[FORGE] Writing src/subscriptions/service.py ...
[FORGE] Writing tests/subscriptions/test_service.py ...

This means story-2 acceptance criteria can safely reference types defined in story-1 — FORGE knows they exist and imports them correctly.

12 — Advisory clearance story

SENTINEL advisories that are not auto-fixed generate a clearance story in your backlog. Common Python advisories:

- id: story-4
title: "Address SENTINEL advisories from Day 1"
status: pending
acceptance_criteria:
- "Replace yaml.load() calls with yaml.safe_load() or yaml.load(Loader=yaml.SafeLoader)"
- "Replace subprocess.run(..., shell=True) with a list-form argument to subprocess.run"
- "Remove hardcoded SECRET_KEY from settings.py; load from environment variable instead"
- "pytest exits 0"

13 — CI build caching

Standard pip with GitHub Actions

- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -e ".[dev]"

uv (faster installs)

- uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- run: uv sync --all-extras

14 — Django variant

For Django projects, adjust the test and build commands:

tech:
test_command: "python manage.py test --verbosity=2"
build_command: "ruff check . && python manage.py check --deploy 2>/dev/null || true"

Describe Django conventions in product.description so FORGE writes class-based views, uses DRF serializers, and places business logic in services/ rather than in models:

product:
description: >
A Django REST Framework API. Class-based views with DRF serializers.
All test classes extend TestCase or APITestCase. Use factory_boy for fixtures.
No business logic in models — place it in app/services/.
Follow Google-style docstrings throughout.

15 — Windows notes

Activate the virtual environment with .venv\Scripts\activate instead of source .venv/bin/activate. If python is not on your PATH, use py -3.12 to invoke the correct version. Forward slashes in source.dirs paths (e.g. src/) work correctly on Windows — do not use backslashes in pace.config.yaml.

Common issues

ModuleNotFoundError when FORGE imports from src/ pytest does not add src/ to sys.path by default. Add this to pyproject.toml:

[tool.pytest.ini_options]
pythonpath = ["src"]

FORGE generates Optional[X] instead of X | None Add a Python version note to product.description: "Use X | None union syntax (Python 3.10+), not Optional[X]." FORGE will adopt the modern syntax on the next run.

pytest hangs after async tests If you use pytest-asyncio, add this to pyproject.toml:

[tool.pytest.ini_options]
asyncio_mode = "auto"

ruff reports unfixable errors on FORGE-generated code Add the offending rule code to ruff.lint.ignore in pyproject.toml and note the exception in product.description. For example: "Ignore SIM117 — nested with statements are acceptable in this codebase."

ImportError for test fixtures Ensure conftest.py is in the correct directory (usually tests/) and that any __init__.py files are present in subdirectories that pytest needs to traverse as packages. A missing tests/__init__.py is the most common cause of fixture discovery failures.