Siirrä kipina-codebench projektin päätasolle

This commit is contained in:
2026-04-14 09:44:14 +03:00
parent b93ae2fd1b
commit 7b27800390
24 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
# Dokumentointiohjeet — Zensical
Hyvä dokumentointi kertoo **mitä asia ON**, ei mitä se tekee. Se on kuin zen-koan: lyhyt, tarkka, riittävä.
## Periaatteet
1. **Yksi rivi riittää.** Jos tarvitset kappaleen, koodi on liian monimutkainen.
2. **Kerro mitä, älä miten.** `"""Tietokantamallit — SQLAlchemy 2.0, SQLite."""` ei `"""This module creates database models using SQLAlchemy..."""`
3. **Älä toista koodia.** Jos funktio on `create_todo`, docstring ei ole "Creates a todo".
4. **Suomi tai englanti, ei molempia.** Valitse yksi kieli per projekti.
5. **Ei täytesanoja.** "This module provides functionality for" → poista.
## Mitä dokumentoidaan
| Kohde | Dokumentointi | Esimerkki |
|-------|--------------|-----------|
| **Moduuli** (.py) | Aina. Yksi rivi: mitä tiedosto sisältää. | `"""Pydantic v2 -skeemat — Create ja Response."""` |
| **Luokka** | Aina. Mitä entiteetti edustaa. | `"""Tehtävä — otsikko, deadline, prioriteetti."""` |
| **Funktio** | Vain jos nimi ei kerro kaikkea. | `get_db``"""Tietokantasessio per pyyntö."""` |
| **CRUD-endpoint** | Ei. Nimi + HTTP-metodi riittää. | `create_todo`, `list_todos` — itsedokumentoivia |
| **Testi** | Ei. Testin nimi on dokumentaatio. | `test_get_todo_not_found` — selvä |
| **Konfiguraatio** | Kommentti vain jos arvo yllättää. | `check_same_thread: False # SQLite + FastAPI` |
## Mitä EI dokumentoida
- Importteja
- Ilmeisiä parametreja (`item_id: int`)
- Tyyppivihjeitä jotka kertovat saman asian
- Geneerisiä "boilerplate"-docstringejä
## Esimerkkejä
### Hyvä (zensical)
```python
"""Tietokantamallit — SQLAlchemy 2.0, Mapped-tyypitys, SQLite."""
class Todo(Base):
"""Tehtävä — otsikko, kuvaus, deadline, prioriteetti ja status."""
...
def get_db():
"""Tietokantasessio per pyyntö."""
...
```
### Huono (verbose)
```python
"""
This module defines the database models for the Todo application.
It uses SQLAlchemy ORM to create the database tables and provides
the session factory for database connections.
"""
class Todo(Base):
"""
Represents a todo item in the database.
Attributes:
id: The unique identifier for the todo item.
title: The title of the todo item.
...
"""
...
```
### Huono (tyhjä)
```python
# Ei docstringejä ollenkaan — lukija ei tiedä mikä tiedoston rooli on
class Todo(Base):
__tablename__ = "todos"
...
```
## Tarkistuslista
Generoitu koodi on hyvin dokumentoitu kun:
- [ ] Jokainen .py-tiedosto alkaa yksirivisellä docstringillä
- [ ] Jokainen luokka kertoo mitä entiteetti edustaa
- [ ] Docstringit ovat saman kielen kuin muu koodi
- [ ] CRUD-endpointeilla ei ole turhia docstringejä
- [ ] Kommentteja on vain siellä missä koodi yllättää

View File

@@ -0,0 +1,123 @@
# Golden Examples — referenssitoteutukset
Kultaiset esimerkit ovat **täydellisiä, testattuja** FastAPI-projekteja joita LLM käyttää mallina koodigeneroinnissa. Malli näkee esimerkin ja tuottaa vastaavan rakenteen uudelle projektille.
## Uuden esimerkin luominen
### 1. Luo hakemisto
```bash
mkdir golden-examples/shop
```
Nimeä hakemisto skenaarion mukaan (todo, blog, shop, booking...).
### 2. Luo 4 tiedostoa
| Tiedosto | Sisältö |
|----------|---------|
| `models.py` | SQLAlchemy 2.0 -mallit (DeclarativeBase, Mapped, mapped_column) |
| `schemas.py` | Pydantic v2 -skeemat (ConfigDict, `str \| None` -syntaksi) |
| `main.py` | FastAPI CRUD -endpointit (POST 201, GET, GET/:id 404, PUT, DELETE 204) |
| `test_main.py` | Pytest + TestClient, erillinen test.db, uniikki data per testi |
### 3. Noudata konventioita
**Python-versio:** >=3.14
**SQLAlchemy 2.0** (ei legacy):
```python
# Oikein
class Base(DeclarativeBase):
pass
class Todo(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(255))
status: Mapped[str] = mapped_column(String(20), default="pending")
# Väärin
Base = declarative_base()
id = Column(Integer, primary_key=True)
```
**Pydantic v2** (ei v1):
```python
# Oikein
class TodoResponse(TodoCreate):
id: int
model_config = ConfigDict(from_attributes=True)
# Väärin
class Config:
orm_mode = True
```
**Tyypitys:**
```python
# Oikein
description: Mapped[str | None] = mapped_column(Text, default=None)
# Väärin
description: Mapped[Optional[str]]
```
**Dokumentointi (zensical):**
```python
"""Tietokantamallit — SQLAlchemy 2.0, Mapped-tyypitys, SQLite."""
class Todo(Base):
"""Tehtävä — otsikko, kuvaus, deadline, prioriteetti ja status."""
```
Yksi rivi riittää. Kerro mitä asia ON, älä mitä se tekee. Katso [DOCUMENTATION.md](DOCUMENTATION.md).
**Testidata — uniikki ja kuvaava:**
```python
# Oikein
def test_create_todo():
response = client.post("/todos/", json={"title": "Osta maitoa", "priority": 2})
def test_update_todo():
created = client.post("/todos/", json={"title": "Vanha otsikko"}).json()
# Väärin — geneerinen data
def test_create_todo():
response = client.post("/todos/", json={"title": "test", "priority": 1})
```
### 4. Testaa Docker-kontissa
```bash
rm -rf /tmp/golden-test && mkdir /tmp/golden-test
cp golden-examples/shop/*.py /tmp/golden-test/
docker run --rm -v /tmp/golden-test:/src:ro kipina-pytest
```
**Kaikkien testien pitää mennä läpi.** Ei varoituksia, ei deprecation-viestejä.
### 5. Vaikeustasot
| Taso | Esimerkit | Haaste |
|------|-----------|--------|
| 1 — Perus-CRUD | `todo/`, `users/`, `notes/` | Yksi entiteetti |
| 2 — Relaatiot | `blog/`, `library/`, `school/` | Foreign key, 23 entiteettiä |
| 3 — Liiketoimintalogiikka | `shop/`, `booking/` | Custom endpointit, validointi |
Aloita tasosta 1 ja etene. Tason 1 esimerkkien pitää olla yksinkertaisia — ne opettavat mallille perusrakenteen.
## Miten esimerkit vaikuttavat
Benchmark lataa `todo/`-esimerkin ja syöttää sen LLM:lle osana koodingenerointipromptia:
```
REFERENCE IMPLEMENTATION (todo project — follow this exact structure):
=== models.py ===
<todo/models.py sisältö>
=== schemas.py ===
...
```
Malli näkee tarkan esimerkin ja tuottaa vastaavan rakenteen uudelle projektille. Mitä parempi esimerkki, sitä parempi tulos.

View File

@@ -0,0 +1,110 @@
"""FastAPI CRUD — kaksi endpoint-settiä, Author ja Post."""
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from models import SessionLocal, Author, Post
from schemas import AuthorCreate, AuthorResponse, PostCreate, PostResponse
app = FastAPI()
def get_db():
"""Tietokantasessio per pyyntö."""
db = SessionLocal()
try:
yield db
finally:
db.close()
# --- Author ---
@app.post("/authors/", response_model=AuthorResponse, status_code=201)
def create_author(item: AuthorCreate, db: Session = Depends(get_db)):
db_item = Author(**item.model_dump())
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@app.get("/authors/", response_model=list[AuthorResponse])
def list_authors(db: Session = Depends(get_db)):
return db.query(Author).all()
@app.get("/authors/{item_id}", response_model=AuthorResponse)
def get_author(item_id: int, db: Session = Depends(get_db)):
item = db.query(Author).filter(Author.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Author not found")
return item
@app.put("/authors/{item_id}", response_model=AuthorResponse)
def update_author(item_id: int, item: AuthorCreate, db: Session = Depends(get_db)):
db_item = db.query(Author).filter(Author.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Author not found")
for key, value in item.model_dump().items():
setattr(db_item, key, value)
db.commit()
db.refresh(db_item)
return db_item
@app.delete("/authors/{item_id}", status_code=204)
def delete_author(item_id: int, db: Session = Depends(get_db)):
db_item = db.query(Author).filter(Author.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Author not found")
db.delete(db_item)
db.commit()
# --- Post ---
@app.post("/posts/", response_model=PostResponse, status_code=201)
def create_post(item: PostCreate, db: Session = Depends(get_db)):
db_item = Post(**item.model_dump())
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@app.get("/posts/", response_model=list[PostResponse])
def list_posts(db: Session = Depends(get_db)):
return db.query(Post).all()
@app.get("/posts/{item_id}", response_model=PostResponse)
def get_post(item_id: int, db: Session = Depends(get_db)):
item = db.query(Post).filter(Post.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Post not found")
return item
@app.put("/posts/{item_id}", response_model=PostResponse)
def update_post(item_id: int, item: PostCreate, db: Session = Depends(get_db)):
db_item = db.query(Post).filter(Post.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Post not found")
for key, value in item.model_dump().items():
setattr(db_item, key, value)
db.commit()
db.refresh(db_item)
return db_item
@app.delete("/posts/{item_id}", status_code=204)
def delete_post(item_id: int, db: Session = Depends(get_db)):
db_item = db.query(Post).filter(Post.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Post not found")
db.delete(db_item)
db.commit()

View File

@@ -0,0 +1,45 @@
"""Tietokantamallit — SQLAlchemy 2.0, Mapped-tyypitys, ForeignKey-relaatiot."""
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
DATABASE_URL = "sqlite:///./app.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
class Author(Base):
"""Kirjoittaja — nimi, sähköposti ja bio."""
__tablename__ = "authors"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255))
email: Mapped[str] = mapped_column(String(255), unique=True)
bio: Mapped[str | None] = mapped_column(Text, default=None)
posts: Mapped[list["Post"]] = relationship(back_populates="author")
class Post(Base):
"""Blogipostaus — otsikko, sisältö, kirjoittaja, julkaisuaika ja tila."""
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(255))
content: Mapped[str] = mapped_column(Text)
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
published_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
status: Mapped[str] = mapped_column(String(20), default="draft")
author: Mapped["Author"] = relationship(back_populates="posts")
Base.metadata.create_all(bind=engine)

View File

@@ -0,0 +1,37 @@
"""Pydantic v2 -skeemat — Create sisääntulolle, Response vastaukselle."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class AuthorCreate(BaseModel):
"""Uuden kirjoittajan luonti. Pakolliset: name, email."""
name: str
email: str
bio: str | None = None
class AuthorResponse(AuthorCreate):
"""Palautettava kirjoittaja — sisältää id:n."""
id: int
model_config = ConfigDict(from_attributes=True)
class PostCreate(BaseModel):
"""Uuden postauksen luonti. Pakolliset: title, content, author_id."""
title: str
content: str
author_id: int
published_at: datetime | None = None
status: str = "draft"
class PostResponse(PostCreate):
"""Palautettava postaus — sisältää id:n."""
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,164 @@
"""Pytest — TestClient, erillinen test.db, uniikki data per testi."""
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from main import app, get_db
from models import Base
test_engine = create_engine(
"sqlite:///./test.db", connect_args={"check_same_thread": False}
)
TestSession = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
Base.metadata.create_all(bind=test_engine)
def override_get_db():
db = TestSession()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
def _create_author(name="Eino Leino", email=None):
"""Apufunktio kirjoittajan luomiseen testeissä."""
if email is None:
email = f"{name.lower().replace(' ', '.')}@example.com"
return client.post(
"/authors/", json={"name": name, "email": email}
).json()
# --- Author-testit ---
def test_create_author():
response = client.post(
"/authors/",
json={"name": "Aleksis Kivi", "email": "aleksis@example.com", "bio": "Suomen kansalliskirjailija"},
)
assert response.status_code == 201
assert response.json()["name"] == "Aleksis Kivi"
assert response.json()["bio"] == "Suomen kansalliskirjailija"
assert "id" in response.json()
def test_list_authors():
_create_author("Minna Canth", "minna.canth@example.com")
response = client.get("/authors/")
assert response.status_code == 200
assert len(response.json()) >= 1
def test_get_author_by_id():
created = _create_author("Väinö Linna", "vaino.linna@example.com")
response = client.get(f"/authors/{created['id']}")
assert response.status_code == 200
assert response.json()["id"] == created["id"]
def test_get_author_not_found():
response = client.get("/authors/99999")
assert response.status_code == 404
def test_update_author():
created = _create_author("Vanha Nimi", "vanha.nimi@example.com")
response = client.put(
f"/authors/{created['id']}",
json={"name": "Uusi Nimi", "email": "uusi.nimi@example.com"},
)
assert response.status_code == 200
assert response.json()["name"] == "Uusi Nimi"
def test_delete_author():
created = _create_author("Poistettava Kirjailija", "poistettava@example.com")
response = client.delete(f"/authors/{created['id']}")
assert response.status_code == 204
response = client.get(f"/authors/{created['id']}")
assert response.status_code == 404
# --- Post-testit ---
def test_create_post():
author = _create_author("Tove Jansson", "tove.jansson@example.com")
response = client.post(
"/posts/",
json={"title": "Muumipeikko ja pyrstötähti", "content": "Eräänä aamuna...", "author_id": author["id"]},
)
assert response.status_code == 201
assert response.json()["title"] == "Muumipeikko ja pyrstötähti"
assert response.json()["author_id"] == author["id"]
assert response.json()["status"] == "draft"
def test_list_posts():
author = _create_author("Juhani Aho", "juhani.aho@example.com")
client.post(
"/posts/",
json={"title": "Rautatie", "content": "Junasta kertova novelli.", "author_id": author["id"]},
)
response = client.get("/posts/")
assert response.status_code == 200
assert len(response.json()) >= 1
def test_get_post_by_id():
author = _create_author("Elias Lönnrot", "elias.lonnrot@example.com")
created = client.post(
"/posts/",
json={"title": "Kalevala", "content": "Vaka vanha Väinämöinen.", "author_id": author["id"]},
).json()
response = client.get(f"/posts/{created['id']}")
assert response.status_code == 200
assert response.json()["id"] == created["id"]
def test_get_post_not_found():
response = client.get("/posts/99999")
assert response.status_code == 404
def test_update_post():
author = _create_author("Joel Lehtonen", "joel.lehtonen@example.com")
created = client.post(
"/posts/",
json={"title": "Vanha otsikko", "content": "Alkuperäinen teksti.", "author_id": author["id"]},
).json()
response = client.put(
f"/posts/{created['id']}",
json={"title": "Päivitetty otsikko", "content": "Muokattu teksti.", "author_id": author["id"], "status": "published"},
)
assert response.status_code == 200
assert response.json()["title"] == "Päivitetty otsikko"
assert response.json()["status"] == "published"
def test_delete_post():
author = _create_author("Aino Kallas", "aino.kallas@example.com")
created = client.post(
"/posts/",
json={"title": "Poistettava postaus", "content": "Tämä poistetaan.", "author_id": author["id"]},
).json()
response = client.delete(f"/posts/{created['id']}")
assert response.status_code == 204
response = client.get(f"/posts/{created['id']}")
assert response.status_code == 404
def test_post_belongs_to_author():
author = _create_author("Sofi Oksanen", "sofi.oksanen@example.com")
post = client.post(
"/posts/",
json={"title": "Puhdistus", "content": "Romaani Virosta.", "author_id": author["id"]},
).json()
assert post["author_id"] == author["id"]

View File

@@ -0,0 +1,61 @@
"""FastAPI CRUD — yksi endpoint-setti per entiteetti."""
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from models import SessionLocal, Todo
from schemas import TodoCreate, TodoResponse
app = FastAPI()
def get_db():
"""Tietokantasessio per pyyntö."""
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.post("/todos/", response_model=TodoResponse, status_code=201)
def create_todo(item: TodoCreate, db: Session = Depends(get_db)):
db_item = Todo(**item.model_dump())
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@app.get("/todos/", response_model=list[TodoResponse])
def list_todos(db: Session = Depends(get_db)):
return db.query(Todo).all()
@app.get("/todos/{item_id}", response_model=TodoResponse)
def get_todo(item_id: int, db: Session = Depends(get_db)):
item = db.query(Todo).filter(Todo.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Todo not found")
return item
@app.put("/todos/{item_id}", response_model=TodoResponse)
def update_todo(item_id: int, item: TodoCreate, db: Session = Depends(get_db)):
db_item = db.query(Todo).filter(Todo.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Todo not found")
for key, value in item.model_dump().items():
setattr(db_item, key, value)
db.commit()
db.refresh(db_item)
return db_item
@app.delete("/todos/{item_id}", status_code=204)
def delete_todo(item_id: int, db: Session = Depends(get_db)):
db_item = db.query(Todo).filter(Todo.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Todo not found")
db.delete(db_item)
db.commit()

View File

@@ -0,0 +1,30 @@
"""Tietokantamallit — SQLAlchemy 2.0, Mapped-tyypitys, SQLite."""
from datetime import date
from sqlalchemy import String, Text, Date, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
DATABASE_URL = "sqlite:///./app.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
class Todo(Base):
"""Tehtävä — otsikko, kuvaus, deadline, prioriteetti ja status."""
__tablename__ = "todos"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, default=None)
due_date: Mapped[date | None] = mapped_column(Date, default=None)
priority: Mapped[int] = mapped_column(default=1)
status: Mapped[str] = mapped_column(String(20), default="pending")
Base.metadata.create_all(bind=engine)

View File

@@ -0,0 +1,11 @@
[project]
name = "todo-app"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"fastapi",
"uvicorn[standard]",
"sqlalchemy",
"pytest",
"httpx",
]

View File

@@ -0,0 +1,22 @@
"""Pydantic v2 -skeemat — Create sisääntulolle, Response vastaukselle."""
from datetime import date
from pydantic import BaseModel, ConfigDict
class TodoCreate(BaseModel):
"""Uuden tehtävän luonti. Pakolliset: title."""
title: str
description: str | None = None
due_date: date | None = None
priority: int = 1
status: str = "pending"
class TodoResponse(TodoCreate):
"""Palautettava tehtävä — sisältää id:n."""
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,69 @@
"""Pytest — TestClient, erillinen test.db, uniikki data per testi."""
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from main import app, get_db
from models import Base
test_engine = create_engine(
"sqlite:///./test.db", connect_args={"check_same_thread": False}
)
TestSession = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
Base.metadata.create_all(bind=test_engine)
def override_get_db():
db = TestSession()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
def test_create_todo():
response = client.post("/todos/", json={"title": "Osta maitoa", "priority": 2})
assert response.status_code == 201
assert response.json()["title"] == "Osta maitoa"
assert "id" in response.json()
def test_list_todos():
client.post("/todos/", json={"title": "Listattava tehtävä"})
response = client.get("/todos/")
assert response.status_code == 200
assert len(response.json()) >= 1
def test_get_todo_by_id():
created = client.post("/todos/", json={"title": "Haettava tehtävä"}).json()
response = client.get(f"/todos/{created['id']}")
assert response.status_code == 200
assert response.json()["id"] == created["id"]
def test_get_todo_not_found():
response = client.get("/todos/99999")
assert response.status_code == 404
def test_update_todo():
created = client.post("/todos/", json={"title": "Vanha otsikko"}).json()
response = client.put(
f"/todos/{created['id']}", json={"title": "Uusi otsikko"}
)
assert response.status_code == 200
assert response.json()["title"] == "Uusi otsikko"
def test_delete_todo():
created = client.post("/todos/", json={"title": "Poistettava"}).json()
response = client.delete(f"/todos/{created['id']}")
assert response.status_code == 204
response = client.get(f"/todos/{created['id']}")
assert response.status_code == 404