Skip to content

Commit 768b28a

Browse files
committed
[FIX] core: prevent modification of field attributes
Certain attributes of fields must only be used within the ORM and should not be used outside it (which could lead to unexpected behaviour). task-4551158
1 parent 8fe9304 commit 768b28a

File tree

4 files changed

+53
-49
lines changed

4 files changed

+53
-49
lines changed

odoo/addons/base/models/ir_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ def _check_manual_name(self, name):
496496

497497

498498
# retrieve field types defined by the framework only (not extensions)
499-
FIELD_TYPES = [(key, key) for key in sorted(fields.Field.by_type)]
499+
FIELD_TYPES = [(key, key) for key in sorted(fields.Field._by_type__)]
500500

501501

502502
class IrModelFields(models.Model):
@@ -2413,7 +2413,7 @@ def _module_data_uninstall(self, modules_to_remove):
24132413
else:
24142414
# the field is shared across registries; don't modify it
24152415
Field = type(field)
2416-
field_ = Field(_base_fields=[field, Field(prefetch=False)])
2416+
field_ = Field(_base_fields__=(field, Field(prefetch=False)))
24172417
add_field(self.env[ir_field.model], ir_field.name, field_)
24182418
field_.setup(model)
24192419
has_shared_field = True

odoo/orm/fields.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from odoo.exceptions import AccessError, MissingError
1717
from odoo.tools import Query, SQL, sql
1818
from odoo.tools.constants import PREFETCH_MAX
19-
from odoo.tools.misc import SENTINEL, OrderedSet, Sentinel, unique
19+
from odoo.tools.misc import SENTINEL, OrderedSet, ReadonlyDict, Sentinel, unique
2020

2121
from .domains import NEGATIVE_CONDITION_OPERATORS, Domain
2222
from .utils import COLLECTION_TYPES, SQL_OPERATORS, SUPERUSER_ID, expand_ids
@@ -253,13 +253,13 @@ def _read_group_many2one_field(self, records, domain):
253253
# Company-dependent fields are stored as jsonb (see column_type).
254254
_column_type: tuple[str, str] | None = None
255255

256-
args: dict[str, typing.Any] | None = None # the parameters given to __init__()
256+
_args__: dict[str, typing.Any] | None = None # the parameters given to __init__()
257257
_module: str | None = None # the field's module name
258258
_modules: tuple[str, ...] = () # modules that define this field
259259
_setup_done = True # whether the field is completely set up
260260
_sequence: int # absolute ordering of the field
261-
_base_fields: tuple[Self, ...] = () # the fields defining self, in override order
262-
_extra_keys: tuple[str, ...] = () # unknown attributes set on the field
261+
_base_fields__: tuple[Self, ...] = () # the fields defining self, in override order
262+
_extra_keys__: tuple[str, ...] = () # unknown attributes set on the field
263263
_direct: bool = False # whether self may be used directly (shared)
264264
_toplevel: bool = False # whether self is on the model's registry class
265265

@@ -304,12 +304,12 @@ def _read_group_many2one_field(self, records, domain):
304304
exportable: bool = True
305305

306306
# mapping from type name to field type
307-
by_type: dict[str, Field] = {}
307+
_by_type__: dict[str, Field] = {}
308308

309309
def __init__(self, string: str | Sentinel = SENTINEL, **kwargs):
310310
kwargs['string'] = string
311311
self._sequence = next(_global_seq)
312-
self.args = {key: val for key, val in kwargs.items() if val is not SENTINEL}
312+
self._args__ = ReadonlyDict({key: val for key, val in kwargs.items() if val is not SENTINEL})
313313

314314
def __str__(self):
315315
if not self.name:
@@ -327,7 +327,7 @@ def __init_subclass__(cls):
327327
return
328328

329329
if cls.type:
330-
cls.by_type.setdefault(cls.type, cls)
330+
cls._by_type__.setdefault(cls.type, cls)
331331

332332
# compute class attributes to avoid calling dir() on fields
333333
cls.related_attrs = []
@@ -337,6 +337,8 @@ def __init_subclass__(cls):
337337
cls.related_attrs.append((attr[9:], attr))
338338
elif attr.startswith('_description_'):
339339
cls.description_attrs.append((attr[13:], attr))
340+
cls.related_attrs = tuple(cls.related_attrs)
341+
cls.description_attrs = tuple(cls.description_attrs)
340342

341343
############################################################################
342344
#
@@ -345,33 +347,33 @@ def __init_subclass__(cls):
345347
# The base field setup is done by field.__set_name__(), which determines the
346348
# field's name, model name, module and its parameters.
347349
#
348-
# The dictionary field.args gives the parameters passed to the field's
350+
# The dictionary field._args__ gives the parameters passed to the field's
349351
# constructor. Most parameters have an attribute of the same name on the
350352
# field. The parameters as attributes are assigned by the field setup.
351353
#
352354
# When several definition classes of the same model redefine a given field,
353355
# the field occurrences are "merged" into one new field instantiated at
354356
# runtime on the registry class of the model. The occurrences of the field
355-
# are given to the new field as the parameter '_base_fields'; it is a list
357+
# are given to the new field as the parameter '_base_fields__'; it is a list
356358
# of fields in override order (or reverse MRO).
357359
#
358-
# In order to save memory, a field should avoid having field.args and/or
360+
# In order to save memory, a field should avoid having field._args__ and/or
359361
# many attributes when possible. We call "direct" a field that can be set
360362
# up directly from its definition class. Direct fields are non-related
361363
# fields defined on models, and can be shared across registries. We call
362364
# "toplevel" a field that is put on the model's registry class, and is
363365
# therefore specific to the registry.
364366
#
365367
# Toplevel field are set up once, and are no longer set up from scratch
366-
# after that. Those fields can save memory by discarding field.args and
367-
# field._base_fields once set up, because those are no longer necessary.
368+
# after that. Those fields can save memory by discarding field._args__ and
369+
# field._base_fields__ once set up, because those are no longer necessary.
368370
#
369371
# Non-toplevel non-direct fields are the fields on definition classes that
370372
# may not be shared. In other words, those fields are never used directly,
371373
# and are always recreated as toplevel fields. On those fields, the base
372-
# setup is useless, because only field.args is used for setting up other
374+
# setup is useless, because only field._args__ is used for setting up other
373375
# fields. We therefore skip the base setup for those fields. The only
374-
# attributes of those fields are: '_sequence', 'args', 'model_name', 'name'
376+
# attributes of those fields are: '_sequence', '_args__', 'model_name', 'name'
375377
# and '_module', which makes their __dict__'s size minimal.
376378

377379
def __set_name__(self, owner: type[BaseModel], name: str) -> None:
@@ -391,14 +393,14 @@ def __set_name__(self, owner: type[BaseModel], name: str) -> None:
391393
self._module = owner._module
392394
owner._field_definitions.append(self)
393395

394-
if not self.args.get('related'):
396+
if not self._args__.get('related'):
395397
self._direct = True
396398
if self._direct or self._toplevel:
397399
self._setup_attrs__(owner, name)
398400
if self._toplevel:
399-
# free memory, self.args and self._base_fields are no longer useful
400-
self.__dict__.pop('args', None)
401-
self.__dict__.pop('_base_fields', None)
401+
# free memory, self._args__ and self._base_fields__ are no longer useful
402+
self.__dict__.pop('_args__', None)
403+
self.__dict__.pop('_base_fields__', None)
402404

403405
#
404406
# Setup field parameter attributes
@@ -409,21 +411,20 @@ def _get_attrs(self, model_class: type[BaseModel], name: str) -> dict[str, typin
409411
# determine all inherited field attributes
410412
attrs = {}
411413
modules: list[str] = []
412-
for field in self.args.get('_base_fields', ()):
414+
for field in self._args__.get('_base_fields__', ()):
413415
if not isinstance(self, type(field)):
414416
# 'self' overrides 'field' and their types are not compatible;
415417
# so we ignore all the parameters collected so far
416418
attrs.clear()
417419
modules.clear()
418420
continue
419-
attrs.update(field.args)
421+
attrs.update(field._args__)
420422
if field._module:
421423
modules.append(field._module)
422-
attrs.update(self.args)
424+
attrs.update(self._args__)
423425
if self._module:
424426
modules.append(self._module)
425427

426-
attrs['args'] = self.args
427428
attrs['model_name'] = model_class._name
428429
attrs['name'] = name
429430
attrs['_module'] = modules[-1] if modules else None
@@ -486,9 +487,9 @@ def _setup_attrs__(self, model_class: type[BaseModel], name: str) -> None:
486487
attrs = self._get_attrs(model_class, name)
487488

488489
# determine parameters that must be validated
489-
extra_keys = [key for key in attrs if not hasattr(self, key)]
490+
extra_keys = tuple(key for key in attrs if not hasattr(self, key))
490491
if extra_keys:
491-
attrs['_extra_keys'] = extra_keys
492+
attrs['_extra_keys__'] = extra_keys
492493

493494
self.__dict__.update(attrs)
494495

@@ -520,7 +521,7 @@ def setup(self, model: BaseModel) -> None:
520521
""" Perform the complete setup of a field. """
521522
if not self._setup_done:
522523
# validate field params
523-
for key in self._extra_keys:
524+
for key in self._extra_keys__:
524525
if not model._valid_field_parameter(self, key):
525526
_logger.warning(
526527
"Field %s: unknown parameter %r, if this is an actual"
@@ -635,7 +636,7 @@ def setup_related(self, model: BaseModel) -> None:
635636
if attr not in self.__dict__ and prop.startswith('_related_'):
636637
setattr(self, attr, getattr(field, prop))
637638

638-
for attr in field._extra_keys:
639+
for attr in field._extra_keys__:
639640
if not hasattr(self, attr) and model._valid_field_parameter(self, attr):
640641
setattr(self, attr, getattr(field, attr))
641642

odoo/orm/fields_selection.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import typing
44
from collections import defaultdict
55

6-
from odoo.tools.misc import SENTINEL, Sentinel, merge_sequences
6+
from odoo.tools.misc import ReadonlyDict, SENTINEL, Sentinel, merge_sequences
77
from odoo.tools.sql import pg_varchar
88

99
from .fields import Field, _logger, determine, resolve_mro
@@ -92,19 +92,19 @@ def _get_attrs(self, model_class, name):
9292

9393
def _setup_attrs__(self, model_class, name):
9494
super()._setup_attrs__(model_class, name)
95-
if not self._base_fields:
95+
if not self._base_fields__:
9696
return
9797

9898
# determine selection (applying 'selection_add' extensions) as a dict
9999
values = None
100100

101-
for field in self._base_fields:
101+
for field in self._base_fields__:
102102
# We cannot use field.selection or field.selection_add here
103103
# because those attributes are overridden by ``_setup_attrs__``.
104-
if 'selection' in field.args:
104+
if 'selection' in field._args__:
105105
if self.related:
106106
_logger.warning("%s: selection attribute will be ignored as the field is related", self)
107-
selection = field.args['selection']
107+
selection = field._args__['selection']
108108
if isinstance(selection, (list, tuple)):
109109
if values is not None and list(values) != [kv[0] for kv in selection]:
110110
_logger.warning("%s: selection=%r overrides existing selection; use selection_add instead", self, selection)
@@ -117,17 +117,17 @@ def _setup_attrs__(self, model_class, name):
117117
else:
118118
raise ValueError(f"{self!r}: selection={selection!r} should be a list, a callable or a method name")
119119

120-
if 'selection_add' in field.args:
120+
if 'selection_add' in field._args__:
121121
if self.related:
122122
_logger.warning("%s: selection_add attribute will be ignored as the field is related", self)
123-
selection_add = field.args['selection_add']
123+
selection_add = field._args__['selection_add']
124124
assert isinstance(selection_add, list), \
125125
"%s: selection_add=%r must be a list" % (self, selection_add)
126126
assert values is not None, \
127127
"%s: selection_add=%r on non-list selection %r" % (self, selection_add, self.selection)
128128

129129
values_add = {kv[0]: (kv[1] if len(kv) > 1 else None) for kv in selection_add}
130-
ondelete = field.args.get('ondelete') or {}
130+
ondelete = field._args__.get('ondelete') or {}
131131
new_values = [key for key in values_add if key not in values]
132132
for key in new_values:
133133
ondelete.setdefault(key, 'set null')
@@ -185,13 +185,13 @@ def _selection_modules(self, model):
185185
module = field._module
186186
if not module:
187187
continue
188-
if 'selection' in field.args:
188+
if 'selection' in field._args__:
189189
value_modules.clear()
190-
if isinstance(field.args['selection'], list):
191-
for value, _label in field.args['selection']:
190+
if isinstance(field._args__['selection'], list):
191+
for value, _label in field._args__['selection']:
192192
value_modules[value].add(module)
193-
if 'selection_add' in field.args:
194-
for value_label in field.args['selection_add']:
193+
if 'selection_add' in field._args__:
194+
for value_label in field._args__['selection_add']:
195195
if len(value_label) > 1:
196196
value_modules[value_label[0]].add(module)
197197
return value_modules

odoo/orm/model_classes.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
frozendict,
1919
sql,
2020
)
21+
from odoo.tools.misc import ReadonlyDict
2122

2223
if typing.TYPE_CHECKING:
2324
from odoo.api import Environment
@@ -182,7 +183,7 @@ def add_to_registry(registry: Registry, model_def: type[BaseModel]) -> type[Base
182183
'_inherit_module': {}, # map parent to introducing module
183184
'_inherit_children': OrderedSet(), # names of children models
184185
'_inherits_children': set(), # names of children models
185-
'_fields': {}, # populated in _setup()
186+
'_fields': ReadonlyDict({}), # populated in _setup()
186187
'_table_objects': frozendict(), # populated in _setup()
187188
})
188189

@@ -359,7 +360,7 @@ def _setup(model: BaseModel):
359360
# avoid clashes with inheritance between different models
360361
for name in model_cls._fields:
361362
discardattr(model_cls, name)
362-
model_cls._fields.clear()
363+
model_cls._fields = ReadonlyDict({})
363364

364365
# collect the definitions of each field (base definition + overrides)
365366
definitions = defaultdict(list)
@@ -375,17 +376,17 @@ def _setup(model: BaseModel):
375376
# field is translated to avoid converting its column to varchar
376377
# and losing data
377378
translate = next((
378-
field.args['translate'] for field in reversed(fields_) if 'translate' in field.args
379+
field._args__['translate'] for field in reversed(fields_) if 'translate' in field._args__
379380
), False)
380381
if not translate:
381382
# patch the field definition by adding an override
382383
_logger.debug("Patching %s.%s with translate=True", model_cls._name, name)
383384
fields_.append(type(fields_[0])(translate=True))
384385
if len(fields_) == 1 and fields_[0]._direct and fields_[0].model_name == model_cls._name:
385-
model_cls._fields[name] = fields_[0]
386+
model_cls._fields = ReadonlyDict({**model_cls._fields, name: fields_[0]})
386387
else:
387388
Field = type(fields_[-1])
388-
add_field(model, name, Field(_base_fields=fields_))
389+
add_field(model, name, Field(_base_fields__=tuple(fields_)))
389390

390391
# 2. add manual fields
391392
if model.pool._init_modules:
@@ -553,7 +554,7 @@ def _add_manual_fields(model: BaseModel):
553554
try:
554555
attrs = IrModelFields._instanciate_attrs(field_data)
555556
if attrs:
556-
field = fields.Field.by_type[field_data['ttype']](**attrs)
557+
field = fields.Field._by_type__[field_data['ttype']](**attrs)
557558
add_field(model, name, field)
558559
except Exception:
559560
_logger.exception("Failed to load field %s.%s: skipped", model._name, field_data['name'])
@@ -584,13 +585,15 @@ def add_field(model: BaseModel, name: str, field: Field):
584585
field._toplevel = True
585586
field.__set_name__(model_cls, name)
586587
# add field as an attribute and in model_cls._fields (for reflection)
587-
model_cls._fields[name] = field
588+
model_cls._fields = ReadonlyDict({**model_cls._fields, name: field})
588589

589590

590591
def pop_field(model: BaseModel, name: str) -> Field | None:
591592
""" Remove the field with the given ``name`` from the model class of ``model``. """
592593
model_cls = model.env.registry[model._name]
593-
field = model_cls._fields.pop(name, None)
594+
fields_dict = dict(model_cls._fields)
595+
field = fields_dict.pop(name, None)
596+
model_cls._fields = ReadonlyDict(fields_dict)
594597
discardattr(model_cls, name)
595598
if model_cls._rec_name == name:
596599
# fixup _rec_name and display_name's dependencies

0 commit comments

Comments
 (0)