关于 python-conventions
python-conventions 为个人及由 Agent 维护的 Python 项目提供设计与风格指导,帮助在编写或审查代码时采用一致默认做法。它解释不明显的陷阱,但不负责配置 lint 或类型检查工具。
主要功能
- 01提供 Python 数据建模默认做法
- 02说明日期与时区处理原则
- 03解释迁移到 Pydantic 的陷阱
- 04区分设计指导与工具配置
- 05附带约定用法代码片段
适用场景
- 01为外部数据和设置选择数据模型
- 02审查 Python 代码中的日期时间处理
- 03将 dataclass 迁移到 Pydantic
- 04在 Agent 维护的项目中统一约定
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ theodoread/ agent-skills --skill python-conventions安装后在 Claude Code 中输入/python-conventions确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/rationale.mdreferences/snippets/async-fanout.pydata-modeling.pyexceptions.pyguard-clauses.pyhttp-retry.pysettings.py详细介绍
Python 设计与风格默认规范
适用于个人及由 Agent 维护的 Python 项目。在编写新代码,或针对以下主题之一审查现有代码时,如果没有明确提出“评估替代方案”,就采用默认方案,不要每次都从头重新讨论。有充分理由符合某个列出的例外情形时,可以偏离默认方案——制定这些规范的目的是避免新的会话或模型无缘无故地悄悄选用不同方案,而不是禁止酌情判断。
两个主题已于 2026-08-31 拆分,作为独立安装项:测试套件规范见
python-testing-conventions;MCP 服务的标准输入/输出日志记录、工具边界和工具文档字符串规范见
mcp-python-conventions。拆分前已进行衡量——我们针对真实请求检查了各部分,确认它们各自适用,且不会相互越界。
这是设计指导,不是工具配置。 这里不会说明应安装哪种类型检查器或代码检查工具,也不会说明如何配置它们——这些属于你的仓库用来执行自身标准的工具配置(以本文作者的情况为例,包括 basedpyright、ruff、shellcheck/shfmt、dprint 和 pytest 配置)。编写代码时应参考这项技能;工具配置则由仓库在搭建时统一执行。
以下每个主题都会说明,它是在覆盖你本能采用的默认方案,还是仅仅予以确认。 能力出色的模型即使没有提示,也能正确处理很多问题——提前返回、运行时查找采用 EAFP、合理编写异步代码,以及优先使用 pathlib 而不是 os.path。这项技能不会重新讨论这些内容;它关注的是模型在没有指引时容易偏离的选择(六种同样合理的数据建模方案、过度抽象的 DRY 倾向、照搬训练数据中最近接触的框架所用的设置模式),或真正不明显且因项目而异的约定,而非通用的 Python 知识(例如 globals.py 的单例结构)。以下每条 模型默认方案 都会说明属于哪种情况——对于仅仅确认你原本就会采用的做法,可以快速略过;对于其他做法,则应重点留意。
数据建模
- 代码片段:
references/snippets/data-modeling.py - 默认选择:对于任何需要解析外部/不可信数据(API 响应、MCP 工具参数)或设置/配置的场景,使用 Pydantic v2 和
frozen=True。其他所有场景——内部结构化数据、函数返回值、记录——使用@dataclass(frozen=True)。 - 原因:默认选择有两个,而不是六个,这样模仿现有代码的 Agent 就更不容易选错。Pydantic 的 Rust 核心缩小了与 v1 的验证速度差距,但始终无法弥合与 dataclass 在普通属性访问速度上的差距——这种区分并非抽象地比较哪种「更快」,而是区分边界验证与其他所有场景。
- 需要升级时选择:
attrs(不采用 Pydantic 的验证与序列化捆绑方式,同时使用验证器/转换器)、NamedTuple(需要直接兼容位置元组,或需要小型、封闭的记录,且顺序 就是 含义——字段数绝不超过约 3 个)、msgspec(Pydantic 开销已成为 经测量确认的 瓶颈时)、TypedDict(值确实必须保持为普通 dict,而不能变成对象)。 - NamedTuple 条款的第三个条件——不需要任何验证或行为——最容易踩坑,而「超过约 3 个字段」是较宽松的判断标准。 两字段的
Quantity(amount, unit)看起来像是教科书式的 NamedTuple,实际上却是个 bug:它继承了元组的排序行为,因此将Quantity(5, "ml")与Quantity(300, "mg")用<比较会返回True,而不是抛出异常,让不兼容的单位被悄然比较成功。对于比较存在前置条件的任何值类型,元组可直接排序是个隐患,而非便利。 - 这种隐患与 NamedTuple 的优点是同一特性从两个角度的体现,因此问题从来不是「这里用 NamedTuple 是否更好」——而是它要替换什么。 普通元组 ->
NamedTuple是明确的升级:原本没有名字,现在有了名字;而且位置形式本就存在,也已用于解包,因此不会泄漏任何新接口。冻结 dataclass ->NamedTuple则是降级:它_新增了_索引、解包、迭代,以及与任何携带相同值的普通元组进行相等性比较的能力——这是一套没人设计过的第二位置 API;在中间插入字段会悄然改变所有调用点上x[1]的含义,且不会在任何地方报错。2026-09-01 审查一个仅使用标准库、共 3,200 行的脚本时确认:16 个匿名的记录形状元组返回值改成了NamedTuple,而 6 个冻结 dataclass 则有意保留。 - 依赖不可用时——例如仅使用标准库的脚本、vendored 工具——在
__post_init__中验证,才能让配置保持为冻结 dataclass。 这就是只用标准库实现的「解析而非验证」(parse, don't validate),值得用这个说法来命名,因为正是这个说法让它容易被找到。配置只读取一次,之后的每个读取者都应能假定它格式正确。这也是 NamedTuple 条款中「不需要验证」条件的具体体现:NamedTuple没有__post_init__,因此要提供同样的保证,就得重写__new__或使用类方法工厂——比原本想要比它更省事的 dataclass 还要繁琐。 dict要么是映射,要么就是失去了类型的记录;判断标准是键代表数据,还是你选定的名称。 从外部传入的键——配置文件、JSON 负载中的键——属于数据,应保持为 dict。作者输入的键则是字段名:如果dirs["store"]在 7 处使用字符串字面量读取,那它就是一条记录;两个具名字段可以将拼写错误从运行时的KeyError转变为检查器能捕获的错误。合理的例外是序列化边界——构建好并准备传给json.dumps的dict[str, object]应保持为 dict;为它添加类型既繁琐,正反两种转换都如此。- 另一种项目级方案:如果项目任何地方使用 Pydantic,就在所有地方使用 Pydantic。 这是对上述拆分方案的合理替代,并非偏离;而且它是双向的——这也让「完全不用 Pydantic」成为真正的选择,而不是让依赖一次只通过一个方便的模块悄然引入。适用于项目已在边界使用 Pydantic,否则就会采用两种记录风格、让 Agent 不知道该模仿哪种的情况;你需要舍弃拆分方案本身的考量(经测量的普通属性访问开销、依赖体量),因此应在项目开始时一次性决定,而不是逐模块决定。
- 选择它最有力的理由并非一致性。 在一个迁移了约 30 个冻结 dataclass 的项目中测得:一个
Annotated别名取代了该代码库中 13 处手动维护的object.__setattr__规范化代码,用于处理最关键的不变量。真正的取舍在于:冻结 dataclass 只能通过__post_init__中一系列复杂操作来表达的验证逻辑;这比「一种风格,而非两种」更有说服力。 - 模型默认行为:覆盖已有选择。 如果不加干预,模型会根据最近看到的内容,在代码库中不一致地混用 Pydantic、dataclass、TypedDict 和 NamedTuple——这里没有一个足够强的单一默认倾向可供确认。
Pydantic 中 dataclass 从未遇到过的陷阱
以下四点均基于 pydantic 2.13.5 测量,都会影响从 dataclasses 迁移到 Pydantic 的项目,或采用上文全有或全无替代方案的项目。最后一点改变的是公共 API,而非调用点。
model_copy(update=...)不执行验证——它自己的文档字符串就说明了这一点。使用update=复制冻结模型时,可以传入朴素 datetime 或违反跨字段约束的值,而其他所有构造路径都会拒绝这些值。相比之下,dataclasses.replace会重新验证,因此这是真正的回归;迁移每个replace()调用点时都会直接遇到这个问题。请使用代码片段中的重新验证辅助函数,而不要使用model_copy。- 宽松模式会将
float强制转换为Decimal。 对于任何一个字段,只要它之所以使用Decimal,是因为不接受浮点数,那么默认行为就会悄无声息地移除这项保证。Annotated[Decimal, Strict()]可以恢复这项保证,并将元数据保留在注解中,与其他元数据放在一起。 - 配置应写在类声明中。 已验证
class Q(BaseModel, frozen=True)与model_config = ConfigDict(frozen=True)等价,可以与其他关键字组合,而且配置就在类名所在行,而不是看起来像数据的属性赋值语句中。 - 验证器抛出的
ValueError会被捕获并重新抛出为pydantic.ValidationError——任何非ValueError的异常都会原样传播。只要项目自定义异常层次结构以ValueError为基类,这个问题就不再无害;这是常见且经过深思熟虑的选择,目的是让以通用方式处理错误输入的调用方继续正常工作。迁移后,这个层次结构中的所有异常类都不会再传到调用方:原本会抛出MyValidationError的构造操作,现在会抛出pydantic.ValidationError,子类之间的区别只会保留在消息文本中。这个问题是在一个只有两个异常类的项目中测得的:一个是验证错误,另一个是单位不匹配错误;区分这两者正是同时保留它们的全部意义。解决办法是从基类中移除ValueError——并记录由此带来的影响,因为pydantic.ValidationError本身就是ValueError:要捕获构造操作可能抛出的所有异常,需使用(ProjectError, ValueError),也就是项目自身的错误,以及 pydantic 自行抛出的结构性错误。- 在解析边界处,反过来也完全合理:有意抛出普通的
ValueError,让 pydantic 捕获它、附上字段位置,并将多个问题汇总到一份报告中——然后在边界处将这份报告转换回项目自己的错误类型。因此,规则不是“绝不继承ValueError”;而是现在这个选择决定了错误携带的是类型还是位置,并且要按层分别作出选择。
- 在解析边界处,反过来也完全合理:有意抛出普通的
- 模型默认行为:覆盖。
model_copy(update=...)看起来显然等同于replace,也自然是首先会尝试使用的方法;调用点上没有任何信息表明它跳过了验证。ValueError陷阱更糟糕,原因也相同——基类是在迁移很久以前就选定的,而变更发生的地方没有任何信息会让人想到它。
日期、时间和时区
-
默认规则:只使用带时区的 datetime,并在每个边界处将其规范化为 UTC。 在接收时拒绝不带时区的 datetime,绝不强行转换;对所有存储或比较的值执行
astimezone(UTC)。仅在显示时转换为本地时间。时区使用zoneinfo(自 3.9 起为标准库),绝不使用pytz。 -
原因:两个带时区的 datetime 如果共享一个非 UTC 的
tzinfo,相减和比较时会使用各自的墙上时间,并悄然忽略期间发生的任何 DST 转换。跨越春季前拨时,一个“24 小时”的回溯窗口实际只覆盖 23 小时;两次给药之间的 6 小时间隔会提前一小时结束。更糟的是,在回拨时段附近,==和-的结果会相互矛盾——一个有歧义的 datetime 与其自身的 UTC 等价值相减结果恰好为零,比较时却不相等——因此,任何基于瞬时相等性的分支判断都是错的。输入时进行规范化,才能确保后续每次比较都表示实际经过的时间。2026-08-27 在ingesta中实测发现,这两种故障都确实存在,而且仅阅读代码无法察觉。 -
对于使用 Pydantic 的项目,规范写法如下:
Utc = Annotated[datetime, AwareDatetime, AfterValidator(lambda v: v.astimezone(UTC))]已验证:无论通过模型的哪种构造方式,它都会规范化带时区的非 UTC datetime,并拒绝不带时区的 datetime。这就是上述完整规则,可作为一个可复用注解;它取代了冻结数据类在每个字段上都需要手动维护的
object.__setattr__规范化逻辑。不使用 Pydantic 时,等效做法仍是在__post_init__中明确执行两个步骤——拒绝不带时区的值,然后执行astimezone(UTC)——绝不能只做其中一个。 -
解析本地墙上时间:
fold(PEP 495)就是完整的 API,每种情况都需要明确规定策略,而不是任由replace(tzinfo=...)决定行为。通过比较偏移量,而不是 datetime来检测——同一区域内的比较会忽略fold,因此无论如何设置,datetime 都会比较为相等。wall.replace(tzinfo=z, fold=0).utcoffset() < ...fold=1...表示时间_不存在_(春季前拨的缺口);>表示时间_有歧义_(秋季回拨)。然后明确选择处理方式:将缺口时间经 UTC 往返转换,会把它移到缺口之后;fold=0则会选择有歧义时间的第一次出现。 -
测试:要确保 DST 正确性,必须在真实的转换时点编写已知答案测试,手动推导预期瞬时值并直接断言。属性测试只是复述实现本身,遇到这个 bug 会直接通过。测试辅助函数也要进行规范化——如果属性测试对带时区的值计算
anchor - duration,它本身就有这个 bug,并会把正确代码报告为有问题;在修复辅助函数之前,确实发生过这种情况。 -
不要这样做:使用
datetime.utcnow()——它会返回一个_不带时区的_ datetime,而且自 3.12 起已弃用;请改用datetime.now(UTC)。也不要用浮点时间戳来绕开问题:带 UTC 时区的 datetime 本身就是值,而不是该值的编码。ruff 的DTZ规则集会自动捕获不带时区构造方式的问题,但无法捕获同一区域内算术的问题。 -
模型默认规则:覆盖。 模型会正确地单独使用
astimezone/ZoneInfo,但不会默认在边界处进行规范化;而且它们常常会写出只在跨越转换时才出错的同区域算术——审查时看不出来,不在 DST 边界上的测试也都发现不了。
设置与密钥管理
- 代码片段:
references/snippets/settings.py - 默认方案:
pydantic-settings。使用带有生产环境安全默认值的基类Settings,每个非生产环境对应一个子类,只覆盖有差异的配置;通过ENVIRONMENT环境变量驱动的选择器,在包的顶层__init__.py中接入,并赋值一次到模块级名称。始终设置frozen=True。 - 原因:这是将模块单例模式(如下)用于设置——急切构造会在导入时快速失败,而普通(非 Singleton)类允许测试自由构造独立实例,无需与共享全局状态周旋。
- 何时升级:使用
dynaconf——仅当仓库确实发展出多环境部署矩阵(由 Vault/Redis 支持的动态数据源、非 Python 操作人员手动编辑配置)时。 - 不要使用:在真正的 FastAPI 应用之外,不要使用 FastAPI 的
@lru_cache包装设置工厂。其设计理由(分摊请求间重复读取.env的成本)不适用于 CLI 工具或 MCP 服务,其测试覆盖机制特定于 FastAPI 依赖注入,而且它牺牲了导入时快速失败,却没有带来此处所需的好处。完整推理见原理文档。 - 模型默认倾向:需要覆盖。大量基于 FastAPI 示例训练的模型,默认会倾向于
@lru_cache工厂模式,而不是这种模式——急切构造基类 + 子类 + 环境选择器的形式,是特意选定的惯用写法,并非自然会采用的方案。
提前返回、守卫子句、快速失败与 EAFP
- 代码片段:
references/snippets/guard-clauses.py - 规则:守卫子句适用于非对称情况——一条正常路径,以及一个少见的异常提前退出。不要用它来区分两个同等重要的业务分支;那应该使用普通的
if/else。守卫子句用于验证_调用方的约定_(参数类型/范围);EAFP 则用于处理 Python 本身就会明确报错的_运行时_操作(字典/属性查找、I/O、网络)。 - 快速失败:
assert只用于内部“绝不可能发生”的自检(在python -O下会被编译掉)——绝不能用于输入验证。任何可能由错误输入或外部状态触发的情况都应raise一个真正的异常。失败时绝不返回None/哨兵值/(success, result)——假值会让调用方无法区分“为空”和“失败”。 - 异常层级:
references/snippets/exceptions.py——每个包至少有一个根异常;只有当调用方确实需要区分异常时,才添加更深层的叶子异常(2–3 层,与requests/click自身的结构相符)。 - 模型默认倾向:大体符合。提前返回风格和针对运行时查找使用 EAFP,已经接近默认行为;真正需要补充的是守卫子句与同等分支之间的细微区别,以及失败时绝不返回
None的规则,模型不会可靠地自动遵循这些规则。
模块化、可测试性、DRY、可读性与封装
- 默认原则:倾向于重复代码,而不是过早抽象(Fowler 的三次法则:第一次可以自由重复,第二次开始犹豫,第三次再重构)——错误的抽象比重复代码更难让 Agent 安全修改,而不是更容易。
- 架构:功能核心、命令式外壳适合 CLI/数据流水线形态的代码;但对于全部职责_就是_编排 I/O 的代码(MCP 服务),这种模式会显得吃力——此时应改用 Michael Feathers 的“接缝”术语体系。
- 封装:Python 没有真正的私有性(PEP 8:单下划线只是“弱提示”,仅此而已)。内部辅助模块默认公开;只对真正属于包公共接口的部分(MCP 工具定义、CLI 入口点)使用
__all__和下划线约定。 - 模型默认倾向:需要主动覆盖。让模型“清理”代码,甚至只是实现一项功能,它往往都会主动提取共享辅助函数/抽象——如果跳过本节,这是最可能遭到抵触的部分,而非无关紧要的小提醒。
将模块用作单例和延迟加载属性
- 代码片段:
references/snippets/settings.py(相同模式, 可推广到设置之外) - 默认做法:在模块级别实例化一个普通类一次(
globals.py模式)——这是标准库认可的做法 (“全局对象模式”),并非 GoF 单例,因此测试可以自由构造第二个隔离实例。对延迟加载的字段使用@property/cached_property,但仅限 getter 幂等且无副作用的情况——否则应使用显式的.load()方法, 因为属性无法在调用处表明开销。 - 需牢记的注意事项:
cached_property的线程安全保证(并发首次访问时恰好执行一次)已在 Python 3.12 中移除——如果 getter 不幂等,这会影响正确性,而不仅仅是性能。测试中的monkeypatch.setattr必须指向模块属性本身(monkeypatch.setattr(config_module, "X", ...)), 而不能指向通过from config import X引入的名称。 - 模型默认做法:覆盖项。 模型会优先选择依赖注入、基于类的单例或每次调用时实例化, 而不是直接使用模块级实例——这种模式是此项目系列特有的,不是常见默认做法。
无状态性与不可变性
- 默认做法:跨越边界的数据/值对象(函数参数、MCP 负载、配置、记录)使用
frozen=True—— 与上方数据建模表中的默认做法相同,并非单独的决策。普通的局部修改(循环累加器、返回前 构建结果)仍按惯例保持可变;不要为了避免这些操作而绕开 Python 自身的惯用方式。 - 合理的有状态例外并非需要淡化解释的边缘情况:缓存、连接池、速率限制器。应明确状态、 限定其作用范围,并在并发时加以保护——而不是将其消除。
- 所有不可变机制都有一个易被忽略之处:冻结容器只会冻结容器本身,绝不会冻结其内容
(对于带有
list字段的冻结数据类,obj.items.append(x)仍然有效)。Final/ClassVar不提供任何运行时强制检查——只有实际运行的类型检查器才能让它们生效(参见脚手架计划中的 basedpyright 配置)。 - 模型默认做法:覆盖项。 模型不会默认使用
frozen=True——默认可变符合 Python 语言 自身的默认设置,因此这是一项模型不会在无人提示时主动采用的显式选择。
命令行接口
- 默认做法:凡是具有子命令、选项或
[project.scripts]条目的场景,均使用 Typer。 注解类型承载 CLI 元数据;这与本技能已应用于数据建模的规则相同——元数据放在注解中, 而不是默认值右侧。 - 仅在确实受限于只使用标准库时使用
argparse,并明确说明限制,而非想当然。真正的 限制情形很少:必须在无需安装步骤的裸解释器上运行的脚本、在任何环境存在之前运行的引导 程序,以及依赖项无法随代码一起分发的场景(例如 Pyodide 负载)。“这只是个小脚本”不算。 - 选择 Typer,而不是原始 Click。 Typer 基于 Click 构建,因此两者并非对立选项——但选择 原始 Click 的项目是在为相同结果选择更多样板代码。Click 仍然是底层实现;只有遇到 Typer 的限制时才应退回使用它,而不应将其作为起点。
- 此规则不适用于任务运行器。
inv用于仓库内工作——参见invoke-task-conventions—— CLI 则是用户安装的程序。两者并不冲突;新命令应根据实际用途归属其中之一。 - 模型默认做法:覆盖项。 如果不加引导,模型会编写
argparse,因为这是标准库提供的 方案,也是大多数训练数据展示的做法。
类型卫生
- 将
# type: ignore/# pyright: ignore注释限定到具体错误代码——绝不要笼统地忽略整行。对真正的代码进行完整类型标注,包括临时示例/代码片段——未标注类型的片段会让模式匹配 Agent 认为其他地方也可以跳过类型标注。 - 模型默认行为:大多会照做。 限定范围的忽略注释已经接近默认行为;真正需要新增的是“连临时片段也要标注类型”——模型听到“只要一个简单示例”时会走的捷径。
- 测试类型而不是值:
assert_type会精确比较声明的类型,而函数类型会包含参数名——装饰后的任务函数体类型是(c: Context) -> None,无法用任何Callable[[Context], None]表达式表示,所以assert_type永远不会与它匹配。不要因此认为断言失败了;这正体现了它的精确性。改用带注解的赋值(body: Callable[[Context], None] = obj.body),这样如果类型退化为Any(通过reportAny)或变成其他具体可调用类型,仍然会失败。注意,此类断言由类型检查器检查,在运行时不起作用,因此要在文件中说明这一点——pytest全绿并不能证明其中任何断言通过。 - 模型默认行为:会覆盖。 对于“证明这个类型就是我认为的类型”,自然的第一反应是使用
assert_type;它的精确性很容易被误解为断言有问题,而不是断言足够精确。
包布局:优先使用 src/,而不是扁平布局
- 默认规则:此类项目中,任何可安装/可导入的包都使用
src/<pkg_name>/,而不是放在仓库根目录下的扁平<pkg_name>/。仓库自己的tasks.py/tasks/调用入口并非包,无论如何都留在仓库根目录的扁平布局中——此类项目的每个仓库都有这类入口,它从不会被安装或在其他地方导入;这项约定针对的是会被构建进 wheel 并导入的内容,而不是仓库自己的工具脚本。(2026-08-19 在power-user-linux-setup项目中实地确认:其tasks/目录包含约 25 个仓库专用的 invoke 任务模块,并非可分发的库——将其移到src/下也会与 invoke 自己的FilesystemLoader冲突;后者会从 cwd 向上查找字面路径tasks.py/tasks/__init__.py,绝不会查询已安装的副本。) - 原因:扁平布局可能让
pytest/导入操作悄悄解析到未安装的 cwd 副本,而不是实际已安装的包(Python 会将 cwd 放在导入路径的最前面)——这会掩盖真实的打包问题(缺少子包、资源文件未包含),直到真实用户安装后才会暴露。src/会让项目根目录本身无法导入,因此测试运行和可编辑安装都会被迫走与真实安装相同的路径。完整的 PyPA/Hynek Schlawack 引用见references/rationale.md§14。 - 升级到:无——这是默认规则,不是升级路径。纯脚本若从未打算在其他地方安装/导入(例如一次性 notebook、
tasks.py本身),则完全不在此约定的适用范围内,并非该约定的例外。 - 模型默认行为:会覆盖。 扁平布局(包目录直接放在仓库根目录)是大多数快速示例/教程代码,以及大多数模型在没有指示时的默认选择;
src/则需要有意识地选择。
异步与并发
- 代码片段:
references/snippets/async-fanout.py - 默认做法:MCP 工具函数使用普通的
def,而非async def——FastMCP 已会通过线程池(anyio.to_thread.run_sync)调度同步工具,让你免费获得真正的并发;该项目现有工具代码中,100% 都是同步的。对于真正的扇出代码(即协调器并发调用多个 MCP 客户端),默认使用asyncio.TaskGroup,而非asyncio.gather()——发生首个未捕获异常时,前者会取消同组的其他任务;而gather()会让它们继续运行,成为孤儿任务。只有在TaskGroup内部对每个子任务使用try/except,才采用类似gather(return_exceptions=True)的部分容错方式,不要直接使用gather。 - 原因:
gather()在失败后仍让同组任务继续运行,这会造成实际的资源泄漏,尤其对于有意限制请求速率、持有会话的“礼貌型”客户端——一个站点出错,不应导致其他站点受速率限制的调用或浏览器会话在无人监控的情况下继续运行。 - 需要进一步控制时:在扇出层使用
asyncio.Semaphore限制并发中的调用数——这与按站点设置的速率限制器控制的是不同维度(并发数与请求间隔),应将两者组合使用,而不是二选一。如果必须在async def代码中执行同步调用,可使用标准库的asyncio.to_thread()作为解决方案(FastMCP 内部调度同步工具时也使用相同机制)。 - 不要:在
async def代码中直接执行阻塞调用(time.sleep()、基于threading.Lock的限流操作、同步 HTTP 调用)——这会在调用持续期间冻结整个事件循环,而不仅仅是调用该操作的任务。 - 模型默认:部分掌握。 模型能正确编写异步语法;但如果不告知框架实际的调度机制,它们无法推断出为何应选择
TaskGroup而非gather,也无法了解 FastMCP 对同步/异步工具函数的界定。
将 AnyIO 作为异步 API
对于异步接口不止一个扇出场景的项目——例如存储、机器人或服务——应使用 AnyIO 编写代码,而不是直接使用 asyncio,并在 asyncio 后端上运行。这四点都已通过源代码确认。
- 理由在于安全性和结构,而非可移植性。 任务组不会像被丢弃的
create_task句柄那样导致任务失联;取消会通过取消作用域传播,而不必在每个调用点重新实现;一套原语就能取代需要在gather/wait/as_completed/wait_for之间作出选择的做法,避免各自不同的取消语义。可移植性是最薄弱的论据,不应作为重点——标准库的asyncio.TaskGroup已经为上述扇出场景提供了大部分结构,因此选择 AnyIO 是为了异步接口的其他部分。 - 应使用 asyncio 作为后端;大多数项目都应放弃选择 trio。 生态系统以 asyncio 为主:SQLAlchemy 的异步支持专用于 asyncio(其
util/concurrency.py使用asyncio.Lock/asyncio.Runner和 greenlet,而且库中根本没有出现 "trio"),asyncpg和 Starlette 也是如此。AnyIO 的完整 API 在 asyncio 上均可用,因此这样做没有任何代价。 - pytest 插件只会针对已安装的后端进行参数化。
anyio_backendfixture 使用get_available_backends(),该函数会返回实际能够导入的后端(AnyIO ≥ 4.12)——因此,如果未安装 trio,每项测试都会运行一次,使用 asyncio。固定该 fixture 只是用一行代码表明意图,并非修复。两方面都值得了解,因为文档中的“All supported backends”(所有受支持的后端)容易被理解为“所有存在的后端”。 - 不要同时安装
pytest-asyncio——AnyIO 自身的文档明确指出它与自动模式冲突,而且 AnyIO 的插件随 AnyIO 一起提供,无需额外安装。 - 将同步入口(CLI)桥接到异步层:对于每条命令都手写
asyncio.run调用流程的做法,asyncer的runnify正是为此提供的轻量封装。 - 模型默认行为:优先选择覆盖项。 模型默认会直接使用
asyncio,并将 AnyIO 视为 trio 兼容库,这颠倒了实际采用 AnyIO 的理由。
HTTP 客户端、会话、超时与重试/退避
- 代码片段:
references/snippets/http-retry.py - 默认选择:所有新的纯 HTTP 获取路径都使用
httpx(兼容 requests 的 API、默认超时设置更安全,并支持异步)。每个获取器实例创建一个Client/Session,并在其整个生命周期内复用,绝不为每个请求新建连接。无论使用哪个客户端,每个请求都要设置明确且针对目标站点调整过的超时。使用tenacity处理重试/退避:采用带抖动的指数退避,仅对范围较窄的可重试状态码(429/502/503/504)和暂时性网络异常生效;不要重试 404/403 这类表示“实际结果”的普通 4xx 状态码;如果响应包含Retry-After标头,则优先采用它,而不是计算出的延迟。 - 原因:复用连接和谨慎重试、遵循
Retry-After,不只是为了提升性能,更是直接服务于“礼貌”、受速率限制的客户端的实际使命;激进重试或每个请求都新建连接都与这一使命背道而驰。 - 升级到:无——这是默认做法,不是升级路径。现有的可用代码如果使用
requests,除非有明确的驱动需求,否则不应为了改用httpx而折腾(参见 Modularity 倾向于精简复用而非重复的立场)。 - 不要:假设任何 HTTP 客户端内置的重试功能都能处理 HTTP 层面的情况——
httpx/requests的内置重试仅适用于连接层面;对于 503,或带有Retry-After的 429,需要使用实际的重试库,或在其上叠加手写逻辑。如果速率限制器的节流调用包装了一个会重试的函数,重试必须发生在节流调用的 内部,而不是在外部包装它,否则慢速重试的退避等待会一直占用站点级的速率限制锁。 - 模型默认行为:大体符合,但存在一个实际缺口。 模型目前已默认设置超时并复用会话/客户端;但它们不会稳定地默认采用带抖动(而非纯线性/指数)的退避,也不会默认遵循
Retry-After——新增的关键点是限定重试范围的具体做法。
完整理由
参见 references/rationale.md——完整的引用记录:参考过的来源、针对每个主题考虑并否决的选项,以及上述每条分支/升级路径背后的推理。各节保留了原有编号,因此测试和 MCP 章节于 2026-08-31 移出、各自归入独立技能后,文件中会有编号空缺;这些编号是稳定的引用编号,并非连续序列。§1–8 最初是在 plans/2026-08-15-python-conventions.md 中研究并撰写的;该计划从 idea 晋升为此已构建技能后,这些内容便迁移至此(该计划文件现已弃用——如需查看,请参阅 git 历史记录)。§12 及之后的内容(异步/并发、HTTP/重试和 src/ 布局)是在晋升之后直接针对这项技能进行研究的,没有单独的计划文件阶段。
入门代码片段
references/snippets/ 中的每种模式都配有一个真实、通过 ruff 检查且独立完整的 Python 文件,可直接复制粘贴,而不是需要自行调整的教程。上文每个主题的“代码片段:”行都直接链接到对应文件。
编辑此技能
安装时,此文件会被_复制_到 ~/.agents/skills/python-conventions,而非创建符号链接,因此编辑已部署的副本会造成本地偏差——这正是此技能旨在防止的问题,而且改动不会传到其他机器。请在安装来源的仓库中编辑源文件,推送后,重新运行 skills add <that source> --global --skill python-conventions,以刷新所有项目中的副本。如果你安装时使用的是他人的仓库,而不是自己的 fork,那么源文件属于对方:请在那里提交 issue 或 pull request。