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
# From your repo rootgit clone https://github.com/pace-framework-org/pace-framework-starter pacecd pacepython -m venv .venv && source .venv/bin/activatepip install PyYAML jsonschema anthropic2 — 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-6Test runner variants
| Setup | test_command |
|---|---|
| pytest (default) | pytest -v --tb=short |
| With coverage | pytest -v --tb=short --cov=src --cov-fail-under=80 |
| Specific directory | pytest tests/unit/ -v --tb=short |
| Parallel (pytest-xdist) | pytest -n auto -v --tb=short |
| Skip slow tests | pytest -m "not slow" -v --tb=short |
| Django | python 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
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
cd pacepython pace/orchestrator.py --day 1What FORGE does with Python
FORGE’s tool-calling loop will:
- Read
pyproject.tomlorrequirements.txtto understand dependencies - Read existing source files to learn module structure, import style, and patterns
- Write new
.pyfiles using type annotations, Pydantic models, and pytest fixtures - Run
ruff check(build_command) to catch style and import errors - Run
pytestand 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.loadswith untrusted data,yaml.loadwithoutLoader=yaml.SafeLoader - Secrets: hardcoded API keys or passwords in source files or
.envfiles committed to git - Path traversal:
open()oros.path.joinwith 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)whereurlcomes 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 = 100target-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:
import pytestfrom sqlalchemy import create_enginefrom 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:
import pytestfrom testcontainers.postgres import PostgresContainerfrom 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 engineMark integration tests so the standard PACE run stays fast:
tech: test_command: "pytest -m 'not integration' -v --tb=short"10 — Mock patterns
pytest-mock (recommended)
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-extras14 — 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.