跳转至

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

做完再看

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

点击展开:aggregator_solution/src/aggregator/client.py
exercises/solutions/week3/aggregator_solution/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
    """
    return httpx.AsyncClient(**_client_kwargs(settings, transport))


def make_sync_client(
    settings: Settings, transport: httpx.BaseTransport | None = None
) -> httpx.Client:
    """建同步客户端(给 fetch_all_threaded / fetch_all_sequential 用),参数同上。"""
    return httpx.Client(**_client_kwargs(settings, transport))


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"
    """
    if isinstance(exc, httpx.TimeoutException):
        return "timeout"
    if isinstance(exc, httpx.ConnectError):
        return "connect"
    if isinstance(exc, httpx.RequestError):
        return "network"
    if isinstance(exc, httpx.HTTPStatusError):
        return f"http_{exc.response.status_code}"
    return "unknown"


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={...})
    """
    started = time.perf_counter()
    try:
        async for attempt in tenacity.AsyncRetrying(**_retry_policy(retries)):
            with attempt:
                response = await client.get(url)
                response.raise_for_status()
                return _success(url, response, started)
    except Exception as exc:  # noqa: BLE001  永不抛出:错误进 FetchResult.error
        return _failure(url, exc, started)
    raise AssertionError("unreachable")


def fetch_one_sync(client: httpx.Client, url: str, retries: int = 3) -> FetchResult:
    """fetch_one 的同步版(线程池/顺序两种实现都用它),行为完全一致。"""
    started = time.perf_counter()
    try:
        for attempt in tenacity.Retrying(**_retry_policy(retries)):
            with attempt:
                response = client.get(url)
                response.raise_for_status()
                return _success(url, response, started)
    except Exception as exc:  # noqa: BLE001
        return _failure(url, exc, started)
    raise AssertionError("unreachable")
点击展开:aggregator_solution/src/aggregator/engine.py
exercises/solutions/week3/aggregator_solution/src/aggregator/engine.py
"""三种抓取实现:asyncio(TaskGroup + Semaphore)、线程池、顺序。(参考答案)

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

import asyncio
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor

import httpx

from aggregator.client import fetch_one, fetch_one_sync
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"]
    """
    if count <= 0:
        raise ValueError("count 必须为正")
    base = base_url.rstrip("/")
    return [f"{base}/items/{i}" for i in range(1, count + 1)]


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, ...]
    """
    if limit <= 0:
        raise ValueError("limit 必须为正")
    sem = asyncio.Semaphore(limit)

    async def guarded(url: str) -> FetchResult:
        async with sem:
            return await fetch_one(client, url, retries=retries)

    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(guarded(url)) for url in urls]
    return [task.result() for task in tasks]


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, ...]
    """
    with ThreadPoolExecutor(max_workers=max(max_workers, 1)) as pool:
        return list(
            pool.map(lambda url: fetch_one_sync(client, url, retries=retries), urls)
        )


def fetch_all_sequential(
    client: httpx.Client, urls: Sequence[str], retries: int = 3
) -> list[FetchResult]:
    """顺序版:for 循环一条条抓,用来当基准线(bench 里对比的"慢参照")。"""
    return [fetch_one_sync(client, url, retries=retries) for url in urls]
点击展开:aggregator_solution/src/aggregator/report.py
exercises/solutions/week3/aggregator_solution/src/aggregator/report.py
"""汇总与输出:统计(成功率、p50/p95)、rich 表格、JSON/CSV 原子写。(参考答案)

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

import csv
import json
import math
import os
from collections import Counter
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
    """
    if not 0 <= pct <= 100:
        raise ValueError("pct 必须在 0..100 之间")
    if not values:
        return 0.0
    ordered = sorted(values)
    rank = max(1, math.ceil(pct / 100 * len(ordered)))
    return float(ordered[rank - 1])


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, ...)
    """
    ok = [r for r in results if r.ok]
    failed = [r for r in results if not r.ok]
    latencies = [r.elapsed_ms for r in results]
    return Summary(
        total=len(results),
        ok=len(ok),
        failed=len(failed),
        success_rate=(len(ok) / len(results)) if results else 0.0,
        p50_ms=percentile(latencies, 50),
        p95_ms=percentile(latencies, 95),
        total_elapsed_ms=total_elapsed_ms,
        errors=dict(Counter(r.error or "unknown" for r in failed)),
    )


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
    """
    table = Table(
        title=f"抓取结果(前 {min(limit, len(results))} / {len(results)} 条)"
    )
    for column in ("url", "status", "elapsed_ms", "error"):
        table.add_column(column, justify="right" if column != "url" else "left")
    for result in list(results)[:limit]:
        row = result.row()
        table.add_row(row["url"], row["status"], row["elapsed_ms"], row["error"])
    return table


def summary_table(summary: Summary) -> Table:
    """做一张两列(指标 / 值)的 rich 表格,展示 Summary 的所有字段。"""
    table = Table(title="汇总")
    table.add_column("指标")
    table.add_column("值", justify="right")
    for key, value in summary.model_dump().items():
        if isinstance(value, float):
            text = f"{value:.2%}" if key == "success_rate" else f"{value:.1f}"
        elif isinstance(value, dict):
            text = ", ".join(f"{k}×{v}" for k, v in value.items()) or "-"
        else:
            text = str(value)
        table.add_row(key, text)
    return table


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 换上去。
    """
    payload = {
        "summary": summary.model_dump(),
        "results": [r.model_dump() for r in results],
    }
    _atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2))


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

    注意 newline="",否则 Windows 上每行之间会多一个空行。
    """
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    with tmp.open("w", encoding="utf-8", newline="") as fp:
        writer = csv.DictWriter(fp, fieldnames=["url", "status", "elapsed_ms", "error"])
        writer.writeheader()
        writer.writerows(r.row() for r in results)
    os.replace(tmp, path)
点击展开:w3_01_pytest_advanced/test_inventory.py
exercises/solutions/week3/w3_01_pytest_advanced/test_inventory.py
"""Week 3 · M12 · pytest 进阶:参考答案(inventory.py 的测试)

验证:与 exercises/week3/w3_01_pytest_advanced/ 下的 inventory.py、conftest.py
放到同一临时目录,运行 pytest --cov=inventory --cov-report=term-missing,覆盖率应 ≥95%。
"""

import csv
import json
import logging
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock

import pytest
from conftest import FixedClock
from inventory import DEFAULT_LOW_STOCK, Inventory, Item, SystemClock

PRICES = {"pen": 2.5, "ink": 10.0, "book": 30.0}


class FakePrices:
    """假价格服务:查表;未知 sku 抛 KeyError(和协议约定一致)。"""

    def price_of(self, sku: str) -> float:
        return PRICES[sku]


@pytest.fixture
def prices() -> MagicMock:
    """用 MagicMock 包一层,既能返回真实价格,又能断言调用次数。"""
    mock = MagicMock(spec=FakePrices())
    mock.price_of.side_effect = FakePrices().price_of
    return mock


@pytest.fixture
def inventory(fixed_clock: FixedClock, prices: MagicMock) -> Inventory:
    return Inventory(clock=fixed_clock, prices=prices)


@pytest.fixture
def stocked(inventory: Inventory) -> Inventory:
    inventory.add("pen", 10)
    inventory.add("ink", 2)
    return inventory


def test_add_new_sku_records_quantity_and_timestamp(
    inventory: Inventory, fixed_clock: FixedClock
):
    item = inventory.add("pen", 3)
    assert item == Item(sku="pen", quantity=3, updated_at=fixed_clock.stamp)
    assert fixed_clock.calls == 1


def test_add_existing_sku_accumulates(inventory: Inventory):
    inventory.add("pen", 3)
    assert inventory.add("pen", 4).quantity == 7
    assert len(inventory) == 1


def test_add_strips_whitespace_in_sku(inventory: Inventory):
    assert inventory.add("  pen ", 1).sku == "pen"
    assert inventory.get("pen") is not None


@pytest.mark.parametrize("sku", ["", "   "], ids=["empty", "blank"])
def test_add_rejects_blank_sku(inventory: Inventory, sku: str):
    with pytest.raises(ValueError, match="sku 不能为空"):
        inventory.add(sku, 1)


@pytest.mark.parametrize("quantity", [0, -1])
def test_add_rejects_non_positive_quantity(inventory: Inventory, quantity: int):
    with pytest.raises(ValueError, match=f"必须为正,收到 {quantity}"):
        inventory.add("pen", quantity)


def test_remove_reduces_quantity(stocked: Inventory):
    assert stocked.remove("pen", 4).quantity == 6


def test_remove_unknown_sku_raises_key_error(stocked: Inventory):
    with pytest.raises(KeyError, match="未知 sku:nope"):
        stocked.remove("nope", 1)


def test_remove_more_than_stock_raises_value_error(stocked: Inventory):
    with pytest.raises(ValueError, match="库存不足:现有 2,要出 3"):
        stocked.remove("ink", 3)
    assert stocked.get("ink").quantity == 2  # 失败不改状态


@pytest.mark.parametrize("quantity", [0, -5])
def test_remove_rejects_non_positive_quantity(stocked: Inventory, quantity: int):
    with pytest.raises(ValueError):
        stocked.remove("pen", quantity)


def test_remove_logs_warning_when_stock_gets_low(
    stocked: Inventory, caplog: pytest.LogCaptureFixture
):
    with caplog.at_level(logging.WARNING, logger="inventory"):
        stocked.remove("pen", 8)  # 剩 2 <= 阈值 3
    assert any("库存偏低" in r.message and "pen" in r.message for r in caplog.records)


def test_remove_does_not_log_when_stock_is_healthy(
    stocked: Inventory, caplog: pytest.LogCaptureFixture
):
    with caplog.at_level(logging.WARNING, logger="inventory"):
        stocked.remove("pen", 1)  # 剩 9
    assert caplog.records == []


def test_total_value_uses_injected_prices(stocked: Inventory):
    assert stocked.total_value() == pytest.approx(10 * 2.5 + 2 * 10.0)


def test_total_value_of_empty_inventory_is_zero(
    inventory: Inventory, prices: MagicMock
):
    assert inventory.total_value() == 0.0
    prices.price_of.assert_not_called()


def test_total_value_asks_price_service_once_per_sku(
    stocked: Inventory, prices: MagicMock
):
    stocked.total_value()
    assert prices.price_of.call_count == 2
    assert {c.args[0] for c in prices.price_of.call_args_list} == {"pen", "ink"}


def test_low_stock_returns_sorted_skus(stocked: Inventory):
    stocked.add("book", 1)
    assert stocked.low_stock() == ["book", "ink"]


def test_low_stock_threshold_is_configurable(fixed_clock: FixedClock):
    inv = Inventory(clock=fixed_clock, prices=FakePrices(), low_stock_threshold=10)
    inv.add("pen", 10)
    assert inv.low_stock() == ["pen"]
    assert DEFAULT_LOW_STOCK == 3


def test_export_json_writes_all_items(stocked: Inventory, tmp_path: Path):
    target = tmp_path / "stock.json"
    assert stocked.export(target) == 2
    rows = json.loads(target.read_text(encoding="utf-8"))
    assert [r["sku"] for r in rows] == ["ink", "pen"]  # 按 sku 排序


def test_export_csv_has_header_row(stocked: Inventory, tmp_path: Path):
    target = tmp_path / "stock.csv"
    stocked.export(target)
    with target.open(encoding="utf-8", newline="") as f:
        rows = list(csv.reader(f))
    assert rows[0] == ["sku", "quantity", "updated_at"] and len(rows) == 3


def test_export_rejects_unsupported_suffix(stocked: Inventory, tmp_path: Path):
    with pytest.raises(ValueError, match="不支持的导出格式"):
        stocked.export(tmp_path / "stock.xml")


def test_system_clock_returns_iso_string():
    stamp = SystemClock().now()
    parsed = datetime.fromisoformat(stamp)
    assert parsed.tzinfo is not None and stamp.endswith("+00:00")


def test_len_and_get_reflect_current_state(inventory: Inventory):
    assert len(inventory) == 0 and inventory.get("pen") is None
    inventory.add("pen", 1)
    assert len(inventory) == 1 and inventory.get("pen").quantity == 1


def test_price_service_key_error_propagates(inventory: Inventory):
    inventory.add("mystery", 1)
    with pytest.raises(KeyError):
        inventory.total_value()
点击展开:w3_02_threads_processes_subprocess.py
exercises/solutions/week3/w3_02_threads_processes_subprocess.py
"""Week 3 · M13 · 线程、进程、subprocess

学习目标:分清"I/O 密集用线程、CPU 密集用进程、调外部程序用 subprocess";
      会用 ThreadPoolExecutor / ProcessPoolExecutor / Lock / Queue / subprocess.run。
用法:uv run pytest exercises/week3/test_w3_02_threads_processes_subprocess.py -q
做法:把每个 TODO 换成你的实现,反复跑测试直到全绿。
      unsafe_counter 已经写好,是"反面教材":在 REPL 里跑几次看它会不会少加
      (少加说明有竞态,每次结果还可能不一样):
>>> import sys
>>> sys.path.insert(0, "exercises/week3")
>>> import w3_02_threads_processes_subprocess as m
>>> print([m.unsafe_counter(8, 50000) for _ in range(3)])
      卡住 8 分钟以上再按 ai-guide.md 的模板提问;拿到提示后自己重写。
"""

import queue
import subprocess
import sys
import threading
import time
from collections.abc import Callable, Iterable
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
from dataclasses import dataclass

_unsafe_total = 0


class CommandTimeout(Exception):
    """run_command 超时时抛出的自定义异常(外层只想 except 一种类型)。"""


@dataclass(frozen=True, slots=True)
class CommandResult:
    """一次外部命令的执行结果。"""

    returncode: int
    stdout: str
    stderr: str
    duration: float


def unsafe_counter(n_threads: int, increments: int) -> int:
    """反面教材:多线程给同一个全局变量做 += ,不加锁,结果可能小于期望值。

    期望值是 n_threads * increments;`total += 1` 是"读-改-写"三步,
    线程可能在中间被切走,于是两个线程写回同一个旧值,加了两次只涨了一次。
    例:unsafe_counter(8, 50000) 期望 400000,实际可能是 3xxxxx
    """
    global _unsafe_total
    _unsafe_total = 0
    old_interval = sys.getswitchinterval()
    sys.setswitchinterval(1e-6)

    def worker() -> None:
        global _unsafe_total
        for _ in range(increments):
            _unsafe_total += 1

    try:
        threads = [threading.Thread(target=worker) for _ in range(n_threads)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
    finally:
        sys.setswitchinterval(old_interval)
    return _unsafe_total


def is_prime(n: int) -> bool:
    """朴素素数判断(故意不优化,好让 CPU 忙起来)。例:is_prime(97) -> True"""
    if n < 2:
        return False
    if n % 2 == 0:
        return n == 2
    factor = 3
    while factor * factor <= n:
        if n % factor == 0:
            return False
        factor += 2
    return True


# ---- 任务 1 ----
def fetch_all_threaded(
    items: list[str],
    fetch: Callable[[str], str],
    max_workers: int = 8,
) -> dict[str, str]:
    """用线程池并发调用注入的 fetch(item),返回 item -> 结果 的字典。

    I/O 密集任务的标准写法:ThreadPoolExecutor + submit + as_completed
    (或 executor.map,但 map 只保证顺序,拿不到"哪个先完成")。
    单个 item 失败就让异常抛出来(future.result() 时抛)。
    例:fetch_all_threaded(["a", "b"], lambda s: s.upper()) -> {"a": "A", "b": "B"}
    """
    results: dict[str, str] = {}
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        futures = {pool.submit(fetch, item): item for item in items}
        for future in as_completed(futures):
            results[futures[future]] = future.result()
    return results


# ---- 任务 2 ----
def safe_counter(n_threads: int, increments: int) -> int:
    """多线程累加,用 threading.Lock 保证结果一定等于 n_threads * increments。

    先看懂 unsafe_counter 为什么会错,再用 with lock: 把"读-改-写"变成原子操作。
    例:safe_counter(4, 10000) -> 40000(跑一百次都是 40000)
    """
    total = 0
    lock = threading.Lock()

    def worker() -> None:
        nonlocal total
        for _ in range(increments):
            with lock:
                total += 1

    threads = [threading.Thread(target=worker) for _ in range(n_threads)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return total


# ---- 任务 3 ----
def count_primes_in_range(bounds: tuple[int, int]) -> int:
    """数 [start, end) 区间里的素数个数(会被丢进子进程执行)。

    必须是模块顶层函数:Windows 用 spawn 启子进程,参数与函数都要能 pickle,
    子进程会重新 import 本模块并按名字找到它。
    例:count_primes_in_range((0, 10)) -> 4(2, 3, 5, 7)
    """
    # TODO: 在这里实现(用 is_prime)
    start, end = bounds
    return sum(1 for n in range(start, end) if is_prime(n))


# ---- 任务 4 ----
def count_primes_parallel(ranges: list[tuple[int, int]]) -> int:
    """用 ProcessPoolExecutor 把各区间分给不同进程数素数,返回总数。

    CPU 密集任务才值得开进程(进程启动 + pickle 有成本)。
    调用方是脚本时必须写 if __name__ == "__main__": 守卫,
    否则 Windows 上子进程重新 import 脚本会无限递归启动。
    例:count_primes_parallel([(0, 10), (10, 20)]) -> 8
    """
    # TODO: 在这里实现(executor.map(count_primes_in_range, ranges) 然后求和)
    with ProcessPoolExecutor() as pool:
        return sum(pool.map(count_primes_in_range, ranges))


# ---- 任务 5 ----
def run_command(cmd: list[str], timeout: float = 10.0) -> CommandResult:
    """跑一条外部命令,返回 CommandResult(含耗时)。

    要求:cmd 是列表(shell=False,不拼字符串,避免注入);
    capture_output=True、text=True、encoding="utf-8";
    超时抛 CommandTimeout(f"命令超时({timeout}s):...");
    非零退出**不抛**异常(check=False),交给调用方判断 returncode。
    例:run_command([sys.executable, "-c", "print(1)"]).stdout.strip() -> "1"
    """
    # TODO: 在这里实现(subprocess.run + time.perf_counter;捕获 TimeoutExpired)
    started = time.perf_counter()
    try:
        completed = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=timeout,
            check=False,
        )
    except subprocess.TimeoutExpired as e:
        raise CommandTimeout(f"命令超时({timeout}s):{cmd}") from e
    return CommandResult(
        returncode=completed.returncode,
        stdout=completed.stdout,
        stderr=completed.stderr,
        duration=time.perf_counter() - started,
    )


# ---- 任务 6 ----
def python_version_via_subprocess() -> str:
    """用子进程里的 Python 打印自己的版本号,返回形如 "3.14.4" 的字符串。

    要点:用 sys.executable 而不是写死 "python"(虚拟环境里 python 可能不是它)。
    例:python_version_via_subprocess() -> "3.14.4"
    """
    # TODO: 在这里实现([sys.executable, "-c", "..."] 里打印 platform.python_version())
    code = "import sys; print('.'.join(map(str, sys.version_info[:3])))"
    return run_command([sys.executable, "-c", code]).stdout.strip()


# ---- 任务 7 ----
def producer_consumer_threads(items: Iterable[str]) -> list[str]:
    """一个生产者线程往 queue.Queue 里放 items,一个消费者线程取出并处理成大写。

    结束方式:生产者放完后放一个哨兵值(比如 None),消费者见到哨兵就退出。
    返回消费者按拿到顺序处理出的结果列表。
    例:producer_consumer_threads(["a", "b"]) -> ["A", "B"]
    """
    q: queue.Queue[str | None] = queue.Queue()
    results: list[str] = []

    def producer() -> None:
        for item in items:
            q.put(item)
        q.put(None)

    def consumer() -> None:
        while True:
            item = q.get()
            if item is None:
                break
            results.append(item.upper())

    threads = [threading.Thread(target=producer), threading.Thread(target=consumer)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return results
点击展开:w3_03_asyncio_basics.py
exercises/solutions/week3/w3_03_asyncio_basics.py
"""Week 3 · M14a · asyncio 基础

学习目标:分清"协程对象 / Task / await";会用 gather、TaskGroup、as_completed、
      to_thread;能亲手证明"并发 10 个 sleep(0.2) 只要 0.2 秒"。
用法:uv run pytest exercises/week3/test_w3_03_asyncio_basics.py -q
做法:把每个 TODO 换成你的实现。协程里绝对不要用 time.sleep(会卡死整个事件循环),
      要么 await asyncio.sleep,要么 await asyncio.to_thread(阻塞函数)。
      卡住 8 分钟以上再按 ai-guide.md 的模板提问;拿到提示后自己重写。

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> import asyncio, time
>>> async def work(i):
>>>     await asyncio.sleep(0.1); return i * 2
>>> async def main():
>>>     t0 = time.perf_counter()
>>>     results = await asyncio.gather(*(work(i) for i in range(5)))
>>>     print(results, round(time.perf_counter() - t0, 1))   # 0.1 还是 0.5?
>>>     c = work(1); print(type(c)); c.close()                 # 协程对象未 await 会警告
>>> asyncio.run(main())
"""

import asyncio
import time
from collections.abc import Callable, Coroutine
from typing import Any

DELAY = 0.02


# ---- 任务 1 ----
async def fetch_fake(key: str, delay: float = DELAY) -> str:
    """假的异步请求:等 delay 秒,返回 f"{key}:ok"。

    这是后面所有任务的"素材":用 await asyncio.sleep(delay) 模拟等网络。
    例:await fetch_fake("a") -> "a:ok"
    """
    await asyncio.sleep(delay)
    return f"{key}:ok"


# ---- 任务 2 ----
async def gather_all(keys: list[str]) -> list[str]:
    """用 asyncio.gather 并发抓所有 key,结果顺序与 keys 一致。

    gather 会把所有协程一起调度;返回值顺序按传入顺序(不是完成顺序)。
    例:await gather_all(["a", "b"]) -> ["a:ok", "b:ok"]
    """
    return list(await asyncio.gather(*(fetch_fake(k) for k in keys)))


# ---- 任务 3 ----
async def run_group(keys: list[str]) -> list[str]:
    """用 asyncio.TaskGroup(3.11+)并发抓所有 key,结果按输入顺序返回。

    TaskGroup 是"结构化并发":async with 块退出时保证所有子任务已结束,
    一个任务失败会取消其余任务,并把异常聚合成 ExceptionGroup。
    提示:先 create_task 存进列表,出了 async with 块再读 task.result()。
    例:await run_group(["a", "b"]) -> ["a:ok", "b:ok"]
    """
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch_fake(k)) for k in keys]
    return [t.result() for t in tasks]


# ---- 任务 4 ----
async def first_n_done(coros: list[Coroutine[Any, Any, str]], n: int) -> list[str]:
    """用 asyncio.as_completed 取前 n 个先完成的结果,剩下的任务全部取消。

    n <= 0 或大于协程个数时抛 ValueError("n 必须在 1..len(coros) 之间");
    注意:没被 await 的协程要么取消要么 close,否则 Python 会警告
    "coroutine was never awaited"。
    例:await first_n_done([fetch_fake("a", 0.01), fetch_fake("b", 1.0)], 1) -> ["a:ok"]
    """
    if not 1 <= n <= len(coros):
        for coro in coros:
            coro.close()
        raise ValueError("n 必须在 1..len(coros) 之间")
    tasks = [asyncio.ensure_future(c) for c in coros]
    results: list[str] = []
    try:
        for finished in asyncio.as_completed(tasks):
            results.append(await finished)
            if len(results) == n:
                break
    finally:
        for t in tasks:
            if not t.done():
                t.cancel()
        await asyncio.gather(*tasks, return_exceptions=True)
    return results


# ---- 任务 5 ----
async def with_thread(fn: Callable[..., Any], *args: Any) -> Any:
    """把阻塞函数丢到线程里执行:await asyncio.to_thread(fn, *args)。

    这是"同步世界 → 异步世界"的桥:读文件、老库、CPU 小活都可以这样接进来。
    例:await with_thread(time.sleep, 0.01) -> None
    """
    return await asyncio.to_thread(fn, *args)


# ---- 任务 6 ----
async def sequential_vs_concurrent(n: int, delay: float) -> tuple[float, float]:
    """跑 n 个 fetch_fake(delay),分别用"顺序"和"并发"两种方式,返回两个耗时(秒)。

    顺序:在 for 循环里逐个 await;并发:一次性 gather / TaskGroup。
    用 time.perf_counter() 计时。
    例:await sequential_vs_concurrent(5, 0.05) -> (0.25.., 0.05..)
    """
    t0 = time.perf_counter()
    for i in range(n):
        await fetch_fake(str(i), delay)
    sequential = time.perf_counter() - t0
    t1 = time.perf_counter()
    await asyncio.gather(*(fetch_fake(str(i), delay) for i in range(n)))
    concurrent = time.perf_counter() - t1
    return sequential, concurrent
点击展开:w3_04_profiling.py
exercises/solutions/week3/w3_04_profiling.py
"""Week 3 · M16 · 性能与观测:先测量,再优化

学习目标:会用 time.perf_counter / cProfile / tracemalloc 测量;
      认得四个经典慢写法(list 里 in、循环拼字符串、循环里 re.compile、重复递归),
      并用 set/join/预编译/缓存把它们改快。
用法:uv run pytest exercises/week3/test_w3_04_profiling.py -q
做法:slow_* 四个函数是"反面教材",已经写好,不要改;
      你要写同名的 fast_* 版本(结果必须完全一样)以及三个测量工具。
      先猜"快多少倍",再用 benchmark 验证——猜错才是学到东西的地方。
"""

import cProfile
import functools
import pstats
import re
import time
import tracemalloc
from collections.abc import Callable
from typing import Any

# ---- 反面教材:下面四个 slow_* 已写好,故意很慢,不要改 ----


def slow_unique(items: list[int]) -> list[int]:
    """去重且保持原顺序——但用 list 的 in 判断,复杂度 O(n²)。

    例:slow_unique([1, 2, 1]) -> [1, 2]
    """
    result: list[int] = []
    for item in items:
        if item not in result:
            result.append(item)
    return result


def slow_join(n: int) -> str:
    """把 0..n-1 拼成 "0,1,2,..." ——但每次 += 都新建一个字符串。

    例:slow_join(3) -> "0,1,2"
    """
    text = ""
    for i in range(n):
        if text:
            text += ","
        text += str(i)
    return text


def slow_count_matches(pattern: str, texts: list[str]) -> int:
    """数有多少条 text 命中 pattern——但每次循环都重新编译正则。

    例:slow_count_matches(r"\\d+", ["a1", "bb"]) -> 1
    """
    total = 0
    for text in texts:
        if re.compile(pattern).search(text):
            total += 1
    return total


def slow_fib(n: int) -> int:
    """朴素递归斐波那契,指数级重复计算。

    例:slow_fib(10) -> 55
    """
    if n < 2:
        return n
    return slow_fib(n - 1) + slow_fib(n - 2)


# ---- 任务 1 ----
def fast_unique(items: list[int]) -> list[int]:
    """和 slow_unique 结果完全一样,但用 set 做"见过没"判断,复杂度 O(n)。

    例:fast_unique([3, 1, 3, 2, 1]) -> [3, 1, 2]
    """
    seen: set[int] = set()
    result: list[int] = []
    for item in items:
        if item not in seen:
            seen.add(item)
            result.append(item)
    return result


# ---- 任务 2 ----
def fast_join(n: int) -> str:
    """和 slow_join 结果完全一样,但用 ",".join(生成器) 一次成型。

    例:fast_join(3) -> "0,1,2"
    """
    return ",".join(str(i) for i in range(n))


# ---- 任务 3 ----
def fast_count_matches(pattern: str, texts: list[str]) -> int:
    """和 slow_count_matches 结果完全一样,但把 re.compile 提到循环外面。

    例:fast_count_matches(r"\\d+", ["a1", "bb", "c3"]) -> 2
    """
    compiled = re.compile(pattern)
    return sum(1 for text in texts if compiled.search(text))


# ---- 任务 4 ----
def fast_fib(n: int) -> int:
    """和 slow_fib 结果完全一样,但用 functools.cache 或循环迭代。

    例:fast_fib(30) -> 832040(要在毫秒级返回)
    """

    @functools.cache
    def fib(k: int) -> int:
        return k if k < 2 else fib(k - 1) + fib(k - 2)

    return fib(n)


# ---- 任务 5 ----
def benchmark(fn: Callable[..., Any], *args: Any, repeat: int = 5) -> float:
    """跑 fn(*args) repeat 次,返回**最快一次**的耗时(秒)。

    为什么取最小值而不是平均:机器上还有别的程序在跑,慢的那几次是噪声。
    repeat <= 0 时抛 ValueError("repeat 必须为正")。
    例:benchmark(sum, range(1000)) -> 0.0000xx
    """
    # TODO: 在这里实现(time.perf_counter)
    if repeat <= 0:
        raise ValueError("repeat 必须为正")
    best = float("inf")
    for _ in range(repeat):
        start = time.perf_counter()
        fn(*args)
        best = min(best, time.perf_counter() - start)
    return best


# ---- 任务 6 ----
def profile_top(fn: Callable[[], Any], n: int = 5) -> list[str]:
    """用 cProfile 跑一次 fn(),返回按累计耗时排序的前 n 个函数名。

    提示:cProfile.Profile() + enable/disable 或 runcall;
    用 pstats.Stats(profiler).sort_stats("cumulative"),
    再从 stats.fcn_list(或 stats.stats)里取函数名(func[2] 是函数名)。
    例:profile_top(lambda: slow_fib(15), 3) -> ["<lambda>", "slow_fib", ...]
    """
    profiler = cProfile.Profile()
    profiler.runcall(fn)
    stats = pstats.Stats(profiler)
    stats.sort_stats("cumulative")
    return [func[2] for func in (stats.fcn_list or [])[:n]]


# ---- 任务 7 ----
def peak_memory(fn: Callable[[], Any]) -> int:
    """用 tracemalloc 测 fn() 执行期间的内存峰值(字节)。

    步骤:tracemalloc.start() → 调 fn() → get_traced_memory() 取第二个值 → stop()。
    例:peak_memory(lambda: [0] * 1_000_000) > 1_000_000
    """
    tracemalloc.start()
    try:
        fn()
        _, peak = tracemalloc.get_traced_memory()
    finally:
        tracemalloc.stop()
    return peak
点击展开:w3_05_asyncio_advanced.py
exercises/solutions/week3/w3_05_asyncio_advanced.py
"""Week 3 · M14b · asyncio 进阶:超时、取消、限流、队列、异步生成器

学习目标:会用 asyncio.timeout / Semaphore / Queue / TaskGroup + except* /
      asynccontextmanager / 异步生成器;懂"CancelledError 必须重新抛出"。
用法:uv run pytest exercises/week3/test_w3_05_asyncio_advanced.py -q
做法:把每个 TODO 换成你的实现。三条铁律:
      1) 不要吞 CancelledError(except asyncio.CancelledError 里要 raise);
      2) 取消任务后要 await 它(或 gather(..., return_exceptions=True))等它真的结束;
      3) 协程里不要 time.sleep、不要大计算。
      卡住 8 分钟以上再按 ai-guide.md 的模板提问;拿到提示后自己重写。
"""

import asyncio
import time
from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Sequence
from contextlib import asynccontextmanager
from typing import Any


# ---- 任务 1 ----
async def gather_with_limit[T](
    coros: Sequence[Coroutine[Any, Any, T]], limit: int
) -> list[T]:
    """并发执行 coros,但同时最多只跑 limit 个,结果顺序与输入一致。

    做法:一个 asyncio.Semaphore(limit) 包住每个协程(async with sem: return await c),
    再用 gather / TaskGroup 一起跑。limit <= 0 抛 ValueError("limit 必须为正")。
    例:limit=2 时,4 个各睡 0.1 秒的协程总耗时约 0.2 秒
    """
    if limit <= 0:
        for c in coros:
            c.close()
        raise ValueError("limit 必须为正")
    sem = asyncio.Semaphore(limit)

    async def guarded(c):
        async with sem:
            return await c

    return list(await asyncio.gather(*(guarded(c) for c in coros)))


# ---- 任务 2 ----
async def fetch_all_with_timeout(
    fetch: Callable[[str], Awaitable[str]], keys: list[str], timeout: float
) -> dict[str, str | None]:
    """并发调 fetch(key),每个 key 单独限时;超时的那个记 None,其余照常返回。

    用 async with asyncio.timeout(timeout):(3.11+)包住单个请求,
    捕获 TimeoutError 后返回 None——注意超时会取消那个任务,别把 CancelledError 吞了。
    例:fetch("slow") 要 1 秒、timeout=0.05 -> {"slow": None, "fast": "fast:ok"}
    """

    async def one(key: str) -> str | None:
        try:
            async with asyncio.timeout(timeout):
                return await fetch(key)
        except TimeoutError:
            return None

    results = await asyncio.gather(*(one(k) for k in keys))
    return dict(zip(keys, results, strict=True))


# ---- 任务 3 ----
async def producer_consumer(n_items: int, n_workers: int) -> list[str]:
    """一个生产者把 n_items 个任务放进 asyncio.Queue,n_workers 个消费者处理。

    要求:用 queue.join() 等所有任务被 task_done(),然后取消 worker 任务;
    返回处理结果的**排序后**列表:["item-0:done", "item-1:done", ...](完成顺序不定)。
    例:await producer_consumer(3, 2) -> ["item-0:done", "item-1:done", "item-2:done"]
    """
    queue: asyncio.Queue[str] = asyncio.Queue()
    results: list[str] = []

    async def worker() -> None:
        while True:
            item = await queue.get()
            try:
                await asyncio.sleep(0)
                results.append(f"{item}:done")
            finally:
                queue.task_done()

    workers = [asyncio.create_task(worker()) for _ in range(n_workers)]
    for i in range(n_items):
        await queue.put(f"item-{i}")
    await queue.join()
    for w in workers:
        w.cancel()
    await asyncio.gather(*workers, return_exceptions=True)
    return sorted(results)


# ---- 任务 4 ----
async def aticker(interval: float, count: int) -> AsyncIterator[int]:
    """异步生成器:每隔 interval 秒 yield 一个序号,从 0 到 count-1。

    调用方用 async for i in aticker(0.01, 3): 消费。
    例:[i async for i in aticker(0.01, 3)] -> [0, 1, 2]
    """
    # TODO: 在这里实现(async def + yield,不要 return 列表)
    for i in range(count):
        await asyncio.sleep(interval)
        yield i


# ---- 任务 5 ----
async def retry_async[T](
    fn: Callable[[], Awaitable[T]], attempts: int = 3, backoff: float = 0.01
) -> T:
    """重试异步调用:失败就等 backoff、backoff*2、backoff*4 ... 再试,最后一次失败照抛。

    attempts <= 0 抛 ValueError("attempts 必须为正")。
    只对"幂等操作"这么干(GET 可以,付款不行)。
    例:前两次抛 RuntimeError、第三次成功 -> 返回第三次的结果
    """
    if attempts <= 0:
        raise ValueError("attempts 必须为正")
    delay = backoff
    for attempt in range(1, attempts + 1):
        try:
            return await fn()
        except Exception:
            if attempt == attempts:
                raise
            await asyncio.sleep(delay)
            delay *= 2
    raise AssertionError("unreachable")


# ---- 任务 6 ----
async def first_completed[T](coros: Sequence[Coroutine[Any, Any, T]]) -> T:
    """返回最先完成的那个结果,其余任务全部取消(并等它们真的结束)。

    提示:asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED);
    空序列抛 ValueError("coros 不能为空")。
    例:[慢 1 秒, 快 0.01 秒] -> 快的那个结果,且总耗时 ≈ 0.01 秒
    """
    if not coros:
        raise ValueError("coros 不能为空")
    tasks = [asyncio.ensure_future(c) for c in coros]
    done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
    for t in pending:
        t.cancel()
    await asyncio.gather(*pending, return_exceptions=True)
    return done.pop().result()


# ---- 任务 7 ----
@asynccontextmanager
async def timed_section(
    label: str, sink: list[tuple[str, float]]
) -> AsyncIterator[None]:
    """异步上下文管理器:退出时把 (label, 耗时秒) 追加到 sink。

    即使块里抛异常也要记时间(用 try/finally)。
    例:
        log: list[tuple[str, float]] = []
        async with timed_section("fetch", log):
            await asyncio.sleep(0.05)
        log -> [("fetch", 0.05..)]
    """
    # TODO: 在这里实现(yield 之前开始计时,finally 里记录)
    start = time.perf_counter()
    try:
        yield
    finally:
        sink.append((label, time.perf_counter() - start))


# ---- 任务 8 ----
async def run_group_collect_errors(
    coros: Sequence[Coroutine[Any, Any, Any]],
) -> tuple[list[Any], list[Exception]]:
    """用 TaskGroup 跑一批协程,返回 (成功结果列表, 异常列表)。

    TaskGroup 里只要一个任务抛异常,其余会被取消,异常聚合成 ExceptionGroup;
    用 except* Exception as eg: 把 eg.exceptions 收集起来。
    成功结果只收集那些 done 且没异常、没被取消的任务。
    例:两个成功 + 一个较慢的失败 -> (["a:ok", "b:ok"], [ValueError(...)])
    """
    tasks = []
    errors: list[Exception] = []
    try:
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(c) for c in coros]
    except* Exception as eg:
        errors.extend(eg.exceptions)
    results = [
        t.result()
        for t in tasks
        if t.done() and not t.cancelled() and t.exception() is None
    ]
    return results, errors
点击展开:w3_06_httpx.py
exercises/solutions/week3/w3_06_httpx.py
"""Week 3 · M15 · httpx:客户端复用、超时、错误层次、流式、重试

学习目标:会用 httpx.Client / AsyncClient(复用连接)、显式 Timeout、
      raise_for_status 与两层错误(RequestError vs HTTPStatusError)、
      流式下载、tenacity 重试;测试全部用 MockTransport,不打真网。
用法:uv run pytest exercises/week3/test_w3_06_httpx.py -q
做法:把每个 TODO 换成你的实现。所有函数都接受一个已建好的 client 参数(依赖注入),
      测试才能塞 MockTransport 进来——这也是真实项目的写法:客户端在外面建一次,到处传。
      卡住 8 分钟以上再按 ai-guide.md 的模板提问;拿到提示后自己重写。
"""

import asyncio
from pathlib import Path
from typing import Any

import httpx
import tenacity


def _is_server_error(exc: BaseException) -> bool:
    """只对 5xx 重试:4xx 是客户端的错,重试也不会变好。"""
    return isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code >= 500


# ---- 任务 1 ----
def make_client(
    base_url: str,
    timeout: float = 5.0,
    transport: httpx.BaseTransport | None = None,
) -> httpx.Client:
    """建一个同步客户端:带 base_url、显式超时、一个 User-Agent 头。

    要求:httpx.Timeout(timeout) 显式设置(不设超时是线上事故常见来源);
    headers 里放 {"User-Agent": "aggregator-exercise/1.0"};
    transport 不为 None 时传给 httpx.Client(transport=...)(测试用 MockTransport)。
    例:make_client("http://x").headers["user-agent"] -> "aggregator-exercise/1.0"
    """
    kwargs = {
        "base_url": base_url,
        "timeout": httpx.Timeout(timeout),
        "headers": {"User-Agent": "aggregator-exercise/1.0"},
    }
    if transport is not None:
        kwargs["transport"] = transport
    return httpx.Client(**kwargs)


# ---- 任务 2 ----
def get_json(client: httpx.Client, path: str) -> dict[str, Any]:
    """GET path 并返回解析好的 JSON;4xx/5xx 抛 httpx.HTTPStatusError。

    要点:response.raise_for_status() 才会把状态码变成异常;
    不 raise_for_status 就 .json(),遇到错误页会得到莫名其妙的数据。
    例:get_json(client, "/items/1") -> {"id": 1, ...}
    """
    response = client.get(path)
    response.raise_for_status()
    return response.json()


# ---- 任务 3 ----
async def fetch_many(
    aclient: httpx.AsyncClient, paths: list[str], limit: int = 5
) -> dict[str, dict[str, Any] | Exception]:
    """并发 GET 多个 path,最多同时 limit 个;单个失败不影响其它。

    做法:asyncio.Semaphore(limit) 限流;TaskGroup 或 gather 并发;
    每个 path 自己 try/except,失败就把异常对象存进结果字典。
    例:{"/ok": {...}, "/bad": HTTPStatusError(...)}
    """
    sem = asyncio.Semaphore(limit)
    results: dict[str, dict | Exception] = {}

    async def one(path: str) -> None:
        async with sem:
            try:
                response = await aclient.get(path)
                response.raise_for_status()
                results[path] = response.json()
            except Exception as e:  # noqa: BLE001 单个失败只记录,不影响其它
                results[path] = e

    async with asyncio.TaskGroup() as tg:
        for path in paths:
            tg.create_task(one(path))
    return {path: results[path] for path in paths}


# ---- 任务 4 ----
async def stream_lines(aclient: httpx.AsyncClient, path: str) -> list[str]:
    """流式读取一个文本响应,返回去掉空行后的行列表。

    做法:async with aclient.stream("GET", path) as response:
    先 raise_for_status(),再 async for line in response.aiter_lines()。
    流式的意义:大响应不用一次性读进内存。
    例:stream_lines(aclient, "/lines") -> ["第一行", "第二行"]
    """
    lines: list[str] = []
    async with aclient.stream("GET", path) as response:
        response.raise_for_status()
        async for line in response.aiter_lines():
            if line.strip():
                lines.append(line.rstrip("\r\n"))
    return lines


# ---- 任务 5 ----
def get_json_retrying(
    client: httpx.Client, path: str, attempts: int = 3
) -> dict[str, Any]:
    """带重试的 GET:只对 5xx(服务端错误)重试,4xx 立刻抛出。

    用 tenacity:stop=stop_after_attempt(attempts)、
    wait=wait_exponential(multiplier=0.01)、retry=retry_if_exception_type(...)、
    reraise=True;因为 attempts 是参数,装饰器写不了,就在函数体里用
    for attempt in tenacity.Retrying(...): with attempt: ... 的写法。
    只重试幂等请求(GET 可以,POST 付款不行)。
    例:前两次 503、第三次 200 -> 返回第三次的 JSON,共请求 3 次
    """
    for attempt in tenacity.Retrying(
        stop=tenacity.stop_after_attempt(attempts),
        wait=tenacity.wait_exponential(multiplier=0.01),
        retry=tenacity.retry_if_exception(_is_server_error),
        reraise=True,
    ):
        with attempt:
            return get_json(client, path)
    raise AssertionError("unreachable")


# ---- 任务 6 ----
def download(client: httpx.Client, path: str, dest: Path) -> int:
    """分块把响应写到 dest 文件,返回写入的字节数。

    做法:with client.stream("GET", path) as response: 先 raise_for_status(),
    再 for chunk in response.iter_bytes(): 写文件(这样 1GB 文件也不会撑爆内存)。
    例:download(client, "/big.bin", tmp_path / "big.bin") -> 1024
    """
    written = 0
    dest.parent.mkdir(parents=True, exist_ok=True)
    with client.stream("GET", path) as response:
        response.raise_for_status()
        with dest.open("wb") as fp:
            for chunk in response.iter_bytes():
                fp.write(chunk)
                written += len(chunk)
    return written


# ---- 任务 7 ----
def classify_error(exc: Exception) -> str:
    """把 httpx 的异常分类成一个短标签,方便日志与统计。

    规则(注意顺序:TimeoutException 是 RequestError 的子类):
    httpx.TimeoutException -> "timeout"
    httpx.ConnectError -> "connect"
    其他 httpx.RequestError -> "network"
    httpx.HTTPStatusError -> "http_4xx" 或 "http_5xx"(看 exc.response.status_code)
    其他异常 -> "unknown"
    例:classify_error(httpx.ConnectTimeout("x")) -> "timeout"
    """
    if isinstance(exc, httpx.TimeoutException):
        return "timeout"
    if isinstance(exc, httpx.ConnectError):
        return "connect"
    if isinstance(exc, httpx.RequestError):
        return "network"
    if isinstance(exc, httpx.HTTPStatusError):
        return "http_5xx" if exc.response.status_code >= 500 else "http_4xx"
    return "unknown"