Python 实战指南(7)——账本动不动就崩?先把“魔法字符串”和“裸报错”干掉

Python 实战指南(7)------账本动不动就崩?先把"魔法字符串"和"裸报错"干掉

凌晨 2:40,你的记账本第 13 次崩了。不是崩在复杂的统计逻辑上,而是崩在一个你闭着眼都能打对的单词上------你手滑把 "expense" 打成了 "expensee",程序一脸懵地告诉你 ValueError: 'expensee' is not a valid RecordKind。你盯着这行报错看了三分钟:什么叫"不是有效的 RecordKind"?RecordKind 是个啥?为什么我昨天还能跑?

再看另一个更阴间的场景:你给程序喂了一笔金额为 -50 的账,它不崩,它默默地把你的余额减了 50。你查了半小时,最后发现是 __post_init__ 里那行校验压根没触发------因为数据是从 JSON 直接塞进去的,绕过了构造函数。这种错误不报错,比报错可怕一百倍。

这一篇,我们把记账本升级到 v0.7,干两件事:

  1. enum 枚举消灭魔法字符串"income" / "expense" 这种裸字符串,从今天起全部换成 RecordKind.INCOME / RecordKind.EXPENSE。打错字?Python 直接不让你跑。
  2. 用自定义异常建立"报错体系"raise ValueError("金额不对") 这种裸抛,换成一套 LedgerError 家族异常,每个异常自带上下文------哪笔账、哪个字段、原始值是什么,一眼看清。

同时你会学到 Python 3.11 之后的两个新武器:StrEnum(字符串枚举)和 ExceptionGroup(异常组)+ except*(批量捕获)。前者让你的枚举直接当字符串用,后者让你一次把所有数据错误报全,而不是挤牙膏一样一次报一个。

老规矩:人话讲概念 → 代码演示 → 实测验证 → 三个真坑。全程 Python 3.12,零第三方库,复制即跑。


1. 痛点回顾:你的账本还在用"裸字符串 + 裸异常"

先回到 v0.6。我们的 Record 类长这样(节选):

python 复制代码
@dataclass(frozen=True, slots=True)
class Record:
    item: str
    amount: int
    date: str
    kind: str = "record"   # <- 裸字符串!

kind 是一个普通字符串,取值全靠"约定":收入写 "income",支出写 "expense"。问题来了:

问题 1:打错字不报错。 "incomee" 也是合法字符串,Python 不会拦你。但你的业务逻辑里 if r.kind == "income" 就永远为 False,这笔账被当成"既不是收入也不是支出",统计结果直接错。

问题 2:魔法值满天飞。 "income""expense" 散落在 models.pystats.pymain.pystorage.py 四个文件里。哪天你想把 "income" 改成 "in"(省字节),得全文搜索替换,漏一个就埋雷。

问题 3:报错信息没人看得懂。 raise ValueError("金额不对")------哪个金额?什么不对?用户看到这个只能抓瞎。测试工程师看到这个更抓狂:报错里没有任何可编程提取的信息,想写断言都没处下手。

我们把这些问题量化成一张表:

现状 后果 v0.7 方案
kind 是裸字符串 打错字静默出错 RecordKind 枚举,写错直接报错
魔法值散落四处 改一个值要全文替换 枚举是单一定义源
校验失败抛 ValueError 报错没有上下文 自定义 AmountError / DateError 携带原始值
用户看到裸 traceback 完全看不懂 main.py 统一 except LedgerError 给中文提示

记住这张表,它就是我们今天要解决的问题清单。


2. 枚举是啥?人话版

2.1 一个直觉类比

想象你是一家餐厅的厨师。菜单上有四道菜:宫保鸡丁、鱼香肉丝、麻婆豆腐、酸辣土豆丝。

你可以用数字 1、2、3、4 来编号它们,也可以写全名。但问题来了:

  • 如果只靠记忆,"3 号桌点 2"------"2" 到底是鱼香肉丝还是麻婆豆腐?数字没有意义。
  • 如果写全名,容易打错字,一打错厨房就不知道做什么。
  • 如果哪天菜单改了,把鱼香肉丝从 2 号挪到 5 号,所有写着 2 的旧订单全错。

而枚举解决的就是这个问题:给"概念"起一个固定的、有名字的、防呆的标签。 概念是"鱼香肉丝",不管它内部编码是 2 还是 5,你引用的是 Menu.FISH_FRAGRANT_PORK,永远不会错。

映射到记账本:账目的"概念"是"收入"和"支出",内部存储可以是 "income" / "expense" 字符串。你用 RecordKind.INCOME 来引用"收入"这个概念 ,而不是直接写 "income" 这个

2.2 定义枚举

Python 的 enum 模块从 3.4 起就是标准库。定义一个枚举:

python 复制代码
from enum import Enum

class Color(Enum):
    RED = 1
    GREEN = 2
    BLUE = 3

成员 Color.REDColor.GREENColor.BLUE。它们:

  • 名字是 RED,值是 1
  • 是唯一的Color(1) 一定返回 Color.RED,绝不可能是别的。
  • 可以迭代list(Color) 给出 [Color.RED, Color.GREEN, Color.BLUE]
  • 可以比较Color.RED == Color.REDTrueColor.RED == Color.GREENFalseColor.RED == 1False(除非用 IntEnum)。

Enum 的世界里,Color.RED 是一个对象,不是数字。你可以给它加方法、加属性,它就是一个完整的类。

2.3 记账本里最朴素的应用

回到记账本。我们定义:

python 复制代码
from enum import Enum

class RecordKind(Enum):
    INCOME = "income"
    EXPENSE = "expense"

现在 RecordKind.INCOME 这个对象,就代表了"收入"这个概念 。你永远不用担心打错字,因为 RecordKind.INCOME 打错(比如 RecordKind.INCMOE)会直接 AttributeError------import 阶段就崩,而不是运行时静默错。

更重要的是,"income" 这个魔法字符串只在一处定义RecordKind 枚举里。其他地方全都用 RecordKind.INCOME,想改值只改一处。


3. 枚举的进阶玩法

3.1 迭代:拿到所有成员

python 复制代码
for k in RecordKind:
    print(k.name, k.value)

输出:

text 复制代码
INCOME income
EXPENSE expense

这在生成菜单、渲染报表时特别好用------你不用再手写一份列表。比如 main.py 里:

python 复制代码
# 自动生成"收入/支出"选项
for k in RecordKind:
    print(k.value)

以后新增一个 TRANSFER = "transfer",菜单自动多一行,不用改任何循环。

3.2 按值构造 & 按名索引

enum 给了你两种从外部"拿成员"的方式:

python 复制代码
RecordKind("income")        # 按值构造 -> RecordKind.INCOME
RecordKind["EXPENSE"]       # 按名索引 -> RecordKind.EXPENSE

按值构造特别有用:你从 JSON 里读到 "income",直接 RecordKind("income") 就能还原成枚举成员。如果值不合法(比如 "incomee"),会抛 ValueError

实测(本机 Python 3.12):

python 复制代码
RecordKind("income") is RecordKind.INCOME      # True
RecordKind["EXPENSE"] is RecordKind.EXPENSE     # True
RecordKind("xxx")                                # ValueError

3.3 给枚举加方法

枚举是类,所以可以加方法。v0.7 里我们给 RecordKind 加一个"宽容版构造器",兼容传入成员或字符串:

python 复制代码
class RecordKind(Enum):
    INCOME = "income"
    EXPENSE = "expense"

    @classmethod
    def from_any(cls, value):
        """传成员或字符串都能转成枚举成员。"""
        if isinstance(value, cls):
            return value
        return cls(value)

用法:

python 复制代码
RecordKind.from_any("income")           # RecordKind.INCOME
RecordKind.from_any(RecordKind.EXPENSE)  # RecordKind.EXPENSE(原样返回)

这在迁移期(你的代码既有旧字符串调用、又有新枚举调用)特别顺手,不用改调用方的写法。

3.4 自定义属性和方法:让枚举会"说人话"

枚举成员还可以拥有属性。v0.7 里我们给支出分类加了"预算提示":

python 复制代码
class Category(Enum):
    FOOD = "餐饮"
    TRANSPORT = "交通"
    SHOPPING = "购物"
    HOUSING = "居住"
    OTHER = "其他"

    @property
    def monthly_hint(self):
        """每个分类给个预算建议(纯演示)。"""
        return {
            Category.FOOD: "建议月预算 2000",
            Category.TRANSPORT: "建议月预算 500",
            Category.SHOPPING: "建议月预算 1000",
            Category.HOUSING: "建议月预算 3000",
            Category.OTHER: "建议月预算 500",
        }[self]

调用:

python 复制代码
Category.FOOD.monthly_hint   # '建议月预算 2000'

注意:@propertyEnum 里默认是被禁的(为了防止和成员名冲突),需要 member() / nonmember() 等装饰器配合。但 Python 3.11+ 之后放宽了。先不用纠结,后面坑里我会给你演示踩坑现场。


4. 常量、元组、字典、枚举:该用哪个?

4.1 先看一段常见代码

python 复制代码
KIND_INCOME = "income"
KIND_EXPENSE = "expense"

# 或
KINDS = ("income", "expense")

# 或
KIND_MAP = {"income": "收入", "expense": "支出"}

这些都是合法的。那为什么要用 Enum?我们做个对比:

方案 可防呆(拼写) 可迭代 可比较 单一来源 可序列化 可加方法
裸字符串 有(值比较)
常量
元组
字典
Enum .value
StrEnum 直接

结论 :当你有"一组固定的、互斥的概念",并且要防呆、要迭代、要加行为时,用 Enum/StrEnum 是最稳的。

4.2 什么时候不该用枚举?

枚举不是银弹。别为所有东西都用枚举:

  • 值是动态的(比如用户名、价格)------不是枚举。
  • 只有两个值且从不变更 (布尔值)------用 bool 就行,别绕。
  • 需要自由组合的标签 (比如一篇博客可以有多个标签)------用 set / frozenset,或 Flag(后面讲)。
  • 和外部系统交换,值完全不在你掌控------用常量 + 文档,别硬塞枚举。

一句话总结:枚举适合"小而稳定"的选项集合;值多、动态、可变时,别硬上。

4.3 一段代码看懂差别

同样实现"根据 kind 判断是收入还是支出",三种写法放在一起对比:

python 复制代码
# 写法 1:裸字符串(v0.6 的做法)
if record.kind == "income":     # 打成 "incom" 就静默出错
    total += record.amount

# 写法 2:常量
KIND_INCOME = "income"
if record.kind == KIND_INCOME:  # 好一点,但 KIND_INCOME 可以被重新赋值
    total += record.amount

# 写法 3:枚举(v0.7 的做法)
if record.kind_enum == RecordKind.INCOME:  # 打错枚举名 import 阶段就崩
    total += record.amount

写法 1 和写法 2 的区别是"有没有给值起名字"。写法 2 和写法 3 的区别是"这个名字有没有被 Python 保护"。枚举成员不可变、不可覆盖、有类型身份------这就是它比常量多出来的安全感。


5. StrEnum:让枚举直接当字符串用

5.1 痛点:Enum 的成员不是字符串

用普通 Enum 定义 RecordKind.INCOME = "income",问题来了:

python 复制代码
RecordKind.INCOME == "income"     # False !?
str(RecordKind.INCOME)             # 'RecordKind.INCOME'(难看)
json.dumps(RecordKind.INCOME)      # 报错:TypeError: Object of type RecordKind is not JSON serializable

明明值是 "income",但 == 不相等、str() 输出也不好看、JSON 序列化直接炸。因为 Enum 成员是枚举类型,不是它的值类型。

我们记账本里 kind 是要存进 JSON 的,"income" 必须能原样存进去读出来。用普通 Enum 就得到处 k.value,烦死了。

5.2 StrEnum 闪亮登场

Python 3.11 引入 StrEnum,专门解决这个痛点:枚举成员本身就是 str 的子类

python 复制代码
from enum import StrEnum

class RecordKind(StrEnum):
    INCOME = "income"
    EXPENSE = "expense"

实测(本机 Python 3.12):

python 复制代码
RecordKind.INCOME == "income"       # True
isinstance(RecordKind.INCOME, str)  # True
f"{RecordKind.EXPENSE}"            # 'expense'
json.dumps([RecordKind.INCOME])    # '["income"]'

一个词总结:StrEnum 把"枚举的严谨"和"字符串的便利"焊在了一起。 旧数据(JSON 里存的 "income")读出来就是 RecordKind.INCOME,反之 RecordKind.INCOME 也能直接写进 JSON。

这就是 v0.7 用 StrEnum 而不是 Enum 的决定性理由:向后兼容,零迁移成本。

5.3 StrEnum 与 Enum / IntEnum 怎么选?

基类 成员是 适用场景
Enum 枚举对象 纯概念枚举,不和值类型交互
IntEnum int 子类 状态码、等级、要与数字运算
StrEnum str 子类(3.11+) 值本身是字符串,要存 JSON / 显示

注意:IntEnum 3.4+,StrEnum 3.11+。如果你的项目还在 Python 3.10,只能用 Enum + 手动 .value

5.4 auto():让枚举自动编号

如果成员的值你不在乎具体数字(只要唯一),用 auto()

python 复制代码
from enum import auto

class Priority(Enum):
    LOW = auto()      # 1
    MEDIUM = auto()   # 2
    HIGH = auto()     # 3

auto() 会自动递增。StrEnum 也可以配 auto()(值自动生成成员的 name 小写形式)。但注意auto() 生成的值不可控,如果你的值要写进 JSON、要给别人看,还是显式给值更稳。

5.5 IntEnum 和 Flag:不只用字符串的场景

记账本用 StrEnum,但你做 Web 开发时会遇到其他场景:

IntEnum :成员是 int 子类,可以直接和数字运算:

python 复制代码
from enum import IntEnum

class HttpStatus(IntEnum):
    OK = 200
    NOT_FOUND = 404
    SERVER_ERROR = 500

code = HttpStatus.NOT_FOUND
if code >= 400:
    print(f"出错了:{code}")   # 出错了:404

Flag 和 IntFlag:支持位运算(组合权限):

python 复制代码
from enum import IntFlag

class Permission(IntFlag):
    READ = 1
    WRITE = 2
    EXECUTE = 4

# 组合权限
rw = Permission.READ | Permission.WRITE     # Permission.READ|WRITE
print(Permission.READ in rw)                 # True
print(Permission.EXECUTE in rw)              # False

IntFlag 特别适合权限系统:一个用户可以有多个权限,用位运算组合。Permission.READ | Permission.WRITE 表示"可读可写",检查时用 in 运算符。

记住 :记账本用 StrEnum(值要存 JSON),权限系统用 IntFlag(要位运算组合),状态码用 IntEnum(要和数字比)。选哪个基类,取决于你的值要和什么类型交互。


6. 实战一:把 Record.kind 换成枚举

6.1 不动 JSON,只动内存

我们的目标是:JSON 里存 "income",内存里用 RecordKind.INCOME 这样:

  • 旧数据(v0.6 存的 "income")读进来,自动变成 RecordKind.INCOME
  • 新数据写出去,RecordKind.INCOME 自动变回 "income"
  • 用户的 JSON 文件一行都不用改。

实现分三步:模型里加 kind_enum 属性、存储层读旧数据时转换、菜单用枚举驱动。

6.2 Recordkind_enum 属性

Recordkind 字段为了兼容旧数据,底层仍然是字符串 (存 "income"),但暴露一个 kind_enum 属性转成枚举:

python 复制代码
class Record:
    # ...(dataclass 字段不变)

    @property
    def kind_enum(self):
        """把字符串 kind 转成枚举成员。"""
        return RecordKind(self.kind)

这样业务代码里写 r.kind_enum == RecordKind.INCOME,既优雅又不破坏存储格式。

6.3 存储层:旧数据自动补

storage.py_normalize 里,从 JSON 读出来的 dict 转 Record 时,kind 字段照原样读("income" / "expense"),不需要 任何转换------因为 RecordKind 的值就是这两个字符串,天然兼容。v0.7 只需要给 Record 加一个 category 字段(默认空串),旧数据没有这个键就用默认值:

python 复制代码
@classmethod
def from_dict(cls, d):
    """从 dict 构造(兼容 v0.6 没有 category 的旧数据)。"""
    return cls(
        item=d["item"],
        amount=int(d["amount"]),
        date=d.get("date", ""),
        kind=d.get("kind", "record"),
        category=d.get("category", ""),   # 新增:旧数据没有就给空串
    )

实测(本机):

python 复制代码
old = {"item": "工资", "amount": "8000", "date": "2026-09-01", "kind": "income"}
r = Record.from_dict(old)
r.kind_enum         # RecordKind.INCOME
r.category          # ''(旧数据自动补空)

零迁移,直接读旧账本。

6.4 菜单层:枚举驱动

main.py 里,cmd_add 接收一个 RecordKind 成员,而不是字符串参数:

python 复制代码
def cmd_add(kind: RecordKind):
    """新增一笔账。kind 是 RecordKind 枚举。"""
    item = input("名称: ").strip()
    amount = int(input("金额(整数): ").strip())
    date = input("日期(YYYY-MM-DD): ").strip()
    cat = ""
    if kind == RecordKind.EXPENSE:
        print("分类:" + " / ".join(c.value for c in Category))
        cat = input("分类(回车=其他): ").strip() or Category.OTHER.value
    with Storage() as store:
        if kind == RecordKind.INCOME:
            rec = IncomeRecord(item, amount, date, category=cat)
        else:
            rec = ExpenseRecord(item, amount, date, category=cat)
        store.data[kind.value].append(rec)
    print(f"已记录:{rec}")

关键点:

  • kind == RecordKind.EXPENSE枚举比较 ,不是字符串比较。打错枚举名(RecordKind.EXPNESE)会在 import 时直接 AttributeError
  • store.data[kind.value].value 拿原始字符串作为 dict 的 key------完美衔接存储层。
  • Category.OTHER.value 同样取字符串 "其他"

你看,枚举把"概念"和"实现"分离了 :业务代码只想"是收入还是支出",存储层只想"字符串是什么"。中间靠 .value 转换。

6.5 顺手:把枚举应用到统计

stats.pyby_kind 接受 RecordKind

python 复制代码
def by_kind(self, kind: RecordKind):
    """按 RecordKind 过滤记录。"""
    return [r for r in self if r.kind == kind.value]

测试里我们验证:

python 复制代码
inc = ledger.by_kind(RecordKind.INCOME)
assert len(inc) == 1
assert inc[0].item == "工资"

一切干净利落。

6.6 测试先行:TDD 里枚举怎么测

这一篇已经给了你一个"测试驱动"的味儿:我们先写了 26 个测试,再回头确保实现。枚举相关测试长这样:

python 复制代码
def test_value_is_str(self):
    assert RecordKind.INCOME == "income"
    assert RecordKind.EXPENSE == "expense"

def test_member_iteration(self):
    members = list(RecordKind)
    assert members == [RecordKind.INCOME, RecordKind.EXPENSE]

def test_constructor_by_value(self):
    assert RecordKind("income") is RecordKind.INCOME

def test_index_by_name(self):
    assert RecordKind["EXPENSE"] is RecordKind.EXPENSE

def test_invalid_value_raises_valueerror(self):
    with pytest.raises(ValueError):
        RecordKind("unknown")

为什么这样写测试? 因为枚举是"契约":定义了你所有合法的取值。测试把这个契约锁死,以后谁把 "income" 改成 "in",测试立刻红。这就是测试工程师视角------枚举也是需要测试保证的 API。

6.7 从 v0.6 到 v0.7:迁移策略

你可能担心:"我的项目里到处都是 "income" 字符串,怎么迁移到枚举?" 真实项目迁移不是一晚上 find-and-replace,而是分三步走:

第一步:定义枚举,不改旧代码。

先在 enums.py 里定义 RecordKind,但不碰任何现有文件。枚举的值和旧字符串完全一样("income" / "expense"),确保两边 == 能过。

python 复制代码
# enums.py(新文件,不动旧代码)
class RecordKind(StrEnum):
    INCOME = "income"
    EXPENSE = "expense"

第二步:新代码用枚举,旧代码不动。

新写的函数、新加的模块用 RecordKind.INCOME。旧代码保持原样------"income" 字符串和 RecordKind.INCOME 因为 StrEnum 天然 ==,两边混用不出错。

python 复制代码
# 新代码
def new_func(kind: RecordKind):
    ...

# 旧代码(不动)
def old_func(kind: str):
    ...

第三步:逐步替换旧代码。

有空就翻一个文件,把 "income" 替换成 RecordKind.INCOME。每替换一个文件,跑一遍测试。测试全绿就提交,不绿就回退。举个例子片段:

python 复制代码
# 替换前
if r.kind == "income":
    total += r.amount

# 替换后
if r.kind_enum == RecordKind.INCOME:
    total += r.amount

关键原则 :枚举的值必须和旧字符串完全一致,这样迁移期两边混用不会出问题。如果你要改值(比如 "income" 改成 "in"),那是另一个独立步骤,等迁移完了再做。 别同时做两件事,出了问题不知道是哪个改动导致的。


7. 异常体系:Python 的"报错地图"

7.1 异常不是"出错了",是"告诉你出了什么错"

很多人怕异常,因为看到红色 traceback 就觉得"我代码崩了"。换个角度理解:异常是 Python 在跟你说"嘿,这地方有个问题,我帮你拦住了,你来看看怎么办"。 没有异常的语言才可怕------出了问题静默继续,数据悄悄写坏,你查三天都查不到。

Python 的异常体系是一棵树。所有异常都继承自 BaseException。但你要记住一条铁律:业务异常一律继承 Exception,绝不继承 BaseException

text 复制代码
BaseException
├── SystemExit         # sys.exit() 触发
├── KeyboardInterrupt  # Ctrl+C 触发
├── GeneratorExit      # 生成器关闭
└── Exception          # <- 你只需要关心这一支
    ├── StopIteration
    ├── ArithmeticError
    │   └── ZeroDivisionError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── ValueError
    ├── TypeError
    ├── OSError
    │   ├── FileNotFoundError
    │   ├── PermissionError
    │   └── ...
    └── ...(你的自定义异常放这里)

为什么绝不继承 BaseException?因为 except BaseException 会把 SystemExitKeyboardInterrupt 也一起吞掉。用户按 Ctrl+C 想退出,结果被你静默吞掉,程序卡死。这种事你一定遇到过------某个程序 Ctrl+C 怎么都退不出,就是有人写了 except BaseException

7.2 异常的生命周期

一段代码出了异常,它会沿着调用栈向上冒泡 ,直到被某个 except 接住。如果一直没人接,最终到达 Python 解释器顶层,打印 traceback 并退出。

python 复制代码
def a():
    raise ValueError("boom")    # 3. 在这里抛出

def b():
    a()                          # 2. 调用 a

def c():
    try:
        b()                      # 1. 调用 b
    except ValueError as e:
        print("接住了:", e)       # 4. 在这里接住

理解这一点很重要:你不需要在每个函数里都 try/except。 异常会自动向上传。你只需要在"能处理它的层级"接住就行------通常是用户界面层(main.py)或 API 边界层。

7.3 常见内置异常速查

你写 Python 每天都会遇到的几个内置异常,先混个脸熟:

异常 什么时候触发 你该怎么处理
ValueError 值的类型对但内容不对(int("abc") 校验输入,给用户友好提示
TypeError 类型不对("a" + 1 检查参数类型,或用 isinstance 预判
IndexError 列表索引越界([10] 但只有 3 个元素) 先检查 len(),或用 try/except
KeyError 字典键不存在(d["missing"] d.get("missing", default)
FileNotFoundError 文件不存在 创建文件,或提示用户选择路径
ZeroDivisionError 除以零 先检查除数
AttributeError 对象没有这个属性 检查拼写,或用 hasattr()

记住一个原则 :能预判的就预判(if len(lst) > 0),不方便预判的才 try/except。两种风格都合法,但预判比 try/except 更显式------读代码的人能直接看到你在防什么。try/except 适合"预判成本太高"或"异常本身就是正常流程"的场景(比如迭代器用 StopIteration 通知结束)。

7.4 try/except/else/finally 完整语义

你可能只见过 try/except。完整家族是四个:

python 复制代码
try:
    risky()                 # 1. 尝试执行
except SomeError as e:      # 2. 捕获(可以有多个 except)
    handle(e)
else:
    no_error()              # 3. 没有异常才执行
finally:
    cleanup()               # 4. 无论如何都执行
执行条件
try 总是尝试执行
except try 里抛了匹配的异常
else try没有抛异常
finally 无论如何都执行(异常被接住、没被接住、正常返回都算)

else 常被忽略,但它很干净:else 里的代码只在没有异常时跑 ,比在 try 末尾直接写更清晰。因为如果你写在 try 末尾,这段代码的异常也会被上面的 except 误接,行为就混了。

finally 保证"清理动作一定执行"。我们 v0.6 的 Storage.__exit__ 就是 finally 的化身------with 语句退出时无论如何都调 __exit__

实测(本机 Python 3.12):

python 复制代码
def demo(flag):
    try:
        if flag:
            raise ValueError("boom")
        result = "ok"
    except ValueError:
        result = "caught"
    else:
        result += "_else"
    finally:
        print(f"finally: {result}")

demo(True)   # finally: caught
demo(False)  # finally: ok_else

8. 自定义异常:让报错变成"可编程的 API"

8.1 裸 raise ValueError 的三个问题

你之前大概率这么写:

python 复制代码
if amount <= 0:
    raise ValueError("金额不对")

然后调用方:

python 复制代码
try:
    add_record(...)
except ValueError:
    print("错了")

问题 1:粒度太粗。 所有业务错误都叫 ValueError,调用方无法区分"金额错了"和"日期错了"。

问题 2:误伤无辜。 except ValueError 会把输入解析错误(int("abc")ValueError)也一起吞掉。你以为是"金额不对",实际上是"用户输入了 abc"------两个完全不同的问题混在一个 catch 里。

问题 3:报错信息没有结构化数据。 "金额不对" 是一个字符串,测试/排障只能靠看字符串内容。你没法写 assert e.amount == -100,因为 ValueError 不带这个属性。

8.2 测试工程师视角:异常也是 API

这是这篇最核心的认知转变。你设计的异常类,决定了调用方能 catch 什么、能拿到什么信息、能怎么自动化处理。

好的异常设计

  • 有类型层级 :调用方可以选择精确 catch(except AmountError)或宽泛 catch(except LedgerError)。
  • 携带上下文 :异常对象上有属性(e.recorde.bad_date),程序可以提取。
  • 消息可读str(e) 给人看,属性给程序用。

差的异常设计

  • 全部 raise ValueError("xxx")------没有类型区分,没有上下文。
  • raise Exception("xxx")------更差,except Exception 会把所有东西都吞掉。
  • print("出错了") 然后继续------这不是异常,这是逃避。

8.3 设计一套记账本异常体系

v0.7 的 errors.py

python 复制代码
class LedgerError(Exception):
    """记账本业务异常基类。所有业务异常从这里派生。"""

class AmountError(LedgerError):
    """金额非法:负数、零、非数字。额外携带 record。"""
    def __init__(self, message, record=None):
        super().__init__(message)
        self.record = record

class DateError(LedgerError):
    """日期格式错误。额外携带 bad_date。"""
    def __init__(self, message, bad_date=None):
        super().__init__(message)
        self.bad_date = bad_date

class DataCorruptError(LedgerError):
    """数据文件损坏。额外携带 source。"""
    def __init__(self, message, source=None):
        super().__init__(message)
        self.source = source

设计要点拆解:

要点 1:一个基类 + 多个派生。 调用方要么 catch 最具体的(AmountError),要么 catch 基类(LedgerError)兜住全部业务错误。两个颗粒度都有,随你选。

python 复制代码
# 精确:只管金额错误
try:
    ...
except AmountError as e:
    print(f"金额问题: {e}")

# 宽泛:兜住所有业务错误
try:
    ...
except LedgerError as e:
    print(f"账本出错: {e}")

要点 2:每个异常携带上下文属性。 AmountError.record(出错的 Record 对象)、DateError.bad_date(原始日期字符串)、DataCorruptError.source(哪个文件坏了)。排障时直接 e.record / e.bad_date 拿到原始数据,不用翻字符串。

要点 3:命名以 Error 结尾。 Python 惯例(ValueErrorTypeErrorFileNotFoundError......),一眼看出是异常类。

8.4 在 Record.__post_init__ 里用起来

Record 的构造函数里,把裸 ValueError 换成自定义异常:

python 复制代码
@dataclass(frozen=True, slots=True)
class Record:
    item: str
    amount: int
    date: str
    kind: str = "record"
    category: str = ""

    def __post_init__(self):
        if self.amount <= 0:
            raise AmountError(f"金额必须大于 0,你传了 {self.amount}", record=self)
        try:
            from datetime import date
            date.fromisoformat(self.date)
        except ValueError:
            raise DateError(f"日期格式不对:{self.date!r},要用 YYYY-MM-DD", bad_date=self.date)

实测(本机):

python 复制代码
IncomeRecord("工资", -100, "2026-09-01")
# AmountError: 金额必须大于 0,你传了 -100
# e.record 就是出错的 Record 对象

ExpenseRecord("吃饭", 25, "2026-13-40")
# DateError: 日期格式不对:'2026-13-40',要用 YYYY-MM-DD
# e.bad_date == '2026-13-40'

这就是"可编程的报错" :报错信息人能看懂,e.record / e.bad_date 程序也能提取。测试里可以写:

python 复制代码
def test_amount_error_carries_record(self):
    with pytest.raises(AmountError) as exc:
        IncomeRecord("工资", -100, "2026-09-01")
    assert exc.value.record is not None
    assert "必须大于 0" in str(exc.value)

8.5 raise ... from ...:保留异常链

当你捕获一个异常后再抛另一个,用 from 保留原始异常:

python 复制代码
try:
    raw = json.load(f)
except json.JSONDecodeError as e:
    raise DataCorruptError("文件损坏", source=path) from e

这样 traceback 里会显示:

text 复制代码
  ...
json.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

The above exception was the direct cause of the following exception:

  ...
DataCorruptError: 文件损坏

两层都看得到 :底层是 JSON 解析失败,上层是你的业务异常。调试时你能同时看到"技术原因"和"业务含义"。不加 from 的话,原始异常就丢了,只看到 DataCorruptError,排查时不知道是 JSON 解析错还是别的。

8.6 from None:故意丢弃原始异常

有时候你不希望用户看到底层异常(太技术化、不安全),可以用 from None 故意切断异常链:

python 复制代码
try:
    raw = json.load(f)
except json.JSONDecodeError:
    raise DataCorruptError("数据文件损坏") from None   # 不保留原始异常

traceback 里不会出现 JSONDecodeError 的信息。但调试阶段不建议这样做------你自己排查时会丢线索。只在"面向最终用户"的边界处用。

8.7 异常设计的三条原则

总结一下 v0.7 异常体系的设计原则,方便你在自己项目里复用:

原则 1:一个基类兜底。 所有业务异常继承同一个基类(LedgerError),调用方只需要 except LedgerError 就能兜住全部业务错误。不要让异常树太扁------每个业务领域有自己的基类。

原则 2:携带上下文。 异常不是消息字符串,是对象。给它加属性(record / bad_date / source),让调用方能编程式提取信息。str(e) 给人看,属性给程序用。

原则 3:命名以 Error 结尾。 AmountErrorDateErrorDataCorruptError------一看就知道是异常。别叫 AmountExceptionDateProblemBadAmount,Python 惯例就是 Error 后缀。


9. 实战二:数据损坏兜底

9.1 场景:JSON 文件被手动编辑坏了

你的 records.json 被同事手动改了,少了个引号。v0.6 的 load()json.load(f) 会抛 json.JSONDecodeErrorValueError 的子类)。用户看到的是:

text 复制代码
json.JSONDecodeError: Expecting property name enclosed in double quotes: line 3 column 5 (char 45)

普通用户看到这个直接懵。我们需要把它转成人话,并且用自定义异常标记"这是数据损坏,不是普通错误"。

9.2 v0.7 的 storage.py 改动

python 复制代码
def load(self):
    if not self.path.exists():
        self.data = {"income": [], "expense": []}
        return self.data
    try:
        with open(self.path, "r", encoding="utf-8") as f:
            raw = json.load(f)
    except (json.JSONDecodeError, UnicodeDecodeError) as e:
        raise DataCorruptError(
            f"数据文件损坏:{self.path},内容不是合法 JSON",
            source=str(self.path),
        ) from e
    self.data = self._normalize(raw)
    return self.data

改动点:

  1. json.load 包在 try 里。
  2. 捕获 JSONDecodeErrorUnicodeDecodeError(编码坏了也算)。
  3. DataCorruptError,携带 source(文件路径)。
  4. from e 保留原始异常链。

9.3 _normalize 也要防

_normalize 里把 dict 转成 Record 对象。如果数据结构不对(缺少 income/expense 键,或者某个记录缺字段),也要抛 DataCorruptError

python 复制代码
@staticmethod
def _normalize(raw):
    if not isinstance(raw, dict) or not all(k in raw for k in ("income", "expense")):
        raise DataCorruptError("数据结构不对:缺少 income/expense 键", source="<unknown>")
    out = {"income": [], "expense": []}
    for key in ("income", "expense"):
        if not isinstance(raw.get(key), list):
            raise DataCorruptError(f"字段 {key} 不是列表", source=str(key))
        for rec in raw[key]:
            try:
                r = Record.from_dict(rec) if isinstance(rec, dict) else rec
            except KeyError as e:
                raise DataCorruptError(f"记录缺少字段 {e}", source=str(rec)) from e
            out[key].append(r)
    return out

9.4 测试验证

python 复制代码
def test_load_corrupt_json_raises_data_corrupt(self):
    with tempfile.TemporaryDirectory() as td:
        path = os.path.join(td, "bad.json")
        Path(path).write_text("{ not json", encoding="utf-8")
        with pytest.raises(DataCorruptError):
            with Storage(path) as s:
                s.load()

def test_normalize_missing_key_raises(self):
    with pytest.raises(DataCorruptError):
        Storage._normalize({"income": []})

两个测试都过了。坏文件 → DataCorruptError,缺键 → DataCorruptError用户看到的是"数据文件损坏,请检查 records.json",而不是 JSONDecodeError traceback。


10. main.py 全局兜底:让用户看到人话

10.1 两层防护

v0.7 的 main.py 有两层异常防护:

内层 :菜单循环里,catch LedgerError(业务异常)和 ValueError(输入解析异常),转成中文提示。

外层__main__ 入口处,catch DataCorruptError(数据损坏),给出专门提示。

python 复制代码
def main():
    ledger = load_ledger()
    while True:
        print(MENU)
        choice = input("请选择 [1-6]: ").strip()
        try:
            if choice == "1":
                cmd_add(RecordKind.INCOME)
            elif choice == "2":
                cmd_add(RecordKind.EXPENSE)
            elif choice == "3":
                cmd_stats(ledger)
            elif choice == "4":
                cmd_ranking(ledger)
            elif choice == "5":
                cmd_category(ledger)
            elif choice == "6":
                print("再见!")
                break
            else:
                print("无效选项,重新输入")
        except LedgerError as e:
            print(f"[账本错误] {e}")
        except ValueError:
            print("[输入错误] 金额/日期格式不对,请重试")

效果对比:

场景 v0.6 行为 v0.7 行为
输入金额 abc 打印 traceback 退出 [输入错误] 金额/日期格式不对
金额传 -100 打印 traceback 退出 [账本错误] 金额必须大于 0
日期 2026-13-40 打印 traceback 退出 [账本错误] 日期格式不对
JSON 坏了 打印 traceback 退出 [严重] 数据文件损坏:请检查 records.json

用户看到的不是 traceback,是人话。

10.2 为什么不 catch 所有异常?

你可能想说"那我在最外面加个 except Exception 兜底不就行了?"

可以,但要小心。except Exception 会吞掉所有 Exception 子类的异常,包括你没想到的。比如 PermissionError(文件没权限)、MemoryError(内存不够)------这些你可能想让它冒泡出去,而不是被吞成"账本错误 None"。

最佳实践

  • 业务异常用 LedgerError 兜------你设计的,你知道怎么处理。
  • 输入异常用 ValueError 兜------用户输错了,重试就行。
  • 其他异常让它崩------崩了至少你能看到 traceback,知道哪里出了问题。静默吞掉比崩掉可怕多了。

10.3 异常处理的三种策略对比

你处理异常时有三种策略,适用不同场景:

策略 A:捕获并恢复------异常发生了,你能处理就处理,程序继续跑。

python 复制代码
try:
    amount = int(user_input)
except ValueError:
    amount = 0   # 默认值,继续跑

适用:输入解析、默认值回退。但不适合记账金额------默认 0 会改变统计结果,应该让用户重新输入。

策略 B:捕获并转换------底层异常你接住,抛一个更高级的业务异常。

python 复制代码
try:
    raw = json.load(f)
except json.JSONDecodeError as e:
    raise DataCorruptError("文件损坏", source=path) from e

适用:你的代码被别人调用时,底层异常对调用方没意义,需要翻译成业务语言。v0.7 的 storage.py 就是这个策略。

策略 C:不捕获,让它冒泡------你处理不了,交给上层处理。

python 复制代码
def add_record(rec):
    # 不 try/except,AmountError 直接冒泡到 main.py
    ledger.add(rec)

适用:大多数情况。你不需要在每个函数里都 try/except------让异常自动向上传,在"能处理它的层级"(通常是 UI 层)接住就行。

记住 :策略 C 是默认选项。只有当你能"真正恢复"或"需要翻译异常"时,才用 A 或 B。如果你只是 except: pass,那不是处理异常,是埋雷。

10.4 异常处理的反模式

这些写法你一定见过,甚至写过。每一个都是坑:

反模式 1:裸 except

python 复制代码
try:
    do_something()
except:    # 接住所有异常,包括 Ctrl+C
    pass

这等于 except BaseException,连 KeyboardInterrupt 都吞。用户按 Ctrl+C 退不出程序。永远不要写裸 except ,至少写 except Exception

反模式 2:except Exception: pass

python 复制代码
try:
    risky()
except Exception:
    pass   # 吞掉所有错误,假装没发生

这比裸 except 好一点(不吞 Ctrl+C),但仍然是"静默吞掉"。出了问题你根本不知道。至少 logging.error 一下,片段示例:

python 复制代码
try:
    risky()
except Exception as e:
    logging.error(f"出错了: {e}", exc_info=True)

反模式 3:捕获太宽

python 复制代码
try:
    value = int(input())
    result = 10 / value
except Exception:
    print("出错了")

int() 可能抛 ValueError,除法可能抛 ZeroDivisionError,你用一个 except Exception 全兜了。用户看到"出错了"但不知道是输入错了还是除以零了。分开捕获

python 复制代码
try:
    value = int(input())
    result = 10 / value
except ValueError:
    print("请输入整数")
except ZeroDivisionError:
    print("不能除以零")

11. 批量校验:让错误"一次报全"

11.1 痛点:一次只报一个错

你导入一批账(比如从 CSV 导入 100 条),其中 3 条有问题。现在的代码一次只抛第一个异常,修完重跑,又抛第二个......要跑 3 次才知道全部问题。

这体验太折磨了。作为测试工程师,你深有体会:缺陷要一次批量发现,别一个个修。

11.2 Python 3.11 的 ExceptionGroup

ExceptionGroup 是 Python 3.11 引入的新特性。它是一个"装异常的异常"------你可以把多个异常打包成一个组抛出:

python 复制代码
errors = [ValueError("错误1"), ValueError("错误2"), TypeError("错误3")]
raise ExceptionGroup("有 3 处错误", errors)

调用方可以用 except*(注意是带星号的)批量捕获,按类型分组

python 复制代码
try:
    ...
except* ValueError as e:
    # 拿到所有 ValueError 子异常
    print(e.exceptions)
except* TypeError as e:
    # 拿到所有 TypeError 子异常
    print(e.exceptions)

e.exceptions 是子异常列表,你可以遍历处理每一个。

11.3 except*except 的区别

这是一个容易混淆的点:

语法 行为
except ValueError 只接一个异常,接住后整个 ExceptionGroup 被消费
except* ValueError ExceptionGroup 里的 ValueError 子异常拆出来,其他类型继续向上抛

实测(本机 Python 3.12):

python 复制代码
try:
    raise ExceptionGroup("g", [ValueError("a"), TypeError("b"), ValueError("c")])
except* ValueError as ve:
    print(f"ValueError: {len(ve.exceptions)} 个")   # 2
except* TypeError as te:
    print(f"TypeError: {len(te.exceptions)} 个")   # 1

输出:

text 复制代码
ValueError: 2 个
TypeError: 1 个

except* 像一个分拣器 :把混合的异常按类型拆开,每种类型送到对应的 except* 块。

11.4 批量校验记账数据

v0.7 的批量校验函数:

python 复制代码
def validate_records(records):
    """批量校验:把所有错误收集进 ExceptionGroup,一次报全。"""
    errors = []
    for r in records:
        try:
            if not r.item.strip():
                raise ValueError("名称不能为空")
            if r.amount <= 0:
                raise ValueError("金额必须大于 0")
            if len(r.date) != 10:
                raise ValueError("日期格式不对")
        except ValueError as e:
            errors.append(ValueError(f"{r.item}: {e}"))
    if errors:
        raise ExceptionGroup("记账数据有 3 处错误", errors)

实测(本机):

python 复制代码
from types import SimpleNamespace

try:
    validate_records([
        SimpleNamespace(item="", amount=8000, date="2026-09-01"),    # 名称空
        SimpleNamespace(item="工资", amount=-1, date="2026-09-01"),   # 金额负
        SimpleNamespace(item="工资", amount=8000, date="2026-9-1"),   # 日期短
    ])
except ExceptionGroup as eg:
    print(eg.message)              # 记账数据有 3 处错误
    print(len(eg.exceptions))      # 3
    for e in eg.exceptions:
        print(e)
    # : 名称不能为空
    # 工资: 金额必须大于 0
    # 工资: 日期格式不对

一次报全,不迭代不痛苦。 3 个错误一次性看到,修完一批再跑。

11.5 把 ExceptionGroup 和自定义异常结合

批量校验时,不要只用 ValueError------用你的自定义异常体系,让调用方可以 except* 精确分流:

python 复制代码
def validate_records_v2(records):
    """批量校验 v2:按异常类型分组。"""
    amount_errors = []
    date_errors = []
    for r in records:
        try:
            if r.amount <= 0:
                raise AmountError(f"金额必须大于 0", record=r)
            if len(r.date) != 10:
                raise DateError(f"日期格式不对", bad_date=r.date)
        except AmountError as e:
            amount_errors.append(e)
        except DateError as e:
            date_errors.append(e)
    all_errors = amount_errors + date_errors
    if all_errors:
        raise ExceptionGroup(f"校验失败:{len(all_errors)} 处错误", all_errors)

调用方可以按类型分别处理:

python 复制代码
try:
    validate_records_v2(records)
except* AmountError as ae:
    print(f"金额错误 {len(ae.exceptions)} 个:")
    for e in ae.exceptions:
        print(f"  {e.record.item}: {e}")
except* DateError as de:
    print(f"日期错误 {len(de.exceptions)} 个:")
    for e in de.exceptions:
        print(f"  {e.bad_date}: {e}")

这就是自定义异常 + ExceptionGroup 的组合威力:既一次报全,又按类型分流,每个子异常还携带上下文属性。测试工程师梦寐以求的报错体验。

11.6 什么时候用 ExceptionGroup

  • 批量导入/校验数据:一口气检查所有记录,把所有问题一次性报出来。
  • 并发任务 :多个协程/线程同时跑,各自的异常可以打包成 ExceptionGroup
  • 表单校验:用户提交一个表单,多个字段都有问题,一次性全部提示。
  • 不需要 :单次操作只有一个错误,直接 raise 就行,别硬套 ExceptionGroup

判断标准:如果你发现自己在写"找到第一个错误就停"的循环,考虑改成"收集所有错误再一次性报"的模式。用户体验会好很多。


12. v0.7 完整代码:全部贴出来,复制就能跑

前面讲了很多概念和片段,现在把 v0.7 的每一个文件完整源码 都贴出来。你不用东拼西凑,按下面的目录结构建好文件,把代码贴进去,cd src && python main.py 就能跑。

12.1 项目结构

text 复制代码
py-from-0-to-1-07-enum/
├── src/
│   ├── main.py              # 入口:菜单 + 全局异常兜底
│   ├── ledger/
│   │   ├── __init__.py      # 包导出(__all__)
│   │   ├── errors.py        # 自定义异常体系(新增)
│   │   ├── enums.py         # RecordKind / Category(新增)
│   │   ├── models.py        # Record(加 category / kind_enum)
│   │   ├── storage.py       # 存取(坏文件抛 DataCorruptError)
│   │   └── stats.py         # 统计(加 by_kind / category_summary)
│   └── tests/
│       └── test_v07.py      # 26 个测试

12.2 enums.py:枚举定义

python 复制代码
# -*- coding: utf-8 -*-
"""枚举定义:v0.7 消灭"魔法字符串"

RecordKind 用 StrEnum 定义收入/支出两类账目。
为什么用 StrEnum 而不是 Enum?因为它的值就是 str,
存到 JSON / 显示给用户 / 和旧数据比较,全都不用改。
"""
from enum import StrEnum, auto


class RecordKind(StrEnum):
    """账目类型。

    用 StrEnum:成员的值就是普通字符串 "income"/"expense",
    所以 v0.6 存下来的数据文件不用迁移,直接兼容。
    """

    INCOME = "income"
    EXPENSE = "expense"
    # 留个位置:将来加 "transfer"(转账)只改这里,不用改业务代码

    @classmethod
    def from_any(cls, value):
        """宽容版构造:传字符串或成员都行。

        用法:RecordKind.from_any("income") -> RecordKind.INCOME
        找不到时抛 ValueError(不是自定义异常,因为这是"调用方传错参数")。
        """
        if isinstance(value, cls):
            return value
        return cls(value)


class Category(StrEnum):
    """支出分类(示例用,v0.8 会扩展成完整的分类体系)。"""

    FOOD = "餐饮"
    TRANSPORT = "交通"
    SHOPPING = "购物"
    HOUSING = "居住"
    OTHER = "其他"

    # 带分类的金额校验辅助:每个分类给一个"当月预算提示"(纯演示)
    @property
    def monthly_hint(self) -> str:
        return {
            Category.FOOD: "建议月预算 2000",
            Category.TRANSPORT: "建议月预算 500",
            Category.SHOPPING: "建议月预算 1000",
            Category.HOUSING: "建议月预算 3000",
            Category.OTHER: "建议月预算 500",
        }[self]

12.3 errors.py:异常体系

python 复制代码
# -*- coding: utf-8 -*-
"""异常体系:v0.7 让记账本"报错也报得明白"

设计原则(测试工程师视角):
- 所有业务异常继承 LedgerError,调用方只需要 catch 一个基类
- 每个异常携带上下文属性(哪笔账、哪个字段、问题值),方便排查
- 基类 LedgerError 继承 Exception(绝不继承 BaseException)
"""


class LedgerError(Exception):
    """记账本业务异常基类。

    所有业务异常都从这里派生。调用方 catch 这一个类,
    就能兜住记账本自己定义的所有错误。
    """


class AmountError(LedgerError):
    """金额非法:负数、零、非数字都算。

    额外携带 record 字段,告诉你具体是哪笔账出了问题。
    """

    def __init__(self, message, record=None):
        super().__init__(message)
        self.record = record


class DateError(LedgerError):
    """日期格式错误:不是 YYYY-MM-DD 或日期不存在。

    额外携带 bad_date 字段,保留原始输入方便排查。
    """

    def __init__(self, message, bad_date=None):
        super().__init__(message)
        self.bad_date = bad_date


class DataCorruptError(LedgerError):
    """数据文件损坏:JSON 解析失败、字段缺失或类型不对。

    额外携带 source 字段,指明是哪个文件出的问题。
    """

    def __init__(self, message, source=None):
        super().__init__(message)
        self.source = source

12.4 models.py:数据模型

python 复制代码
# -*- coding: utf-8 -*-
"""数据模型:v0.7 用 RecordKind 替代魔法字符串

Record 增加 category 字段(支出分类),同时保留 v0.6 的
to_dict / from_dict 兼容层,旧数据能直接读进来。
"""
from dataclasses import dataclass

from .enums import RecordKind, Category
from .errors import AmountError, DateError


@dataclass(frozen=True, slots=True)
class Record:
    """一笔账:item 名称 + amount 金额 + date 日期 + kind 类型 + category 分类。"""

    item: str
    amount: int
    date: str
    kind: str = "record"
    category: str = ""

    def __post_init__(self):
        # 金额必须大于 0(0 和负数都是输入错误)
        if self.amount <= 0:
            raise AmountError(f"金额必须大于 0,你传了 {self.amount}", record=self)
        # 日期必须是 YYYY-MM-DD 且真实存在
        try:
            from datetime import date
            date.fromisoformat(self.date)
        except ValueError:
            raise DateError(f"日期格式不对:{self.date!r},要用 YYYY-MM-DD", bad_date=self.date)

    @property
    def signed_amount(self) -> int:
        """带符号金额:收入为正,支出为负。"""
        return self.amount if self.kind == "income" else -self.amount

    @property
    def kind_enum(self) -> "RecordKind":
        """把字符串 kind 转成 RecordKind 枚举(用 StrEnum 兼容旧数据)。"""
        return RecordKind(self.kind)

    def to_dict(self) -> dict:
        return {
            "item": self.item,
            "amount": self.amount,
            "date": self.date,
            "kind": self.kind,
            "category": self.category,
        }

    @classmethod
    def from_dict(cls, d: dict) -> "Record":
        """从 dict 构造(兼容 v0.6 没有 category 的旧数据)。"""
        return cls(
            item=d["item"],
            amount=int(d["amount"]),
            date=d.get("date", ""),
            kind=d.get("kind", "record"),
            category=d.get("category", ""),
        )


class IncomeRecord(Record):
    """收入记录:kind 固定为 income。"""

    def __init__(self, item, amount, date, category=""):
        super().__init__(item, amount, date, kind="income", category=category)


class ExpenseRecord(Record):
    """支出记录:kind 固定为 expense。"""

    def __init__(self, item, amount, date, category=""):
        super().__init__(item, amount, date, kind="expense", category=category)

12.5 storage.py:存储层

python 复制代码
# -*- coding: utf-8 -*-
"""存储层:v0.7 加数据损坏兜底

JSON 解析失败时抛 DataCorruptError(自定义异常),
而不是让原始 ValueError 冒泡到用户面前。
"""
import json
from pathlib import Path

from .models import IncomeRecord, ExpenseRecord, Record
from .errors import DataCorruptError

DATA_DIR = Path(__file__).resolve().parent.parent / "data"
DATA_FILE = DATA_DIR / "records.json"


class Storage:
    """负责记账本的存取。支持 with 语句:进入时加载,退出时保存。"""

    def __init__(self, path=None):
        self.path = Path(path) if path else DATA_FILE
        self.data = {"income": [], "expense": []}

    # ---- 上下文管理器协议 ----
    def __enter__(self):
        self.load()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        # 没有异常才保存(异常时不覆盖原有数据)
        if exc_type is None:
            self.save()
        return False

    # ---- 存取逻辑 ----
    def load(self):
        if not self.path.exists():
            self.data = {"income": [], "expense": []}
            return self.data
        try:
            with open(self.path, "r", encoding="utf-8") as f:
                raw = json.load(f)
        except (json.JSONDecodeError, UnicodeDecodeError) as e:
            raise DataCorruptError(
                f"数据文件损坏:{self.path},内容不是合法 JSON",
                source=str(self.path),
            ) from e
        self.data = self._normalize(raw)
        return self.data

    def save(self):
        DATA_DIR.mkdir(parents=True, exist_ok=True)
        out = {}
        for key in ("income", "expense"):
            out[key] = [r.to_dict() if isinstance(r, Record) else r for r in self.data[key]]
        with open(self.path, "w", encoding="utf-8") as f:
            json.dump(out, f, ensure_ascii=False, indent=2)

    @staticmethod
    def _normalize(raw):
        """把 v0.6 的 dict 记录升级为 Record 对象;老数据自动补 kind/category。"""
        if not isinstance(raw, dict) or not all(k in raw for k in ("income", "expense")):
            raise DataCorruptError("数据结构不对:缺少 income/expense 键", source="<unknown>")
        out = {"income": [], "expense": []}
        for key in ("income", "expense"):
            if not isinstance(raw.get(key), list):
                raise DataCorruptError(f"字段 {key} 不是列表", source=str(key))
            for rec in raw[key]:
                try:
                    r = Record.from_dict(rec) if isinstance(rec, dict) else rec
                except KeyError as e:
                    raise DataCorruptError(f"记录缺少字段 {e}", source=str(rec)) from e
                out[key].append(r)
        return out


# ---- 模块级便捷函数(兼容老代码/测试)----
def save_records(data, path=None):
    store = Storage(path)
    store.data = data
    store.save()
    return path or str(store.path)


def load_records(path=None):
    store = Storage(path)
    return store.load()

12.6 stats.py:统计层

python 复制代码
# -*- coding: utf-8 -*-
"""统计层:汇总、排行、趋势(v0.7 增加分类统计)

Ledger 类把所有记录装进一个"账本"里:
- 支持 for 循环(生成器版 __iter__)
- 支持 len(ledger)(__len__)
- 类方法 from_records 作为替代构造器
"""
from datetime import date, timedelta

from .models import Record
from .enums import RecordKind


class Ledger:
    """一个记账本:内部按 kind 分组,对外统一迭代所有记录。"""

    def __init__(self, income=None, expense=None):
        self.income = list(income) if income else []
        self.expense = list(expense) if expense else []

    # ---- 替代构造器 ----
    @classmethod
    def from_records(cls, records):
        """把一个 Record 列表按 kind 分装成 Ledger。"""
        inc, exp = [], []
        for r in records:
            (inc if r.kind == "income" else exp).append(r)
        return cls(income=inc, expense=exp)

    # ---- 迭代器协议(生成器版,防坑)----
    def __iter__(self):
        """每次迭代都新建一个生成器,互不干扰。"""
        yield from self.income
        yield from self.expense

    # ---- 长度 ----
    def __len__(self):
        return len(self.income) + len(self.expense)

    # ---- 增删 ----
    def add(self, rec: Record):
        if rec.kind == "income":
            self.income.append(rec)
        else:
            self.expense.append(rec)

    # ---- 统计 ----
    @property
    def balance(self) -> int:
        """总余额(收入合计 - 支出合计)。"""
        return sum(r.amount for r in self.income) - sum(r.amount for r in self.expense)

    def monthly_summary(self, ym: str):
        """月度统计:返回 (收入, 支出) 元组。"""
        income = sum(r.amount for r in self.income if r.date[:7] == ym)
        expense = sum(r.amount for r in self.expense if r.date[:7] == ym)
        return income, expense

    def expense_ranking(self, top: int = 5):
        """支出排行:按金额降序,取前 top 名。"""
        ranked = sorted(self.expense, key=lambda r: r.amount, reverse=True)
        return ranked[:top]

    def date_range_summary(self, start: str, end: str):
        """日期范围统计:返回范围内收入/支出/笔数。"""
        s, e = date.fromisoformat(start), date.fromisoformat(end)
        income = expense = 0
        count = 0
        for r in self:
            d = date.fromisoformat(r.date)
            if s <= d <= e:
                count += 1
                if r.kind == "income":
                    income += r.amount
                else:
                    expense += r.amount
        return income, expense, count

    def recent_days(self, days: int):
        """最近 N 天(含今天)统计。"""
        end = date.today()
        start = end - timedelta(days=days - 1)
        return self.date_range_summary(start.isoformat(), end.isoformat())

    def weekly_summary(self, week_num: int):
        """按 ISO 周数统计。week_num 如 2026-W37 或 37。"""
        if "-W" in str(week_num):
            year, w = str(week_num).split("-W")
            year = int(year)
            w = int(w)
        else:
            year = date.today().year
            w = int(week_num)
        income = expense = 0
        for r in self:
            d = date.fromisoformat(r.date)
            if d.isocalendar()[0] == year and d.isocalendar()[1] == w:
                if r.kind == "income":
                    income += r.amount
                else:
                    expense += r.amount
        return income, expense

    # ---- v0.7 新增:按枚举分类统计 ----
    def by_kind(self, kind: RecordKind) -> list:
        """按 RecordKind 过滤记录,返回新列表。"""
        return [r for r in self if r.kind == kind.value]

    def category_summary(self):
        """按支出分类汇总:返回 {分类名: 总金额} 字典。"""
        result = {}
        for r in self.expense:
            cat = r.category or "未分类"
            result[cat] = result.get(cat, 0) + r.amount
        return result

12.7 __init__.py:包导出

python 复制代码
# -*- coding: utf-8 -*-
"""ledger 包:v0.7 统一导出

对外只暴露稳定 API:
- RecordKind / Category 枚举
- Record / IncomeRecord / ExpenseRecord
- Ledger 账本
- Storage 存储
- LedgerError 异常体系
"""
from .enums import RecordKind, Category
from .models import Record, IncomeRecord, ExpenseRecord
from .stats import Ledger
from .storage import Storage, DATA_FILE
from .errors import LedgerError, AmountError, DateError, DataCorruptError

__all__ = [
    "RecordKind",
    "Category",
    "Record",
    "IncomeRecord",
    "ExpenseRecord",
    "Ledger",
    "Storage",
    "DATA_FILE",
    "LedgerError",
    "AmountError",
    "DateError",
    "DataCorruptError",
]

12.8 main.py:入口 + 全局兜底

python 复制代码
# -*- coding: utf-8 -*-
"""v0.7 记账本入口:命令行 + 全局异常兜底

流程:
- 用 RecordKind 枚举驱动菜单,消灭魔法字符串
- 所有业务异常用 except LedgerError 兜底,报错给友好提示
- 数据损坏单独提示,不让用户面对裸 traceback
"""
import sys

from ledger.errors import LedgerError, DataCorruptError
from ledger.models import IncomeRecord, ExpenseRecord
from ledger.storage import Storage
from ledger.enums import RecordKind, Category
from ledger.stats import Ledger

MENU = """
选项: 记账本 v0.7
1. 记一笔收入
2. 记一笔支出
3. 查看本月统计
4. 查看支出排行
5. 查看分类汇总
6. 退出
"""


def load_ledger() -> Ledger:
    """从数据文件加载 Ledger。数据损坏时抛 DataCorruptError。"""
    with Storage() as store:
        return Ledger.from_records(store.data["income"] + store.data["expense"])


def cmd_add(kind: RecordKind):
    """新增一条账。kind 是 RecordKind 枚举,不是魔法字符串。"""
    item = input("名称: ").strip()
    amount = int(input("金额(整数): ").strip())
    date = input("日期(YYYY-MM-DD): ").strip()
    cat = ""
    if kind == RecordKind.EXPENSE:
        print("分类:" + " / ".join(c.value for c in Category))
        cat = input("分类(回车=其他): ").strip() or Category.OTHER.value
    with Storage() as store:
        if kind == RecordKind.INCOME:
            rec = IncomeRecord(item, amount, date, category=cat)
        else:
            rec = ExpenseRecord(item, amount, date, category=cat)
        store.data[kind.value].append(rec)
    print(f"已记录:{rec}")


def cmd_stats(ledger: Ledger):
    from datetime import date
    ym = date.today().strftime("%Y-%m")
    income, expense = ledger.monthly_summary(ym)
    print(f"{ym} 收入 {income} 元,支出 {expense} 元,结余 {income - expense} 元")


def cmd_ranking(ledger: Ledger):
    for i, r in enumerate(ledger.expense_ranking(), 1):
        print(f"{i}. {r.item} {r.amount} 元({r.date})")


def cmd_category(ledger: Ledger):
    for cat, total in ledger.category_summary().items():
        print(f"{cat}: {total} 元")


def main():
    ledger = load_ledger()
    while True:
        print(MENU)
        choice = input("请选择 [1-6]: ").strip()
        try:
            if choice == "1":
                cmd_add(RecordKind.INCOME)
            elif choice == "2":
                cmd_add(RecordKind.EXPENSE)
            elif choice == "3":
                cmd_stats(ledger)
            elif choice == "4":
                cmd_ranking(ledger)
            elif choice == "5":
                cmd_category(ledger)
            elif choice == "6":
                print("再见!")
                break
            else:
                print("无效选项,重新输入")
        except LedgerError as e:
            print(f"[账本错误] {e}")
        except ValueError:
            print("[输入错误] 金额/日期格式不对,请重试")


if __name__ == "__main__":
    try:
        main()
    except DataCorruptError as e:
        print(f"[严重] 数据文件损坏:{e}")
        print("请检查 records.json 是否被手动编辑过")
        sys.exit(1)

12.9 test_v07.py:26 个测试

python 复制代码
# -*- coding: utf-8 -*-
"""第 7 篇测试:enum 枚举 + 自定义异常 + ExceptionGroup

测试工程师视角:验证"异常也是 API"------每个异常能携带多少上下文,
调用方 catch 之后能拿到什么信息,都要测到位。
"""
import json
import os
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from ledger import (
    RecordKind, Category, Record, IncomeRecord, ExpenseRecord, Ledger, Storage,
    LedgerError, AmountError, DateError, DataCorruptError,
)


# ============ enum 枚举 ============

class TestRecordKind:
    def test_value_is_str(self):
        assert RecordKind.INCOME == "income"
        assert RecordKind.EXPENSE == "expense"

    def test_str_enum_usable_in_fstring(self):
        assert f"{RecordKind.INCOME}" == "income"

    def test_member_iteration(self):
        members = list(RecordKind)
        assert members == [RecordKind.INCOME, RecordKind.EXPENSE]

    def test_constructor_by_value(self):
        assert RecordKind("income") is RecordKind.INCOME

    def test_index_by_name(self):
        assert RecordKind["EXPENSE"] is RecordKind.EXPENSE

    def test_from_string_accepts_member(self):
        assert RecordKind.from_any("income") == RecordKind.INCOME
        assert RecordKind.from_any(RecordKind.EXPENSE) == RecordKind.EXPENSE

    def test_invalid_value_raises_valueerror(self):
        with pytest.raises(ValueError):
            RecordKind("unknown")


class TestCategory:
    def test_values_are_chinese(self):
        assert Category.FOOD == "餐饮"

    def test_property_hint(self):
        assert Category.FOOD.monthly_hint == "建议月预算 2000"


# ---------------- 自定义异常 ----------------

class TestCustomErrors:
    def test_all_derive_from_ledger_error(self):
        assert issubclass(AmountError, LedgerError)
        assert issubclass(DateError, LedgerError)
        assert issubclass(DataCorruptError, LedgerError)

    def test_ledger_error_derives_from_exception(self):
        assert issubclass(LedgerError, Exception)
        assert not issubclass(LedgerError, BaseException) or issubclass(LedgerError, Exception)

    def test_amount_error_carries_record(self):
        with pytest.raises(AmountError) as exc:
            IncomeRecord("工资", -100, "2026-09-01")
        assert exc.value.record is not None
        assert "必须大于 0" in str(exc.value)

    def test_date_error_carries_bad_date(self):
        with pytest.raises(DateError) as exc:
            Record("吃饭", 10, "2026-13-40")
        assert exc.value.bad_date == "2026-13-40"

    def test_data_corrupt_carries_source(self):
        with pytest.raises(DataCorruptError) as exc:
            Storage._normalize({"income": "not-a-list", "expense": []})
        assert exc.value.source == "income"


# ---------------- Record 模型 ----------------

class TestRecord:
    def test_kind_enum_property(self):
        r = IncomeRecord("工资", 8000, "2026-09-01")
        assert r.kind_enum is RecordKind.INCOME

    def test_signed_amount(self):
        assert IncomeRecord("工资", 8000, "2026-09-01").signed_amount == 8000
        assert ExpenseRecord("吃饭", 25, "2026-09-02").signed_amount == -25

    def test_category_field(self):
        r = ExpenseRecord("午饭", 25, "2026-09-02", category="食品")
        assert r.category == "食品"

    def test_from_dict_backward_compat(self):
        old = {"item": "工资", "amount": "8000", "date": "2026-09-01", "kind": "income"}
        r = Record.from_dict(old)
        assert r.amount == 8000
        assert r.category == ""

    def test_invalid_amount_raises(self):
        with pytest.raises(AmountError):
            IncomeRecord("工资", 0, "2026-09-01")


# ---------------- 异常兜底 ----------------

class TestStorage:
    def test_save_and_load_roundtrip(self):
        with tempfile.TemporaryDirectory() as td:
            path = os.path.join(td, "t.json")
            with Storage(path) as s:
                s.data["income"].append(IncomeRecord("工资", 8000, "2026-09-01"))
            with Storage(path) as s2:
                assert s2.data["income"][0].amount == 8000

    def test_load_corrupt_json_raises_data_corrupt(self):
        with tempfile.TemporaryDirectory() as td:
            path = os.path.join(td, "bad.json")
            Path(path).write_text("{ not json", encoding="utf-8")
            with pytest.raises(DataCorruptError):
                with Storage(path) as s:
                    s.load()

    def test_normalize_missing_key_raises(self):
        with pytest.raises(DataCorruptError):
            Storage._normalize({"income": []})


class TestLedgerEnumStats:
    def test_by_kind_returns_list(self):
        ledger = Ledger.from_records([
            IncomeRecord("工资", 8000, "2026-09-01"),
            ExpenseRecord("吃饭", 25, "2026-09-02"),
        ])
        inc = ledger.by_kind(RecordKind.INCOME)
        assert len(inc) == 1
        assert inc[0].item == "工资"

    def test_category_summary(self):
        ledger = Ledger.from_records([
            ExpenseRecord("午饭", 25, "2026-09-01", category="食品"),
            ExpenseRecord("地铁", 6, "2026-09-02", category="交通"),
            ExpenseRecord("晚饭", 35, "2026-09-03", category="食品"),
        ])
        summary = ledger.category_summary()
        assert summary == {"食品": 60, "交通": 6}


# ---------------- ExceptionGroup 批量校验 ----------------

def _validate_records(records):
    """批量校验:把所有错误收集进 ExceptionGroup,一次报全。"""
    errors = []
    for r in records:
        try:
            if not r.item.strip():
                raise ValueError("名称不能为空")
            if r.amount <= 0:
                raise ValueError("金额必须大于 0")
            if len(r.date) != 10:
                raise ValueError("日期格式不对")
        except ValueError as e:
            errors.append(ValueError(f"第 {r.item} 笔:{e}"))
    if errors:
        raise ExceptionGroup("记账数据有 3 处错误", errors)


class TestExceptionGroup:
    def test_group_collects_all_errors(self):
        # 用普通对象模拟 3 处数据错误:
        # 名称空、金额负、日期短
        records = [
            SimpleNamespace(item="", amount=8000, date="2026-09-01"),
            SimpleNamespace(item="工资", amount=-1, date="2026-09-01"),
            SimpleNamespace(item="工资", amount=8000, date="2026-9-1"),
        ]
        with pytest.raises(ExceptionGroup) as exc:
            _validate_records(records)
        assert len(exc.value.exceptions) == 3
        assert all(isinstance(e, ValueError) for e in exc.value.exceptions)

    def test_except_star_split_by_type(self):
        eg = ExceptionGroup("g", [ValueError("a"), TypeError("b"), ValueError("c")])
        caught_v = caught_t = []
        try:
            raise eg
        except* ValueError as ve:
            caught_v = ve.exceptions
        except* TypeError as te:
            caught_t = te.exceptions
        assert len(caught_v) == 2
        assert len(caught_t) == 1

12.10 运行方式

bash 复制代码
cd src
python main.py

菜单:

text 复制代码
选项: 记账本 v0.7
1. 记一笔收入
2. 记一笔支出
3. 查看本月统计
4. 查看支出排行
5. 查看分类汇总
6. 退出

测试:

bash 复制代码
cd src
python -m pytest tests -q

输出:

text 复制代码
26 passed in 0.04s

26 个测试覆盖:

  • RecordKind 枚举:值/迭代/构造/索引/非法值(6 个)
  • Category 枚举:值/属性(2 个)
  • 自定义异常:继承链/上下文属性(5 个)
  • Record 模型:kind_enum/signed_amount/category/旧数据兼容/非法金额(5 个)
  • Storage:存取往返/坏 JSON/缺键(3 个)
  • Ledger 统计:by_kind/category_summary(2 个)
  • ExceptionGroup:批量收集/except* 拆组(2 个)
  • 异常继承关系验证(1 个)

12.11 几个关键测试逐个看

挑几个最有代表性的测试,讲解"为什么这样测":

测试 1:枚举值是字符串

python 复制代码
def test_value_is_str(self):
    assert RecordKind.INCOME == "income"
    assert RecordKind.EXPENSE == "expense"

为什么重要?因为这验证了 StrEnum 的核心契约------成员等于它的值。如果有人不小心改成了普通 Enum,这个测试立刻红。

测试 2:异常携带上下文

python 复制代码
def test_amount_error_carries_record(self):
    with pytest.raises(AmountError) as exc:
        IncomeRecord("工资", -100, "2026-09-01")
    assert exc.value.record is not None
    assert "必须大于 0" in str(exc.value)

为什么重要?因为它同时验证了三件事:抛的是 AmountError(类型正确)、e.record 存在(上下文携带)、消息包含"必须大于 0"(可读性)。一条测试锁死三个契约。

测试 3:数据损坏抛对的异常

python 复制代码
def test_load_corrupt_json_raises_data_corrupt(self):
    with tempfile.TemporaryDirectory() as td:
        path = os.path.join(td, "bad.json")
        Path(path).write_text("{ not json", encoding="utf-8")
        with pytest.raises(DataCorruptError):
            with Storage(path) as s:
                s.load()

为什么重要?因为它验证了"坏 JSON → DataCorruptError"这条转换链。v0.6 里坏 JSON 会抛 JSONDecodeError,v0.7 必须抛 DataCorruptError。这个测试确保转换逻辑不被回退。

测试 4:ExceptionGroup 批量收集

python 复制代码
def test_group_collects_all_errors(self):
    records = [
        SimpleNamespace(item="", amount=8000, date="2026-09-01"),
        SimpleNamespace(item="工资", amount=-1, date="2026-09-01"),
        SimpleNamespace(item="工资", amount=8000, date="2026-9-1"),
    ]
    with pytest.raises(ExceptionGroup) as exc:
        _validate_records(records)
    assert len(exc.value.exceptions) == 3
    assert all(isinstance(e, ValueError) for e in exc.value.exceptions)

为什么重要?因为它验证了"一次报全"------3 个错误全部收集到,而且都是 ValueError 类型。如果校验函数在第一个错误就 return 了,这个测试会红(len 不等于 3)。

这些测试就是 v0.7 的"安全网":以后改代码,跑一遍测试,绿了才敢提交。下一篇我们会深入 pytest 的工作流,把这张安全网织得更密。


13. 三个真坑:每个都值得你踩一遍(但别真的踩)

13.1 坑 1:StrEnum 与 Enum 混用,== 判定悄悄失效

症状

你在一个枚举里同时混了 StrEnum 成员和普通 Enum 成员,或者把 RecordKind 的成员和字符串直接比较:

python 复制代码
from enum import Enum, StrEnum

class A(StrEnum):
    X = "x"

class B(Enum):
    X = "x"

print(A.X == B.X)   # False ?!
print(A.X == "x")   # True(StrEnum 是 str 子类)
print(B.X == "x")   # False(普通 Enum 不是 str)

同一个字符串 "x",在 StrEnumEnum== 行为完全不同。

原因

StrEnum 成员继承 str,所以 A.X == "x" 走字符串比较;Enum 成员是普通对象,B.X == "x" 是对象比较(默认 False)。两者不能互相 ==

解决

  1. 项目里统一用 StrEnum (记账本就只用一个 RecordKind)。
  2. 需要和字符串比较时,用 kind.value == "x" 或统一转枚举再比。
  3. 别在一个枚举里混两种父类。

13.2 坑 2:@property 在枚举里炸了

症状

你在枚举里加了一个 @property,在某些版本上报错:

python 复制代码
from enum import Enum

class Color(Enum):
    RED = 1

    @property
    def rgb(self):
        return (255, 0, 0)

print(Color.RED.rgb)

在 Python 3.9 之前,propertyEnum 里不工作。3.9 之后原生支持。如果你遇到"我明明加了 property 却报错",先查 Python 版本。

原因

Enum 的描述符协议和 property 有冲突。Enum 早期版本把类体里的所有对象都当成"候选成员",property 被误判了。3.9 之后 CPython 修复了这个问题。

解决

  1. 升级 Python 到 3.11+(我们这篇就是这样)。
  2. 如果必须在旧版用 property,用 from enum import member, nonmember(3.11+ 引入)标注。member() 使对象成为枚举成员,nonmember() 使对象不被当成成员(方法/属性用这个)。

13.3 坑 3:异常继承链设计错了,catch 范围失控

症状

python 复制代码
class A(Exception): ...
class B(A): ...        # B 是 A 的子类

try:
    raise B("boom")
except A:              # 会接住 B!
    print("接住了 A")
except B:              # 永远执行不到(A 在前面先接了)
    print("接住了 B")

输出:

text 复制代码
接住了 A

except B 是死代码。因为 BA 的子类,except A 先匹配到了,后面的 except B 永远不会执行。

原因

Python 的 except从上到下匹配 的。第一个能匹配的 except 接住异常后,后面的 except 全部跳过。子类异常会被父类的 except 拦截。

解决

  1. except 从具体到通用排序 :先 except B,再 except A
  2. 自定义异常一律继承 Exception(不继承 BaseException)。
  3. 非必要不 except BaseExceptionKeyboardInterruptSystemExit 让它们该走就走。
python 复制代码
try:
    raise B("boom")
except B:              # 先接具体的
    print("接住了 B")
except A:              # 再接通用的
    print("接住了 A")

输出:

text 复制代码
接住了 B

14. 经验清单:把第 7 篇浓缩成 5 条带走

  1. 枚举消灭魔法字符串"income"/"expense" 这类裸字符串,一律用 RecordKind.INCOME 代替。打错字 import 阶段就报错,不埋运行时雷。
  2. 值要参与 IO 用 StrEnum (3.11+):成员本身是字符串,JSON 存取零迁移。想强制唯一加 @unique
  3. 自定义异常是 API :业务异常继承 Exception(绝不继承 BaseException),带上下文属性(record / bad_date / source),让调用方 catch 得到数据而非字符串。raise ... from ... 保留异常链。
  4. try/except/else/finally 四件套各司其职else 只在无异常时执行,finally 保证清理。except 从具体到通用排序,别让后面的 except 变死代码。
  5. 批量错误用 ExceptionGroup + except* (3.11+):一次报全所有问题,按类型分组处理,别一个个跑。单次操作直接 raise 就行,别硬套。

15. 下篇预告

v0.7 我们让账本"报错也报得明白"。但有个问题一直悬着:这些异常、校验、枚举,都是我们"自己说好"的,靠什么保证别人改代码时不破坏它?

下一篇,我们正式引入 pytest 测试框架:fixture、parametrize、monkeypatch、conftest。不是简单跑一下,而是建立一套"测试驱动开发"的工作流------先写测试、再改代码、跑绿了才提交。等你写完下篇,会发现自己写代码心里"有了底"------改一行,跑测试,绿了才敢提交。

互动问题:你在实际项目里,有没有踩过"魔法字符串打错字导致静默出错"的坑?花了多久才排查出来?评论区聊聊,下篇开头帮你复盘。


数据来源:本文所有代码均在本机 Python 3.12 实测运行(26 passed in 0.04s),枚举/异常行为参考 Python 官方文档 enumexceptions 章节;StrEnumExceptionGroup 为 Python 3.11 引入的特性,均已在本机验证。

相关推荐
Beyond_System|系统之外1 小时前
【学编程】Python基础编程题100道(21-60)
开发语言·python·算法
根目录下的猫1 小时前
RK3588适配的轻量级AI模型推荐
人工智能·后端·python·目标检测
wno7042 小时前
Spring Security权限控制
java·python·spring
ShineWinsu2 小时前
对于Coze—AI:SDK的解析
人工智能·python·ai·sdk·项目·coze·字节跳动
右耳朵猫AI3 小时前
Python周刊2026W38 | 标准流编码修复、PEP 845/846 草案、解析器提速 10%、集合字典二次复杂度
python·ai·数据科学
微软技术分享3 小时前
大模型网络结构:模型接口测试用例参考
python
Bruce_Liuxiaowei4 小时前
Python 实例赋值遮蔽类属性:一个从不报错的静默陷阱
开发语言·python·语法糖
做运维的阿瑞4 小时前
Python 标准库汇总:分类速览与常用模块清单
linux·运维·python
why-geo4 小时前
Hermes Agent 与 Python 的会产生什么样的碰撞
人工智能·python·ai编程