Skip to content

Helpers

Polytropos provides helper functions for Odoo addon development.

Installation

To use helpers in your addon, add polytropos to your external dependencies:

pyproject.toml
[[tool.polytropos.manifest]]
external_dependencies.python = ["polytropos"]

Usage

Import the polytropos.ph module to use the helper functions:

from polytropos import ph

if ph.rel(">=18.0"):
    # Code specific to Odoo 18+
    do_something()
else:
    # Code for Odoo 17 and earlier
    do_something_else()

This module requires Odoo to be installed. If Odoo is not available, importing this module will raise ModuleNotFoundError.

Logging

Polytropos uses Python's standard logging system. You can control the log level using the POLYTROPOS_LOGGING environment variable:

# Set log level to DEBUG
export POLYTROPOS_LOGGING=DEBUG

# Set log level to INFO
export POLYTROPOS_LOGGING=INFO

# Set log level to WARNING (default)
export POLYTROPOS_LOGGING=WARNING

This is useful for debugging conditional code execution and version checks at build time.

At run time, you can also set its value using standard Odoo logging handlers:

odoo server --log-handler polytropos:INFO

Version-conditional method overrides

When a method's behavior changes between Odoo releases, you can implement both variants in a single class using @ph.override and .when():

from odoo import models
from polytropos import ph


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

    @ph.override
    def write(self, vals):
        """Normalize email on write (Odoo 19+)."""
        if "email" in vals:
            vals["email"] = vals["email"].strip().lower()
        return super().write(vals)

    @write.when("<19")
    def write(self, vals):
        """Also normalize name in older releases."""
        if "name" in vals:
            vals["name"] = vals["name"].strip()
        return self.write.default(self, vals)

At class creation time, the current Odoo release is checked:

  • If the current release is Odoo 19+, the default implementation is kept.
  • If the current release is Odoo 18 or older, the variant is kept.
  • The non-matching variant is discarded — it does not pollute the class.

How it works

@ph.override wraps the default method in an OverrideResolver descriptor. Each .when(spec) call registers a version-conditional variant. When the class is created, __set_name__ evaluates the Odoo release against each specifier in declaration order (first match wins) and replaces the descriptor with the resolved function.

The resolved function has the correct __name__ and __qualname__, so super(), method resolution order (MRO), and Odoo's metaclass all work naturally.

Unlimited variants

You can define as many variants as needed:

class MyModel(models.Model):
    @ph.override
    def compute_price(self, product):
        return product.list_price  # default for >=19

    @compute_price.when(">=18,<19")
    def compute_price(self, product):
        return product.list_price * 1.21  # VAT included in 18

    @compute_price.when("<18")
    def compute_price(self, product):
        return product.price  # different field in 17

Variants are evaluated in declaration order; the first matching specifier wins. If no variant matches, the default implementation is used.

Inheritance

Subclasses inherit the resolved method naturally via MRO. A subclass can define its own @ph.override + variants, which resolve independently without affecting the parent.

Calling the default from a variant

When a variant only needs to add a pre/post-processing step for a specific release while keeping the core logic from the default, call the default via self.method.default(self, ...):

@write.when("<19")
def write(self, vals):
    if "name" in vals:
        vals["name"] = vals["name"].strip()
    return self.write.default(self, vals)

The resolved function (whether default or variant) always exposes a .default attribute pointing to the original default implementation. It is an unbound function, so you must pass self explicitly.

You can also use .default for introspection or testing:

partner = self.env["res.partner"].browse(42)
# The method that's actually used for this release
partner.write({"email": "  Foo@Bar.COM  "})
# The original default implementation (unbound)
partner.write.default(partner, {"email": "  Foo@Bar.COM  "})

Infinite recursion risk

The default's own .default points to itself. Calling self.method.default(self, ...) from within the default implementation causes infinite recursion. Only call .default from within a .when() variant.

When .default does NOT work

Delegating to the default only works when the method signature is the same across releases. If the parent method's signature changed (e.g. name_search's second parameter changed from args to domain), the default's super() call would fail on older releases. You have two options:

Option A — Helper method

Extract shared logic to a helper method, each variant calls super() independently:

from odoo import models
from polytropos import ph


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

    @ph.override
    def name_search(self, name, domain=None, operator="ilike", limit=100):
        domain = list(domain or [])
        self._name_search_domain(domain)
        return super().name_search(name, domain=domain, operator=operator, limit=limit)

    @name_search.when("<19")
    def name_search(self, name, args=None, operator="ilike", limit=100):
        args = list(args or [])
        self._name_search_domain(args)
        return super().name_search(name, args=args, operator=operator, limit=limit)

    def _name_search_domain(self, domain):
        """Add extra domain conditions for name_search."""
        domain.append(("loves_polytropos", "=", True))

Option B — Dynamic kwargs

The default uses ph.rel() to build the correct keyword argument for super() dynamically. This allows the variant to still delegate to the default:

from odoo import models
from polytropos import ph


class ResCompany(models.Model):
    _inherit = "res.company"

    @ph.override
    def name_search(self, name, domain=None, operator="ilike", limit=100):
        domain = list(domain or [])
        domain.append(("active", "=", True))
        super_kwargs = {"args" if ph.rel("<19") else "domain": domain}
        return super().name_search(name, operator=operator, limit=limit, **super_kwargs)

    @name_search.when("<19")
    def name_search(self, name, args=None, operator="ilike", limit=100):
        return self.name_search.default(
            self, name, domain=args, operator=operator, limit=limit
        )

Rule of thumb: use .default when the difference is a pre/post-processing step or a decorator. When the parent's signature changed, use a helper method or dynamic kwargs.

What if Odoo is not installed?

If odoo.release.series is not available (e.g. in a standalone script), the default implementation is always used.

See Also