跳转至

aggregator 骨架源码(只读展示)

说明与验收见 aggregator README;实际编码请在 VS Code 里打开 projects/aggregator/

.env.example

projects/aggregator/.env.example
# 复制成 .env(.env 不要提交);所有变量都是 AGG_ 前缀 + Settings 里的字段名
AGG_BASE_URL=http://127.0.0.1:8001
AGG_COUNT=20
AGG_LIMIT=8
AGG_TIMEOUT=2.0
AGG_RETRIES=3
AGG_MAX_WORKERS=8
AGG_OUTPUT_FORMAT=table
AGG_LOG_LEVEL=INFO
AGG_STUB_PORT=8001
AGG_STUB_DELAY=0.05
AGG_STUB_FAIL_RATE=0.0

pyproject.toml

projects/aggregator/pyproject.toml
[project]
name = "aggregator"
version = "0.1.0"
description = "第 3 周小项目:并发抓取聚合器(asyncio + httpx + tenacity + 自带桩服务器)"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
    "httpx>=0.28",
    "tenacity>=9.0",
    "pydantic>=2.12",
    "pydantic-settings>=2.10",
    "typer>=0.16",
    "rich>=14.0",
]

[project.scripts]
aggregator = "aggregator.cli:app"

[build-system]
requires = ["uv_build>=0.9,<0.10"]
build-backend = "uv_build"

[tool.ruff.lint]
extend-select = ["N", "D", "SIM"]
# D400/D415 要求首行以英文句号结尾,中文 docstring 用"。",故忽略
ignore = ["D400", "D403", "D415"]
pydocstyle = { convention = "google" }
per-file-ignores = { "tests/*" = ["D"] }

src/aggregator/__init__.py

projects/aggregator/src/aggregator/__init__.py
1
2
3
"""aggregator —— 第 3 周小项目:并发抓取聚合器。"""

__version__ = "0.1.0"

src/aggregator/cli.py

projects/aggregator/src/aggregator/cli.py
"""命令行入口(typer):version / fetch / bench / serve-stub。

这一层已经写好,它只负责"读参数 → 调 engine/report → 打印"。
所以在 client.py / engine.py / report.py 写完之前,fetch 与 bench 会抛 NotImplementedError。
用法:
    uv run aggregator --help
    uv run aggregator serve-stub --port 8001 --delay 0.1 --fail-rate 0.2
    uv run aggregator fetch --count 50 --limit 8 --timeout 2 --output summary.json
    uv run aggregator bench --count 30
"""

import asyncio
import time
from collections.abc import Callable
from pathlib import Path
from typing import Annotated

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

from aggregator import __version__
from aggregator.client import make_async_client, make_sync_client
from aggregator.engine import (
    build_urls,
    fetch_all_async,
    fetch_all_sequential,
    fetch_all_threaded,
)
from aggregator.logging_config import configure_logging, get_logger
from aggregator.models import FetchResult, Summary
from aggregator.report import (
    results_table,
    summarize,
    summary_table,
    write_csv,
    write_json,
)
from aggregator.settings import Settings, get_settings
from aggregator.stub_server import serve

app = typer.Typer(
    help="aggregator:并发抓取聚合器(第 3 周小项目)", no_args_is_help=True
)
console = Console()
logger = get_logger(__name__)

CountOpt = Annotated[int | None, typer.Option("--count", "-n", help="抓多少条")]
LimitOpt = Annotated[int | None, typer.Option("--limit", "-l", help="最大并发数")]
TimeoutOpt = Annotated[float | None, typer.Option("--timeout", help="单条超时(秒)")]
RetriesOpt = Annotated[int | None, typer.Option("--retries", help="重试次数")]
BaseUrlOpt = Annotated[str | None, typer.Option("--base-url", help="桩服务器地址")]
OutputOpt = Annotated[
    Path | None, typer.Option("--output", "-o", help="导出到 .json 或 .csv")
]


@app.callback()
def main() -> None:
    """aggregator:并发抓取聚合器。"""


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


@app.command()
def fetch(
    count: CountOpt = None,
    limit: LimitOpt = None,
    timeout: TimeoutOpt = None,
    retries: RetriesOpt = None,
    base_url: BaseUrlOpt = None,
    output: OutputOpt = None,
) -> None:
    """并发抓一批 URL,打印表格与汇总,可选导出 JSON/CSV。"""
    settings = get_settings(
        count=count,
        limit=limit,
        timeout=timeout,
        retries=retries,
        base_url=base_url,
    )
    configure_logging(settings.log_level)
    urls = build_urls(settings.base_url, settings.count)
    started = time.perf_counter()
    try:
        results = asyncio.run(_fetch_async(settings, urls))
    except KeyboardInterrupt:
        console.print("[yellow]已取消(Ctrl+C)[/yellow]")
        raise typer.Exit(code=130) from None
    elapsed_ms = (time.perf_counter() - started) * 1000
    summary = summarize(results, elapsed_ms)
    console.print(results_table(results, limit=min(settings.count, 10)))
    console.print(summary_table(summary))
    if output is not None:
        _export(output, summary, results)
        console.print(f"已写入 {output}")


@app.command()
def bench(
    count: CountOpt = None,
    limit: LimitOpt = None,
    timeout: TimeoutOpt = None,
    retries: RetriesOpt = None,
    base_url: BaseUrlOpt = None,
) -> None:
    """对比三种实现(顺序 / 线程池 / asyncio)的耗时。"""
    settings = get_settings(
        count=count,
        limit=limit,
        timeout=timeout,
        retries=retries,
        base_url=base_url,
    )
    configure_logging(settings.log_level)
    urls = build_urls(settings.base_url, settings.count)
    rows: list[tuple[str, float, int]] = []
    with make_sync_client(settings) as client:
        rows.append(
            _timed(
                "sequential",
                lambda: fetch_all_sequential(client, urls, retries=settings.retries),
            )
        )
        rows.append(
            _timed(
                "threaded",
                lambda: fetch_all_threaded(
                    client,
                    urls,
                    max_workers=settings.max_workers,
                    retries=settings.retries,
                ),
            )
        )
    started = time.perf_counter()
    async_results = asyncio.run(_fetch_async(settings, urls))
    rows.append(
        (
            "async",
            (time.perf_counter() - started) * 1000,
            sum(1 for r in async_results if r.ok),
        )
    )
    baseline = next(ms for name, ms, _ in rows if name == "sequential")
    table = Table(title=f"bench:{len(urls)} 条,limit={settings.limit}")
    table.add_column("实现")
    table.add_column("耗时 ms", justify="right")
    table.add_column("成功", justify="right")
    table.add_column("相对顺序", justify="right")
    for name, ms, ok in rows:
        speedup = baseline / ms if ms else 0.0
        table.add_row(name, f"{ms:.0f}", str(ok), f"{speedup:.1f}×")
    console.print(table)


@app.command(name="serve-stub")
def serve_stub(
    port: Annotated[int, typer.Option("--port", "-p", help="端口,0 表示随机")] = 8001,
    delay: Annotated[float, typer.Option("--delay", help="每个请求的延迟(秒)")] = 0.1,
    fail_rate: Annotated[float, typer.Option("--fail-rate", help="失败率 0..1")] = 0.0,
) -> None:
    """起一个本地桩服务器(Ctrl+C 退出),演示与基准都打它。"""
    serve(port=port, delay=delay, fail_rate=fail_rate)


async def _fetch_async(settings: Settings, urls: list[str]) -> list[FetchResult]:
    """asyncio 版抓取:客户端建一次,用完关掉。"""
    async with make_async_client(settings) as client:
        return await fetch_all_async(
            client, urls, limit=settings.limit, retries=settings.retries
        )


def _timed(
    name: str, runner: Callable[[], list[FetchResult]]
) -> tuple[str, float, int]:
    """跑一种实现并计时,返回 (名字, 毫秒, 成功数)。"""
    started = time.perf_counter()
    results = runner()
    elapsed_ms = (time.perf_counter() - started) * 1000
    return name, elapsed_ms, sum(1 for r in results if r.ok)


def _export(output: Path, summary: Summary, results: list[FetchResult]) -> None:
    """按后缀导出:.json 走 write_json,.csv 走 write_csv。"""
    suffix = output.suffix.lower()
    if suffix == ".json":
        write_json(output, summary, results)
    elif suffix == ".csv":
        write_csv(output, results)
    else:
        raise typer.BadParameter(f"只支持 .json 或 .csv,收到 {output.suffix}")


if __name__ == "__main__":
    app()

src/aggregator/client.py

projects/aggregator/src/aggregator/client.py
"""HTTP 客户端层:建客户端、抓一条、错误分类、重试。(骨架)

测试见 tests/test_client.py(全部用 httpx.MockTransport,不打真网)。
铁律:客户端在外面建一次、到处传(不要每个请求 new 一个 Client);超时必须显式设。
"""

import time
from typing import Any

import httpx
import tenacity

from aggregator.models import FetchResult
from aggregator.settings import Settings

USER_AGENT = "aggregator/0.1 (learn-python week3)"


def _client_kwargs(settings: Settings, transport: object | None) -> dict[str, Any]:
    kwargs: dict[str, Any] = {
        "base_url": settings.base_url,
        "timeout": httpx.Timeout(settings.timeout),
        "headers": {"User-Agent": USER_AGENT},
        "limits": httpx.Limits(max_connections=max(settings.limit * 2, 2)),
    }
    if transport is not None:
        kwargs["transport"] = transport
    return kwargs


def make_async_client(
    settings: Settings, transport: httpx.AsyncBaseTransport | None = None
) -> httpx.AsyncClient:
    """建异步客户端:base_url、显式 Timeout(settings.timeout)、User-Agent 头。

    limits 建议 httpx.Limits(max_connections=settings.limit * 2);
    transport 不为 None 时透传(测试用 MockTransport)。
    例:make_async_client(Settings()).timeout.read -> 2.0
    """
    # TODO: 在这里实现
    raise NotImplementedError


def make_sync_client(
    settings: Settings, transport: httpx.BaseTransport | None = None
) -> httpx.Client:
    """建同步客户端(给 fetch_all_threaded / fetch_all_sequential 用),参数同上。"""
    # TODO: 在这里实现
    raise NotImplementedError


def classify_error(exc: Exception) -> str:
    """把异常翻译成短标签,进 FetchResult.error 与 Summary.errors 统计。

    httpx.TimeoutException -> "timeout";httpx.ConnectError -> "connect";
    其他 httpx.RequestError -> "network";
    httpx.HTTPStatusError -> f"http_{status_code}";其他 -> "unknown"。
    例:classify_error(httpx.ReadTimeout("x")) -> "timeout"
    """
    # TODO: 在这里实现
    raise NotImplementedError


def _should_retry(exc: BaseException) -> bool:
    """只重试 5xx 与网络层错误;4xx 是客户端的问题,重试没有意义。"""
    if isinstance(exc, httpx.HTTPStatusError):
        return exc.response.status_code >= 500
    return isinstance(exc, httpx.RequestError)


def _retry_policy(retries: int) -> dict[str, Any]:
    return {
        "stop": tenacity.stop_after_attempt(max(retries, 1)),
        "wait": tenacity.wait_exponential(multiplier=0.05),
        "retry": tenacity.retry_if_exception(_should_retry),
        "reraise": True,
    }


def _success(url: str, response: httpx.Response, started: float) -> FetchResult:
    return FetchResult(
        url=url,
        status=response.status_code,
        elapsed_ms=(time.perf_counter() - started) * 1000,
        data=response.json(),
    )


def _failure(url: str, exc: Exception, started: float) -> FetchResult:
    response = getattr(exc, "response", None)
    return FetchResult(
        url=url,
        status=response.status_code if response is not None else None,
        elapsed_ms=(time.perf_counter() - started) * 1000,
        error=classify_error(exc),
    )


async def fetch_one(
    client: httpx.AsyncClient, url: str, retries: int = 3
) -> FetchResult:
    """抓一条 URL,永不抛异常:失败就把错误写进 FetchResult.error。

    要求:
    - 计时用 time.perf_counter(),elapsed_ms 填毫秒;
    - raise_for_status() 后 .json();
    - 用 tenacity 重试:retries 是**总尝试次数**(stop_after_attempt(retries)),
      指数退避 wait_exponential(multiplier=0.05),
      只重试 5xx 与网络错误,4xx 不重试(客户端错误重试没意义);
    - 最终失败时 error=classify_error(exc),status 有响应就填。
    例:fetch_one(client, "http://x/items/1") -> FetchResult(status=200, data={...})
    """
    # TODO: 在这里实现
    raise NotImplementedError


def fetch_one_sync(client: httpx.Client, url: str, retries: int = 3) -> FetchResult:
    """fetch_one 的同步版(线程池/顺序两种实现都用它),行为完全一致。"""
    # TODO: 在这里实现
    raise NotImplementedError

src/aggregator/engine.py

projects/aggregator/src/aggregator/engine.py
"""三种抓取实现:asyncio(TaskGroup + Semaphore)、线程池、顺序。(骨架)

测试见 tests/test_engine.py 与 tests/test_integration.py。
目标:`aggregator bench --count 30` 里 async 与 threaded 都比 sequential 快 ≥5 倍。
"""

from collections.abc import Sequence

import httpx

from aggregator.models import FetchResult


def build_urls(base_url: str, count: int) -> list[str]:
    """生成 count 个待抓 URL:{base_url}/items/1 ... /items/{count}。

    count <= 0 抛 ValueError("count 必须为正");base_url 末尾多余的 / 要去掉。
    例:build_urls("http://x/", 2) -> ["http://x/items/1", "http://x/items/2"]
    """
    # TODO: 在这里实现
    raise NotImplementedError


async def fetch_all_async(
    client: httpx.AsyncClient,
    urls: Sequence[str],
    limit: int = 8,
    retries: int = 3,
) -> list[FetchResult]:
    """asyncio 版:TaskGroup 并发 + Semaphore(limit) 限流,结果顺序与 urls 一致。

    fetch_one 不抛异常,所以这里不会因为某一条失败而整体崩掉。
    limit <= 0 抛 ValueError("limit 必须为正")。
    例:await fetch_all_async(client, urls, limit=8) -> [FetchResult, ...]
    """
    # TODO: 在这里实现
    raise NotImplementedError


def fetch_all_threaded(
    client: httpx.Client,
    urls: Sequence[str],
    max_workers: int = 8,
    retries: int = 3,
) -> list[FetchResult]:
    """线程池版:ThreadPoolExecutor 并发调 fetch_one_sync,结果顺序与 urls 一致。

    提示:executor.map 天然保序;共用一个 httpx.Client 是线程安全的。
    例:fetch_all_threaded(client, urls, max_workers=8) -> [FetchResult, ...]
    """
    # TODO: 在这里实现
    raise NotImplementedError


def fetch_all_sequential(
    client: httpx.Client, urls: Sequence[str], retries: int = 3
) -> list[FetchResult]:
    """顺序版:for 循环一条条抓,用来当基准线(bench 里对比的"慢参照")。"""
    # TODO: 在这里实现
    raise NotImplementedError

src/aggregator/logging_config.py

projects/aggregator/src/aggregator/logging_config.py
"""日志配置:rich 彩色输出 + 一行搞定的 configure_logging(已写好)。

用法:在 CLI 入口调一次 configure_logging(settings.log_level),
      其他模块只写 logger = logging.getLogger(__name__),不要各自配 handler。
"""

import logging

from rich.logging import RichHandler


def configure_logging(level: str = "INFO") -> None:
    """配置根 logger:rich 彩色 handler,只配一次(重复调用会先清掉旧 handler)。

    例:configure_logging("DEBUG") 之后 logging.getLogger("x").debug("hi") 会显示
    """
    root = logging.getLogger()
    for handler in list(root.handlers):
        root.removeHandler(handler)
    handler = RichHandler(rich_tracebacks=True, show_path=False, markup=False)
    handler.setFormatter(logging.Formatter("%(message)s", datefmt="%H:%M:%S"))
    root.addHandler(handler)
    root.setLevel(level.upper())


def get_logger(name: str) -> logging.Logger:
    """拿一个模块级 logger(就是 logging.getLogger 的别名,写起来短点)。"""
    return logging.getLogger(name)

src/aggregator/models.py

projects/aggregator/src/aggregator/models.py
"""数据模型(Pydantic):一次抓取的结果 FetchResult 与一批抓取的汇总 Summary。

这个文件已经写好,直接用;要实现的是 client.py / engine.py / report.py。
"""

from typing import Any

from pydantic import BaseModel, Field


class FetchResult(BaseModel):
    """一次抓取的结果:成功填 status/data,失败填 error。"""

    url: str
    status: int | None = None
    elapsed_ms: float = 0.0
    data: dict[str, Any] | None = None
    error: str | None = None

    @property
    def ok(self) -> bool:
        """是否算成功:没有 error 且状态码是 2xx。"""
        return self.error is None and self.status is not None and self.status < 300

    def row(self) -> dict[str, str]:
        """摊平成一行字符串,给终端表格和 CSV 用。"""
        return {
            "url": self.url,
            "status": str(self.status) if self.status is not None else "-",
            "elapsed_ms": f"{self.elapsed_ms:.1f}",
            "error": self.error or "",
        }


class Summary(BaseModel):
    """一批抓取的汇总统计。"""

    total: int = 0
    ok: int = 0
    failed: int = 0
    success_rate: float = 0.0
    p50_ms: float = 0.0
    p95_ms: float = 0.0
    total_elapsed_ms: float = 0.0
    errors: dict[str, int] = Field(default_factory=dict)

src/aggregator/report.py

projects/aggregator/src/aggregator/report.py
"""汇总与输出:统计(成功率、p50/p95)、rich 表格、JSON/CSV 原子写。(骨架)

原子写的意思:先写同目录的临时文件,再 os.replace(临时, 目标)——
中途断电/报错也不会留下半个文件。
"""

import os
from collections.abc import Sequence
from pathlib import Path

from rich.table import Table

from aggregator.models import FetchResult, Summary


def percentile(values: Sequence[float], pct: float) -> float:
    """取百分位(最近秩法:向上取整索引),空序列返回 0.0。

    pct 不在 0..100 抛 ValueError("pct 必须在 0..100 之间")。
    例:percentile([1, 2, 3, 4], 50) -> 2.0;percentile([], 95) -> 0.0
    """
    # TODO: 在这里实现
    raise NotImplementedError


def summarize(results: Sequence[FetchResult], total_elapsed_ms: float = 0.0) -> Summary:
    """把一批结果汇总成 Summary:成功数、失败数、成功率、p50/p95、错误分类计数。

    success_rate 用 0..1 的小数(空结果为 0.0);
    errors 是 {错误标签: 次数},只统计失败的那些。
    例:3 成功 1 失败 -> Summary(total=4, ok=3, success_rate=0.75, ...)
    """
    # TODO: 在这里实现
    raise NotImplementedError


def results_table(results: Sequence[FetchResult], limit: int = 10) -> Table:
    """做一张 rich 表格:列 url / status / elapsed_ms / error,最多显示 limit 行。

    例:results_table(results, limit=5).row_count -> 5
    """
    # TODO: 在这里实现
    raise NotImplementedError


def summary_table(summary: Summary) -> Table:
    """做一张两列(指标 / 值)的 rich 表格,展示 Summary 的所有字段。"""
    # TODO: 在这里实现
    raise NotImplementedError


def _atomic_write_text(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(text, encoding="utf-8", newline="")
    os.replace(tmp, path)


def write_json(path: Path, summary: Summary, results: Sequence[FetchResult]) -> None:
    """原子写 JSON:{"summary": {...}, "results": [...]}。

    提示:summary.model_dump()、[r.model_dump() for r in results];
    先写 path.with_suffix(path.suffix + ".tmp"),再 os.replace 换上去。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def write_csv(path: Path, results: Sequence[FetchResult]) -> None:
    """原子写 CSV:表头 url,status,elapsed_ms,error(用 FetchResult.row())。

    注意 newline="",否则 Windows 上每行之间会多一个空行。
    """
    # TODO: 在这里实现
    raise NotImplementedError

src/aggregator/settings.py

projects/aggregator/src/aggregator/settings.py
"""配置(pydantic-settings):环境变量前缀 AGG_,也读 .env。

这个文件已经写好。优先级:命令行参数 > 环境变量 / .env > 这里的默认值。
例:AGG_LIMIT=16 uv run aggregator fetch  等价于  --limit 16
"""

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    """聚合器的全部可调参数。"""

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

    base_url: str = "http://127.0.0.1:8001"
    count: int = 20
    limit: int = 8
    timeout: float = 2.0
    retries: int = 3
    max_workers: int = 8
    output_format: str = "table"
    log_level: str = "INFO"
    stub_port: int = 8001
    stub_delay: float = 0.05
    stub_fail_rate: float = 0.0


def get_settings(**overrides: object) -> Settings:
    """读一份配置;overrides 里非 None 的值覆盖环境变量与默认值。

    例:get_settings(limit=4).limit -> 4
    """
    clean = {key: value for key, value in overrides.items() if value is not None}
    return Settings(**clean)  # type: ignore[arg-type]

src/aggregator/stub_server.py

projects/aggregator/src/aggregator/stub_server.py
"""桩服务器:用 asyncio 写的极简 HTTP 假 API,让演示 / 测试 / 基准全部打本地。

已经写好,直接用。路由:
    GET /items/{n}  -> 200 {"id": n, "name": "item-n", ...},可配置延迟与失败率
    GET /health     -> 200 {"status": "ok"}
    其他            -> 404 {"detail": "not found"}
命令行:uv run aggregator serve-stub --port 8001 --delay 0.1 --fail-rate 0.2
测试里:server, thread = start_in_thread(port=0, delay=0.0)  # port=0 让系统挑空闲端口

为什么不用标准库 http.server:ThreadingHTTPServer 面对几十个同时到达的连接时,
接受连接的速度成了瓶颈,会让"asyncio 并发"看起来比线程池慢好几倍——基准就失真了。
asyncio 版服务器每个连接只是一个协程,几百个并发也不在话下。
"""

import asyncio
import contextlib
import json
import random
import threading
import time
from dataclasses import dataclass
from typing import Any

_MIN_DELAY_GRANULARITY = 0.0


def _route(path: str, fail_rate: float, delay: float) -> tuple[int, dict[str, Any]]:
    """纯函数:路径 -> (状态码, JSON)。便于单测,也不依赖网络。"""
    path = path.split("?", 1)[0]
    if path == "/health":
        return 200, {"status": "ok"}
    parts = path.strip("/").split("/")
    if len(parts) == 2 and parts[0] == "items" and parts[1].isdigit():
        if random.random() < fail_rate:
            return 500, {"detail": "stub 故意失败了"}
        item_id = int(parts[1])
        return 200, {
            "id": item_id,
            "name": f"item-{item_id}",
            "score": item_id * 3 % 100,
            "delay": delay,
        }
    return 404, {"detail": "not found"}


def _http_response(status: int, payload: dict[str, Any]) -> bytes:
    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    reason = {200: "OK", 404: "Not Found", 500: "Internal Server Error"}.get(
        status, "OK"
    )
    head = (
        f"HTTP/1.1 {status} {reason}\r\n"
        "Content-Type: application/json; charset=utf-8\r\n"
        f"Content-Length: {len(body)}\r\n"
        "Connection: close\r\n\r\n"
    )
    return head.encode("ascii") + body


@dataclass
class StubServer:
    """运行中的桩服务器句柄:server_address / shutdown() / server_close()。"""

    host: str
    port: int
    _loop: asyncio.AbstractEventLoop
    _server: asyncio.AbstractServer
    _stopped: threading.Event

    @property
    def server_address(self) -> tuple[str, int]:
        """(host, port),与 http.server 的属性同名,方便互换。"""
        return (self.host, self.port)

    def shutdown(self) -> None:
        """请求事件循环停止(线程安全),并等待它真的停下来。"""
        self._loop.call_soon_threadsafe(self._server.close)
        self._loop.call_soon_threadsafe(self._loop.stop)
        self._stopped.wait(timeout=5)

    def server_close(self) -> None:
        """与 http.server 接口对齐;资源已在 shutdown 时释放。"""


async def _serve_forever(
    host: str,
    port: int,
    delay: float,
    fail_rate: float,
    ready: asyncio.Future[StubServer] | None = None,
    stopped: threading.Event | None = None,
) -> None:
    async def handle(
        reader: asyncio.StreamReader, writer: asyncio.StreamWriter
    ) -> None:
        try:
            request_line = await asyncio.wait_for(reader.readline(), timeout=5)
            while (await asyncio.wait_for(reader.readline(), timeout=5)) not in (
                b"\r\n",
                b"",
            ):
                pass
            parts = request_line.decode("latin-1").split()
            path = parts[1] if len(parts) >= 2 else "/"
            if delay:
                await asyncio.sleep(delay)
            status, payload = _route(path, fail_rate, delay)
            writer.write(_http_response(status, payload))
            await writer.drain()
        except TimeoutError, ConnectionError:
            pass
        finally:
            writer.close()

    server = await asyncio.start_server(handle, host, port, backlog=256)
    bound_port = server.sockets[0].getsockname()[1]
    loop = asyncio.get_running_loop()
    handle_obj = StubServer(
        host, bound_port, loop, server, stopped or threading.Event()
    )
    if ready is not None:
        ready.set_result(handle_obj)
    async with server:
        with contextlib.suppress(asyncio.CancelledError):
            await server.serve_forever()


def start_in_thread(
    port: int = 0, delay: float = 0.0, fail_rate: float = 0.0
) -> tuple[StubServer, threading.Thread]:
    """在后台线程里跑一个桩服务器,返回 (server, thread)。

    用完记得 server.shutdown(); server.server_close(); thread.join()。
    例:
        server, thread = start_in_thread(port=0)
        base_url = f"http://127.0.0.1:{server.server_address[1]}"
    """
    ready: dict[str, StubServer] = {}
    started = threading.Event()
    stopped = threading.Event()

    def run() -> None:
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        future: asyncio.Future[StubServer] = loop.create_future()

        def on_ready(fut: asyncio.Future[StubServer]) -> None:
            ready["server"] = fut.result()
            started.set()

        future.add_done_callback(on_ready)
        task = loop.create_task(
            _serve_forever("127.0.0.1", port, delay, fail_rate, future, stopped)
        )
        try:
            loop.run_forever()
        finally:
            task.cancel()
            loop.run_until_complete(asyncio.gather(task, return_exceptions=True))
            loop.close()
            stopped.set()

    thread = threading.Thread(target=run, name="stub-server", daemon=True)
    thread.start()
    if not started.wait(timeout=5):
        raise RuntimeError("桩服务器 5 秒内没有启动")
    return ready["server"], thread


def serve(port: int = 8001, delay: float = 0.05, fail_rate: float = 0.0) -> None:
    """前台运行(给 `aggregator serve-stub` 用),Ctrl+C 退出。"""
    print(
        f"桩服务器 http://127.0.0.1:{port}  delay={delay}s  fail_rate={fail_rate}  Ctrl+C 退出"
    )
    started = time.perf_counter()
    try:
        asyncio.run(_serve_forever("127.0.0.1", port, delay, fail_rate))
    except KeyboardInterrupt:
        print(f"\n已停止,运行了 {time.perf_counter() - started:.0f} 秒")

tests/conftest.py

projects/aggregator/tests/conftest.py
"""测试共用 fixture:会话级桩服务器 + 常用 MockTransport 客户端。

用法:uv run pytest projects/aggregator -q
说明:桩服务器用 port=0 起在一个空闲端口,整个会话只起一次,结束时干净关闭。
"""

from collections.abc import Callable, Iterator

import httpx
import pytest

from aggregator.settings import Settings
from aggregator.stub_server import start_in_thread

Handler = Callable[[httpx.Request], httpx.Response]


@pytest.fixture(scope="session")
def stub_base_url() -> Iterator[str]:
    """会话级:起一个本地桩服务器,产出它的 base_url,会话结束后关掉。"""
    server, thread = start_in_thread(port=0, delay=0.05, fail_rate=0.0)
    port = server.server_address[1]
    try:
        yield f"http://127.0.0.1:{port}"
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=5)


@pytest.fixture
def settings(stub_base_url: str) -> Settings:
    """指向桩服务器的一份小配置(并发小、超时短,测试跑得快)。"""
    return Settings(base_url=stub_base_url, count=6, limit=4, timeout=5.0, retries=2)


@pytest.fixture
def offline_settings() -> Settings:
    """不需要真服务器时用的配置(配合 MockTransport)。"""
    return Settings(
        base_url="http://stub.local", count=4, limit=2, timeout=1.0, retries=2
    )


def json_handler(request: httpx.Request) -> httpx.Response:
    """默认假服务器:/items/{n} 返回 JSON,其它 404。"""
    path = request.url.path
    if path.startswith("/items/"):
        item_id = int(path.rsplit("/", 1)[-1])
        return httpx.Response(200, json={"id": item_id, "name": f"item-{item_id}"})
    return httpx.Response(404, json={"detail": "not found"})

tests/test_aggregator_cli.py

projects/aggregator/tests/test_aggregator_cli.py
"""CLI 测试:用 typer 的 CliRunner 直接调用命令,打本地桩服务器。"""

import json
from pathlib import Path

from typer.testing import CliRunner

from aggregator.cli import app

runner = CliRunner()


def test_help_and_version():
    result = runner.invoke(app, ["--help"])
    assert result.exit_code == 0
    for cmd in ("fetch", "bench", "serve-stub", "version"):
        assert cmd in result.output
    assert runner.invoke(app, ["version"]).exit_code == 0


def test_fetch_prints_tables_and_exports_json(stub_base_url: str, tmp_path: Path):
    out = tmp_path / "summary.json"
    result = runner.invoke(
        app,
        [
            "fetch",
            "--count",
            "4",
            "--limit",
            "2",
            "--base-url",
            stub_base_url,
            "-o",
            str(out),
        ],
    )
    assert result.exit_code == 0, result.output
    assert "汇总" in result.output
    data = json.loads(out.read_text(encoding="utf-8"))
    assert data["summary"]["total"] == 4 and data["summary"]["ok"] == 4


def test_fetch_exports_csv(stub_base_url: str, tmp_path: Path):
    out = tmp_path / "results.csv"
    result = runner.invoke(
        app, ["fetch", "-n", "2", "--base-url", stub_base_url, "-o", str(out)]
    )
    assert result.exit_code == 0, result.output
    assert (
        out.read_text(encoding="utf-8").splitlines()[0] == "url,status,elapsed_ms,error"
    )


def test_fetch_rejects_unknown_output_suffix(stub_base_url: str, tmp_path: Path):
    result = runner.invoke(
        app,
        [
            "fetch",
            "-n",
            "1",
            "--base-url",
            stub_base_url,
            "-o",
            str(tmp_path / "x.txt"),
        ],
    )
    assert result.exit_code != 0


def test_bench_compares_three_implementations(stub_base_url: str):
    result = runner.invoke(
        app, ["bench", "--count", "6", "--limit", "6", "--base-url", stub_base_url]
    )
    assert result.exit_code == 0, result.output
    for name in ("sequential", "threaded", "async"):
        assert name in result.output

tests/test_client.py

projects/aggregator/tests/test_client.py
"""client.py 的单元测试:全部用 httpx.MockTransport,不打真网。"""

import httpx
import pytest
from conftest import json_handler

from aggregator.client import (
    classify_error,
    fetch_one,
    fetch_one_sync,
    make_async_client,
    make_sync_client,
)
from aggregator.settings import Settings

URL = "http://stub.local/items/1"


def test_make_async_client_sets_base_url_timeout_headers(
    offline_settings: Settings,
) -> None:
    client = make_async_client(offline_settings)
    assert str(client.base_url) == offline_settings.base_url
    assert client.timeout.read == offline_settings.timeout
    assert "aggregator" in client.headers["user-agent"]


def test_make_sync_client_sets_base_url_timeout_headers(
    offline_settings: Settings,
) -> None:
    with make_sync_client(offline_settings) as client:
        assert str(client.base_url) == offline_settings.base_url
        assert client.timeout.connect == offline_settings.timeout
        assert "aggregator" in client.headers["user-agent"]


@pytest.mark.parametrize(
    ("exc", "expected"),
    [
        (httpx.ReadTimeout("慢"), "timeout"),
        (httpx.ConnectTimeout("慢"), "timeout"),
        (httpx.ConnectError("连不上"), "connect"),
        (httpx.RequestError("其它"), "network"),
        (
            httpx.HTTPStatusError(
                "404", request=httpx.Request("GET", URL), response=httpx.Response(404)
            ),
            "http_404",
        ),
        (
            httpx.HTTPStatusError(
                "503", request=httpx.Request("GET", URL), response=httpx.Response(503)
            ),
            "http_503",
        ),
        (ValueError("别的"), "unknown"),
    ],
    ids=["读超时", "连接超时", "连接失败", "网络", "404", "503", "其他"],
)
def test_classify_error(exc: Exception, expected: str) -> None:
    assert classify_error(exc) == expected


async def test_fetch_one_success(offline_settings: Settings) -> None:
    client = make_async_client(
        offline_settings, transport=httpx.MockTransport(json_handler)
    )
    async with client:
        result = await fetch_one(client, URL, retries=2)
    assert result.ok
    assert result.status == 200
    assert result.data == {"id": 1, "name": "item-1"}
    assert result.error is None
    assert result.elapsed_ms >= 0


async def test_fetch_one_records_404_without_raising(
    offline_settings: Settings,
) -> None:
    client = make_async_client(
        offline_settings, transport=httpx.MockTransport(json_handler)
    )
    async with client:
        result = await fetch_one(client, "http://stub.local/nope", retries=2)
    assert not result.ok
    assert result.error == "http_404"
    assert result.status == 404


async def test_fetch_one_retries_5xx_then_succeeds(offline_settings: Settings) -> None:
    calls = 0

    def flaky(request: httpx.Request) -> httpx.Response:
        nonlocal calls
        calls += 1
        if calls < 3:
            return httpx.Response(503, json={"detail": "忙"})
        return httpx.Response(200, json={"id": 1})

    client = make_async_client(offline_settings, transport=httpx.MockTransport(flaky))
    async with client:
        result = await fetch_one(client, URL, retries=3)
    assert result.ok
    assert calls == 3


async def test_fetch_one_gives_up_after_retries(offline_settings: Settings) -> None:
    calls = 0

    def always_503(request: httpx.Request) -> httpx.Response:
        nonlocal calls
        calls += 1
        return httpx.Response(503, json={"detail": "一直忙"})

    client = make_async_client(
        offline_settings, transport=httpx.MockTransport(always_503)
    )
    async with client:
        result = await fetch_one(client, URL, retries=3)
    assert not result.ok
    assert result.error == "http_503"
    assert calls == 3


async def test_fetch_one_does_not_retry_404(offline_settings: Settings) -> None:
    calls = 0

    def not_found(request: httpx.Request) -> httpx.Response:
        nonlocal calls
        calls += 1
        return httpx.Response(404, json={"detail": "没有"})

    client = make_async_client(
        offline_settings, transport=httpx.MockTransport(not_found)
    )
    async with client:
        result = await fetch_one(client, URL, retries=3)
    assert result.error == "http_404"
    assert calls == 1


async def test_fetch_one_classifies_timeout(offline_settings: Settings) -> None:
    def timeout_handler(request: httpx.Request) -> httpx.Response:
        raise httpx.ReadTimeout("读超时", request=request)

    client = make_async_client(
        offline_settings, transport=httpx.MockTransport(timeout_handler)
    )
    async with client:
        result = await fetch_one(client, URL, retries=2)
    assert result.error == "timeout"
    assert result.status is None


def test_fetch_one_sync_success(offline_settings: Settings) -> None:
    with make_sync_client(
        offline_settings, transport=httpx.MockTransport(json_handler)
    ) as client:
        result = fetch_one_sync(client, "http://stub.local/items/7", retries=2)
    assert result.ok
    assert result.data == {"id": 7, "name": "item-7"}


def test_fetch_one_sync_records_error(offline_settings: Settings) -> None:
    with make_sync_client(
        offline_settings, transport=httpx.MockTransport(json_handler)
    ) as client:
        result = fetch_one_sync(client, "http://stub.local/nope", retries=2)
    assert result.error == "http_404"

tests/test_engine.py

projects/aggregator/tests/test_engine.py
"""engine.py 的单元测试:三种实现都用 MockTransport,不打真网。"""

import asyncio

import httpx
import pytest
from conftest import json_handler

from aggregator.client import make_async_client, make_sync_client
from aggregator.engine import (
    build_urls,
    fetch_all_async,
    fetch_all_sequential,
    fetch_all_threaded,
)
from aggregator.settings import Settings

BASE = "http://stub.local"


def test_build_urls() -> None:
    assert build_urls(BASE, 2) == [f"{BASE}/items/1", f"{BASE}/items/2"]


def test_build_urls_strips_trailing_slash() -> None:
    assert build_urls(BASE + "/", 1) == [f"{BASE}/items/1"]


@pytest.mark.parametrize("count", [0, -3], ids=["零", "负数"])
def test_build_urls_rejects_bad_count(count: int) -> None:
    with pytest.raises(ValueError, match="count 必须为正"):
        build_urls(BASE, count)


async def test_fetch_all_async_keeps_url_order(offline_settings: Settings) -> None:
    urls = build_urls(BASE, 5)
    client = make_async_client(
        offline_settings, transport=httpx.MockTransport(json_handler)
    )
    async with client:
        results = await fetch_all_async(client, urls, limit=2, retries=2)
    assert [r.url for r in results] == urls
    assert all(r.ok for r in results)


async def test_fetch_all_async_rejects_bad_limit(offline_settings: Settings) -> None:
    client = make_async_client(
        offline_settings, transport=httpx.MockTransport(json_handler)
    )
    async with client:
        with pytest.raises(ValueError, match="limit 必须为正"):
            await fetch_all_async(client, build_urls(BASE, 2), limit=0)


async def test_fetch_all_async_respects_limit(offline_settings: Settings) -> None:
    running = 0
    peak = 0

    async def slow(request: httpx.Request) -> httpx.Response:
        nonlocal running, peak
        running += 1
        peak = max(peak, running)
        await asyncio.sleep(0.02)
        running -= 1
        return httpx.Response(200, json={"id": 1})

    client = make_async_client(offline_settings, transport=httpx.MockTransport(slow))
    async with client:
        await fetch_all_async(client, build_urls(BASE, 8), limit=3, retries=1)
    assert peak <= 3


async def test_fetch_all_async_survives_partial_failure(
    offline_settings: Settings,
) -> None:
    def half_broken(request: httpx.Request) -> httpx.Response:
        item_id = int(request.url.path.rsplit("/", 1)[-1])
        if item_id % 2 == 0:
            return httpx.Response(404, json={"detail": "没有"})
        return httpx.Response(200, json={"id": item_id})

    client = make_async_client(
        offline_settings, transport=httpx.MockTransport(half_broken)
    )
    async with client:
        results = await fetch_all_async(client, build_urls(BASE, 4), limit=4, retries=1)
    assert [r.ok for r in results] == [True, False, True, False]


def test_fetch_all_threaded_keeps_url_order(offline_settings: Settings) -> None:
    urls = build_urls(BASE, 5)
    with make_sync_client(
        offline_settings, transport=httpx.MockTransport(json_handler)
    ) as client:
        results = fetch_all_threaded(client, urls, max_workers=4, retries=2)
    assert [r.url for r in results] == urls
    assert all(r.ok for r in results)


def test_fetch_all_sequential_keeps_url_order(offline_settings: Settings) -> None:
    urls = build_urls(BASE, 3)
    with make_sync_client(
        offline_settings, transport=httpx.MockTransport(json_handler)
    ) as client:
        results = fetch_all_sequential(client, urls, retries=2)
    assert [r.url for r in results] == urls
    assert all(r.ok for r in results)


def test_three_implementations_agree(offline_settings: Settings) -> None:
    urls = build_urls(BASE, 4)
    transport = httpx.MockTransport(json_handler)
    with make_sync_client(offline_settings, transport=transport) as client:
        seq = fetch_all_sequential(client, urls, retries=1)
        thr = fetch_all_threaded(client, urls, max_workers=4, retries=1)
    aclient = make_async_client(
        offline_settings, transport=httpx.MockTransport(json_handler)
    )

    async def run() -> list[object]:
        async with aclient:
            return [r.data for r in await fetch_all_async(aclient, urls, limit=4)]

    assert [r.data for r in seq] == [r.data for r in thr] == asyncio.run(run())

tests/test_integration.py

projects/aggregator/tests/test_integration.py
"""集成测试:打本地桩服务器,走完整链路(engine → client → report)。

桩服务器由 conftest.py 的会话级 fixture 起在空闲端口;delay=0.05,所以并发一定比顺序快。
"""

import asyncio
import json
import time
from pathlib import Path

from aggregator.client import make_async_client, make_sync_client
from aggregator.engine import (
    build_urls,
    fetch_all_async,
    fetch_all_sequential,
    fetch_all_threaded,
)
from aggregator.report import (
    results_table,
    summarize,
    summary_table,
    write_csv,
    write_json,
)
from aggregator.settings import Settings


def test_sequential_all_ok(settings: Settings):
    urls = build_urls(settings.base_url, settings.count)
    with make_sync_client(settings) as client:
        results = fetch_all_sequential(client, urls, retries=settings.retries)
    assert [r.url for r in results] == urls
    assert all(r.ok and r.data["id"] == i for i, r in enumerate(results, start=1))


def test_threaded_and_async_are_faster_than_sequential(settings: Settings):
    urls = build_urls(settings.base_url, 12)
    with make_sync_client(settings) as client:
        t0 = time.perf_counter()
        fetch_all_sequential(client, urls, retries=1)
        sequential = time.perf_counter() - t0
        t1 = time.perf_counter()
        threaded = fetch_all_threaded(client, urls, max_workers=12, retries=1)
        threaded_time = time.perf_counter() - t1

    async def run() -> tuple[list, float]:
        async with make_async_client(settings) as aclient:
            t2 = time.perf_counter()
            res = await fetch_all_async(aclient, urls, limit=12, retries=1)
            return res, time.perf_counter() - t2

    async_results, async_time = asyncio.run(run())
    assert all(r.ok for r in threaded) and all(r.ok for r in async_results)
    assert threaded_time < sequential / 2
    # 本机(Windows)上 httpx 异步栈的每请求开销偏大,async 通常只有 2× 左右;Linux 上接近线程池
    assert async_time < sequential * 0.7


def test_404_is_recorded_not_raised(settings: Settings):
    with make_sync_client(settings) as client:
        results = fetch_all_sequential(
            client, [f"{settings.base_url}/missing"], retries=1
        )
    (result,) = results
    assert not result.ok and result.status == 404 and result.error == "http_404"


def test_report_pipeline(settings: Settings, tmp_path: Path):
    urls = build_urls(settings.base_url, 4) + [f"{settings.base_url}/missing"]
    with make_sync_client(settings) as client:
        results = fetch_all_threaded(client, urls, max_workers=4, retries=1)
    summary = summarize(results, total_elapsed_ms=123.0)
    assert (summary.total, summary.ok, summary.failed) == (5, 4, 1)
    assert summary.success_rate == 0.8 and summary.errors == {"http_404": 1}
    assert results_table(results, limit=3).row_count == 3
    assert summary_table(summary).row_count == len(summary.model_dump())
    write_json(tmp_path / "out" / "summary.json", summary, results)
    write_csv(tmp_path / "out" / "results.csv", results)
    data = json.loads((tmp_path / "out" / "summary.json").read_text(encoding="utf-8"))
    assert data["summary"]["ok"] == 4 and len(data["results"]) == 5
    csv_lines = (
        (tmp_path / "out" / "results.csv").read_text(encoding="utf-8").splitlines()
    )
    assert csv_lines[0] == "url,status,elapsed_ms,error" and len(csv_lines) == 6
    assert not list((tmp_path / "out").glob("*.tmp"))

tests/test_stub_server.py

projects/aggregator/tests/test_stub_server.py
"""桩服务器自己的测试:真的起一个本地 HTTP 服务器,只打 127.0.0.1。"""

import time

import httpx

from aggregator.stub_server import start_in_thread


def test_health(stub_base_url: str) -> None:
    response = httpx.get(f"{stub_base_url}/health", timeout=5)
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}


def test_items_returns_json(stub_base_url: str) -> None:
    response = httpx.get(f"{stub_base_url}/items/3", timeout=5)
    assert response.status_code == 200
    body = response.json()
    assert body["id"] == 3
    assert body["name"] == "item-3"


def test_unknown_path_returns_404(stub_base_url: str) -> None:
    response = httpx.get(f"{stub_base_url}/nope", timeout=5)
    assert response.status_code == 404
    assert response.json() == {"detail": "not found"}


def test_non_numeric_item_returns_404(stub_base_url: str) -> None:
    response = httpx.get(f"{stub_base_url}/items/abc", timeout=5)
    assert response.status_code == 404


def test_fail_rate_one_always_fails() -> None:
    server, thread = start_in_thread(port=0, delay=0.0, fail_rate=1.0)
    base_url = f"http://127.0.0.1:{server.server_address[1]}"
    try:
        statuses = [
            httpx.get(f"{base_url}/items/{i}", timeout=5).status_code for i in range(3)
        ]
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=5)
    assert statuses == [500, 500, 500]


def test_delay_is_applied() -> None:
    server, thread = start_in_thread(port=0, delay=0.2, fail_rate=0.0)
    base_url = f"http://127.0.0.1:{server.server_address[1]}"
    try:
        started = time.perf_counter()
        response = httpx.get(f"{base_url}/items/1", timeout=5)
        elapsed = time.perf_counter() - started
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=5)
    assert response.status_code == 200
    assert elapsed >= 0.2