# 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