跳转至

结业编码题 参考答案(默认折叠)

做完再看

先让自己的测试全部通过,再展开对照。看完合上,凭记忆把自己的版本重写一遍——只看不写等于没学(见 ai-guide.md 规则 5)。

点击展开:task_bookshelf.py
exercises/solutions/final/task_bookshelf.py
"""结业编码题参考实现:书架(bookshelf)。

规格见 exercises/final/quiz_final.md 第三部分。验证:
    uv run pytest exercises/solutions/final/test_task_bookshelf.py -q
"""

from __future__ import annotations

import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Protocol

from pydantic import BaseModel, ConfigDict, Field, field_validator

# ---- 领域 ----


class BookshelfError(Exception):
    """书架错误基类。"""


class BookNotFoundError(BookshelfError):
    """按 id 找不到书。"""


class DuplicateIsbnError(BookshelfError):
    """ISBN 重复。"""


@dataclass(frozen=True)
class Book:
    """领域模型:一本书(不可变)。"""

    id: int | None
    isbn: str
    title: str
    tags: tuple[str, ...]
    added_at: datetime


# ---- 传输/校验模型 ----


class BookIn(BaseModel):
    """外部输入:ISBN 13 位数字、标题 1–200 字、标签 ≤5 个且小写去重。"""

    model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")

    isbn: str = Field(pattern=r"^\d{13}$")
    title: str = Field(min_length=1, max_length=200)
    tags: list[str] = Field(default_factory=list, max_length=5)

    @field_validator("tags")
    @classmethod
    def _norm(cls, tags: list[str]) -> list[str]:
        return list(dict.fromkeys(t.strip().lower() for t in tags if t.strip()))


# ---- 仓储 ----


class BookRepository(Protocol):
    """存储接口。"""

    def add(self, book: Book) -> Book: ...
    def get(self, book_id: int) -> Book | None: ...
    def find_by_isbn(self, isbn: str) -> Book | None: ...
    def list(self) -> list[Book]: ...


class InMemoryBookRepository:
    """测试用内存实现。"""

    def __init__(self) -> None:
        self._books: dict[int, Book] = {}

    def add(self, book: Book) -> Book:
        new_id = len(self._books) + 1
        stored = Book(new_id, book.isbn, book.title, book.tags, book.added_at)
        self._books[new_id] = stored
        return stored

    def get(self, book_id: int) -> Book | None:
        return self._books.get(book_id)

    def find_by_isbn(self, isbn: str) -> Book | None:
        return next((b for b in self._books.values() if b.isbn == isbn), None)

    def list(self) -> list[Book]:
        return sorted(self._books.values(), key=lambda b: b.id or 0)


class SqliteBookRepository:
    """SQLite 实现:参数化查询 + with conn 事务。"""

    def __init__(self, path: Path) -> None:
        self.conn = sqlite3.connect(path)
        self.conn.row_factory = sqlite3.Row
        with self.conn:
            self.conn.execute(
                "CREATE TABLE IF NOT EXISTS books (id INTEGER PRIMARY KEY,"
                " isbn TEXT UNIQUE, title TEXT, tags TEXT, added_at TEXT)"
            )

    def add(self, book: Book) -> Book:
        with self.conn:
            cur = self.conn.execute(
                "INSERT INTO books (isbn, title, tags, added_at) VALUES (?, ?, ?, ?)",
                (book.isbn, book.title, ",".join(book.tags), book.added_at.isoformat()),
            )
        return Book(
            int(cur.lastrowid or 0), book.isbn, book.title, book.tags, book.added_at
        )

    def get(self, book_id: int) -> Book | None:
        row = self.conn.execute(
            "SELECT * FROM books WHERE id = ?", (book_id,)
        ).fetchone()
        return self._to_book(row) if row else None

    def find_by_isbn(self, isbn: str) -> Book | None:
        row = self.conn.execute(
            "SELECT * FROM books WHERE isbn = ?", (isbn,)
        ).fetchone()
        return self._to_book(row) if row else None

    def list(self) -> list[Book]:
        rows = self.conn.execute("SELECT * FROM books ORDER BY id").fetchall()
        return [self._to_book(r) for r in rows]

    @staticmethod
    def _to_book(row: sqlite3.Row) -> Book:
        tags = tuple(t for t in str(row["tags"]).split(",") if t)
        return Book(
            row["id"],
            row["isbn"],
            row["title"],
            tags,
            datetime.fromisoformat(row["added_at"]),
        )


# ---- 服务 ----


class Bookshelf:
    """用例:加书(校验 + 查重)、取书、按标签列出。"""

    def __init__(self, repo: BookRepository) -> None:
        self.repo = repo

    def add(self, data: dict) -> Book:
        """dict -> BookIn 校验 -> 查重 -> 存储。校验失败抛 pydantic.ValidationError。"""
        payload = BookIn.model_validate(data)
        if self.repo.find_by_isbn(payload.isbn) is not None:
            raise DuplicateIsbnError(f"ISBN {payload.isbn} 已存在")
        book = Book(
            None, payload.isbn, payload.title, tuple(payload.tags), datetime.now(UTC)
        )
        return self.repo.add(book)

    def get(self, book_id: int) -> Book:
        book = self.repo.get(book_id)
        if book is None:
            raise BookNotFoundError(f"书 {book_id} 不存在")
        return book

    def by_tag(self, tag: str) -> list[Book]:
        tag = tag.strip().lower()
        return [b for b in self.repo.list() if tag in b.tags]
点击展开:test_task_bookshelf.py
exercises/solutions/final/test_task_bookshelf.py
"""结业编码题的测试(题目要求写 ≥3 个,这里给出参考版 8 个)。"""

from pathlib import Path

import pytest
from pydantic import ValidationError
from task_bookshelf import (
    BookNotFoundError,
    BookRepository,
    Bookshelf,
    DuplicateIsbnError,
    InMemoryBookRepository,
    SqliteBookRepository,
)

VALID = {
    "isbn": "9787115546081",
    "title": " 流畅的 Python ",
    "tags": ["Python", "python", "编程"],
}


@pytest.fixture(params=["memory", "sqlite"])
def repo(request, tmp_path: Path) -> BookRepository:
    if request.param == "memory":
        return InMemoryBookRepository()
    return SqliteBookRepository(tmp_path / "books.db")


@pytest.fixture
def shelf(repo: BookRepository) -> Bookshelf:
    return Bookshelf(repo)


def test_add_normalizes_and_assigns_id(shelf: Bookshelf):
    book = shelf.add(VALID)
    assert (
        book.id == 1
        and book.title == "流畅的 Python"
        and book.tags == ("python", "编程")
    )
    assert book.added_at.tzinfo is not None


@pytest.mark.parametrize(
    "bad",
    [
        {**VALID, "isbn": "123"},
        {**VALID, "title": ""},
        {**VALID, "tags": list("abcdef")},
        {**VALID, "extra": 1},
    ],
    ids=["isbn", "title", "tags", "extra"],
)
def test_add_rejects_invalid_input(shelf: Bookshelf, bad: dict):
    with pytest.raises(ValidationError):
        shelf.add(bad)


def test_duplicate_isbn(shelf: Bookshelf):
    shelf.add(VALID)
    with pytest.raises(DuplicateIsbnError, match="已存在"):
        shelf.add({**VALID, "title": "另一本"})


def test_get_missing_raises_domain_error(shelf: Bookshelf):
    with pytest.raises(BookNotFoundError):
        shelf.get(42)


def test_by_tag_is_case_insensitive(shelf: Bookshelf):
    shelf.add(VALID)
    shelf.add({"isbn": "9787111641247", "title": "算法", "tags": ["cs"]})
    assert [b.title for b in shelf.by_tag("PYTHON")] == ["流畅的 Python"]
    assert shelf.by_tag("none") == []