分享 / Odoo / 给 Odoo 补上静态类型:odoo-typegen

给 Odoo 补上静态类型:odoo-typegen

Odoo 的模型是运行时拼装出来的:一个模型由多个 addon 通过 _inherit 不断扩展,最后在 registry 里合成。这带来一个副作用——静态类型检查器看不见它。

问题在哪

Python 的类型检查器(Pyright、mypy)靠继承关系推断类型。但 Odoo 扩展模型并不靠 Python 继承:

class CrmLead(models.Model):
    _inherit = "crm.lead"

    @api.model
    def attach_source(self, partner_id: int, source: str) -> None:
        ...

在检查器眼里,env["crm.lead"] 的类型永远是 BaseModel:它不知道这个模型上还有什么方法。

于是你想调用别处加进来的方法时,编辑器只能这样报错:

class ResPartner(models.Model):
    _inherit = "res.partner"

    def action_mark_referral(self) -> None:
        leads = self.env["crm.lead"]
        leads.attach_source(partner_id=self.id, source="referral")
        # Pyright: Cannot access member "attach_source" for type "BaseModel"

⚠️ 关键在这条报错的含义:它不是说方法不存在,而是检查器不知道这个模型上有什么。 于是你既得不到补全,也得不到校验——写错参数名、传错类型,它都不会提醒你,只能整段 # type: ignore 掉。

如果你想再进一步,用 Protocol 去约束"服务层需要的模型接口",也同样做不到:检查器无法确认运行期拿到的模型实例真的满足那个接口。

设计思路

odoo-typegen 的做法是:读源码、生成类型存根(stub),让检查器"知道"模型最终长什么样。三个关键设计:

  1. 不 import,只解析:用 Astroid 读 AST,而不是把 addon 导入解释器运行。 这样生成过程不需要跑起来的 Odoo、不需要数据库,也不会执行任何 addon 代码——干净且安全。
  2. 按 manifest 依赖图排序:拓扑排序,被依赖的 addon 先解析,后加的覆盖在后。顺序对了,成员合并才对。
  3. 每个模型生成一个 .pyi,再生成一个带 overload 的 Environment 存根,把模型名映射到具体类型。

用法

# 指向你的 addon 目录(会按 manifest 依赖自动排序)
odoo-typegen ./addons

生成 typings/ 目录后,让 Pyright 把它包含进去(stubPath / extraPaths),编辑器立刻就能推断 env["model"] 的具体类型。

一个例子

① 某个 addon 给 crm.lead 加了方法(同上文的 attach_source)

② 跑一次 odoo-typegen,得到:

# typings/crm/lead.pyi
class CrmLead(Model):
    def attach_source(self, partner_id: int, source: str) -> None: ...

# typings/odoo/orm/environments.pyi
class Environment:
    @typing.overload
    def __getitem__(self, model_name: typing.Literal["crm.lead"]) -> CrmLead: ...
    @typing.overload
    def __getitem__(self, model_name: str) -> typing.Any: ...

③ 调用处就被检查了:

leads = self.env["crm.lead"]            # 推断为 CrmLead
leads.attach_source(partner_id=p.id, source="referral")   # ✅
leads.attach_source(partner_id=p.id, source=42)           # ❌ 类型错误

不需要 import CrmLead,也不需要手写任何注解。

目前的局限(作者自述)

  1. Odoo 核心模块的模型覆盖还没解析——架构上能做,先要解决性能。
  2. 模型内部的 self 没有类型——要给 self 类型就得 import 生成的存根,作者暂时不想引入这个依赖。
  3. ORM 基础方法(search/browse)的返回值还是 Any——在路线图上。
  4. “这个方法是哪个 addon 加的” 还在构想——数据其实已经有了(explain --model --method)。

一点评价

它解决的是编辑器与 CI 里的类型安全,不影响运行期,对现有代码零侵入、可逐步引入。

在 Odoo 这种"运行时拼装"的框架里,它走了一条不碰运行时的折中路线:不改造元类、不引入 monkey patch,代价是每个项目要生成一份存根,并随依赖变化重新生成。相比"让 _inherit 变成真正的 Python 继承"(如 typodoo 的思路),它更保守,也更容易随 Odoo 版本升级。

原文:Odoo-Typegen: Bringing Modern Static Typing to Odoo (作者 jbrocher) 仓库:jbrocher/odoo-typegen