Skip to content

Commit 6a3516b

Browse files
authored
feat: backend scaffold + Pydantic StrictModel + example schemas (#17, #18) (#55)
1 parent 85fd2ec commit 6a3516b

14 files changed

Lines changed: 679 additions & 15 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,15 @@ jobs:
5454
coverage:
5555
name: Coverage
5656
runs-on: ubuntu-latest
57-
# Runs the suite with coverage. Until ticket #17 lands real source under
58-
# src/, the template has no measurable coverage; pyproject.toml's
59-
# [tool.coverage.report].fail_under stays at 75 (the eventual target),
60-
# while CI uses --cov-fail-under=0 so the empty scaffold doesn't fail.
61-
# When #17 + #18 ship real source + tests, drop the override here.
57+
# Enforces [tool.coverage.report].fail_under from pyproject.toml (75%).
6258
steps:
6359
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
6460
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
6561
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
6662
with:
6763
python-version: "3.14"
6864
- run: uv sync --frozen --extra dev
69-
- run: uv run pytest tests/ --cov=src --cov-report=term-missing --cov-fail-under=0
65+
- run: uv run pytest tests/ --cov=src --cov-report=term-missing
7066

7167
architecture:
7268
name: Architecture (import-linter)

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ classifiers = [
3030
]
3131
dependencies = [
3232
"fastapi>=0.115.0",
33+
"uvicorn[standard]>=0.34.0",
3334
"pydantic>=2.11.0",
35+
"pydantic-settings>=2.9.0",
36+
"httpx>=0.28.1",
3437
]
3538

3639
[project.optional-dependencies]

src/api/main.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""harness-python-react — FastAPI application entry point."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from contextlib import asynccontextmanager
7+
from importlib.metadata import PackageNotFoundError, version
8+
from typing import TYPE_CHECKING
9+
10+
from fastapi import FastAPI
11+
from fastapi.middleware.cors import CORSMiddleware
12+
13+
from src.api.routes import router as v1_router
14+
from src.api.sessions import SessionStore
15+
16+
if TYPE_CHECKING:
17+
from collections.abc import AsyncIterator
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
def _package_version() -> str:
23+
"""Resolve the running package version, falling back when not installed.
24+
25+
`[tool.uv] package = false` skips installing the workspace as a Python
26+
package, so `importlib.metadata.version()` raises in local dev. Tests +
27+
`uvicorn --reload` should still boot; callers see ``0.0.0+local`` and the
28+
Docker image (which DOES install the package) reports the real value.
29+
"""
30+
try:
31+
return version("harness-python-react")
32+
except PackageNotFoundError:
33+
return "0.0.0+local"
34+
35+
36+
@asynccontextmanager
37+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
38+
"""Application lifespan: initialise process-wide services on startup."""
39+
app.state.session_store = SessionStore()
40+
logger.info("harness-python-react API started (v%s)", _package_version())
41+
yield
42+
logger.info("harness-python-react API stopped")
43+
44+
45+
app = FastAPI(
46+
title="harness-python-react",
47+
description="Production-quality LLM-driven coding harness — backend scaffold.",
48+
version=_package_version(),
49+
lifespan=lifespan,
50+
)
51+
52+
# CORS — wide-open in the scaffold so the Vite dev server on :5173 can hit
53+
# the backend on :8000 without preflight friction. Tighten via config in a
54+
# real deployment.
55+
app.add_middleware(
56+
CORSMiddleware,
57+
allow_origins=["*"],
58+
allow_credentials=False,
59+
allow_methods=["*"],
60+
allow_headers=["*"],
61+
)
62+
63+
app.include_router(v1_router)

src/api/routes.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Versioned API routes under /api/v1/.
2+
3+
Two endpoints in the scaffold:
4+
5+
- ``GET /api/v1/health`` — liveness + running package version.
6+
- ``GET /api/v1/echo`` — returns the ``msg`` query param wrapped in a
7+
``StrictModel``. Demonstrates the request/response
8+
contract pattern that downstream tickets follow.
9+
10+
Session-store wiring is plumbed via ``request.app.state.session_store`` for
11+
endpoints that need it (none in the scaffold; pattern is preserved for the
12+
agent / chat endpoint a real project would add).
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from importlib.metadata import PackageNotFoundError, version
18+
19+
from fastapi import APIRouter
20+
from pydantic import Field
21+
22+
from src.models._base import StrictModel
23+
from src.models.health import HealthResponse
24+
25+
26+
def _package_version() -> str:
27+
try:
28+
return version("harness-python-react")
29+
except PackageNotFoundError:
30+
return "0.0.0+local"
31+
32+
33+
router = APIRouter(prefix="/api/v1")
34+
35+
36+
class EchoResponse(StrictModel, strict=True):
37+
"""Response body for `GET /api/v1/echo`."""
38+
39+
echoed: str = Field(description="Whatever the client sent in `?msg=`")
40+
41+
42+
@router.get("/health")
43+
async def health() -> HealthResponse:
44+
"""Liveness signal + the running package version.
45+
46+
The version is sourced from ``importlib.metadata`` so the deployed
47+
container can be correlated with a ``pyproject.toml`` revision and a
48+
release tag without inspecting the image.
49+
"""
50+
return HealthResponse(status="ok", version=_package_version())
51+
52+
53+
@router.get("/echo")
54+
async def echo(msg: str) -> EchoResponse:
55+
"""Echo the ``msg`` query parameter back as a typed response."""
56+
return EchoResponse(echoed=msg)

src/api/sessions.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""In-memory session store — a portable harness pattern.
2+
3+
Single-process, GIL-protected. Each session holds its conversation history
4+
as a list of message dicts compatible with the typical OpenAI chat
5+
completions format. Not safe for multi-process deployments; replace with
6+
Redis or a database for persistence and true concurrency safety.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from datetime import UTC, datetime
12+
from typing import Any
13+
from uuid import UUID, uuid4
14+
15+
from src.models.session import SessionInfo
16+
17+
18+
class SessionStore:
19+
"""In-memory session store."""
20+
21+
def __init__(self) -> None:
22+
self._sessions: dict[str, dict[str, Any]] = {}
23+
24+
def create(self) -> SessionInfo:
25+
"""Create a new session with an empty conversation history."""
26+
session_id = str(uuid4())
27+
self._sessions[session_id] = {
28+
"session_id": session_id,
29+
"created_at": datetime.now(tz=UTC),
30+
"messages": [],
31+
}
32+
return SessionInfo(
33+
session_id=UUID(session_id),
34+
created_at=self._sessions[session_id]["created_at"],
35+
message_count=0,
36+
)
37+
38+
def get(self, session_id: str) -> SessionInfo | None:
39+
"""Get session info, or None if not found."""
40+
data = self._sessions.get(session_id)
41+
if data is None:
42+
return None
43+
return SessionInfo(
44+
session_id=UUID(data["session_id"]),
45+
created_at=data["created_at"],
46+
message_count=len(data["messages"]),
47+
)
48+
49+
def get_messages(self, session_id: str) -> list[dict[str, Any]] | None:
50+
"""Get conversation history for a session, or None if not found."""
51+
data = self._sessions.get(session_id)
52+
if data is None:
53+
return None
54+
return list(data["messages"])
55+
56+
def set_messages(self, session_id: str, messages: list[dict[str, Any]]) -> None:
57+
"""Replace the conversation history for a session."""
58+
if session_id in self._sessions:
59+
self._sessions[session_id]["messages"] = messages
60+
61+
def exists(self, session_id: str) -> bool:
62+
"""Check if a session exists."""
63+
return session_id in self._sessions

src/models/_base.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Base class for every Pydantic contract that crosses a module or process seam.
2+
3+
Inheriting from ``StrictModel`` gives a contract ``extra="forbid"``: unknown
4+
keys raise ``ValidationError`` at construction. Typos and renamed fields fail
5+
immediately at the seam instead of silently surfacing three calls deep.
6+
7+
Classes that additionally want strict type checking (no implicit coercion —
8+
e.g. rejecting ``"3.14"`` for a ``float`` field) opt in per-class via the
9+
``strict=True`` keyword, e.g. ``class Foo(StrictModel, strict=True)``. This is
10+
deliberate: models that cross the HTTP boundary need JSON coercion for UUIDs
11+
and integers, while internal result contracts do not.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from pydantic import BaseModel, ConfigDict
17+
18+
19+
class StrictModel(BaseModel):
20+
"""Base for every contract that crosses a module or process seam."""
21+
22+
model_config = ConfigDict(extra="forbid")

src/models/config.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Application configuration — pluggable LLM provider via environment variables.
2+
3+
The agent / eval harness call into an LLM through a single seam configured
4+
at startup. Keep the seam provider-agnostic so the template doesn't lock
5+
users into a specific vendor: the four ``LLM_*`` environment variables below
6+
are all that's required to point at OpenAI, Anthropic, Azure OpenAI, a
7+
self-hosted vLLM endpoint, or any OpenAI-compatible gateway.
8+
9+
Wired through `pydantic-settings` so the same value precedence applies for
10+
local dev (`.env`), Docker (env), and CI (secrets):
11+
12+
1. environment variable
13+
2. .env file
14+
3. default declared on the field below
15+
16+
A real production deployment would also want a vault-backed secret-manager
17+
fetch for `LLM_API_KEY`; that's intentionally out of scope for the scaffold.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from pydantic import Field
23+
from pydantic_settings import BaseSettings, SettingsConfigDict
24+
25+
26+
class Settings(BaseSettings):
27+
"""Process-wide configuration loaded from environment variables."""
28+
29+
model_config = SettingsConfigDict(
30+
env_file=".env",
31+
env_file_encoding="utf-8",
32+
extra="ignore",
33+
case_sensitive=False,
34+
)
35+
36+
llm_provider: str = Field(
37+
default="openai",
38+
description=(
39+
"Identifier for the LLM provider — used by the LLM-client seam to"
40+
" select the right SDK adapter. Examples: openai, anthropic,"
41+
" azure-openai, vllm."
42+
),
43+
)
44+
llm_api_key: str = Field(
45+
default="",
46+
description=(
47+
"API key passed to the LLM provider. Empty in local dev when no"
48+
" LLM call is exercised; required when the eval harness or agent"
49+
" loop runs."
50+
),
51+
)
52+
llm_base_url: str | None = Field(
53+
default=None,
54+
description=(
55+
"Optional base URL — set when pointing at Azure OpenAI, a"
56+
" self-hosted vLLM endpoint, or any OpenAI-compatible gateway."
57+
" Leave None to use the provider SDK's default."
58+
),
59+
)
60+
llm_model: str = Field(
61+
default="gpt-4o-mini",
62+
description=(
63+
"Model identifier passed to the provider. Defaults to a small,"
64+
" inexpensive model so an accidental run doesn't burn credits."
65+
),
66+
)
67+
68+
69+
def get_settings() -> Settings:
70+
"""Construct a fresh Settings instance.
71+
72+
Not memoised — tests need to override env variables and re-construct.
73+
Production callers should hold a single instance at app-startup.
74+
"""
75+
return Settings()

src/models/health.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Pydantic model for the `/api/v1/health` endpoint."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Literal
6+
7+
from pydantic import Field
8+
9+
from src.models._base import StrictModel
10+
11+
12+
class HealthResponse(StrictModel, strict=True):
13+
"""Response body for `GET /api/v1/health`.
14+
15+
The `version` field is populated at request time from
16+
`importlib.metadata.version("harness-python-react")` so the running
17+
container can be correlated with a `pyproject.toml` revision and a
18+
release tag without inspecting the image.
19+
"""
20+
21+
status: Literal["ok"] = Field(
22+
description="Liveness signal — always 'ok' if the process is responsive"
23+
)
24+
version: str = Field(description="Package version reported by importlib.metadata")

src/models/session.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Pydantic models for session management."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime
6+
from uuid import UUID
7+
8+
from pydantic import Field
9+
10+
from src.models._base import StrictModel
11+
12+
13+
class SessionCreate(StrictModel, strict=True):
14+
"""Request body for creating a new session (currently empty)."""
15+
16+
17+
class SessionInfo(StrictModel, strict=True):
18+
"""Public information about an existing session."""
19+
20+
session_id: UUID = Field(description="Unique session identifier")
21+
created_at: datetime = Field(description="When the session was created")
22+
message_count: int = Field(
23+
default=0, description="Number of messages in the session"
24+
)

tests/conftest.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Pytest fixtures shared across the suite."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
import pytest
8+
from fastapi.testclient import TestClient
9+
10+
from src.api.main import app
11+
12+
if TYPE_CHECKING:
13+
from collections.abc import Iterator
14+
15+
16+
@pytest.fixture()
17+
def client() -> Iterator[TestClient]:
18+
"""FastAPI TestClient with the app's full lifespan exercised."""
19+
with TestClient(app) as test_client:
20+
yield test_client

0 commit comments

Comments
 (0)