# HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1732186976 -3600 # Thu Nov 21 12:02:56 2024 +0100 # Branch 17.0 # Node ID 5253a20806311d7a2f7be075eb5fd3452caf4079 # Parent eb1932fd59fdac405a8bafefd21995c78be45b23 👕 Typing diff --git a/__init__.py b/__init__.py --- a/__init__.py +++ b/__init__.py @@ -43,9 +43,7 @@ from .relation import RelationToMany, RelationToManyMap, RelationToOne, relation from .switch import Switch from .validate import ( - VALIDATION_SKIP, - VALIDATION_SOFT, - VALIDATION_STRICT, + Validation, NotInitialized, Validator, ) diff --git a/__manifest__.py b/__manifest__.py --- a/__manifest__.py +++ b/__manifest__.py @@ -17,7 +17,6 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - { "name": "Converter", "license": "AGPL-3", @@ -26,8 +25,7 @@ "category": "Hidden", "author": "XCG Consulting", "website": "https://orbeet.io/", - "depends": ["base", "mail"], - "data": [], + "depends": ["mail"], "installable": True, "external_dependencies": {"python": ["fastjsonschema"]}, } diff --git a/base.py b/base.py --- a/base.py +++ b/base.py @@ -36,10 +36,11 @@ import inspect import logging +from abc import ABCMeta, abstractmethod from collections.abc import Callable, Mapping from typing import Any -from odoo import api, models +from odoo import api, models # type: ignore[import-untyped] from .exception import InternalError from .validate import Validator @@ -60,7 +61,8 @@ Newinstance = NewinstanceType() -ContextBuilder = Callable[[models.Model, Mapping | None], Mapping | None] +Context = Mapping | None +ContextBuilder = Callable[[models.BaseModel, Context], Context] PHASE_PRECREATE = "precreate" PHASE_POSTCREATE = "postcreate" @@ -71,10 +73,10 @@ def build_context( - instance: models.Model | None, - ctx: Mapping | None, + instance: models.BaseModel | None, + ctx: Context, extend: ContextBuilder | None, -) -> dict | None: +) -> Context: if instance is None: return ctx if extend: @@ -82,7 +84,9 @@ ctx = {} else: ctx = dict(ctx) - ctx.update(extend(instance)) + extended = extend(instance, None) + if extended is not None: + ctx.update(extended) return ctx @@ -91,9 +95,7 @@ It does not actually convert anything. """ - def odoo_to_message( - self, instance: models.Model, ctx: Mapping | None = None - ) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: """From an instance, this method returns a matching value for the message field. :param instance: an instance of an Odoo model @@ -107,7 +109,7 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, ) -> dict: """From a message, returns a dict. @@ -123,10 +125,17 @@ """ return {} - @classmethod - def is_instance_getter(cls) -> bool: + @property + def is_instance_getter(self) -> bool: return False + # XXX should that be moved to a different class, like PostHookConverter + def get_instance( + self, odoo_env: api.Environment, message_data + ) -> models.BaseModel | NewinstanceType | None: + """Return an instance of a model. Check is_instance_getter before calling""" + raise Exception("Not an instance getter") + def get__type__(self) -> set[str]: """Indicate if this converter is associated to several __type__. If so, it will be called with incoming messages associated to them. @@ -136,10 +145,16 @@ @property def validator(self) -> Validator | None: """A validator to use for validation of created messages""" - return self._validator + return self._get_validator() @validator.setter def validator(self, value: Validator | None) -> None: + self._set_validator(value) + + def _get_validator(self) -> Validator | None: + return self._validator + + def _set_validator(self, value: Validator | None) -> None: if value is None: self._validator = None else: @@ -152,26 +167,38 @@ @property def validation(self) -> str: - return self._validation + return self._get_validation() @validation.setter def validation(self, value: str) -> None: + self._set_validation(value) + + def _get_validation(self) -> str: + return self._validation + + def _set_validation(self, value: str) -> None: """Define if validation should be done""" assert value is not None self._validation = value +class PostHookConverter(Converter, metaclass=ABCMeta): + @abstractmethod + def post_hook(self, instance: models.BaseModel, message_data): + """Post hook""" + + class Readonly(Converter): def __init__(self, conv): super().__init__() self.conv = conv - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: return self.conv.odoo_to_message(instance, ctx) class Computed(Converter): - def __init__(self, from_odoo: Callable[[models.Model, Mapping | None], Any]): + def __init__(self, from_odoo: Callable[[models.BaseModel, Context], Any]): self.from_odoo = from_odoo sig = inspect.signature(from_odoo) @@ -182,11 +209,9 @@ f"{self.from_odoo_arg_count}" ) - def odoo_to_message( - self, instance: models.Model, ctx: Mapping | None = None - ) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: if self.from_odoo_arg_count == 1: - return self.from_odoo(instance) + return self.from_odoo(instance, None) return self.from_odoo(instance, ctx) @@ -194,9 +219,7 @@ def __init__(self, value): self.value = value - def odoo_to_message( - self, instance: models.Model, ctx: Mapping | None = None - ) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: return self.value @@ -206,7 +229,7 @@ model_name: str, converter: Converter, operation: str | None = None, -) -> models.Model: +) -> models.BaseModel: """ :param odoo_env: an Odoo environment @@ -218,6 +241,7 @@ :py:meth:odoo.addons.Converter.get_instance :return: """ + instance: NewinstanceType | models.BaseModel if operation == OPERATION_CREATION: instance = Newinstance else: diff --git a/field.py b/field.py --- a/field.py +++ b/field.py @@ -20,12 +20,12 @@ import datetime from collections.abc import Callable -from typing import Any +from typing import Any, Literal import pytz -from odoo import api, models +from odoo import api, models # type: ignore[import-untyped] -from .base import PHASE_POSTCREATE, Converter, Newinstance, Skip +from .base import PHASE_POSTCREATE, Context, Converter, Newinstance, Skip class Field(Converter): @@ -61,7 +61,7 @@ self.default = default self.send_empty = send_empty or required_blank_value is not None self.required_blank_value = required_blank_value - self._blank_value = None + self._blank_value: Literal[False, "", 0] | list | float | None = None self._message_formatter = message_formatter self._odoo_formatter = odoo_formatter @@ -87,7 +87,7 @@ return self._blank_value - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: value = False # could be empty due to chaining converter on a many2one without value # for example @@ -120,7 +120,7 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, ) -> dict: if phase == PHASE_POSTCREATE: @@ -161,7 +161,7 @@ super().__init__(field_name, default, send_empty, required_blank_value) self._language = language - def _lazy_dicts(self, instance: models.Model): + def _lazy_dicts(self, instance: models.BaseModel): if not hasattr(self, "_lazy_dict_odoo_to_message"): description_selection = ( instance.with_context(lang=self._language) @@ -173,7 +173,7 @@ (b, a) for a, b in description_selection ) - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: value = super().odoo_to_message(instance, ctx) if value: self._lazy_dicts(instance) @@ -185,7 +185,7 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, ) -> dict: message = super().message_to_odoo( diff --git a/keyfield.py b/keyfield.py --- a/keyfield.py +++ b/keyfield.py @@ -20,9 +20,9 @@ from typing import Any -from odoo import models +from odoo import api, models # type: ignore[import-untyped] -from .base import Converter +from .base import Context, Converter, NewinstanceType class KeyField(Converter): @@ -39,10 +39,12 @@ self.model_name = model_name self.lookup_limit = 1 if limit_lookup else None - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: return getattr(instance, self.field_name) - def get_instance(self, odoo_env, message_data): + def get_instance( + self, odoo_env: api.Environment, message_data + ) -> models.BaseModel | NewinstanceType | None: instance = odoo_env[self.model_name].search( [(self.field_name, "=", message_data)], limit=self.lookup_limit ) @@ -50,6 +52,6 @@ instance.ensure_one() return instance - @classmethod - def is_instance_getter(cls) -> bool: + @property + def is_instance_getter(self) -> bool: return True diff --git a/list.py b/list.py --- a/list.py +++ b/list.py @@ -20,9 +20,9 @@ from typing import Any -from odoo import models +from odoo import models # type: ignore[import-untyped] -from .base import ContextBuilder, Converter, Skip, build_context +from .base import Context, ContextBuilder, Converter, Skip, build_context class List(Converter): @@ -37,7 +37,7 @@ self._converters = converters self.context = context - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) message_data = [] @@ -54,7 +54,7 @@ odoo_env, phase: str, message_value, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, ) -> dict: return {} diff --git a/mail_template.py b/mail_template.py --- a/mail_template.py +++ b/mail_template.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -17,16 +17,15 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - import ast from typing import Any -from odoo import models +from odoo import models # type: ignore[import-untyped] -from . import base +from .base import Context, Converter -class MailTemplate(base.Converter): +class MailTemplate(Converter): """This converter wraps ``mail.template::_render_template``. Multiple records are allowed but ``mail.template::_render_template`` still runs once per record; to accomodate, we provide ``ctx["records"]``. @@ -36,7 +35,7 @@ self.template = template self.post_eval = post_eval - def odoo_to_message(self, records: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, records: models.BaseModel, ctx: Context = None) -> Any: value = ( records.env["mail.template"] .with_context(records=records, safe=True) diff --git a/model.py b/model.py --- a/model.py +++ b/model.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -17,67 +17,69 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - import logging import traceback from collections import OrderedDict from collections.abc import Iterable, Mapping from typing import Any -from odoo import _, api, models -from odoo.exceptions import UserError +import fastjsonschema # type: ignore[import-untyped] +from odoo import _, api, models # type: ignore[import-untyped] +from odoo.exceptions import UserError # type: ignore[import-untyped] from .base import ( + Context, ContextBuilder, Converter, Newinstance, NewinstanceType, + PostHookConverter, Skip, build_context, ) -from .validate import VALIDATION_SKIP, VALIDATION_STRICT, Validator +from .validate import NotInitialized, Validation, Validator _logger = logging.getLogger(__name__) -class Model(Converter): +class Model(PostHookConverter): """A converter that takes a dict of key, used when a message has values""" def __init__( self, - __type__: str, + __type__: str | None, converters: Mapping[str, Converter], json_schema: str | None = None, # The validator is usually not given at this point but is common # throughout a project. That’s why it is a property validator: Validator | None = None, merge_with: Iterable[Converter] | None = None, - validation: str = VALIDATION_SKIP, + validation: Validation = Validation.SKIP, context: ContextBuilder | None = None, ): super().__init__() - self._type: str = __type__ + self._type: str | None = __type__ self._converters: Mapping[str, Converter] = converters - self._post_hooks_converters_key: list[str] = [] + self._post_hooks_converters: dict[str, PostHookConverter] = {} self._jsonschema: str | None = json_schema - self._get_instance: Converter = None + self._get_instance: str | None = None """First converter whose `is_instance_getter` is true if any""" self.merge_with: Iterable[Converter] | None = merge_with self.context: ContextBuilder | None = context - self.validator: Validator | None = validator + self.validator = validator self.validation = validation for key, converter in converters.items(): - if self._get_instance is None and converter.is_instance_getter(): + if self._get_instance is None and converter.is_instance_getter: self._get_instance = key - if hasattr(converter, "post_hook"): - self._post_hooks_converters_key.append(key) + if isinstance(converter, PostHookConverter): + self._post_hooks_converters[key] = converter - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) message_data = {} - if self._type: + if self._type is not None: message_data["__type__"] = self._type errors = [] @@ -109,13 +111,16 @@ continue message_data.update(value) - if self.validation != VALIDATION_SKIP and self._jsonschema is not None: - try: - self.validator.validate(self._jsonschema, message_data) - except Exception as exception: - _logger.warning("Validation failed", exc_info=1) - if self.validation == VALIDATION_STRICT: - raise exception + if self.validation != Validation.SKIP and self._jsonschema is not None: + if self.validator: + try: + self.validator.validate(self._jsonschema, message_data) + except (NotInitialized, fastjsonschema.JsonSchemaException): + _logger.warning("Validation failed", exc_info=True) + if self.validation == Validation.STRICT: + raise + elif self.validation == Validation.STRICT: + raise Exception("Strict validation without validator") return message_data @@ -124,12 +129,12 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, ) -> dict: values = OrderedDict() - if self._type and message_value["__type__"] != self._type: + if self._type is not None and message_value["__type__"] != self._type: raise Exception( "Expected __type__ {}, found {}".format( self._type, message_value["__type__"] @@ -157,12 +162,13 @@ return values + @property def is_instance_getter(self) -> bool: return self._get_instance is not None def get_instance( self, odoo_env: api.Environment, message_data - ) -> None | models.Model | NewinstanceType: + ) -> models.BaseModel | NewinstanceType | None: """:return: an instance linked to the converter, if any""" if self._get_instance: instance = self._converters[self._get_instance].get_instance( @@ -173,14 +179,14 @@ return instance return None - def post_hook(self, instance: models.Model, message_data): - for key in self._post_hooks_converters_key: + def post_hook(self, instance: models.BaseModel, message_data): + for key in self._post_hooks_converters: if key in message_data: - self._converters[key].post_hook(instance, message_data[key]) + self._post_hooks_converters[key].post_hook(instance, message_data[key]) if self.merge_with: for converter in self.merge_with: if hasattr(converter, "post_hook"): converter.post_hook(instance, message_data) def get__type__(self) -> set[str]: - return {self._type} + return set() if self._type is None else {self._type} diff --git a/models/ir_model_data.py b/models/ir_model_data.py --- a/models/ir_model_data.py +++ b/models/ir_model_data.py @@ -20,13 +20,13 @@ import uuid -from odoo import api, models +from odoo import api, models # type: ignore[import-untyped] # Xrefs are stored within "ir.model.data" with this module name. _XREF_IMD_MODULE = "__converter__" -class IrModelData(models.Model): +class IrModelData(models.BaseModel): """Add xref tools. All done with the super-admin user to bypass security rules. @@ -37,21 +37,21 @@ _inherit = "ir.model.data" @api.model - def generate_name(self): + def generate_name(self) -> str: """Generate an xref for odoo record; - It return a UUID from a string of 32 hex digit + :return: a UUID from a string of 32 hex digit """ return uuid.uuid4().hex @api.model - def object_to_module_and_name(self, record_set, module=_XREF_IMD_MODULE): + def object_to_module_and_name( + self, record_set: models.BaseModel, module: str | None = _XREF_IMD_MODULE + ) -> tuple[str, str]: """Retrieve an xref pointing to the specified Odoo record; create one when missing. :param module: Name of the module to use. None to use any name, if no xmlid exists "" will be used as the module name. - :type module: Optional[str] - :rtype: Tuple[str, str] :return: tuple module and name """ record_set.ensure_one() @@ -76,13 +76,13 @@ return module, name @api.model - def object_to_xmlid(self, record_set, module=_XREF_IMD_MODULE): + def object_to_xmlid( + self, record_set: models.BaseModel, module: str | None = _XREF_IMD_MODULE + ) -> str: """Retrieve an xref pointing to the specified Odoo record; create one when missing. :param module: Name of the module to use. None to use any name, if no xmlid exists "" will be used as the module name. - :type module: Optional[str] - :rtype: xmlid """ return "{0[0]}.{0[1]}".format( self.object_to_module_and_name(record_set, module) @@ -91,10 +91,10 @@ @api.model def set_xmlid( self, - record_set: models.Model, + record_set: models.BaseModel, name: str, module: str = _XREF_IMD_MODULE, - only_when_missing: str = False, + only_when_missing: bool = False, ): """Save an external reference to the specified Odoo record. :param module: Name of the module to use. diff --git a/py.typed b/py.typed new file mode 100644 diff --git a/pyproject.toml b/pyproject.toml --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ [project.optional-dependencies] doc = ["sphinx", "sphinx-odoo-autodoc"] test = [] +typing = ["types-pytz"] [project.urls] repository = "https://orus.io/xcg/odoo-modules/converter" @@ -42,6 +43,7 @@ [tool.hatch.build.targets.wheel] include = [ + "py.typed", "*.csv", "/i18n/", "/static/", @@ -58,24 +60,6 @@ [tool.hatch.version] source = "vcs" -[tool.isort] -section-order = [ - "future", - "standard-library", - "third-party", - "odoo", - "odoo-addons", - "first-party", - "local-folder" -] - -[tool.isort.sections] -"odoo" = ["odoo"] -"odoo-addons" = ["odoo.addons"] - -[tool.ruff.lint.mccabe] -max-complexity = 16 - [tool.ruff] target-version = "py310" @@ -92,3 +76,21 @@ [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401", "I001"] # ignore unused and unsorted imports in __init__.py "__manifest__.py" = ["B018"] # useless expression + +[tool.ruff.lint.mccabe] +max-complexity = 16 + +[tool.isort] +section-order = [ + "future", + "standard-library", + "third-party", + "odoo", + "odoo-addons", + "first-party", + "local-folder" +] + +[tool.isort.sections] +"odoo" = ["odoo"] +"odoo-addons" = ["odoo.addons"] diff --git a/relation.py b/relation.py --- a/relation.py +++ b/relation.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -17,14 +17,20 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - import logging from collections.abc import Callable from typing import Any -from odoo import api, models +from odoo import api, models # type: ignore[import-untyped] -from .base import ContextBuilder, Converter, NewinstanceType, Skip, build_context +from .base import ( + Context, + ContextBuilder, + Converter, + NewinstanceType, + Skip, + build_context, +) from .field import Field _logger = logging.getLogger(__name__) @@ -34,7 +40,7 @@ def __init__( self, field_name: str, - model_name: str, + model_name: str | None, converter: Converter, send_empty: bool = True, context: ContextBuilder | None = None, @@ -45,7 +51,7 @@ self._send_empty = send_empty self.context = context - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) # do not use super, otherwise if empty, will convert that relation_instance = getattr(instance, self.field_name) @@ -61,7 +67,7 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, ) -> dict: if not value_present: @@ -82,8 +88,8 @@ field_name: str, model_name: str | None, converter: Converter, - sortkey: None | Callable[[models.Model], bool] = None, - filtered: None | str | Callable[[models.Model], bool] = None, + sortkey: None | Callable[[models.BaseModel], bool] = None, + filtered: None | str | Callable[[models.BaseModel], bool] = None, context: ContextBuilder | None = None, limit: Any | None = None, ): @@ -98,7 +104,7 @@ self.context = context self.limit = limit - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) value = super().odoo_to_message(instance, ctx) if value is Skip: @@ -121,7 +127,7 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, ) -> dict: # if not present or value is None, do not update the values. @@ -151,7 +157,7 @@ model_name: str | None, key_converter: Converter, value_converter: Converter, - filtered: None | str | Callable[[models.Model], bool] = None, + filtered: None | str | Callable[[models.BaseModel], bool] = None, context: ContextBuilder | None = None, ): """ @@ -164,7 +170,7 @@ self.filtered = filtered self.context = context - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) value = super().odoo_to_message(instance, ctx) if value is Skip: @@ -188,7 +194,7 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, ) -> dict: # if not present or value is None, do not update the values. diff --git a/switch.py b/switch.py --- a/switch.py +++ b/switch.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -17,14 +17,13 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - -from collections.abc import Callable, Mapping +from collections.abc import Callable from typing import Any -from odoo import api, models +from odoo import api, models # type: ignore[import-untyped] -from .base import ContextBuilder, Converter, Skip, SkipType -from .validate import VALIDATION_SKIP, Validator +from .base import Context, ContextBuilder, Converter, NewinstanceType, Skip, SkipType +from .validate import Validation, Validator class Switch(Converter): @@ -53,13 +52,13 @@ self, converters: list[ tuple[ - Callable[[models.Model], bool], + Callable[[models.BaseModel], bool] | None, Callable[[Any], bool], Converter, ] ], validator: Validator | None = None, - validation: str = VALIDATION_SKIP, + validation: str = Validation.SKIP, context: ContextBuilder | None = None, ): """ @@ -74,9 +73,7 @@ self.validator = validator self.validation = validation - def odoo_to_message( - self, instance: models.Model, ctx: Mapping | None = None - ) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: for out_cond, _in_cond, converter in self._converters: if out_cond is None or out_cond(instance): if isinstance(converter, SkipType): @@ -91,7 +88,7 @@ odoo_env: api.Environment, phase: str, message_value: Any, - instance: models.Model, + instance: models.BaseModel, value_present: bool = True, ) -> dict: for _out_cond, in_cond, converter in self._converters: @@ -104,33 +101,35 @@ value_present=value_present, ) - return Skip + return {} + @property def is_instance_getter(self) -> bool: for _out_cond, _in_cond, converter in self._converters: - if converter.is_instance_getter(): + if converter.is_instance_getter: return True return False - def get_instance(self, odoo_env, message_data): + def get_instance( + self, odoo_env: api.Environment, message_data + ) -> models.BaseModel | NewinstanceType | None: for _out_cond, in_cond, converter in self._converters: - if converter.is_instance_getter() and ( + if converter.is_instance_getter and ( in_cond is None or in_cond(message_data) ): return converter.get_instance(odoo_env, message_data) + return super().get_instance(odoo_env, message_data) - @Converter.validator.setter - def validator(self, value: Validator | None) -> None: + def _set_validator(self, value: Validator | None) -> None: # also set validator on any converters in our switch, in case they care - Converter.validator.fset(self, value) + super()._set_validator(value) for _out_cond, _in_cond, converter in self._converters: converter.validator = value - @Converter.validation.setter - def validation(self, value: str) -> None: + def _set_validation(self, value: str) -> None: # also set validation on any converters in our switch - Converter.validation.fset(self, value) + super()._set_validation(value) for _out_cond, _in_cond, converter in self._converters: converter.validation = value diff --git a/tests/test_base.py b/tests/test_base.py --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -17,9 +17,10 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## +from odoo import tests # type: ignore[import-untyped] +from odoo.exceptions import UserError # type: ignore[import-untyped] -from odoo import tests -from odoo.addons.converter import ( +from .. import ( Constant, Field, Model, @@ -27,7 +28,6 @@ message_to_odoo, relation, ) -from odoo.exceptions import UserError class Test(tests.TransactionCase): @@ -43,8 +43,8 @@ self.assertEqual("a", converter.odoo_to_message(self.env["res.users"])) def test_unset_field(self): - # this tests when a relationnal substitution - # is substitued on a record that has not + # this tests when a relational substitution + # is substituted on a record that has not # the relation set self.assertTrue(self.active_user.create_uid) @@ -60,7 +60,7 @@ Field("name"), ) converter = Model( - None, + "res.users", { "user_creator_name": rel, }, diff --git a/tests/test_converters.py b/tests/test_converters.py --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -18,7 +18,7 @@ # ############################################################################## -from odoo import tests +from odoo import tests # type: ignore[import-untyped] from ..base import Skip from ..field import Field diff --git a/tests/test_field.py b/tests/test_field.py --- a/tests/test_field.py +++ b/tests/test_field.py @@ -20,8 +20,9 @@ from typing import Any -from odoo import tests -from odoo.addons.converter import Field, TranslatedSelection +from odoo import tests # type: ignore[import-untyped] + +from .. import Field, TranslatedSelection class Test(tests.TransactionCase): diff --git a/tests/test_ir_model.py b/tests/test_ir_model.py --- a/tests/test_ir_model.py +++ b/tests/test_ir_model.py @@ -18,7 +18,7 @@ # ############################################################################## -from odoo.tests import TransactionCase, tagged +from odoo.tests import TransactionCase, tagged # type: ignore[import-untyped] @tagged("post_install", "-at_install") diff --git a/tests/test_mail_template.py b/tests/test_mail_template.py --- a/tests/test_mail_template.py +++ b/tests/test_mail_template.py @@ -18,8 +18,9 @@ # ############################################################################## -from odoo import tests -from odoo.addons.converter import MailTemplate +from odoo import tests # type: ignore[import-untyped] + +from .. import MailTemplate class Test(tests.TransactionCase): diff --git a/tests/test_relation.py b/tests/test_relation.py --- a/tests/test_relation.py +++ b/tests/test_relation.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2021 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2021, 2024 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -19,8 +19,9 @@ ############################################################################## from typing import Any -from odoo import tests -from odoo.addons.converter import ( +from odoo import tests # type: ignore[import-untyped] + +from .. import ( Field, Model, RelationToMany, @@ -80,7 +81,7 @@ "partner_id", "res.partner", Model( - "", + "partner", { "color": Field("color"), "name": Field("name"), @@ -96,8 +97,9 @@ old_partner = user.partner_id # Run our message reception. - message = { + message: dict[str, Any] = { "partner": { + "__type__": "partner", "color": 2, "name": "TEST", "xref": "new_partner_converter", @@ -106,7 +108,7 @@ } message_to_odoo(self.env, message, self.env["res.users"], converter) - # Ensure a new partner got created and that is has an xref (post hook). + # Ensure a new partner got created and that it has an xref (post hook). new_partner = self.env.ref("test.new_partner_converter") self.assertEqual(user.partner_id, new_partner) self.assertNotEqual(new_partner, old_partner) diff --git a/tests/test_switch.py b/tests/test_switch.py --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -18,7 +18,7 @@ # ############################################################################## -from odoo import tests +from odoo import tests # type: ignore[import-untyped] from .. import Field, Model, Switch diff --git a/validate.py b/validate.py --- a/validate.py +++ b/validate.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -17,16 +17,21 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - import json import os +from enum import Enum +from typing import Any, LiteralString -import fastjsonschema -import odoo.addons +import fastjsonschema # type: ignore[import-untyped] +import odoo.addons # type: ignore[import-untyped] + -VALIDATION_SKIP = "skip" -VALIDATION_SOFT = "soft" -VALIDATION_STRICT = "strict" +class Validation(str, Enum): + """Type of validation""" + + SKIP = "skip" + SOFT = "soft" + STRICT = "strict" class NotInitialized(Exception): @@ -44,11 +49,12 @@ self.repository = repository # exemple "https://annonces-legales.fr/xbus/schemas/v1/{}.schema.json" self.default_url_pattern = default_url_pattern - self.validators = {} + self.validators: dict[LiteralString, Any] = {} self.initialized = False self.encoding = "UTF-8" def initialize(self) -> None: + # TODO Not working if module is installed compressed repo_module_basepath = os.path.dirname( getattr(odoo.addons, self.repository_module_name).__file__ ) @@ -77,7 +83,7 @@ ) self.initialized = True - def validate(self, schema_id, payload) -> None: + def validate(self, schema_id: str, payload) -> None: if not self.initialized: raise NotInitialized("please call the initialize() method") diff --git a/xref.py b/xref.py --- a/xref.py +++ b/xref.py @@ -20,24 +20,26 @@ from typing import Any -from odoo import models +from odoo import models # type: ignore[import-untyped] -from .base import Converter, NewinstanceType +from .base import Context, NewinstanceType, PostHookConverter from .models.ir_model_data import _XREF_IMD_MODULE # TODO dans quel cas ça ne pourrait pas être un instance getter??? -class Xref(Converter): +class Xref(PostHookConverter): """This converter represents an external reference, using the standard xmlid with a custom module name. """ - def __init__(self, module: str = _XREF_IMD_MODULE, is_instance_getter: bool = True): + def __init__( + self, module: str | None = _XREF_IMD_MODULE, is_instance_getter: bool = True + ): super().__init__() self._module = module self._is_instance_getter = is_instance_getter - def odoo_to_message(self, instance: models.Model, ctx: dict | None = None) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: if not instance: return "" return instance.env["ir.model.data"].object_to_xmlid( @@ -46,16 +48,20 @@ def get_instance( self, odoo_env, message_data - ) -> None | models.Model | NewinstanceType: - return odoo_env.ref( - ".".join([self._module, message_data]), raise_if_not_found=False - ) + ) -> None | models.BaseModel | NewinstanceType: + if self._is_instance_getter: + return odoo_env.ref( + ".".join(["" if self._module is None else self._module, message_data]), + raise_if_not_found=False, + ) + return None - def post_hook(self, instance: models.Model, message_data): + def post_hook(self, instance: models.BaseModel, message_data): # add xmlid to the newly created object instance.env["ir.model.data"].set_xmlid( instance, message_data, module=self._module, only_when_missing=True ) + @property def is_instance_getter(self) -> bool: return self._is_instance_getter # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1740039624 -3600 # Thu Feb 20 09:20:24 2025 +0100 # Branch 17.0 # Node ID f26bea3b125a1827049daac3ba262d9286cf852d # Parent 5253a20806311d7a2f7be075eb5fd3452caf4079 📚 changelog diff --git a/NEWS.rst b/NEWS.rst --- a/NEWS.rst +++ b/NEWS.rst @@ -1,6 +1,11 @@ Changelog ========= +17.0.2.0.0 +---------- + +Fixes and changes after making the module typing compliant. + 17.0.1.0.1 ---------- # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1732206592 -3600 # Thu Nov 21 17:29:52 2024 +0100 # Branch 17.0 # Node ID a29e20092d8528e702b6c7131898c35c4f02592e # Parent f26bea3b125a1827049daac3ba262d9286cf852d Remove typing section, ci tells mypy to install it automatically diff --git a/pyproject.toml b/pyproject.toml --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ [project.optional-dependencies] doc = ["sphinx", "sphinx-odoo-autodoc"] test = [] -typing = ["types-pytz"] [project.urls] repository = "https://orus.io/xcg/odoo-modules/converter" # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1732293808 -3600 # Fri Nov 22 17:43:28 2024 +0100 # Branch 17.0 # Node ID 2ef1884b66676ae4e736453c4dc71981031ec4f5 # Parent a29e20092d8528e702b6c7131898c35c4f02592e More typing and default version for hatch diff --git a/models/ir_model_data.py b/models/ir_model_data.py --- a/models/ir_model_data.py +++ b/models/ir_model_data.py @@ -19,11 +19,12 @@ ############################################################################## import uuid +from typing import Final from odoo import api, models # type: ignore[import-untyped] # Xrefs are stored within "ir.model.data" with this module name. -_XREF_IMD_MODULE = "__converter__" +_XREF_IMD_MODULE: Final[str] = "__converter__" class IrModelData(models.BaseModel): diff --git a/pyproject.toml b/pyproject.toml --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ [tool.hatch.version] source = "vcs" +fallback-version = "17.0.0.0.0" [tool.ruff] target-version = "py310" # HG changeset patch # User szeka_wong <szeka.wong@xcg-consulting.fr> # Date 1733317850 -3600 # Wed Dec 04 14:10:50 2024 +0100 # Branch 17.0 # Node ID 4824c98020bebada591f7a44b908546bf47c46cc # Parent 2ef1884b66676ae4e736453c4dc71981031ec4f5 Evolve: Allow to skip update process. diff --git a/NEWS.rst b/NEWS.rst --- a/NEWS.rst +++ b/NEWS.rst @@ -6,6 +6,8 @@ Fixes and changes after making the module typing compliant. +Evolve: Allow to skip update process. + 17.0.1.0.1 ---------- diff --git a/__manifest__.py b/__manifest__.py --- a/__manifest__.py +++ b/__manifest__.py @@ -21,7 +21,7 @@ "name": "Converter", "license": "AGPL-3", "summary": "Convert odoo records to/from plain data structures.", - "version": "17.0.1.0.1", + "version": "17.0.2.0.0", "category": "Hidden", "author": "XCG Consulting", "website": "https://orbeet.io/", diff --git a/base.py b/base.py --- a/base.py +++ b/base.py @@ -111,7 +111,7 @@ message_value: Any, instance: models.BaseModel, value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: """From a message, returns a dict. Only field whose values are changed are included in the returned dict. :param odoo_env: odoo environment @@ -252,6 +252,9 @@ changes = converter.message_to_odoo( odoo_env, PHASE_PRECREATE, payload, instance ) + if isinstance(changes, SkipType): + return odoo_env[model_name] + instance = odoo_env[model_name].create(changes) changes = converter.message_to_odoo( odoo_env, PHASE_POSTCREATE, payload, instance @@ -262,6 +265,9 @@ operation is None and not instance or instance is Newinstance ): changes = converter.message_to_odoo(odoo_env, PHASE_UPDATE, payload, instance) + if isinstance(changes, SkipType): + return odoo_env[model_name] + if changes: instance.write(changes) if hasattr(converter, "post_hook"): # HG changeset patch # User szeka_wong <szeka.wong@xcg-consulting.fr> # Date 1733381191 -3600 # Thu Dec 05 07:46:31 2024 +0100 # Branch 17.0 # Node ID 9b38ff64c6132d9d4cd01c7c834ba8de1f78f535 # Parent 4824c98020bebada591f7a44b908546bf47c46cc mypy diff --git a/model.py b/model.py --- a/model.py +++ b/model.py @@ -35,6 +35,7 @@ NewinstanceType, PostHookConverter, Skip, + SkipType, build_context, ) from .validate import NotInitialized, Validation, Validator @@ -131,8 +132,8 @@ message_value: Any, instance: models.BaseModel, value_present: bool = True, - ) -> dict: - values = OrderedDict() + ) -> dict | SkipType: + values: dict[str, Any] = OrderedDict() if self._type is not None and message_value["__type__"] != self._type: raise Exception( @@ -142,21 +143,22 @@ ) for key in self._converters: value = message_value.get(key, None) if message_value else None - values.update( - self._converters[key].message_to_odoo( - odoo_env, - phase, - value, - instance, - message_value and key in message_value, - ) + attribute_vals = self._converters[key].message_to_odoo( + odoo_env, + phase, + value, + instance, + message_value and key in message_value, ) + if isinstance(attribute_vals, SkipType): + continue + values.update(attribute_vals) if self.merge_with: for conv in self.merge_with: value = conv.message_to_odoo( odoo_env, phase, message_value, instance, value_present ) - if value is Skip: + if isinstance(value, SkipType): continue values.update(value) diff --git a/switch.py b/switch.py --- a/switch.py +++ b/switch.py @@ -90,7 +90,7 @@ message_value: Any, instance: models.BaseModel, value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: for _out_cond, in_cond, converter in self._converters: if in_cond is None or in_cond(message_value): return converter.message_to_odoo( # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1733748764 -3600 # Mon Dec 09 13:52:44 2024 +0100 # Branch 17.0 # Node ID a1c6f43a13bc48068e89173940d1952b14abb70a # Parent 9b38ff64c6132d9d4cd01c7c834ba8de1f78f535 Expose Context, NewinstanceType and build_context at the top level package diff --git a/NEWS.rst b/NEWS.rst --- a/NEWS.rst +++ b/NEWS.rst @@ -8,6 +8,8 @@ Evolve: Allow to skip update process. +Expose Context, NewinstanceType and build_context at the top level package. + 17.0.1.0.1 ---------- diff --git a/__init__.py b/__init__.py --- a/__init__.py +++ b/__init__.py @@ -27,12 +27,15 @@ PHASE_UPDATE, Computed, Constant, + Context, ContextBuilder, Converter, Newinstance, + NewinstanceType, Readonly, Skip, message_to_odoo, + build_context, ) from .exception import InternalError from .field import Field, TranslatedSelection # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1733753226 -3600 # Mon Dec 09 15:07:06 2024 +0100 # Branch 17.0 # Node ID e7ffb4a0ac94ef02be2b401c91e3f5f2ad8b195b # Parent a1c6f43a13bc48068e89173940d1952b14abb70a Remove mail dependency and adapt test and CI to run mail dependant tests anyway diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,3 +1,8 @@ include: - project: xcg/ci-templates file: /odoo/17.0/gitlab-ci.yaml + +variables: + TEST_OPTIONS: --test-tags=odoo_addons_mail + # Also install mail to run mail dependant tets + ODOO_SCRIPTS_MODULE_LIST: mail diff --git a/NEWS.rst b/NEWS.rst --- a/NEWS.rst +++ b/NEWS.rst @@ -10,6 +10,8 @@ Expose Context, NewinstanceType and build_context at the top level package. +Remove mail dependency, to avoid forcing its installation, only needed when using some specific converters. + 17.0.1.0.1 ---------- diff --git a/__manifest__.py b/__manifest__.py --- a/__manifest__.py +++ b/__manifest__.py @@ -25,7 +25,7 @@ "category": "Hidden", "author": "XCG Consulting", "website": "https://orbeet.io/", - "depends": ["mail"], + "depends": ["base"], "installable": True, "external_dependencies": {"python": ["fastjsonschema"]}, } diff --git a/mail_template.py b/mail_template.py --- a/mail_template.py +++ b/mail_template.py @@ -29,6 +29,8 @@ """This converter wraps ``mail.template::_render_template``. Multiple records are allowed but ``mail.template::_render_template`` still runs once per record; to accomodate, we provide ``ctx["records"]``. + + Using this converter requires the mail module to be installed. """ def __init__(self, template: str, post_eval: bool = False): diff --git a/tests/test_mail_template.py b/tests/test_mail_template.py --- a/tests/test_mail_template.py +++ b/tests/test_mail_template.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2023 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2023, 2024 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -20,9 +20,10 @@ from odoo import tests # type: ignore[import-untyped] -from .. import MailTemplate +from ..mail_template import MailTemplate +@tests.tagged("-standard", "odoo_addons_mail") class Test(tests.TransactionCase): """Test converter that wraps ``mail.template::_render_template``.""" # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1734017944 -3600 # Thu Dec 12 16:39:04 2024 +0100 # Branch 17.0 # Node ID 41d22db75a10dc3f3a97c16cac7f9d07b0028623 # Parent e7ffb4a0ac94ef02be2b401c91e3f5f2ad8b195b 🔨✨ validator package does not assume a odoo.addons package name, provide full package name instead diff --git a/NEWS.rst b/NEWS.rst --- a/NEWS.rst +++ b/NEWS.rst @@ -12,6 +12,8 @@ Remove mail dependency, to avoid forcing its installation, only needed when using some specific converters. +Breaking change: validator package does not assume a odoo.addons package name, provide full package name instead. + 17.0.1.0.1 ---------- diff --git a/pyproject.toml b/pyproject.toml --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,8 @@ "*.xml", "*.py", "*.svg", - "*.png" + "*.png", + "*.json" ] [tool.hatch.build.targets.wheel.sources] diff --git a/tests/__init__.py b/tests/__init__.py --- a/tests/__init__.py +++ b/tests/__init__.py @@ -6,4 +6,5 @@ test_mail_template, test_relation, test_switch, + test_validate, ) diff --git a/tests/schemas/__init__.py b/tests/schemas/__init__.py new file mode 100644 --- /dev/null +++ b/tests/schemas/__init__.py @@ -0,0 +1,10 @@ +import json +import pkgutil +from typing import Optional, Any + + +def get_schemas() -> list[Any]: + for file_prefix in ("product",): + data: bytes | None = pkgutil.get_data(__name__, f"{file_prefix}.schema.json") + if data: + yield json.loads(data) diff --git a/tests/schemas/product.schema.json b/tests/schemas/product.schema.json new file mode 100644 --- /dev/null +++ b/tests/schemas/product.schema.json @@ -0,0 +1,71 @@ +{ + + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "$id": "https://example.com/product.schema.json", + + "title": "Product", + + "description": "A product from Acme's catalog", + + "type": "object", + + "properties": { + + "productId": { + + "description": "The unique identifier for a product", + + "type": "integer" + + }, + + "productName": { + + "description": "Name of the product", + + "type": "string" + + }, + + "price": { + + "description": "The price of the product", + + "type": "number", + + "exclusiveMinimum": 0 + + }, + + "tags": { + + "description": "Tags for the product", + + "type": "array", + + "items": { + + "type": "string" + + }, + + "minItems": 1, + + "uniqueItems": true + + } + + }, + + "required": [ + + "productId", + + "productName", + + "price" + + ] + +} diff --git a/tests/schemas_dir/product.schema.json b/tests/schemas_dir/product.schema.json new file mode 100644 --- /dev/null +++ b/tests/schemas_dir/product.schema.json @@ -0,0 +1,71 @@ +{ + + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "$id": "https://example.com/product.schema.json", + + "title": "Product", + + "description": "A product from Acme's catalog", + + "type": "object", + + "properties": { + + "productId": { + + "description": "The unique identifier for a product", + + "type": "integer" + + }, + + "productName": { + + "description": "Name of the product", + + "type": "string" + + }, + + "price": { + + "description": "The price of the product", + + "type": "number", + + "exclusiveMinimum": 0 + + }, + + "tags": { + + "description": "Tags for the product", + + "type": "array", + + "items": { + + "type": "string" + + }, + + "minItems": 1, + + "uniqueItems": true + + } + + }, + + "required": [ + + "productId", + + "productName", + + "price" + + ] + +} diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,64 @@ +############################################################################## +# +# Converter Odoo module +# Copyright © 2024 XCG Consulting <https://xcg-consulting.fr> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +############################################################################## +import json + +from odoo import tests # type: ignore[import-untyped] + +from ..validate import Validator + + +class TestValidate(tests.TransactionCase): + def test_validate(self): + validator = Validator( + "odoo.addons.converter.tests.schemas", "https://example.com/{}.schema.json" + ) + validator.initialize() + validator.validate( + "product", + json.loads("""{ + "productId": 1, + "productName": "An ice sculpture", + "price": 12.5, + "tags": [ + "cold", + "ice" + ] +}"""), + ) + + def test_validate_dir(self): + validator = Validator( + "odoo.addons.converter.tests", + "https://example.com/{}.schema.json", + "schemas_dir", + ) + validator.initialize() + validator.validate( + "product", + json.loads("""{ + "productId": 1, + "productName": "An ice sculpture", + "price": 12.5, + "tags": [ + "cold", + "ice" + ] +}"""), + ) diff --git a/validate.py b/validate.py --- a/validate.py +++ b/validate.py @@ -18,12 +18,15 @@ # ############################################################################## import json +import logging import os +from collections.abc import Callable from enum import Enum +from importlib import import_module from typing import Any, LiteralString import fastjsonschema # type: ignore[import-untyped] -import odoo.addons # type: ignore[import-untyped] +_logger = logging.getLogger(__name__) class Validation(str, Enum): @@ -38,40 +41,57 @@ pass +def _add_schema(schemas, schema): + if "$id" in schema: + schemas[schema["$id"]] = schema + else: + _logger.warning("Schema without $id (schema ignored)") + + class Validator: def __init__( self, - repository_module_name: str, - repository: str, + package_name: str, default_url_pattern: str, + directory: str | None = None, ): - self.repository_module_name = repository_module_name - self.repository = repository + """ + :param package_name: Package where the schema can be found + :param default_url_pattern: pattern for url ({} will be replaced by $id) + :param directory: directory to search for json, not used if a get_schema is + provided in the package. + """ + self.package_name = package_name # exemple "https://annonces-legales.fr/xbus/schemas/v1/{}.schema.json" self.default_url_pattern = default_url_pattern - self.validators: dict[LiteralString, Any] = {} + self.validators: dict[LiteralString, Callable] = {} self.initialized = False self.encoding = "UTF-8" + self.directory = directory def initialize(self) -> None: - # TODO Not working if module is installed compressed - repo_module_basepath = os.path.dirname( - getattr(odoo.addons, self.repository_module_name).__file__ - ) - - # Read local schema definitions. - schemas = {} - schema_search_path = os.path.abspath( - os.path.join(repo_module_basepath, self.repository) - ) - for root, _dirs, files in os.walk(schema_search_path): - for fname in files: - fpath = os.path.join(root, fname) - if fpath.endswith((".json",)): - with open(fpath, encoding=self.encoding) as schema_fd: - schema = json.load(schema_fd) - if "$id" in schema: - schemas[schema["$id"]] = schema + if self.initialized: + return + schemas: dict[LiteralString, Any] = {} + module = import_module(self.package_name) + if hasattr(module, "get_schemas"): + for schema in module.get_schemas(): + _add_schema(schemas, schema) + else: + # Fallback on searching schema json files + schema_search_path = os.path.dirname(module.__file__) + schema_search_path = os.path.abspath( + os.path.join(schema_search_path, self.directory) + if self.directory is not None + else schema_search_path + ) + for root, _dirs, files in os.walk(schema_search_path): + for fname in files: + fpath = os.path.join(root, fname) + if fpath.endswith((".json",)): + with open(fpath, encoding=self.encoding) as schema_fd: + schema = json.load(schema_fd) + _add_schema(schemas, schema) # Prepare validators for each schema. We add an HTTPS handler that # points back to our schema definition cache built above. # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1734513980 -3600 # Wed Dec 18 10:26:20 2024 +0100 # Branch 17.0 # Node ID 8a065abac2ee72bf0705dea71605e2b5c56142a0 # Parent 41d22db75a10dc3f3a97c16cac7f9d07b0028623 👕 mypy diff --git a/tests/schemas/__init__.py b/tests/schemas/__init__.py --- a/tests/schemas/__init__.py +++ b/tests/schemas/__init__.py @@ -1,9 +1,10 @@ import json import pkgutil -from typing import Optional, Any +from typing import Any +from collections.abc import Generator -def get_schemas() -> list[Any]: +def get_schemas() -> Generator[Any]: for file_prefix in ("product",): data: bytes | None = pkgutil.get_data(__name__, f"{file_prefix}.schema.json") if data: # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1734514546 -3600 # Wed Dec 18 10:35:46 2024 +0100 # Branch 17.0 # Node ID d905a690006f2abeb68efde6b8ff538dc73dae0c # Parent 8a065abac2ee72bf0705dea71605e2b5c56142a0 👕 mypy: handle __file__ is none diff --git a/validate.py b/validate.py --- a/validate.py +++ b/validate.py @@ -26,6 +26,9 @@ from typing import Any, LiteralString import fastjsonschema # type: ignore[import-untyped] +from odoo.exceptions import UserError # type: ignore[import-untyped] + +import fastjsonschema # type: ignore[import-untyped] _logger = logging.getLogger(__name__) @@ -78,6 +81,9 @@ for schema in module.get_schemas(): _add_schema(schemas, schema) else: + if module.__file__ is None: + # XXX maybe not the best type of error + raise UserError("Module %s has no file", self.package_name) # Fallback on searching schema json files schema_search_path = os.path.dirname(module.__file__) schema_search_path = os.path.abspath( # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1737650283 -3600 # Thu Jan 23 17:38:03 2025 +0100 # Branch 17.0 # Node ID 9af87ba588390cab7c9c6e23bd6be25210698624 # Parent d905a690006f2abeb68efde6b8ff538dc73dae0c Add summary in pyproject.toml diff --git a/pyproject.toml b/pyproject.toml --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [project] name = "odoo-addon-converter" +description = "Odoo addon to convert records to and from plain data structures" dynamic = ["version"] readme = "README.rst" requires-python = "~=3.10" # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1738241556 -3600 # Thu Jan 30 13:52:36 2025 +0100 # Branch 17.0 # Node ID 0c55df3649c9438e9cf5227bcbf7f238df404850 # Parent 9af87ba588390cab7c9c6e23bd6be25210698624 Fix maturity badge and use external badges for pypi diff --git a/.badges/maturity.svg b/.badges/maturity.svg --- a/.badges/maturity.svg +++ b/.badges/maturity.svg @@ -1,16 +1,16 @@ <?xml version="1.0" encoding="UTF-8" ?> -<svg xmlns="http://www.w3.org/2000/svg" width="103" height="20"> +<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20"> <linearGradient id="b" x2="0" y2="100%"> <stop offset="0" stop-color="#bbb" stop-opacity=".1" /> <stop offset="1" stop-opacity=".1" /> </linearGradient> <mask id="anybadge_1"> - <rect width="103" height="20" rx="3" fill="#fff" /> + <rect width="108" height="20" rx="3" fill="#fff" /> </mask> <g mask="url(#anybadge_1)"> <path fill="#555" d="M0 0h61v20H0z" /> - <path fill="#e05d44" d="M61 0h42v20H61z" /> - <path fill="url(#b)" d="M0 0h103v20H0z" /> + <path fill="#4C1" d="M61 0h47v20H61z" /> + <path fill="url(#b)" d="M0 0h108v20H0z" /> </g> <g fill="#fff" @@ -27,7 +27,7 @@ font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11" > - <text x="83.0" y="15" fill="#010101" fill-opacity=".3">Alpha</text> - <text x="82.0" y="14">Alpha</text> + <text x="85.5" y="15" fill="#010101" fill-opacity=".3">Stable</text> + <text x="84.5" y="14">Stable</text> </g> </svg> diff --git a/README.rst b/README.rst --- a/README.rst +++ b/README.rst @@ -2,16 +2,16 @@ Converter ========= -.. |maturity| image:: .badges/maturity.svg +.. |maturity| image:: https://img.shields.io/badge/maturity-Stable-green.png :target: https://odoo-community.org/page/development-status :alt: Stable -.. |license| image:: .badges/licence-AGPL--3-blue.svg +.. |license| image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 -.. |ruff| image:: .badges/code_style-ruff.svg +.. |ruff| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json :target: https://github.com/astral-sh/ruff :alt: Ruff -.. |prettier| image:: .badges/code_style-prettier-ff69b4.svg +.. |prettier| image:: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square :target: https://github.com/prettier/prettier :alt: Prettier # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1739455665 -3600 # Thu Feb 13 15:07:45 2025 +0100 # Branch 17.0 # Node ID 062c3ff4415429371e41386e9ab2026d6c9b70f6 # Parent 0c55df3649c9438e9cf5227bcbf7f238df404850 ✨ Added Writeonly converter. 👕 Add some typing information, or make it consistent. 📚 Add more docstrings. 🚑 Fix using Skip in switch converter. diff --git a/NEWS.rst b/NEWS.rst --- a/NEWS.rst +++ b/NEWS.rst @@ -14,6 +14,14 @@ Breaking change: validator package does not assume a odoo.addons package name, provide full package name instead. +Added Writeonly converter. + +Add some typing information, or make it consistent. + +Add more docstrings. + +Fix using Skip in switch converter. + 17.0.1.0.1 ---------- diff --git a/__init__.py b/__init__.py --- a/__init__.py +++ b/__init__.py @@ -34,6 +34,8 @@ NewinstanceType, Readonly, Skip, + SkipType, + Writeonly, message_to_odoo, build_context, ) diff --git a/base.py b/base.py --- a/base.py +++ b/base.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2025 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -189,7 +189,7 @@ class Readonly(Converter): - def __init__(self, conv): + def __init__(self, conv: Converter): super().__init__() self.conv = conv @@ -197,6 +197,35 @@ return self.conv.odoo_to_message(instance, ctx) +class Writeonly(Converter): + """A converter that only convert to odoo but does nothing from odoo.""" + + def __init__(self, conv: Converter): + super().__init__() + self._conv = conv + + def message_to_odoo( + self, + odoo_env: api.Environment, + phase: str, + message_value: Any, + instance: models.BaseModel, + value_present: bool = True, + ) -> dict | SkipType: + return self._conv.message_to_odoo( + odoo_env, phase, message_value, instance, value_present + ) + + @property + def is_instance_getter(self) -> bool: + return self._conv.is_instance_getter + + def get_instance( + self, odoo_env: api.Environment, message_data + ) -> models.BaseModel | NewinstanceType | None: + return self._conv.get_instance(odoo_env, message_data) + + class Computed(Converter): def __init__(self, from_odoo: Callable[[models.BaseModel, Context], Any]): self.from_odoo = from_odoo @@ -216,11 +245,13 @@ class Constant(Converter): - def __init__(self, value): - self.value = value + """When building messages, this converter return a constant value.""" + + def __init__(self, value: Any): + self._value = value def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: - return self.value + return self._value def message_to_odoo( diff --git a/field.py b/field.py --- a/field.py +++ b/field.py @@ -25,7 +25,7 @@ import pytz from odoo import api, models # type: ignore[import-untyped] -from .base import PHASE_POSTCREATE, Context, Converter, Newinstance, Skip +from .base import PHASE_POSTCREATE, Context, Converter, Newinstance, Skip, SkipType class Field(Converter): @@ -122,7 +122,7 @@ message_value: Any, instance: models.BaseModel, value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: if phase == PHASE_POSTCREATE: return {} if not value_present: @@ -133,7 +133,7 @@ # do not include value if already the same if instance and instance is not Newinstance: value = self.odoo_to_message(instance) - if value is Skip or value == message_value: + if isinstance(value, SkipType) or value == message_value: return {} if self._odoo_formatter: message_value = self._odoo_formatter(message_value) @@ -187,11 +187,11 @@ message_value: Any, instance: models.BaseModel, value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: message = super().message_to_odoo( odoo_env, phase, message_value, instance, value_present ) - if self.field_name in message: + if not isinstance(message, SkipType) and self.field_name in message: self._lazy_dicts(instance) message[self.field_name] = self._lazy_dict_message_to_odoo.get( message[self.field_name] diff --git a/list.py b/list.py --- a/list.py +++ b/list.py @@ -20,9 +20,9 @@ from typing import Any -from odoo import models # type: ignore[import-untyped] +from odoo import api, models # type: ignore[import-untyped] -from .base import Context, ContextBuilder, Converter, Skip, build_context +from .base import Context, ContextBuilder, Converter, SkipType, build_context class List(Converter): @@ -44,17 +44,24 @@ for converter in self._converters: value = converter.odoo_to_message(instance, ctx) - if value is not Skip: + if not isinstance(value, SkipType): message_data.append(value) return message_data def message_to_odoo( self, - odoo_env, + odoo_env: api.Environment, phase: str, - message_value, + message_value: Any, instance: models.BaseModel, value_present: bool = True, - ) -> dict: - return {} + ) -> dict | SkipType: + result = {} + for i, converter in enumerate(self._converters): + new_values = converter.message_to_odoo( + odoo_env, phase, message_value[i], instance, value_present + ) + if not isinstance(new_values, SkipType): + result.update(new_values) + return result diff --git a/mail_template.py b/mail_template.py --- a/mail_template.py +++ b/mail_template.py @@ -28,7 +28,7 @@ class MailTemplate(Converter): """This converter wraps ``mail.template::_render_template``. Multiple records are allowed but ``mail.template::_render_template`` still - runs once per record; to accomodate, we provide ``ctx["records"]``. + runs once per record; to accommodate, we provide ``ctx["records"]``. Using this converter requires the mail module to be installed. """ diff --git a/model.py b/model.py --- a/model.py +++ b/model.py @@ -34,7 +34,6 @@ Newinstance, NewinstanceType, PostHookConverter, - Skip, SkipType, build_context, ) @@ -92,7 +91,7 @@ {"key": key, "traceback": "".join(traceback.format_exception(e))} ) continue - if value is not Skip: + if not isinstance(value, SkipType): message_data[key] = value if len(errors) != 0: formatted_errors = "\n\n".join( @@ -108,7 +107,7 @@ if self.merge_with: for conv in self.merge_with: value = conv.odoo_to_message(instance, ctx) - if value is Skip: + if isinstance(value, SkipType): continue message_data.update(value) diff --git a/relation.py b/relation.py --- a/relation.py +++ b/relation.py @@ -29,6 +29,7 @@ Converter, NewinstanceType, Skip, + SkipType, build_context, ) from .field import Field @@ -69,7 +70,7 @@ message_value: Any, instance: models.BaseModel, value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: if not value_present: return {} @@ -107,8 +108,8 @@ def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) value = super().odoo_to_message(instance, ctx) - if value is Skip: - return Skip + if isinstance(value, SkipType): + return value if self.filtered: value = value.filtered(self.filtered) if self.sortkey: @@ -119,7 +120,7 @@ return [ m for m in (self.converter.odoo_to_message(r, ctx) for r in value) - if m is not Skip + if not isinstance(m, SkipType) ] def message_to_odoo( @@ -129,7 +130,7 @@ message_value: Any, instance: models.BaseModel, value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: # if not present or value is None, do not update the values. if not value_present or message_value is None: return {} @@ -173,8 +174,8 @@ def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) value = super().odoo_to_message(instance, ctx) - if value is Skip: - return Skip + if isinstance(value, SkipType): + return value if self.filtered: value = value.filtered(self.filtered) return { @@ -186,7 +187,7 @@ ) for r in value ) - if k is not Skip and v is not Skip + if not isinstance(k, SkipType) and not isinstance(v, SkipType) } def message_to_odoo( @@ -196,7 +197,7 @@ message_value: Any, instance: models.BaseModel, value_present: bool = True, - ) -> dict: + ) -> dict | SkipType: # if not present or value is None, do not update the values. if not value_present or message_value is None: return {} diff --git a/switch.py b/switch.py --- a/switch.py +++ b/switch.py @@ -39,8 +39,8 @@ AURION_REFERENTIAL: Switch( [ ( - lambda e: e.is_xxx, - lambda p: "wave_code" in p, + lambda record: record.is_xxx, + lambda message_value: "wave_code" in message_value, Model("__wave__", {}), ), (None, None, Model("__event__", {})), @@ -53,8 +53,8 @@ converters: list[ tuple[ Callable[[models.BaseModel], bool] | None, - Callable[[Any], bool], - Converter, + Callable[[Any], bool] | None, + Converter | SkipType, ] ], validator: Validator | None = None, @@ -92,7 +92,9 @@ value_present: bool = True, ) -> dict | SkipType: for _out_cond, in_cond, converter in self._converters: - if in_cond is None or in_cond(message_value): + if not isinstance(converter, SkipType) and ( + in_cond is None or in_cond(message_value) + ): return converter.message_to_odoo( odoo_env, phase, @@ -106,7 +108,7 @@ @property def is_instance_getter(self) -> bool: for _out_cond, _in_cond, converter in self._converters: - if converter.is_instance_getter: + if not isinstance(converter, SkipType) and converter.is_instance_getter: return True return False @@ -115,8 +117,10 @@ self, odoo_env: api.Environment, message_data ) -> models.BaseModel | NewinstanceType | None: for _out_cond, in_cond, converter in self._converters: - if converter.is_instance_getter and ( - in_cond is None or in_cond(message_data) + if ( + not isinstance(converter, SkipType) + and converter.is_instance_getter + and (in_cond is None or in_cond(message_data)) ): return converter.get_instance(odoo_env, message_data) return super().get_instance(odoo_env, message_data) @@ -125,16 +129,19 @@ # also set validator on any converters in our switch, in case they care super()._set_validator(value) for _out_cond, _in_cond, converter in self._converters: - converter.validator = value + if not isinstance(converter, SkipType): + converter.validator = value def _set_validation(self, value: str) -> None: # also set validation on any converters in our switch super()._set_validation(value) for _out_cond, _in_cond, converter in self._converters: - converter.validation = value + if not isinstance(converter, SkipType): + converter.validation = value def get__type__(self) -> set[str]: types = set() for _out_cond, _in_cond, converter in self._converters: - types.update(converter.get__type__()) + if not isinstance(converter, SkipType): + types.update(converter.get__type__()) return types diff --git a/xref.py b/xref.py --- a/xref.py +++ b/xref.py @@ -20,7 +20,7 @@ from typing import Any -from odoo import models # type: ignore[import-untyped] +from odoo import api, models # type: ignore[import-untyped] from .base import Context, NewinstanceType, PostHookConverter from .models.ir_model_data import _XREF_IMD_MODULE @@ -47,8 +47,8 @@ ) def get_instance( - self, odoo_env, message_data - ) -> None | models.BaseModel | NewinstanceType: + self, odoo_env: api.Environment, message_data + ) -> models.BaseModel | NewinstanceType | None: if self._is_instance_getter: return odoo_env.ref( ".".join(["" if self._module is None else self._module, message_data]), # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1739977707 -3600 # Wed Feb 19 16:08:27 2025 +0100 # Branch 17.0 # Node ID 69e493a16337a11b82f881298393109d836ddd9b # Parent 062c3ff4415429371e41386e9ab2026d6c9b70f6 ✨ support for datatype in newer sync schemas In Model converter, also validate messages in ``message_to_odoo``. Model converter argument changed, `__type__` is now the last argument and is optional. It is expected not to be used anymore. Added possible_datatypes property and odoo_datatype on Converter. Defaults to empty set and None. On Model, it can be set. Replace generic exceptions. diff --git a/NEWS.rst b/NEWS.rst --- a/NEWS.rst +++ b/NEWS.rst @@ -22,6 +22,15 @@ Fix using Skip in switch converter. +In Model converter, also validate messages in ``message_to_odoo``. + +Model converter argument changed, `__type__` is now the last argument and is optional. It is expected not to be used +anymore. +Added possible_datatypes property and odoo_datatype on Converter. Defaults to empty set and None. +On Model, it can be set. + +Replace generic exception. + 17.0.1.0.1 ---------- diff --git a/__manifest__.py b/__manifest__.py --- a/__manifest__.py +++ b/__manifest__.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020-2024 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020-2025 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as diff --git a/base.py b/base.py --- a/base.py +++ b/base.py @@ -43,13 +43,24 @@ from odoo import api, models # type: ignore[import-untyped] from .exception import InternalError -from .validate import Validator +from .validate import Validation, Validator logger = logging.getLogger(__name__) class SkipType: - pass + def get__type__(self) -> set[str]: + # Avoid conditions isinstance(converter, SkipType) + return set() + + @property + def possible_datatypes(self) -> set[str]: + # Avoid conditions isinstance(converter, SkipType) + return set() + + def odoo_datatype(self, instance: models.BaseModel) -> str | None: + # Avoid conditions isinstance(converter, SkipType) + return None Skip = SkipType() @@ -90,11 +101,19 @@ return ctx +class NotAnInstanceGetterException(Exception): + def __init__(self): + super().__init__("Not an instance getter") + + class Converter: """Base converter class. It does not actually convert anything. """ + def __init__(self): + self._validation: Validation = Validation.SKIP + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: """From an instance, this method returns a matching value for the message field. @@ -134,7 +153,7 @@ self, odoo_env: api.Environment, message_data ) -> models.BaseModel | NewinstanceType | None: """Return an instance of a model. Check is_instance_getter before calling""" - raise Exception("Not an instance getter") + raise NotAnInstanceGetterException() def get__type__(self) -> set[str]: """Indicate if this converter is associated to several __type__. @@ -166,21 +185,30 @@ ) @property - def validation(self) -> str: + def validation(self) -> Validation: return self._get_validation() @validation.setter - def validation(self, value: str) -> None: + def validation(self, value: Validation) -> None: self._set_validation(value) - def _get_validation(self) -> str: + def _get_validation(self) -> Validation: return self._validation - def _set_validation(self, value: str) -> None: + def _set_validation(self, value: Validation) -> None: """Define if validation should be done""" assert value is not None self._validation = value + @property + def possible_datatypes(self) -> set[str]: + """Possible values for datatype.""" + # A set, as for get___type__, to allow switch to handle different messages. + return set() + + def odoo_datatype(self, instance: models.BaseModel) -> str | None: + return None + class PostHookConverter(Converter, metaclass=ABCMeta): @abstractmethod @@ -191,10 +219,13 @@ class Readonly(Converter): def __init__(self, conv: Converter): super().__init__() - self.conv = conv + self._conv = conv def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: - return self.conv.odoo_to_message(instance, ctx) + return self._conv.odoo_to_message(instance, ctx) + + def odoo_datatype(self, instance: models.BaseModel) -> str | None: + return self._conv.odoo_datatype(instance) class Writeonly(Converter): @@ -225,6 +256,10 @@ ) -> models.BaseModel | NewinstanceType | None: return self._conv.get_instance(odoo_env, message_data) + @property + def possible_datatypes(self) -> set[str]: + return self._conv.possible_datatypes + class Computed(Converter): def __init__(self, from_odoo: Callable[[models.BaseModel, Context], Any]): diff --git a/model.py b/model.py --- a/model.py +++ b/model.py @@ -21,7 +21,7 @@ import traceback from collections import OrderedDict from collections.abc import Iterable, Mapping -from typing import Any +from typing import Any, Final import fastjsonschema # type: ignore[import-untyped] from odoo import _, api, models # type: ignore[import-untyped] @@ -42,12 +42,20 @@ _logger = logging.getLogger(__name__) +class IncorrectTypeException(Exception): + """__type__ is in the message is not the same as the expected value""" + + +class MissingRequiredValidatorException(Exception): + def __init__(self): + super().__init__("Strict validation without validator") + + class Model(PostHookConverter): """A converter that takes a dict of key, used when a message has values""" def __init__( self, - __type__: str | None, converters: Mapping[str, Converter], json_schema: str | None = None, # The validator is usually not given at this point but is common @@ -56,7 +64,12 @@ merge_with: Iterable[Converter] | None = None, validation: Validation = Validation.SKIP, context: ContextBuilder | None = None, + datatype: str | None = None, + __type__: str | None = None, ): + """ + :param datatype: datatype to use. Usually used with None __type__. + """ super().__init__() self._type: str | None = __type__ self._converters: Mapping[str, Converter] = converters @@ -68,6 +81,7 @@ self.context: ContextBuilder | None = context self.validator = validator self.validation = validation + self._datatype: Final[str | None] = datatype for key, converter in converters.items(): if self._get_instance is None and converter.is_instance_getter: @@ -120,7 +134,7 @@ if self.validation == Validation.STRICT: raise elif self.validation == Validation.STRICT: - raise Exception("Strict validation without validator") + raise MissingRequiredValidatorException() return message_data @@ -134,8 +148,19 @@ ) -> dict | SkipType: values: dict[str, Any] = OrderedDict() + if self.validation != Validation.SKIP and self._jsonschema is not None: + if self.validator: + try: + self.validator.validate(self._jsonschema, message_value) + except (NotInitialized, fastjsonschema.JsonSchemaException): + _logger.warning("Validation failed", exc_info=True) + if self.validation == Validation.STRICT: + raise + elif self.validation == Validation.STRICT: + raise MissingRequiredValidatorException() + if self._type is not None and message_value["__type__"] != self._type: - raise Exception( + raise IncorrectTypeException( "Expected __type__ {}, found {}".format( self._type, message_value["__type__"] ) @@ -191,3 +216,13 @@ def get__type__(self) -> set[str]: return set() if self._type is None else {self._type} + + @property + def possible_datatypes(self) -> set[str]: + result = set() + if self._datatype is not None: + result.add(self._datatype) + return result + + def odoo_datatype(self, instance: models.BaseModel) -> str | None: + return self._datatype diff --git a/switch.py b/switch.py --- a/switch.py +++ b/switch.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020, 2024 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2024, 2025 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -36,7 +36,7 @@ .. code-block:: python - AURION_REFERENTIAL: Switch( + Switch( [ ( lambda record: record.is_xxx, @@ -58,7 +58,7 @@ ] ], validator: Validator | None = None, - validation: str = Validation.SKIP, + validation: Validation = Validation.SKIP, context: ContextBuilder | None = None, ): """ @@ -103,7 +103,7 @@ value_present=value_present, ) - return {} + return Skip @property def is_instance_getter(self) -> bool: @@ -132,7 +132,7 @@ if not isinstance(converter, SkipType): converter.validator = value - def _set_validation(self, value: str) -> None: + def _set_validation(self, value: Validation) -> None: # also set validation on any converters in our switch super()._set_validation(value) for _out_cond, _in_cond, converter in self._converters: @@ -142,6 +142,18 @@ def get__type__(self) -> set[str]: types = set() for _out_cond, _in_cond, converter in self._converters: - if not isinstance(converter, SkipType): - types.update(converter.get__type__()) + types.update(converter.get__type__()) return types + + @property + def possible_datatypes(self) -> set[str]: + result = super().possible_datatypes + for _out_cond, _in_cond, converter in self._converters: + result.update(converter.possible_datatypes) + return result + + def odoo_datatype(self, instance: models.BaseModel) -> str | None: + for out_cond, _in_cond, converter in self._converters: + if out_cond is None or out_cond(instance): + return converter.odoo_datatype(instance) + return super().odoo_datatype(instance) diff --git a/tests/test_base.py b/tests/test_base.py --- a/tests/test_base.py +++ b/tests/test_base.py @@ -60,10 +60,10 @@ Field("name"), ) converter = Model( - "res.users", { "user_creator_name": rel, }, + __type__="res.users", ) with self.assertRaises(UserError) as e: converter.odoo_to_message(self.active_user) @@ -84,7 +84,6 @@ def test_convert(self): converter = Model( - None, { "active": Field("active"), "ref": Xref("base"), diff --git a/tests/test_converters.py b/tests/test_converters.py --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -99,7 +99,6 @@ """ return Model( # TODO add a schema to test validation - "test-type", { "id": Xref(), "country_code": RelationToOne( @@ -108,4 +107,5 @@ "name": Field("name"), "partner_id": RelationToOne("partner_id", "res.partner", Xref()), }, + __type__="test-type", ) diff --git a/tests/test_relation.py b/tests/test_relation.py --- a/tests/test_relation.py +++ b/tests/test_relation.py @@ -75,18 +75,17 @@ # This converter wraps a user and adds info from its related partner. converter = Model( - None, { "partner": RelationToOne( "partner_id", "res.partner", Model( - "partner", { "color": Field("color"), "name": Field("name"), "xref": Xref("test"), }, + __type__="partner", ), ), "xref": Xref("base"), @@ -128,17 +127,16 @@ # This converter wraps a user and adds info from its related partner. converter = Model( - None, { "users": RelationToMany( "user_ids", "res.users", Model( - "user", { "email": Field("email"), "xref": Xref("base"), }, + __type__="user", ), ), "xref": Xref("base"), @@ -167,3 +165,46 @@ # Check the partner's users self.assertTrue(partner.user_ids) self.assertEqual(len(partner.user_ids), 2) + + def test_many2many_to_odoo_no___type__(self): + """Ensure multiple sub-objects linked from the main one gets updated + when Odoo receives a message. + """ + + # This converter wraps a user and adds info from its related partner. + converter = Model( + { + "users": RelationToMany( + "user_ids", + "res.users", + Model( + { + "email": Field("email"), + "xref": Xref("base"), + }, + ), + ), + "xref": Xref("base"), + }, + ) + + partner = self.env.ref("base.main_partner") + self.assertFalse(partner.user_ids) + + # Run our message reception. + message: dict[str, Any] = { + "users": [ + { + "xref": "user_admin", + }, + { + "xref": "user_demo", + }, + ], + "xref": "main_partner", + } + message_to_odoo(self.env, message, self.env["res.partner"], converter) + + # Check the partner's users + self.assertTrue(partner.user_ids) + self.assertEqual(len(partner.user_ids), 2) diff --git a/tests/test_switch.py b/tests/test_switch.py --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -38,16 +38,16 @@ lambda e: e.active, falser, Model( - "__activebanks__", {"name": Field("name"), "active": Field("active")}, + __type__="__activebanks__", ), ), ( None, falser, Model( - "__inactivebanks__", {"name": Field("name"), "active": Field("active")}, + __type__="__inactivebanks__", ), ), ] # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1740040261 -3600 # Thu Feb 20 09:31:01 2025 +0100 # Branch 17.0 # Node ID fe0b2d7d1db288275c73b77f83d3b42f2022a0b2 # Parent 69e493a16337a11b82f881298393109d836ddd9b 👕 ruff, prettier diff --git a/tests/schemas/product.schema.json b/tests/schemas/product.schema.json --- a/tests/schemas/product.schema.json +++ b/tests/schemas/product.schema.json @@ -1,5 +1,4 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://example.com/product.schema.json", @@ -11,61 +10,40 @@ "type": "object", "properties": { - "productId": { - "description": "The unique identifier for a product", "type": "integer" - }, "productName": { - "description": "Name of the product", "type": "string" - }, "price": { - "description": "The price of the product", "type": "number", "exclusiveMinimum": 0 - }, "tags": { - "description": "Tags for the product", "type": "array", "items": { - "type": "string" - }, "minItems": 1, "uniqueItems": true - } - }, - "required": [ - - "productId", - - "productName", - - "price" - - ] - + "required": ["productId", "productName", "price"] } diff --git a/tests/schemas_dir/product.schema.json b/tests/schemas_dir/product.schema.json --- a/tests/schemas_dir/product.schema.json +++ b/tests/schemas_dir/product.schema.json @@ -1,5 +1,4 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://example.com/product.schema.json", @@ -11,61 +10,40 @@ "type": "object", "properties": { - "productId": { - "description": "The unique identifier for a product", "type": "integer" - }, "productName": { - "description": "Name of the product", "type": "string" - }, "price": { - "description": "The price of the product", "type": "number", "exclusiveMinimum": 0 - }, "tags": { - "description": "Tags for the product", "type": "array", "items": { - "type": "string" - }, "minItems": 1, "uniqueItems": true - } - }, - "required": [ - - "productId", - - "productName", - - "price" - - ] - + "required": ["productId", "productName", "price"] } diff --git a/validate.py b/validate.py --- a/validate.py +++ b/validate.py @@ -28,7 +28,6 @@ import fastjsonschema # type: ignore[import-untyped] from odoo.exceptions import UserError # type: ignore[import-untyped] -import fastjsonschema # type: ignore[import-untyped] _logger = logging.getLogger(__name__) # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1740041120 -3600 # Thu Feb 20 09:45:20 2025 +0100 # Branch 17.0 # Node ID 3cf99f4ba473da089ca920e8f9e8dfccc98a86fb # Parent fe0b2d7d1db288275c73b77f83d3b42f2022a0b2 👕 mypy diff --git a/doc/conf.py b/doc/conf.py --- a/doc/conf.py +++ b/doc/conf.py @@ -15,7 +15,7 @@ from importlib.metadata import version as import_version import odoo # type: ignore[import-untyped] -from odoo_scripts.config import Configuration +from odoo_scripts.config import Configuration # type: ignore[import-untyped] # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -158,15 +158,20 @@ os.path.dirname(os.path.abspath(odoo.__file__)) ) # sphinxodooautodoc_addons_path : List of paths were Odoo addons to load are located -c = None +c: Configuration | None = None # find setup file of superproject, if any -directory = os.path.dirname(os.getenv("PWD")) +pwd = os.getenv("PWD") +directory: str | None +if pwd is not None: + directory = os.path.dirname(pwd) +else: + directory = None while not c and directory: setup_path = os.path.join(directory, "setup.cfg") if os.path.isfile(setup_path): - c = configparser.ConfigParser() - c.read(setup_path) - if c.has_section("odoo_scripts"): + setup_cfg = configparser.ConfigParser() + setup_cfg.read(setup_path) + if setup_cfg.has_section("odoo_scripts"): # reload with odoo_scripts c = Configuration(directory) else: @@ -182,13 +187,13 @@ if c: addon_dirs = set(os.path.dirname(path) for path in c.modules) - for line in addon_dirs: - sphinxodooautodoc_addons_path.append(os.path.join(directory, line)) + if directory is not None: + for line in addon_dirs: + sphinxodooautodoc_addons_path.append(os.path.join(directory, line)) else: # add this directory top dir - sphinxodooautodoc_addons_path.append( - os.path.dirname(os.path.dirname(os.getenv("PWD"))) - ) + if pwd is not None: + sphinxodooautodoc_addons_path.append(os.path.dirname(os.path.dirname(pwd))) other_addons = os.getenv("ODOO_ADDONS_PATH", default=None) if other_addons: for addon_path in other_addons.split(","): diff --git a/validate.py b/validate.py --- a/validate.py +++ b/validate.py @@ -23,7 +23,7 @@ from collections.abc import Callable from enum import Enum from importlib import import_module -from typing import Any, LiteralString +from typing import Any import fastjsonschema # type: ignore[import-untyped] from odoo.exceptions import UserError # type: ignore[import-untyped] @@ -66,7 +66,7 @@ self.package_name = package_name # exemple "https://annonces-legales.fr/xbus/schemas/v1/{}.schema.json" self.default_url_pattern = default_url_pattern - self.validators: dict[LiteralString, Callable] = {} + self.validators: dict[str, Callable] = {} self.initialized = False self.encoding = "UTF-8" self.directory = directory @@ -74,7 +74,7 @@ def initialize(self) -> None: if self.initialized: return - schemas: dict[LiteralString, Any] = {} + schemas: dict[str, Any] = {} module = import_module(self.package_name) if hasattr(module, "get_schemas"): for schema in module.get_schemas(): # HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1740067988 -3600 # Thu Feb 20 17:13:08 2025 +0100 # Branch 17.0 # Node ID 81195e4078e5aa2a32ffc2a6f2c707b998f4eabb # Parent 3cf99f4ba473da089ca920e8f9e8dfccc98a86fb 🚑 Fix Switch converter to call post_hook, ✨ Xref converter: - Allow prefix on Xref converter - Add option to include module name in messages. Incoming and outgoing message value have the same comportment. For example, if __converter__ is used as the module, both generated messages and received message will contain __converter__.<name>. Previously, generated messages would use the module name while received one would not. diff --git a/NEWS.rst b/NEWS.rst --- a/NEWS.rst +++ b/NEWS.rst @@ -31,6 +31,15 @@ Replace generic exception. +Fix Switch converter to call post_hook. + +Xref converter: + +- Allow prefix on Xref converter +- Add option to include module name in messages. Incoming and outgoing message value have the same comportment. + For example, if __converter__ is used as the module, both generated messages and received message will contain __converter__.<name>. + Previously, generated messages would use the module name while received one would not. + 17.0.1.0.1 ---------- diff --git a/models/ir_model_data.py b/models/ir_model_data.py --- a/models/ir_model_data.py +++ b/models/ir_model_data.py @@ -38,21 +38,26 @@ _inherit = "ir.model.data" @api.model - def generate_name(self) -> str: + def generate_name(self, prefix: str = "") -> str: """Generate an xref for odoo record; + :param prefix: prefix to use before the name. :return: a UUID from a string of 32 hex digit """ - return uuid.uuid4().hex + return prefix + uuid.uuid4().hex @api.model def object_to_module_and_name( - self, record_set: models.BaseModel, module: str | None = _XREF_IMD_MODULE + self, + record_set: models.BaseModel, + module: str | None = _XREF_IMD_MODULE, + prefix: str = "", ) -> tuple[str, str]: """Retrieve an xref pointing to the specified Odoo record; create one when missing. :param module: Name of the module to use. None to use any name, if no xmlid exists "" will be used as the module name. + :param prefix: prefix to use before the name. :return: tuple module and name """ record_set.ensure_one() @@ -63,6 +68,8 @@ ] if module is not None: domain.append(("module", "=", module)) + if prefix: + domain.append(("name", "=like", f"{prefix}%")) # Find an existing xref. See class docstring for details. imd = self.sudo().search(domain, limit=1) @@ -70,7 +77,7 @@ return imd.module, imd.name # Could not find an existing xref; create one. - name = self.generate_name() + name = self.generate_name(prefix) if module is None: module = "" self.set_xmlid(record_set, name, module) @@ -78,15 +85,19 @@ @api.model def object_to_xmlid( - self, record_set: models.BaseModel, module: str | None = _XREF_IMD_MODULE + self, + record_set: models.BaseModel, + module: str | None = _XREF_IMD_MODULE, + prefix: str = "", ) -> str: """Retrieve an xref pointing to the specified Odoo record; create one when missing. :param module: Name of the module to use. None to use any name, if no xmlid exists "" will be used as the module name. + :param prefix: prefix to use before the name. """ return "{0[0]}.{0[1]}".format( - self.object_to_module_and_name(record_set, module) + self.object_to_module_and_name(record_set, module, prefix) ) @api.model diff --git a/switch.py b/switch.py --- a/switch.py +++ b/switch.py @@ -22,11 +22,19 @@ from odoo import api, models # type: ignore[import-untyped] -from .base import Context, ContextBuilder, Converter, NewinstanceType, Skip, SkipType +from .base import ( + Context, + ContextBuilder, + Converter, + NewinstanceType, + PostHookConverter, + Skip, + SkipType, +) from .validate import Validation, Validator -class Switch(Converter): +class Switch(PostHookConverter): """A converter to handle switch cases. A list of converters are provided with a function. The first function to match is used, any function that is None will be used. @@ -157,3 +165,10 @@ if out_cond is None or out_cond(instance): return converter.odoo_datatype(instance) return super().odoo_datatype(instance) + + def post_hook(self, instance: models.BaseModel, message_data): + for _out_cond, in_cond, converter in self._converters: + if in_cond is None or in_cond(message_data): + if hasattr(converter, "post_hook"): + converter.post_hook(instance, message_data) + return diff --git a/tests/test_converters.py b/tests/test_converters.py --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -100,12 +100,14 @@ return Model( # TODO add a schema to test validation { - "id": Xref(), + "id": Xref(include_module_name=True), "country_code": RelationToOne( "country_id", None, KeyField("code", "res.country") ), "name": Field("name"), - "partner_id": RelationToOne("partner_id", "res.partner", Xref()), + "partner_id": RelationToOne( + "partner_id", "res.partner", Xref(include_module_name=True) + ), }, __type__="test-type", ) diff --git a/tests/test_relation.py b/tests/test_relation.py --- a/tests/test_relation.py +++ b/tests/test_relation.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2021, 2024 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2021, 2024, 2025 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -36,7 +36,9 @@ @classmethod def setUpClass(cls) -> None: super().setUpClass() - cls.converter1 = RelationToOne("company_id", "res.company", Xref(None)) + cls.converter1 = RelationToOne( + "company_id", "res.company", Xref(None, prefix="main_") + ) cls.converter2 = RelationToOne("action_id", "res.company", Xref(None)) cls.converter3 = RelationToOne( "action_id", "res.company", Xref(None), send_empty=False @@ -52,13 +54,13 @@ def test_many2one_from_odoo(self): message = self.converter1.odoo_to_message(self.user_admin) - self.assertEqual(message, "base.main_company") + self.assertEqual(message, "company") message = self.converter1.odoo_to_message(self.user_root) - self.assertEqual(message, "base.main_company") + self.assertEqual(message, "company") def test_many2one_skip_from_odoo(self): message = self.converter4.odoo_to_message(self.user_admin) - self.assertEqual(message, "base.main_company") + self.assertEqual(message, "main_company") def test_empty_many2one_from_odoo(self): message = self.converter2.odoo_to_message(self.user_root) diff --git a/xref.py b/xref.py --- a/xref.py +++ b/xref.py @@ -1,7 +1,7 @@ ############################################################################## # # Converter Odoo module -# Copyright © 2020 XCG Consulting <https://xcg-consulting.fr> +# Copyright © 2020, 2025 XCG Consulting <https://xcg-consulting.fr> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -17,13 +17,24 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## +import logging +import os +import uuid +from typing import Any, Final -from typing import Any +from odoo import _, api, models # type: ignore[import-untyped] -from odoo import api, models # type: ignore[import-untyped] +from .base import ( + Context, + ContextBuilder, + Converter, + NewinstanceType, + PostHookConverter, + build_context, +) +from .models.ir_model_data import _XREF_IMD_MODULE -from .base import Context, NewinstanceType, PostHookConverter -from .models.ir_model_data import _XREF_IMD_MODULE +_logger = logging.getLogger(__name__) # TODO dans quel cas ça ne pourrait pas être un instance getter??? @@ -33,35 +44,148 @@ """ def __init__( - self, module: str | None = _XREF_IMD_MODULE, is_instance_getter: bool = True + self, + module: str | None = _XREF_IMD_MODULE, + is_instance_getter: bool = True, + include_module_name: bool = False, + prefix: str = "", ): + """ + :param prefix: prefix to use in ir.model.data, nor sent nor received. + Used to prevent duplication if received id is too simple. + """ super().__init__() self._module = module self._is_instance_getter = is_instance_getter + self._include_module_name: Final[bool] = include_module_name + self._prefix: Final[str] = prefix def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: if not instance: return "" - return instance.env["ir.model.data"].object_to_xmlid( - instance, module=self._module + module, name = instance.env["ir.model.data"].object_to_module_and_name( + instance, self._module, self._prefix ) + if self._prefix is not None: + name = name[len(self._prefix) :] + if self._include_module_name: + return f"{module}.{name}" + return name def get_instance( self, odoo_env: api.Environment, message_data ) -> models.BaseModel | NewinstanceType | None: if self._is_instance_getter: + module, name = self._module_name(message_data) return odoo_env.ref( - ".".join(["" if self._module is None else self._module, message_data]), + f"{module}.{name}", raise_if_not_found=False, ) return None def post_hook(self, instance: models.BaseModel, message_data): # add xmlid to the newly created object + module, name = self._module_name(message_data) instance.env["ir.model.data"].set_xmlid( - instance, message_data, module=self._module, only_when_missing=True + instance, name, module=module, only_when_missing=True ) + def _module_name(self, value: str) -> tuple[str, str]: + """Return module and name depending on options""" + module = "" if self._module is None else self._module + name = value + if self._include_module_name: + module, name = value.split(".", 1) + assert module == self._module + if self._prefix is not None: + name = self._prefix + name + return module, name + @property def is_instance_getter(self) -> bool: return self._is_instance_getter + + +class JsonLD_ID(Xref): + """This converter represents a JsonLD ID , an url made of + a base part defined as ir.config_parameter, an optional breadcrumb + and a unique id part using the standard xmlid. + """ + + def __init__( + self, + breadcrumb: str | Converter, + module: str | None = _XREF_IMD_MODULE, + is_instance_getter: bool = True, + unique_id_field: str | None = None, + context: ContextBuilder | None = None, + has_base_url: bool = True, + ): + """ + :param breadcrumb: Part of the url describing the entity, + must match the syntax expected by os.path, ie absolute path + begins with a slash. With absolute path the base part is + ignored. Can also be a converter, if so, the result of the + combined converters must be a string. + """ + super().__init__( + module=module, + is_instance_getter=is_instance_getter, + ) + self.converter: Converter | None = None + self._breadcrumb = breadcrumb if isinstance(breadcrumb, str) else None + if isinstance(breadcrumb, Converter): + self.converter = breadcrumb + self._unique_id_field = unique_id_field + self._context = context + self._has_base_url = has_base_url + + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: + if not instance: + return "" + + imds = ( + instance.env["ir.model.data"] + .sudo() + .search( + [ + ("module", "=", self._module), + ("model", "=", instance._name), + ("res_id", "=", instance.id), + ] + ) + ) + + ctx = build_context(instance, ctx, self._context) + jsonld_id_base_url = ( + instance.env["ir.config_parameter"] + .sudo() + .get_param("sync.jsonld_id_base_url") + ) + if self._has_base_url and not jsonld_id_base_url: + _logger.error( + _("Missing config parameter: 'sync.jsonld_id_base_url' is not defined") + ) + return "" + + if self.converter is not None: + self._breadcrumb = self.converter.odoo_to_message(instance, ctx) + + # xref does not exist or does not match the jsonld expected format, create it + schema_base = os.path.join( + jsonld_id_base_url if self._has_base_url else "", + self._breadcrumb if self._breadcrumb is not None else "", + ) + if not imds or all(not imd.name.startswith(schema_base) for imd in imds): + if self._unique_id_field is not None: + name = getattr(instance, self._unique_id_field) + else: + name = uuid.uuid4().hex + + xref = os.path.join(schema_base, name) + instance.env["ir.model.data"].set_xmlid(instance, xref, module=self._module) + else: + for imd in imds: + if imd.name.startswith(schema_base): + xref = imd.name + return xref