跳转至

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

怎么用

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

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

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

project_pipeline/pipeline.py

exercises/week1/project_pipeline/pipeline.py
"""Week 1 小项目 · 文本处理管线(骨架)

用生成器管线统计一个目录下全部 .md 文件的词频、标题数、代码块数。
用法:uv run python exercises/week1/project_pipeline/pipeline.py <目录> [--top 20]
测试:uv run pytest exercises/week1/project_pipeline -q
做法:Registry/Timer/@timer/Pipeline 已给出;
      实现 7 个 TODO(5 个步骤 + summarize + Pipeline.stream)。

设计要点:
- 每个步骤都是"接收可迭代、产出迭代器"的生成器函数,用 @register_step 注册;
- 出错的文件不中断管线:Record.error 记录原因,后续步骤跳过它,summarize 统计错误数;
- Pipeline 按名字从注册表取步骤并串起来;Timer/@timer 负责计时。
"""

from __future__ import annotations

import argparse
import functools
import re
import time
from collections import Counter  # noqa: F401  (summarize 会用到)
from collections.abc import Callable, Iterable, Iterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Protocol

# ---------- 数据模型 ----------


@dataclass
class Record:
    """管线里流动的一条记录:一个文件。"""

    path: Path
    text: str = ""
    words: list[str] = field(default_factory=list)
    headings: int = 0
    code_blocks: int = 0
    error: str | None = None


@dataclass(frozen=True)
class Stats:
    """最终统计结果(不可变)。"""

    files: int
    errors: int
    headings: int
    code_blocks: int
    top_words: tuple[tuple[str, int], ...]


class PipelineError(Exception):
    """管线内部错误的基类。"""


# ---------- 注册表 ----------


class Step(Protocol):
    """一个步骤:接收可迭代对象,返回迭代器(通常是生成器函数)。"""

    def __call__(self, items: Iterable[Any]) -> Iterator[Any]: ...


class Registry:
    """名字 → 步骤 的注册表;`@registry.register("name")` 或 `@registry.register`。"""

    def __init__(self) -> None:
        self._steps: dict[str, Step] = {}

    def register(self, name: str | Callable | None = None) -> Any:
        def decorator(fn: Step) -> Step:
            key = fn.__name__ if name is None or callable(name) else name
            if key in self._steps:
                raise ValueError(f"步骤重名:{key}")
            self._steps[key] = fn
            return fn

        if callable(name):  # 不带括号使用:@registry.register
            return decorator(name)
        return decorator

    def get(self, name: str) -> Step:
        try:
            return self._steps[name]
        except KeyError as e:
            raise PipelineError(f"未知步骤:{name}") from e

    def names(self) -> list[str]:
        return list(self._steps)


registry = Registry()
register_step = registry.register

# ---------- 计时 ----------


class Timer:
    """上下文管理器:`with Timer() as t: ...; t.elapsed`。"""

    def __enter__(self) -> Timer:
        self._start = time.perf_counter()
        self.elapsed = 0.0
        return self

    def __exit__(self, *exc: object) -> None:
        self.elapsed = time.perf_counter() - self._start


def timer[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    """装饰器:把耗时(秒)记到 fn.last_elapsed 上。"""

    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        with Timer() as t:
            result = fn(*args, **kwargs)
        wrapper.last_elapsed = t.elapsed  # type: ignore[attr-defined]
        return result

    wrapper.last_elapsed = 0.0  # type: ignore[attr-defined]
    return wrapper


# ---------- 步骤 ----------

CODE_BLOCK = re.compile(r"```.*?```", re.DOTALL)
HEADING = re.compile(r"^#{1,6}\s+\S", re.MULTILINE)
WORD = re.compile(r"[A-Za-z][A-Za-z'-]+|[\u4e00-\u9fff]+")


def iter_markdown(root: Path) -> Iterator[Path]:
    """惰性遍历目录下全部 .md 文件(按路径排序保证可重复)。"""
    yield from sorted(p for p in root.rglob("*.md") if p.is_file())


@register_step("read_files")
def read_files(paths: Iterable[Path]) -> Iterator[Record]:
    """读文件成 Record;读不了的文件不中断,记入 Record.error。"""
    # TODO: 在这里实现
    raise NotImplementedError


@register_step("strip_code_blocks")
def strip_code_blocks(records: Iterable[Record]) -> Iterator[Record]:
    """统计并删除 ``` 代码块,避免代码里的词污染词频。"""
    # TODO: 在这里实现
    raise NotImplementedError


@register_step("count_headings")
def count_headings(records: Iterable[Record]) -> Iterator[Record]:
    """统计 Markdown 标题行数。"""
    # TODO: 在这里实现
    raise NotImplementedError


@register_step("split_words")
def split_words(records: Iterable[Record]) -> Iterator[Record]:
    """切词:英文按单词、中文按连续汉字串。"""
    # TODO: 在这里实现
    raise NotImplementedError


@register_step("normalize")
def normalize(records: Iterable[Record]) -> Iterator[Record]:
    """小写化并去掉 1 个字符的词。"""
    # TODO: 在这里实现
    raise NotImplementedError


def summarize(records: Iterable[Record], top: int = 20) -> Stats:
    """终点:把记录流聚合成 Stats(这一步会消耗整个迭代器)。"""
    # TODO: 在这里实现
    raise NotImplementedError


# ---------- 管线 ----------

DEFAULT_STEPS = [
    "read_files",
    "strip_code_blocks",
    "count_headings",
    "split_words",
    "normalize",
]


class Pipeline:
    """按名字串起若干步骤:source → step1 → step2 → ... → summarize。"""

    def __init__(
        self, steps: Iterable[str] = DEFAULT_STEPS, registry: Registry = registry
    ) -> None:
        self.step_names = list(steps)
        self._steps = [registry.get(name) for name in self.step_names]

    def __len__(self) -> int:
        return len(self._steps)

    def __iter__(self) -> Iterator[str]:
        return iter(self.step_names)

    def __repr__(self) -> str:
        return f"Pipeline({' -> '.join(self.step_names)})"

    def stream(self, source: Iterable[Any]) -> Iterator[Any]:
        """只串联步骤、不聚合,仍是惰性的。"""
        # TODO: 在这里实现
        raise NotImplementedError

    @timer
    def run(self, source: Iterable[Any], top: int = 20) -> Stats:
        return summarize(self.stream(source), top=top)


def format_report(stats: Stats, elapsed: float) -> str:
    head = f"文件 {stats.files}(失败 {stats.errors})"
    head += f"  标题 {stats.headings}  代码块 {stats.code_blocks}"
    lines = [head, f"耗时 {elapsed * 1000:.1f} ms", "词频:"]
    lines += [f"  {w:<20}{n:>6}" for w, n in stats.top_words]
    return "\n".join(lines)


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="统计目录下 Markdown 的词频/标题/代码块"
    )
    parser.add_argument("root", type=Path)
    parser.add_argument("--top", type=int, default=20)
    args = parser.parse_args(argv)
    if not args.root.is_dir():
        parser.error(f"{args.root} 不是目录")
    pipeline = Pipeline()
    stats = pipeline.run(iter_markdown(args.root), top=args.top)
    elapsed: float = pipeline.run.last_elapsed  # type: ignore[attr-defined]
    print(format_report(stats, elapsed))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

project_pipeline/test_pipeline.py

exercises/week1/project_pipeline/test_pipeline.py
"""文本处理管线的测试。运行:uv run pytest exercises/week1/project_pipeline -q"""

import itertools
from pathlib import Path

import pytest
from pipeline import (
    Pipeline,
    PipelineError,
    Record,
    Registry,
    Stats,
    Timer,
    count_headings,
    iter_markdown,
    normalize,
    read_files,
    split_words,
    strip_code_blocks,
    summarize,
)


@pytest.fixture
def docs(tmp_path: Path) -> Path:
    (tmp_path / "a.md").write_text(
        "# 标题一\n\nPython python 很好用。\n", encoding="utf-8"
    )
    (tmp_path / "sub").mkdir()
    (tmp_path / "sub" / "b.md").write_text(
        "## 标题二\n```python\nprint('code words here')\n```\n"
        "### 标题三\nhello Hello world\n",
        encoding="utf-8",
    )
    (tmp_path / "c.txt").write_text("不是 markdown", encoding="utf-8")
    (tmp_path / "bad.md").write_bytes(b"\xff\xfe\xfa bad bytes")
    return tmp_path


def test_iter_markdown_only_md_sorted(docs: Path):
    names = [p.name for p in iter_markdown(docs)]
    assert names == ["a.md", "bad.md", "b.md"]


def test_read_files_records_errors_instead_of_raising(docs: Path):
    records = list(read_files(iter_markdown(docs)))
    by_name = {r.path.name: r for r in records}
    assert by_name["a.md"].text.startswith("# 标题一")
    assert (
        by_name["bad.md"].error is not None
        and "UnicodeDecodeError" in by_name["bad.md"].error
    )


def test_steps_are_lazy():
    counter = itertools.count()

    def infinite_records():
        for i in counter:
            yield Record(path=Path(f"{i}.md"), text="# h\nword word")

    stream = normalize(
        split_words(count_headings(strip_code_blocks(infinite_records())))
    )
    first = next(stream)
    assert first.headings == 1 and first.words == ["word", "word"]
    assert next(counter) < 5  # 没有把无限源耗尽


def test_strip_code_blocks_and_headings(docs: Path):
    records = list(count_headings(strip_code_blocks(read_files(iter_markdown(docs)))))
    b = next(r for r in records if r.path.name == "b.md")
    assert b.code_blocks == 1 and "print" not in b.text
    assert b.headings == 2


def test_split_and_normalize():
    rec = Record(path=Path("x.md"), text="Python python A 很好用 hello-world")
    (out,) = list(normalize(split_words(iter([rec]))))
    assert out.words == ["python", "python", "很好用", "hello-world"]


def test_registry_rejects_duplicates_and_unknown():
    reg = Registry()

    @reg.register("s")
    def s(items):
        yield from items

    with pytest.raises(ValueError):
        reg.register("s")(s)
    with pytest.raises(PipelineError):
        reg.get("nope")
    assert reg.names() == ["s"]


def test_pipeline_run_returns_stats(docs: Path):
    p = Pipeline()
    assert len(p) == 5 and list(p)[0] == "read_files" and "read_files" in repr(p)
    stats = p.run(iter_markdown(docs), top=3)
    assert isinstance(stats, Stats)
    assert stats.files == 3 and stats.errors == 1
    assert stats.headings == 3 and stats.code_blocks == 1
    assert stats.top_words[0] in {("python", 2), ("hello", 2)}
    assert p.run.last_elapsed >= 0


def test_summarize_and_timer():
    stats = summarize(iter([Record(path=Path("x"), words=["a", "a", "b"])]))
    assert stats.top_words == (("a", 2), ("b", 1))
    with Timer() as t:
        sum(range(1000))
    assert t.elapsed >= 0

test_w1_01_generators.py

exercises/week1/test_w1_01_generators.py
"""Week 1 · M1a · 生成器练习的测试(已写好,不要改)

用法:uv run pytest exercises/week1/test_w1_01_generators.py -q
读输出:. 通过,F 断言失败,E 测试里抛了异常(骨架还没实现时全是 E)。
惰性相关的测试都用 itertools.count() 这类无限迭代器:
如果你的实现先把输入读完,测试会挂住而不是失败——那也说明写错了。
"""

import inspect
from itertools import count, islice
from pathlib import Path

import pytest
from w1_01_generators import (
    Countdown,
    countdown,
    flatten,
    parse_records,
    read_chunks,
    running_mean,
    take,
)


def test_countdown_values() -> None:
    assert list(countdown(3)) == [3, 2, 1, 0]
    assert list(countdown(0)) == [0]


def test_countdown_is_generator() -> None:
    gen = countdown(3)
    assert inspect.isgenerator(gen)
    assert next(gen) == 3


def test_countdown_exhausted_after_one_pass() -> None:
    gen = countdown(2)
    assert list(gen) == [2, 1, 0]
    assert list(gen) == []


def test_read_chunks_joins_back(tmp_path: Path) -> None:
    text = "中文和 ASCII 混排\n第二行\n"
    path = tmp_path / "sample.txt"
    path.write_text(text, encoding="utf-8")
    assert "".join(read_chunks(path, 3)) == text


def test_read_chunks_sizes(tmp_path: Path) -> None:
    path = tmp_path / "abc.txt"
    path.write_text("abcde", encoding="utf-8")
    assert list(read_chunks(path, 2)) == ["ab", "cd", "e"]


def test_read_chunks_rejects_bad_size(tmp_path: Path) -> None:
    path = tmp_path / "abc.txt"
    path.write_text("abc", encoding="utf-8")
    with pytest.raises(ValueError):
        list(read_chunks(path, 0))


def test_parse_records_skips_blank_and_comments() -> None:
    lines = ["alice,90", "", "  ", "# 注释", "bob,80"]
    assert list(parse_records(lines)) == [
        {"name": "alice", "score": 90},
        {"name": "bob", "score": 80},
    ]


def test_parse_records_is_lazy() -> None:
    infinite = (f"user{i},{i}" for i in count())
    assert take(2, parse_records(infinite)) == [
        {"name": "user0", "score": 0},
        {"name": "user1", "score": 1},
    ]


def test_parse_records_reports_line_number() -> None:
    with pytest.raises(ValueError, match="3"):
        list(parse_records(["a,1", "b,2", "c,oops"]))


def test_parse_records_rejects_wrong_field_count() -> None:
    with pytest.raises(ValueError):
        list(parse_records(["a,1,2"]))


def test_take_finite() -> None:
    assert take(2, [1, 2, 3]) == [1, 2]
    assert take(10, [1, 2]) == [1, 2]


def test_take_infinite() -> None:
    assert take(3, count()) == [0, 1, 2]
    assert take(0, count()) == []


def test_take_rejects_negative() -> None:
    with pytest.raises(ValueError):
        take(-1, [1, 2])


def test_running_mean_values() -> None:
    assert list(running_mean([2, 4, 6])) == [2.0, 3.0, 4.0]
    assert list(running_mean([])) == []


def test_running_mean_is_lazy() -> None:
    assert list(islice(running_mean(count(1)), 3)) == [1.0, 1.5, 2.0]


def test_flatten_keeps_strings_whole() -> None:
    assert list(flatten([1, [2, [3, "ab"]]])) == [1, 2, 3, "ab"]


def test_flatten_tuples_and_empty() -> None:
    assert list(flatten([(1, 2), [], [[[3]]]])) == [1, 2, 3]
    assert list(flatten([])) == []


def test_countdown_class_iterates() -> None:
    assert list(Countdown(2)) == [2, 1, 0]
    collected: list[int] = []
    for value in Countdown(1):
        collected.append(value)
    assert collected == [1, 0]


def test_countdown_class_is_its_own_iterator() -> None:
    c = Countdown(1)
    assert iter(c) is c
    assert list(c) == [1, 0]
    assert list(c) == []

test_w1_02_itertools.py

exercises/week1/test_w1_02_itertools.py
"""Week 1 · M1b · itertools 练习的测试(已写好,不要改)

用法:uv run pytest exercises/week1/test_w1_02_itertools.py -q
惰性相关的测试传的是 itertools.count():先耗尽输入的实现会挂住。
"""

from itertools import count, islice, pairwise

import pytest
from w1_02_itertools import (
    chunked,
    cumulative_max,
    first_true,
    group_by_key,
    interleave,
    memory_report,
    top_k_pairs,
    window,
)


def test_chunked_full_and_partial() -> None:
    assert list(chunked([1, 2, 3, 4], 2)) == [(1, 2), (3, 4)]
    assert list(chunked([1, 2, 3], 2)) == [(1, 2), (3,)]
    assert list(chunked([], 2)) == []


def test_chunked_is_lazy() -> None:
    assert list(islice(chunked(count(), 3), 2)) == [(0, 1, 2), (3, 4, 5)]


def test_chunked_rejects_bad_size() -> None:
    with pytest.raises(ValueError):
        list(chunked([1, 2], 0))
    with pytest.raises(ValueError):
        list(chunked([1, 2], -3))


def test_window_equals_pairwise() -> None:
    data = [1, 2, 3, 4]
    assert list(window(data, 2)) == list(pairwise(data))


def test_window_larger_n_and_too_short() -> None:
    assert list(window([1, 2, 3, 4], 3)) == [(1, 2, 3), (2, 3, 4)]
    assert list(window([1, 2], 3)) == []


def test_window_is_lazy() -> None:
    assert list(islice(window(count(), 2), 3)) == [(0, 1), (1, 2), (2, 3)]


def test_window_rejects_bad_n() -> None:
    with pytest.raises(ValueError):
        list(window([1, 2], 0))


def test_group_by_key_sorts_first() -> None:
    records = [
        {"tag": "b", "n": 1},
        {"tag": "a", "n": 2},
        {"tag": "b", "n": 3},
    ]
    grouped = group_by_key(records, "tag")
    assert set(grouped) == {"a", "b"}
    assert grouped["b"] == [{"tag": "b", "n": 1}, {"tag": "b", "n": 3}]
    assert grouped["a"] == [{"tag": "a", "n": 2}]


def test_group_by_key_returns_plain_dict() -> None:
    grouped = group_by_key([{"t": "x"}], "t")
    assert type(grouped) is dict
    assert grouped == {"x": [{"t": "x"}]}
    assert group_by_key([], "t") == {}


def test_interleave_equal_lengths() -> None:
    assert list(interleave([1, 2], [10, 20])) == [1, 10, 2, 20]


def test_interleave_uneven_lengths() -> None:
    assert list(interleave([1, 2, 3], "ab")) == [1, "a", 2, "b", 3]
    assert list(interleave()) == []


def test_first_true_found() -> None:
    assert first_true([1, 3, 4, 6], lambda x: x % 2 == 0) == 4


def test_first_true_default_and_lazy() -> None:
    assert first_true([1, 3], lambda x: x % 2 == 0) is None
    assert first_true([1, 3], lambda x: x > 100, default=-1) == -1
    assert first_true(count(), lambda x: x == 5) == 5


def test_cumulative_max_values() -> None:
    assert cumulative_max([3, 1, 4, 1, 5]) == [3, 3, 4, 4, 5]
    assert cumulative_max([]) == []


def test_cumulative_max_single_and_decreasing() -> None:
    assert cumulative_max([7]) == [7]
    assert cumulative_max([5, 4, 3]) == [5, 5, 5]


def test_top_k_pairs_counts() -> None:
    words = ["a", "b", "a", "c", "a", "b"]
    assert top_k_pairs(words, 2) == [("a", 3), ("b", 2)]


def test_top_k_pairs_edge_cases() -> None:
    assert top_k_pairs(["a"], 5) == [("a", 1)]
    assert top_k_pairs(["a"], 0) == []
    assert top_k_pairs([], 3) == []


def test_memory_report_keys() -> None:
    report = memory_report()
    assert set(report) == {"list_bytes", "gen_bytes"}
    assert all(isinstance(v, int) for v in report.values())


def test_memory_report_generator_is_much_smaller() -> None:
    report = memory_report()
    assert report["gen_bytes"] * 1000 < report["list_bytes"]

test_w1_03_decorators.py

exercises/week1/test_w1_03_decorators.py
"""Week 1 · M2a · 闭包与装饰器练习的测试(已写好,不要改)

用法:uv run pytest exercises/week1/test_w1_03_decorators.py -q
注意 test_timer_keeps_metadata:忘了 @wraps 的实现会在这里失败。
"""

import pytest
from w1_03_decorators import (
    compose,
    log_calls,
    make_counter,
    once,
    retry,
    timer,
    validate_positive,
)


def test_make_counter_increments() -> None:
    counter = make_counter()
    assert counter() == 1
    assert counter() == 2
    assert counter() == 3


def test_make_counter_instances_are_independent() -> None:
    a = make_counter()
    b = make_counter()
    assert a() == 1
    assert a() == 2
    assert b() == 1


def test_timer_returns_result() -> None:
    @timer
    def add(a: int, b: int) -> int:
        return a + b

    assert add(1, 2) == 3


def test_timer_keeps_metadata() -> None:
    @timer
    def add(a: int, b: int) -> int:
        """加法。"""
        return a + b

    assert add.__name__ == "add"
    assert add.__doc__ == "加法。"


def test_timer_prints_elapsed(capsys: pytest.CaptureFixture[str]) -> None:
    @timer
    def slow() -> str:
        return "ok"

    assert slow() == "ok"
    out = capsys.readouterr().out
    assert "slow" in out
    assert "耗时" in out


def test_log_calls_format() -> None:
    logs: list[str] = []

    @log_calls(logs)
    def add(a: int, b: int) -> int:
        return a + b

    assert add(2, 3) == 5
    assert logs == ["add(2, 3) -> 5"]


def test_log_calls_kwargs_and_order() -> None:
    logs: list[str] = []

    @log_calls(logs)
    def power(base: int, exp: int = 2) -> int:
        return base**exp

    assert power(2, exp=3) == 8
    assert power(3) == 9
    assert logs == ["power(2, exp=3) -> 8", "power(3) -> 9"]


def test_log_calls_keeps_metadata() -> None:
    logs: list[str] = []

    @log_calls(logs)
    def hello() -> None:
        """打招呼。"""

    hello()
    assert hello.__name__ == "hello"
    assert hello.__doc__ == "打招呼。"


def test_retry_succeeds_on_third_call() -> None:
    calls = {"n": 0}

    @retry(times=3, exceptions=(ValueError,), delay=0.0)
    def flaky() -> int:
        calls["n"] += 1
        if calls["n"] < 3:
            raise ValueError("还没好")
        return 42

    assert flaky() == 42
    assert calls["n"] == 3


def test_retry_reraises_after_last_attempt() -> None:
    calls = {"n": 0}

    @retry(times=3, delay=0.0)
    def always_bad() -> None:
        calls["n"] += 1
        raise ValueError("坏了")

    with pytest.raises(ValueError, match="坏了"):
        always_bad()
    assert calls["n"] == 3


def test_retry_does_not_swallow_other_exceptions() -> None:
    calls = {"n": 0}

    @retry(times=3, exceptions=(ValueError,), delay=0.0)
    def wrong_kind() -> None:
        calls["n"] += 1
        raise KeyError("别的错")

    with pytest.raises(KeyError):
        wrong_kind()
    assert calls["n"] == 1


def test_retry_rejects_bad_times() -> None:
    with pytest.raises(ValueError):
        retry(times=0)


def test_validate_positive_allows_positive() -> None:
    @validate_positive
    def area(w: float, h: float) -> float:
        return w * h

    assert area(2, 3) == 6


def test_validate_positive_rejects_non_positive() -> None:
    @validate_positive
    def area(w: float, h: float) -> float:
        return w * h

    with pytest.raises(ValueError):
        area(2, 0)
    with pytest.raises(ValueError):
        area(-1, 3)


def test_once_runs_body_only_once() -> None:
    calls = {"n": 0}

    @once
    def setup(value: int = 1) -> int:
        calls["n"] += 1
        return value

    assert setup(5) == 5
    assert setup(9) == 5
    assert setup() == 5
    assert calls["n"] == 1


def test_once_keeps_metadata() -> None:
    @once
    def setup() -> str:
        """只跑一次。"""
        return "done"

    assert setup.__name__ == "setup"
    assert setup.__doc__ == "只跑一次。"


def test_compose_order() -> None:
    assert compose(str, lambda x: x + 1)(1) == "2"
    assert compose(lambda x: x * 2, lambda x: x + 3)(1) == 8


def test_compose_identity_and_three() -> None:
    assert compose()(7) == 7
    assert compose(str, lambda x: x * 2, lambda x: x + 1)(2) == "6"

test_w1_04_registry_inspect.py

exercises/week1/test_w1_04_registry_inspect.py
"""Week 1 · M2b · 注册表与 inspect 练习的测试(已写好,不要改)

用法:uv run pytest exercises/week1/test_w1_04_registry_inspect.py -q
describe 的期望结果写得很死:先照着 docstring 里的格式实现,再回头看这里。
"""

import time

import pytest
from w1_04_registry_inspect import (
    CALL_COUNTS,
    Registry,
    cached_fib,
    call_with_kwargs,
    describe,
    naive_fib,
    repeat,
    to_str,
)


def test_registry_with_explicit_name() -> None:
    reg = Registry()

    @reg.register("add")
    def add(a: int, b: int) -> int:
        return a + b

    assert reg.names() == ["add"]
    assert reg.get("add")(1, 2) == 3
    assert add(1, 2) == 3  # 装饰器要返回原函数


def test_registry_without_parentheses() -> None:
    reg = Registry()

    @reg.register
    def double(x: int) -> int:
        return x * 2

    @reg.register()
    def triple(x: int) -> int:
        return x * 3

    assert reg.names() == ["double", "triple"]
    assert reg.get("double")(2) == 4
    assert reg.get("triple")(2) == 6


def test_registry_rejects_duplicate_names() -> None:
    reg = Registry()

    @reg.register("add")
    def add(a: int, b: int) -> int:
        return a + b

    with pytest.raises(ValueError, match="add"):

        @reg.register("add")
        def add2(a: int, b: int) -> int:
            return a + b


def test_registry_get_missing_name() -> None:
    reg = Registry()
    assert reg.names() == []
    with pytest.raises(KeyError):
        reg.get("nope")


def test_repeat_collects_results() -> None:
    calls: list[int] = []

    @repeat(3)
    def tick() -> int:
        calls.append(1)
        return len(calls)

    assert tick() == [1, 2, 3]
    assert len(calls) == 3


def test_repeat_metadata_and_bad_n() -> None:
    @repeat(2)
    def hi(name: str) -> str:
        """打招呼。"""
        return f"hi {name}"

    assert hi("a") == ["hi a", "hi a"]
    assert hi.__name__ == "hi"
    assert hi.__doc__ == "打招呼。"
    with pytest.raises(ValueError):
        repeat(0)


def test_cached_fib_values() -> None:
    assert cached_fib(0) == 0
    assert cached_fib(1) == 1
    assert cached_fib(10) == 55


def test_cached_fib_is_fast() -> None:
    start = time.perf_counter()
    assert cached_fib(80) == 23416728348467685
    assert time.perf_counter() - start < 1.0


def test_cached_fib_calls_far_fewer_times_than_naive() -> None:
    CALL_COUNTS["naive_fib"] = 0
    CALL_COUNTS["cached_fib"] = 0
    cached_fib.cache_clear()
    assert naive_fib(20) == cached_fib(20) == 6765
    assert CALL_COUNTS["naive_fib"] > 10_000
    assert CALL_COUNTS["cached_fib"] <= 21


def test_describe_annotated_function() -> None:
    def f(a: int, b: str = "x") -> str:
        """示例"""
        return b * a

    assert describe(f) == {
        "name": "f",
        "doc": "示例",
        "params": [
            {"name": "a", "type": "int", "default": None, "required": True},
            {"name": "b", "type": "str", "default": "x", "required": False},
        ],
        "returns": "str",
    }


def test_describe_without_annotations() -> None:
    def g(x, flag=False):
        return x

    result = describe(g)
    assert result["name"] == "g"
    assert result["doc"] == ""
    assert result["returns"] == "Any"
    assert result["params"] == [
        {"name": "x", "type": "Any", "default": None, "required": True},
        {"name": "flag", "type": "Any", "default": False, "required": False},
    ]


def test_describe_keyword_only_param() -> None:
    def h(a: int, *, verbose: bool = False) -> None:
        """带关键字参数。"""

    params = describe(h)["params"]
    assert [p["name"] for p in params] == ["a", "verbose"]
    assert params[1] == {
        "name": "verbose",
        "type": "bool",
        "default": False,
        "required": False,
    }


def test_call_with_kwargs_picks_and_ignores() -> None:
    def f(a: int, b: int = 2) -> int:
        return a + b

    assert call_with_kwargs(f, {"a": 1, "zzz": 9}) == 3
    assert call_with_kwargs(f, {"a": 1, "b": 10}) == 11


def test_call_with_kwargs_missing_required() -> None:
    def f(a: int, b: int) -> int:
        return a + b

    with pytest.raises(TypeError, match="b"):
        call_with_kwargs(f, {"a": 1})


def test_to_str_dispatch() -> None:
    assert to_str(3) == "整数 3"
    assert to_str([1, 2]) == "列表(2 项)"


def test_to_str_default_branch() -> None:
    assert to_str("a") == "'a'"
    assert to_str({"k": 1}) == "{'k': 1}"
    assert to_str([]) == "列表(0 项)"

test_w1_05_context_managers.py

exercises/week1/test_w1_05_context_managers.py
"""Week 1 · M3 · 上下文管理器练习的测试(已写好,不要改)

用法:uv run pytest exercises/week1/test_w1_05_context_managers.py -q
所有涉及文件与目录的测试都用 pytest 的 tmp_path,不会碰到你的工作目录。
"""

import os
from pathlib import Path

import pytest
from w1_05_context_managers import (
    Timer,
    atomic_write,
    cd,
    open_all,
    suppress_and_log,
    temp_env,
)


def test_timer_records_elapsed() -> None:
    with Timer() as t:
        assert isinstance(t, Timer)
        sum(range(10_000))
    assert t.elapsed > 0
    assert isinstance(t.elapsed, float)


def test_timer_records_even_on_error() -> None:
    timer = Timer()
    with pytest.raises(ValueError), timer:
        raise ValueError("boom")
    assert timer.elapsed >= 0


def test_temp_env_sets_and_restores() -> None:
    assert "W1_DEMO" not in os.environ
    with temp_env(W1_DEMO="test"):
        assert os.environ["W1_DEMO"] == "test"
    assert "W1_DEMO" not in os.environ


def test_temp_env_restores_old_value_and_on_error() -> None:
    os.environ["W1_OLD"] = "keep"
    try:
        with temp_env(W1_OLD="new"):
            assert os.environ["W1_OLD"] == "new"
        assert os.environ["W1_OLD"] == "keep"
        with pytest.raises(ValueError), temp_env(W1_OLD="new"):
            raise ValueError("boom")
        assert os.environ["W1_OLD"] == "keep"
    finally:
        os.environ.pop("W1_OLD", None)


def test_cd_switches_and_restores(tmp_path: Path) -> None:
    before = Path.cwd()
    with cd(tmp_path) as here:
        assert Path.cwd().resolve() == tmp_path.resolve()
        assert here.resolve() == tmp_path.resolve()
    assert Path.cwd() == before


def test_cd_restores_on_error(tmp_path: Path) -> None:
    before = Path.cwd()
    with pytest.raises(ValueError), cd(tmp_path):
        raise ValueError("boom")
    assert Path.cwd() == before


def test_open_all_reads_every_file(tmp_path: Path) -> None:
    paths = []
    for i in range(3):
        p = tmp_path / f"f{i}.txt"
        p.write_text(f"内容{i}", encoding="utf-8")
        paths.append(p)
    with open_all(paths) as files:
        assert [f.read() for f in files] == ["内容0", "内容1", "内容2"]
    assert all(f.closed for f in files)


def test_open_all_empty_and_closes_on_error(tmp_path: Path) -> None:
    with open_all([]) as files:
        assert files == []
    p = tmp_path / "a.txt"
    p.write_text("a", encoding="utf-8")
    opened: list = []
    with pytest.raises(ValueError):
        with open_all([p]) as files:
            opened = files
            raise ValueError("boom")
    assert all(f.closed for f in opened)


def test_suppress_and_log_swallows_and_records() -> None:
    logs: list[str] = []
    with suppress_and_log(ValueError, logs):
        raise ValueError("x")
    assert logs == ["ValueError('x')"]


def test_suppress_and_log_lets_others_through() -> None:
    logs: list[str] = []
    with pytest.raises(KeyError), suppress_and_log(ValueError, logs):
        raise KeyError("别的错")
    assert logs == []
    with suppress_and_log(ValueError, logs):
        pass
    assert logs == []


def test_atomic_write_creates_file(tmp_path: Path) -> None:
    target = tmp_path / "out.txt"
    with atomic_write(target) as f:
        f.write("新内容")
    assert target.read_text(encoding="utf-8") == "新内容"
    assert sorted(p.name for p in tmp_path.iterdir()) == ["out.txt"]


def test_atomic_write_leaves_nothing_on_error(tmp_path: Path) -> None:
    target = tmp_path / "out.txt"
    target.write_text("旧内容", encoding="utf-8")
    with pytest.raises(ValueError):
        with atomic_write(target) as f:
            f.write("半成品")
            raise ValueError("boom")
    assert target.read_text(encoding="utf-8") == "旧内容"
    assert sorted(p.name for p in tmp_path.iterdir()) == ["out.txt"]

test_w1_06_functional.py

exercises/week1/test_w1_06_functional.py
"""Week 1 · M3 · 函数式工具练习的测试(已写好,不要改)

用法:uv run pytest exercises/week1/test_w1_06_functional.py -q
两种 squares_of_evens 只验证结果一致:写法好不好读由你自己评判。
"""

import pytest
from w1_06_functional import (
    compose_reduce,
    make_rate_applier,
    sort_by_fields,
    squares_of_evens_comprehension,
    squares_of_evens_map,
    upper_all,
)

ROWS = [
    {"city": "上海", "age": 30, "name": "c"},
    {"city": "北京", "age": 20, "name": "a"},
    {"city": "北京", "age": 40, "name": "b"},
]


def test_sort_by_fields_single_field() -> None:
    result = sort_by_fields(ROWS, ["age"])
    assert [r["age"] for r in result] == [20, 30, 40]
    assert [r["age"] for r in ROWS] == [30, 20, 40]  # 原列表不能被改动


def test_sort_by_fields_two_fields() -> None:
    result = sort_by_fields(ROWS, ["city", "age"])
    assert [(r["city"], r["age"]) for r in result] == [
        ("上海", 30),
        ("北京", 20),
        ("北京", 40),
    ]


def test_sort_by_fields_empty_fields_and_records() -> None:
    assert sort_by_fields(ROWS, []) == ROWS
    assert sort_by_fields([], ["age"]) == []


def test_compose_reduce_order() -> None:
    assert compose_reduce(str, lambda x: x + 1)(1) == "2"
    assert compose_reduce(lambda x: x * 2, lambda x: x + 3)(1) == 8


def test_compose_reduce_identity_and_three() -> None:
    assert compose_reduce()(7) == 7
    assert compose_reduce(str, lambda x: x * 2, lambda x: x + 1)(2) == "6"


@pytest.mark.parametrize(
    ("nums", "expected"),
    [
        ([1, 2, 3, 4], [4, 16]),
        ([], []),
        ([1, 3, 5], []),
        ([0, -2], [0, 4]),
    ],
)
def test_squares_of_evens_both_styles(nums: list[int], expected: list[int]) -> None:
    assert squares_of_evens_map(nums) == expected
    assert squares_of_evens_comprehension(nums) == expected


def test_squares_of_evens_styles_agree() -> None:
    nums = list(range(20))
    assert squares_of_evens_map(nums) == squares_of_evens_comprehension(nums)


def test_make_rate_applier_prefills_rate() -> None:
    tax = make_rate_applier(0.1)
    assert tax(100) == 10.0
    assert tax(35) == 3.5


def test_make_rate_applier_instances_are_independent() -> None:
    low = make_rate_applier(0.1)
    high = make_rate_applier(0.25)
    assert low(80) == 8.0
    assert high(80) == 20.0


def test_upper_all_basic() -> None:
    assert upper_all(["ab", "cd"]) == ["AB", "CD"]


def test_upper_all_edge_cases() -> None:
    assert upper_all([]) == []
    assert upper_all(["中文", "aB"]) == ["中文", "AB"]

test_w1_07_data_model.py

exercises/week1/test_w1_07_data_model.py
"""Week 1 · M4a · 数据模型练习的测试(已写好,不要改)

用法:uv run pytest exercises/week1/test_w1_07_data_model.py -q
Money 的 repr 是逐字符比对的:先照 docstring 的例子写。
"""

from decimal import Decimal

import pytest
from w1_07_data_model import Celsius, Config, Money, Playlist

CNY = "CNY"
USD = "USD"


def money(value: str, currency: str = CNY) -> Money:
    """测试用小助手:Money(Decimal("1.50"), "CNY")。"""
    return Money(Decimal(value), currency)


def test_money_repr_and_str() -> None:
    m = money("1.50")
    assert repr(m) == "Money(Decimal('1.50'), 'CNY')"
    assert str(m) == "1.50 CNY"


def test_money_repr_can_be_evaluated() -> None:
    m = money("2.25", USD)
    restored = eval(repr(m), {"Money": Money, "Decimal": Decimal})
    assert restored == m


def test_money_equality() -> None:
    assert money("1.00") == money("1.00")
    assert money("1.00") != money("1.00", USD)
    assert money("1.00") != "1.00 CNY"


def test_money_is_hashable() -> None:
    prices = {money("1.00"): "便宜", money("100.00"): "贵"}
    assert prices[money("1.00")] == "便宜"
    assert len({money("1.00"), money("1.00"), money("2.00")}) == 2


def test_money_ordering() -> None:
    assert money("1.00") < money("2.00")
    assert money("2.00") >= money("2.00")
    assert sorted([money("3.00"), money("1.00"), money("2.00")]) == [
        money("1.00"),
        money("2.00"),
        money("3.00"),
    ]


def test_money_ordering_rejects_other_currency() -> None:
    with pytest.raises(ValueError):
        assert money("1.00") < money("1.00", USD)


def test_money_addition() -> None:
    assert money("1.50") + money("0.50") == money("2.00")


def test_money_addition_rejects_other_currency() -> None:
    with pytest.raises(ValueError):
        _ = money("1.00") + money("1.00", USD)


def test_money_sum_uses_radd() -> None:
    total = sum([money("1.00"), money("2.50")])
    assert total == money("3.50")


def test_money_bool() -> None:
    assert bool(money("0.01")) is True
    assert bool(money("0.00")) is False


def test_playlist_len_and_index() -> None:
    p = Playlist(["a", "b", "c"])
    assert len(p) == 3
    assert p[0] == "a"
    assert p[-1] == "c"
    with pytest.raises(IndexError):
        _ = p[3]


def test_playlist_slice_returns_playlist() -> None:
    p = Playlist(["a", "b", "c"])
    part = p[:2]
    assert isinstance(part, Playlist)
    assert list(part) == ["a", "b"]
    assert repr(part) == "Playlist(['a', 'b'])"


def test_playlist_contains_and_iter() -> None:
    p = Playlist(["a", "b"])
    assert "a" in p
    assert "z" not in p
    collected: list[str] = []
    for track in p:
        collected.append(track)
    assert collected == ["a", "b"]


def test_playlist_reversed_and_empty() -> None:
    assert list(reversed(Playlist(["a", "b"]))) == ["b", "a"]
    empty = Playlist([])
    assert len(empty) == 0
    assert list(empty) == []


def test_config_reads_keys_as_attributes() -> None:
    cfg = Config({"host": "localhost", "port": 8000})
    assert cfg.host == "localhost"
    assert cfg.port == 8000
    assert cfg.data == {"host": "localhost", "port": 8000}


def test_config_missing_key_raises_attribute_error() -> None:
    cfg = Config({"host": "localhost"})
    with pytest.raises(AttributeError, match="port"):
        _ = cfg.port


def test_celsius_format_default_and_spec() -> None:
    t = Celsius(20.0)
    assert f"{t}" == "20.0°C"
    assert f"{t:.1f}" == "20.0°C"
    assert f"{t:.2f}" == "20.00°C"


def test_celsius_format_fahrenheit() -> None:
    assert f"{Celsius(20.0):F}" == "68.0°F"
    assert Celsius(100.0).to_fahrenheit() == 212.0
    assert Celsius(0.0).to_fahrenheit() == 32.0

test_w1_08_dataclass_enum.py

exercises/week1/test_w1_08_dataclass_enum.py
"""Week 1 · M4b · dataclass 与 Enum 练习的测试(已写好,不要改)

用法:uv run pytest exercises/week1/test_w1_08_dataclass_enum.py -q
枚举成员的值必须和骨架 docstring 写的一致,否则这里会红。
"""

from dataclasses import FrozenInstanceError

import pytest
from w1_08_dataclass_enum import (
    Priority,
    Status,
    Task,
    Weekday,
    describe,
    from_dict,
    sort_tasks,
    to_dict,
)


def test_priority_values_and_ordering() -> None:
    assert (Priority.LOW, Priority.NORMAL, Priority.HIGH) == (1, 2, 3)
    assert Priority.HIGH > Priority.LOW
    assert int(Priority.NORMAL) == 2


def test_priority_lookup_by_value_and_name() -> None:
    assert Priority(3) is Priority.HIGH
    assert Priority["LOW"] is Priority.LOW


def test_status_is_also_str() -> None:
    assert Status.DONE == "done"
    assert f"{Status.DOING}" == "doing"
    assert [s.value for s in Status] == ["todo", "doing", "done"]


def test_status_lookup_by_value() -> None:
    assert Status("todo") is Status.TODO
    with pytest.raises(ValueError):
        Status("nope")


def test_task_defaults() -> None:
    task = Task("写周报")
    assert task.priority is Priority.NORMAL
    assert task.status is Status.TODO
    assert task.tags == []


def test_task_default_tags_are_not_shared() -> None:
    a = Task("a")
    b = Task("b")
    a.tags.append("x")
    assert b.tags == []


def test_task_is_frozen_and_slotted() -> None:
    task = Task("a")
    with pytest.raises(FrozenInstanceError):
        task.title = "b"
    # slots=True 之后实例没有 __dict__,拼错属性名会立刻暴露
    assert not hasattr(task, "__dict__")
    assert Task.__slots__ == ("title", "priority", "status", "tags")


def test_task_compares_by_value() -> None:
    assert Task("a", tags=[]) == Task("a")
    assert Task("a") != Task("b")
    # frozen=True 生成了 __hash__,但字段里有 list,真去哈希还是会报错
    with pytest.raises(TypeError):
        hash(Task("a"))


def test_task_post_init_rejects_empty_title() -> None:
    with pytest.raises(ValueError):
        Task("")
    with pytest.raises(ValueError):
        Task("   ")


def test_with_status_returns_new_task() -> None:
    task = Task("a", priority=Priority.HIGH, tags=["x"])
    done = task.with_status(Status.DONE)
    assert done.status is Status.DONE
    assert done.title == "a"
    assert done.priority is Priority.HIGH
    assert done.tags == ["x"]
    assert task.status is Status.TODO


def test_sort_tasks_priority_desc_then_title_asc() -> None:
    tasks = [
        Task("b"),
        Task("a", priority=Priority.HIGH),
        Task("a"),
        Task("c", priority=Priority.LOW),
    ]
    result = sort_tasks(tasks)
    assert [(t.priority, t.title) for t in result] == [
        (Priority.HIGH, "a"),
        (Priority.NORMAL, "a"),
        (Priority.NORMAL, "b"),
        (Priority.LOW, "c"),
    ]


def test_sort_tasks_returns_new_list() -> None:
    tasks = [Task("b"), Task("a")]
    result = sort_tasks(tasks)
    assert result is not tasks
    assert [t.title for t in tasks] == ["b", "a"]


def test_to_dict_uses_plain_values() -> None:
    task = Task("a", priority=Priority.HIGH, tags=["x"])
    assert to_dict(task) == {
        "title": "a",
        "priority": 3,
        "status": "todo",
        "tags": ["x"],
    }


def test_to_dict_values_are_not_enums() -> None:
    data = to_dict(Task("a"))
    assert type(data["priority"]) is int
    assert type(data["status"]) is str


def test_from_dict_restores_enums() -> None:
    task = from_dict({"title": "a", "priority": 3, "status": "done"})
    assert task.priority is Priority.HIGH
    assert task.status is Status.DONE
    assert task.tags == []


def test_from_dict_round_trip_and_missing_title() -> None:
    task = Task("a", priority=Priority.LOW, status=Status.DOING, tags=["x"])
    assert from_dict(to_dict(task)) == task
    with pytest.raises(KeyError):
        from_dict({"priority": 1})


def test_weekday_combination() -> None:
    workdays = Weekday.MON | Weekday.TUE
    assert Weekday.MON in workdays
    assert Weekday.WED not in workdays
    assert len(list(workdays)) == 2


def test_weekday_weekend_member() -> None:
    assert Weekday.SAT in Weekday.WEEKEND
    assert Weekday.SUN in Weekday.WEEKEND
    assert Weekday.MON not in Weekday.WEEKEND
    assert len(list(Weekday)) == 7


def test_describe_matches_every_status() -> None:
    assert describe(Status.TODO) == "待办"
    assert describe(Status.DOING) == "进行中"
    assert describe(Status.DONE) == "已完成"


def test_describe_accepts_value_lookup() -> None:
    assert describe(Status("done")) == "已完成"

test_w1_09_oop_protocols.py

exercises/week1/test_w1_09_oop_protocols.py
"""Week 1 · M4c · OOP 进阶练习的测试(已写好,不要改)

用法:uv run pytest exercises/week1/test_w1_09_oop_protocols.py -q
注意:抽象类与 Protocol 都不能实例化,测试里专门验证了这两条。
"""

from math import isclose, pi

import pytest
from w1_09_oop_protocols import (
    Button,
    Car,
    Circle,
    Drawable,
    InMemoryRepository,
    Label,
    Plugin,
    Rect,
    Repository,
    Shape,
    Temperature,
    Truck,
    Vehicle,
    render_all,
)


def test_shape_cannot_be_instantiated() -> None:
    with pytest.raises(TypeError):
        Shape()


def test_circle_area_and_perimeter() -> None:
    c = Circle(2)
    assert isclose(c.area(), pi * 4)
    assert isclose(c.perimeter(), 2 * pi * 2)


def test_rect_area_and_perimeter() -> None:
    r = Rect(2, 3)
    assert r.area() == 6
    assert r.perimeter() == 10


def test_shape_summary_uses_subclass_methods() -> None:
    assert Rect(2, 3).summary() == "面积 6.00 周长 10.00"
    assert isinstance(Circle(1), Shape)


def test_shapes_reject_bad_sizes() -> None:
    with pytest.raises(ValueError):
        Circle(0)
    with pytest.raises(ValueError):
        Rect(1, -1)


def test_drawable_protocol_is_structural() -> None:
    assert isinstance(Button("A"), Drawable)
    assert isinstance(Label("b"), Drawable)
    assert not issubclass(Button, Label)


def test_protocol_cannot_be_instantiated() -> None:
    with pytest.raises(TypeError):
        Drawable()


def test_draw_outputs() -> None:
    assert Button("确定").draw() == "[确定]"
    assert Label("你好").draw() == "你好"


def test_render_all_collects_strings() -> None:
    assert render_all([Button("A"), Label("b")]) == ["[A]", "b"]
    assert render_all([]) == []


def test_in_memory_repository_add_get_list() -> None:
    repo = InMemoryRepository()
    repo.add("a", "苹果")
    repo.add("b", "香蕉")
    assert repo.get("a") == "苹果"
    assert repo.list() == ["苹果", "香蕉"]


def test_in_memory_repository_overwrite_and_missing() -> None:
    repo = InMemoryRepository()
    repo.add("a", "苹果")
    repo.add("a", "菠萝")
    assert repo.get("a") == "菠萝"
    assert repo.list() == ["菠萝"]
    with pytest.raises(KeyError):
        repo.get("x")


def test_in_memory_repository_satisfies_protocol() -> None:
    assert isinstance(InMemoryRepository(), Repository)


def test_temperature_property_reads_and_writes() -> None:
    t = Temperature(20)
    assert t.celsius == 20
    t.celsius = 25
    assert t.celsius == 25
    assert t.fahrenheit == 77.0


def test_temperature_rejects_below_absolute_zero() -> None:
    with pytest.raises(ValueError):
        Temperature(-300)
    t = Temperature(0)
    with pytest.raises(ValueError):
        t.celsius = -273.16
    assert t.celsius == 0


def test_temperature_fahrenheit_is_read_only() -> None:
    t = Temperature(0)
    assert t.fahrenheit == 32.0
    with pytest.raises(AttributeError):
        t.fahrenheit = 100


def test_plugin_registers_subclasses() -> None:
    Plugin.registry.clear()

    class Hello(Plugin):
        """打招呼插件。"""

    class Bye(Plugin):
        """告别插件。"""

    assert Plugin.registry["hello"] is Hello
    assert Plugin.registry["bye"] is Bye
    assert sorted(Plugin.registry) == ["bye", "hello"]


def test_plugin_base_is_not_registered() -> None:
    Plugin.registry.clear()
    assert Plugin.registry == {}

    class Solo(Plugin):
        """唯一的插件。"""

    assert list(Plugin.registry) == ["solo"]
    assert issubclass(Solo, Plugin)


def test_cooperative_init_passes_kwargs_up() -> None:
    car = Car(name="A", doors=2)
    assert (car.name, car.doors) == ("A", 2)
    truck = Truck(name="B", payload_kg=1000)
    assert (truck.name, truck.payload_kg) == ("B", 1000)


def test_cooperative_init_defaults_and_missing_args() -> None:
    car = Car(name="A")
    assert car.doors == 4
    assert isinstance(car, Vehicle)
    with pytest.raises(TypeError):
        Truck(payload_kg=1)

test_w1_10_exceptions_syntax.py

exercises/week1/test_w1_10_exceptions_syntax.py
"""w1_10 的测试。运行:uv run pytest exercises/week1/test_w1_10_exceptions_syntax.py
-q"""

import pytest
from w1_10_exceptions_syntax import (
    Add,
    Greet,
    InvalidNote,
    KBError,
    NoteNotFound,
    Quit,
    Stack,
    apply_all,
    clamp,
    first,
    handle,
    load_note,
    read_with_cleanup,
    render,
    validate_many,
    validate_note,
)


def test_exception_hierarchy():
    assert issubclass(NoteNotFound, KBError)
    assert issubclass(InvalidNote, KBError)
    assert issubclass(KBError, Exception)


def test_load_note_ok():
    assert load_note({1: "a"}, 1) == "a"


def test_load_note_missing_keeps_cause():
    with pytest.raises(NoteNotFound) as info:
        load_note({}, 9)
    assert info.value.note_id == 9
    assert isinstance(info.value.__cause__, KeyError)
    assert "9" in str(info.value)


def test_load_note_caught_by_base_class():
    with pytest.raises(KBError):
        load_note({}, 1)


def test_validate_note():
    assert validate_note("  hi  ") == "hi"
    with pytest.raises(InvalidNote):
        validate_note("   ")
    with pytest.raises(InvalidNote):
        validate_note("x" * 51)


def test_validate_many_ok():
    assert validate_many(["a", " b "]) == ["a", "b"]
    assert validate_many([]) == []


def test_validate_many_collects_all_errors():
    with pytest.raises(ExceptionGroup) as info:
        validate_many(["", "ok", "x" * 60])
    group = info.value
    assert len(group.exceptions) == 2
    assert all(isinstance(e, InvalidNote) for e in group.exceptions)
    notes = [note for e in group.exceptions for note in getattr(e, "__notes__", [])]
    assert any("第 0 项" in n for n in notes) and any("第 2 项" in n for n in notes)


def test_except_star_works_with_group():
    caught: list[str] = []
    try:
        validate_many(["", ""])
    except* InvalidNote as eg:
        caught.extend(str(e) for e in eg.exceptions)
    assert len(caught) == 2


@pytest.mark.parametrize(
    ("cmd", "expected"),
    [
        (Add(2, 3), "结果:5"),
        (Add(0, 0), "零"),
        (Greet("小明"), "你好,小明"),
        (Greet(""), "你好,陌生人"),
        (Quit(), "再见"),
        ("jump", "未知命令"),
        (None, "未知命令"),
    ],
    ids=["add", "add-zero", "greet", "greet-empty", "quit", "str", "none"],
)
def test_handle(cmd, expected):
    assert handle(cmd) == expected


def test_stack_generic():
    s = Stack[int]()
    assert not s and len(s) == 0
    s.push(1)
    s.push(2)
    assert s.peek() == 2 and len(s) == 2
    assert s.pop() == 2
    assert s.pop() == 1
    with pytest.raises(IndexError):
        s.pop()
    with pytest.raises(IndexError):
        s.peek()


def test_first_and_apply_all():
    assert first([3, 4]) == 3
    assert first([]) is None
    assert first([], default=0) == 0
    assert apply_all([str, lambda v: v * 2], 4) == ["4", 8]


def test_render_escapes_interpolations_only():
    name = "<b>"
    assert render(t"hi {name}!") == "hi &lt;b&gt;!"
    n = 3.14159
    assert render(t"pi={n:.2f}") == "pi=3.14"
    assert render(t"<p>静态尖括号保留</p>") == "<p>静态尖括号保留</p>"


def test_clamp_positional_only_and_strict():
    assert clamp(1.5) == 1.0
    assert clamp(-1, 0, 10) == 0
    assert clamp(5, lo=0, hi=10) == 5
    with pytest.raises(TypeError):
        clamp(value=1.0)  # type: ignore[call-arg]
    with pytest.raises(ValueError):
        clamp(2.0, strict=True)


def test_read_with_cleanup_order():
    log: list[str] = []
    assert read_with_cleanup(lambda: "text", log) == "text"
    assert log == ["ok", "closed"]
    log.clear()

    def boom() -> str:
        raise OSError("disk")

    assert read_with_cleanup(boom, log) is None
    assert log == ["error: disk", "closed"]

w1_01_generators.py

exercises/week1/w1_01_generators.py
"""Week 1 · M1a · 迭代协议与生成器

学习目标:分清可迭代对象与迭代器(`iter()`/`next()`/`StopIteration`);
      用 `yield` 写惰性生成器,把"读—解析—过滤—聚合"串成管线;
      用 `yield from` 委托,并手写一个实现迭代协议的类。
用法:uv run pytest exercises/week1/test_w1_01_generators.py -q
做法:把每个 TODO 换成你的实现,反复运行 pytest 直到全绿;
      卡住 8 分钟以上再按 ai-guide.md 提问,拿到提示后自己重写。

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> def gen():
>>>     print("start"); yield 1; print("middle"); yield 2; print("end")
>>> g = gen()
>>> print(type(g)); print(next(g)); print(next(g))
>>> print(list(g), list(g))          # 第二个 list 是什么?
>>> squares = (x * x for x in range(3))
>>> print(sum(squares), sum(squares))  # 第二个 sum 是什么?
"""

from collections.abc import Iterable, Iterator
from itertools import islice  # noqa: F401  take() 会用到
from pathlib import Path


def countdown(n: int) -> Iterator[int]:
    """生成器:从 n 递减产出到 0(含 0)。

    例:list(countdown(3)) -> [3, 2, 1, 0]
    提示:函数体里只要出现 yield,调用它就返回生成器对象。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def read_chunks(path: Path, size: int) -> Iterator[str]:
    """惰性逐块读取 UTF-8 文本文件,每次产出至多 size 个字符。

    size <= 0 时抛 ValueError("size 必须是正整数")。
    例:文件内容 "abcde"、size=2 -> "ab", "cd", "e"
    提示:while True + f.read(size),读到空串就 return。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def parse_records(lines: Iterable[str]) -> Iterator[dict[str, str | int]]:
    """把 "name,score" 形式的每行解析成 {"name": str, "score": int}。

    跳过空行与以 # 开头的注释行;行号从 1 开始计。
    字段数不对或 score 不是整数 -> ValueError("第 3 行不合法: ...")。
    例:["a,1", "", "# c", "b,2"] -> {"name": "a", "score": 1}, ...
    提示:必须惰性——传入无限生成器也要能只取前 2 条。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def take[T](n: int, it: Iterable[T]) -> list[T]:
    """取可迭代对象的前 n 个元素(不足则全取),用 itertools.islice。

    n < 0 时抛 ValueError("n 不能是负数")。
    例:take(2, itertools.count()) -> [0, 1]
    """
    # TODO: 在这里实现
    raise NotImplementedError


def running_mean(nums: Iterable[float]) -> Iterator[float]:
    """逐个产出"到目前为止"的平均值。

    例:list(running_mean([2, 4, 6])) -> [2.0, 3.0, 4.0]
    提示:边遍历边累加,不要先转成列表。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def flatten(nested: Iterable[object]) -> Iterator[object]:
    """递归展平任意层嵌套的列表/元组;字符串当作原子不拆开。

    例:list(flatten([1, [2, [3, "ab"]]])) -> [1, 2, 3, "ab"]
    提示:只对 list/tuple 递归,用 yield from 委托给自己。
    """
    # TODO: 在这里实现
    raise NotImplementedError


class Countdown:
    """手写迭代器类:自己实现 __iter__ 与 __next__。

    例:list(Countdown(2)) -> [2, 1, 0];同一个实例遍历一次就耗尽。
    """

    def __init__(self, start: int) -> None:
        self.current = start

    def __iter__(self) -> Countdown:
        """迭代器自己就是可迭代对象,返回 self。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def __next__(self) -> int:
        """产出当前值并递减;current < 0 时抛 StopIteration。"""
        # TODO: 在这里实现
        raise NotImplementedError

w1_02_itertools.py

exercises/week1/w1_02_itertools.py
"""Week 1 · M1b · itertools 与惰性思维

学习目标:熟练用 `batched/pairwise/groupby/accumulate/zip_longest` 组合迭代器;
      记住 `groupby` 前必须先按同一个 key 排序;
      体会"生成器只占一个对象大小,列表要装下全部元素"。
用法:uv run pytest exercises/week1/test_w1_02_itertools.py -q
做法:把每个 TODO 换成你的实现,反复运行 pytest 直到全绿;
      优先用 itertools 现成工具,不要手写下标循环。

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> from itertools import groupby, islice, count, accumulate
>>> data = [("a", 1), ("b", 2), ("a", 3)]
>>> print({k: [v for _, v in g] for k, g in groupby(data, key=lambda p: p[0])})
>>> print(list(islice(count(10, 5), 3)))
>>> print(list(accumulate([3, 1, 4], max)))
>>> print(list(map(str, [1, 2])), type(map(str, [1, 2])))
"""

import sys  # noqa: F401  memory_report 会用到
from collections import Counter, deque  # noqa: F401  window/top_k_pairs 会用到
from collections.abc import Callable, Iterable, Iterator
from itertools import (  # noqa: F401  这些都会用到
    accumulate,
    batched,
    groupby,
    zip_longest,
)
from operator import itemgetter  # noqa: F401  group_by_key 会用到
from typing import Any

# interleave 用的哨兵:区分"真的有这个元素"和"zip_longest 的填充值"
_MISSING = object()


def chunked[T](it: Iterable[T], size: int) -> Iterator[tuple[T, ...]]:
    """把可迭代对象按 size 切成元组,最后一块可能不满。

    size <= 0 时抛 ValueError("size 必须是正整数")。
    例:list(chunked([1, 2, 3], 2)) -> [(1, 2), (3,)]
    提示:itertools.batched(3.12 新增)就是干这个的。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def window[T](it: Iterable[T], n: int) -> Iterator[tuple[T, ...]]:
    """滑动窗口:每次产出连续 n 个元素组成的元组。

    n <= 0 时抛 ValueError("n 必须是正整数");元素不足 n 个时不产出任何窗口。
    例:list(window([1, 2, 3], 2)) -> [(1, 2), (2, 3)](等价 pairwise)
    提示:deque(maxlen=n) 会自动挤掉最老的元素。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def group_by_key(
    records: Iterable[dict[str, Any]], key: str
) -> dict[str, list[dict[str, Any]]]:
    """按字典里 key 字段的值分组,返回普通 dict。

    例:group_by_key([{"t": "a"}, {"t": "b"}, {"t": "a"}], "t")
        -> {"a": [{"t": "a"}, {"t": "a"}], "b": [{"t": "b"}]}
    提示:itertools.groupby 只合并相邻的相同 key,所以必须先 sorted。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def interleave[T](*its: Iterable[T]) -> Iterator[T]:
    """交错合并多个可迭代对象;长度不同时跳过缺失位置。

    例:list(interleave([1, 2, 3], "ab")) -> [1, "a", 2, "b", 3]
    提示:zip_longest(fillvalue=_MISSING) + 判断 `is not _MISSING`。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def first_true[T](
    it: Iterable[T], pred: Callable[[T], bool], default: T | None = None
) -> T | None:
    """返回第一个让 pred 为真的元素,没有就返回 default。

    例:first_true([1, 3, 4], lambda x: x % 2 == 0) -> 4
    提示:next(生成器表达式, default) 一行搞定,而且是惰性的。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def cumulative_max(nums: Iterable[float]) -> list[float]:
    """逐位记录"到目前为止的最大值"。

    例:cumulative_max([3, 1, 4, 1, 5]) -> [3, 3, 4, 4, 5]
    提示:accumulate(nums, max)。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def top_k_pairs(words: Iterable[str], k: int) -> list[tuple[str, int]]:
    """统计词频,返回出现次数最多的 k 个 (词, 次数),按次数降序。

    k <= 0 时返回空列表。
    例:top_k_pairs(["a", "b", "a"], 1) -> [("a", 2)]
    提示:collections.Counter(...).most_common(k)。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def memory_report() -> dict[str, int]:
    """对比 100 万个元素的列表推导 与 同样条件的生成器表达式的对象大小。

    返回 {"list_bytes": ..., "gen_bytes": ...},用 sys.getsizeof 测。
    例:{"list_bytes": 8448728, "gen_bytes": 224}(数字随环境略有差别)
    提示:生成器对象本身很小,因为元素还没被算出来。
    """
    # TODO: 在这里实现
    raise NotImplementedError

w1_03_decorators.py

exercises/week1/w1_03_decorators.py
"""Week 1 · M2a · 闭包与装饰器基础

学习目标:理解闭包捕获的是变量而不是值(晚绑定陷阱),会用 `nonlocal`;
      把"装饰器 = 接收函数返回函数"写熟,永远记得加 `functools.wraps`;
      手写计时/日志/重试三件套,看清导入时与调用时分别执行了什么。
用法:uv run pytest exercises/week1/test_w1_03_decorators.py -q
做法:把每个 TODO 换成你的实现,反复运行 pytest 直到全绿;
      写完检查每个 wrapper 是否都加了 @wraps。

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> def outer():
>>>     x = 1
>>>     def inner():
>>>         nonlocal x; x += 1; return x
>>>     return inner
>>> f = outer(); print(f(), f()); g = outer(); print(g())
>>> fs = [lambda: i for i in range(3)]; print([h() for h in fs])          # 晚绑定
>>> fs = [lambda i=i: i for i in range(3)]; print([h() for h in fs])      # 修复
>>> def deco(fn):
>>>     print("decorating", fn.__name__)
>>>     return fn
>>> @deco
>>> def hello(): pass
>>> print("module loaded")     # 顺序?
"""

import time  # noqa: F401  timer/retry 会用到
from collections.abc import Callable
from functools import reduce, wraps  # noqa: F401  wrapper 与 compose 会用到
from typing import Any


def make_counter() -> Callable[[], int]:
    """返回一个计数器函数:每调用一次就返回比上次大 1 的整数(从 1 开始)。

    两个计数器互不影响(各自有独立的闭包变量)。
    例:c = make_counter(); c() -> 1; c() -> 2
    提示:在内层函数里用 nonlocal 修改外层变量。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def timer[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    """装饰器:打印被装饰函数的耗时(形如 "add 耗时 0.000012s"),返回原结果。

    必须用 @wraps 保住 __name__/__doc__。
    例:@timer def add(a, b): ... -> add(1, 2) 仍返回 3,并打印一行耗时
    提示:time.perf_counter() 比 time.time() 更适合测耗时。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def log_calls[**P, R](
    logger_list: list[str],
) -> Callable[[Callable[P, R]], Callable[P, R]]:
    """带参装饰器:把每次调用记录成一行字符串追加到 logger_list。

    格式:位置参数与关键字参数都用 repr,形如 "add(2, 3) -> 5"、
    "power(2, exp=3) -> 8"。
    例:logs = []; @log_calls(logs) def add(a, b): return a + b
        add(2, 3) -> 5 且 logs == ["add(2, 3) -> 5"]
    提示:三层结构——外层收参数,中层收函数,内层收调用实参。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def retry[**P, R](
    times: int = 3,
    exceptions: tuple[type[BaseException], ...] = (ValueError,),
    delay: float = 0.0,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
    """带参装饰器:调用失败就重试,最多调用 times 次,最后一次仍失败则原样抛出。

    只重试 exceptions 里列出的异常;每次重试前 sleep(delay) 秒。
    times < 1 时抛 ValueError("times 必须 >= 1")。
    例:一个"前两次抛 ValueError、第三次返回 42"的函数,被 @retry() 装饰后返回 42
    提示:循环 times 次,最后一轮不要再吞异常。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def validate_positive[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    """装饰器:所有位置参数必须 > 0,否则抛 ValueError(关键字参数不检查)。

    例:@validate_positive def area(w, h): return w * h
        area(2, 3) -> 6;area(2, -1) -> ValueError
    提示:非数字参数不用管,只检查 int/float。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def once[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    """装饰器:只真正执行一次,之后不管传什么参数都返回第一次的结果。

    例:被装饰的 setup() 调用 3 次,内部只跑了 1 次
    提示:用闭包记住"是否已执行"和"缓存的结果"。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def compose(*fns: Callable[[Any], Any]) -> Callable[[Any], Any]:
    """从右到左组合单参函数:compose(f, g)(x) == f(g(x))。

    不传函数时返回恒等函数。
    例:compose(str, lambda x: x + 1)(1) -> "2"
    提示:先想清楚顺序,再用循环或 functools.reduce。
    """
    # TODO: 在这里实现
    raise NotImplementedError

w1_04_registry_inspect.py

exercises/week1/w1_04_registry_inspect.py
"""Week 1 · M2b · 带参装饰器、functools、注册表与 inspect

学习目标:写出"可带括号也可不带括号"的装饰器;用 `cache`/`singledispatch`;
      用注册表模式把名字映射到函数(插件、命令、之后的 @tool 都是它);
      用 `inspect.signature` + `get_type_hints` 反查函数长什么样。
用法:uv run pytest exercises/week1/test_w1_04_registry_inspect.py -q
做法:把每个 TODO 换成你的实现,反复运行 pytest 直到全绿;
      describe 的返回格式要和文档字符串里的例子一字不差。

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> import inspect
>>> def f(a: int, b: str = "x", *, flag: bool = False) -> str: ...
>>> sig = inspect.signature(f)
>>> print(sig)
>>> for name, p in sig.parameters.items():
>>>     print(name, p.kind, p.default is inspect.Parameter.empty, p.annotation)
>>> from functools import cache
>>> @cache
>>> def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)
>>> print(fib(30), fib.cache_info())     # hits/misses 各是多少?
"""

import inspect
from collections.abc import Callable
from functools import cache, singledispatch, wraps  # noqa: F401  三个都会用到
from typing import Any, get_type_hints  # noqa: F401  describe 会用到

# 记录两个斐波那契版本各自"真正进入函数体"的次数,测试用它对比缓存效果
CALL_COUNTS: dict[str, int] = {"naive_fib": 0, "cached_fib": 0}


class Registry:
    """名字 -> 函数 的注册表:插件、命令、工具都靠它按名字找实现。

    例:reg = Registry()
        @reg.register("add")      # 也支持不带括号:@reg.register
        def add(a, b): return a + b
        reg.get("add")(1, 2) -> 3;reg.names() -> ["add"]
    """

    def __init__(self) -> None:
        self._items: dict[str, Callable[..., Any]] = {}

    def register(
        self, name: str | Callable[..., Any] | None = None
    ) -> Callable[..., Any]:
        """注册装饰器:三种写法都要能用。

        `@reg.register`、`@reg.register()`、`@reg.register("名字")`。
        不给名字时用函数的 __name__;重名 -> ValueError("名字 add 已被注册")。
        返回值必须是原函数(这样被装饰的函数还能直接调用)。
        提示:判断 name 是不是可调用对象,就知道是"不带括号"的用法。
        """
        # TODO: 在这里实现
        raise NotImplementedError

    def get(self, name: str) -> Callable[..., Any]:
        """按名字取函数;没有这个名字 -> KeyError("未注册的名字: xxx")。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def names(self) -> list[str]:
        """按注册顺序返回全部名字。"""
        # TODO: 在这里实现
        raise NotImplementedError


def repeat[**P, R](n: int) -> Callable[[Callable[P, R]], Callable[P, list[R]]]:
    """带参装饰器:把被装饰函数连续调用 n 次,返回结果列表。

    n < 1 时抛 ValueError("n 必须 >= 1");用 @wraps 保住元数据。
    例:@repeat(3) def hi(): return "hi" -> hi() == ["hi", "hi", "hi"]
    """
    # TODO: 在这里实现
    raise NotImplementedError


def naive_fib(n: int) -> int:
    """不带缓存的递归斐波那契(已给出,用来当对照组)。

    每次进入函数体都给 CALL_COUNTS["naive_fib"] 加 1:n 稍大就会指数级爆炸。
    """
    CALL_COUNTS["naive_fib"] += 1
    if n < 2:
        return n
    return naive_fib(n - 1) + naive_fib(n - 2)


def cached_fib(n: int) -> int:
    """带缓存的递归斐波那契:同一个 n 只算一次。

    函数体第一行要写 CALL_COUNTS["cached_fib"] += 1(测试用它数真实调用次数)。
    例:cached_fib(10) -> 55;cached_fib(80) 应该瞬间返回
    提示:给函数加 @functools.cache,递归写法和 naive_fib 一样。
    """
    # TODO: 在这里实现(记得在函数上方加 @cache)
    raise NotImplementedError


def _type_name(annotation: object) -> str:
    """把注解转成好读的字符串(已给出):int -> "int",没有注解 -> "Any"。"""
    if annotation is inspect.Signature.empty:
        return "Any"
    return getattr(annotation, "__name__", None) or str(annotation)


def describe(fn: Callable[..., Any]) -> dict[str, Any]:
    """反查函数签名,生成"参数说明表"(从函数生成 JSON Schema 的 Python 部分)。

    返回 {"name", "doc", "params": [{"name", "type", "default", "required"}],
    "returns"};没有注解的地方用 "Any",必填参数的 default 记 None,
    没有文档字符串时 doc 为空字符串。
    例:def f(a: int, b: str = "x") -> str: "示例"
        describe(f) -> {"name": "f", "doc": "示例",
                        "params": [{"name": "a", "type": "int",
                                    "default": None, "required": True},
                                   {"name": "b", "type": "str",
                                    "default": "x", "required": False}],
                        "returns": "str"}
    提示:inspect.signature + get_type_hints + inspect.getdoc + _type_name。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def call_with_kwargs(fn: Callable[..., Any], data: dict[str, Any]) -> Any:
    """按 fn 的签名从 data 里挑参数调用它:多余的键忽略,缺必填参数就报错。

    缺参数 -> TypeError("调用 f 缺少参数: b")(消息里要出现缺失的参数名)。
    例:def f(a, b=2): return a + b
        call_with_kwargs(f, {"a": 1, "zzz": 9}) -> 3
    提示:遍历 inspect.signature(fn).parameters,只取 data 里有的键。
    """
    # TODO: 在这里实现
    raise NotImplementedError


@singledispatch
def to_str(value: object) -> str:
    """按参数类型分派的"人话描述":默认分支返回 repr(value)。

    例:to_str(3) -> "整数 3";to_str([1, 2]) -> "列表(2 项)";
        to_str("a") -> "'a'"
    提示:本函数只写默认分支,再用 @to_str.register 分别补 int 和 list 的版本。
    """
    # TODO: 在这里实现(默认分支 + 两个 @to_str.register)
    raise NotImplementedError

w1_05_context_managers.py

exercises/week1/w1_05_context_managers.py
"""Week 1 · M3 · 上下文管理器

学习目标:把 `with` 展开成 `__enter__`/`__exit__`,记住返回 True 会吞异常;
      用 `@contextmanager` + `try/finally` 写"用完一定恢复"的工具;
      用 `ExitStack` 管理数量不定的资源。
用法:uv run pytest exercises/week1/test_w1_05_context_managers.py -q
做法:把每个 TODO 换成你的实现,反复运行 pytest 直到全绿;
      每个"临时改变全局状态"的实现都必须放在 try/finally 里恢复。

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> from contextlib import contextmanager
>>> @contextmanager
>>> def cm():
>>>     print("enter"); yield "值"; print("exit")
>>> with cm() as v: print("body", v)
>>> class Eat:
>>>     def __enter__(self): return self
>>>     def __exit__(self, et, e, tb): print("exit", et); return True
>>> with Eat(): raise ValueError("boom")
>>> print("还活着?")            # 异常被吞了吗?
"""

import os  # noqa: F401  temp_env/cd 会用到
import time  # noqa: F401  Timer 会用到
from collections.abc import Iterable, Iterator
from contextlib import ExitStack, contextmanager  # noqa: F401  open_all 会用到
from pathlib import Path
from types import TracebackType
from typing import TextIO


class Timer:
    """类实现的上下文管理器:记录 with 块耗时到 self.elapsed(秒)。

    例:with Timer() as t: ...  然后 t.elapsed > 0
        块里抛异常时 elapsed 也要被记录,异常照常向外传。
    """

    def __init__(self) -> None:
        self.elapsed: float = 0.0
        self._start: float = 0.0

    def __enter__(self) -> Timer:
        """记下开始时间并返回 self(这样 `as t` 拿到的是计时器本身)。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        """算出耗时写进 self.elapsed;不要返回 True,异常应该继续往外抛。"""
        # TODO: 在这里实现
        raise NotImplementedError


@contextmanager
def temp_env(**variables: str) -> Iterator[None]:
    """临时设置环境变量,退出时恢复原样(原本不存在的要删掉)。

    例:with temp_env(MODE="test"): os.environ["MODE"] == "test"
        退出后 "MODE" 又消失了;块里抛异常也要恢复。
    提示:先把旧值存起来(用 os.environ.get),再 try/finally 恢复。
    """
    # TODO: 在这里实现
    raise NotImplementedError


@contextmanager
def cd(path: Path) -> Iterator[Path]:
    """临时切换工作目录,退出时切回来(即使块里抛异常)。

    yield 出去的是切换后的目录(Path.cwd())。
    例:with cd(tmp_path) as here: Path.cwd() == here
    提示:os.chdir + try/finally。
    """
    # TODO: 在这里实现
    raise NotImplementedError


@contextmanager
def open_all(paths: Iterable[Path]) -> Iterator[list[TextIO]]:
    """一次打开任意多个文件(UTF-8 只读),退出时全部关闭。

    例:with open_all([a, b]) as files: [f.read() for f in files]
        退出后每个 f.closed 都是 True;paths 为空时 yield 空列表。
    提示:contextlib.ExitStack().enter_context(p.open(...))。
    """
    # TODO: 在这里实现
    raise NotImplementedError


@contextmanager
def suppress_and_log(exc_type: type[BaseException], log: list[str]) -> Iterator[None]:
    """吞掉指定类型的异常,并把 repr(异常) 追加到 log;其它异常照常抛出。

    例:with suppress_and_log(ValueError, logs): raise ValueError("x")
        -> 不报错,logs == ["ValueError('x')"]
    提示:try/except exc_type as e,记录后什么都不做就等于吞掉。
    """
    # TODO: 在这里实现
    raise NotImplementedError


@contextmanager
def atomic_write(path: Path) -> Iterator[TextIO]:
    """原子写:先写同目录的 .tmp 文件,成功后 replace 覆盖目标。

    块里抛异常时目标文件保持原样,且不留下临时文件。
    例:with atomic_write(p) as f: f.write("新内容")
    提示:tmp = path.with_name(path.name + ".tmp");失败时
          tmp.unlink(missing_ok=True)。
    """
    # TODO: 在这里实现
    raise NotImplementedError

w1_06_functional.py

exercises/week1/w1_06_functional.py
"""Week 1 · M3 · 函数式工具的定位

学习目标:知道 `sorted(key=)`/`itemgetter`/`methodcaller`/`partial` 各自解决什么问题;
      `reduce` 只在"没有内建聚合"时用(比如组合函数);
      同一件事用 `map/filter` 和推导式各写一遍,自己判断哪种更好读。
用法:uv run pytest exercises/week1/test_w1_06_functional.py -q
做法:把每个 TODO 换成你的实现,反复运行 pytest 直到全绿;
      做完在日志里回答:squares_of_evens 的两种写法,你更愿意读哪种?

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> from operator import itemgetter, methodcaller
>>> from functools import partial, reduce
>>> rows = [{"n": "b", "a": 2}, {"n": "a", "a": 2}]
>>> print(sorted(rows, key=itemgetter("a", "n")))
>>> print(list(map(methodcaller("upper"), ["ab"])))
>>> print(partial(pow, 2)(10), reduce(lambda x, y: x + y, [1, 2, 3]))
>>> print(list(filter(None, [0, 1, "", "x"])))
"""

from collections.abc import Callable, Iterable, Sequence
from functools import partial, reduce  # noqa: F401  两个都会用到
from operator import itemgetter, methodcaller  # noqa: F401  排序与批量调方法
from typing import Any


def sort_by_fields(
    records: Iterable[dict[str, Any]], fields: Sequence[str]
) -> list[dict[str, Any]]:
    """按多个字段升序排序,返回新列表(原列表不动)。

    fields 为空时按原顺序返回。
    例:sort_by_fields(rows, ["city", "age"]) 先按 city 再按 age
    提示:sorted(records, key=itemgetter(*fields));itemgetter 支持多个键。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def compose_reduce(*fns: Callable[[Any], Any]) -> Callable[[Any], Any]:
    """用 functools.reduce 实现从右到左的函数组合。

    compose_reduce(f, g)(x) == f(g(x));不传函数时是恒等函数。
    例:compose_reduce(str, lambda x: x + 1)(1) -> "2"
    提示:reduce 的初值是"输入值",每一步把它喂给下一个函数。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def squares_of_evens_map(nums: Iterable[int]) -> list[int]:
    """偶数的平方——用 map/filter 写。

    例:squares_of_evens_map([1, 2, 3, 4]) -> [4, 16]
    提示:filter 挑偶数,map 求平方,最后 list()。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def squares_of_evens_comprehension(nums: Iterable[int]) -> list[int]:
    """偶数的平方——用列表推导式写(结果必须与上一个函数完全一致)。

    例:squares_of_evens_comprehension([1, 2, 3, 4]) -> [4, 16]
    提示:[x * x for x in nums if x % 2 == 0]。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def _apply_rate(rate: float, amount: float) -> float:
    """按比例算出金额(已给出):保留 2 位小数。"""
    return round(amount * rate, 2)


def make_rate_applier(rate: float) -> Callable[[float], float]:
    """用 functools.partial 预填 _apply_rate 的 rate,返回只收 amount 的函数。

    例:make_rate_applier(0.1)(100) -> 10.0
    提示:partial 只能预填靠前的位置参数,所以 rate 写在第一个参数上。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def upper_all(words: Iterable[str]) -> list[str]:
    """把每个字符串转大写——用 operator.methodcaller 而不是 lambda。

    例:upper_all(["ab", "cd"]) -> ["AB", "CD"]
    提示:map(methodcaller("upper"), words)。
    """
    # TODO: 在这里实现
    raise NotImplementedError

w1_07_data_model.py

exercises/week1/w1_07_data_model.py
"""Week 1 · M4a · 数据模型与魔术方法

学习目标:`__repr__` 给开发者、`__str__` 给用户;定义 `__eq__` 就要配 `__hash__`;
      用 `__lt__` + `total_ordering` 换全套比较;实现序列协议后切片也能用;
      `__getattr__` 只在正常查找失败时才被调用,`__format__` 决定 f-string 的行为。
用法:uv run pytest exercises/week1/test_w1_07_data_model.py -q
做法:把每个 TODO 换成你的实现,反复运行 pytest 直到全绿;
      写 __eq__ 时顺手问自己:这个对象需要能进 set/dict 吗?

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> class P:
>>>     def __init__(self, x): self.x = x
>>>     def __eq__(self, o): return self.x == o.x
>>> p = P(1); print(p == P(1), p is P(1))
>>> # print({p})            → TypeError: unhashable type: 'P'  为什么?
>>> class V:
>>>     def __init__(self, *xs): self.xs = list(xs)
>>>     def __len__(self): return len(self.xs)
>>>     def __getitem__(self, i): return self.xs[i]
>>> v = V(1, 2, 3); print(len(v), v[0], 2 in v, list(reversed(v)), bool(V()))
"""

from collections.abc import Iterable, Iterator
from decimal import Decimal
from functools import total_ordering
from typing import Any


@total_ordering
class Money:
    """一笔钱:金额 + 货币。不同货币不能相加也不能比较。

    例:Money(Decimal("1.50"), "CNY") + Money(Decimal("0.50"), "CNY")
        -> Money(Decimal('2.00'), 'CNY')
        sum([m1, m2]) 能用(靠 __radd__ 处理起始值 0)
    """

    def __init__(self, amount: Decimal, currency: str) -> None:
        self.amount = amount
        self.currency = currency

    def __repr__(self) -> str:
        """给开发者看,最好能 eval 回来。

        例:repr(Money(Decimal("1.50"), "CNY"))
            -> "Money(Decimal('1.50'), 'CNY')"
        """
        # TODO: 在这里实现
        raise NotImplementedError

    def __str__(self) -> str:
        """给用户看:金额 + 空格 + 货币代码,例 "1.50 CNY"。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def __eq__(self, other: object) -> bool:
        """金额与货币都相同才相等;和非 Money 比较返回 NotImplemented。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def __hash__(self) -> int:
        """定义了 __eq__ 就必须给 __hash__,否则对象不能进 set/dict。

        提示:hash((self.amount, self.currency))。
        """
        # TODO: 在这里实现
        raise NotImplementedError

    def __lt__(self, other: Money) -> bool:
        """同货币按金额比大小;货币不同 -> ValueError("货币不同无法比较")。

        有了 __lt__ 和 __eq__,@total_ordering 会补出 <=、>、>=。
        """
        # TODO: 在这里实现
        raise NotImplementedError

    def __add__(self, other: Money) -> Money:
        """同货币相加返回新 Money;货币不同 -> ValueError("货币不同无法相加")。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def __radd__(self, other: object) -> Money:
        """让 sum() 能用:sum 的起始值是 0,0 + Money 会走到这里。

        other 是 0 时返回 self,否则返回 NotImplemented。
        """
        # TODO: 在这里实现
        raise NotImplementedError

    def __bool__(self) -> bool:
        """金额为 0 时为假值。"""
        # TODO: 在这里实现
        raise NotImplementedError


class Playlist:
    """歌单:实现序列协议,`len/下标/切片/in/for/reversed` 全都能用。

    例:p = Playlist(["a", "b", "c"]);len(p) -> 3;p[0] -> "a";
        p[:2] -> Playlist(['a', 'b'])(切片返回新的 Playlist)
    """

    def __init__(self, tracks: Iterable[str]) -> None:
        self.tracks = list(tracks)

    def __repr__(self) -> str:
        """例:Playlist(['a', 'b'])。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def __len__(self) -> int:
        """歌曲数量。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def __getitem__(self, index: int | slice) -> str | Playlist:
        """整数下标返回歌名;切片返回新的 Playlist。

        提示:isinstance(index, slice) 判断是哪一种。
        """
        # TODO: 在这里实现
        raise NotImplementedError

    def __contains__(self, item: object) -> bool:
        """`"a" in playlist`。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def __iter__(self) -> Iterator[str]:
        """让 for 直接遍历歌名。"""
        # TODO: 在这里实现
        raise NotImplementedError


class Config:
    """把字典的键当属性读:cfg.host 等价于 data["host"]。

    例:Config({"host": "localhost"}).host -> "localhost"
        缺少的键 -> AttributeError("没有配置项: port")
    """

    def __init__(self, data: dict[str, Any]) -> None:
        self.data = data

    def __getattr__(self, name: str) -> Any:
        """只有正常属性查找失败时才会调用这里(self.data 不会进来)。

        找不到 -> AttributeError(f"没有配置项: {name}")。
        """
        # TODO: 在这里实现
        raise NotImplementedError


class Celsius:
    """摄氏温度,支持 f-string 格式化。

    例:t = Celsius(20.0);f"{t}" -> "20.0°C";f"{t:.1f}" -> "20.0°C";
        f"{t:F}" -> "68.0°F"
    """

    def __init__(self, degrees: float) -> None:
        self.degrees = degrees

    def to_fahrenheit(self) -> float:
        """摄氏转华氏:degrees * 9 / 5 + 32。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def __format__(self, spec: str) -> str:
        """spec 为 "F" 时输出华氏(保留 1 位小数),否则按 spec 输出摄氏。

        例:format(Celsius(20), "F") -> "68.0°F";
            format(Celsius(20), ".2f") -> "20.00°C"
        提示:format(self.degrees, spec) 可以复用内建的数字格式化。
        """
        # TODO: 在这里实现
        raise NotImplementedError

w1_08_dataclass_enum.py

exercises/week1/w1_08_dataclass_enum.py
"""Week 1 · M4b · dataclass 与 Enum 进阶

学习目标:会用 `@dataclass(frozen=True, slots=True)` 建不可变领域模型;
      用 `field(default_factory=list)` 避开可变默认值坑,用 `__post_init__` 校验;
      用 `IntEnum/StrEnum/Flag` 表达"有限取值",并让 `match` 直接匹配枚举成员。
用法:uv run pytest exercises/week1/test_w1_08_dataclass_enum.py -q
做法:把每个 TODO 换成你的实现,反复运行 pytest 直到全绿;
      枚举成员的值要和 docstring 里写的一致,测试是照它写的。

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> from dataclasses import dataclass, field, replace
>>> from enum import IntEnum, StrEnum, auto
>>> @dataclass(frozen=True, slots=True)
>>> class T:
>>>     name: str
>>>     tags: list[str] = field(default_factory=list)
>>> t = T("a"); print(t, t == T("a"), hash(t) == hash(T("a")))
>>> # t.name = "b"          → 什么异常?
>>> print(replace(t, name="b"))
>>> class S(StrEnum):
>>>     DONE = "done"
>>> print(S.DONE == "done", f"{S.DONE}", list(S))
"""

from collections.abc import Iterable
from dataclasses import asdict, dataclass, field, replace  # noqa: F401  都会用到
from enum import Flag, IntEnum, StrEnum, auto  # noqa: F401  三种枚举 + auto
from typing import Any


class Priority(IntEnum):
    """任务优先级:IntEnum 可以直接比大小、直接当整数用。

    成员:LOW = 1、NORMAL = 2、HIGH = 3(NORMAL 已给出当样板)。
    例:Priority.HIGH > Priority.LOW -> True;int(Priority.NORMAL) -> 2
    """

    NORMAL = 2
    # TODO: 在这里补上 LOW 与 HIGH


class Status(StrEnum):
    """任务状态:StrEnum 成员既是枚举也是字符串,直接能进 JSON。

    成员:TODO = "todo"、DOING = "doing"、DONE = "done"(第一个已给出)。
    例:Status.DONE == "done" -> True;f"{Status.DONE}" -> "done"
    """

    TODO = "todo"
    # TODO: 在这里补上 DOING 与 DONE


class Weekday(Flag):
    """星期:Flag 可以用 | 组合、用 in 判断包含。

    成员:MON/TUE/WED/THU/FRI/SAT/SUN 用 auto(),再加一个组合成员
    WEEKEND = SAT | SUN。
    例:Weekday.SAT in Weekday.WEEKEND -> True;
        (Weekday.MON | Weekday.TUE) 里有两个成员
    """

    # TODO: 在这里补上七个 auto() 成员和 WEEKEND 组合


@dataclass(frozen=True, slots=True)
class Task:
    """一条任务:不可变(frozen)+ 省内存防拼错(slots)。

    例:Task("写周报") -> Task(title='写周报', priority=<Priority.NORMAL: 2>,
        status=<Status.TODO: 'todo'>, tags=[])
        Task("") -> ValueError("标题不能为空")
    """

    title: str
    priority: Priority = Priority.NORMAL
    status: Status = Status.TODO
    tags: list[str] = field(default_factory=list)

    def __post_init__(self) -> None:
        """校验:title 去掉首尾空白后不能为空,否则 ValueError("标题不能为空")。

        提示:frozen 的对象要改字段得用 object.__setattr__,这里只校验不改。
        """
        # TODO: 在这里实现
        raise NotImplementedError

    def with_status(self, new: Status) -> Task:
        """返回一个只有 status 不同的新 Task(原对象不变)。

        例:Task("a").with_status(Status.DONE).status -> Status.DONE
        提示:dataclasses.replace(self, status=new)。
        """
        # TODO: 在这里实现
        raise NotImplementedError


def sort_tasks(tasks: Iterable[Task]) -> list[Task]:
    """排序:优先级从高到低,同优先级按标题升序。

    例:[NORMAL "b", HIGH "a", NORMAL "a"] -> [HIGH "a", NORMAL "a", NORMAL "b"]
    提示:sorted(key=lambda t: (-t.priority, t.title))。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def to_dict(task: Task) -> dict[str, Any]:
    """把 Task 变成纯 Python 值的字典(枚举转成它的值,方便 json.dumps)。

    例:to_dict(Task("a", tags=["x"])) ->
        {"title": "a", "priority": 2, "status": "todo", "tags": ["x"]}
    提示:dataclasses.asdict 之后再把两个枚举字段换成 .value。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def from_dict(d: dict[str, Any]) -> Task:
    """从字典还原 Task:缺省的字段用默认值,枚举从值还原。

    例:from_dict({"title": "a", "priority": 3}) ->
        Task(title='a', priority=<Priority.HIGH: 3>, ...)
        缺 title -> KeyError
    提示:Priority(d["priority"])、Status(d["status"]) 能按值查回成员。
    """
    # TODO: 在这里实现
    raise NotImplementedError


def describe(status: Status) -> str:
    """用 match 匹配枚举成员,返回中文说明。

    Status.TODO -> "待办"、DOING -> "进行中"、DONE -> "已完成"。
    例:describe(Status.DOING) -> "进行中"
    提示:case Status.TODO: ...(注意 case x: 是"捕获"而不是比较)。
    """
    # TODO: 在这里实现
    raise NotImplementedError

w1_09_oop_protocols.py

exercises/week1/w1_09_oop_protocols.py
"""Week 1 · M4c · OOP 进阶:ABC、Protocol、property、协作式 super

学习目标:用 `abc.ABC` + `@abstractmethod` 强制子类实现,抽象类不能实例化;
      用 `typing.Protocol` 做结构化子类型——不继承也算实现(鸭子类型有了类型检查);
      用 `@property` 做校验、`__init_subclass__` 自动注册、协作式 `super()`。
用法:uv run pytest exercises/week1/test_w1_09_oop_protocols.py -q
做法:把每个 TODO 换成你的实现,反复运行 pytest 直到全绿;
      写完想一想:哪些地方组合比继承更合适?

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> from abc import ABC, abstractmethod
>>> from typing import Protocol, runtime_checkable
>>> class S(ABC):
>>>     @abstractmethod
>>>     def area(self) -> float: ...
>>> # print(S())        → 什么异常?
>>> @runtime_checkable
>>> class D(Protocol):
>>>     def draw(self) -> str: ...
>>> class A:
>>>     def draw(self) -> str: return "A"
>>> print(isinstance(A(), D), issubclass(A, D))
>>> class Base:
>>>     def __init__(self, **kw): print("Base", kw); super().__init__()
>>> class Kid(Base):
>>>     def __init__(self, x, **kw): super().__init__(**kw); self.x = x
>>> Kid(1, y=2); print(Kid.__mro__)
"""

from abc import ABC, abstractmethod
from collections.abc import Iterable
from math import pi  # noqa: F401  Circle 会用到
from typing import Any, Protocol, runtime_checkable


class Shape(ABC):
    """图形抽象基类(已给出):子类必须实现 area 与 perimeter。

    例:Shape() -> TypeError(抽象类不能实例化)
    """

    @abstractmethod
    def area(self) -> float:
        """面积。"""

    @abstractmethod
    def perimeter(self) -> float:
        """周长。"""

    def summary(self) -> str:
        """所有子类共用的具体方法(已给出):面积与周长各保留 2 位小数。"""
        return f"面积 {self.area():.2f} 周长 {self.perimeter():.2f}"


class Circle(Shape):
    """圆:area = pi * r²,perimeter = 2 * pi * r。

    例:Circle(1).area() -> 3.14159...;半径 <= 0 -> ValueError
    """

    def __init__(self, radius: float) -> None:
        if radius <= 0:
            raise ValueError("半径必须 > 0")
        self.radius = radius

    def area(self) -> float:
        """圆面积。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def perimeter(self) -> float:
        """圆周长。"""
        # TODO: 在这里实现
        raise NotImplementedError


class Rect(Shape):
    """矩形:area = w * h,perimeter = 2 * (w + h)。

    例:Rect(2, 3).area() -> 6;边长 <= 0 -> ValueError
    """

    def __init__(self, width: float, height: float) -> None:
        if width <= 0 or height <= 0:
            raise ValueError("边长必须 > 0")
        self.width = width
        self.height = height

    def area(self) -> float:
        """矩形面积。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def perimeter(self) -> float:
        """矩形周长。"""
        # TODO: 在这里实现
        raise NotImplementedError


@runtime_checkable
class Drawable(Protocol):
    """能画出自己的东西(已给出):只要有 draw() -> str 就算实现,不用继承。

    Protocol 是"接口形状",不该被实例化。
    """

    def draw(self) -> str:
        """返回一行字符画。"""
        ...


class Button:
    """按钮:实现 Drawable,但不继承它。

    例:Button("确定").draw() -> "[确定]"
    """

    def __init__(self, label: str) -> None:
        self.label = label

    def draw(self) -> str:
        """画成 [标签] 的样子。"""
        # TODO: 在这里实现
        raise NotImplementedError


class Label:
    """文本标签:同样实现 Drawable,和 Button 毫无继承关系。

    例:Label("你好").draw() -> "你好"
    """

    def __init__(self, text: str) -> None:
        self.text = text

    def draw(self) -> str:
        """直接返回文本。"""
        # TODO: 在这里实现
        raise NotImplementedError


def render_all(items: Iterable[Drawable]) -> list[str]:
    """把每个可画对象画出来,收集成列表。

    例:render_all([Button("A"), Label("b")]) -> ["[A]", "b"]
    提示:类型注解写 Drawable 就够了,不需要 isinstance 检查。
    """
    # TODO: 在这里实现
    raise NotImplementedError


@runtime_checkable
class Repository(Protocol):
    """仓库接口(已给出):下周的 SQLite 版会再实现一次同样的三个方法。"""

    def add(self, key: str, value: str) -> None:
        """存一条。"""
        ...

    def get(self, key: str) -> str:
        """按 key 取,取不到抛 KeyError。"""
        ...

    def list(self) -> list[str]:
        """按插入顺序返回全部 value。"""
        ...


class InMemoryRepository:
    """用字典实现 Repository:不继承 Protocol,靠"长得像"来满足接口。

    例:r = InMemoryRepository(); r.add("a", "苹果"); r.get("a") -> "苹果"
        r.get("x") -> KeyError("找不到 x");重复 add 同一个 key 覆盖旧值
    """

    def __init__(self) -> None:
        self._items: dict[str, str] = {}

    def add(self, key: str, value: str) -> None:
        """存一条(key 重复就覆盖)。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def get(self, key: str) -> str:
        """按 key 取;不存在 -> KeyError("找不到 x")。"""
        # TODO: 在这里实现
        raise NotImplementedError

    def list(self) -> list[str]:
        """按插入顺序返回全部 value。"""
        # TODO: 在这里实现
        raise NotImplementedError


class Temperature:
    """用 @property 给属性加校验:低于绝对零度直接拒绝。

    例:t = Temperature(20); t.celsius = 25; t.fahrenheit -> 77.0
        t.celsius = -300 -> ValueError("低于绝对零度")
    """

    def __init__(self, celsius: float) -> None:
        # 走 setter,这样 __init__ 也享受校验
        self.celsius = celsius

    @property
    def celsius(self) -> float:
        """摄氏温度(读)。"""
        # TODO: 在这里实现(返回内部保存的 _celsius)
        raise NotImplementedError

    @celsius.setter
    def celsius(self, value: float) -> None:
        """摄氏温度(写):小于 -273.15 时 ValueError("低于绝对零度")。"""
        # TODO: 在这里实现
        raise NotImplementedError

    @property
    def fahrenheit(self) -> float:
        """只读的华氏温度:celsius * 9 / 5 + 32。"""
        # TODO: 在这里实现
        raise NotImplementedError


class Plugin:
    """插件基类:每定义一个子类就自动登记到 Plugin.registry。

    例:class Hello(Plugin): ...  之后 Plugin.registry["hello"] is Hello
        注册用的名字是类名小写。
    """

    registry: dict[str, type[Plugin]] = {}

    def __init_subclass__(cls, **kwargs: Any) -> None:
        """定义子类时自动调用(不是实例化时)。

        提示:先 super().__init_subclass__(**kwargs),再把 cls 放进 registry。
        """
        # TODO: 在这里实现
        raise NotImplementedError


class Vehicle:
    """协作式 __init__ 的基类(已给出):只认自己的参数,剩下的往上传。"""

    def __init__(self, *, name: str, **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self.name = name


class Car(Vehicle):
    """轿车:多一个 doors 参数(默认 4),其余参数交给父类。

    例:Car(name="A", doors=2).name -> "A"
    """

    def __init__(self, *, doors: int = 4, **kwargs: Any) -> None:
        """记住:先 super().__init__(**kwargs) 再设自己的属性。"""
        # TODO: 在这里实现
        raise NotImplementedError


class Truck(Vehicle):
    """卡车:多一个 payload_kg 参数(必填),其余参数交给父类。

    例:Truck(name="B", payload_kg=1000).payload_kg -> 1000
    """

    def __init__(self, *, payload_kg: float, **kwargs: Any) -> None:
        """同样先调 super().__init__(**kwargs),漏传 name 会直接报错。"""
        # TODO: 在这里实现
        raise NotImplementedError

w1_10_exceptions_syntax.py

exercises/week1/w1_10_exceptions_syntax.py
"""Week 1 · M5 · 异常进阶与 3.12–3.14 新语法

学习目标:设计包级异常层次并用 `raise ... from e` 保留原因;用 ExceptionGroup/except*
收集多个错误;match 的类模式与守卫;PEP 695 泛型;t-string;位置仅参数;
try/else/finally 语义。
用法:uv run pytest exercises/week1/test_w1_10_exceptions_syntax.py -q
做法:把每个 TODO 换成你的实现,反复运行直到全部通过。
      卡住 8 分钟以上再按 ai-guide.md 的模板提问;拿到提示后自己重写。

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> def f(x):
...     try:
...         return int(x)
...     except ValueError:
...         return -1
...     finally:
...         print("finally")
>>> print(f("7"), f("x"))
>>> try:
...     raise ExceptionGroup("g", [ValueError("a"), TypeError("b")])
... except* ValueError as eg:
...     print("V", [str(e) for e in eg.exceptions])
... except* TypeError as eg:
...     print("T", [str(e) for e in eg.exceptions])
>>> match {"kind": "add", "x": 1}:
...     case {"kind": "add", "x": x}: print("add", x)
...     case _: print("other")
"""

from collections.abc import Callable, Sequence
from dataclasses import dataclass
from string.templatelib import Interpolation, Template  # noqa: F401  (render 会用到)

# ---- 任务 1:异常层次 ----


class KBError(Exception):
    """知识库所有错误的基类。调用方只需 `except KBError`。"""


class NoteNotFound(KBError):
    """按 id 找不到笔记。应有属性 note_id,消息形如 "笔记 9 不存在"。"""

    def __init__(self, note_id: int) -> None:
        # TODO: 调用 super().__init__ 生成消息,并保存 self.note_id
        raise NotImplementedError


class InvalidNote(KBError):
    """笔记内容不合法。"""


# ---- 任务 2:转换异常并保留原因 ----


def load_note(store: dict[int, str], note_id: int) -> str:
    """从字典取笔记;KeyError 转成 NoteNotFound,并用 `from e` 保留原因。

    例:load_note({1: "a"}, 1) -> "a";load_note({}, 9) 抛 NoteNotFound,其 __cause__
    是 KeyError
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 3:收集全部错误再一起抛 ----


def validate_note(text: str) -> str:
    """单条校验:去首尾空白后非空、≤ 50 字,否则抛 InvalidNote;返回清理后的文本。"""
    # TODO: 在这里实现
    raise NotImplementedError


def validate_many(items: Sequence[str]) -> list[str]:
    """逐条校验,合法的收集起来;只要有错误就在最后抛 ExceptionGroup(包含全部错误)。

    每个错误用 e.add_note(f"第 {index} 项") 标注位置。
    例:validate_many(["a", "b"]) -> ["a", "b"]
        validate_many(["", "x" * 60]) 抛 ExceptionGroup,含 2 个 InvalidNote
    """
    # TODO: 在这里实现
    raise NotImplementedError


# ---- 任务 4:match 类模式 ----


@dataclass(frozen=True)
class Add:
    x: int
    y: int


@dataclass(frozen=True)
class Greet:
    name: str


@dataclass(frozen=True)
class Quit:
    pass


type Command = Add | Greet | Quit


def handle(cmd: object) -> str:
    """用 match 的类模式分派命令。

    例:handle(Add(2, 3)) -> "结果:5";handle(Add(0, 0)) -> "零";
        handle(Greet("小明")) -> "你好,小明";handle(Greet("")) -> "你好,陌生人";
        handle(Quit()) -> "再见";其他任何值 -> "未知命令"
    """
    # TODO: 用 match/case(类模式 + 守卫)实现
    raise NotImplementedError


# ---- 任务 5:PEP 695 泛型 ----


class Stack[T]:
    """泛型栈:push/pop/peek;空栈 pop/peek 抛 IndexError;支持 len() 与 bool()。"""

    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        # TODO
        raise NotImplementedError

    def pop(self) -> T:
        # TODO
        raise NotImplementedError

    def peek(self) -> T:
        # TODO
        raise NotImplementedError

    def __len__(self) -> int:
        # TODO
        raise NotImplementedError

    def __bool__(self) -> bool:
        # TODO
        raise NotImplementedError


def first[T](xs: Sequence[T], default: T | None = None) -> T | None:
    """返回序列第一个元素;空序列返回 default。"""
    # TODO
    raise NotImplementedError


def apply_all[T, R](fns: Sequence[Callable[[T], R]], value: T) -> list[R]:
    """把同一个值依次交给每个函数,返回结果列表。例:apply_all([str, abs], -4) ->
    ["-4", 4]"""
    # TODO
    raise NotImplementedError


# ---- 任务 6:t-string(3.14)----


def render(t: Template) -> str:
    """渲染模板字符串:字面部分原样输出,插值部分先 format(value, format_spec) 再把 < >
    转义。

    提示:遍历 Template 会依次得到 str 与 Interpolation(.value / .format_spec)。
    例:name = "<b>"; render(t"hi {name}") -> "hi &lt;b&gt;"
    """
    # TODO
    raise NotImplementedError


# ---- 任务 7:位置仅参数与 finally 语义 ----


def clamp(
    value: float, /, lo: float = 0.0, hi: float = 1.0, *, strict: bool = False
) -> float:
    """value 只能按位置传;strict=True 时越界抛 ValueError 而不是夹住。"""
    # TODO
    raise NotImplementedError


def read_with_cleanup(reader: Callable[[], str], log: list[str]) -> str | None:
    """演示 try/except/else/finally:

    reader() 成功 → log 追加 "ok" 并返回文本;
    抛 OSError → log 追加 f"error: {e}" 并返回 None;
    无论如何最后追加 "closed"。
    """
    # TODO
    raise NotImplementedError