Pydantic 的 field_validator:字段什么时候校验,返回值去了哪里?

在请求模型里看到 @field_validator("score"),很容易以为它只是给方法加了一个标记:这个方法由谁调用?返回的值又去了哪里? 它的作用是告诉 Pydantic:校验 score 字段时,自动执行下面的方法,并把返回值作为这个字段的最终值。 本文适合已经会写 BaseModel、但还不熟悉自定义校验的读者,示例使用 Pydantic v2。 类型正确,还不够 假设接口接收一次考试成绩: { "score": 120 } int 可以说明成绩应当是整数,但 120 也是整数。对于满分 100 分的考试,还需要检查成绩是否在 0~100 之间。 这些规则可以放进字段校验器,在请求进入业务处理前统一完成。 一个可以直接运行的例子 把下面代码保存为 example.py,通过 uv run --with 'pydantic>=2,<3' python example.py 执行: from pydantic import BaseModel, ValidationError, field_validator class ExamResult(BaseModel): score: int @field_validator("score") @classmethod def validate_score(cls, value: int) -> int: if not 0 <= value <= 100: raise ValueError("成绩必须在 0~100 之间") return value result = ExamResult(score=85) assert result.score == 85 print(result.score) try: ExamResult(score=120) except ValidationError as exc: error = exc.errors()[0] assert error["loc"] == ("score",) print(error["msg"]) else: raise AssertionError("应该拒绝超过满分的成绩") 输出: ...

2026年10月22日 · 1 分钟 · map[name:Jeanphilo]

Python range() 的边界:为什么循环有时一次也不执行?

下面这段代码不会打印任何内容,也不会报错: for x in range(10, 7): print(x) 原因很简单:range(10, 7) 使用默认的正步长,起点已经超过终点,因此范围为空,循环体一次也不执行。 本文适合刚开始学习 Python 循环的读者,阅读约需 3 分钟。 先看懂 range() 的三个参数 range() 常见的写法有三种: list(range(4)) # [0, 1, 2, 3] list(range(2, 7)) # [2, 3, 4, 5, 6] list(range(2, 7, 2)) # [2, 4, 6] 完整形式是 range(start, stop, step): start:起点,省略时默认为 0。 stop:终点,不包含在范围内。 step:步长,省略时默认为 1。 range() 返回的是一个范围对象,并不是列表。这里使用 list(),只是为了直接观察其中的值。 对于正步长,每个值都必须满足 x < stop。因此,range(2, 7) 从 2 开始,每次增加 1,到 6 为止,不包含 7。 起点碰到或超过终点,会发生什么? 默认步长是 1,所以如果一开始就有 start >= stop,连第一个值都不符合条件: list(range(7, 7)) # [] list(range(10, 7)) # [] 空范围是正常结果,不是异常。 for 循环没有值可以遍历,就会直接跳过循环体,继续执行后面的代码: ...

2026年10月8日 · 2 分钟 · map[name:Jeanphilo]

Python 中 @staticmethod 和 @abstractmethod 的区别

在阅读 Repository 或 Service 代码时,经常会同时看到 @staticmethod 和 @abstractmethod。它们都写在方法上方,很容易被误认为是同一类装饰器。 实际上,两者解决的是不同问题: @staticmethod 解决方法如何被调用 @abstractmethod 约束子类必须提供什么行为 理解这个区别后,方法应该放在类中、模块中,还是抽象基类中,会清楚很多。 @staticmethod:不依赖实例状态的方法 普通实例方法默认接收 self: class KeywordMatcher: def contains(self, text: str, keyword: str) -> bool: return keyword.casefold() in text.casefold() 如果方法体不读取或修改 self,可以写成静态方法: class KeywordMatcher: @staticmethod def contains(text: str, keyword: str) -> bool: return keyword.casefold() in text.casefold() 调用时不需要创建实例: KeywordMatcher.contains("变压器采购项目", "变压器") 静态方法没有自动传入的 self,因此不能访问实例属性: class KeywordMatcher: def __init__(self, case_sensitive: bool): self.case_sensitive = case_sensitive @staticmethod def contains(text: str, keyword: str) -> bool: # 这里不能访问 self.case_sensitive return keyword in text 如果函数只是通用计算,并不属于某个类的概念,直接定义为模块级函数通常更自然: def contains(text: str, keyword: str) -> bool: return keyword.casefold() in text.casefold() 所以“不需要 self”是使用 @staticmethod 的重要条件,但还要问一句:这个行为是否真的属于该类?如果答案是否定的,就不必为了使用装饰器而把函数塞进类里。 ...

2026年9月9日 · 2 分钟 · map[name:Jeanphilo]

JSON 不只是键值对:Python 列表如何映射到 SQLAlchemy JSON 列

JSON 不只有键值对,还可以表示数组、字符串、数字和布尔值。本文用关键词订阅示例解释 Python tuple、list、dict 与 SQLAlchemy JSON 列之间的映射关系。

2026年9月8日 · 3 分钟 · map[name:Jeanphilo]

Python 类型标注中的 tuple[T, ...]:任意长度和固定长度

Python 类型标注中的 tuple[T, ...]:任意长度和固定长度 副标题: tuple[DocumentBlock, ...] 中的 ... 不是一个要放进元组的值,而是 类型标注中的“任意数量”记号。理解它,才能看懂解析结果为什么使用元组,以及它和列表的边界。 适读人群: 正在使用 Python 类型标注、dataclass 或文档解析模型的开发者 阅读时间: 6 分钟 从 ParsedDocument 的 blocks 字段说起 在文档解析服务中,可以把解析结果表示成一个不可变的数据对象: from dataclasses import dataclass @dataclass(frozen=True) class DocumentBlock: block_id: str raw_text: str @dataclass(frozen=True) class ParsedDocument: blocks: tuple[DocumentBlock, ...] 这里最容易让人疑惑的是: blocks: tuple[DocumentBlock, ...] 这个标注到底表示什么?为什么不是: blocks: tuple[DocumentBlock] 答案是:前者表示“任意长度、每一项都是 DocumentBlock”,后者表示“固定只有一项, 而且这一项是 DocumentBlock”。 tuple[T, ...] 表示任意长度 把它拆开看: tuple[T, ...] ^ ^ | └── 元素数量不限 └────── 每一项的类型是 T 因此: ...

2026年9月8日 · 4 分钟 · map[name:Jeanphilo]

Python 中 if not value 与 is None 的区别

Python 中 if not value 与 is None 的区别 副标题: if not line 判断的是“这个值是否为假值”,if line is None 判断的是“这个值是否就是 None”。两者可能在某些输入上得到相同结果,但表达的业务语义并不相同。 适读人群: 正在学习 Python 条件判断,或需要处理文本、接口参数和可选字段的开发者 阅读时间: 6 分钟 从文档解析中的空行过滤说起 逐行解析文档时,经常会看到下面的代码: for raw_line in lines: line = raw_line.strip() if not line: continue # 继续处理非空内容 这里为什么使用: if not line: 而不是: if line is None: 关键不在于哪种写法更简短,而在于程序此时要识别的是空字符串,不是 缺失值 None。 先跟踪 line 是怎么产生的 这一段代码的执行顺序是: raw_line = " " line = raw_line.strip() str.strip() 会删除字符串首尾的空白字符,并返回一个字符串: assert " 投标人资格要求 ".strip() == "投标人资格要求" assert " ".strip() == "" assert "".strip() == "" 只包含空格的行经过 strip() 后会变成空字符串 "",不会变成 None。 因此后面的条件实际需要回答: ...

2026年9月8日 · 3 分钟 · map[name:Jeanphilo]

Python 字符串为什么不可变:从 replace 到变量重绑定

Python 字符串为什么不可变:从 replace 到变量重绑定 副标题: replace 看起来修改了字符串,实际发生的是创建结果并让变量改指向它。理解这一区别,才能看懂字符串方法、别名行为和循环拼接的成本。 适读人群: 刚开始学习 Python 字符串、变量与对象关系的读者 阅读时间: 6 分钟 从一段括号消除代码说起 下面的函数反复删除成对的括号,直到字符串不再变化: def is_valid_by_elimination(s: str) -> bool: while True: previous = s s = s.replace("()", "").replace("[]", "").replace("{}", "") if len(s) == len(previous): return s == "" 这里容易产生一个疑问: previous = s s = s.replace("()", "") 既然 previous 和 s 原来指向同一个字符串,第二行为什么不会同时改变 previous? 答案是:Python 字符串是不可变对象。replace 没有修改原字符串,而是计算出一个 结果,随后赋值语句让变量 s 改为指向这个结果。previous 仍然指向原字符串。 “不可变”到底是什么意思 字符串不可变,指的是: 一个 str 对象创建以后,它所表示的字符序列不能被原地修改。 例如: text = "cat" text[0] = "b" Python 会抛出异常: TypeError: 'str' object does not support item assignment 不能把已有字符串中的 c 原地改成 b。如果需要 "bat",必须得到另一个字符串值: ...

2026年8月28日 · 3 分钟 · map[name:Jeanphilo]

Python Callable 类型标注:怎么描述一个 handler 函数?

Python Callable 类型标注:怎么描述一个 handler 函数? 副标题: Callable[[dict[str, Any]], Any] 不是一个新类,而是在类型层面说明: 这个参数是一个“能被调用的对象”,它接收什么参数,返回什么结果。 标签: Python / 类型标注 / Callable / Type Alias / Pipeline 适读人群: 正在写回调、handler、pipeline step、插件机制的 Python 开发者 阅读时间: 6 min 背景:为什么 handler 类型看起来这么长? 在 pipeline runner 里,经常会有这种 handler 表: handlers = { "prepare": prepare, "enrich": enrich, } 每个 handler 都接收同一个运行时上下文: def prepare(context: dict[str, Any]) -> dict[str, bool]: context["prepared"] = True return {"prepared": True} 如果直接给 handler 表写类型,会变成: from collections.abc import Callable, Mapping from typing import Any handlers: Mapping[str, Callable[[dict[str, Any]], Any]] 这一串不难,但读起来吵。它把“workflow name 到 workflow handler 的映射”写成了一段类型语法。 ...

2026年6月3日 · 3 分钟 · map[name:Jeanphilo]

Python 从 dict/list 原型到稳定接口:什么时候该整理类型?

Python 从 dict/list 原型到稳定接口:什么时候该整理类型? 副标题: 验证想法时先用普通变量和 dict/list 跑通行为;模型稳定后,再把它们整理成函数参数、dataclass、helper 和类型标注。 标签: Python / 原型验证 / 类型标注 / 接口设计 / Pipeline 适读人群: 正在从脚本式代码过渡到可维护模块的 Python 开发者 阅读时间: 7 min 背景:为什么一开始不用把类型设计得很完整? 写一个新功能时,经常会卡在这个问题上: 我应该一开始就写 dataclass、class、Mapping、Callable 吗? 还是先用普通 dict/list 把行为跑通? 答案不是固定的,但有一个很实用的判断: 模型还不确定时,先用 dict/list 验证行为; 模型稳定后,再把稳定的东西整理成接口。 这不是偷懒,而是在降低错误抽象的成本。 比如你想验证一个 graph pipeline runner,最早可以只写: graph_plans = { "parallel": { "prepare": (), "enrich": ("prepare",), "extract": ("prepare",), } } 这个变量一开始不是“最终架构”,只是为了回答一个问题: dependency map 能不能表达 prepare 之后 enrich 和 extract 同时 ready? 如果这个问题都没验证清楚,提前设计完整类层次反而会遮住真正的行为压力。 第一阶段:用普通变量验证模型 最早的脚本里,很多东西看起来像“常量”: ...

2026年6月3日 · 3 分钟 · map[name:Jeanphilo]

Python 类型标注里为什么用 Mapping、Sequence 这类抽象接口?

Python 类型标注里为什么用 Mapping、Sequence 这类抽象接口? 副标题: 当一个函数只读取配置,不修改调用方传入的字典时,用 Mapping 比直接写 dict 更能表达接口意图;同理,Iterable、Sequence 和 Callable 也在表达“我需要什么能力”。 标签: Python / 类型标注 / Mapping / Sequence / Callable / 接口设计 适读人群: 正在写 Python 配置对象、runner、pipeline、SDK 参数的开发者 阅读时间: 7 min 背景:为什么不是所有字典参数都写成 dict? 写 Python 配置型代码时,经常会有这种参数: graph_plans = { "parallel": { "prepare": (), "enrich": ("prepare",), "extract": ("prepare",), } } 如果一个 runner 只是读取它: runner = GraphPipelineRunner(graph_plans=graph_plans, handlers=handlers) 那么类型标注可以写成: from collections.abc import Mapping def __init__( self, *, graph_plans: Mapping[str, Mapping[str, tuple[str, ...]]], handlers: Mapping[str, WorkflowHandler], ) -> None: ... 刚开始看会觉得复杂:为什么不直接写 dict? ...

2026年6月3日 · 3 分钟 · map[name:Jeanphilo]