跳转至

Day 2 参考答案(默认折叠)

做完再看

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

点击展开:01_exceptions.py
exercises/solutions/day2/01_exceptions.py
"""Day 2 · Block 1 · 异常处理(参考答案)

学习目标:用 try/except 具体异常/else/finally 精确处理错误,用 raise 主动报错;
      把"可能失败的操作"包成返回 None 或默认值的安全函数,并写输入校验循环;
      知道裸 except 会连 Ctrl+C 和拼写错误一起吞掉,所以永远写具体异常。
用法:uv run python exercises/solutions/day2/01_exceptions.py
做法:这是参考答案。先自己写完 exercises/day2/01_exceptions.py,再来逐行对照,
      看看自己的写法和这里差在哪,而不是先看答案再抄。

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> def f(x):
>>>     try:
>>>         print("try")
>>>         r = 10 / x
>>>     except ZeroDivisionError as e:
>>>         print("except", e)
>>>         return "错误"
>>>     else:
>>>         print("else")
>>>         return r
>>>     finally:
>>>         print("finally")
>>> print(f(2))
>>> print(f(0))
>>> # int("abc") → ValueError    "3" + 1 → TypeError
>>> # [1][5] → IndexError    {}["k"] → KeyError
"""

from collections.abc import Callable


# ---- 任务 4(先给出,后面的 validate_score 要用) ----
class InvalidScoreError(ValueError):
    """分数不合法时抛出的自定义异常。

    继承 ValueError,所以 `except ValueError:` 也能捕获它。
    例:raise InvalidScoreError("分数 101 不在 0-100")
    """


# ---- 任务 1 ----
def parse_int(s: str) -> int | None:
    """把字符串转成整数;转换失败返回 None。

    例:parse_int(" 42 ") -> 42;parse_int("abc") -> None
    """
    try:
        return int(s.strip())
    except ValueError:
        return None


# ---- 任务 2 ----
def make_fake_input(answers: list[str]) -> Callable[[str], str]:
    """做一个假的 input 函数:每次调用按顺序返回 answers 里的下一个答案。

    有了它,测试就不需要真的用键盘输入了。
    例:fake = make_fake_input(["abc", "5"]);fake("请输入:") -> "abc"
    """
    remaining = list(answers)

    def fake_input(prompt: str = "") -> str:
        return remaining.pop(0)

    return fake_input


def read_positive_int(prompt: str, input_func: Callable[[str], str] = input) -> int:
    """反复调用 input_func(prompt),直到拿到一个正整数为止。

    例:read_positive_int("n=", make_fake_input(["abc", "-1", "5"])) -> 5
    """
    while True:
        value = parse_int(input_func(prompt))
        if value is None:
            print("请输入一个整数,例如 5。")
        elif value <= 0:
            print("请输入大于 0 的整数。")
        else:
            return value


# ---- 任务 3 ----
def safe_get(lst: list, index: int, default=None):
    """取 lst[index];下标越界(IndexError)时返回 default。

    注意负数下标是合法的:safe_get([1, 2], -1) 取到的是最后一个元素。
    例:safe_get([1, 2], 5, -1) -> -1;safe_get([1, 2], 1) -> 2
    """
    try:
        return lst[index]
    except IndexError:
        return default


# ---- 任务 5 ----
def validate_score(score: int) -> int:
    """校验分数必须在 0-100 之间;合法则原样返回,不合法则主动报错。

    例:validate_score(88) -> 88;validate_score(101) 抛 InvalidScoreError
    """
    if not 0 <= score <= 100:
        raise InvalidScoreError(f"分数 {score} 不在 0-100")
    return score


# ---- 任务 6 ----
def divide_all(pairs: list[tuple[float, float]]) -> list[float | None]:
    """逐对相除;除零的位置放 None,不让一个坏数据中断整批计算。

    例:divide_all([(6, 3), (1, 0), (5, 2)]) -> [2.0, None, 2.5]
    """
    results: list[float | None] = []
    for a, b in pairs:
        try:
            results.append(a / b)
        except ZeroDivisionError:
            results.append(None)
    return results


# ---- 任务 7 ----
def get_config_int(config: dict, key: str) -> int | None:
    """从配置字典里读一个整数;键不存在或值转不成 int 时返回 None。

    一次捕获两种异常要写成带括号的元组:except (KeyError, ValueError):
    例:get_config_int({"n": "7"}, "n") -> 7;get_config_int({}, "n") -> None
    """
    try:
        return int(config[key])
    except KeyError, ValueError:
        return None


# ---- 任务 8 ----
def withdraw(balance: float, amount: float) -> float:
    """从余额里取钱,返回取款后的余额;金额不合法时 raise ValueError。

    例:withdraw(100, 30) -> 70;withdraw(100, 200) 抛 ValueError("余额不足")
    """
    if amount <= 0:
        raise ValueError("金额必须为正")
    if amount > balance:
        raise ValueError("余额不足")
    return balance - amount


# ====================== 自测:不要改动下面的代码 ======================


def _check_parse_int() -> None:
    assert parse_int(" 42 ") == 42, "parse_int(' 42 ') 应为 42"
    assert parse_int("abc") is None, "parse_int('abc') 应为 None"
    assert parse_int("3.5") is None, "parse_int('3.5') 应为 None(int 不认小数点)"


def _check_read_positive_int() -> None:
    fake = make_fake_input(["abc", "-1", "5"])
    got = read_positive_int("请输入一个正整数:", fake)
    assert got == 5, f"跳过 'abc' 和 '-1' 后应返回 5,实际 {got}"


def _check_safe_get() -> None:
    assert safe_get([1, 2], 5, -1) == -1, "下标 5 越界,应返回 default -1"
    assert safe_get([1, 2], 1) == 2, "safe_get([1, 2], 1) 应为 2"
    assert safe_get([1, 2], -1) == 2, "下标 -1 是合法的,应为最后一个元素 2"
    assert safe_get([], 0) is None, "default 默认是 None"


def _check_validate_score() -> None:
    assert issubclass(InvalidScoreError, ValueError), (
        "InvalidScoreError 应继承 ValueError"
    )
    assert validate_score(88) == 88, "88 在 0-100 内,应原样返回"
    assert validate_score(0) == 0, "0 是合法分数(边界值)"
    assert validate_score(100) == 100, "100 是合法分数(边界值)"
    try:
        validate_score(101)
    except InvalidScoreError as e:
        assert "101" in str(e), f"错误消息里应包含分数 101,实际 {str(e)!r}"
    else:
        raise AssertionError("应抛出 InvalidScoreError")
    try:
        validate_score(-1)
    except ValueError:
        pass
    else:
        raise AssertionError("应抛出 ValueError(自定义异常也能被它捕获)")


def _check_divide_all() -> None:
    got = divide_all([(6, 3), (1, 0), (5, 2)])
    assert got == [2.0, None, 2.5], f"应为 [2.0, None, 2.5],实际 {got}"
    assert divide_all([]) == [], "空列表应返回空列表"


def _check_get_config_int() -> None:
    assert get_config_int({"n": "7"}, "n") == 7, "字符串 '7' 应转成 7"
    assert get_config_int({"n": "x"}, "n") is None, "'x' 转不成 int,应为 None"
    assert get_config_int({}, "n") is None, "键不存在,应为 None"
    assert get_config_int({"n": 9}, "n") == 9, "值本来就是 int 也要能处理"


def _check_withdraw() -> None:
    assert withdraw(100, 30) == 70, "100 取 30 应剩 70"
    try:
        withdraw(100, 0)
    except ValueError as e:
        assert str(e) == "金额必须为正", f"消息应为 '金额必须为正',实际 {str(e)!r}"
    else:
        raise AssertionError("应抛出 ValueError")
    try:
        withdraw(100, 200)
    except ValueError as e:
        assert str(e) == "余额不足", f"消息应为 '余额不足',实际 {str(e)!r}"
    else:
        raise AssertionError("应抛出 ValueError")


CHECKS = [
    _check_parse_int,
    _check_read_positive_int,
    _check_safe_get,
    _check_validate_score,
    _check_divide_all,
    _check_get_config_int,
    _check_withdraw,
]


def _run_checks() -> None:
    passed = 0
    for check in CHECKS:
        name = check.__name__.removeprefix("_check_")
        try:
            check()
        except NotImplementedError:
            print(f"[TODO] {name}: 还没实现")
        except AssertionError as e:
            print(f"[FAIL] {name}: {e}")
        except Exception as e:
            print(f"[ERROR] {name}: {type(e).__name__}: {e}")
        else:
            passed += 1
            print(f"[OK]   {name}")
    total = len(CHECKS)
    tail = ",本文件全部完成!" if passed == total else ""
    print(f"\n{passed}/{total} 通过{tail}")


if __name__ == "__main__":
    _run_checks()
点击展开:02_files_paths.py
exercises/solutions/day2/02_files_paths.py
"""Day 2 · Block 2a + 2b · 文件、路径、JSON、CSV(参考答案)

学习目标:用 with open(..., encoding="utf-8") 和 pathlib.Path 读写文本文件;
      亲手复现 Windows 默认 GBK 造成的 UnicodeDecodeError 并修好它;
      用 json(ensure_ascii=False)存档、用 csv.DictReader/DictWriter 读写表格。
用法:uv run python exercises/solutions/day2/02_files_paths.py
做法:这是参考答案。先自己写完 exercises/day2/02_files_paths.py,再来逐行对照。
      注意:所有自测都在系统临时目录里进行,不会在仓库里留下任何文件。

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> from pathlib import Path
>>> p = Path("exercises/day2/data/hello.txt")
>>> print(p.read_text(encoding="utf-8"))   # 正常
>>> print(p.read_text(encoding="gbk"))     # UnicodeDecodeError(或乱码)
>>> print(open(p).read())                  # 中文 Windows 上默认 gbk → 同样出错
"""

import csv
import json
import tempfile
from pathlib import Path


# ---- 任务 1 ----
def write_lines(path: Path, lines: list[str]) -> None:
    """用 UTF-8 把每行写进文件,每行末尾补一个换行符;父目录不存在就创建。

    例:write_lines(folder / "sub" / "a.txt", ["a", "你好"]) 会先建好 sub 目录
    """
    path.parent.mkdir(parents=True, exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        for line in lines:
            f.write(line + "\n")


# ---- 任务 2 ----
def read_lines(path: Path) -> list[str]:
    """用 UTF-8 读取文件的每一行,去掉行尾换行符;文件不存在返回空列表。

    例:内容是两行 "a" 和 "你好" 的文件 -> ["a", "你好"]
    """
    try:
        return path.read_text(encoding="utf-8").splitlines()
    except FileNotFoundError:
        return []


# ---- 任务 3 ----
def count_words(path: Path) -> int:
    """统计文件里用空白(空格、换行)分隔的词数。

    例:内容是 "a b" 和 "c d e" 两行的文件 -> 5
    """
    text = path.read_text(encoding="utf-8")
    return len(text.split())


# ---- 任务 4 ----
def append_log(path: Path, message: str) -> None:
    """以追加模式("a")在文件末尾写一行日志,不覆盖原有内容。

    例:连续 append_log(p, "第一条") 和 append_log(p, "第二条") 后文件有两行
    """
    with open(path, "a", encoding="utf-8") as f:
        f.write(message + "\n")


# ---- 任务 5 ----
def ensure_dir(path: Path) -> Path:
    """确保目录存在(连中间层一起创建),已存在也不报错;返回这个路径。

    例:ensure_dir(folder / "a" / "b") -> folder/a/b,且重复调用不会出错
    """
    path.mkdir(parents=True, exist_ok=True)
    return path


# ---- 任务 6 ----
def demo_gbk_pitfall(folder: Path) -> str:
    """故意踩一次编码坑:UTF-8 写入中文,再用 encoding="gbk" 读回来。

    规则:读取时抛 UnicodeDecodeError -> 返回
        "UnicodeDecodeError: 请显式指定 encoding='utf-8'";
    没抛异常但读出的内容和原文不一样(乱码)也算踩中,返回的字符串同样以
        "UnicodeDecodeError" 开头;两种情况都没有才返回 "未触发"。
    例:demo_gbk_pitfall(folder) -> "UnicodeDecodeError: 请显式指定 ..."
    """
    text = "你好,世界"
    path = folder / "gbk_pitfall.txt"
    path.write_text(text, encoding="utf-8")
    try:
        got = path.read_text(encoding="gbk")
    except UnicodeDecodeError:
        return "UnicodeDecodeError: 请显式指定 encoding='utf-8'"
    if got != text:
        return "UnicodeDecodeError: 读成了乱码,请显式指定 encoding='utf-8'"
    return "未触发"


# ---- 任务 7 ----
def save_json(path: Path, data) -> None:
    """把 data 存成 JSON 文件:UTF-8、缩进 2 空格、中文不转成 \\uXXXX。

    例:save_json(p, {"名字": "张三"}) 后文件里能直接看到 "张三"
    """
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


# ---- 任务 8 ----
def load_json(path: Path, default):
    """读回 JSON 文件;文件不存在或内容不是合法 JSON 时返回 default。

    例:load_json(不存在的路径, []) -> [];内容为 "{bad" 时也返回 default
    """
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError, json.JSONDecodeError:
        return default


# ---- 任务 9 ----
def list_files_by_suffix(folder: Path, suffix: str) -> list[str]:
    """列出目录下所有指定后缀的文件名(只要名字,不要路径),按字母排序。

    例:目录里有 a.txt、b.txt、c.md -> list_files_by_suffix(f, ".txt")
        得到 ["a.txt", "b.txt"]
    """
    return sorted(p.name for p in folder.glob(f"*{suffix}") if p.is_file())


# ---- 任务 10 ----
def read_csv_rows(path: Path) -> list[dict[str, str]]:
    """用 csv.DictReader 读 CSV,返回每行一个字典(值都是字符串)。

    例:表头 name,score 的两行文件 -> [{"name": "张三", "score": "90"}, ...]
    """
    with open(path, newline="", encoding="utf-8") as f:
        return list(csv.DictReader(f))


def write_csv_rows(path: Path, rows: list[dict]) -> None:
    """用 csv.DictWriter 写 CSV,表头取第一行字典的键;rows 为空则只建空文件。

    Windows 上必须写 newline="",否则每行之间会多出一个空行。
    例:write_csv_rows(p, [{"name": "张三", "score": "90"}])
    """
    with open(path, "w", newline="", encoding="utf-8") as f:
        if not rows:
            return
        writer = csv.DictWriter(f, fieldnames=list(rows[0]))
        writer.writeheader()
        writer.writerows(rows)


# ====================== 自测:不要改动下面的代码 ======================


def _check_write_lines() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        folder = Path(tmp)
        target = folder / "sub" / "out.txt"
        write_lines(target, ["a", "你好"])
        assert target.exists(), "父目录不存在时也要能写成功(要先 mkdir)"
        text = target.read_text(encoding="utf-8")
        assert text == "a\n你好\n", f"内容应为 'a\\n你好\\n',实际 {text!r}"


def _check_read_lines() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        folder = Path(tmp)
        path = folder / "in.txt"
        path.write_text("a\n你好\n", encoding="utf-8")
        got = read_lines(path)
        assert got == ["a", "你好"], f"应为 ['a', '你好'],实际 {got}"
        missing = read_lines(folder / "不存在.txt")
        assert missing == [], f"文件不存在应返回 [],实际 {missing}"


def _check_count_words() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        folder = Path(tmp)
        path = folder / "words.txt"
        path.write_text("a b\nc d e\n", encoding="utf-8")
        assert count_words(path) == 5, "'a b' + 'c d e' 一共 5 个词"
        empty = folder / "empty.txt"
        empty.write_text("", encoding="utf-8")
        assert count_words(empty) == 0, "空文件应为 0 个词"


def _check_append_log() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        path = Path(tmp) / "log.txt"
        append_log(path, "第一条")
        append_log(path, "第二条")
        lines = path.read_text(encoding="utf-8").splitlines()
        assert lines == ["第一条", "第二条"], f"应为两行日志,实际 {lines}"


def _check_ensure_dir() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        target = Path(tmp) / "a" / "b"
        got = ensure_dir(target)
        assert got == target, "应返回传入的路径"
        assert target.is_dir(), "目录应被创建(parents=True)"
        ensure_dir(target)
        assert target.is_dir(), "第二次调用不能报错(exist_ok=True)"


def _check_demo_gbk_pitfall() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        got = demo_gbk_pitfall(Path(tmp))
        assert isinstance(got, str), "应返回一个字符串"
        assert got.startswith("UnicodeDecodeError"), (
            f"应踩中编码坑并返回以 UnicodeDecodeError 开头的说明,实际 {got!r}"
        )


def _check_save_json() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        path = Path(tmp) / "data.json"
        data = {"名字": "张三", "分数": [90, 80]}
        save_json(path, data)
        text = path.read_text(encoding="utf-8")
        assert '"张三"' in text, "文件里应直接看到 张三(要 ensure_ascii=False)"
        assert "\\u5f20" not in text, "不应写成 \\uXXXX 转义"
        assert json.loads(text) == data, "写出的内容应能原样读回"


def _check_load_json() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        folder = Path(tmp)
        path = folder / "data.json"
        path.write_text('{"名字": "张三", "分数": [90, 80]}', encoding="utf-8")
        got = load_json(path, None)
        assert got == {"名字": "张三", "分数": [90, 80]}, f"读回的字典不对:{got}"
        assert load_json(folder / "无.json", "默认") == "默认", (
            "文件不存在应返回 default"
        )
        bad = folder / "bad.json"
        bad.write_text("{bad", encoding="utf-8")
        assert load_json(bad, {}) == {}, "内容不是合法 JSON 时应返回 default"


def _check_list_files_by_suffix() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        folder = Path(tmp)
        for name in ("b.txt", "a.txt", "c.md"):
            (folder / name).write_text("x", encoding="utf-8")
        got = list_files_by_suffix(folder, ".txt")
        assert got == ["a.txt", "b.txt"], f"应为 ['a.txt', 'b.txt'],实际 {got}"
        assert list_files_by_suffix(folder, ".md") == ["c.md"], ".md 只有 c.md"
        assert list_files_by_suffix(folder, ".csv") == [], "没有 .csv 应返回 []"


def _check_csv_rows() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        path = Path(tmp) / "scores.csv"
        rows = [
            {"name": "张三", "score": "90"},
            {"name": "李四", "score": "80"},
        ]
        original = [dict(r) for r in rows]
        write_csv_rows(path, rows)
        assert rows == original, "write_csv_rows 不应修改传进来的 rows"
        got = read_csv_rows(path)
        assert got == rows, f"读回的内容应和写入的一致,实际 {got}"
        line_count = len(path.read_text(encoding="utf-8").splitlines())
        assert line_count == 3, (
            f"表头 + 2 行数据 = 3 行(没写 newline='' 会多空行),实际 {line_count}"
        )


CHECKS = [
    _check_write_lines,
    _check_read_lines,
    _check_count_words,
    _check_append_log,
    _check_ensure_dir,
    _check_demo_gbk_pitfall,
    _check_save_json,
    _check_load_json,
    _check_list_files_by_suffix,
    _check_csv_rows,
]


def _run_checks() -> None:
    passed = 0
    for check in CHECKS:
        name = check.__name__.removeprefix("_check_")
        try:
            check()
        except NotImplementedError:
            print(f"[TODO] {name}: 还没实现")
        except AssertionError as e:
            print(f"[FAIL] {name}: {e}")
        except Exception as e:
            print(f"[ERROR] {name}: {type(e).__name__}: {e}")
        else:
            passed += 1
            print(f"[OK]   {name}")
    total = len(CHECKS)
    tail = ",本文件全部完成!" if passed == total else ""
    print(f"\n{passed}/{total} 通过{tail}")


if __name__ == "__main__":
    _run_checks()
点击展开:03_modules_stdlib.py
exercises/solutions/day2/03_modules_stdlib.py
"""Day 2 · Block 3 · 模块与标准库(参考答案)

学习目标:import 自己写的 shapes 模块,也会用 random/math/datetime/collections;
      用 random.Random(seed) 得到可复现的"随机",用 Counter/defaultdict 统计分组;
      记住陷阱:练习文件千万别起名 random.py,否则 import random 会导入自己。
用法:uv run python exercises/solutions/day2/03_modules_stdlib.py
做法:这是参考答案。先自己写完 exercises/day2/03_modules_stdlib.py,再来对照。
      本文件开头 import 了同目录的 shapes.py——那也是你要自己写的模块。

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> import random
>>> print(random.Random(42).randint(1, 6) == random.Random(42).randint(1, 6))
>>> from collections import Counter
>>> print(Counter("abracadabra".split()).most_common(1))
>>> from datetime import date
>>> print(date(2026, 9, 5) - date(2026, 9, 1))
"""

import math
import random
import statistics
from collections import Counter, defaultdict
from datetime import date

from shapes import circle_area, rectangle_area, triangle_area


# ---- 任务 1 ----
def roll_dice(n: int, sides: int = 6, seed: int | None = None) -> list[int]:
    """掷 n 次 sides 面骰子,返回结果列表;传同一个 seed 两次结果完全相同。

    用 random.Random(seed) 造一个独立的随机数发生器,不影响全局 random。
    例:roll_dice(3, seed=42) -> 每次运行都是同样的三个数
    """
    rng = random.Random(seed)
    return [rng.randint(1, sides) for _ in range(n)]


# ---- 任务 2 ----
def most_common_words(text: str, n: int) -> list[tuple[str, int]]:
    """统计出现次数最多的 n 个词(先转小写,按空白切分)。

    例:most_common_words("a b a c b a", 2) -> [("a", 3), ("b", 2)]
    """
    return Counter(text.lower().split()).most_common(n)


# ---- 任务 3 ----
def group_by_length(words: list[str]) -> dict[int, list[str]]:
    """按词的长度分组,保持原来的先后顺序。

    例:group_by_length(["hi", "yo", "hey"]) -> {2: ["hi", "yo"], 3: ["hey"]}
    """
    groups: defaultdict[int, list[str]] = defaultdict(list)
    for word in words:
        groups[len(word)].append(word)
    return dict(groups)


# ---- 任务 4 ----
def days_between(d1: str, d2: str) -> int:
    """两个 ISO 格式日期字符串相差多少天(取绝对值,不分先后)。

    例:days_between("2026-09-05", "2026-10-01") -> 26
    """
    delta = date.fromisoformat(d2) - date.fromisoformat(d1)
    return abs(delta.days)


# ---- 任务 5 ----
def format_chinese_date(d: date) -> str:
    """把日期格式化成中文形式;月、日补足两位。

    不要用 strftime 输出中文(不同系统结果不一样),用 f-string 自己拼。
    例:format_chinese_date(date(2026, 9, 5)) -> "2026年09月05日"
    """
    return f"{d.year}{d.month:02d}{d.day:02d}日"


# ---- 任务 6 ----
def median_and_mean(nums: list[float]) -> tuple[float, float]:
    """返回 (中位数, 平均数)。

    例:median_and_mean([1, 2, 3, 10]) -> (2.5, 4.0)
    """
    return float(statistics.median(nums)), float(statistics.mean(nums))


# ---- 任务 7 ----
def lcm(a: int, b: int) -> int:
    """求最小公倍数:a * b // gcd(a, b)。

    例:lcm(4, 6) -> 12;lcm(7, 3) -> 21
    """
    return a * b // math.gcd(a, b)


# ====================== 自测:不要改动下面的代码 ======================


def _check_roll_dice() -> None:
    got = roll_dice(5, seed=42)
    assert len(got) == 5, f"应掷 5 次,实际 {len(got)} 次"
    assert all(1 <= x <= 6 for x in got), f"每个点数应在 1-6,实际 {got}"
    assert roll_dice(5, seed=42) == got, "同一个 seed 两次结果应完全相同"
    twenty = roll_dice(20, sides=20, seed=1)
    assert all(1 <= x <= 20 for x in twenty), "sides=20 时点数应在 1-20"
    assert roll_dice(0) == [], "掷 0 次应返回空列表"


def _check_most_common_words() -> None:
    got = most_common_words("a b a c b a", 2)
    assert got == [("a", 3), ("b", 2)], f"应为 [('a', 3), ('b', 2)],实际 {got}"
    upper = most_common_words("Hi hi HI yo", 1)
    assert upper == [("hi", 3)], f"应先转小写再统计,实际 {upper}"


def _check_group_by_length() -> None:
    got = group_by_length(["hi", "yo", "hey"])
    want = {2: ["hi", "yo"], 3: ["hey"]}
    assert got == want, f"应为 {want},实际 {got}"
    assert group_by_length([]) == {}, "空列表应返回空字典"


def _check_days_between() -> None:
    assert days_between("2026-09-05", "2026-10-01") == 26, "9-05 到 10-01 是 26 天"
    assert days_between("2026-10-01", "2026-09-05") == 26, "顺序反过来结果一样"
    assert days_between("2026-09-05", "2026-09-05") == 0, "同一天相差 0 天"


def _check_format_chinese_date() -> None:
    got = format_chinese_date(date(2026, 9, 5))
    assert got == "2026年09月05日", f"应为 '2026年09月05日',实际 {got!r}"
    got = format_chinese_date(date(2026, 12, 31))
    assert got == "2026年12月31日", f"应为 '2026年12月31日',实际 {got!r}"


def _check_median_and_mean() -> None:
    got = median_and_mean([1, 2, 3, 10])
    assert got == (2.5, 4.0), f"应为 (2.5, 4.0),实际 {got}"
    assert median_and_mean([5]) == (5.0, 5.0), "只有一个数时中位数和平均数都是它"


def _check_lcm() -> None:
    assert lcm(4, 6) == 12, "lcm(4, 6) 应为 12"
    assert lcm(7, 3) == 21, "互质的两数最小公倍数是它们的乘积"
    assert lcm(6, 6) == 6, "lcm(6, 6) 应为 6"


def _check_shapes_module() -> None:
    assert triangle_area(3, 4, 5) == 6.0, "3/4/5 直角三角形面积应为 6.0"
    assert rectangle_area(3, 4) == 12, "3 × 4 矩形面积应为 12"
    assert math.isclose(circle_area(1), math.pi), "半径 1 的圆面积应为 π"
    try:
        triangle_area(1, 1, 5)
    except ValueError:
        pass
    else:
        raise AssertionError("1/1/5 构不成三角形,应抛出 ValueError")


CHECKS = [
    _check_roll_dice,
    _check_most_common_words,
    _check_group_by_length,
    _check_days_between,
    _check_format_chinese_date,
    _check_median_and_mean,
    _check_lcm,
    _check_shapes_module,
]


def _run_checks() -> None:
    passed = 0
    for check in CHECKS:
        name = check.__name__.removeprefix("_check_")
        try:
            check()
        except NotImplementedError:
            print(f"[TODO] {name}: 还没实现")
        except AssertionError as e:
            print(f"[FAIL] {name}: {e}")
        except Exception as e:
            print(f"[ERROR] {name}: {type(e).__name__}: {e}")
        else:
            passed += 1
            print(f"[OK]   {name}")
    total = len(CHECKS)
    tail = ",本文件全部完成!" if passed == total else ""
    print(f"\n{passed}/{total} 通过{tail}")


if __name__ == "__main__":
    _run_checks()
点击展开:04_oop_dataclass.py
exercises/solutions/day2/04_oop_dataclass.py
"""Day 2 · Block 4a + 4b · 面向对象与 dataclass(参考答案)

学习目标:用 @dataclass 快速建模(配 field(default_factory=list) 避开可变默认值坑);
      手写 class:__init__/self、类属性 vs 实例属性、@property 做只读属性;
      写 __str__/__repr__/__eq__,用 super().__init__() 继承,用组合装下一堆对象。
用法:uv run python exercises/solutions/day2/04_oop_dataclass.py
做法:这是参考答案。先自己写完 exercises/day2/04_oop_dataclass.py,再来逐行对照。

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> from dataclasses import dataclass, field
>>> @dataclass
>>> class P:
>>>     name: str
>>>     tags: list[str] = field(default_factory=list)
>>> a = P("x"); b = P("x")
>>> print(a == b, a is b)          # ?
>>> print(a)                       # dataclass 自动生成的 __repr__
>>> class Acc:
>>>     def __init__(self, n): self.n = n
>>> c = Acc(1); d = c; d.n = 5
>>> print(c.n)                     # ?  对象也有别名
"""

from dataclasses import dataclass, field


# ---- 任务 1:@dataclass 建模 ----
@dataclass
class Student:
    """一个学生:名字 + 一串分数。

    例:Student("小明", [90, 80]).average() -> 85.0
    """

    name: str
    scores: list[int] = field(default_factory=list)

    def add_score(self, score: int) -> None:
        """添加一个分数;不在 0-100 之间时 raise ValueError。

        例:add_score(90) 之后 scores 多了一个 90;add_score(101) 抛 ValueError
        """
        if not 0 <= score <= 100:
            raise ValueError(f"分数 {score} 不在 0-100")
        self.scores.append(score)

    def average(self) -> float:
        """平均分,保留 1 位小数;没有分数时返回 0.0。

        例:Student("A", [90, 80]).average() -> 85.0
        """
        if not self.scores:
            return 0.0
        return round(sum(self.scores) / len(self.scores), 1)

    def best(self) -> int | None:
        """最高分;没有分数时返回 None。

        例:Student("A", [90, 80]).best() -> 90
        """
        if not self.scores:
            return None
        return max(self.scores)


# ---- 任务 2:手写 class ----
class BankAccount:
    """一个银行账户:户主 + 余额(余额只读,只能通过存取款改变)。

    例:acc = BankAccount("张三");acc.deposit(100);acc.withdraw(30) -> 70.0
    """

    bank_name = "Python 银行"  # 类属性:所有账户共享

    def __init__(self, owner: str, balance: float = 0.0) -> None:
        """记下户主和初始余额;余额存在 self._balance 里(下划线表示"别乱动")。"""
        self.owner = owner
        self._balance = balance

    @property
    def balance(self) -> float:
        """只读的余额。只有 getter 没有 setter,所以赋值会抛 AttributeError。"""
        return self._balance

    def deposit(self, amount: float) -> float:
        """存钱,返回新余额;amount <= 0 时 raise ValueError("金额必须为正")。

        例:BankAccount("张三").deposit(100) -> 100.0
        """
        if amount <= 0:
            raise ValueError("金额必须为正")
        self._balance += amount
        return self._balance

    def withdraw(self, amount: float) -> float:
        """取钱,返回新余额;金额非正或超过余额时 raise ValueError。

        例:余额 100 时 withdraw(30) -> 70.0;withdraw(200) 抛 ValueError
        """
        if amount <= 0:
            raise ValueError("金额必须为正")
        if amount > self._balance:
            raise ValueError("余额不足")
        self._balance -= amount
        return self._balance

    def __str__(self) -> str:
        """给人看的样子:'张三: 70.00 元'。"""
        return f"{self.owner}: {self._balance:.2f} 元"

    def __repr__(self) -> str:
        """给程序员看的样子:BankAccount(owner='张三', balance=70.0)。"""
        return f"BankAccount(owner={self.owner!r}, balance={self._balance})"

    def __eq__(self, other) -> bool:
        """户主和余额都相同才算相等;和别的类型比较返回 NotImplemented。"""
        if not isinstance(other, BankAccount):
            return NotImplemented
        return self.owner == other.owner and self._balance == other._balance


# ---- 任务 3:继承 ----
class SavingsAccount(BankAccount):
    """储蓄账户:在普通账户上多一个利率,可以结算利息。

    例:SavingsAccount("李四", 1000).add_interest() -> 20.0
    """

    def __init__(self, owner: str, balance: float = 0.0, rate: float = 0.02) -> None:
        """先用 super().__init__ 复用父类的初始化,再记下自己的利率。"""
        super().__init__(owner, balance)
        self.rate = rate

    def add_interest(self) -> float:
        """按利率算出利息、存入账户,并返回这笔利息。

        例:余额 1000、利率 0.02 -> 返回 20.0,之后余额 1020.0
        """
        interest = self._balance * self.rate
        self.deposit(interest)
        return interest

    def __str__(self) -> str:
        """在父类的样子后面追加利率:'李四: 1020.00 元(利率 2.0%)'。"""
        return f"{super().__str__()}(利率 {self.rate * 100:.1f}%)"


# ---- 任务 4:组合(一个班里装很多学生) ----
@dataclass
class Classroom:
    """一个班级:名字 + 一群 Student 对象(组合优先于继承)。

    例:room = Classroom("一班");room.add(Student("A", [90]))
    """

    name: str
    students: list[Student] = field(default_factory=list)

    def add(self, student: Student) -> None:
        """把一个学生加进班级。"""
        self.students.append(student)

    def find(self, name: str) -> Student | None:
        """按名字找学生;找不到返回 None。

        例:room.find("A").name -> "A";room.find("不存在") -> None
        """
        for student in self.students:
            if student.name == name:
                return student
        return None

    def top_student(self) -> Student | None:
        """平均分最高的学生;班里没人时返回 None。

        例:A 平均 90、B 平均 75 -> 返回 A
        """
        if not self.students:
            return None
        return max(self.students, key=lambda s: s.average())

    def class_average(self) -> float:
        """全班"学生平均分"的平均值,保留 1 位小数;没人时返回 0.0。

        例:A 平均 90.0、B 平均 75.0 -> 82.5
        """
        if not self.students:
            return 0.0
        averages = [s.average() for s in self.students]
        return round(sum(averages) / len(averages), 1)


# ====================== 自测:不要改动下面的代码 ======================


def _check_student() -> None:
    a = Student("A")
    a.add_score(90)
    assert a.scores == [90], f"add_score(90) 后 scores 应为 [90],实际 {a.scores}"
    try:
        a.add_score(101)
    except ValueError:
        pass
    else:
        raise AssertionError("add_score(101) 应抛出 ValueError")
    assert Student("A") == Student("A"), "dataclass 会按字段自动生成 __eq__"
    assert Student("A") is not Student("A"), "相等不等于同一个对象"
    assert Student("X").scores is not Student("X").scores, (
        "两个实例的 scores 不能是同一个列表(要用 field(default_factory=list))"
    )


def _check_student_stats() -> None:
    s = Student("B", [90, 80])
    assert s.average() == 85.0, f"[90, 80] 的平均分应为 85.0,实际 {s.average()}"
    assert s.best() == 90, "最高分应为 90"
    assert Student("C", [70, 80, 86]).average() == 78.7, "应 round 到 1 位小数"
    empty = Student("D")
    assert empty.average() == 0.0, "没有分数时平均分应为 0.0"
    assert empty.best() is None, "没有分数时最高分应为 None"


def _check_bank_account() -> None:
    acc = BankAccount("张三")
    assert acc.owner == "张三", "owner 应被 __init__ 存下来"
    assert acc.balance == 0.0, "默认余额应为 0.0"
    assert BankAccount.bank_name == "Python 银行", "bank_name 应是类属性"
    assert acc.bank_name == "Python 银行", "实例也能读到类属性"
    try:
        acc.balance = 1
    except AttributeError:
        pass
    else:
        raise AssertionError("balance 应只读(@property 不写 setter)")


def _check_deposit_withdraw() -> None:
    acc = BankAccount("张三")
    assert acc.deposit(100) == 100.0, "deposit 应返回新余额 100.0"
    assert acc.withdraw(30) == 70.0, "withdraw 应返回新余额 70.0"
    assert acc.balance == 70.0, "两笔操作后余额应为 70.0"
    try:
        acc.deposit(0)
    except ValueError as e:
        assert str(e) == "金额必须为正", f"消息应为 '金额必须为正',实际 {str(e)!r}"
    else:
        raise AssertionError("deposit(0) 应抛出 ValueError")
    try:
        acc.withdraw(1000)
    except ValueError as e:
        assert str(e) == "余额不足", f"消息应为 '余额不足',实际 {str(e)!r}"
    else:
        raise AssertionError("余额不够时 withdraw 应抛出 ValueError")
    assert acc.balance == 70.0, "失败的存取款不应改变余额"


def _check_bank_account_dunder() -> None:
    acc = BankAccount("张三", 70.0)
    assert str(acc) == "张三: 70.00 元", f"str 应为 '张三: 70.00 元',实际 {str(acc)!r}"
    want = "BankAccount(owner='张三', balance=70.0)"
    assert repr(acc) == want, f"repr 应为 {want},实际 {repr(acc)}"
    assert acc == BankAccount("张三", 70.0), "同户主同余额应相等"
    assert acc != BankAccount("李四", 70.0), "户主不同不应相等"
    assert acc != BankAccount("张三", 10.0), "余额不同不应相等"
    assert acc != "张三", "和别的类型比较应为不相等(返回 NotImplemented)"


def _check_savings_account() -> None:
    s = SavingsAccount("李四", 1000)
    assert isinstance(s, BankAccount), "SavingsAccount 应继承 BankAccount"
    assert s.rate == 0.02, "默认利率应为 0.02"
    interest = s.add_interest()
    assert interest == 20.0, f"利息应为 20.0,实际 {interest}"
    assert s.balance == 1020.0, f"结息后余额应为 1020.0,实际 {s.balance}"
    got = str(s)
    assert got == "李四: 1020.00 元(利率 2.0%)", f"__str__ 不对:{got!r}"


def _check_classroom() -> None:
    room = Classroom("一班")
    room.add(Student("A", [90]))
    room.add(Student("B", [70, 80]))
    assert len(room.students) == 2, "加了两个学生,students 应有 2 个元素"
    top = room.top_student()
    assert top is not None and top.name == "A", "平均分最高的是 A"
    got = room.class_average()
    assert got == 82.5, f"(90.0 + 75.0) / 2 应为 82.5,实际 {got}"
    found = room.find("B")
    assert found is not None and found.scores == [70, 80], "find('B') 应找到 B"
    assert room.find("不存在") is None, "找不到应返回 None"
    empty = Classroom("空班")
    assert empty.class_average() == 0.0, "空班平均分应为 0.0"
    assert empty.top_student() is None, "空班没有第一名,应为 None"


CHECKS = [
    _check_student,
    _check_student_stats,
    _check_bank_account,
    _check_deposit_withdraw,
    _check_bank_account_dunder,
    _check_savings_account,
    _check_classroom,
]


def _run_checks() -> None:
    passed = 0
    for check in CHECKS:
        name = check.__name__.removeprefix("_check_")
        try:
            check()
        except NotImplementedError:
            print(f"[TODO] {name}: 还没实现")
        except AssertionError as e:
            print(f"[FAIL] {name}: {e}")
        except Exception as e:
            print(f"[ERROR] {name}: {type(e).__name__}: {e}")
        else:
            passed += 1
            print(f"[OK]   {name}")
    total = len(CHECKS)
    tail = ",本文件全部完成!" if passed == total else ""
    print(f"\n{passed}/{total} 通过{tail}")


if __name__ == "__main__":
    _run_checks()
点击展开:05_pythonic.py
exercises/solutions/day2/05_pythonic.py
"""Day 2 · Block 5 · Pythonic 写法、迭代与类型注解(参考答案)

对照用:先自己把 exercises/day2/05_pythonic.py 写完再看这里。
用法:uv run python exercises/solutions/day2/05_pythonic.py

预测练习(先在纸上写出输出,再到 REPL 验证):
>>> print([x * 2 for x in range(3)], {x % 3 for x in range(10)})
>>> print({w: len(w) for w in ["a", "bb"]})
>>> g = (x * x for x in range(3))
>>> print(next(g), next(g), next(g))     # 再 next(g) → StopIteration
>>> print(sorted(["b", "A", "c"]), sorted(["b", "A", "c"], key=str.lower))
>>> print(sorted([("a", 3), ("b", 1)], key=lambda t: t[1]))
>>> print(any(x > 5 for x in [1, 7]), all(x > 5 for x in [1, 7]))
"""

from collections.abc import Iterator
from typing import Any


# ---- 任务 1 ----
def squares_of_evens(nums: list[int]) -> list[int]:
    """偶数的平方(用列表推导式一行完成)。例:[1, 2, 3, 4] -> [4, 16]"""
    return [x * x for x in nums if x % 2 == 0]


# ---- 任务 2 ----
def word_length_map(words: list[str]) -> dict[str, int]:
    """词 -> 词长的字典(用字典推导式)。

    例:["hi", "hey"] -> {"hi": 2, "hey": 3}
    """
    return {word: len(word) for word in words}


# ---- 任务 3 ----
def flatten(matrix: list[list[int]]) -> list[int]:
    """把二维列表拉平(用嵌套推导式一行完成)。

    例:[[1, 2], [3], []] -> [1, 2, 3]
    """
    return [x for row in matrix for x in row]


# ---- 任务 4 ----
def sort_by_score(
    records: list[dict[str, Any]], reverse: bool = True
) -> list[dict[str, Any]]:
    """按 record["score"] 排序,返回新列表,不修改入参。

    sorted 返回新列表,所以入参天然不会被改动(.sort() 才是原地)。
    例:[{"n": "a", "score": 1}, {"n": "b", "score": 5}] -> b 在前
    """
    return sorted(records, key=lambda r: r["score"], reverse=reverse)


# ---- 任务 5 ----
def all_passed(scores: list[int], threshold: int = 60) -> bool:
    """是否全部达到 threshold(用 all)。例:[60, 70] -> True"""
    return all(score >= threshold for score in scores)


# ---- 任务 6 ----
def any_perfect(scores: list[int]) -> bool:
    """是否有满分 100(用 any)。例:[99, 100] -> True"""
    return any(score == 100 for score in scores)


# ---- 任务 7 ----
def countdown(n: int) -> Iterator[int]:
    """生成器:依次 yield n, n-1, ..., 0。

    例:list(countdown(3)) -> [3, 2, 1, 0]
    """
    yield from range(n, -1, -1)


# ---- 任务 8 ----
def chunks(items: list[Any], size: int) -> Iterator[list[Any]]:
    """生成器:把 items 切成每段 size 个,最后一段可能不足。

    size <= 0 时抛出 ValueError("size 必须为正")。
    例:list(chunks([1, 2, 3, 4, 5], 2)) -> [[1, 2], [3, 4], [5]]
    """
    if size <= 0:
        raise ValueError("size 必须为正")
    for start in range(0, len(items), size):
        yield items[start : start + size]


# ---- 任务 9 ----
def even_sum_below(limit: int) -> int:
    """小于 limit 的偶数之和(用 sum(生成器表达式))。例:10 -> 20"""
    return sum(x for x in range(limit) if x % 2 == 0)


# ---- 任务 10 ----
def describe_command(cmd: str) -> str:
    """解析一条命令,必须用 match cmd.split(): 实现。

    ["add", a, b] -> f"结果:{int(a) + int(b)}"
    ["greet", name] -> f"你好,{name}"
    ["quit"] -> "再见";[] -> "空命令";其他 -> "未知命令"
    例:"add 3 4" -> "结果:7"
    """
    match cmd.split():
        case ["add", a, b]:
            return f"结果:{int(a) + int(b)}"
        case ["greet", name]:
            return f"你好,{name}"
        case ["quit"]:
            return "再见"
        case []:
            return "空命令"
        case _:
            return "未知命令"


# ---- 任务 11 ----
def find_user(users: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
    """按 name 查用户,找不到返回 None(可用 next(..., None))。

    例:find_user([{"name": "小明"}], "小刚") -> None
    """
    return next((user for user in users if user.get("name") == name), None)


# ---- 任务 12 ----
def numbered(words: list[str]) -> list[tuple[int, str]]:
    """给每个词编号,从 1 开始(用 enumerate(start=1))。

    例:["a", "b"] -> [(1, "a"), (2, "b")]
    """
    return list(enumerate(words, start=1))


# ====================== 自测:不要改动下面的代码 ======================


def _check_squares_of_evens() -> None:
    assert squares_of_evens([1, 2, 3, 4]) == [4, 16], (
        "squares_of_evens([1,2,3,4]) 应为 [4, 16]"
    )
    assert squares_of_evens([]) == [], "空列表应返回空列表"


def _check_word_length_map() -> None:
    assert word_length_map(["hi", "hey"]) == {"hi": 2, "hey": 3}, (
        'word_length_map(["hi","hey"]) 应为 {"hi": 2, "hey": 3}'
    )


def _check_flatten() -> None:
    assert flatten([[1, 2], [3], []]) == [1, 2, 3], (
        "flatten([[1,2],[3],[]]) 应为 [1, 2, 3]"
    )


def _check_sort_by_score() -> None:
    data = [{"n": "a", "score": 1}, {"n": "b", "score": 5}]
    got = sort_by_score(data)
    assert [r["n"] for r in got] == ["b", "a"], "默认降序,b 应在前"
    assert [r["n"] for r in sort_by_score(data, reverse=False)] == ["a", "b"], (
        "reverse=False 时应升序"
    )
    assert [r["n"] for r in data] == ["a", "b"], "不能修改入参 records"


def _check_all_passed() -> None:
    assert all_passed([60, 70]) is True, "[60, 70] 应为 True"
    assert all_passed([59, 100]) is False, "[59, 100] 应为 False"
    assert all_passed([]) is True, "空列表的 all 为 True"


def _check_any_perfect() -> None:
    assert any_perfect([99, 100]) is True, "[99, 100] 应为 True"
    assert any_perfect([99, 98]) is False, "[99, 98] 应为 False"


def _check_countdown() -> None:
    assert list(countdown(3)) == [3, 2, 1, 0], "countdown(3) 应为 [3, 2, 1, 0]"
    assert list(countdown(0)) == [0], "countdown(0) 应为 [0]"


def _check_chunks() -> None:
    assert list(chunks([1, 2, 3, 4, 5], 2)) == [[1, 2], [3, 4], [5]], (
        "chunks([1,2,3,4,5], 2) 应为 [[1,2],[3,4],[5]]"
    )
    try:
        list(chunks([1, 2], 0))
    except ValueError:
        pass
    else:
        raise AssertionError("size <= 0 时应抛出 ValueError")


def _check_even_sum_below() -> None:
    assert even_sum_below(10) == 20, "even_sum_below(10) 应为 20"
    assert even_sum_below(1) == 0, "even_sum_below(1) 应为 0"


def _check_describe_command() -> None:
    assert describe_command("add 3 4") == "结果:7", '"add 3 4" 应为 "结果:7"'
    assert describe_command("greet 小明") == "你好,小明", "greet 分支不对"
    assert describe_command("quit") == "再见", '"quit" 应为 "再见"'
    assert describe_command("") == "空命令", '"" 应为 "空命令"'
    assert describe_command("jump") == "未知命令", '"jump" 应为 "未知命令"'


def _check_find_user() -> None:
    users = [{"name": "小明", "age": 15}, {"name": "小红", "age": 16}]
    assert find_user(users, "小红") == {"name": "小红", "age": 16}, "应找到小红"
    assert find_user(users, "小刚") is None, "找不到时应返回 None"


def _check_numbered() -> None:
    assert numbered(["a", "b"]) == [(1, "a"), (2, "b")], "编号应从 1 开始"
    assert numbered([]) == [], "空列表应返回空列表"


CHECKS = [
    _check_squares_of_evens,
    _check_word_length_map,
    _check_flatten,
    _check_sort_by_score,
    _check_all_passed,
    _check_any_perfect,
    _check_countdown,
    _check_chunks,
    _check_even_sum_below,
    _check_describe_command,
    _check_find_user,
    _check_numbered,
]


def _run_checks() -> None:
    passed = 0
    for check in CHECKS:
        name = check.__name__.removeprefix("_check_")
        try:
            check()
        except NotImplementedError:
            print(f"[TODO] {name}: 还没实现")
        except AssertionError as e:
            print(f"[FAIL] {name}: {e}")
        except Exception as e:
            print(f"[ERROR] {name}: {type(e).__name__}: {e}")
        else:
            passed += 1
            print(f"[OK]   {name}")
    total = len(CHECKS)
    tail = ",本文件全部完成!" if passed == total else ""
    print(f"\n{passed}/{total} 通过{tail}")


if __name__ == "__main__":
    _run_checks()
点击展开:06_testing/wordtools.py
exercises/solutions/day2/06_testing/wordtools.py
"""Day 2 · Block 6 · wordtools 参考答案

对照用:先自己把 exercises/day2/06_testing/wordtools.py 写完再看这里。
验证方法:把本文件复制到临时目录,并与
      exercises/day2/06_testing/test_wordtools.py 一起放在同一个目录,
      再在那个目录上运行 pytest(答案目录本身不放 test 文件,
      否则会和练习目录里的同名测试文件冲突)。
"""


def normalize(text: str) -> str:
    """规范化文本:转小写、去首尾空白、把多个连续空白压成一个空格。

    text 不是字符串时抛 TypeError("text 必须是字符串")。
    例:"  Hello   World  " -> "hello world"
    split() 不带参数会按任意空白(空格/制表符/换行)切分并丢掉空串,
    再用 " ".join(...) 拼回去,多余空白就自然消失了。
    """
    if not isinstance(text, str):
        raise TypeError("text 必须是字符串")
    return " ".join(text.lower().split())


def count_words(text: str) -> int:
    """统计词数(先 normalize,再按空格切分)。空串 -> 0。

    text 不是字符串时抛 TypeError("text 必须是字符串")。
    例:"a b  c" -> 3;"   " -> 0
    """
    return len(normalize(text).split())


def longest_word(text: str) -> str:
    """返回最长的词(先 normalize);并列时取先出现的;空串 -> ""。

    例:"I love programming" -> "programming"
    例:"aa bb c" -> "aa"(max 遇到并列时保留先出现的那个)
    """
    words = normalize(text).split()
    if not words:
        return ""
    return max(words, key=len)


def censor(text: str, banned: list[str]) -> str:
    """把 banned 里的词替换成 "***",其余保持 normalize 后的形式。

    整词匹配、不分大小写;banned 为空列表时等价于 normalize。
    例:censor("Hello Stupid world", ["stupid"]) -> "hello *** world"
    """
    banned_words = {word.lower() for word in banned}
    words = normalize(text).split()
    return " ".join("***" if word in banned_words else word for word in words)
点击展开:07_grades_v2/grades_v2.py
exercises/solutions/day2/07_grades_v2/grades_v2.py
"""Day 2 · Block 7 · 成绩单 v2 参考答案(CSV -> dataclass -> 报表文件)

对照用:先自己把 exercises/day2/07_grades_v2/grades_v2.py 写完再看这里。
验证方法:把本文件复制到一个临时目录,再把
      exercises/day2/07_grades_v2/data/ 与 test_grades_v2.py
      一起复制过去,然后在那个目录上运行 pytest
      (答案目录本身不放 test 文件和 data,避免与练习目录同名冲突)。
"""

import csv
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path

SUBJECTS = ["语文", "数学", "英语"]


@dataclass
class StudentRecord:
    """一名学生:姓名、班级、各科分数。"""

    name: str
    class_name: str
    scores: dict[str, int] = field(default_factory=dict)

    def average(self) -> float:
        """平均分,round(..., 1);没有任何分数时返回 0.0。

        例:{"语文": 85, "数学": 92, "英语": 78} -> 85.0
        """
        if not self.scores:
            return 0.0
        return round(sum(self.scores.values()) / len(self.scores), 1)


def load_records(path: Path) -> tuple[list[StudentRecord], list[str]]:
    """读 CSV(UTF-8),返回 (记录列表, 警告列表)。

    某行任一科分数无法 int() 就整行跳过,并记一条警告
    f"第 {行号} 行 {姓名}:分数无效,已跳过"(数据首行算第 2 行)。
    """
    records: list[StudentRecord] = []
    warnings: list[str] = []
    with open(path, encoding="utf-8", newline="") as f:
        for line_no, row in enumerate(csv.DictReader(f), start=2):
            name = (row.get("姓名") or "").strip()
            try:
                scores = {subject: int(row[subject]) for subject in SUBJECTS}
            except TypeError, ValueError:
                warnings.append(f"第 {line_no}{name}:分数无效,已跳过")
                continue
            class_name = (row.get("班级") or "").strip()
            records.append(StudentRecord(name, class_name, scores))
    return records, warnings


def letter_grade(score: float) -> str:
    """分数转等级:A≥90,B≥80,C≥70,D≥60,其余 F。例:85.0 -> "B"。"""
    if score >= 90:
        return "A"
    if score >= 80:
        return "B"
    if score >= 70:
        return "C"
    if score >= 60:
        return "D"
    return "F"


def class_averages(records: list[StudentRecord]) -> dict[str, float]:
    """每个班级"学生平均分"的平均,round(..., 1),按班级名排序。

    注意:先算每人的**未四舍五入**平均,再求平均,最后才 round。
    """
    buckets: dict[str, list[float]] = defaultdict(list)
    for record in records:
        if record.scores:
            raw = sum(record.scores.values()) / len(record.scores)
            buckets[record.class_name].append(raw)
    return {
        name: round(sum(values) / len(values), 1)
        for name, values in sorted(buckets.items())
    }


def subject_averages(records: list[StudentRecord]) -> dict[str, float]:
    """各科平均分,round(..., 1);键的顺序按科目首次出现的顺序。"""
    buckets: dict[str, list[int]] = defaultdict(list)
    for record in records:
        for subject, score in record.scores.items():
            buckets[subject].append(score)
    return {
        subject: round(sum(values) / len(values), 1)
        for subject, values in buckets.items()
    }


def rank_students(records: list[StudentRecord]) -> list[tuple[int, str, float]]:
    """按平均分降序、同分按姓名升序,返回 (名次, 姓名, 平均分),名次从 1 起。"""
    ordered = sorted(records, key=lambda r: (-r.average(), r.name))
    return [
        (rank, record.name, record.average())
        for rank, record in enumerate(ordered, start=1)
    ]


def build_report(records: list[StudentRecord], warnings: list[str]) -> str:
    """把统计结果拼成多行报表文本。"""
    lines = ["===== 成绩报表 =====", "", "【排名】"]
    for rank, name, avg in rank_students(records):
        lines.append(f"{rank:>2}. {name:<4}{avg:>6.1f} {letter_grade(avg)}")
    lines += ["", "【班级平均】"]
    lines += [f"{k}{v:.1f}" for k, v in class_averages(records).items()]
    lines += ["", "【各科平均】"]
    lines += [f"{k}{v:.1f}" for k, v in subject_averages(records).items()]
    lines += ["", "【警告】"]
    lines += [f"- {w}" for w in warnings] if warnings else ["(无)"]
    return "\n".join(lines)


def save_report(path: Path, text: str) -> None:
    """UTF-8 写入报表文本,父目录不存在则创建。"""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8")


def main() -> None:
    """读 data/grades.csv,打印报表并写入 data/report.txt。"""
    data_path = Path(__file__).parent / "data" / "grades.csv"
    records, warnings = load_records(data_path)
    report = build_report(records, warnings)
    print(report)
    report_path = data_path.parent / "report.txt"
    save_report(report_path, report)
    print(f"\n报表已写入:{report_path}")


if __name__ == "__main__":
    main()
点击展开:shapes.py
exercises/solutions/day2/shapes.py
"""Day 2 · Block 3 · 自己写的第一个模块:图形面积(参考答案)

学习目标:把一组相关函数放进自己的模块,供别的文件 import;
      用 if __name__ == "__main__": 区分"直接运行"和"被别人 import";
      参数不合法时 raise ValueError,而不是返回一个假的数字。
用法:uv run python exercises/solutions/day2/shapes.py(直接运行会打印示例)
      被 03_modules_stdlib.py 导入时,下面的示例不会打印。
"""

import math


def circle_area(r: float) -> float:
    """圆面积 = π r²;半径为负数时 raise ValueError。

    例:circle_area(1) -> 3.141592653589793
    """
    if r < 0:
        raise ValueError(f"半径不能为负数:{r}")
    return math.pi * r**2


def rectangle_area(w: float, h: float) -> float:
    """矩形面积 = 宽 × 高;任一边为负数时 raise ValueError。

    例:rectangle_area(3, 4) -> 12
    """
    if w < 0 or h < 0:
        raise ValueError(f"边长不能为负数:w={w}, h={h}")
    return w * h


def triangle_area(a: float, b: float, c: float) -> float:
    """用海伦公式算三角形面积;三边构不成三角形时 raise ValueError。

    构成条件:三边都为正,且任意两边之和大于第三边。
    例:triangle_area(3, 4, 5) -> 6.0;triangle_area(1, 1, 5) 抛 ValueError
    """
    if a <= 0 or b <= 0 or c <= 0:
        raise ValueError(f"三边必须都是正数:{a}, {b}, {c}")
    if a + b <= c or a + c <= b or b + c <= a:
        raise ValueError(f"这三条边构不成三角形:{a}, {b}, {c}")
    s = (a + b + c) / 2
    return math.sqrt(s * (s - a) * (s - b) * (s - c))


if __name__ == "__main__":
    print(f"半径 1 的圆面积:{circle_area(1):.4f}")
    print(f"3 × 4 矩形面积:{rectangle_area(3, 4)}")
    print(f"边长 3/4/5 三角形面积:{triangle_area(3, 4, 5)}")