# HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1740129381 -3600 # Fri Feb 21 10:16:21 2025 +0100 # Branch 16.0 # Node ID 85125162a750c2a37d21b92c0d17f73993cdac38 # Parent edc971a2dc195ec9b65a7ed93de4df4c1ce14e79 # EXP-Topic backport 👕 Typing diff --git a/__init__.py b/__init__.py --- a/__init__.py +++ b/__init__.py @@ -42,11 +42,5 @@ from .model import Model from .relation import RelationToMany, RelationToManyMap, RelationToOne, relation from .switch import Switch -from .validate import ( - VALIDATION_SKIP, - VALIDATION_SOFT, - VALIDATION_STRICT, - NotInitialized, - Validator, -) +from .validate import NotInitialized, Validation, Validator from .xref import Xref 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 Mapping from typing import Any, Callable, Optional -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, Optional[Mapping]], Optional[Mapping]] +Context = Optional[Mapping] +ContextBuilder = Callable[[models.BaseModel, Context], Context] PHASE_PRECREATE = "precreate" PHASE_POSTCREATE = "postcreate" @@ -72,9 +74,9 @@ def build_context( instance: Optional[models.Model], - ctx: Optional[Mapping], + ctx: Context, extend: Optional[ContextBuilder], -) -> Optional[dict]: +) -> 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: Optional[Mapping] = 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) -> Optional[Validator]: """A validator to use for validation of created messages""" - return self._validator + return self._get_validator() @validator.setter def validator(self, value: Optional[Validator]) -> 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,43 +167,51 @@ @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: Optional[dict] = 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, Optional[Mapping]], Any]): + def __init__(self, from_odoo: Callable[[models.BaseModel, Context], Any]): self.from_odoo = from_odoo sig = inspect.signature(from_odoo) self.from_odoo_arg_count = len(sig.parameters) if self.from_odoo_arg_count not in (1, 2): raise ValueError( - "Computed 'from_odoo' callback must have 1 or 2 args: got %s" - % self.from_odoo_arg_count + "Computed 'from_odoo' callback must have 1 or 2 args: got " + f"{self.from_odoo_arg_count}" ) - def odoo_to_message( - self, instance: models.Model, ctx: Optional[Mapping] = 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) @@ -196,9 +219,7 @@ def __init__(self, value): self.value = value - def odoo_to_message( - self, instance: models.Model, ctx: Optional[Mapping] = None - ) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: return self.value @@ -208,7 +229,7 @@ model_name: str, converter: Converter, operation: Optional[str] = None, -) -> models.Model: +) -> models.BaseModel: """ :param odoo_env: an Odoo environment @@ -220,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/doc/autotodo.py b/doc/autotodo.py --- a/doc/autotodo.py +++ b/doc/autotodo.py @@ -2,7 +2,7 @@ ############################################################################## # # OpenERP, Open Source Management Solution -# Copyright © 2014, 2018, 2022, 2023 XCG Consulting +# Copyright © 2014, 2018, 2022-2024 XCG Consulting # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -22,7 +22,7 @@ import os import os.path import sys -from collections.abc import Mapping +from collections.abc import MutableMapping def main(): @@ -31,17 +31,17 @@ sys.exit(1) folder = sys.argv[1] - exts = sys.argv[2].split(",") - tags = sys.argv[3].split(",") - todolist = {tag: [] for tag in tags} - path_file_length: Mapping[str, int] = {} + exts: list[str] = sys.argv[2].split(",") + tags: list[str] = sys.argv[3].split(",") + todolist: dict[str, list[tuple[str, int, str]]] = {tag: [] for tag in tags} + path_file_length: MutableMapping[str, int] = {} for root, _dirs, files in os.walk(folder): scan_folder((exts, tags, todolist, path_file_length), root, files) create_autotodo(folder, todolist, path_file_length) -def write_info(f, infos, folder, path_file_length: Mapping[str, int]): +def write_info(f, infos, folder, path_file_length: MutableMapping[str, int]): # Check sphinx version for lineno-start support import sphinx @@ -55,16 +55,15 @@ path = i[0] line = i[1] lines = (line - 3, min(line + 4, path_file_length[path])) - class_name = ":class:`%s`" % os.path.basename(os.path.splitext(path)[0]) + class_name = f":class:`{os.path.basename(os.path.splitext(path)[0])}`" f.write( - "%s\n" - "%s\n\n" - "Line %s\n" - "\t.. literalinclude:: %s\n" + "{}\n" + "{}\n\n" + "Line {}\n" + "\t.. literalinclude:: {}\n" "\t\t:language: python\n" - "\t\t:lines: %s-%s\n" - "\t\t:emphasize-lines: %s\n" - % ( + "\t\t:lines: {}-{}\n" + "\t\t:emphasize-lines: {}\n".format( class_name, "-" * len(class_name), line, @@ -75,18 +74,27 @@ ) ) if lineno_start: - f.write("\t\t:lineno-start: %s\n" % lines[0]) + f.write(f"\t\t:lineno-start: {lines[0]}\n") f.write("\n") -def create_autotodo(folder, todolist, path_file_length: Mapping[str, int]): +def create_autotodo(folder, todolist, path_file_length: MutableMapping[str, int]): with open("autotodo", "w+") as f: for tag, info in list(todolist.items()): - f.write("%s\n%s\n\n" % (tag, "=" * len(tag))) + f.write("{}\n{}\n\n".format(tag, "=" * len(tag))) write_info(f, info, folder, path_file_length) -def scan_folder(data_tuple, dirname, names): +def scan_folder( + data_tuple: tuple[ + list[str], + list[str], + dict[str, list[tuple[str, int, str]]], + MutableMapping[str, int], + ], + dirname: str, + names: list[str], +): (exts, tags, res, path_file_length) = data_tuple for name in names: (root, ext) = os.path.splitext(name) @@ -99,10 +107,12 @@ res[tag].extend(info) -def scan_file(filename, tags) -> tuple[dict[str, list[tuple[str, int, str]]], int]: +def scan_file( + filename: str, tags: list[str] +) -> tuple[dict[str, list[tuple[str, int, str]]], int]: res: dict[str, list[tuple[str, int, str]]] = {tag: [] for tag in tags} line_num: int = 0 - with open(filename, "r") as f: + with open(filename) as f: for line_num, line in enumerate(f): for tag in tags: if tag in line: diff --git a/doc/conf.py b/doc/conf.py --- a/doc/conf.py +++ b/doc/conf.py @@ -14,9 +14,9 @@ from importlib.metadata import PackageNotFoundError from importlib.metadata import version as import_version -from odoo_scripts.config import Configuration +from odoo_scripts.config import Configuration # type: ignore[import-untyped] -import odoo +import odoo # 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 @@ -111,8 +111,6 @@ # -- Options for LaTeX output --------------------------------------------- -latex_elements = {} - # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). @@ -163,15 +161,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: @@ -187,13 +190,15 @@ 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/field.py b/field.py --- a/field.py +++ b/field.py @@ -19,13 +19,13 @@ ############################################################################## import datetime -from typing import Any, Callable, Optional +from typing import Any, Callable, Literal, Optional 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,9 +87,7 @@ return self._blank_value - def odoo_to_message( - self, instance: models.Model, ctx: Optional[dict] = 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 @@ -122,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: @@ -163,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) @@ -175,9 +173,7 @@ (b, a) for a, b in description_selection ) - def odoo_to_message( - self, instance: models.Model, ctx: Optional[dict] = 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) @@ -189,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 @@ -18,11 +18,11 @@ # ############################################################################## -from typing import Any, Optional +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,12 +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: Optional[dict] = 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 ) @@ -52,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, Optional -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,9 +37,7 @@ self._converters = converters self.context = context - def odoo_to_message( - self, instance: models.Model, ctx: Optional[dict] = None - ) -> Any: + def odoo_to_message(self, instance: models.BaseModel, ctx: Context = None) -> Any: ctx = build_context(instance, ctx, self.context) message_data = [] @@ -56,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, Optional +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: Optional[dict] = 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,48 +17,51 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - import logging from collections import OrderedDict from collections.abc import Iterable, Mapping from typing import Any, Optional -from odoo import api, models +import fastjsonschema # type: ignore[import-untyped] + +from odoo import api, models # 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: Optional[str] = None, # The validator is usually not given at this point but is common # throughout a project. That’s why it is a property validator: Optional[Validator] = None, merge_with: Optional[Iterable[Converter]] = None, - validation: str = VALIDATION_SKIP, + validation: Validation = Validation.SKIP, context: Optional[ContextBuilder] = 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: Optional[str] = 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: Optional[Iterable[Converter]] = merge_with self.context: Optional[ContextBuilder] = context @@ -66,18 +69,16 @@ 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: Optional[dict] = 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 for key in self._converters: @@ -92,13 +93,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 @@ -107,12 +111,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__"] @@ -140,12 +144,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( @@ -156,14 +161,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 @@ -18,6 +18,7 @@ [project.optional-dependencies] doc = ["sphinx", "sphinx-odoo-autodoc"] test = [] +typing = ["types-pytz"] [project.urls] repository = "https://orus.io/xcg/odoo-modules/converter" 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,13 +17,19 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## - import logging from typing import Any, Callable, Optional -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__) @@ -33,7 +39,7 @@ def __init__( self, field_name: str, - model_name: str, + model_name: str | None, converter: Converter, send_empty: bool = True, context: Optional[ContextBuilder] = None, @@ -44,9 +50,7 @@ self._send_empty = send_empty self.context = context - def odoo_to_message( - self, instance: models.Model, ctx: Optional[dict] = 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) @@ -62,7 +66,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: @@ -83,8 +87,8 @@ field_name: str, model_name: Optional[str], 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: Optional[ContextBuilder] = None, limit: Optional[Any] = None, ): @@ -99,9 +103,7 @@ self.context = context self.limit = limit - def odoo_to_message( - self, instance: models.Model, ctx: Optional[dict] = 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: @@ -124,7 +126,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. @@ -154,7 +156,7 @@ model_name: Optional[str], key_converter: Converter, value_converter: Converter, - filtered: None | str | Callable[[models.Model], bool] = None, + filtered: None | str | Callable[[models.BaseModel], bool] = None, context: Optional[ContextBuilder] = None, ): """ @@ -167,9 +169,7 @@ self.filtered = filtered self.context = context - def odoo_to_message( - self, instance: models.Model, ctx: Optional[dict] = 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: @@ -193,7 +193,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 Mapping -from typing import Any, Callable, Optional +from collections.abc import Callable +from typing import Any, Optional -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: Optional[Validator] = None, - validation: str = VALIDATION_SKIP, + validation: str = Validation.SKIP, context: Optional[ContextBuilder] = None, ): """ @@ -74,9 +73,7 @@ self.validator = validator self.validation = validation - def odoo_to_message( - self, instance: models.Model, ctx: Optional[Mapping] = 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: Optional[Validator]) -> 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,10 +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 Constant, Field, Model, Xref, message_to_odoo +from .. import Constant, Field, Model, Xref, message_to_odoo, relation class Test(tests.TransactionCase): @@ -34,6 +34,46 @@ converter = Constant("a") self.assertEqual("a", converter.odoo_to_message(self.env["res.users"])) + def test_unset_field(self): + # this tests when a relational substitution + # is substituted on a record that has not + # the relation set + + self.assertTrue(self.active_user.create_uid) + # set its create_uid to False + self.active_user.write( + { + "create_uid": False, + } + ) + + rel = relation( + "create_uid", + Field("name"), + ) + converter = Model( + "res.users", + { + "user_creator_name": rel, + }, + ) + with self.assertRaises(UserError) as e: + converter.odoo_to_message(self.active_user) + + self.assertTrue( + str(e.exception).startswith( + "Got unexpected errors while parsing substitutions:" + ), + "UserError does not start as expected", + ) + + self.assertTrue( + str(e.exception).endswith( + "KeyError: None\nKey: user_creator_name", + ), + "UserError does not end as expected", + ) + def test_convert(self): converter = Model( None, 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,9 +20,9 @@ from typing import Any -from odoo import tests +from odoo import tests # type: ignore[import-untyped] -from odoo.addons.converter import Field, TranslatedSelection +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,9 +18,9 @@ # ############################################################################## -from odoo import tests +from odoo import tests # type: ignore[import-untyped] -from odoo.addons.converter import MailTemplate +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,17 +19,9 @@ ############################################################################## from typing import Any -from odoo import tests +from odoo import tests # type: ignore[import-untyped] -from odoo.addons.converter import ( - Field, - Model, - RelationToMany, - RelationToOne, - Skip, - Xref, - message_to_odoo, -) +from .. import Field, Model, RelationToMany, RelationToOne, Skip, Xref, message_to_odoo class Test(tests.TransactionCase): @@ -81,7 +73,7 @@ "partner_id", "res.partner", Model( - "", + "partner", { "color": Field("color"), "name": Field("name"), @@ -97,8 +89,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", @@ -107,7 +100,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,17 +17,22 @@ # 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 fastjsonschema # type: ignore[import-untyped] + +import odoo.addons # type: ignore[import-untyped] -import odoo.addons + +class Validation(str, Enum): + """Type of validation""" -VALIDATION_SKIP = "skip" -VALIDATION_SOFT = "soft" -VALIDATION_STRICT = "strict" + SKIP = "skip" + SOFT = "soft" + STRICT = "strict" class NotInitialized(Exception): @@ -45,11 +50,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__ ) @@ -78,7 +84,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 @@ -18,28 +18,28 @@ # ############################################################################## -from typing import Any, Optional +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: Optional[dict] = 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( @@ -48,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