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
1 change: 0 additions & 1 deletion .claude/agents/ai-service-generator.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
---
name: ai-service-generator
description: Specialist implementer for LLM/AI integration features using the Anthropic SDK. Use instead of the general implementer when the plan involves Claude API calls, streaming, or tool use. Writes src/ and tests/ only.
model: claude-sonnet-4-6
tools:
- Read
- Edit
Expand Down
1 change: 0 additions & 1 deletion .claude/agents/implementer.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
---
name: implementer
description: Turns a plan from docs/plans/ into working, tested code. Writes src/ and tests/ only.
model: claude-sonnet-4-6
tools:
- Read
- Edit
Expand Down
1 change: 0 additions & 1 deletion .claude/agents/planner.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
---
name: planner
description: Read-only agent that produces implementation plans in docs/plans/. Does not edit source or test files.
model: claude-sonnet-4-6
tools:
- Read
- Glob
Expand Down
4 changes: 4 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@
"WebFetch(domain:huggingface.co)"
],
"deny": [
"Bash(git push * main)",
"Bash(git push *:refs/heads/main)",
"Bash(git checkout main)",
"Bash(git switch main)",
"Read(./.env)",
"Read(./.env.*)",
"Read(**/.env)",
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Base
FROM python:3.11-slim AS base

COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /usr/local/bin/uv

ENV TZ=Europe/Berlin
ARG DEBIAN_FRONTEND=noninteractive
Expand Down
6 changes: 4 additions & 2 deletions evals/test_structural.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,17 @@ def test_agent_frontmatter_parses(agent_file: Path) -> None:
@pytest.mark.parametrize("agent_file", _agent_files(), ids=lambda p: p.stem)
def test_agent_required_fields(agent_file: Path) -> None:
fm, _ = _strip_frontmatter(agent_file.read_text())
for field in ("name", "description", "model", "tools"):
for field in ("name", "description", "tools"):
assert field in fm, f"{agent_file.name}: missing required field '{field}'"
assert fm["description"], f"{agent_file.name}: 'description' must be non-empty"


@pytest.mark.parametrize("agent_file", _agent_files(), ids=lambda p: p.stem)
def test_agent_model_is_known(agent_file: Path) -> None:
fm, _ = _strip_frontmatter(agent_file.read_text())
model = fm.get("model", "")
model = fm.get("model")
if model is None:
return
assert model in KNOWN_MODELS, (
f"{agent_file.name}: model '{model}' is not a known Claude model ID. "
f"Known: {sorted(KNOWN_MODELS)}"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ ignore = ["E402"]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"]
"evals/**" = ["S101", "S603", "S607"]
"evals/**" = ["S101", "S603", "S607", "E501"]
".claude/hooks/**" = ["S101", "S603", "S607"]


Expand Down
4 changes: 4 additions & 0 deletions src/app/models/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@


class ItemCreate(BaseModel):
model_config = ConfigDict(extra="forbid")

name: str = Field(min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=1000)


class ItemUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")

name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=1000)

Expand Down
6 changes: 4 additions & 2 deletions src/app/routes/items.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status

from app.models.item import Item, ItemCreate, ItemUpdate
from app.store.item_store import ItemStore, get_item_store
Expand All @@ -17,9 +17,11 @@ async def create_item(

@router.get("", response_model=list[Item], status_code=status.HTTP_200_OK)
async def list_items(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
store: ItemStore = Depends(get_item_store),
) -> list[Item]:
return await store.list()
return await store.list_items(limit=limit, offset=offset)


@router.get("/{item_id}", response_model=Item, status_code=status.HTTP_200_OK)
Expand Down
4 changes: 2 additions & 2 deletions src/app/store/item_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ async def get(self, item_id: str) -> Item:
async with self._lock:
return self._store[item_id]

async def list(self) -> list[Item]:
async def list_items(self, limit: int = 20, offset: int = 0) -> list[Item]:
async with self._lock:
return list(self._store.values())
return list(self._store.values())[offset : offset + limit]

async def update(self, item_id: str, data: ItemUpdate) -> Item:
async with self._lock:
Expand Down
48 changes: 48 additions & 0 deletions tests/test_items.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import pytest
import pytest_asyncio
from httpx import AsyncClient


@pytest_asyncio.fixture(autouse=True)
async def store_reset() -> None:
from app.store.item_store import _item_store

_item_store._store.clear()


# POST /items


Expand Down Expand Up @@ -54,6 +62,46 @@ async def test_list_items_no_404(client: AsyncClient) -> None:
assert r.status_code != 404


@pytest.mark.parametrize(
"limit,offset,expected_count,expected_status",
[
(20, 0, 3, 200), # default pagination, fewer items than limit
(2, 0, 2, 200), # custom limit clips result
(2, 2, 1, 200), # offset skips into list
(20, 99, 0, 200), # offset beyond length → empty list
],
)
async def test_list_items_pagination(
client: AsyncClient,
limit: int,
offset: int,
expected_count: int,
expected_status: int,
) -> None:
for i in range(3):
await client.post("/items", json={"name": f"item-{i}"})
r = await client.get("/items", params={"limit": limit, "offset": offset})
assert r.status_code == expected_status
assert len(r.json()) == expected_count


@pytest.mark.parametrize(
"params,expected_status",
[
({"limit": 0}, 422), # below ge=1
({"limit": 101}, 422), # above le=100
({"offset": -1}, 422), # below ge=0
],
)
async def test_list_items_pagination_validation(
client: AsyncClient,
params: dict[str, int],
expected_status: int,
) -> None:
r = await client.get("/items", params=params)
assert r.status_code == expected_status


# GET /items/{item_id}


Expand Down
25 changes: 7 additions & 18 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,16 @@
from httpx import ASGITransport, AsyncClient
from httpx import AsyncClient

from app.main import app


async def test_index_returns_200() -> None:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
r = await c.get("/")
async def test_index_returns_200(client: AsyncClient) -> None:
r = await client.get("/")
assert r.status_code == 200


async def test_index_contains_app_name() -> None:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
r = await c.get("/")
async def test_index_contains_app_name(client: AsyncClient) -> None:
r = await client.get("/")
assert "agentic-coding-template" in r.text


async def test_index_not_found() -> None:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as c:
r = await c.get("/nonexistent-route")
async def test_index_not_found(client: AsyncClient) -> None:
r = await client.get("/nonexistent-route")
assert r.status_code == 404