2026/9/11 21:30:57

PostHog Python Dataclass 编写规范:用 `@frozen` 构建安全的内部值对象

PostHog Python Dataclass 编写规范:用 `@frozen` 构建安全的内部值对象 PostHog Python Dataclass 编写规范用frozen构建安全的内部值对象【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog本指南基于 PostHog 仓库中的.agents/skills/writing-dataclasses/SKILL.md开发者技能规范系统讲解 PostHog 团队对 Pythondataclass的「家规」何时应该使用 dataclass 而非元组或dict[str, Any]为什么统一使用posthog.dataclasses.frozen装饰器以及如何命名、构造、消费、演进和跨层传递这些对象。读完本文你将掌握 PostHog 的 dataclass 最佳实践并能直接套用到自己的项目中把「静默类型互换」这类运行时 bug 提前变成类型检查错误。核心动机消灭静默的同类互换规范开篇即点明了所有规则背后的唯一目的值共享同一类型时会被静默互换silently swapped而位置型元组positional tuple或dict[str, Any]恰恰给了这种互换发生的空间。例如(start, end)、(width, height)、(rows, columns)这样的二元组两个元素类型完全相同调用方一旦写反解释器不会报错只会产生一个难以追踪的运行时 bug。而一个具名named、冻结frozen的 dataclass能把这种互换变成类型检查失败typecheck failure而不是运行时错误。文档明确说明如果某条规则在你的场景下不能服务于这个目的请在 PR 里说明理由并跳过它——规则服务于意图而非为规则而规则。何时用 dataclass何时不用规范给出三条清晰的判断标准返回或传递 dataclass 而非元组当两个或以上元素共享同一类型时如(start, end)、(width, height)、(rows, columns)或者元组大约有 3 个元素、位置化访问伤害可读性时应当使用 dataclass。而一个小型、元素类型明显不同的元组如(user, count)保持原样即可。优先 dataclass 而非dict[str, Any]当一组固定的值跨越函数边界时。dict 的键拼写错误只会在运行时失败而 dataclass 字段拼写错误在类型检查阶段就会暴露。真正动态的键集合才继续使用 dict。NamedTuple不是答案它仍然支持位置化解包解决不了互换问题。选择哪个装饰器posthog.dataclasses.frozen装饰器的默认值PostHog 在 posthog/dataclasses.py 中提供了家规装饰器frozen它是标准库dataclass的封装默认开启frozenTrue, kw_onlyTrue, slotsTrue且每个参数都可覆盖from posthog.dataclasses import frozen frozen class BillingPeriod: start: datetime end: datetime frozen(slotsFalse) # 类使用了 functools.cached_property class ParsedQuery: ... frozen(frozenFalse) # 构造后确实需要被修改builder、accumulator class RunAccumulator: ...从 posthog/dataclasses.py 的源码可以看到其实现使用dataclass_transform(frozen_defaultTrue, kw_only_defaultTrue)标注以便类型检查器理解语义通过kwargs.setdefault(frozen, True)、setdefault(kw_only, True)、setdefault(slots, True)设置家规默认值再透传给dataclass(**kwargs)。可覆盖的完整参数包括frozen、kw_only、slots、eq、order、repr、unsafe_hash、match_args、weakref_slot。三个默认值的意义kw_onlyTrue是真正阻止构造期互换的关键BillingPeriod(starta, endb)永远不写BillingPeriod(a, b)。没有充分理由不要覆盖它。slotsTrue会阻止functools.cached_property和临时属性需要这些能力时用frozen(slotsFalse)覆盖而不是放弃frozen。frozenTrue提供不可变性冻结实例可哈希能安全地作为 dict/set 的键。命名规范类名要按领域概念命名而不是按「管道/基础设施」命名ClickHouseCredentials、BillingPeriod、SnapshotManifestItem。只有函数的产出物真的就是那个概念时才用*Result后缀内部对象永远不要用*Info、*Data、*Tuple、*Response这类名字。仅在一个模块内部使用的类用下划线前缀如_Foo标明私有性。在仓库中可以找到大量遵循此规范的实例例如 posthog/models/organization.py 中的frozen class BillingPeriod其current_billing_period方法返回BillingPeriod(startstart, endend)——正是kw_only关键字构造的写法posthog/models/identity_provider_config.py、posthog/models/integration/oauth.py 等文件中也都大量使用frozen。构造与不变量invariant强制在__post_init__中强制不变量如start end、恰好设置一种认证方式、数值在范围内。这样非法实例在构造时就会失败而不是在后续深层逻辑中才暴露。冻结类上仅在必须归一化normalize时使用object.__setattr__更推荐的做法是直接抛出异常。封闭字符串集合的字段要用Literal[...]或枚举而不是裸str注意收窄现有字段类型可能暴露调用方传入裸str的 mypy 错误——应当修复调用方而不是把字段重新放宽。冻结 dataclass 可哈希当键包含两个以上同类型组成部分时用一个小的 keyed 类作为 dict/set 键而不是元组。消费与演进Evolving用点号读字段result.start。永远不要写a, b result.a, result.b解包成位置局部变量——那会重新引入互换问题。用dataclasses.replace(instance, fieldvalue)演进冻结实例不要手工逐字段拷贝。多变体时用match/case分发如case BinaryOp(leftleft, rightright):单一类型的判断则保持普通的isinstance守卫即可。密钥保护field(reprFalse)秘密字段必须标记field(reprFalse)防止它们通过repr()泄露进 traceback 和日志。同时永远不要对这类 dataclass 调用asdict()输出到日志——那会重新暴露reprFalse隐藏的内容。跨层传递Preserve Whole Object当函数参数与调用方已持有的 dataclass 字段一一对应时应该接受整个 dataclass 而不是解包后的字段。这会阻止同类型的形参被逐层位置化传递threaded through several layers这正是 Fowler 所说的 Preserve Whole Object 模式。规范给出了三条按优先级排列的例外carve-outs不变量优先于镜像Invariants win over mirroring永远不要把更宽的类型传给需要更窄类型的被调用方。如果 dataclass 有url: str | None而辅助函数需要str就保留str参数或在边界收窄类型不要接受 dataclass 后再加运行时ValueError——那等于把类型检查换成守卫子句与初衷背道而驰。线上wire签名保持原样Temporalactivity.defn/workflow.run与 celery 任务边界接受什么就接受什么其背后的辅助函数才接受 dataclass。作为请求体的 facade 契约同样是 wire 签名facade/contracts.py中由DataclassSerializer和validated_request支撑的契约就是 HTTP 请求体、OpenAPI schema、以及客户端发送的形状。产品的logic可以接受自己的契约只要字段与 logic 所需一一对应就不要预先构建并行的内部 DTO。只有出现真正的分歧时才拆分内部参数对象wire 携带了 logic 忽略的死字段/废弃字段、logic 需要 wire 不接受的值、或 logic 需要 wire 无法承诺的不变量。拆分点位于facade/api.py——它是唯一的进程内调用方。仓库中的 facade 契约实践PostHog 的每个产品模块都遵循「facade 契约」模式。以 products/access_control/backend/facade/contracts.py 为例该文件明确注释为「Stable, framework-free frozen dataclasses that define what this product exposes to the rest of the codebase. No Django/DRF imports here.」——即用dataclass(frozenTrue)定义稳定的、不依赖框架的输出/输入 DTO如PropertyAccessControlRule、PropertyAccessControlState并通过field(default_factorylist)提供可变容器默认值枚举字段使用PropertyAccessLevel枚举类型而非裸str。结合 products/architecture.md 可以看到整个模式的全貌facade 是产品唯一的公共接口facade/api.py定义公共接口展示层DRF位于 facade 之上、在契约表面之外。规范要求 facade 契约默认使用pydantic.dataclasses.dataclass(frozenTrue)以便在构造时完成校验。由什么来强制这些规则规范由三层机制共同保证1. 阻塞性棘轮blocking ratchetposthog/test/repo_invariants/test_dataclass_defaults.py 是一个基于 AST 的测试扫描posthog、ee、products、common四个根目录下的所有.py文件找出「未声明frozen选择的裸dataclass」并与基线文件dataclass_frozen_baseline.txt对比——每个文件超过基线数量即 CI 失败。已有裸dataclass被基线「祖父条款」豁免随迁移逐步清理。关键细节dataclass(frozenFalse)显式声明可变性是通过的——棘轮要求的是明确的选择而非强制不可变如果只是移动了已有裸dataclass代码用python posthog/test/repo_invariants/test_dataclass_defaults.py重新生成基线而不是给装饰器加上frozenFalse不要给自己没动过的裸dataclass添加frozenFalse——棘轮按文件计数与基线比对未变化的计数能通过而这次编辑只是在别人的文件里制造噪音churn。2. 建议性 semgrep 规则advisory.semgrep/rules/devex/prefer-frozen-dataclasses.yaml标记「裸dataclass未声明frozen选择」级别 WARNING信息性不阻塞。它排除**/migrations/**与**/.semgrep/**并特意排除了无法导入posthog.dataclasses的独立 PEP 723 脚本包**/packages/pr-approval-agent/**。规则还智能地区分从pydantic.dataclasses导入的dataclass有自己的校验语义不会被误报。真正需要豁免时在类行上加# nosemgrep: prefer-frozen-dataclasses -- reason。.semgrep/rules/devex/tuple-return-prefer-dataclass.yaml标记函数返回类型为tuple[$T, $T]两个同类型元素可被静默互换或tuple[$A, $B, $C, ...]3 元素需位置化读取的注解提示改用frozen具名字段。既有发现同样被祖父豁免只有新增的才阻塞 CI。3. 代码评审review规则之外的一切最终靠 review 把关——这正是规范开头「如果某条规则在你的场景下不适用请在 PR 中说明并跳过」的文化前提。快速自查清单在提交 dataclass 相关代码前对照以下清单关注点正确做法装饰器用frozen默认 frozen kw_only slots确需可变/非 slots 时显式覆盖构造关键字构造BillingPeriod(starta, endb)绝不位置化构造不变量在__post_init__中校验构造即失败字段类型封闭字符串集用Literal[...]或枚举读取点号读字段不位置化解包演进dataclasses.replace(instance, fieldvalue)密钥field(reprFalse)且不asdict()进日志跨层接受整个 dataclasswire 签名保持原样分歧时在facade/api.py拆分内部 DTO命名领域概念名不用*Info/*Data/*Tuple/*Response这套规范的直接收益是PostHog 数千个 Python 文件共享的内部值对象——无论是计费周期、凭据配置还是各产品 facade 的输入输出 DTO——都能以「类型系统可验证」的方式传递把互换类缺陷消灭在 CI 阶段。【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考