# HG changeset patch # User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr> # Date 1732186976 -3600 # Thu Nov 21 12:02:56 2024 +0100 # Branch 18.0 # Node ID 2a40d04b025ac4fc4985feda4621981fcd9c2563 # Parent f88452a152ee41ae12f247f0a25d3823cb13e089 👕 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 @@ " got {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/doc/autotodo.py b/doc/autotodo.py --- a/doc/autotodo.py +++ b/doc/autotodo.py @@ -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 @@ -78,14 +78,23 @@ 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("{}\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) @@ -98,7 +107,9 @@ 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) as f: diff --git a/doc/conf.py b/doc/conf.py --- a/doc/conf.py +++ b/doc/conf.py @@ -26,7 +26,6 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.doctest", - "sphinx.ext.intersphinx", "sphinx.ext.todo", "sphinx.ext.coverage", "sphinx.ext.graphviz", @@ -104,8 +103,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]). 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"] 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, Newinstance, 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: @@ -69,13 +75,15 @@ post_hook = getattr(self.converter, "post_hook", None) - if self.converter.is_instance_getter(): - rel_record = self.converter.get_instance(odoo_env, message_value) + if self.converter.is_instance_getter: + rel_record: models.BaseModel | NewinstanceType | None = ( + self.converter.get_instance(odoo_env, message_value) + ) if rel_record is None: return {self.field_name: None} - if rel_record is Newinstance: + if isinstance(rel_record, NewinstanceType): rel_record = None updates = self.converter.message_to_odoo( @@ -126,8 +134,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, ): @@ -142,7 +150,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: @@ -165,7 +173,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. @@ -186,7 +194,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, ): """ @@ -199,7 +207,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: @@ -223,7 +231,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 @@ -17,9 +17,11 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # ############################################################################## +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, RelationToOne, @@ -78,7 +80,7 @@ "partner_id", "res.partner", Model( - "", + "partner", { "color": Field("color"), "name": Field("name"), @@ -94,8 +96,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", @@ -104,7 +107,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