From 1418e06b37fea46f4561ea22836374442b2fb8fa Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 22 Nov 2021 10:33:21 +0100 Subject: [PATCH] [IMP] Pydantic: Improve API to be more pythonic --- pydantic/README.rst | 144 ++++++++++---- pydantic/__init__.py | 1 + pydantic/__manifest__.py | 1 + pydantic/builder.py | 31 +-- pydantic/context.py | 8 + pydantic/ir_http.py | 29 +++ pydantic/models.py | 262 ++++++++++++++++++------- pydantic/readme/USAGE.rst | 108 +++++++--- pydantic/registry.py | 133 +++++++++---- pydantic/static/description/index.html | 112 ++++++++--- pydantic/tests/common.py | 25 ++- pydantic/tests/test_pydantic.py | 187 ++++++++++-------- pydantic/utils.py | 11 +- requirements.txt | 1 + setup/pydantic/setup.py | 6 +- 15 files changed, 721 insertions(+), 338 deletions(-) create mode 100644 pydantic/context.py create mode 100644 pydantic/ir_http.py diff --git a/pydantic/README.rst b/pydantic/README.rst index 3dbbe94dc..54eea0e69 100644 --- a/pydantic/README.rst +++ b/pydantic/README.rst @@ -13,11 +13,17 @@ Pydantic .. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 -.. |badge3| image:: https://img.shields.io/badge/github-oca%2Frest--framework-lightgray.png?logo=github - :target: https://github.com/oca/rest-framework/tree/14.0/pydantic - :alt: oca/rest-framework - -|badge1| |badge2| |badge3| +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github + :target: https://github.com/OCA/rest-framework/tree/14.0/pydantic + :alt: OCA/rest-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/rest-framework-14-0/rest-framework-14-0-pydantic + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/271/14.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| This addon allows you to define inheritable `Pydantic classes `_. @@ -30,7 +36,7 @@ Usage ===== To define your own pydantic model you just need to create a class that inherits from -``odoo.addons.pydantic.models.BaseModel`` +``odoo.addons.pydantic.models.BaseModel`` or a subclass of. .. code-block:: python @@ -39,15 +45,11 @@ To define your own pydantic model you just need to create a class that inherits class PartnerShortInfo(BaseModel): - _name = "partner.short.info" id: str name: str class PartnerInfo(BaseModel): - _name = "partner.info" - _inherit = "partner.short.info" - street: str street2: str = None zip_code: str = None @@ -56,44 +58,102 @@ To define your own pydantic model you just need to create a class that inherits is_componay : bool = Field(None) -As for odoo models, you can extend the `base` pydantic model by inheriting of `base`. +In the preceding code, 2 new models are created, one for each class. If you +want to extend an existing model, you must pass the extended pydantic model +trough the `extends` parameter on class declaration. + +.. code-block:: python + + class Coordinate(models.BaseModel): + lat = 0.1 + lng = 10.1 + + class PartnerInfoWithCoordintate(PartnerInfo, extends=PartnerInfo): + coordinate: Coordinate = None + +`PartnerInfoWithCoordintate` extends `PartnerInfo`. IOW, Base class are now the +same and define the same fields and methods. They can be used indifferently into +the code. All the logic will be provided by the aggregated class. .. code-block:: python - class Base(BaseModel): - _inherit = "base" + partner1 = PartnerInfo.construct() + partner2 = PartnerInfoWithCoordintate.construct() - def _my_method(self): - pass + assert partner1.__class__ == partner2.__class__ + assert PartnerInfo.schema() == PartnerInfoWithCoordinate.schema() + +.. note:: + + Since validation occurs on instance creation, it's important to avoid to + create an instance of a Pydantic class by usign the normal instance + constructor `partner = PartnerInfo(..)`. In such a case, if the class is + extended by an other addon and a required field is added, this code will + no more work. It's therefore a good practice to use the `construct()` class + method to create a pydantic instance. + +.. caution:: + + Adding required fields to an existing data structure into an extension + addon violates the `Liskov substitution principle`_ and should generally + be avoided. This is certainly forbidden in requests data structures. + When extending response data structures this could be useful to document + new fields that are guaranteed to be present when extension addons are + installed. + +In contrast to Odoo, access to a Pydantic class is not done through a specific +registry. To use a Pydantic class, you just have to import it in your module +and write your code like in any other python application. + +.. code-block:: python + + from odoo.addons.my_addons.datamodels import PartnerInfo + from odoo import models + + class ResPartner(models.Basemodel): + _inherit = "res.partner" + + def to_json(self): + return [i._to_partner_info().json() for i in self] + + def _to_partner_info(self): + self.ensure_one() + pInfo = PartnerInfo.construct(id=self.id, name=self.name, street=self.street, city=self.city) + return pInfo -Pydantic model classes are available through the `pydantic_registry` registry provided by the Odoo's environment. To support pydantic models that map to Odoo models, Pydantic model instances can be created from arbitrary odoo model instances by mapping fields from odoo -models to fields defined by the pydantic model. To ease the mapping, -your pydantic model should inherit from 'odoo_orm_mode' +models to fields defined by the pydantic model. To ease the mapping, the addon +provide a utility class `odoo.addons.pydantic.utils.GenericOdooGetter`. .. code-block:: python - class UserInfo(models.BaseModel): - _name = "user" - _inherit = "odoo_orm_mode" - name: str - groups: List["group"] = pydantic.Field(alias="groups_id") + import pydantic + from odoo.addons.pydantic import models, utils + + class Group(models.BaseModel): + name: str + + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter + class UserInfo(models.BaseModel): + name: str + groups: List[Group] = pydantic.Field(alias="groups_id") - class Group(models.BaseModel): - _name="group" - _inherit = "odoo_orm_mode" - name: str + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter - user = self.env.user - UserInfoCls = self.env.pydantic_registry["user"] - user_info = UserInfoCls.from_orm(user) + user = self.env.user + user_info = UserInfo.from_orm(user) -See the official Pydantic documentation_ to discover all the available functionalities. +See the official `Pydantic documentation`_ to discover all the available functionalities. -.. _documentation: https://pydantic-docs.helpmanual.io/ +.. _`Liskov substitution principle`: https://en.wikipedia.org/wiki/Liskov_substitution_principle +.. _`Pydantic documentation`: https://pydantic-docs.helpmanual.io/ Known issues / Roadmap ====================== @@ -105,10 +165,10 @@ be found on GitHub. Bug Tracker =========== -Bugs are tracked on `GitHub Issues `_. +Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us smashing it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -128,14 +188,24 @@ Contributors Maintainers ~~~~~~~~~~~ +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + .. |maintainer-lmignon| image:: https://github.com/lmignon.png?size=40px :target: https://github.com/lmignon :alt: lmignon -Current maintainer: +Current `maintainer `__: |maintainer-lmignon| -This module is part of the `oca/rest-framework `_ project on GitHub. +This module is part of the `OCA/rest-framework `_ project on GitHub. -You are welcome to contribute. +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/pydantic/__init__.py b/pydantic/__init__.py index a11993e12..4dbbce515 100644 --- a/pydantic/__init__.py +++ b/pydantic/__init__.py @@ -1,3 +1,4 @@ from . import builder from . import models from . import registry +from . import ir_http diff --git a/pydantic/__manifest__.py b/pydantic/__manifest__.py index 616ca52d2..9ed053c80 100644 --- a/pydantic/__manifest__.py +++ b/pydantic/__manifest__.py @@ -17,6 +17,7 @@ "external_dependencies": { "python": [ "pydantic", + "contextvars", ] }, "installable": True, diff --git a/pydantic/builder.py b/pydantic/builder.py index 11d7d51a9..5b300bf88 100644 --- a/pydantic/builder.py +++ b/pydantic/builder.py @@ -13,19 +13,20 @@ from typing import List, Optional import odoo -from odoo import api, models +from odoo import api, models as omodels from .registry import PydanticClassesRegistry, _pydantic_classes_databases -class PydanticClassesBuilder(models.AbstractModel): +class PydanticClassesBuilder(omodels.AbstractModel): """Build the component classes And register them in a global registry. Every time an Odoo registry is built, the know pydantic models are cleared and rebuilt as well. The pydantic classes are built by taking every models with - a ``_name`` and applying pydantic models with an ``_inherits`` upon them. + a ``__xreg_name__`` and applying pydantic models with an ``__xreg_base_names__`` + upon them. The final pydantic classes are registered in global registry. @@ -47,7 +48,6 @@ def _register_hook(self): # registry so we have an empty cache and we'll add components in it. registry = self._init_global_registry() self.build_registry(registry) - registry.ready = True @api.model def _init_global_registry(self): @@ -80,25 +80,4 @@ def build_registry( module_list = [name for (name,) in self.env.cr.fetchall() if name not in graph] graph.add_modules(self.env.cr, module_list) - # Here we have a graph of installed modules. By iterating on the graph, - # we get the modules from the most generic one to the most specialized - # one. We walk through the graph to build the definition of the classes - # to assemble. The goal is to have for each class name the final - # picture of all the pieces required to build the right hierarchy. - # It's required to avoid to change the bases of an already build class - # each time a module extend the initial implementation as Odoo is - # doing with `Model`. The final definition of a class could depend on - # the potential metaclass associated to the class (a metaclass is a - # class factory). It's therefore not safe to modify on the fly - # the __bases__ attribute of a class once it's constructed since - # the factory method of the metaclass depends these 'bases' - # __new__(mcs, name, bases, new_namespace, **kwargs). - # 'bases' could therefore be processed by the factory in a way or an - # other to build the final class. If you modify the bases after the - # class creation, the logic implemented by the factory will not be - # applied to the new bases and your class could be in an incoherent - # state. - for module in graph: - registry.load_pydantic_classes(module) - registry.build_pydantic_classes() - registry.update_forward_refs() + registry.init_registry([m.name for m in graph]) diff --git a/pydantic/context.py b/pydantic/context.py new file mode 100644 index 000000000..02c012d04 --- /dev/null +++ b/pydantic/context.py @@ -0,0 +1,8 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +# define context vars to hold the pydantic registry + +from contextvars import ContextVar + +odoo_pydantic_registry = ContextVar("pydantic_registry") diff --git a/pydantic/ir_http.py b/pydantic/ir_http.py new file mode 100644 index 000000000..48a9d83c8 --- /dev/null +++ b/pydantic/ir_http.py @@ -0,0 +1,29 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from contextlib import contextmanager + +from odoo import models +from odoo.http import request + +from .context import odoo_pydantic_registry +from .registry import _pydantic_classes_databases + + +class IrHttp(models.AbstractModel): + _inherit = "ir.http" + + @classmethod + def _dispatch(cls): + with cls._pydantic_context_registry(): + return super()._dispatch() + + @classmethod + @contextmanager + def _pydantic_context_registry(cls): + registry = _pydantic_classes_databases.get(request.env.cr.dbname, {}) + token = odoo_pydantic_registry.set(registry) + try: + yield + finally: + odoo_pydantic_registry.reset(token) diff --git a/pydantic/models.py b/pydantic/models.py index 0b4127490..bf5f00b6e 100644 --- a/pydantic/models.py +++ b/pydantic/models.py @@ -1,102 +1,212 @@ # Copyright 2021 ACSONE SA/NV # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) - import collections -from typing import DefaultDict, List, TypeVar, Union +import functools +import inspect +from typing import Any, List, TypeVar, no_type_check + +try: + from typing import OrderedDict +except ImportError: + from typing import Dict as OrderedDict import pydantic +from pydantic.fields import ModelField +from pydantic.main import ModelMetaclass from pydantic.utils import ClassAttribute from . import utils +from .context import odoo_pydantic_registry ModelType = TypeVar("Model", bound="BaseModel") +_is_base_model_class_defined = False +_registry_build_mode = False -class BaseModel(pydantic.BaseModel): - _name: str = None - _inherit: Union[List[str], str] = None - _pydantic_classes_by_module: DefaultDict[ +class ExtendablePydanticModelMeta(ModelMetaclass): + @no_type_check + def __new__(cls, clsname, bases, namespace, extends=None, **kwargs): + """create a expected class and a fragment class that will + be assembled at the end of registry load process + to build the final class. + """ + if _is_base_model_class_defined: + registry_name = ".".join( + (namespace["__module__"], namespace["__qualname__"]) + ) + if extends: + # if we extends an other BaseModel, the registry name must + # be the one from the extended BaseModel + if not issubclass(extends, BaseModel): + raise TypeError( + f"Pyndatic class {registry_name} extends an non " + f"pytdantic class {extends.__name__} " + ) + registry_name = getattr(extends, "__xreg_name__", None) + registry_base_names = [ + b.__xreg_name__ + for b in bases + if issubclass(b, BaseModel) and b != BaseModel + ] + namespace.update( + { + "__xreg_name__": ClassAttribute("__xreg_name__", registry_name), + "__xreg_base_names__": ClassAttribute( + "__xreg_base_names__", registry_base_names + ), + } + ) + # for the original class, we wrap the class methods to forward + # the call to the aggregated one at runtime + new_namespace = cls._wrap_class_methods(namespace) + else: + # here we are into the instanciation fo BaseModel + # we must wrap all the classmethod defined into pydantic.BaseModel + new_namespace = cls._wrap_pydantic_base_model_class_methods(namespace) + assembly_frag_cls = None + if _is_base_model_class_defined and not _registry_build_mode: + # we are into the loading process of original BaseModel + # For each defined BaseModel class, we create and register the + # corresponding fragment to be aggregated into the final class + other_bases = [BaseModel] + [ + b for b in bases if not (issubclass(b, BaseModel)) + ] + namespace.update({"__qualname__": namespace["__qualname__"] + "Frag"}) + assembly_frag_cls = super().__new__( + cls, + name=clsname + "Frag", + bases=tuple(other_bases), + namespace=namespace, + **kwargs, + ) + assembly_frag_cls.__register__() + + # We build the Origial class + new_cls = super().__new__( + cls, name=clsname, bases=bases, namespace=new_namespace, **kwargs + ) + if assembly_frag_cls: + assembly_frag_cls._original_cls = ClassAttribute("_original_cls", new_cls) + return new_cls + + @classmethod + def _wrap_class_methods(cls, namespace): + new_namespace = {} + for key, value in namespace.items(): + if isinstance(value, classmethod): + func = value.__func__ + + def new_method( + cls, *args, _method_name=None, _initial_func=None, **kwargs + ): + # ensure that arggs and kwargs are conform to the + # initial signature + inspect.signature(_initial_func).bind(cls, *args, **kwargs) + return getattr(cls._get_assembled_cls(), _method_name)( + *args, **kwargs + ) + + new_method_def = functools.partial( + new_method, _method_name=key, _initial_func=func + ) + # preserve signature for IDE + functools.update_wrapper(new_method_def, func) + new_namespace[key] = classmethod(new_method_def) + else: + new_namespace[key] = value + return new_namespace + + @classmethod + def _wrap_pydantic_base_model_class_methods(cls, namespace): + new_namespace = namespace + methods = inspect.getmembers(pydantic.BaseModel, inspect.ismethod) + for name, method in methods: + func = method.__func__ + if name.startswith("__"): + continue + if name in namespace: + continue + + def new_method(cls, *args, _method_name=None, _initial_func=None, **kwargs): + # ensure that arggs and kwargs are conform to the + # initial signature + inspect.signature(_initial_func).bind(cls, *args, **kwargs) + if getattr(cls, "_is_aggregated_class", False) or hasattr( + cls, "_original_cls" + ): + return _initial_func(cls, *args, **kwargs) + cls = cls._get_assembled_cls() + return getattr(cls, _method_name)(*args, **kwargs) + + new_method_def = functools.partial( + new_method, _method_name=name, _initial_func=func + ) + # preserve signature for IDE + functools.update_wrapper(new_method_def, func) + new_namespace[name] = classmethod(new_method_def) + return new_namespace + + def __subclasscheck__(cls, subclass): # noqa: B902 + """Implement issubclass(sub, cls).""" + if hasattr(subclass, "_original_cls"): + return cls.__subclasscheck__(subclass._original_cls) + return super().__subclasscheck__(subclass) + + +class BaseModel(pydantic.BaseModel, metaclass=ExtendablePydanticModelMeta): + _pydantic_classes_by_module: OrderedDict[ str, List[ModelType] - ] = collections.defaultdict(list) + ] = collections.OrderedDict() - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - if cls != BaseModel: - cls.__normalize__definition__() - cls.__register__() + def __new__(cls, *args, **kwargs): + if getattr(cls, "_is_aggregated_class", False): + return super().__new__(cls) + return cls._get_assembled_cls()(*args, **kwargs) @classmethod - def __normalize__definition__(cls): - """Normalize class definition + def update_forward_refs(cls, **localns: Any) -> None: + for b in cls.__bases__: + if issubclass(b, BaseModel): + b.update_forward_refs(**localns) + super().update_forward_refs(**localns) + if hasattr(cls, "_original_cls"): + cls._original_cls.update_forward_refs(**localns) - Compute the module name - Compute and validate the model name if class is a subclass - of another BaseModel; - Ensure that _inherit is a list + @classmethod + def _resolve_submodel_fields(cls, registry: dict = None): + """ + Replace the original field type into the definition of the field + by the one from the registry """ - parents = cls._inherit - if isinstance(parents, str): - parents = [parents] - elif parents is None: - parents = [] - name = cls._name or (parents[0] if len(parents) == 1 else None) - if not name: - raise TypeError(f"Extended pydantic class {cls} must have a name") - cls._name = ClassAttribute("_module", name) - cls._module = ClassAttribute("_module", utils._get_addon_name(cls.__module__)) - cls.__config__.title = name - # all BaseModels except 'base' implicitly inherit from 'base' - if name != "base": - parents = list(parents) + ["base"] - cls._inherit = ClassAttribute("_inherit", parents) + registry = registry if registry else odoo_pydantic_registry.get() + for field in cls.__fields__.values(): + cls._resolve_submodel_field(field, registry) + + @classmethod + def _get_assembled_cls(cls, registry: dict = None) -> ModelType: + if getattr(cls, "_is_aggregated_class", False): + return cls + registry = registry if registry else odoo_pydantic_registry.get() + return registry[cls.__xreg_name__] + + @classmethod + def _resolve_submodel_field(cls, field: ModelField, registry: dict): + if issubclass(field.type_, BaseModel): + field.type_ = field.type_._get_assembled_cls(registry=registry) + field.prepare() + if field.sub_fields: + for sub_f in field.sub_fields: + cls._resolve_submodel_field(sub_f, registry) @classmethod def __register__(cls): """Register the class into the list of classes defined by the module""" if "tests" not in cls.__module__.split(":"): - cls._pydantic_classes_by_module[cls._module].append(cls) - - -class Base(BaseModel): - """This is the base pydantic BaseModel for every BaseModels - - It is implicitely inherited by all BaseModels. - - All your base are belong to us - """ - - _name = "base" - - -class OdooOrmMode(BaseModel): - """Generic model that can be used to instantiate pydantis model from - odoo models - - Usage: - - .. code-block:: python - - class UserInfo(models.BaseModel): - _name = "user" - _inherit = "odoo_orm_mode" - name: str - groups: List["group"] = pydantic.Field(alias="groups_id") - - - class Group(models.BaseModel): - _name="group" - _inherit = "odoo_orm_mode" - name: str - - user = self.env.user - UserInfoCls = self.env.pydantic_registry["user"] - user_info = UserInfoCls.from_orm(user) - - """ + module = utils._get_addon_name(cls.__module__) + if module not in cls._pydantic_classes_by_module: + cls._pydantic_classes_by_module[module] = [] + cls._pydantic_classes_by_module[module].append(cls) - _name = "odoo_orm_mode" - class Config: - orm_mode = True - getter_dict = utils.GenericOdooGetter +_is_base_model_class_defined = True diff --git a/pydantic/readme/USAGE.rst b/pydantic/readme/USAGE.rst index 9fc225a6d..0ffa5f54c 100644 --- a/pydantic/readme/USAGE.rst +++ b/pydantic/readme/USAGE.rst @@ -1,5 +1,5 @@ To define your own pydantic model you just need to create a class that inherits from -``odoo.addons.pydantic.models.BaseModel`` +``odoo.addons.pydantic.models.BaseModel`` or a subclass of. .. code-block:: python @@ -8,15 +8,11 @@ To define your own pydantic model you just need to create a class that inherits class PartnerShortInfo(BaseModel): - _name = "partner.short.info" id: str name: str class PartnerInfo(BaseModel): - _name = "partner.info" - _inherit = "partner.short.info" - street: str street2: str = None zip_code: str = None @@ -25,41 +21,99 @@ To define your own pydantic model you just need to create a class that inherits is_componay : bool = Field(None) -As for odoo models, you can extend the `base` pydantic model by inheriting of `base`. +In the preceding code, 2 new models are created, one for each class. If you +want to extend an existing model, you must pass the extended pydantic model +trough the `extends` parameter on class declaration. + +.. code-block:: python + + class Coordinate(models.BaseModel): + lat = 0.1 + lng = 10.1 + + class PartnerInfoWithCoordintate(PartnerInfo, extends=PartnerInfo): + coordinate: Coordinate = None + +`PartnerInfoWithCoordintate` extends `PartnerInfo`. IOW, Base class are now the +same and define the same fields and methods. They can be used indifferently into +the code. All the logic will be provided by the aggregated class. .. code-block:: python - class Base(BaseModel): - _inherit = "base" + partner1 = PartnerInfo.construct() + partner2 = PartnerInfoWithCoordintate.construct() - def _my_method(self): - pass + assert partner1.__class__ == partner2.__class__ + assert PartnerInfo.schema() == PartnerInfoWithCoordinate.schema() + +.. note:: + + Since validation occurs on instance creation, it's important to avoid to + create an instance of a Pydantic class by usign the normal instance + constructor `partner = PartnerInfo(..)`. In such a case, if the class is + extended by an other addon and a required field is added, this code will + no more work. It's therefore a good practice to use the `construct()` class + method to create a pydantic instance. + +.. caution:: + + Adding required fields to an existing data structure into an extension + addon violates the `Liskov substitution principle`_ and should generally + be avoided. This is certainly forbidden in requests data structures. + When extending response data structures this could be useful to document + new fields that are guaranteed to be present when extension addons are + installed. + +In contrast to Odoo, access to a Pydantic class is not done through a specific +registry. To use a Pydantic class, you just have to import it in your module +and write your code like in any other python application. + +.. code-block:: python + + from odoo.addons.my_addons.datamodels import PartnerInfo + from odoo import models + + class ResPartner(models.Basemodel): + _inherit = "res.partner" + + def to_json(self): + return [i._to_partner_info().json() for i in self] + + def _to_partner_info(self): + self.ensure_one() + pInfo = PartnerInfo.construct(id=self.id, name=self.name, street=self.street, city=self.city) + return pInfo -Pydantic model classes are available through the `pydantic_registry` registry provided by the Odoo's environment. To support pydantic models that map to Odoo models, Pydantic model instances can be created from arbitrary odoo model instances by mapping fields from odoo -models to fields defined by the pydantic model. To ease the mapping, -your pydantic model should inherit from 'odoo_orm_mode' +models to fields defined by the pydantic model. To ease the mapping, the addon +provide a utility class `odoo.addons.pydantic.utils.GenericOdooGetter`. .. code-block:: python - class UserInfo(models.BaseModel): - _name = "user" - _inherit = "odoo_orm_mode" - name: str - groups: List["group"] = pydantic.Field(alias="groups_id") + import pydantic + from odoo.addons.pydantic import models, utils + + class Group(models.BaseModel): + name: str + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter + + class UserInfo(models.BaseModel): + name: str + groups: List[Group] = pydantic.Field(alias="groups_id") - class Group(models.BaseModel): - _name="group" - _inherit = "odoo_orm_mode" - name: str + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter - user = self.env.user - UserInfoCls = self.env.pydantic_registry["user"] - user_info = UserInfoCls.from_orm(user) + user = self.env.user + user_info = UserInfo.from_orm(user) -See the official Pydantic documentation_ to discover all the available functionalities. +See the official `Pydantic documentation`_ to discover all the available functionalities. -.. _documentation: https://pydantic-docs.helpmanual.io/ +.. _`Liskov substitution principle`: https://en.wikipedia.org/wiki/Liskov_substitution_principle +.. _`Pydantic documentation`: https://pydantic-docs.helpmanual.io/ diff --git a/pydantic/registry.py b/pydantic/registry.py index bc367bb3a..03bec9d92 100644 --- a/pydantic/registry.py +++ b/pydantic/registry.py @@ -1,35 +1,37 @@ # Copyright 2021 ACSONE SA/NV # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) - +from contextlib import contextmanager from typing import Dict, List, Optional, Set -from odoo.api import Environment from odoo.tools import LastOrderedSet -from pydantic.typing import update_field_forward_refs +from pydantic.utils import ClassAttribute -from .models import BaseModel, ModelType +from . import models class PydanticClassDef(object): name: str = None - hierarchy: List[ModelType] = None + hierarchy: List[models.ModelType] = None base_names: Set[str] = None - def __init__(self, cls: ModelType): - self.name = cls._name + def __init__(self, cls): + self.name = cls.__xreg_name__ self.hierarchy = [cls] - self.base_names = set(cls._inherit or []) + self.base_names = set(cls.__xreg_base_names__ or []) - def add_child(self, cls: ModelType): + def add_child(self, cls): self.hierarchy.append(cls) - for base in cls._inherit: + for base in cls.__xreg_base_names__: self.base_names.add(base) @property - def is_mixed_bases(self) -> bool: + def is_mixed_bases(self): return set(self.name) != self.base_names + def __repr__(self): + return f"PydanticClassDef {self.name}" + class PydanticClassDefsRegistry(dict): pass @@ -42,7 +44,8 @@ class PydanticClassesDatabases(dict): class PydanticClassesRegistry(object): """Store all the PydanticClasses and allow to retrieve them by name - The key is the ``_name`` of the pydantic classes. + The key is the ``cls.__module__ + "." + cls.__qualname__`` of the + pydantic classes. The :attr:`ready` attribute must be set to ``True`` when all the pydantic classes are loaded. @@ -50,23 +53,25 @@ class PydanticClassesRegistry(object): """ def __init__(self): - self._pydantic_classes: Dict[str, ModelType] = {} + self._pydantic_classes: Dict[str, models.ModelType] = {} self._loaded_modules: Set[str] = set() self.ready: bool = False self._pydantic_class_defs: Dict[ str, PydanticClassDef ] = PydanticClassDefsRegistry() - def __getitem__(self, key: str) -> ModelType: + def __getitem__(self, key: str) -> models.ModelType: return self._pydantic_classes[key] - def __setitem__(self, key: str, value: ModelType): + def __setitem__(self, key: str, value: models.ModelType): self._pydantic_classes[key] = value def __contains__(self, key: str) -> bool: return key in self._pydantic_classes - def get(self, key: str, default: Optional[ModelType] = None) -> ModelType: + def get( + self, key: str, default: Optional[models.ModelType] = None + ) -> models.ModelType: return self._pydantic_classes.get(key, default) def __iter__(self): @@ -75,17 +80,19 @@ def __iter__(self): def load_pydantic_classes(self, module: str): if module in self._loaded_modules: return - for cls in BaseModel._pydantic_classes_by_module[module]: + for cls in models.BaseModel._pydantic_classes_by_module.get(module, []): self.load_pydantic_class_def(cls) self._loaded_modules.add(module) - def load_pydantic_class_def(self, cls: ModelType): - parents = cls._inherit - if cls._name in self and not parents: - raise TypeError(f"Pydantic {cls._name} (in class {cls}) already exists.") - class_def = self._pydantic_class_defs.get(cls._name) + def load_pydantic_class_def(self, cls: models.ModelType): + parents = cls.__xreg_base_names__ + if cls.__xreg_name__ in self and not parents: + raise TypeError( + f"Pydantic {cls.__xreg_name__} (in class {cls}) already exists." + ) + class_def = self._pydantic_class_defs.get(cls.__xreg_name__) if not class_def: - self._pydantic_class_defs[cls._name] = PydanticClassDef(cls) + self._pydantic_class_defs[cls.__xreg_name__] = PydanticClassDef(cls) else: class_def.add_child(cls) @@ -120,10 +127,10 @@ def build_pydantic_classes(self): if all_in_registry: self.build_pydantic_class(class_def) continue - remaining.append(name, class_def) + remaining.append((name, class_def)) to_build = remaining - def build_pydantic_class(self, class_def: PydanticClassDef) -> ModelType: + def build_pydantic_class(self, class_def: PydanticClassDef) -> models.ModelType: """ Build the class hierarchy from the first one to the last one into the hierachy definition. @@ -133,7 +140,7 @@ def build_pydantic_class(self, class_def: PydanticClassDef) -> ModelType: # retrieve pydantic_parent # determine all the classes the component should inherit from bases = LastOrderedSet([cls]) - for base_name in cls._inherit: + for base_name in cls.__xreg_base_names__: if base_name not in self: raise TypeError( f"Pydnatic class '{name}' extends an non-existing " @@ -141,15 +148,18 @@ def build_pydantic_class(self, class_def: PydanticClassDef) -> ModelType: ) parent_class = self[base_name] bases.add(parent_class) - - uniq_class_name = f"{name}_{id(cls)}" + simple_name = name.split(".")[-1] + uniq_class_name = f"{simple_name}_{id(cls)}" PydanticClass = type( - name, + simple_name, tuple(bases), { # attrs for pickle to find this class - "__module__": __name__, + "__module__": cls.__module__, "__qualname__": uniq_class_name, + "_is_aggregated_class": ClassAttribute( + "_is_aggregated_class", True + ), }, ) base = PydanticClass @@ -159,20 +169,59 @@ def build_pydantic_class(self, class_def: PydanticClassDef) -> ModelType: def update_forward_refs(self): """Try to update ForwardRefs on fields to resolve dynamic type usage.""" for cls in self._pydantic_classes.values(): - for f in cls.__fields__.values(): - update_field_forward_refs(f, {}, self) + cls.update_forward_refs() + def resolve_submodel_fields(self): + for cls in self._pydantic_classes.values(): + cls._resolve_submodel_fields(registry=self) -# We will store a PydanticClassestRegistry per database here, -# it will be cleared and updated when the odoo's registry is rebuilt -_pydantic_classes_databases = PydanticClassesDatabases() - + @contextmanager + def build_mode(self): + models._registry_build_mode = True + try: + yield + finally: + models._registry_build_mode = False -@property -def pydantic_registry(self): - if not hasattr(self, "_pydantic_registry"): - self._pydantic_registry = _pydantic_classes_databases.get(self.cr.dbname) - return self._pydantic_registry + def init_registry(self, modules: List[str] = None): + """ + Build the pydantic classes by aggregating the classes declared + in the given module list in the same as the list one. IOW, the mro + into the aggregated classes will be the inverse one of the given module + list. If no module list given, build the aggregated classes for all the + modules loaded by the metaclass in the same order as the loading process + """ + # Thes list of module should shoudl be build from the graph of module + # dependencies. The order of this list represent the list of modules + # from the most generic one to the most specialized one. + # We walk through the graph to build the definition of the classes + # to assemble. The goal is to have for each class name the final + # picture of all the fragments required to build the right hierarchy. + # It's required to avoid to change the bases of an already build class + # each time a module extend the initial implementation as Odoo is + # doing with `Model`. The final definition of a class could depend on + # the potential metaclass associated to the class (a metaclass is a + # class factory). It's therefore not safe to modify on the fly + # the __bases__ attribute of a class once it's constructed since + # the factory method of the metaclass depends on these 'bases' + # __new__(mcs, name, bases, new_namespace, **kwargs). + # 'bases' could therefore be processed by the factory in a way or an + # other to build the final class. If you modify the bases after the + # class creation, the logic implemented by the factory will not be + # applied to the new bases and your class could be in an incoherent + # state. + modules = ( + modules if modules else models.BaseModel._pydantic_classes_by_module.keys() + ) + with self.build_mode(): + for module in modules: + self.load_pydantic_classes(module) + self.build_pydantic_classes() + self.update_forward_refs() + self.resolve_submodel_fields() + self.ready = True -Environment.pydantic_registry = pydantic_registry +# We will store a PydanticClassestRegistry per database here, +# it will be cleared and updated when the odoo's registry is rebuilt +_pydantic_classes_databases = PydanticClassesDatabases() diff --git a/pydantic/static/description/index.html b/pydantic/static/description/index.html index 516de8f71..ce8dce2a4 100644 --- a/pydantic/static/description/index.html +++ b/pydantic/static/description/index.html @@ -367,7 +367,7 @@

Pydantic

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: LGPL-3 oca/rest-framework

+

Beta License: LGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runbot

This addon allows you to define inheritable Pydantic classes.

Table of contents

@@ -386,22 +386,18 @@

Pydantic

Usage

To define your own pydantic model you just need to create a class that inherits from -odoo.addons.pydantic.models.BaseModel

+odoo.addons.pydantic.models.BaseModel or a subclass of.

 from odoo.addons.pydantic.models import BaseModel
 from pydantic import Field
 
 
 class PartnerShortInfo(BaseModel):
-    _name = "partner.short.info"
     id: str
     name: str
 
 
 class PartnerInfo(BaseModel):
-    _name = "partner.info"
-    _inherit = "partner.short.info"
-
     street: str
     street2: str = None
     zip_code: str = None
@@ -409,37 +405,90 @@ 

Usage

phone: str = None is_componay : bool = Field(None)
-

As for odoo models, you can extend the base pydantic model by inheriting of base.

+

In the preceding code, 2 new models are created, one for each class. If you +want to extend an existing model, you must pass the extended pydantic model +trough the extends parameter on class declaration.

-class Base(BaseModel):
-    _inherit = "base"
+class Coordinate(models.BaseModel):
+    lat = 0.1
+    lng = 10.1
 
-    def _my_method(self):
-        pass
+class PartnerInfoWithCoordintate(PartnerInfo, extends=PartnerInfo):
+    coordinate: Coordinate = None
+
+

PartnerInfoWithCoordintate extends PartnerInfo. IOW, Base class are now the +same and define the same fields and methods. They can be used indifferently into +the code. All the logic will be provided by the aggregated class.

+
+partner1 = PartnerInfo.construct()
+partner2 = PartnerInfoWithCoordintate.construct()
+
+assert partner1.__class__ == partner2.__class__
+assert PartnerInfo.schema() == PartnerInfoWithCoordinate.schema()
+
+
+

Note

+

Since validation occurs on instance creation, it’s important to avoid to +create an instance of a Pydantic class by usign the normal instance +constructor partner = PartnerInfo(..). In such a case, if the class is +extended by an other addon and a required field is added, this code will +no more work. It’s therefore a good practice to use the construct() class +method to create a pydantic instance.

+
+
+

Caution!

+

Adding required fields to an existing data structure into an extension +addon violates the Liskov substitution principle and should generally +be avoided. This is certainly forbidden in requests data structures. +When extending response data structures this could be useful to document +new fields that are guaranteed to be present when extension addons are +installed.

+
+

In contrast to Odoo, access to a Pydantic class is not done through a specific +registry. To use a Pydantic class, you just have to import it in your module +and write your code like in any other python application.

+
+from odoo.addons.my_addons.datamodels import PartnerInfo
+from odoo import models
+
+class ResPartner(models.Basemodel):
+   _inherit = "res.partner"
+
+   def to_json(self):
+       return [i._to_partner_info().json() for i in self]
+
+   def _to_partner_info(self):
+       self.ensure_one()
+       pInfo = PartnerInfo.construct(id=self.id, name=self.name, street=self.street, city=self.city)
+       return pInfo
 
-

Pydantic model classes are available through the pydantic_registry registry provided by the Odoo’s environment.

To support pydantic models that map to Odoo models, Pydantic model instances can be created from arbitrary odoo model instances by mapping fields from odoo -models to fields defined by the pydantic model. To ease the mapping, -your pydantic model should inherit from ‘odoo_orm_mode’

+models to fields defined by the pydantic model. To ease the mapping, the addon +provide a utility class odoo.addons.pydantic.utils.GenericOdooGetter.

-class UserInfo(models.BaseModel):
-    _name = "user"
-    _inherit = "odoo_orm_mode"
+import pydantic
+from odoo.addons.pydantic import models, utils
+
+class Group(models.BaseModel):
     name: str
-    groups: List["group"] = pydantic.Field(alias="groups_id")
 
+    class Config:
+        orm_mode = True
+        getter_dict = utils.GenericOdooGetter
 
-class Group(models.BaseModel):
-    _name="group"
-    _inherit = "odoo_orm_mode"
+class UserInfo(models.BaseModel):
     name: str
+    groups: List[Group] = pydantic.Field(alias="groups_id")
+
+    class Config:
+        orm_mode = True
+        getter_dict = utils.GenericOdooGetter
 
 user = self.env.user
-UserInfoCls = self.env.pydantic_registry["user"]
-user_info = UserInfoCls.from_orm(user)
+user_info = UserInfo.from_orm(user)
 
-

See the official Pydantic documentation to discover all the available functionalities.

+

See the official Pydantic documentation to discover all the available functionalities.

Bug Tracker

-

Bugs are tracked on GitHub Issues. +

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us smashing it by providing a detailed and welcomed -feedback.

+feedback.

Do not contact contributors directly about support or help with technical issues.

@@ -471,10 +520,15 @@

Contributors

Maintainers

-

Current maintainer:

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

lmignon

-

This module is part of the oca/rest-framework project on GitHub.

-

You are welcome to contribute.

+

This module is part of the OCA/rest-framework project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

diff --git a/pydantic/tests/common.py b/pydantic/tests/common.py index 0abf31268..815d1fc1a 100644 --- a/pydantic/tests/common.py +++ b/pydantic/tests/common.py @@ -9,6 +9,7 @@ from odoo.tests import common from .. import registry, utils +from ..context import odoo_pydantic_registry from ..models import BaseModel @@ -39,17 +40,17 @@ def setUpPydantic(cls): builder.build_registry(pydantic_registry, states=("installed",)) # build the pydantic classes of the current tested addon current_addon = utils._get_addon_name(cls.__module__) - builder.build_classes(current_addon, pydantic_registry) + pydantic_registry.init_registry([current_addon]) # pylint: disable=W8106 def setUp(self): # should be ready only during tests, never during installation # of addons - self._pydantics_registry.ready = True + token = odoo_pydantic_registry.set(self._pydantics_registry) @self.addCleanup def notready(): - self._pydantics_registry.ready = False + odoo_pydantic_registry.reset(token) class TransactionPydanticCase(common.TransactionCase, PydanticMixin): @@ -140,7 +141,7 @@ def setUp(self): # it will be our temporary pydantic registry for our test session self.pydantic_registry = registry.PydanticClassesRegistry() - # it builds the 'final pydantic' for every pydantic of the + # it builds the 'final pydantic' class for every pydantic class of the # 'pydantic' addon and push them in the pydantic registry self.pydantic_registry.load_pydantic_classes("pydantic") # build the pydantic classes of every installed addons already installed @@ -174,6 +175,12 @@ def _close_and_roolback(): # the pydantic classes registry, but we don't mind for the tests. self.pydantic_registry.ready = True + token = odoo_pydantic_registry.set(self.pydantic_registry) + + @self.addCleanup + def notready(): + odoo_pydantic_registry.reset(token) + def tearDown(self): super(PydanticRegistryCase, self).tearDown() self.restore_registry() @@ -182,10 +189,12 @@ def _load_module_pydantics(self, module): self.pydantic_registry.load_pydantics(module) def _build_pydantic_classes(self, *classes): - for cls in classes: - self.pydantic_registry.load_pydantic_class_def(cls) - self.pydantic_registry.build_pydantic_classes() - self.pydantic_registry.update_forward_refs() + with self.pydantic_registry.build_mode(): + for cls in classes: + self.pydantic_registry.load_pydantic_class_def(cls) + self.pydantic_registry.build_pydantic_classes() + self.pydantic_registry.update_forward_refs() + self.pydantic_registry.resolve_submodel_fields() def backup_registry(self): self._original_classes_by_module = collections.defaultdict(list) diff --git a/pydantic/tests/test_pydantic.py b/pydantic/tests/test_pydantic.py index a7eab8fc7..76d2133ef 100644 --- a/pydantic/tests/test_pydantic.py +++ b/pydantic/tests/test_pydantic.py @@ -5,22 +5,20 @@ import pydantic -from .. import models -from .common import PydanticRegistryCase, TransactionPydanticRegistryCase +from .. import models, utils +from .common import PydanticRegistryCase class TestPydantic(PydanticRegistryCase): def test_simple_inheritance(self): class Location(models.BaseModel): - _name = "location" lat = 0.1 lng = 10.1 def test(self) -> str: return "location" - class ExtendedLocation(models.BaseModel): - _inherit = "location" + class ExtendedLocation(Location, extends=Location): name: str def test(self, return_super: bool = False) -> str: @@ -29,111 +27,130 @@ def test(self, return_super: bool = False) -> str: return "extended" self._build_pydantic_classes(Location, ExtendedLocation) - ClsLocation = self.pydantic_registry["location"] + ClsLocation = self.pydantic_registry[Location.__xreg_name__] self.assertTrue(issubclass(ClsLocation, ExtendedLocation)) self.assertTrue(issubclass(ClsLocation, Location)) - properties = ClsLocation.schema().get("properties", {}).keys() - self.assertSetEqual({"lat", "lng", "name"}, set(properties)) - location = ClsLocation(name="name", lng=5.0, lat=4.2) - self.assertDictEqual(location.dict(), {"lat": 4.2, "lng": 5.0, "name": "name"}) - self.assertEqual(location.test(), "extended") - self.assertEqual(location.test(return_super=True), "location") + + # check that the behaviour is the same for all the definitions + # of the same model... + classes = Location, ExtendedLocation, ClsLocation + for cls in classes: + schema = cls.schema() + properties = schema.get("properties", {}).keys() + self.assertEqual(schema.get("title"), "Location") + self.assertSetEqual({"lat", "lng", "name"}, set(properties)) + location = cls(name="name", lng=5.0, lat=4.2) + self.assertDictEqual( + location.dict(), {"lat": 4.2, "lng": 5.0, "name": "name"} + ) + self.assertEqual(location.test(), "extended") + self.assertEqual(location.test(return_super=True), "location") def test_composite_inheritance(self): class Coordinate(models.BaseModel): - _name = "coordinate" lat = 0.1 lng = 10.1 class Name(models.BaseModel): - _name = "name" name: str - class Location(models.BaseModel): - _name = "location" - _inherit = ["name", "coordinate"] + class Location(Coordinate, Name): + pass self._build_pydantic_classes(Coordinate, Name, Location) - self.assertIn("coordinate", self.pydantic_registry) - self.assertIn("name", self.pydantic_registry) - self.assertIn("location", self.pydantic_registry) - ClsLocation = self.pydantic_registry["location"] + ClsLocation = self.pydantic_registry[Location.__xreg_name__] self.assertTrue(issubclass(ClsLocation, Coordinate)) self.assertTrue(issubclass(ClsLocation, Name)) - properties = ClsLocation.schema().get("properties", {}).keys() - self.assertSetEqual({"lat", "lng", "name"}, set(properties)) - location = ClsLocation(name="name", lng=5.0, lat=4.2) - self.assertDictEqual(location.dict(), {"lat": 4.2, "lng": 5.0, "name": "name"}) - def test_model_relation(self): - class Person(models.BaseModel): - _name = "person" - name: str - coordinate: "coordinate" - - class Coordinate(models.BaseModel): - _name = "coordinate" - lat = 0.1 - lng = 10.1 - - self._build_pydantic_classes(Person, Coordinate) - self.assertIn("coordinate", self.pydantic_registry) - self.assertIn("person", self.pydantic_registry) - ClsPerson = self.pydantic_registry["person"] - ClsCoordinate = self.pydantic_registry["coordinate"] - person = ClsPerson(name="test", coordinate={"lng": 5.0, "lat": 4.2}) - coordinate = person.coordinate - self.assertTrue(isinstance(coordinate, Coordinate)) - # sub schema are stored into the definition property - definitions = ClsPerson.schema().get("definitions", {}) - self.assertIn("coordinate", definitions) - self.assertDictEqual(definitions["coordinate"], ClsCoordinate.schema()) - - def test_inherit_bases(self): - """ Check all BaseModels inherit from base """ + # check that the behaviour is the same for all the definitions + # of the same model... + classes = Location, ClsLocation + for cls in classes: + properties = cls.schema().get("properties", {}).keys() + self.assertSetEqual({"lat", "lng", "name"}, set(properties)) + location = cls(name="name", lng=5.0, lat=4.2) + self.assertDictEqual( + location.dict(), {"lat": 4.2, "lng": 5.0, "name": "name"} + ) + def test_model_relation(self): class Coordinate(models.BaseModel): - _name = "coordinate" lat = 0.1 lng = 10.1 - class Base(models.BaseModel): - _inherit = "base" - title: str = "My title" - - self._build_pydantic_classes(Coordinate, Base) - self.assertIn("coordinate", self.pydantic_registry) - self.assertIn("base", self.pydantic_registry) - ClsCoordinate = self.pydantic_registry["coordinate"] - self.assertTrue(issubclass(ClsCoordinate, models.Base)) - properties = ClsCoordinate.schema().get("properties", {}).keys() - self.assertSetEqual({"lat", "lng", "title"}, set(properties)) + class Person(models.BaseModel): + name: str + coordinate: Coordinate + + class ExtendedCoordinate(Coordinate, extends=Coordinate): + country: str = None + + self._build_pydantic_classes(Person, Coordinate, ExtendedCoordinate) + ClsPerson = self.pydantic_registry[Person.__xreg_name__] + + # check that the behaviour is the same for all the definitions + # of the same model... + classes = Person, ClsPerson + for cls in classes: + person = cls( + name="test", + coordinate={"lng": 5.0, "lat": 4.2, "country": "belgium"}, + ) + coordinate = person.coordinate + self.assertTrue(isinstance(coordinate, Coordinate)) + # sub schema are stored into the definition property + definitions = ClsPerson.schema().get("definitions", {}) + self.assertIn("Coordinate", definitions) + coordinate_properties = ( + definitions["Coordinate"].get("properties", {}).keys() + ) + self.assertSetEqual({"lat", "lng", "country"}, set(coordinate_properties)) def test_from_orm(self): - class User(models.BaseModel): - _name = "user" - _inherit = "odoo_orm_mode" + class Group(models.BaseModel): name: str - groups: List["group"] = pydantic.Field(alias="groups_id") # noqa: F821 - class Group(models.BaseModel): - _name = "group" - _inherit = "odoo_orm_mode" + class User(models.BaseModel): name: str + groups: List[Group] = pydantic.Field(alias="groups_id") # noqa: F821 + + class OrmMode(models.BaseModel): + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter - self._build_pydantic_classes(User, Group) - ClsUser = self.pydantic_registry["user"] + class GroupOrm(Group, OrmMode, extends=Group): + pass + + class UserOrm(User, OrmMode, extends=User): + pass + + self._build_pydantic_classes(Group, User, OrmMode, GroupOrm, UserOrm) + ClsUser = self.pydantic_registry[User.__xreg_name__] + + # check that the behaviour is the same for all the definitions + # of the same model... + classes = User, UserOrm, ClsUser odoo_user = self.env.user - user = ClsUser.from_orm(odoo_user) - expected = { - "name": odoo_user.name, - "groups": [{"name": g.name} for g in odoo_user.groups_id], - } - self.assertDictEqual(user.dict(), expected) - - -class TestRegistryAccess(TransactionPydanticRegistryCase): - def test_registry_access(self): - """Check the access to the registry directly on Env""" - base = self.env.pydantic_registry["base"] - self.assertIsInstance(base(), models.BaseModel) + for cls in classes: + user = cls.from_orm(odoo_user) + expected = { + "name": odoo_user.name, + "groups": [{"name": g.name} for g in odoo_user.groups_id], + } + self.assertDictEqual(user.dict(), expected) + + def test_instance(self): + class Location(models.BaseModel): + lat = 0.1 + lng = 10.1 + + class ExtendedLocation(Location, extends=Location): + name: str + + self._build_pydantic_classes(Location, ExtendedLocation) + + inst1 = Location.construct() + inst2 = ExtendedLocation.construct() + self.assertEqual(inst1.__class__, inst2.__class__) + self.assertEqual(inst1.schema(), inst2.schema()) diff --git a/pydantic/utils.py b/pydantic/utils.py index bee64c9e7..20ee877ca 100644 --- a/pydantic/utils.py +++ b/pydantic/utils.py @@ -23,26 +23,23 @@ class GenericOdooGetter(GetterDict): import pydantic from odoo.addons.pydantic import models, utils - class UserInfo(models.BaseModel): - _name = "user" + class Group(models.BaseModel): name: str - groups: List["group"] = pydantic.Field(alias="groups_id") class Config: orm_mode = True getter_dict = utils.GenericOdooGetter - class Group(models.BaseModel): - _name="group" + class UserInfo(models.BaseModel): name: str + groups: List[Group] = pydantic.Field(alias="groups_id") class Config: orm_mode = True getter_dict = utils.GenericOdooGetter user = self.env.user - UserInfoCls = self.env.pydantic_registry["user"] - user_info = UserInfoCls.from_orm(user) + user_info = UserInfo.from_orm(user) To avoid having to repeat the specific configuration required for the `from_orm` method into each pydantic model, "odoo_orm_mode" can be used diff --git a/requirements.txt b/requirements.txt index 700ae5ca0..20c95515f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ apispec apispec>=4.0.0 cerberus +contextvars;python_version<"3.7" jsondiff marshmallow marshmallow-objects>=2.0.0 diff --git a/setup/pydantic/setup.py b/setup/pydantic/setup.py index 28c57bb64..3665ea16f 100644 --- a/setup/pydantic/setup.py +++ b/setup/pydantic/setup.py @@ -2,5 +2,9 @@ setuptools.setup( setup_requires=['setuptools-odoo'], - odoo_addon=True, + odoo_addon={ + "external_dependencies_override": { + "python": {"contextvars": 'contextvars;python_version<"3.7"'} + } + }, )