跳转至

第 3 周 练习骨架(只读展示)

怎么用

这里只是为了方便在手机/平板上看题。实际编码请在 VS Code 里打开 exercises/week3/ 下的对应文件,把 TODO 换成你的实现,然后运行:

uv run pytest exercises/week3/test_文件名.py -q(目录型练习用 uv run pytest exercises/week3/目录名 -q

任务规格、测试用例与陷阱反例见对应的计划页

test_w3_02_threads_processes_subprocess.py

exercises/week3/test_w3_02_threads_processes_subprocess.py
"""Week 3 · M13 · w3_02 的测试(已写好,不要改)

用法:uv run pytest exercises/week3/test_w3_02_threads_processes_subprocess.py -q
读输出:. 通过,F 断言失败,E 测试里抛了异常(比如还没实现的 NotImplementedError)。
说明:所有"网络请求"都用 time.sleep 桩代替,整个文件不联网、不到 2 秒跑完。
"""

import sys
import time

import pytest
from w3_02_threads_processes_subprocess import (
    CommandTimeout,
    count_primes_in_range,
    count_primes_parallel,
    fetch_all_threaded,
    producer_consumer_threads,
    python_version_via_subprocess,
    run_command,
    safe_counter,
)

SLEEP = 0.05
ITEMS = [f"item-{i}" for i in range(8)]


def slow_fetch(item: str) -> str:
    """假的"网络请求":睡 0.05 秒再返回大写结果。"""
    time.sleep(SLEEP)
    return item.upper()


def boom_fetch(item: str) -> str:
    """对某一个 item 抛异常,用来验证异常会在 result() 时冒出来。"""
    if item == "item-3":
        raise RuntimeError("模拟请求失败")
    return item.upper()


def test_fetch_all_threaded_keeps_item_to_result_mapping() -> None:
    got = fetch_all_threaded(["a", "b", "c"], lambda s: s.upper())
    assert got == {"a": "A", "b": "B", "c": "C"}


def test_fetch_all_threaded_is_much_faster_than_sequential() -> None:
    start = time.perf_counter()
    got = fetch_all_threaded(ITEMS, slow_fetch, max_workers=8)
    elapsed = time.perf_counter() - start
    assert len(got) == len(ITEMS)
    assert elapsed < len(ITEMS) * SLEEP / 2


def test_fetch_all_threaded_propagates_worker_exception() -> None:
    with pytest.raises(RuntimeError, match="模拟请求失败"):
        fetch_all_threaded(ITEMS, boom_fetch)


@pytest.mark.parametrize(
    ("n_threads", "increments"),
    [(2, 1000), (8, 5000)],
    ids=["2线程", "8线程"],
)
def test_safe_counter_is_exact(n_threads: int, increments: int) -> None:
    assert safe_counter(n_threads, increments) == n_threads * increments


def test_count_primes_in_range() -> None:
    assert count_primes_in_range((0, 10)) == 4
    assert count_primes_in_range((10, 20)) == 4
    assert count_primes_in_range((0, 2)) == 0


def test_count_primes_parallel_matches_sequential() -> None:
    ranges = [(0, 2000), (2000, 4000), (4000, 6000)]
    expected = sum(count_primes_in_range(bounds) for bounds in ranges)
    assert count_primes_parallel(ranges) == expected


def test_run_command_captures_stdout() -> None:
    result = run_command([sys.executable, "-X", "utf8", "-c", "print('你好')"])
    assert result.returncode == 0
    assert result.stdout.strip() == "你好"
    assert result.duration >= 0


def test_run_command_nonzero_exit_does_not_raise() -> None:
    result = run_command([sys.executable, "-c", "import sys; sys.exit(3)"])
    assert result.returncode == 3
    assert result.stdout == ""


def test_run_command_writes_stderr() -> None:
    code = "import sys; print('出错了', file=sys.stderr); sys.exit(1)"
    result = run_command([sys.executable, "-X", "utf8", "-c", code])
    assert result.returncode == 1
    assert "出错了" in result.stderr


def test_run_command_timeout_raises_command_timeout() -> None:
    with pytest.raises(CommandTimeout):
        run_command([sys.executable, "-c", "import time; time.sleep(5)"], timeout=0.3)


def test_python_version_via_subprocess() -> None:
    version = python_version_via_subprocess()
    assert version.count(".") == 2
    assert version.startswith("3.")


def test_producer_consumer_threads_processes_all_items() -> None:
    assert producer_consumer_threads(["a", "b", "c"]) == ["A", "B", "C"]


def test_producer_consumer_threads_handles_empty_input() -> None:
    assert producer_consumer_threads([]) == []

test_w3_03_asyncio_basics.py

exercises/week3/test_w3_03_asyncio_basics.py
"""Week 3 · M14a · w3_03 的测试(已写好,不要改)

用法:uv run pytest exercises/week3/test_w3_03_asyncio_basics.py -q
说明:pyproject.toml 里配了 asyncio_mode = "auto",所以 async def test_x() 直接就能跑,
      不需要 @pytest.mark.asyncio;测试里的"网络"全是 asyncio.sleep。
"""

import time

import pytest
from w3_03_asyncio_basics import (
    fetch_fake,
    first_n_done,
    gather_all,
    run_group,
    sequential_vs_concurrent,
    with_thread,
)


async def test_fetch_fake_returns_key_ok() -> None:
    assert await fetch_fake("a", 0.001) == "a:ok"


async def test_gather_all_keeps_input_order() -> None:
    assert await gather_all(["a", "b", "c"]) == ["a:ok", "b:ok", "c:ok"]


async def test_gather_all_is_concurrent() -> None:
    keys = [f"k{i}" for i in range(10)]
    start = time.perf_counter()
    got = await gather_all(keys)
    elapsed = time.perf_counter() - start
    assert len(got) == 10
    assert elapsed < 0.15


async def test_gather_all_empty_list() -> None:
    assert await gather_all([]) == []


async def test_run_group_keeps_input_order() -> None:
    assert await run_group(["x", "y", "z"]) == ["x:ok", "y:ok", "z:ok"]


async def test_run_group_is_concurrent() -> None:
    keys = [f"k{i}" for i in range(10)]
    start = time.perf_counter()
    await run_group(keys)
    assert time.perf_counter() - start < 0.15


async def test_first_n_done_returns_fastest_first() -> None:
    coros = [fetch_fake("slow", 0.5), fetch_fake("fast", 0.01)]
    start = time.perf_counter()
    got = await first_n_done(coros, 1)
    assert got == ["fast:ok"]
    assert time.perf_counter() - start < 0.3


async def test_first_n_done_rejects_bad_n() -> None:
    coros = [fetch_fake("a", 0.001)]
    with pytest.raises(ValueError, match="n 必须"):
        await first_n_done(coros, 5)


async def test_with_thread_runs_blocking_function() -> None:
    start = time.perf_counter()
    assert await with_thread(str.upper, "abc") == "ABC"
    await with_thread(time.sleep, 0.05)
    assert time.perf_counter() - start >= 0.05


async def test_sequential_vs_concurrent() -> None:
    seq, conc = await sequential_vs_concurrent(5, 0.02)
    assert seq >= 5 * 0.02 * 0.8
    assert conc < seq / 2

test_w3_04_profiling.py

exercises/week3/test_w3_04_profiling.py
"""Week 3 · M16 · w3_04 的测试(已写好,不要改)

用法:uv run pytest exercises/week3/test_w3_04_profiling.py -q
说明:性能断言只要求 fast 不慢于 slow(不写"快 10 倍"这种会随机器抖动的断言),
      正确性断言才是主角:fast_* 必须和 slow_* 逐个输入结果一致。
"""

import pytest
from w3_04_profiling import (
    benchmark,
    fast_count_matches,
    fast_fib,
    fast_join,
    fast_unique,
    peak_memory,
    profile_top,
    slow_count_matches,
    slow_fib,
    slow_join,
    slow_unique,
)

BIG_ITEMS = [i % 500 for i in range(3000)]
TEXTS = [f"line-{i}" if i % 3 else "no digits here" for i in range(2000)]


@pytest.mark.parametrize(
    "items",
    [[], [1], [1, 1, 1], [3, 1, 3, 2, 1], list(range(20)), BIG_ITEMS],
    ids=["空", "单个", "全重复", "混合", "无重复", "大输入"],
)
def test_fast_unique_matches_slow(items: list[int]) -> None:
    assert fast_unique(items) == slow_unique(items)


@pytest.mark.parametrize("n", [0, 1, 3, 100], ids=["0", "1", "3", "100"])
def test_fast_join_matches_slow(n: int) -> None:
    assert fast_join(n) == slow_join(n)


@pytest.mark.parametrize(
    ("pattern", "texts"),
    [(r"\d+", ["a1", "bb", "c3"]), (r"^no", TEXTS[:50]), (r"zzz", ["a", "b"])],
    ids=["数字", "行首", "无命中"],
)
def test_fast_count_matches_matches_slow(pattern: str, texts: list[str]) -> None:
    assert fast_count_matches(pattern, texts) == slow_count_matches(pattern, texts)


@pytest.mark.parametrize("n", [0, 1, 2, 10, 25], ids=["0", "1", "2", "10", "25"])
def test_fast_fib_matches_slow(n: int) -> None:
    assert fast_fib(n) == slow_fib(n)


def test_fast_fib_handles_big_n() -> None:
    assert fast_fib(60) == 1548008755920


def test_fast_unique_not_slower_than_slow() -> None:
    slow = benchmark(slow_unique, BIG_ITEMS, repeat=3)
    fast = benchmark(fast_unique, BIG_ITEMS, repeat=3)
    assert fast <= slow


def test_fast_join_not_slower_than_slow() -> None:
    slow = benchmark(slow_join, 20000, repeat=3)
    fast = benchmark(fast_join, 20000, repeat=3)
    assert fast <= slow


def test_fast_count_matches_not_slower_than_slow() -> None:
    slow = benchmark(slow_count_matches, r"\d+", TEXTS, repeat=3)
    fast = benchmark(fast_count_matches, r"\d+", TEXTS, repeat=3)
    assert fast <= slow


def test_fast_fib_not_slower_than_slow() -> None:
    slow = benchmark(slow_fib, 22, repeat=3)
    fast = benchmark(fast_fib, 22, repeat=3)
    assert fast <= slow


def test_benchmark_returns_positive_seconds() -> None:
    elapsed = benchmark(slow_fib, 15, repeat=2)
    assert 0 < elapsed < 5


def test_benchmark_rejects_non_positive_repeat() -> None:
    with pytest.raises(ValueError, match="repeat 必须为正"):
        benchmark(slow_fib, 5, repeat=0)


def test_profile_top_lists_hot_functions() -> None:
    names = profile_top(lambda: slow_fib(18), n=3)
    assert len(names) == 3
    assert all(isinstance(name, str) for name in names)
    assert "slow_fib" in names


def test_peak_memory_grows_with_allocation() -> None:
    small = peak_memory(lambda: [0] * 1000)
    big = peak_memory(lambda: [0] * 1_000_000)
    assert big > small
    assert big > 1_000_000

test_w3_05_asyncio_advanced.py

exercises/week3/test_w3_05_asyncio_advanced.py
"""Week 3 · M14b · w3_05 的测试(已写好,不要改)

用法:uv run pytest exercises/week3/test_w3_05_asyncio_advanced.py -q
说明:全部离线;"慢请求"用 asyncio.sleep 模拟,超时/取消都是真的走 asyncio 机制。
"""

import asyncio
import time

import pytest
from w3_05_asyncio_advanced import (
    aticker,
    fetch_all_with_timeout,
    first_completed,
    gather_with_limit,
    producer_consumer,
    retry_async,
    run_group_collect_errors,
    timed_section,
)


async def work(key: str, delay: float = 0.01) -> str:
    """成功的假请求。"""
    await asyncio.sleep(delay)
    return f"{key}:ok"


async def boom(delay: float = 0.05) -> str:
    """失败的假请求(比成功的慢一点,好让 TaskGroup 的行为确定)。"""
    await asyncio.sleep(delay)
    raise ValueError("模拟失败")


async def test_gather_with_limit_keeps_order() -> None:
    got = await gather_with_limit([work("a"), work("b"), work("c")], limit=2)
    assert got == ["a:ok", "b:ok", "c:ok"]


async def test_gather_with_limit_actually_limits_concurrency() -> None:
    coros = [work(str(i), 0.05) for i in range(4)]
    start = time.perf_counter()
    await gather_with_limit(coros, limit=2)
    elapsed = time.perf_counter() - start
    assert elapsed >= 0.09
    assert elapsed < 0.19


async def test_gather_with_limit_rejects_bad_limit() -> None:
    coros = [work("a")]
    with pytest.raises(ValueError, match="limit 必须为正"):
        await gather_with_limit(coros, limit=0)


async def test_fetch_all_with_timeout_marks_slow_keys_none() -> None:
    async def fetch(key: str) -> str:
        await asyncio.sleep(1.0 if key == "slow" else 0.01)
        return f"{key}:ok"

    got = await fetch_all_with_timeout(fetch, ["slow", "fast"], timeout=0.1)
    assert got == {"slow": None, "fast": "fast:ok"}


async def test_fetch_all_with_timeout_all_ok() -> None:
    async def fetch(key: str) -> str:
        await asyncio.sleep(0.01)
        return f"{key}:ok"

    got = await fetch_all_with_timeout(fetch, ["a", "b"], timeout=0.5)
    assert got == {"a": "a:ok", "b": "b:ok"}


async def test_producer_consumer_processes_every_item() -> None:
    got = await producer_consumer(5, 2)
    assert got == [f"item-{i}:done" for i in range(5)]


async def test_producer_consumer_with_zero_items() -> None:
    assert await producer_consumer(0, 2) == []


async def test_aticker_yields_count_values() -> None:
    got = [i async for i in aticker(0.01, 3)]
    assert got == [0, 1, 2]


async def test_aticker_takes_at_least_interval_times_count() -> None:
    start = time.perf_counter()
    async for _ in aticker(0.02, 3):
        pass
    assert time.perf_counter() - start >= 0.05


async def test_retry_async_succeeds_after_failures() -> None:
    calls = 0

    async def flaky() -> str:
        nonlocal calls
        calls += 1
        if calls < 3:
            raise RuntimeError("暂时失败")
        return "ok"

    assert await retry_async(flaky, attempts=3, backoff=0.001) == "ok"
    assert calls == 3


async def test_retry_async_raises_last_error() -> None:
    async def always_fail() -> str:
        raise RuntimeError("一直失败")

    with pytest.raises(RuntimeError, match="一直失败"):
        await retry_async(always_fail, attempts=2, backoff=0.001)


async def test_retry_async_rejects_bad_attempts() -> None:
    async def ok() -> str:
        return "ok"

    with pytest.raises(ValueError, match="attempts 必须为正"):
        await retry_async(ok, attempts=0)


async def test_first_completed_returns_fastest() -> None:
    start = time.perf_counter()
    got = await first_completed([work("slow", 1.0), work("fast", 0.01)])
    assert got == "fast:ok"
    assert time.perf_counter() - start < 0.5


async def test_first_completed_rejects_empty() -> None:
    with pytest.raises(ValueError, match="coros 不能为空"):
        await first_completed([])


async def test_timed_section_records_elapsed() -> None:
    log: list[tuple[str, float]] = []
    async with timed_section("fetch", log):
        await asyncio.sleep(0.03)
    assert len(log) == 1
    label, elapsed = log[0]
    assert label == "fetch"
    assert elapsed >= 0.03


async def test_timed_section_records_even_on_error() -> None:
    log: list[tuple[str, float]] = []
    with pytest.raises(RuntimeError):
        async with timed_section("bad", log):
            raise RuntimeError("炸了")
    assert [label for label, _ in log] == ["bad"]


async def test_run_group_collect_errors_all_ok() -> None:
    results, errors = await run_group_collect_errors([work("a"), work("b")])
    assert sorted(results) == ["a:ok", "b:ok"]
    assert errors == []


async def test_run_group_collect_errors_collects_failure() -> None:
    coros = [work("a", 0.01), work("b", 0.01), boom(0.05)]
    results, errors = await run_group_collect_errors(coros)
    assert sorted(results) == ["a:ok", "b:ok"]
    assert len(errors) == 1
    assert isinstance(errors[0], ValueError)

test_w3_06_httpx.py

exercises/week3/test_w3_06_httpx.py
"""Week 3 · M15 · w3_06 的测试(已写好,不要改)

用法:uv run pytest exercises/week3/test_w3_06_httpx.py -q
说明:全部用 httpx.MockTransport(handler) 假装服务器,**一个字节都不走网络**。
      handler 是个普通函数:收到 httpx.Request,返回 httpx.Response。
"""

import asyncio
import time
from pathlib import Path

import httpx
import pytest
from w3_06_httpx import (
    classify_error,
    download,
    fetch_many,
    get_json,
    get_json_retrying,
    make_client,
    stream_lines,
)

BASE_URL = "http://stub.local"


def handler(request: httpx.Request) -> httpx.Response:
    """假服务器:按路径返回不同响应。"""
    path = request.url.path
    if path.startswith("/items/"):
        item_id = path.rsplit("/", 1)[-1]
        return httpx.Response(200, json={"id": int(item_id), "name": f"物品{item_id}"})
    if path == "/lines":
        return httpx.Response(200, text="第一行\n\n第二行\n")
    if path == "/big.bin":
        return httpx.Response(200, content=b"x" * 1024)
    if path == "/not-found":
        return httpx.Response(404, json={"detail": "没有这个东西"})
    if path == "/boom":
        return httpx.Response(500, text="服务器内部错误")
    if path == "/slow":
        raise httpx.ReadTimeout("读超时", request=request)
    return httpx.Response(404, text="unknown stub path")


def make_test_client() -> httpx.Client:
    """给同步测试用的客户端(MockTransport 版)。"""
    return make_client(BASE_URL, timeout=1.0, transport=httpx.MockTransport(handler))


def make_async_test_client() -> httpx.AsyncClient:
    """给异步测试用的客户端(同一个 handler,MockTransport 两边都能用)。"""
    return httpx.AsyncClient(
        base_url=BASE_URL,
        timeout=httpx.Timeout(1.0),
        transport=httpx.MockTransport(handler),
    )


def test_make_client_sets_base_url_timeout_and_headers() -> None:
    with make_test_client() as client:
        assert str(client.base_url) == BASE_URL
        assert client.timeout.read == 1.0
        assert client.headers["user-agent"] == "aggregator-exercise/1.0"


def test_get_json_returns_parsed_body() -> None:
    with make_test_client() as client:
        assert get_json(client, "/items/7") == {"id": 7, "name": "物品7"}


def test_get_json_raises_on_404() -> None:
    with make_test_client() as client, pytest.raises(httpx.HTTPStatusError):
        get_json(client, "/not-found")


def test_get_json_raises_on_500() -> None:
    with make_test_client() as client, pytest.raises(httpx.HTTPStatusError):
        get_json(client, "/boom")


async def test_fetch_many_returns_results_and_errors() -> None:
    async with make_async_test_client() as aclient:
        got = await fetch_many(aclient, ["/items/1", "/items/2", "/not-found"], limit=2)
    assert got["/items/1"] == {"id": 1, "name": "物品1"}
    assert got["/items/2"] == {"id": 2, "name": "物品2"}
    assert isinstance(got["/not-found"], httpx.HTTPStatusError)


async def test_fetch_many_handles_empty_paths() -> None:
    async with make_async_test_client() as aclient:
        assert await fetch_many(aclient, [], limit=2) == {}


async def test_fetch_many_is_concurrent() -> None:
    async def slow_handler(request: httpx.Request) -> httpx.Response:
        await asyncio.sleep(0.02)
        return httpx.Response(200, json={"path": request.url.path})

    async with httpx.AsyncClient(
        base_url=BASE_URL, transport=httpx.MockTransport(slow_handler)
    ) as aclient:
        paths = [f"/items/{i}" for i in range(8)]
        start = time.perf_counter()
        got = await fetch_many(aclient, paths, limit=8)
    assert len(got) == 8
    assert time.perf_counter() - start < 8 * 0.02


async def test_stream_lines_skips_blank_lines() -> None:
    async with make_async_test_client() as aclient:
        assert await stream_lines(aclient, "/lines") == ["第一行", "第二行"]


async def test_stream_lines_raises_for_status() -> None:
    async with make_async_test_client() as aclient:
        with pytest.raises(httpx.HTTPStatusError):
            await stream_lines(aclient, "/boom")


def test_get_json_retrying_recovers_after_two_503() -> None:
    calls = 0

    def flaky(request: httpx.Request) -> httpx.Response:
        nonlocal calls
        calls += 1
        if calls < 3:
            return httpx.Response(503, text="服务暂时不可用")
        return httpx.Response(200, json={"ok": True, "calls": calls})

    with httpx.Client(
        base_url=BASE_URL, transport=httpx.MockTransport(flaky)
    ) as client:
        assert get_json_retrying(client, "/items/1", attempts=3) == {
            "ok": True,
            "calls": 3,
        }
    assert calls == 3


def test_get_json_retrying_gives_up_and_raises() -> None:
    calls = 0

    def always_503(request: httpx.Request) -> httpx.Response:
        nonlocal calls
        calls += 1
        return httpx.Response(503, text="一直不可用")

    with httpx.Client(
        base_url=BASE_URL, transport=httpx.MockTransport(always_503)
    ) as client:
        with pytest.raises(httpx.HTTPStatusError):
            get_json_retrying(client, "/items/1", attempts=3)
    assert calls == 3


def test_get_json_retrying_does_not_retry_404() -> None:
    calls = 0

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

    with httpx.Client(
        base_url=BASE_URL, transport=httpx.MockTransport(not_found)
    ) as client:
        with pytest.raises(httpx.HTTPStatusError):
            get_json_retrying(client, "/items/1", attempts=3)
    assert calls == 1


def test_download_writes_file_and_returns_size(tmp_path: Path) -> None:
    dest = tmp_path / "big.bin"
    with make_test_client() as client:
        size = download(client, "/big.bin", dest)
    assert size == 1024
    assert dest.stat().st_size == 1024


def test_download_raises_for_status(tmp_path: Path) -> None:
    with make_test_client() as client, pytest.raises(httpx.HTTPStatusError):
        download(client, "/boom", tmp_path / "nope.bin")


@pytest.mark.parametrize(
    ("exc", "expected"),
    [
        (httpx.ConnectTimeout("超时"), "timeout"),
        (httpx.ReadTimeout("超时"), "timeout"),
        (httpx.ConnectError("连不上"), "connect"),
        (httpx.RequestError("其它网络问题"), "network"),
        (
            httpx.HTTPStatusError(
                "404",
                request=httpx.Request("GET", BASE_URL),
                response=httpx.Response(404),
            ),
            "http_4xx",
        ),
        (
            httpx.HTTPStatusError(
                "500",
                request=httpx.Request("GET", BASE_URL),
                response=httpx.Response(500),
            ),
            "http_5xx",
        ),
        (ValueError("别的错"), "unknown"),
    ],
    ids=["连接超时", "读超时", "连接失败", "网络", "4xx", "5xx", "其他"],
)
def test_classify_error(exc: Exception, expected: str) -> None:
    assert classify_error(exc) == expected


def test_timeout_from_transport_is_classified() -> None:
    with make_test_client() as client:
        try:
            get_json(client, "/slow")
        except Exception as exc:
            assert classify_error(exc) == "timeout"
        else:
            raise AssertionError("应该抛超时异常")

w3_01_pytest_advanced/conftest.py

exercises/week3/w3_01_pytest_advanced/conftest.py
"""Week 3 · M12 · 共享 fixture(给你一个范例,其余自己加)

学习目标:知道 conftest.py 里的 fixture 不用 import 就能被同目录测试用;
      会写"假实现"替代注入的依赖,会用 yield 做清理。
用法:不要直接运行本文件;它会被 pytest 自动加载。
做法:照 fixed_clock 的样子,把你常用的假对象(比如 FakePrices、装好货的 Inventory)
      也提成 fixture,测试里直接写在参数列表里就能拿到。
"""

import pytest


class FixedClock:
    """假时钟:now() 永远返回同一个字符串,于是断言时间戳变得可能。"""

    def __init__(self, stamp: str = "2026-09-21T09:00:00+00:00") -> None:
        self.stamp = stamp
        self.calls = 0

    def now(self) -> str:
        """返回固定时间,并记录被问过几次。"""
        self.calls += 1
        return self.stamp


@pytest.fixture
def fixed_clock() -> FixedClock:
    """范例 fixture:默认 function 作用域,每个测试拿到全新的一份。

    想改作用域就写 @pytest.fixture(scope="module");
    需要清理就把 return 换成 yield,yield 之后的代码在测试结束后执行。
    """
    return FixedClock()

w3_01_pytest_advanced/inventory.py

exercises/week3/w3_01_pytest_advanced/inventory.py
"""Week 3 · M12 · pytest 进阶:被测代码(完整实现,不要改)

学习目标:读懂一段有依赖注入(Clock / PriceService 两个 Protocol)的业务代码,
      为它设计"正常 / 边界 / 错误 / 副作用"四类测试用例。
用法:uv run pytest exercises/week3/w3_01_pytest_advanced -q
      看覆盖率再加:--cov=inventory --cov-report=term-missing
做法:本文件是题面,已经写完,一行都不要改;
      要写的是同目录的 test_inventory.py(≥12 个测试,覆盖率 ≥95%)。
      看不懂某个方法就先在 REPL 里手动调用它,观察返回值和异常。
"""

import csv
import json
import logging
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Protocol

logger = logging.getLogger(__name__)

DEFAULT_LOW_STOCK = 3


class Clock(Protocol):
    """时钟协议:有 now() 就能塞进 Inventory(测试里换成返回固定字符串的假时钟)。"""

    def now(self) -> str:
        """返回 ISO 8601 格式的时间字符串。"""
        ...


class PriceService(Protocol):
    """价格协议:真实实现要查数据库或打网络,测试里用假对象 / mock 替换。"""

    def price_of(self, sku: str) -> float:
        """返回单价;未知 sku 抛 KeyError。"""
        ...


class SystemClock:
    """生产环境用的真时钟。"""

    def now(self) -> str:
        """当前 UTC 时间,秒精度。例:2026-09-21T01:02:03+00:00"""
        return datetime.now(UTC).isoformat(timespec="seconds")


@dataclass(frozen=True, slots=True)
class Item:
    """一条库存记录:库存量与最后一次变动时间。"""

    sku: str
    quantity: int
    updated_at: str


class Inventory:
    """库存管理:加货、出货、估值、低库存预警、导出。

    时钟与价格都从构造函数注入,所以测试不需要联网也不需要等真实时间。
    例:
        inv = Inventory(clock=FixedClock("2026-09-21T09:00:00"), prices=FakePrices())
        inv.add("pen", 10)
        inv.total_value()
    """

    def __init__(
        self,
        clock: Clock,
        prices: PriceService,
        low_stock_threshold: int = DEFAULT_LOW_STOCK,
    ) -> None:
        self._clock = clock
        self._prices = prices
        self._threshold = low_stock_threshold
        self._items: dict[str, Item] = {}

    def __len__(self) -> int:
        """当前有几种 sku。"""
        return len(self._items)

    def get(self, sku: str) -> Item | None:
        """查一条记录,没有返回 None。"""
        return self._items.get(sku)

    def add(self, sku: str, quantity: int) -> Item:
        """加货:新 sku 直接建,老 sku 累加,返回加完之后的记录。

        sku 首尾空白会被去掉;空 sku 抛 ValueError("sku 不能为空");
        quantity <= 0 抛 ValueError("quantity 必须为正,收到 ...")。
        例:add("pen", 3) 两次 -> Item(sku="pen", quantity=6, ...)
        """
        sku = sku.strip()
        if not sku:
            raise ValueError("sku 不能为空")
        if quantity <= 0:
            raise ValueError(f"quantity 必须为正,收到 {quantity}")
        current = self._items.get(sku)
        total = quantity if current is None else current.quantity + quantity
        item = Item(sku=sku, quantity=total, updated_at=self._clock.now())
        self._items[sku] = item
        return item

    def remove(self, sku: str, quantity: int) -> Item:
        """出货:返回出完之后的记录;剩余量 <= 阈值时打一条 WARNING 日志。

        quantity <= 0 抛 ValueError;未知 sku 抛 KeyError("未知 sku:...");
        库存不足抛 ValueError("... 库存不足:现有 x,要出 y")。
        例:add("pen", 5) 后 remove("pen", 3) -> Item(quantity=2),并记一条 WARNING
        """
        if quantity <= 0:
            raise ValueError(f"quantity 必须为正,收到 {quantity}")
        current = self._items.get(sku)
        if current is None:
            raise KeyError(f"未知 sku:{sku}")
        if quantity > current.quantity:
            raise ValueError(
                f"{sku} 库存不足:现有 {current.quantity},要出 {quantity}"
            )
        left = current.quantity - quantity
        item = Item(sku=sku, quantity=left, updated_at=self._clock.now())
        self._items[sku] = item
        if left <= self._threshold:
            logger.warning("库存偏低:%s 只剩 %d 件", sku, left)
        return item

    def total_value(self) -> float:
        """总市值 = Σ 库存量 × 单价,保留 2 位小数;空库存为 0.0。

        单价来自注入的 PriceService,所以测试里可以用 mock 断言"每个 sku 各问了一次"。
        例:pen 单价 2.5、10 支 -> 25.0
        """
        total = 0.0
        for item in self._items.values():
            total += item.quantity * self._prices.price_of(item.sku)
        return round(total, 2)

    def low_stock(self) -> list[str]:
        """库存量 <= 阈值的 sku 列表,按字母排序。例:["ink", "pen"]"""
        return sorted(
            sku for sku, item in self._items.items() if item.quantity <= self._threshold
        )

    def export(self, path: Path) -> int:
        """把库存写到 path,返回写出的记录条数。

        后缀 .json 写 JSON 数组,.csv 写带表头的 CSV(列:sku,quantity,updated_at);
        其他后缀抛 ValueError("不支持的导出格式:...")。记录按 sku 排序。
        例:export(tmp_path / "stock.csv") -> 2
        """
        rows = [asdict(self._items[sku]) for sku in sorted(self._items)]
        suffix = path.suffix.lower()
        if suffix == ".json":
            path.write_text(
                json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8"
            )
        elif suffix == ".csv":
            with path.open("w", encoding="utf-8", newline="") as f:
                writer = csv.DictWriter(f, fieldnames=["sku", "quantity", "updated_at"])
                writer.writeheader()
                writer.writerows(rows)
        else:
            raise ValueError(f"不支持的导出格式:{path.suffix}")
        return len(rows)

w3_01_pytest_advanced/test_inventory.py

exercises/week3/w3_01_pytest_advanced/test_inventory.py
"""Week 3 · M12 · 反向练习:只给测试名,你来写测试

学习目标:会用 fixture(含 conftest.py 里的 fixed_clock)、parametrize(带 ids)、
      pytest.raises(match=)、pytest.approx、tmp_path、caplog、mocker/MagicMock(spec=),
      并用覆盖率报告找出自己漏测的行。
用法:uv run pytest exercises/week3/w3_01_pytest_advanced -q
      看覆盖率再加:--cov=inventory --cov-report=term-missing
做法:inventory.py 一行都不要改。把每个函数体的 TODO 换成真正的断言
      (删掉 assert False 那行),需要的假对象自己写或提到 conftest.py 里。
      验收:全部通过 + inventory 覆盖率 ≥95%(Protocol 里两行 ... 允许不覆盖)。
      提示:先跑一次带 --cov-report=term-missing 的命令,照 Missing 那列补测试。
"""

from pathlib import Path


def test_add_new_sku_records_quantity_and_timestamp() -> None:
    """新 sku 加货后:quantity 等于加入量,updated_at 等于注入时钟返回的值。"""
    # TODO: 用 fixed_clock fixture + 一个假 PriceService 造 Inventory,再断言
    assert False, "TODO"  # noqa: B011


def test_add_existing_sku_accumulates() -> None:
    """同一个 sku 连续 add 两次,数量应累加(3 + 4 = 7),只占一种 sku。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_add_strips_whitespace_in_sku() -> None:
    """add(" pen ") 与 add("pen") 应视为同一个 sku。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_add_rejects_blank_sku() -> None:
    """空白 sku 抛 ValueError,用 pytest.raises(ValueError, match="sku 不能为空")。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_add_rejects_non_positive_quantity() -> None:
    """quantity 为 0 和 -1 都要抛 ValueError:用 @pytest.mark.parametrize 覆盖两种。"""
    # TODO: 在这里实现(记得给 parametrize 加 ids=)
    assert False, "TODO"  # noqa: B011


def test_remove_reduces_quantity() -> None:
    """add 10 再 remove 4,剩 6,且 get() 拿到的是新记录。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_remove_unknown_sku_raises_key_error() -> None:
    """出一个不存在的 sku:KeyError,消息里带 "未知 sku"。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_remove_more_than_stock_raises_value_error() -> None:
    """库存不足时抛 ValueError,且库存保持原样(异常后状态不能被改坏)。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_remove_rejects_non_positive_quantity() -> None:
    """remove 的 quantity <= 0 也要抛 ValueError。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_remove_logs_warning_when_stock_gets_low() -> None:
    """剩余量 <= 阈值时记一条 WARNING:用 caplog 断言级别与内容。"""
    # TODO: 在这里实现(需要 caplog.set_level(logging.WARNING))
    assert False, "TODO"  # noqa: B011


def test_remove_does_not_log_when_stock_is_healthy() -> None:
    """剩余量高于阈值时不应有任何 WARNING(反面用例,防止日志乱报)。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_total_value_uses_injected_prices() -> None:
    """总市值 = Σ 量 × 价,用 pytest.approx 比浮点数。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_total_value_of_empty_inventory_is_zero() -> None:
    """空库存的总市值是 0.0,而且一次也不该去问价格服务。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_total_value_asks_price_service_once_per_sku() -> None:
    """用 mocker.Mock(spec=PriceService) 或 MagicMock 断言每个 sku 各查一次价。"""
    # TODO: 在这里实现(pytest-mock 的 mocker fixture 或 unittest.mock.MagicMock)
    assert False, "TODO"  # noqa: B011


def test_low_stock_returns_sorted_skus() -> None:
    """low_stock() 只列出 <= 阈值的 sku,并且按字母排序。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_low_stock_threshold_is_configurable() -> None:
    """构造时传 low_stock_threshold=5,边界值(正好等于 5)也算低库存。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_export_json_writes_all_items(tmp_path: Path) -> None:
    """导出 .json 到 tmp_path:返回条数正确,读回来的内容按 sku 排序。"""
    # TODO: 在这里实现(tmp_path 是 pytest 给每个测试单独建的临时目录)
    assert False, "TODO"  # noqa: B011


def test_export_csv_has_header_row(tmp_path: Path) -> None:
    """导出 .csv:第一行是表头 sku,quantity,updated_at,共 1 + n 行。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_export_rejects_unsupported_suffix(tmp_path: Path) -> None:
    """.txt 之类的后缀抛 ValueError("不支持的导出格式:..."),且不留下文件。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_system_clock_returns_iso_string() -> None:
    """SystemClock().now() 能被 datetime.fromisoformat 解析(覆盖真实时钟那一行)。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011


def test_len_and_get_reflect_current_state() -> None:
    """len(inv) 是 sku 种类数;get() 查不到时返回 None。"""
    # TODO: 在这里实现
    assert False, "TODO"  # noqa: B011

w3_02_threads_processes_subprocess.py

exercises/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 sys
import threading
from collections.abc import Callable, Iterable
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"}
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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)
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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)
    raise NotImplementedError


# ---- 任务 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) 然后求和)
    raise NotImplementedError


# ---- 任务 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)
    raise NotImplementedError


# ---- 任务 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())
    raise NotImplementedError


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

    结束方式:生产者放完后放一个哨兵值(比如 None),消费者见到哨兵就退出。
    返回消费者按拿到顺序处理出的结果列表。
    例:producer_consumer_threads(["a", "b"]) -> ["A", "B"]
    """
    # TODO: 在这里实现
    raise NotImplementedError

w3_03_asyncio_basics.py

exercises/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())
"""

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"
    """
    # TODO: 在这里实现
    raise NotImplementedError


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

    gather 会把所有协程一起调度;返回值顺序按传入顺序(不是完成顺序)。
    例:await gather_all(["a", "b"]) -> ["a:ok", "b:ok"]
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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"]
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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"]
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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..)
    """
    # TODO: 在这里实现
    raise NotImplementedError

w3_04_profiling.py

exercises/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 re
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]
    """
    # TODO: 在这里实现
    raise NotImplementedError


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

    例:fast_join(3) -> "0,1,2"
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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
    """
    # TODO: 在这里实现
    raise NotImplementedError


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

    例:fast_fib(30) -> 832040(要在毫秒级返回)
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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)
    raise NotImplementedError


# ---- 任务 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", ...]
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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
    """
    # TODO: 在这里实现
    raise NotImplementedError

w3_05_asyncio_advanced.py

exercises/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 的模板提问;拿到提示后自己重写。
"""

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 秒
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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"}
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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"]
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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 列表)
    raise NotImplementedError


# ---- 任务 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、第三次成功 -> 返回第三次的结果
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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 秒
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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 里记录)
    raise NotImplementedError


# ---- 任务 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(...)])
    """
    # TODO: 在这里实现
    raise NotImplementedError

w3_06_httpx.py

exercises/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 的模板提问;拿到提示后自己重写。
"""

from pathlib import Path
from typing import Any

import httpx


# ---- 任务 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"
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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, ...}
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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(...)}
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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") -> ["第一行", "第二行"]
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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 次
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 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"
    """
    # TODO: 在这里实现
    raise NotImplementedError