Capstone:组装并验证 TextLab
目标:能够把前面已经实现的 schema、异步分类、TaskGroup、FastAPI 与 SQLAlchemy repository 组合成可从干净环境运行和测试的服务。
最后一课不再引入新框架。你已经分别完成:
- Pydantic request/response 边界;
- FastAPI route 与 TestClient;
- AsyncIO timeout 和 TaskGroup;
- SQLAlchemy Engine、Session、事务和 repository;
- uv、Ruff、Pyright、pytest 质量门。
现在只做依赖方向和生命周期的整合:HTTP 层依赖 service,service 依赖 provider 与 repository,外部实现通过构造或 FastAPI dependency 注入。
最终目录
textlab/
├── pyproject.toml
├── uv.lock
├── scripts/check.sh
├── src/textlab/
│ ├── __init__.py
│ ├── api.py
│ ├── db.py
│ ├── schemas.py
│ └── service.py
└── tests/
├── test_api.py
├── test_db.py
└── test_service.py
服务层:只负责用例
建立 src/textlab/service.py:
from __future__ import annotations
import asyncio
from typing import Protocol
from textlab.schemas import MessageInput, MessageOutput
class AsyncTextProvider(Protocol):
async def classify(self, text: str) -> str:
raise NotImplementedError
class MessageWriter(Protocol):
def add(self, *, text: str, category: str, priority: int) -> int:
raise NotImplementedError
class LocalProvider:
async def classify(self, text: str) -> str:
await asyncio.sleep(0)
return "refund" if "退款" in text else "general"
class TextService:
def __init__(self, provider: AsyncTextProvider, writer: MessageWriter) -> None:
self._provider = provider
self._writer = writer
async def process(self, message: MessageInput) -> tuple[int, MessageOutput]:
async with asyncio.timeout(2):
category = await self._provider.classify(message.text)
output = MessageOutput(
text=message.text,
category=category,
priority=message.priority,
)
message_id = await asyncio.to_thread(
self._writer.add,
text=output.text,
category=output.category,
priority=output.priority,
)
return message_id, output
这里 SQLite repository 是同步实现,因此用 to_thread 避免在 async route 中直接阻塞。真实项目也可以选择 SQLAlchemy async engine;那是另一条完整技术路线,不应在 Capstone 临时切换。
API 组装:依赖有唯一创建位置
在 api.py 中创建 Engine、session factory、repository 和 service,并用 lifespan 初始化表。route 只调用 service 并映射 timeout:
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from sqlalchemy.orm import Session
from textlab.db import Base, MessageRepository, build_engine
from textlab.schemas import MessageInput, MessageOutput
from textlab.service import LocalProvider, TextService
engine = build_engine("sqlite+pysqlite:///./textlab.db")
repository = MessageRepository(lambda: Session(engine))
service = TextService(LocalProvider(), repository)
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
Base.metadata.create_all(engine)
yield
engine.dispose()
app = FastAPI(title="TextLab", version="1.0.0", lifespan=lifespan)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/messages", response_model=MessageOutput, status_code=201)
async def create_message(message: MessageInput) -> MessageOutput:
try:
_, output = await service.process(message)
return output
except TimeoutError as exc:
raise HTTPException(status_code=504, detail="processing timed out") from exc
这是完整的最终 api.py。它依赖第 15–17 课已经完整建立的 schemas.py、db.py、MessageRepository 和本课的 service.py;没有新的隐藏类型或省略实现。
可控的 service 测试
tests/test_service.py 使用 fake,不等待真实网络:
import asyncio
from textlab.schemas import MessageInput, Metadata
from textlab.service import TextService
class FakeProvider:
async def classify(self, text: str) -> str:
return "fake"
class FakeWriter:
def __init__(self) -> None:
self.rows: list[tuple[str, str, int]] = []
def add(self, *, text: str, category: str, priority: int) -> int:
self.rows.append((text, category, priority))
return len(self.rows)
def test_process() -> None:
writer = FakeWriter()
service = TextService(FakeProvider(), writer)
message = MessageInput(
text="hello",
priority=1,
metadata=Metadata(source="test"),
)
message_id, output = asyncio.run(service.process(message))
assert message_id == 1
assert output.category == "fake"
assert writer.rows == [("hello", "fake", 1)]
若你的 pytest 已配置 async plugin,也可写 async test;这里用 asyncio.run 保持依赖最少。
最终验收
从项目目录执行:
rm -rf .venv
uv sync --locked
./scripts/check.sh
uv run fastapi dev src/textlab/api.py
另一个终端请求 health 和 messages。预期:
- clean sync 成功;
- format、lint、type、tests 全部通过;
- 合法 message 返回 201;
- 缺失 metadata 或额外字段返回 422;
- provider 超时测试返回 504;
- repository 测试证明事务失败不会留下记录。
Windows 请使用等价删除命令和 PowerShell check script。
独立挑战:让两个 provider 共同工作
在不改 HTTP schema 的前提下,同时运行分类与摘要 provider。使用 TaskGroup,让任一失败时取消另一个;把 summary 加入内部持久化模型,若要加入公共响应则明确版本化 API。
验收标准与实现提示
在 TextService.process 中:
async with asyncio.timeout(2):
async with asyncio.TaskGroup() as group:
category_task = group.create_task(self._provider.classify(message.text))
summary_task = group.create_task(self._summarizer.summarize(message.text))
category = category_task.result()
summary = summary_task.result()
测试至少覆盖:两者成功;classify 失败时 summarize 的 finally cleanup 被调用;整体 timeout;数据库写入只在两个结果成功后发生。不要用真实 sleep 竞争,使用 asyncio.Event 控制 fake。
3.12 完成后如何看 3.14
先保持项目测试在 3.12 通过,再增加 3.14 CI 矩阵。重点观察延迟标注语义、free-threaded build 的第三方扩展兼容性、ProcessPoolExecutor 启动方式和新的 interpreter 能力。升级不是把版本号改掉,而是让同一验收集证明行为仍成立。