跳转至

第 2 周 参考答案(默认折叠)

做完再看

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

点击展开:kb_solution/src/kb/init.py
exercises/solutions/week2/kb_solution/src/kb/__init__.py
1
2
3
"""kb —— 第 2 周小项目:命令行知识库。"""

__version__ = "0.1.0"
点击展开:kb_solution/src/kb/main.py
exercises/solutions/week2/kb_solution/src/kb/__main__.py
1
2
3
4
5
"""让 `python -m kb` 可用。"""

from kb.cli import app

app()
点击展开:kb_solution/src/kb/cli.py
exercises/solutions/week2/kb_solution/src/kb/cli.py
"""命令行入口(typer)。只有这里允许 print / 读参数;业务全部在 service。"""

from pathlib import Path
from typing import Annotated

import typer
from rich.console import Console
from rich.table import Table

from kb import __version__
from kb.logging_config import setup_logging
from kb.models import Note
from kb.repository import SqliteNoteRepository
from kb.service import KBError, NoteService
from kb.settings import get_settings

app = typer.Typer(help="kb:命令行知识库(第 2 周小项目)", no_args_is_help=True)
console = Console()


def get_service() -> NoteService:
    """按配置创建服务(每条命令调用一次,测试里用环境变量指向临时 db)。"""
    settings = get_settings()
    setup_logging(settings.log_level, settings.log_file)
    return NoteService(SqliteNoteRepository(settings.db_path))


def render(notes: list[Note]) -> Table:
    """把笔记列表渲染成 rich 表格。"""
    table = Table(title=f"{len(notes)} 条笔记")
    table.add_column("ID", justify="right")
    table.add_column("标题")
    table.add_column("标签")
    table.add_column("创建时间")
    for n in notes:
        table.add_row(
            str(n.id), n.title, ", ".join(n.tags), n.created_at.strftime("%m-%d %H:%M")
        )
    return table


@app.callback()
def main() -> None:
    """kb:命令行知识库。有了这个回调,typer 才把每个 @app.command 当作子命令。"""


@app.command()
def hello(name: Annotated[str, typer.Argument(help="名字")] = "世界") -> None:
    """打招呼——用来验证安装与入口点是否正常。"""
    typer.echo(f"你好,{name}!kb 已就位。")


@app.command()
def version() -> None:
    """打印版本号。"""
    typer.echo(__version__)


@app.command()
def add(
    title: Annotated[str, typer.Argument(help="标题")],
    body: Annotated[str, typer.Option("--body", "-b", help="正文")] = "",
    tag: Annotated[
        list[str] | None, typer.Option("--tag", "-t", help="标签,可重复")
    ] = None,
) -> None:
    """新增一条笔记。"""
    try:
        note = get_service().add(title, body, tag or [])
    except (KBError, ValueError) as e:
        console.print(f"[red]失败:{e}[/red]")
        raise typer.Exit(code=1) from None
    console.print(f"已添加 #{note.id} {note.title}")


@app.command(name="list")
def list_notes(
    tag: Annotated[str | None, typer.Option("--tag", "-t", help="只看这个标签")] = None,
) -> None:
    """列出笔记。"""
    console.print(render(get_service().list(tag)))


@app.command()
def search(
    pattern: Annotated[str, typer.Argument(help="关键词或正则")],
    regex: Annotated[bool, typer.Option("--regex", help="按正则搜索")] = False,
) -> None:
    """按关键词或正则搜索标题与正文。"""
    console.print(render(get_service().search(pattern, regex=regex)))


@app.command()
def delete(note_id: Annotated[int, typer.Argument(help="笔记 ID")]) -> None:
    """删除一条笔记。"""
    try:
        get_service().delete(note_id)
    except KBError as e:
        console.print(f"[red]{e}[/red]")
        raise typer.Exit(code=1) from None
    console.print(f"已删除 #{note_id}")


@app.command()
def export(path: Annotated[Path, typer.Argument(help="输出 .json 路径")]) -> None:
    """把全部笔记导出为 JSON。"""
    count = get_service().export_json(path)
    console.print(f"已导出 {count} 条到 {path}")


@app.command(name="import")
def import_md(root: Annotated[Path, typer.Argument(help="Markdown 目录")]) -> None:
    """从目录批量导入 Markdown 文件。"""
    if not root.is_dir():
        console.print(f"[red]{root} 不是目录[/red]")
        raise typer.Exit(code=2)
    report = get_service().import_markdown(root)
    console.print(f"导入成功 {report.imported} 条,失败 {len(report.failed)} 条")
    for line in report.failed:
        console.print(f"  [yellow]{line}[/yellow]")


if __name__ == "__main__":
    app()
点击展开:kb_solution/src/kb/logging_config.py
exercises/solutions/week2/kb_solution/src/kb/logging_config.py
"""日志配置:控制台用 rich,文件用 JSON(UTF-8、按大小轮转)。

只有程序入口(cli.py)调用 setup_logging;其他模块只 `logging.getLogger(__name__)`。
"""

import json
import logging
import logging.handlers
from datetime import UTC, datetime
from pathlib import Path

from rich.logging import RichHandler

_CONFIGURED_FLAG = "_kb_configured"


class JsonFormatter(logging.Formatter):
    """一行一个 JSON 对象,便于机器解析。"""

    def format(self, record: logging.LogRecord) -> str:
        """format。"""
        payload = {
            "time": datetime.fromtimestamp(record.created, UTC).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "line": f"{record.module}:{record.lineno}",
        }
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False)


def setup_logging(level: str = "INFO", log_file: Path | None = None) -> logging.Logger:
    """配置根 logger:rich 控制台 +(可选)JSON 轮转文件;重复调用不重复加 handler。"""
    root = logging.getLogger()
    if getattr(root, _CONFIGURED_FLAG, False):
        root.setLevel(level.upper())
        return root
    root.setLevel(level.upper())
    console = RichHandler(rich_tracebacks=True, show_path=False)
    console.setFormatter(logging.Formatter("%(message)s"))
    root.addHandler(console)
    if log_file is not None:
        log_file.parent.mkdir(parents=True, exist_ok=True)
        file_handler = logging.handlers.RotatingFileHandler(
            log_file, maxBytes=1_000_000, backupCount=3, encoding="utf-8"
        )
        file_handler.setFormatter(JsonFormatter())
        root.addHandler(file_handler)
    setattr(root, _CONFIGURED_FLAG, True)
    return root
点击展开:kb_solution/src/kb/markdown_import.py
exercises/solutions/week2/kb_solution/src/kb/markdown_import.py
"""从 Markdown 文件提取笔记:一级标题作 title,`#tag` 作标签,其余作正文。"""

import re
from collections.abc import Iterator
from pathlib import Path

from kb.models import Note

HEADING = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
HASHTAG = re.compile(r"(?<![\w#])#([\w\u4e00-\u9fff-]+)")
CODE_BLOCK = re.compile(r"```.*?```", re.DOTALL)


def iter_markdown(root: Path) -> Iterator[Path]:
    """惰性、按路径排序地产出 root 下全部 .md 文件。"""
    yield from sorted(p for p in root.rglob("*.md") if p.is_file())


def parse_markdown(text: str, fallback_title: str) -> Note:
    """把一段 Markdown 变成 Note:

    - title:第一个一级标题;没有则用 fallback_title(通常是文件名)
    - tags:正文(代码块除外)里的 #tag,去重小写
    - body:去掉标题行后的全文(保留代码块)
    """
    without_code = CODE_BLOCK.sub(" ", text)
    match = HEADING.search(without_code)
    title = match.group(1) if match else fallback_title
    tags = HASHTAG.findall(without_code)
    body = HEADING.sub("", text, count=1).strip()
    return Note(title=title[:100], body=body, tags=tags[:5])


def import_directory(root: Path) -> Iterator[tuple[Path, Note | None, str | None]]:
    """逐个文件产出 (路径, Note 或 None, 错误信息或 None);单个文件失败不中断。"""
    for path in iter_markdown(root):
        try:
            text = path.read_text(encoding="utf-8")
            yield path, parse_markdown(text, fallback_title=path.stem), None
        except (OSError, UnicodeDecodeError, ValueError) as e:
            yield path, None, f"{type(e).__name__}: {e}"
点击展开:kb_solution/src/kb/models.py
exercises/solutions/week2/kb_solution/src/kb/models.py
"""数据模型:Note(Pydantic,负责校验与序列化)。"""

from datetime import UTC, datetime

from pydantic import BaseModel, ConfigDict, Field, field_validator

MAX_TAGS = 5


class Note(BaseModel):
    """一条笔记。

    例:Note(title=" 标题 ", tags=["Python", "python", "AI"]).tags -> ["python", "ai"]
    """

    model_config = ConfigDict(str_strip_whitespace=True)

    id: int | None = None
    title: str = Field(min_length=1, max_length=100)
    body: str = ""
    tags: list[str] = Field(default_factory=list)
    created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))

    @field_validator("tags")
    @classmethod
    def normalize_tags(cls, tags: list[str]) -> list[str]:
        """标签统一小写、去首尾空白、去重(保持顺序)、最多 MAX_TAGS 个。"""
        seen: dict[str, None] = {}
        for tag in tags:
            cleaned = tag.strip().lower()
            if cleaned:
                seen.setdefault(cleaned, None)
        result = list(seen)
        if len(result) > MAX_TAGS:
            raise ValueError(f"标签最多 {MAX_TAGS} 个,收到 {len(result)} 个")
        return result
点击展开:kb_solution/src/kb/repository.py
exercises/solutions/week2/kb_solution/src/kb/repository.py
"""仓储层:NoteRepository 协议 + SQLite 实现 + 内存实现(测试/范例用)。"""

import logging
import re
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Protocol

from kb.models import Note

logger = logging.getLogger(__name__)

SCHEMA = """
CREATE TABLE IF NOT EXISTS notes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    body TEXT NOT NULL DEFAULT '',
    tags TEXT NOT NULL DEFAULT '',
    created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_title ON notes(title);
"""


class NoteRepository(Protocol):
    """存储层接口:服务层只依赖它,不关心背后是 SQLite 还是内存。"""

    def add(self, note: Note) -> Note:
        """保存并返回带 id 的副本。"""
        ...

    def get(self, note_id: int) -> Note | None:
        """按 id 取一条,不存在返回 None。"""
        ...

    def list(self, tag: str | None = None) -> list[Note]:
        """按 id 升序列出(可按标签过滤)。"""
        ...

    def search(self, pattern: str, *, regex: bool = False) -> list[Note]:
        """在标题与正文里按关键词或正则搜索。"""
        ...

    def delete(self, note_id: int) -> bool:
        """按 id 删除,返回是否删掉。"""
        ...


def _matches(note: Note, pattern: str, regex: bool) -> bool:
    """关键词(不分大小写)或正则是否命中标题/正文。"""
    haystack = f"{note.title}\n{note.body}"
    if regex:
        return re.search(pattern, haystack, re.IGNORECASE) is not None
    return pattern.lower() in haystack.lower()


class InMemoryNoteRepository:
    """字典实现:给测试与范例用(已完整实现,请对照着写 SQLite 版)。"""

    def __init__(self) -> None:
        """初始化。"""
        self._notes: dict[int, Note] = {}
        self._next_id = 1

    def add(self, note: Note) -> Note:
        """新增一条笔记,返回带 id 的副本。"""
        stored = note.model_copy(update={"id": self._next_id})
        self._notes[self._next_id] = stored
        self._next_id += 1
        return stored

    def get(self, note_id: int) -> Note | None:
        """按 id 取一条,不存在返回 None。"""
        return self._notes.get(note_id)

    def list(self, tag: str | None = None) -> list[Note]:
        """按 id 升序返回全部(可按标签过滤)。"""
        notes = sorted(self._notes.values(), key=lambda n: n.id or 0)
        if tag is not None:
            notes = [n for n in notes if tag.lower() in n.tags]
        return notes

    def search(self, pattern: str, *, regex: bool = False) -> list[Note]:
        """在标题与正文里按关键词(或正则)搜索。"""
        return [n for n in self.list() if _matches(n, pattern, regex)]

    def delete(self, note_id: int) -> bool:
        """按 id 删除,返回是否真的删掉了。"""
        return self._notes.pop(note_id, None) is not None


class SqliteNoteRepository:
    """SQLite 实现:参数化查询、`with conn:` 事务、Row 工厂、幂等建表。"""

    def __init__(self, db_path: Path) -> None:
        """初始化。"""
        db_path.parent.mkdir(parents=True, exist_ok=True)
        self.conn = sqlite3.connect(db_path)
        self.conn.row_factory = sqlite3.Row
        with self.conn:
            self.conn.executescript(SCHEMA)
        logger.debug("已连接数据库 %s", db_path)

    def close(self) -> None:
        """关闭数据库连接。"""
        self.conn.close()

    def add(self, note: Note) -> Note:
        """新增一条笔记,返回带 id 的副本。"""
        with self.conn:
            cursor = self.conn.execute(
                "INSERT INTO notes (title, body, tags, created_at) VALUES (?, ?, ?, ?)",
                (
                    note.title,
                    note.body,
                    ",".join(note.tags),
                    note.created_at.isoformat(),
                ),
            )
        return note.model_copy(update={"id": int(cursor.lastrowid or 0)})

    def get(self, note_id: int) -> Note | None:
        """按 id 取一条,不存在返回 None。"""
        row = self.conn.execute(
            "SELECT * FROM notes WHERE id = ?", (note_id,)
        ).fetchone()
        return None if row is None else self._to_note(row)

    def list(self, tag: str | None = None) -> list[Note]:
        """按 id 升序返回全部(可按标签过滤)。"""
        rows = self.conn.execute("SELECT * FROM notes ORDER BY id").fetchall()
        notes = [self._to_note(row) for row in rows]
        if tag is not None:
            notes = [n for n in notes if tag.lower() in n.tags]
        return notes

    def search(self, pattern: str, *, regex: bool = False) -> list[Note]:
        """在标题与正文里按关键词(或正则)搜索。"""
        if regex:
            return [n for n in self.list() if _matches(n, pattern, regex=True)]
        like = f"%{pattern}%"
        rows = self.conn.execute(
            "SELECT * FROM notes WHERE title LIKE ? OR body LIKE ? ORDER BY id",
            (like, like),
        ).fetchall()
        return [self._to_note(row) for row in rows]

    def delete(self, note_id: int) -> bool:
        """按 id 删除,返回是否真的删掉了。"""
        with self.conn:
            cursor = self.conn.execute("DELETE FROM notes WHERE id = ?", (note_id,))
        return cursor.rowcount > 0

    @staticmethod
    def _to_note(row: sqlite3.Row) -> Note:
        """_to_note。"""
        tags = [t for t in str(row["tags"]).split(",") if t]
        return Note(
            id=row["id"],
            title=row["title"],
            body=row["body"],
            tags=tags,
            created_at=datetime.fromisoformat(row["created_at"]),
        )
点击展开:kb_solution/src/kb/service.py
exercises/solutions/week2/kb_solution/src/kb/service.py
"""服务层:业务用例。只依赖 NoteRepository 协议,不 print、不读键盘。"""

import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path

from kb.markdown_import import import_directory
from kb.models import Note
from kb.repository import NoteRepository

logger = logging.getLogger(__name__)


class KBError(Exception):
    """kb 的业务错误基类。"""


class NoteNotFoundError(KBError):
    """找不到笔记。"""


@dataclass(frozen=True)
class ImportReport:
    """一次导入的结果。"""

    imported: int
    failed: list[str]


class NoteService:
    """增、查、搜、删、导出、导入。"""

    def __init__(self, repo: NoteRepository) -> None:
        """初始化。"""
        self.repo = repo

    def add(self, title: str, body: str = "", tags: list[str] | None = None) -> Note:
        """新增一条笔记,返回带 id 的副本。"""
        note = Note(title=title, body=body, tags=tags or [])
        stored = self.repo.add(note)
        logger.info("新增笔记 #%s %s", stored.id, stored.title)
        return stored

    def get(self, note_id: int) -> Note:
        """按 id 取一条,不存在返回 None。"""
        note = self.repo.get(note_id)
        if note is None:
            raise NoteNotFoundError(f"笔记 {note_id} 不存在")
        return note

    def list(self, tag: str | None = None) -> list[Note]:
        """按 id 升序返回全部(可按标签过滤)。"""
        return self.repo.list(tag)

    def search(self, pattern: str, *, regex: bool = False) -> list[Note]:
        """在标题与正文里按关键词(或正则)搜索。"""
        return self.repo.search(pattern, regex=regex)

    def delete(self, note_id: int) -> None:
        """按 id 删除,返回是否真的删掉了。"""
        if not self.repo.delete(note_id):
            raise NoteNotFoundError(f"笔记 {note_id} 不存在")
        logger.info("删除笔记 #%s", note_id)

    def export_json(self, path: Path) -> int:
        """原子写:先写 .tmp 再 replace;返回导出条数。"""
        notes = [n.model_dump(mode="json") for n in self.repo.list()]
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(path.suffix + ".tmp")
        tmp.write_text(
            json.dumps(notes, ensure_ascii=False, indent=2), encoding="utf-8"
        )
        os.replace(tmp, path)
        logger.info("导出 %d 条到 %s", len(notes), path)
        return len(notes)

    def import_markdown(self, root: Path) -> ImportReport:
        """从目录批量导入 Markdown,单个文件失败不中断,返回报告。"""
        imported, failed = 0, []
        for path, note, error in import_directory(root):
            if note is None:
                failed.append(f"{path}: {error}")
                logger.warning("跳过 %s%s", path, error)
                continue
            self.repo.add(note)
            imported += 1
        logger.info("导入完成:成功 %d,失败 %d", imported, len(failed))
        return ImportReport(imported=imported, failed=failed)
点击展开:kb_solution/src/kb/settings.py
exercises/solutions/week2/kb_solution/src/kb/settings.py
"""配置:环境变量 / .env → Settings(pydantic-settings)。

优先级:环境变量 > .env 文件 > 默认值。变量名前缀 KB_,例如 KB_DB_PATH=./kb.db。
"""

from functools import lru_cache
from pathlib import Path

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    """kb 的全部可配置项。"""

    model_config = SettingsConfigDict(
        env_prefix="KB_", env_file=".env", env_file_encoding="utf-8", extra="ignore"
    )

    db_path: Path = Path("kb.db")
    log_level: str = "INFO"
    log_file: Path | None = None


@lru_cache
def get_settings() -> Settings:
    """进程内只解析一次配置;测试里用 get_settings.cache_clear() 重置。"""
    return Settings()
点击展开:w2_03_typing.py
exercises/solutions/week2/w2_03_typing.py
"""Week 2 · M7 · typing 进阶——参考答案

对应练习:exercises/week2/w2_03_typing.py
测试:把本文件复制成 w2_03_typing.py 后跑
      uv run pytest exercises/week2/test_w2_03_typing.py -q
另外要求:Pylance(standard)零报错、`uvx ty check` 零错误。
"""

import functools
import time
from collections.abc import Callable, Sequence
from typing import (
    Final,
    Literal,
    NotRequired,
    Protocol,
    Self,
    TypedDict,
    TypeIs,
    Unpack,
    overload,
    runtime_checkable,
)

MAX_STACK: Final = 100
"""Final:常量。类型检查器会拒绝对它重新赋值。"""

type Json = dict[str, "Json"] | list["Json"] | str | int | float | bool | None

LAST_DURATIONS: dict[str, float] = {}


def is_json(value: object) -> TypeIs[Json]:
    """判断 value 能不能直接被 json.dumps 序列化(只认 Json 别名里的那几种)。

    例:is_json({"a": [1, None]}) -> True;is_json({1: "a"}) -> False
    """
    if value is None or isinstance(value, str | bool | int | float):
        return True
    if isinstance(value, list):
        return all(is_json(item) for item in value)
    if isinstance(value, dict):
        return all(isinstance(k, str) and is_json(v) for k, v in value.items())
    return False


class Stack[T]:
    """后进先出的栈。push 返回 self(Self 类型),所以可以链式调用。

    例:Stack[int]().push(1).push(2).pop() -> 2
    """

    def __init__(self) -> None:
        """创建一个空栈。"""
        self._items: list[T] = []

    def push(self, item: T) -> Self:
        """压入一个元素并返回自己,便于链式调用。"""
        self._items.append(item)
        return self

    def pop(self) -> T:
        """弹出并返回栈顶;空栈时抛 IndexError("栈是空的")。"""
        if not self._items:
            raise IndexError("栈是空的")
        return self._items.pop()

    def peek(self) -> T:
        """看一眼栈顶但不弹出;空栈时抛 IndexError("栈是空的")。"""
        if not self._items:
            raise IndexError("栈是空的")
        return self._items[-1]

    def __len__(self) -> int:
        """栈里的元素个数。"""
        return len(self._items)

    def is_empty(self) -> bool:
        """是否为空栈。例:Stack[str]().is_empty() -> True"""
        return not self._items


def pairwise[T](xs: Sequence[T]) -> list[tuple[T, T]]:
    """相邻两两配对。例:pairwise([1, 2, 3]) -> [(1, 2), (2, 3)]"""
    return [(xs[i], xs[i + 1]) for i in range(len(xs) - 1)]


def timed[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    """装饰器:把 fn 的耗时(秒)记到 LAST_DURATIONS[fn.__name__],返回值原样返回。

    例:@timed 装饰 add(a, b) 后,add(1, 2) -> 3,且 LAST_DURATIONS["add"] >= 0
    """

    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            LAST_DURATIONS[fn.__name__] = time.perf_counter() - start

    return wrapper


class User(TypedDict):
    """一个形状固定的字典:email 可有可无。"""

    name: str
    age: int
    email: NotRequired[str]


def make_user(**kwargs: Unpack[User]) -> User:
    """构造 User:name 去掉首尾空白;age 为负时抛 ValueError("age 不能为负")。

    例:make_user(name=" 小明 ", age=15) -> {"name": "小明", "age": 15}
    """
    age = kwargs["age"]
    if age < 0:
        raise ValueError("age 不能为负")
    user: User = {"name": kwargs["name"].strip(), "age": age}
    email = kwargs.get("email")
    if email is not None:
        user["email"] = email
    return user


def describe_user(user: User) -> str:
    """一句话描述用户;有 email 就带上。

    例:describe_user({"name": "小明", "age": 15}) -> "小明(15 岁)"
    """
    email = user.get("email")
    if email is None:
        return f"{user['name']}{user['age']} 岁)"
    return f"{user['name']}{user['age']} 岁,{email})"


type Mode = Literal["r", "w"]


def open_mode(mode: Mode) -> str:
    """把打开模式翻译成中文。例:open_mode("r") -> "只读" """
    match mode:
        case "r":
            return "只读"
        case "w":
            return "只写"
        case _:
            raise ValueError(f"不支持的模式:{mode!r}")


@overload
def parse(x: str) -> int: ...


@overload
def parse(x: bytes) -> str: ...


def parse(x: str | bytes) -> int | str:
    """重载:给 str 返回 int(十进制解析),给 bytes 返回 str(utf-8 解码)。

    例:parse("42") -> 42;parse(b"hi") -> "hi"
    """
    if isinstance(x, str):
        return int(x)
    if isinstance(x, bytes):
        return x.decode("utf-8")
    raise TypeError("parse 只接受 str 或 bytes")


@runtime_checkable
class Repository[T](Protocol):
    """结构化子类型:谁有这三个方法,谁就算实现了 Repository[T]。"""

    def add(self, item: T) -> None:
        """加一个元素。"""
        ...

    def get(self, index: int) -> T | None:
        """按下标取,越界返回 None。"""
        ...

    def list_all(self) -> list[T]:
        """返回全部元素的副本。"""
        ...


class ListRepository[T]:
    """用列表实现 Repository[T]。

    例:r = ListRepository[int](); r.add(1); r.list_all() -> [1]
    """

    def __init__(self) -> None:
        """创建空仓储。"""
        self._items: list[T] = []

    def add(self, item: T) -> None:
        """加一个元素。"""
        self._items.append(item)

    def get(self, index: int) -> T | None:
        """按下标取,越界返回 None。"""
        if 0 <= index < len(self._items):
            return self._items[index]
        return None

    def list_all(self) -> list[T]:
        """返回副本,调用方改它不影响仓储内部。"""
        return list(self._items)
点击展开:w2_04_pydantic_models.py
exercises/solutions/week2/w2_04_pydantic_models.py
"""Week 2 · M8a · Pydantic(一)——参考答案

对应练习:exercises/week2/w2_04_pydantic_models.py
"""

from datetime import UTC, datetime
from decimal import Decimal
from typing import Annotated, Any, Literal, Self

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    ValidationError,
    field_validator,
    model_validator,
)


class Note(BaseModel):
    """一条笔记。

    注意 Field(max_length=5) 的检查发生在去重之前(["a"] * 6 会报错)。

    例:Note(title="标题", tags=["Python", "python", " AI "]).tags -> ["python", "ai"]
    """

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

    title: str = Field(min_length=1, max_length=100)
    body: str = ""
    tags: list[str] = Field(default=[], max_length=5)
    created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
    priority: Literal["low", "normal", "high"] = "normal"

    @field_validator("tags")
    @classmethod
    def _normalize_tags(cls, v: list[str]) -> list[str]:
        """标签统一小写、去空白、去空串,并按出现顺序去重。"""
        seen: dict[str, None] = {}
        for tag in v:
            cleaned = tag.strip().lower()
            if cleaned:
                seen.setdefault(cleaned, None)
        return list(seen)


class Address(BaseModel):
    """收件地址。

    例:Address(city="北京", street="中关村 1 号", zipcode="100080").zipcode -> "100080"
    """

    model_config = ConfigDict(extra="forbid")

    city: str = Field(min_length=1)
    street: str = ""
    zipcode: str = Field(pattern=r"^\d{6}$")


class Profile(BaseModel):
    """用户资料:把 Address 嵌进来。

    例:Profile(name="小明", age=15, address={"city": "北京", "zipcode": "100080"})
    """

    model_config = ConfigDict(extra="forbid")

    name: str
    age: Annotated[int, Field(ge=0, le=150)]
    address: Address
    nickname: str | None = None


class Meeting(BaseModel):
    """会议:结束时间必须晚于开始时间。

    例:start >= end 时抛 ValidationError
    """

    title: str
    start: datetime
    end: datetime

    @model_validator(mode="after")
    def _check_order(self) -> Self:
        """跨字段校验:mode="after" 拿到的是已经建好的实例。"""
        if self.start >= self.end:
            raise ValueError("start 必须早于 end")
        return self


class Money(BaseModel):
    """金额。strict=True 关掉宽松解析。

    例:Money(amount=Decimal("1.5"), currency="CNY") 可以;amount="1.5" 会报错
    """

    model_config = ConfigDict(strict=True)

    amount: Decimal = Field(gt=0)
    currency: str = Field(pattern=r"^[A-Z]{3}$")


def format_errors(exc: ValidationError, index: int) -> list[str]:
    """把一个 ValidationError 变成人能读的字符串列表。

    例:["第 0 条 title: String should have at least 1 character"]
    """
    messages: list[str] = []
    for err in exc.errors():
        loc = ".".join(str(part) for part in err["loc"])
        messages.append(f"第 {index}{loc}: {err['msg']}")
    return messages


def parse_notes(raw: list[dict[str, Any]]) -> tuple[list[Note], list[str]]:
    """批量解析笔记:好的收进列表,坏的记下错误。

    例:parse_notes([{"title": "a"}, {"title": ""}]) -> (1 条 Note, 1 条错误)
    """
    notes: list[Note] = []
    errors: list[str] = []
    for index, item in enumerate(raw):
        try:
            notes.append(Note.model_validate(item))
        except ValidationError as exc:
            errors.extend(format_errors(exc, index))
    return notes, errors


def promote(note: Note) -> Note:
    """返回一个 priority 为 "high" 的副本,原对象保持不变。

    例:promote(Note(title="t")).priority -> "high"
    """
    return note.model_copy(update={"priority": "high"})


__all__ = [
    "Address",
    "Meeting",
    "Money",
    "Note",
    "Profile",
    "format_errors",
    "parse_notes",
    "promote",
]
点击展开:w2_05_pydantic_schema_settings.py
exercises/solutions/week2/w2_05_pydantic_schema_settings.py
"""Week 2 · M8b · Pydantic(二)——参考答案

对应练习:exercises/week2/w2_05_pydantic_schema_settings.py
"""

import time
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Annotated, Any, Literal

from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Click(BaseModel):
    """鼠标点击事件。kind 是判别字段。"""

    kind: Literal["click"] = "click"
    x: int
    y: int


class KeyPress(BaseModel):
    """键盘事件。"""

    kind: Literal["key"] = "key"
    key: str


type Event = Annotated[Click | KeyPress, Field(discriminator="kind")]

# 模块级常量:TypeAdapter 建一次就够,复用能省掉重复编译校验器的开销
EVENT_ADAPTER: TypeAdapter[Event] = TypeAdapter(Event)
EVENT_LIST_ADAPTER: TypeAdapter[list[Event]] = TypeAdapter(list[Event])


def parse_event(data: dict[str, Any]) -> Click | KeyPress:
    """按 kind 把一个 dict 解析成 Click 或 KeyPress。

    例:parse_event({"kind": "click", "x": 1, "y": 2}) -> Click(x=1, y=2)
    """
    return EVENT_ADAPTER.validate_python(data)


def parse_events(raw: list[dict[str, Any]]) -> list[Click | KeyPress]:
    """一次解析一批事件。

    例:parse_events([{"kind": "key", "key": "a"}]) -> [KeyPress(key="a")]
    """
    return EVENT_LIST_ADAPTER.validate_python(raw)


class ApiNote(BaseModel):
    """要发给前端的笔记。"""

    model_config = ConfigDict(populate_by_name=True)

    title: str
    created_at: datetime = Field(alias="createdAt")
    price: Decimal
    note_id: int | None = None

    @computed_field
    @property
    def summary(self) -> str:
        """一句话摘要(计算字段)。"""
        return f"{self.title}{self.price} 元)"


def dump_for_api(model: BaseModel) -> dict[str, Any]:
    """把任意模型变成可以直接 json.dumps 的 dict。

    例:dump_for_api(ApiNote(...))["createdAt"] -> "2026-09-14T00:00:00Z"
    """
    return model.model_dump(mode="json", exclude_none=True, by_alias=True)


class Article(BaseModel):
    """文章。注意每个字段的 description 都会进 JSON Schema。"""

    title: str = Field(description="文章标题")
    views: int = Field(default=0, description="阅读量")
    author: str | None = Field(default=None, description="作者,可省略")
    tags: list[str] = Field(default_factory=list, description="标签")


def _property_type(prop: dict[str, Any]) -> str:
    """从一个 JSON Schema 的 property 里取出类型描述。"""
    if "type" in prop:
        return str(prop["type"])
    if "anyOf" in prop:
        return "|".join(str(sub.get("type", "unknown")) for sub in prop["anyOf"])
    return "unknown"


def schema_summary(model: type[BaseModel]) -> dict[str, Any]:
    """从 model_json_schema() 里提取一份人和机器都能读的摘要。

    例:schema_summary(Article)["required"] -> ["title"]
    """
    schema = model.model_json_schema()
    fields: dict[str, Any] = {}
    for name, prop in schema.get("properties", {}).items():
        fields[name] = {
            "type": _property_type(prop),
            "description": prop.get("description", ""),
            "default": prop.get("default"),
        }
    return {"required": list(schema.get("required", [])), "fields": fields}


class Settings(BaseSettings):
    """从环境变量 / .env 读配置。

    例:设了 KB_LOG_LEVEL=DEBUG 后 Settings().log_level -> "DEBUG"
    """

    model_config = SettingsConfigDict(
        env_prefix="KB_",
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    db_path: Path = Path("kb.db")
    log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"


def describe_settings(settings: Settings) -> str:
    """一句话描述配置,用来在 CLI 启动时打日志。

    例:describe_settings(Settings()) -> "db=kb.db level=INFO"
    """
    return f"db={settings.db_path} level={settings.log_level}"


class Doc(BaseModel):
    """一条文档记录(用来比较两种校验方式)。"""

    id: int
    title: str
    created_at: datetime


DOC_LIST_ADAPTER: TypeAdapter[list[Doc]] = TypeAdapter(list[Doc])


def validate_docs(raw: list[dict[str, Any]]) -> list[Doc]:
    """用 TypeAdapter(list[Doc]) 一次校验整批。

    例:validate_docs([{"id": 1, "title": "a", "created_at": "2026-09-14T00:00:00Z"}])
    """
    return DOC_LIST_ADAPTER.validate_python(raw)


def validate_docs_one_by_one(raw: list[dict[str, Any]]) -> list[Doc]:
    """逐条 Doc.model_validate(...)。

    例:结果与 validate_docs 相等
    """
    return [Doc.model_validate(item) for item in raw]


def time_validation(raw: list[dict[str, Any]], repeat: int = 3) -> tuple[float, float]:
    """把两种方式各跑 repeat 遍,返回 (TypeAdapter 用的秒数, 逐条用的秒数)。

    例:time_validation(raw, repeat=1) -> (0.0012, 0.0031)
    """
    if repeat <= 0:
        raise ValueError("repeat 必须为正")
    start = time.perf_counter()
    for _ in range(repeat):
        validate_docs(raw)
    adapter_seconds = time.perf_counter() - start
    start = time.perf_counter()
    for _ in range(repeat):
        validate_docs_one_by_one(raw)
    return adapter_seconds, time.perf_counter() - start
点击展开:w2_06_logging_config.py
exercises/solutions/week2/w2_06_logging_config.py
"""Week 2 · M9 · logging 与配置——参考答案

对应练习:exercises/week2/w2_06_logging_config.py
"""

# setup_logging 有个参数叫 json(照练习要求的签名),为了不混淆,模块改个别名导入
import json as json_module
import logging
import os
import tomllib
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field
from rich.logging import RichHandler

logger = logging.getLogger(__name__)

MANAGED_FLAG = "_w2_managed"


class JsonFormatter(logging.Formatter):
    """把一条日志格式化成一行 JSON。

    例:json.loads(JsonFormatter().format(record))["message"] -> "a=1"
    """

    def format(self, record: logging.LogRecord) -> str:
        """把一条 LogRecord 变成一行 JSON 字符串。"""
        payload: dict[str, str] = {
            "time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        if record.exc_info:
            payload["exc_info"] = self.formatException(record.exc_info)
        return json_module.dumps(payload, ensure_ascii=False)


def setup_logging(
    level: str = "INFO",
    log_file: Path | None = None,
    json: bool = False,
) -> logging.Logger:
    """配置根 logger 并返回它。幂等:重复调用不会重复加 handler。

    例:setup_logging("DEBUG") 后 logging.getLogger().level -> 10
    """
    levels = logging.getLevelNamesMapping()
    numeric = levels.get(level.upper())
    if numeric is None:
        raise ValueError(f"未知日志级别:{level}")

    root = logging.getLogger()
    for handler in managed_handlers():
        root.removeHandler(handler)
        handler.close()
    root.setLevel(numeric)

    formatter: logging.Formatter = (
        JsonFormatter()
        if json
        else logging.Formatter("%(levelname)s %(name)s: %(message)s")
    )

    handlers: list[logging.Handler] = [RichHandler(rich_tracebacks=True)]
    if log_file is not None:
        log_file.parent.mkdir(parents=True, exist_ok=True)
        handlers.append(
            RotatingFileHandler(
                log_file, maxBytes=1_000_000, backupCount=3, encoding="utf-8"
            )
        )
    for handler in handlers:
        handler.setFormatter(formatter)
        setattr(handler, MANAGED_FLAG, True)
        root.addHandler(handler)
    return root


def managed_handlers() -> list[logging.Handler]:
    """返回根 logger 上由 setup_logging 加的那些 handler。

    例:setup_logging() 后 len(managed_handlers()) -> 1
    """
    return [
        handler
        for handler in logging.getLogger().handlers
        if getattr(handler, MANAGED_FLAG, False)
    ]


def divide(a: float, b: float) -> float | None:
    """相除。成功时 logger.info,除零时 logger.exception 并返回 None。

    例:divide(6, 3) -> 2.0;divide(1, 0) -> None
    """
    try:
        result = a / b
    except ZeroDivisionError:
        logger.exception("除以零:%s / %s", a, b)
        return None
    logger.info("divide %s / %s = %s", a, b, result)
    return result


class AppConfig(BaseModel):
    """应用配置(从 TOML 读出来后用它校验)。"""

    model_config = ConfigDict(extra="forbid")

    name: str = Field(min_length=1)
    level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
    max_items: int = Field(default=100, gt=0)


def load_config(path: Path) -> AppConfig:
    """用 tomllib 读 TOML 文件,再交给 AppConfig 校验。

    例:内容为 'name = "kb"' 的文件 -> AppConfig(name="kb")
    """
    with path.open("rb") as fp:
        data = tomllib.load(fp)
    return AppConfig.model_validate(data)


def resolve_setting(cli_value: str | None, env_name: str, default: str) -> str:
    """按"命令行参数 > 环境变量 > 默认值"的优先级取一个配置值。

    例:resolve_setting("DEBUG", "KB_LOG_LEVEL", "INFO") -> "DEBUG"
    """
    if cli_value is not None:
        return cli_value
    return os.environ.get(env_name) or default
点击展开:w2_07_regex.py
exercises/solutions/week2/w2_07_regex.py
r"""Week 2 · M10 · 正则表达式——参考答案

对应练习:exercises/week2/w2_07_regex.py
"""

import re
from collections import Counter

LOG_LINE_RE = re.compile(
    r"(?P<date>\d{4}-\d{2}-\d{2}) "
    r"(?P<time>\d{2}:\d{2}:\d{2}) "
    r"\[(?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL)\] "
    r"(?P<module>[\w.]+): "
    r"(?P<message>.*)"
)

URL_RE = re.compile(
    r"""
    (?P<scheme>https?)          # 协议:http 或 https
    ://
    (?P<host>[\w.-]+)           # 主机名(不含端口)
    (?::\d+)?                   # 可选端口,非捕获
    (?P<path>/[^\s?\#]*)?       # 可选路径;VERBOSE 里的 # 要转义
    (?:\?[^\s\#]*)?             # 可选 query
    (?:\#\S*)?                  # 可选片段
    """,
    re.VERBOSE,
)

EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
PHONE_RE = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
DATE_RE = re.compile(r"(?<!\d)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?!\d)")
CODE_BLOCK_RE = re.compile(r"```.*?```\n?", re.DOTALL)
HEADING_RE = re.compile(r"^#{1,6}[ \t]*", re.MULTILINE)
BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]*\)")
TOKEN_RE = re.compile(r"[\u4e00-\u9fff]|[A-Za-z]+|\d+(?:\.\d+)?")
SECRET_RE = re.compile(r"sk-[A-Za-z0-9]{8,}")
PHONE_MASK_RE = re.compile(r"(?<!\d)(1[3-9]\d)\d{4}(\d{4})(?!\d)")
WORD_RE = re.compile(r"[A-Za-z]+")


def parse_log_line(line: str) -> dict[str, str] | None:
    """解析一行日志,返回 5 个命名分组组成的字典;不匹配返回 None。

    例:parse_log_line("2026-09-05 12:00:01 [ERROR] kb.cli: 出错") -> {...}
    """
    match = LOG_LINE_RE.fullmatch(line)
    if match is None:
        return None
    return match.groupdict()


def extract_emails(text: str) -> list[str]:
    """按出现顺序提取所有邮箱地址。

    例:extract_emails("a@b.com") -> ["a@b.com"]
    """
    return EMAIL_RE.findall(text)


def extract_cn_phones(text: str) -> list[str]:
    r"""提取中国大陆手机号:1 开头,第二位 3–9,共 11 位数字。

    例:extract_cn_phones("打 13812345678") -> ["13812345678"]
    """
    return PHONE_RE.findall(text)


def extract_dates(text: str) -> list[str]:
    """提取 ISO 8601 日期(YYYY-MM-DD)。

    例:extract_dates("从 2026-09-14 到 2026-09-20") -> ["2026-09-14", "2026-09-20"]
    """
    return DATE_RE.findall(text)


def normalize_whitespace(text: str) -> str:
    r"""把所有连续空白压成一个空格,并去掉首尾空白。

    例:normalize_whitespace("  a\tb\n\nc  ") -> "a b c"
    """
    return re.sub(r"\s+", " ", text).strip()


def strip_markdown(text: str) -> str:
    r"""把 Markdown 变成纯文本。

    例:strip_markdown("# 标题\n\n**粗**和[链接](http://a.b)") -> "标题\n\n粗和链接"
    """
    cleaned = CODE_BLOCK_RE.sub("", text)
    cleaned = HEADING_RE.sub("", cleaned)
    cleaned = BOLD_RE.sub(r"\1", cleaned)
    cleaned = LINK_RE.sub(r"\1", cleaned)
    cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
    return cleaned.strip()


def tokenize_zh_en(text: str) -> list[str]:
    """中英文混排分词:中文按字切,英文按词切,数字连成一个。

    例:tokenize_zh_en("我爱 Python 3.14!") -> ["我", "爱", "Python", "3.14"]
    """
    return TOKEN_RE.findall(text)


def mask_secrets(text: str) -> str:
    r"""脱敏:把密钥和手机号中间几位遮掉。

    例:mask_secrets("key=sk-abcd1234efgh") -> "key=sk-***"
    """
    masked = SECRET_RE.sub("sk-***", text)
    return PHONE_MASK_RE.sub(r"\1****\2", masked)


def parse_url(url: str) -> dict[str, str] | None:
    """用 URL_RE 解析 URL,返回 scheme/host/path;不匹配返回 None。

    例:parse_url("https://cnb.cool/me/repo?tab=1")["path"] -> "/me/repo"
    """
    match = URL_RE.fullmatch(url)
    if match is None:
        return None
    return {
        "scheme": match["scheme"],
        "host": match["host"],
        "path": match["path"] or "",
    }


def count_words(text: str) -> dict[str, int]:
    """统计英文单词出现次数(忽略大小写),按次数降序、字母升序。

    例:count_words("a b A c b") -> {"a": 2, "b": 2, "c": 1}
    """
    counter = Counter(word.lower() for word in WORD_RE.findall(text))
    return dict(sorted(counter.items(), key=lambda kv: (-kv[1], kv[0])))
点击展开:w2_08_files_sqlite.py
exercises/solutions/week2/w2_08_files_sqlite.py
"""Week 2 · M11 · 文件与序列化进阶 + SQLite——参考答案

对应练习:exercises/week2/w2_08_files_sqlite.py
"""

import csv
import hashlib
import json
import os
import re
import sqlite3
from collections.abc import Iterator, Sequence
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any

SCHEMA = """
CREATE TABLE IF NOT EXISTS notes (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT NOT NULL,
    body       TEXT NOT NULL DEFAULT '',
    tags       TEXT NOT NULL DEFAULT '',
    created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes (created_at);
"""


def iter_markdown_files(root: Path) -> Iterator[Path]:
    """递归找出 root 下所有 .md 文件,按路径排序后逐个 yield。

    例:iter_markdown_files(Path("research")) -> 逐个 Path
    """
    if not root.exists():
        raise FileNotFoundError(f"目录不存在:{root}")
    for path in sorted(root.rglob("*")):
        if not path.is_file() or path.suffix.lower() != ".md":
            continue
        if any(part.startswith(".") for part in path.relative_to(root).parts[:-1]):
            continue
        yield path


def file_sha256(path: Path, chunk_size: int = 65536) -> str:
    """算文件的 SHA-256 十六进制摘要,分块读。

    例:内容为 b"hello" 的文件 -> "2cf24dba...9824"
    """
    digest = hashlib.sha256()
    with path.open("rb") as fp:
        while chunk := fp.read(chunk_size):
            digest.update(chunk)
    return digest.hexdigest()


def json_default(obj: Any) -> Any:
    """给 json.dump 的 default= 用:把它不认识的类型转成能序列化的东西。

    例:json_default(Decimal("1.5")) -> "1.5"
    """
    if isinstance(obj, datetime):
        return obj.isoformat()
    if isinstance(obj, Decimal):
        return str(obj)
    if isinstance(obj, set | frozenset):
        return sorted(obj)
    raise TypeError(f"不支持的类型:{type(obj).__name__}")


def export_json(path: Path, notes: list[dict[str, Any]]) -> None:
    """把 notes 原子地写成 JSON 文件。

    例:export_json(p, [{"title": "中文"}]) 后 p 里是 UTF-8 的 JSON 数组
    """
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    try:
        with tmp.open("w", encoding="utf-8") as fp:
            json.dump(notes, fp, ensure_ascii=False, indent=2, default=json_default)
        os.replace(tmp, path)
    except BaseException:
        tmp.unlink(missing_ok=True)
        raise


def read_csv_with_bom(path: Path) -> list[dict[str, str]]:
    """读 Excel 导出的 CSV(带 BOM),返回每行一个 dict。

    例:表头 name,score 的文件 -> [{"name": "小明", "score": "90"}]
    """
    with path.open("r", encoding="utf-8-sig", newline="") as fp:
        return [dict(row) for row in csv.DictReader(fp)]


def utc_now_iso() -> str:
    """当前时间的 UTC ISO 8601 字符串(带时区)。

    例:utc_now_iso() -> "2026-09-14T02:03:04.567890+00:00"
    """
    return datetime.now(UTC).isoformat()


def migrate(conn: sqlite3.Connection) -> None:
    """建表建索引,幂等。

    例:migrate(conn); migrate(conn) 不报错
    """
    with conn:
        conn.executescript(SCHEMA)
        conn.execute("PRAGMA journal_mode=WAL")


class SqliteNoteRepository:
    """用 SQLite 存笔记:add/get/list/search/delete。

    例:repo = SqliteNoteRepository(tmp_path / "kb.db"); repo.add("标题") -> 1
    """

    def __init__(self, db_path: Path) -> None:
        """连上数据库,设置 row_factory,并跑一次 migrate。"""
        db_path.parent.mkdir(parents=True, exist_ok=True)
        self.conn = sqlite3.connect(db_path)
        self.conn.row_factory = sqlite3.Row
        migrate(self.conn)

    def add(self, title: str, body: str = "", tags: Sequence[str] = ()) -> int:
        """插入一条笔记,返回新的 id。

        例:repo.add("标题", tags=["python"]) -> 1
        """
        with self.conn:
            cursor = self.conn.execute(
                "INSERT INTO notes (title, body, tags, created_at) VALUES (?, ?, ?, ?)",
                (title, body, ",".join(tags), utc_now_iso()),
            )
        return int(cursor.lastrowid or 0)

    def get(self, note_id: int) -> dict[str, Any] | None:
        """按 id 取一条,不存在返回 None。

        例:repo.get(999) -> None
        """
        row = self.conn.execute(
            "SELECT * FROM notes WHERE id = ?", (note_id,)
        ).fetchone()
        return None if row is None else self._row_to_dict(row)

    def list(self) -> list[dict[str, Any]]:
        """按 id 升序返回全部笔记。

        例:repo.list() -> [{"id": 1, ...}]
        """
        rows = self.conn.execute("SELECT * FROM notes ORDER BY id").fetchall()
        return [self._row_to_dict(row) for row in rows]

    def search(self, pattern: str, *, regex: bool = False) -> list[dict[str, Any]]:
        """在 title 和 body 里搜,返回命中的笔记。

        例:repo.search("python") -> 命中的那些
        """
        if regex:
            compiled = re.compile(pattern, re.IGNORECASE)
            return [
                note
                for note in self.list()
                if compiled.search(f"{note['title']}\n{note['body']}")
            ]
        rows = self.conn.execute(
            "SELECT * FROM notes WHERE title LIKE ? OR body LIKE ? ORDER BY id",
            (f"%{pattern}%", f"%{pattern}%"),
        ).fetchall()
        return [self._row_to_dict(row) for row in rows]

    def delete(self, note_id: int) -> bool:
        """删一条,返回是否真的删掉了。

        例:repo.delete(1) -> True;再删一次 -> False
        """
        with self.conn:
            cursor = self.conn.execute("DELETE FROM notes WHERE id = ?", (note_id,))
        return cursor.rowcount > 0

    def close(self) -> None:
        """关闭连接。"""
        self.conn.close()

    def _row_to_dict(self, row: sqlite3.Row) -> dict[str, Any]:
        """把一行 sqlite3.Row 变成 dict,并把 tags 还原成 list[str]。

        例:tags 字段是 "a,b" -> {"tags": ["a", "b"], ...}
        """
        note = dict(row)
        raw_tags = str(note.get("tags") or "")
        note["tags"] = [tag for tag in raw_tags.split(",") if tag]
        return note