Skip to content

OD223 compute-leaves-field-unassigned

A compute method leaves a non-stored readonly field unassigned on some records.

Category Severity Series Needs the index
Correctness Error saas~13.3 and later yes

What it reports

A compute method with a path through its loop over self that ends an iteration without assigning a field it computes, or that leaves the loop before the last record, when Odoo neither stores the field nor lets users write it. Odoo merges every definition of the field in the module and its depends, so the merged one decides: store is absent or false, readonly is true, or absent with no inverse, and the field is not related. A module that Odoo always installs with base, such as web, counts as in depends. An attribute that definitions set differently, or that **kwargs may set, leaves the field unchecked. The finding is at the method's name, and names each such field.

The method must be in the class that defines the field and hold every assignment of the field in its one for record in self: loop, each on that record. A method that calls super(), reads the field, may assign it by a name built at runtime (setattr, a key that is not a literal, or update() or write() given something other than a dict of literal keys), or calls a method of self or passes self on outside the loop is not checked, nor is a method that a class of the same model in the same module overrides by a method that calls it through super(), in any file, since the override may assign the rest; an override in another module does not count. Through the loop body:

  • an assignment, update() or write() of the field, a raise, and a call of a method of the record or of self, or a call given the record, which may assign the field in a helper, count as assigning it;
  • a continue or break before that is reported, and so is an if without else whose test is false, but a chain with an elif and no else, like a match, is taken to cover every case;
  • an if with no elif assigns the field when its test holds if its body assigns it, or when the test does not hold if its lone else does, and a for loop whose body assigns it does when its iterable is not empty; two such statements of the same block cover every path only when one assigns exactly when the other does not: if x: and if not x:, if x: and another if x: that assigns in its else, for line in lines: and if not lines:, or one comparison of the same operands under opposite operators, such as == and !=, with the operands of ==, !=, is and is not in either order; two if statements that assign under the same test are reported, and so are == "a" and == "b", and two loops that each assign the field;
  • such a statement in the body of a with pairs with a sibling statement of the enclosing block, unless the with runs under contextlib.suppress, and so does one in the body or else of a try when every handler assigns the field or raises, since an exception may skip the assignment; in the body of an if x:, a statement that assigns when x holds, such as for line in x:, covers every path, and so in a lone else does one that assigns when x does not;
  • a continue of a nested loop before the assignment means that loop may assign nothing, so after for line in lines: if not line.x: continue then record.f = line.x, that loop does not pair with if not lines:;
  • a return in the loop body is reported, before or after the assignment, since the records after it stay unassigned, and so is a break after the assignment that leaves the loop over self; neither is when it is in a nested function, or follows, as a statement of its block or of a block around it, a call of a method of self, which may assign the field on every record, a raise, or a statement that makes one of them on every path: a with whose body does, unless it runs under contextlib.suppress, an if with an elif or else or a match each of whose branches does, a while True: loop whose body does before every break of it, or a try whose body or else does and so does each handler, or whose finally does; a return or break in a finally is still reported when a continue or raise after the assignment skips that call;
  • a try assigns when its body and else do and so does each handler, or when its finally does, and a return in any of them, or a break that leaves the loop over self, is reported, even before a finally that assigns, unless that finally calls a method of self; a handler finds the field assigned when the try body, after nothing but pass and constants bound to names, first assigns the field alone (record.f = ..., not chained or unpacked) False or None, another falsy constant unless it is a One2many or Many2many field, or a number, negative too, to an Integer, Float, Monetary or Many2one field, since Odoo refuses any other value of some type, such as a Selection value outside its list;
  • a nested loop may run no iteration, so its else counts, and only when every break in its body assigns first; a while True: loop counts when its body assigns before every break, and a continue in it only starts the next iteration, so the paths to its break decide; when a break may come before the first statement that calls a method of self or raises, on every path, a body that may continue still counts as assigning but not as that call, so a later return or break after an assignment is reported.

Why it matters

When code reads a computed field, Odoo calls its method on the records to compute, then looks for those it left without a value. Odoo raises ValueError: Compute method failed to assign for a field it neither stores nor lets users write, so the form, list or report that reads the field fails for the records the method skipped, often only for some data. Other computed fields get False silently. Assign the field on every path of the loop, with False when there is no value.

Odoo raises that error from saas~13.3: it came to saas~13.5 and 14.0 with 126ebbc905af, and was backported to saas~13.3 with a555b71261f6 and saas~13.4 with ec12ff214ddf. From 13.0 to saas~13.2, a field the method leaves unassigned raises CacheMiss, a KeyError, when Odoo does not store it or the record is new, whether users can write it or not. 12.0 sets every field the method computes to False before calling it, so nothing raises.

The rule does not run for a module when the module or one of its dependencies depends on a module that is not indexed. A model is skipped when the class that introduces it, with _name or as a class Odoo names, is in a module outside the dependencies, or when a class of it, or of a model it inherits or delegates to, has a plain Python base class or an _inherit element that is not a literal, a parent whose fields the scan cannot see.

Example

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

    initials = fields.Char(compute="_compute_initials")

    @api.depends("name")
    def _compute_initials(self):
        for partner in self:
            if partner.name:
                partner.initials = "".join(word[0] for word in partner.name.split())

A partner without a name, such as a new address in a form, makes reading initials raise.

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

    initials = fields.Char(compute="_compute_initials")

    @api.depends("name")
    def _compute_initials(self):
        for partner in self:
            if partner.name:
                partner.initials = "".join(word[0] for word in partner.name.split())
            else:
                partner.initials = False

Suppressing

def _compute_initials(self):  # odooscan: ignore[OD223]

See suppressing a finding.