Skip to main content

Command Palette

Search for a command to run...

Odoo-Typegen: Bringing Modern Static Typing to Odoo

Updated
•11 min read•View as Markdown
Odoo-Typegen: Bringing Modern Static Typing to Odoo

We recently adopted Odoo as our main ERP system at my current company. We're pretty satisfied with it; it covers many of our needs out of the box and is highly customizable. In particular, Odoo's addon system allows our in-house dev team to customize Odoo to fit our processes, and bridge the gap with company-specific concepts through domain-oriented addons.

One difficulty we faced early was understanding how the architectural patterns we're used to working with would fit into the Odoo system. In particular, we're fans of Domain Driven Design (DDD) and patterns like hexagonal architecture. In Python, those patterns work way better when supported by modern type hinting, like Protocols, for defining and validating interfaces between layers. However, we quickly realized this approach would not translate cleanly to Odoo.

Indeed, Odoo does not rely on Python inheritance for extending existing models through addons. Because of that, it's hard for a static type checker to guarantee that a given model instance retrieved through Odoo's environment matches a given protocol.

Meme about static typing in Odoo

There is existing work on this issue. For instance, Stéphane Bidoul’s 2022 talk and its companion project, typodoo, demonstrate how this could be solved upstream: adapt Odoo’s model metaclass so normal Python inheritance maps to _inherit, which would enable static typing. I agree that this is the right long-term direction, but I was not comfortable depending on a talk demo that monkey-patches such a critical path and would need validation with every Odoo upgrade.

Until Odoo supports that approach upstream, I built odoo-typegen: a CLI that inspects an Odoo project’s addon dependency graph and generates static stubs. The result lets Pyright understand the attributes and methods available through env["crm.lead"], with no source changes or runtime impact.

If you just want to use the tool, head over to the repo. The project’s README covers installation and usage. In this post, we'll give more details on why this tool exists and how it's implemented.

🔍 Why Static Type Checking fails with Odoo

When it comes to extending models declared by other addons, as noted earlier, Odoo does not rely on Python inheritance.

Instead, it uses Odoo’s custom model inheritance and extension mechanism, which is built at runtime from the addons dependency graph based on an _inherit attribute. Here is what it looks like:

from odoo import api, models


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

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

In this example, attach_source is a custom model-level method that finds a contact's leads and records their source. Its implementation is omitted here.

Until runtime, these overrides are not linked to the model that Odoo’s registry makes available. Static type checkers such as Pyright and Mypy therefore cannot infer which attributes and methods the built registry exposes. Instead, every model retrieved through the environment is typed as BaseModel.

Meme showing Pyright treating crm.lead and BaseModel as the same type

To demonstrate why this might be an issue, here is an example of something that bugged me (and my team) for a while.

Suppose you have a service called LeadProcessingService that needs to use some custom attach_source method of the crm.lead model. This method might be implemented by an addon you installed, or one of your own custom addons. The module where this service lives might look like this:

from typing import Protocol


class LeadModelProtocol(Protocol):
    """
    Minimal crm.lead protocol needed by the Lead Processing service
    """

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


class LeadProcessingService:
    def __init__(self, lead_model: LeadModelProtocol): ...

Finally, imagine that you forgot to install the addon that adds attach_source to crm.lead, or that its actual signature is def attach_source(self, partner_id: int, source: int) -> None. You'd want Pyright to catch either mismatch where the service is configured:

from odoo.api import Environment
from lead_processing_service import LeadProcessingService

# Pyright will probably raise something like this:
# Argument of type "BaseModel" cannot be assigned to parameter "lead_model" of type "LeadModelProtocol"
# in function "__init__"
# "BaseModel" is incompatible with protocol "LeadModelProtocol"
#   "attach_source" is not present [reportArgumentType]


def configure_lead_service(env: Environment) -> LeadProcessingService:
    return LeadProcessingService(lead_model=env["crm.lead"])

Except, it can't. Because Pyright has no way to know what env["crm.lead"] will look like once Odoo has finished building its registry, it will report an error. And rightfully so: from its perspective, we're passing a BaseModel instance to something that expects a rich LeadModelProtocol. To silence the typing error, you'd have to either add a # type: ignore or cast env["crm.lead"] at some point. Either way, we're asking the type checker to trust that the model actually matches our protocol, which we have no way to automatically verify.

🛠️ How odoo-typegen solves this

So what if we could give Pyright that missing information? I tried my hand at building a Python CLI that reads the addon source and generates type stubs for the models those addons extend. The idea is to give env["crm.lead"] a type that includes methods like attach_source, so Pyright can check it against our LeadModelProtocol.

One early choice was to use Astroid, an AST library, to parse addon files rather than import them in a Python interpreter. Importing and inspecting a standard Python class is straightforward. In this case, however, importing a file that depends on Odoo would require an Odoo installation and a running local database, just to run the CLI. This local instance would also need to be configured properly, with the correct addons installed, for the code to run. This would also risk running arbitrary side effects depending on the module code. None of this seemed suitable for a typegen CLI.

Design choice: odoo-typegen parses addon source without importing it. This keeps generation independent of a configured Odoo runtime and avoids executing addon code.

Parsing the files

I wanted a rich model so the parsing process would be easier to follow and leave room for additional features. Stub generation relies on four components:

  1. Registry, built by RegistryService, indexes the addons found in the supplied folder and records their manifest dependencies. It can order the addons following the dependency graph using TopologicalSorter.

  2. ModelFragment represents a model declaration or extension. It stores the extracted fields and methods, along with their source locations.

  3. StubClass represents the aggregation of all model fragments into a single interface.

These are all used by the Compiler. The Compiler visits addons in dependency order, groups their fragments in a ModelIndex, and combines their members for stub generation.

A rich representation of discovered and resolved addons improves traceability and logging. In the future, it could also allow odoo-typegen to answer questions such as “Where does this method come from?” by looking up the relevant ModelFragment.

These components work together to generate the stub files. The complete sequence of events when you run odoo-typegen is as follows:

  1. Index the addons in the supplied folder and read their __manifest__.py dependencies into a Registry.

  2. Visit those addons in topological order: each addon comes after the dependencies present in the registry. Starting from each addon's __init__.py, follow supported relative imports and extract model fragments, including their fields and methods.

  3. Group fragments by their effective model name (_name, or a single _inherit target) in a ModelIndex, preserving that order. Combine their members and emit one stub file per model.

  4. Emit an Environment stub with overloads returning typed models. With --odoo-path, also extract its existing interface from the local Odoo source.

  5. Emit BaseModel and Model stubs so that self.env is typed, plus re-exports for odoo.api and odoo.models.

For example, if crm_second_extension depends on crm_base_extension, the compiler collects the base extension's fragments first, even if it discovered the addons in the opposite order. Dependencies outside the supplied folder are ignored when sorting.

Here is a high-level view of the generation pipeline:

Sequence Diagram

The result

That's the gist of it. Now, going back to our earlier example, which extended crm.lead with attach_source, if you were to run:

odoo-typegen ./addons

Then the following stub files would be generated:

# typings/crm/lead.pyi
# Generated by odoo-typegen.
from odoo.models import Model


class CrmLead(Model):
    def attach_source(self, partner_id: int, source: str) -> None: ...
# typings/odoo/orm/environments.pyi
# Generated by odoo-typegen.
import typing
from crm.lead import CrmLead


class Environment:
    @typing.overload
    def __getitem__(self, model_name: typing.Literal["crm.lead"]) -> CrmLead: ...
    @typing.overload
    def __getitem__(self, model_name: str) -> typing.Any: ...

Now a contact action can use that custom method to mark the leads of each selected contact as referrals:

from odoo import models


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

    def action_mark_referral(self) -> None:
        leads = self.env["crm.lead"]  # Inferred: CrmLead
        for partner in self:
            # This is type-checked 🎉
            leads.attach_source(
                partner_id=partner.id,
                source="referral",
            )

The generated stubs let Pyright recognize attach_source and check its arguments: passing source=42 would be a type error. The action handles multiple selected contacts without importing or annotating CrmLead.

⚠️ Limitations

I'm pretty happy with this result, as it solves most of my team's problems regarding Odoo's lack of modern type hints. However, there are still some limitations that need to be overcome.

1. Model overrides from Odoo core are not parsed yet

This should be fairly easy to solve with the current architecture: point the existing compiler to a local Odoo source tree. With the current performance bottleneck, however, this would take too long. The plan is to tackle the obvious optimization first, then see how long parsing the core modules actually takes.

We would not need to parse every core model, only the ones in the dependency graph built by following each __manifest__.py, using the selected addon folder as the root.

2. self is not typed inside model overrides

Right now, odoo-typegen automatically generates an Environment stub so models retrieved through env are typed correctly. Inside the model itself, though, we'd want self to be typed as it would be with normal inheritance. That is harder to do without creating a dependency on the generated stub files.

Typing each override based on its position in the inheritance graph would be easy enough, thanks to the ModelFragment that records each override. We could generate one intermediate StubClass for every override, containing both the current class's signature and the overrides applied by previous addons.

However, this would mean manually importing those stub files to declare the type of self, creating a code dependency on my library. I'd like to avoid that for now.

If someone has an idea for solving this, feel free to submit a PR!

3. ORM calls can lose the model's type

self.env["crm.lead"] is typed and includes the base methods of Model like search or browse(). However, the return types of these base methods are not currently typed by the compiler, so self.env["crm.lead"].search() would be inferred as Any. Typing those methods is the next item on the roadmap!

4. Answering the question "Where does this method come from?"

This is more of an additional feature than a limitation. Currently the ModelIndex, before being aggregated into StubClass by the compiler, "knows" the source of each method. This means we could probably do something like odoo-typegen explain --model "crm.lead" --method "attach_source" and have the tool respond with the addon that added the method.

🎉 Conclusion

If you made it this far, thanks for reading!

I started this project for fun, to gain a better understanding of Odoo internals. It turned out to be pretty useful for my team, so I decided to share it in a blog post.

Maybe you'll find it useful too! If that's the case, I'd be delighted to hear some feedback. Feel free to open an issue in the repository. If you want to tackle the limitations listed in the post, feel free to open a PR as well :).

Happy coding!

📝 Sources

  1. Odoo ORM API — Environment, Odoo documentation.

  2. Towards idiomatic Python with types for the Odoo ORM, Stéphane Bidoul, Odoo Experience 2022.

  3. typodoo, Stéphane Bidoul, GitHub repository.

  4. odoo-typegen, GitHub repository.

  5. Odoo ORM API — Inheritance and extension, Odoo documentation.

  6. Astroid, pylint-dev, GitHub repository.

More from this blog

IO.IO: Tips & Tutorial For The Modern Web Developer

14 posts

Lead Software Engineer, I write about Web Development and Software Architecture. You can find me on Twitter and Mastodon