跳转至

Day 2(周日 09-06):进阶——写出"像 Python"的、能保存数据、有测试的程序

今日目标:21:30 前,exercises/day2/ 全部 [OK],uv run pytest exercises/day2 全绿,uv run ruff check exercises 零警告;亲手复现并修复过一次 UnicodeDecodeError;能用 @dataclass 建模、用 json 存档、用 pytest 写 5 个测试;选定周一的项目。

今日节奏:晨测 20 分钟 → 8 个 50+10 分钟块 → 80 分钟综合块 → 复盘。早上第一件事不是学新东西,是闭卷测验——这是记忆巩固效率最高的 20 分钟。

钟点 内容
09:00–09:20 晨测 Day 1 闭卷测验(下方 10 题),不开电脑、不开 AI
09:20–10:10 Block 1 异常处理
10:20–11:10 Block 2a 文件与路径(一):open/with/pathlib/UTF-8 踩坑复现
11:20–12:10 Block 2b 文件与路径(二):JSON、CSV
12:10–13:30 午餐 + 闭眼 20–30 分钟
13:30–14:20 Block 3 模块与标准库:写自己的模块、random/math/datetime/collections/statistics
14:30–15:20 Block 4a 面向对象(一):@dataclass、手写 class__init__/self
15:30–16:20 Block 4b 面向对象(二):__str__/__eq__、继承与 super()、组合
16:20–16:40 加餐 + 散步
16:40–17:30 Block 5 Pythonic:推导式、sorted(key=)、生成器、match、类型注解
17:40–18:30 Block 6 测试与代码质量:pytest、ruff、PEP 8
18:30–19:30 晚餐
19:30–20:50 Block 7 综合 v2:成绩单 CSV → dataclass → 报表文件 → pytest
20:50–21:30 复盘 AI 代码评审(模板 4)→ 学习日志 → 选定周一项目(读 project/README.md)→ 22:30 前睡

晨测(20 分钟,闭卷,写在纸上;答案见 exercises/solutions/quiz_answers.md

  1. 写出输出:print(7 // 2, -7 // 2, 7 % 3, 2 ** 3, 10 / 5)
  2. 写出输出:s = "abcdef"; print(s[1:4], s[-2:], s[::-1], s[::2])
  3. 下面代码打印什么?为什么?
    a = [1, 2, 3]
    b = a
    b.append(4)
    print(a)
    
  4. x = [3, 1, 2].sort(); print(x) 打印什么?怎么改才能得到排好序的列表?
  5. 写一个函数 count(words: list[str]) -> dict[str, int],统计每个词出现次数(用 get)。
  6. 下面代码有什么问题?怎么改?
    def add(item, lst=[]):
        lst.append(item)
        return lst
    
  7. 写出输出:d = {"a": 1}; print(d.get("b"), d.get("b", 0), "a" in d, len(d))
  8. 写出 f"{3.14159:.2f}|{42:>5}|{7:02d}" 的结果(用 | 分隔,空格要数清)。
  9. 这段代码报什么错?为什么?
    total = 0
    def add(n):
        total = total + n
        return total
    add(5)
    
  10. 用一句话解释:TypeErrorValueErrorIndexErrorKeyError 分别在什么情况下出现。

评分:每题 1 分,≥8 分继续;6–7 分把错题对应的 Day 1 块"预测"代码重跑一遍再开始;≤5 分用 Block 1 前 20 分钟重做 Day 1 对应练习。


Block 1(50 分钟):异常处理

知识点:A try/except 具体异常/else/finally;A 输入校验循环;A raise ValueError("原因");A(陷阱)裸 except:;B 自定义异常;C except A, B: 不带括号(3.14 合法,我们统一带括号);C 异常组、finallyreturn 的警告。

阅读:廖雪峰《错误处理》;官方教程 8.3–8.6;Automate 3e Ch5 "Raising Exceptions"。

预测

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

练习exercises/day2/01_exceptions.py

函数 / 类 行为 测试用例
parse_int(s: str) -> int | None int(s.strip()),失败返回 None " 42 "→42"abc"→None"3.5"→None
read_positive_int(prompt: str, input_func=input) -> int 循环调用 input_func(prompt) 直到得到正整数;非数字或 ≤0 时 print 提示后重试(input_func 参数让测试可以传假输入) 文件里提供 make_fake_input(["abc", "-1", "5"]);调用后返回 5
safe_get(lst: list, index: int, default=None) IndexError → default ([1, 2], 5, -1)→-1([1, 2], 1)→2([1, 2], -1)→2
class InvalidScoreError(ValueError) 自定义异常 issubclass(InvalidScoreError, ValueError)
validate_score(score: int) -> int 不在 0–100 → raise InvalidScoreError(f"分数 {score} 不在 0-100") 88→88101InvalidScoreError(且能被 except ValueError 捕获)
divide_all(pairs: list[tuple[float, float]]) -> list[float | None] 除零的位置放 None,不中断 [(6, 3), (1, 0), (5, 2)]→[2.0, None, 2.5]
get_config_int(config: dict, key: str) -> int | None 键不存在或值无法转 int → None必须写 except (KeyError, ValueError): ({"n": "7"}, "n")→7({"n": "x"}, "n")→None({}, "n")→None
withdraw(balance: float, amount: float) -> float amount <= 0ValueError("金额必须为正")amount > balanceValueError("余额不足") (100, 30)→70;两种错误的消息分别匹配

陷阱反例:裸 except: 会吞掉 Ctrl+CKeyboardInterrupt)和拼写错误(NameError),让 bug 消失得无影无踪;except Exception as e: pass 同样危险。


Block 2a + 2b(2 × 50 分钟):文件、路径、JSON、CSV

知识点:A with open(path, encoding="utf-8")r/w/a、逐行读、writeA Windows 默认 GBK;A pathlib.Path/existsread_textwrite_textmkdir);A json.load/dump(ensure_ascii=False, indent=2);A FileNotFoundError 恢复;B glob.name/.suffix/.stem;B csv.DictReader/DictWriternewline="");C os.path

阅读:廖雪峰《文件读写》《序列化》(JSON 部分);官方教程 7.2;Automate 3e Ch10;research/01 第 2.2 节(为什么 3.15 会改默认编码)。

故事线(2a 开头 10 分钟,必做):用记事本/VS Code 新建 exercises/day2/data/hello.txt,内容"你好,Python",以 UTF-8 保存。在 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 → 同样出错;本机若已开系统 UTF-8 则不报错
Python 3.14 在 Windows 上默认用系统代码页(中文系统是 GBK/cp936),3.15(2026-10-01)起默认 UTF-8。结论:现在写代码一律显式 encoding="utf-8"

练习exercises/day2/02_files_paths.py(自测在临时目录里进行,不会弄脏仓库;2a 做前 6 题,2b 做后 4 题)

函数 行为 测试用例
write_lines(path: Path, lines: list[str]) -> None UTF-8 写入,每行末尾 \n;父目录不存在则创建 ["a", "你好"] 后文件内容为 "a\n你好\n"
read_lines(path: Path) -> list[str] UTF-8 读取,去掉行尾换行;文件不存在返回 [](捕获 FileNotFoundError 上一题的文件 → ["a", "你好"];不存在的路径 → []
count_words(path: Path) -> int 全文按空白切分计数 内容 "a b\nc d e\n"5
append_log(path: Path, message: str) -> None 追加模式 "a",一行一条 追加两次后 read_lines 得到两行
ensure_dir(path: Path) -> Path mkdir(parents=True, exist_ok=True),返回 path 连续调用两次不报错,目录存在
demo_gbk_pitfall(folder: Path) -> str 用 UTF-8 写入 "你好,世界",再故意encoding="gbk" 读,捕获 UnicodeDecodeError 并返回字符串 "UnicodeDecodeError: 请显式指定 encoding='utf-8'";若意外没报错则返回 "未触发" 返回值以 "UnicodeDecodeError" 开头(任何机器上都能稳定复现)
save_json(path: Path, data) -> None json.dump(data, f, ensure_ascii=False, indent=2),UTF-8 {"名字": "张三", "分数": [90, 80]} 后文件文本里能直接看到 "张三"(不是 \u5f20
load_json(path: Path, default) 文件不存在或不是合法 JSON(json.JSONDecodeError)→ 返回 default 读回上一题得到原字典;不存在 → default;内容为 "{bad"default
list_files_by_suffix(folder: Path, suffix: str) -> list[str] glob(f"*{suffix}") 的文件名排序列表 目录里有 a.txt, b.txt, c.md(".txt")→["a.txt", "b.txt"]
read_csv_rows(path: Path) -> list[dict[str, str]] / write_csv_rows(path: Path, rows: list[dict]) -> None csv.DictReader / csv.DictWriter(字段名取自第一行字典),newline="",UTF-8 写两行再读回,结果与原列表相等(值都是字符串)

陷阱反例:不写 encoding"C:\new\test.txt" 里的 \n \t 变成转义(用 r"..."Path);json.dump 默认 ensure_ascii=True 中文全是 \uXXXX;CSV 不写 newline="" 在 Windows 会多出空行;JSON 不能直接存 setdate、自定义对象。


Block 3(50 分钟):模块与标准库

知识点:A import 三种写法、if __name__ == "__main__":;A 自己写模块并导入;A(陷阱)模块名与标准库重名、from x import *;A randommath;B datetimecollectionsstatisticssys.argv;B uv add rich;C itertools/functools/re/argparse/logging

阅读:廖雪峰《模块》《使用模块》;官方教程 6、10.1–10.6;标准库文档中文版查 randomdatetime

练习 1:新建模块 exercises/day2/shapes.py(你自己从零写): | 函数 | 行为 | | --- | --- | | circle_area(r: float) -> float | math.pi * r ** 2r < 0ValueError | | rectangle_area(w: float, h: float) -> float | 负数 → ValueError | | triangle_area(a: float, b: float, c: float) -> float | 海伦公式;三边不能构成三角形 → ValueError | | 文件末尾 if __name__ == "__main__": | 打印三个示例;被 import 时不打印 |

练习 2exercises/day2/03_modules_stdlib.py(开头 from shapes import circle_area, rectangle_area, triangle_area

函数 行为 测试用例
roll_dice(n: int, sides: int = 6, seed: int | None = None) -> list[int] random.Random(seed),返回 n 个 1..sides 长度为 n、都在范围内;同 seed 两次结果相同
most_common_words(text: str, n: int) -> list[tuple[str, int]] collections.Counter(...).most_common(n),先转小写 ("a b a c b a", 2)→[("a", 3), ("b", 2)]
group_by_length(words: list[str]) -> dict[int, list[str]] defaultdict(list) ["hi", "yo", "hey"]→{2: ["hi", "yo"], 3: ["hey"]}
days_between(d1: str, d2: str) -> int date.fromisoformat,返回绝对天数 ("2026-09-05", "2026-10-01")→26
format_chinese_date(d: date) -> str f"{d.year}年{d.month:02d}月{d.day:02d}日" date(2026, 9, 5)→"2026年09月05日"
median_and_mean(nums: list[float]) -> tuple[float, float] statistics.median / mean [1, 2, 3, 10]→(2.5, 4.0)
lcm(a: int, b: int) -> int a * b // math.gcd(a, b) (4, 6)→12(7, 3)→21
自测里调用 circle_area(1)triangle_area(3, 4, 5) 验证模块导入成功 triangle_area(3, 4, 5) == 6.0triangle_area(1, 1, 5)ValueError

演示(5 分钟)uv add rich → 在 REPL from rich import print; print({"a": [1, 2], "b": "你好"}) 看彩色输出 → uv remove rich(本仓库不依赖它)。理解 pyproject.tomldependencies 的变化。

陷阱反例:把练习文件命名为 random.pyimport random 导入的是自己(AttributeError: module 'random' has no attribute 'randint');from math import * 污染命名空间。


Block 4a + 4b(2 × 50 分钟):面向对象与 dataclass

知识点:A "一切皆对象";A class__init__self、实例属性、方法;B @dataclass + field(default_factory=list);B __str__/__repr__/__eq__;B 继承、super().__init__()、方法重写;B 组合优先于继承;B 类属性 vs 实例属性、@property;C 多重继承/MRO/__slots__

阅读:廖雪峰《类和实例》《访问限制》《继承和多态》;官方教程 9.3–9.5;dataclasses 官方文档中文版开头示例。

预测

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)                     # ?  对象也有别名

练习exercises/day2/04_oop_dataclass.py(4a 做 Student 与 BankAccount,4b 做 SavingsAccount 与 Classroom)

类 / 成员 行为 测试用例
@dataclass class Student 字段 name: strscores: list[int] = field(default_factory=list) Student("A") == Student("A") 为 True;两个实例的 scores 不是同一个列表
Student.add_score(score: int) -> None 0–100 之外 → ValueError add_score(101) 抛错
Student.average() -> float 空 → 0.0,否则 round(mean, 1) [90, 80]→85.0
Student.best() -> int | None 最高分;空 → None
class BankAccount(手写) __init__(self, owner: str, balance: float = 0.0);类属性 bank_name = "Python 银行"balance@property 只读(内部 _balance BankAccount("张三").balance == 0.0;给 balance 赋值抛 AttributeError
deposit(amount) / withdraw(amount) -> float amount <= 0ValueError("金额必须为正");余额不足 → ValueError("余额不足");返回新余额 deposit(100)withdraw(30)→70.0
__str__ / __repr__ / __eq__ str(acc) == "张三: 70.00 元"repr 形如 BankAccount(owner='张三', balance=70.0)__eq__ 比较 owner 与 balance 两个同 owner 同余额的账户 == 为 True
class SavingsAccount(BankAccount) __init__(owner, balance=0.0, rate=0.02)super().__init__add_interest() -> float 把利息存入并返回利息;重写 __str__ 追加 "(利率 2.0%)" SavingsAccount("李四", 1000).add_interest()→20.0,之后余额 1020.0isinstance(s, BankAccount) 为 True
@dataclass class Classroom name: strstudents: list[Student] = field(default_factory=list)add(student)find(name) -> Student | Nonetop_student() -> Student | Noneclass_average() -> float(学生平均分的平均,round(..., 1),空 → 0.0 加入 Student("A", [90])Student("B", [70, 80])top_student().name == "A"class_average() == 82.5

陷阱反例:方法忘写 self;在类体里写 scores = [] 当默认值(所有实例共享同一个列表,和可变默认参数是同一个坑);__init__ 拼成 _init_


Block 5(50 分钟):Pythonic 写法、迭代与类型注解

知识点:A 列表/字典/集合推导式;A sorted(key=lambda ...)min/max(key=);A any/allzip/enumeratesum(生成器表达式);B 生成器 yielditer/next;B match;B 类型注解全套;C 海象 :=;C t-string。

阅读:廖雪峰《列表生成式》《生成器》《迭代器》;官方教程 4.6(match)、5.1.3、9.8–9.9;官方文档 typing 模块开头。

预测

print([x * 2 for x in range(3)], {x % 3 for x in range(10)}, {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]))

练习exercises/day2/05_pythonic.py(全部函数必须带完整类型注解)

函数 行为 测试用例
squares_of_evens(nums: list[int]) -> list[int] 列表推导式 [1, 2, 3, 4]→[4, 16]
word_length_map(words: list[str]) -> dict[str, int] 字典推导式 ["hi", "hey"]→{"hi": 2, "hey": 3}
flatten(matrix: list[list[int]]) -> list[int] 嵌套推导式一行 [[1, 2], [3], []]→[1, 2, 3]
sort_by_score(records: list[dict], reverse: bool = True) -> list[dict] sorted(key=lambda r: r["score"]),不改入参 [{"n": "a", "score": 1}, {"n": "b", "score": 5}] → b 在前
all_passed(scores: list[int], threshold: int = 60) -> bool / any_perfect(scores: list[int]) -> bool all / any [60, 70]→True[59, 100]→Falseany_perfect([99, 100])→True
countdown(n: int) 生成器,用 yield(或 yield from range(...))产出 n 到 0 list(countdown(3))→[3, 2, 1, 0]
chunks(items: list, size: int) 生成器,每次 yield 一段;size <= 0ValueError list(chunks([1, 2, 3, 4, 5], 2))→[[1, 2], [3, 4], [5]]
even_sum_below(limit: int) -> int sum(x for x in range(limit) if x % 2 == 0) 10→20
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""greet 小明"→"你好,小明""quit"→"再见"""→"空命令""jump"→"未知命令"
find_user(users: list[dict], name: str) -> dict | None 找不到返回 None(用 next(..., None) 或循环)
numbered(words: list[str]) -> list[tuple[int, str]] enumerate(start=1) ["a", "b"]→[(1, "a"), (2, "b")]

陷阱反例:生成器只能遍历一次;推导式里的变量不会泄漏到外面(3.x);sorted 返回新列表而 .sort() 原地;matchcase name: 是"捕获任何值"而不是比较变量。


Block 6(50 分钟):测试与代码质量

知识点:B pytest:test_*.pydef test_x(): assertuv run pytest -q、读输出 ./F/E;B pytest.raises@pytest.mark.parametrize;A 可测试设计(算与打印分离);A PEP 8 要点;B ruff check/format

阅读:pytest 官方 "Get Started"(英文,10 分钟);PEP 8 的"命名约定"一节;research/06 第四节。

练习exercises/day2/06_testing/ - wordtools.py(你实现):

函数 行为
normalize(text: str) -> str 转小写、去首尾空白、多个空白压成一个空格;传入的不是字符串 → raise TypeError("text 必须是字符串")
count_words(text: str) -> int 基于 normalize 后按空格计数;空串 → 0;count_words(None) 因此抛 TypeError(测试里有 pytest.raises 示例)
longest_word(text: str) -> str 最长的词;并列取先出现的;空串 → ""
censor(text: str, banned: list[str]) -> str banned 中的词(不分大小写、整词匹配)替换为 "***";其余保持 normalize 后的形式
  • test_wordtools.py(已给出 8 个测试,其中含 parametrizepytest.raises 示例;末尾有两个 # TODO: 补充测试 由你写:normalize 处理制表符/换行、censor 对空 banned 列表的行为)。
  • 运行:uv run pytest exercises/day2/06_testing -q,直到全绿;再 uv run ruff check exercises/day2uv run ruff format exercises/day2

演示:故意把 count_words 改错,看 pytest 的失败输出长什么样,学会读 assert 3 == 2 那一行的左右值。


Block 7(80 分钟):综合 v2——成绩单文件版

目标:把 Day 1 的成绩单程序升级为"读 CSV 文件 → dataclass 建模 → 统计 → 写报表文件 → 有 pytest 测试"的小项目,为周一的 3 小时项目预演一遍完整流程。

练习exercises/day2/07_grades_v2/grades_v2.pytest_grades_v2.py(已给出,含 6 个测试)、data/grades.csv(UTF-8,8 行,其中 2 行有脏数据:"缺考"与空值)。

成员 行为
@dataclass class StudentRecord name: strclass_name: strscores: dict[str, int];方法 average() -> floatround(..., 1);空 → 0.0
load_records(path: Path) -> tuple[list[StudentRecord], list[str]] csv.DictReader(UTF-8);某行任一分数无法 int() → 跳过该行并在 warnings 里记一条 f"第 {行号} 行 {姓名}:分数无效,已跳过";返回 (records, warnings)
letter_grade(score: float) -> str 同 Day 1
class_averages(records) -> dict[str, float] 每个班级"学生平均分"的平均,round(..., 1)
subject_averages(records) -> dict[str, float] 各科平均,round(..., 1)
rank_students(records) -> list[tuple[int, str, float]] 按平均分降序、同分按姓名升序;(名次, 姓名, 平均分)
build_report(records, warnings) -> str 多行文本:标题、排名表(每行 f"{名次:>2}. {姓名:<4}{平均:>6.1f} {等级}")、班级平均、各科平均、警告列表
save_report(path: Path, text: str) -> None UTF-8 写入
main() Path(__file__).parent / "data" / "grades.csv",打印报表并写到 data/report.txt

对照数据(做完后核对):有效记录 6 条,警告 2 条(第 7 行周八、第 9 行郑十);class_averages == {"1班": 82.6, "2班": 86.7}subject_averages == {"语文": 85.0, "数学": 81.8, "英语": 87.0};排名前三:孙七 92.7、李四 91.0、赵六 86.0。

验收uv run pytest exercises/day2/07_grades_v2 -q 全绿;uv run python exercises/day2/07_grades_v2/grades_v2.py 打印报表且生成 report.txtruff check 零警告;每个函数 ≤ 20 行。


复盘(40 分钟)

  1. AI 代码评审(15 分钟):把 grades_v2.pyai-guide.md 模板 4 让 AI 评审(今天唯一允许 AI 看完整代码的时刻);只改它指出的前 3 条;改完重跑 pytest。
  2. 学习日志(10 分钟):写进 checklist.md
  3. 选定周一项目(15 分钟):读 project/README.md,选 A/B/C 之一(推荐 A),在纸上列出你打算写的 6–8 个函数名和它们的输入输出——明天开工前不用再想
  4. 明早晨测范围:Day 1 + Day 2 全部"预测"代码与陷阱反例。22:30 前睡觉。

若时间不够:Block 7 只要求 load_recordsrank_studentsbuild_report 三个函数通过对应测试;Block 5 的 describe_commandchunks 可跳过。