diff --git a/pydantic/README.rst b/pydantic/README.rst new file mode 100644 index 000000000..3dbbe94dc --- /dev/null +++ b/pydantic/README.rst @@ -0,0 +1,141 @@ +======== +Pydantic +======== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |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| + +This addon allows you to define inheritable `Pydantic classes `_. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To define your own pydantic model you just need to create a class that inherits from +``odoo.addons.pydantic.models.BaseModel`` + +.. code-block:: python + + 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 + city: str + phone: str = None + is_componay : bool = Field(None) + + +As for odoo models, you can extend the `base` pydantic model by inheriting of `base`. + +.. code-block:: python + + class Base(BaseModel): + _inherit = "base" + + def _my_method(self): + pass + +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' + +.. 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) + +See the official Pydantic documentation_ to discover all the available functionalities. + +.. _documentation: https://pydantic-docs.helpmanual.io/ + +Known issues / Roadmap +====================== + +The `roadmap `_ +and `known issues `_ can +be found on GitHub. + +Bug Tracker +=========== + +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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Laurent Mignon + +Maintainers +~~~~~~~~~~~ + +.. |maintainer-lmignon| image:: https://github.com/lmignon.png?size=40px + :target: https://github.com/lmignon + :alt: lmignon + +Current maintainer: + +|maintainer-lmignon| + +This module is part of the `oca/rest-framework `_ project on GitHub. + +You are welcome to contribute. diff --git a/pydantic/__init__.py b/pydantic/__init__.py new file mode 100644 index 000000000..a11993e12 --- /dev/null +++ b/pydantic/__init__.py @@ -0,0 +1,3 @@ +from . import builder +from . import models +from . import registry diff --git a/pydantic/__manifest__.py b/pydantic/__manifest__.py new file mode 100644 index 000000000..616ca52d2 --- /dev/null +++ b/pydantic/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +{ + "name": "Pydantic", + "summary": """ + Enhance pydantic to allow model extension""", + "version": "14.0.1.0.0", + "development_status": "Beta", + "license": "LGPL-3", + "maintainers": ["lmignon"], + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/rest-framework", + "depends": [], + "data": [], + "demo": [], + "external_dependencies": { + "python": [ + "pydantic", + ] + }, + "installable": True, +} diff --git a/pydantic/builder.py b/pydantic/builder.py new file mode 100644 index 000000000..11d7d51a9 --- /dev/null +++ b/pydantic/builder.py @@ -0,0 +1,104 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +""" + +Pydantic Models Builder +======================= + +Build the pydantic models at the build of a registry by resolving the +inheritance declaration and ForwardRefs type declaration into the models + +""" +from typing import List, Optional + +import odoo +from odoo import api, models + +from .registry import PydanticClassesRegistry, _pydantic_classes_databases + + +class PydanticClassesBuilder(models.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. + + The final pydantic classes are registered in global registry. + + This class is an Odoo model, allowing us to hook the build of the + pydantic classes at the end of the Odoo's registry loading, using + ``_register_hook``. This method is called after all modules are loaded, so + we are sure that we have all the components Classes and in the correct + order. + + """ + + _name = "pydantic.classes.builder" + _description = "Pydantic Classes Builder" + + def _register_hook(self): + # This method is called by Odoo when the registry is built, + # so in case the registry is rebuilt (cache invalidation, ...), + # we have to to rebuild the components. We use a new + # 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): + registry = PydanticClassesRegistry() + _pydantic_classes_databases[self.env.cr.dbname] = registry + return registry + + @api.model + def build_registry( + self, + registry: PydanticClassesRegistry, + states: Optional[List[str]] = None, + exclude_addons: Optional[List[str]] = None, + ): + if not states: + states = ("installed", "to upgrade") + # lookup all the installed (or about to be) addons and generate + # the graph, so we can load the components following the order + # of the addons' dependencies + graph = odoo.modules.graph.Graph() + graph.add_module(self.env.cr, "base") + + query = "SELECT name " "FROM ir_module_module " "WHERE state IN %s " + params = [tuple(states)] + if exclude_addons: + query += " AND name NOT IN %s " + params.append(tuple(exclude_addons)) + self.env.cr.execute(query, params) + + 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() diff --git a/pydantic/models.py b/pydantic/models.py new file mode 100644 index 000000000..0b4127490 --- /dev/null +++ b/pydantic/models.py @@ -0,0 +1,102 @@ +# 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 pydantic +from pydantic.utils import ClassAttribute + +from . import utils + +ModelType = TypeVar("Model", bound="BaseModel") + + +class BaseModel(pydantic.BaseModel): + _name: str = None + _inherit: Union[List[str], str] = None + + _pydantic_classes_by_module: DefaultDict[ + str, List[ModelType] + ] = collections.defaultdict(list) + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if cls != BaseModel: + cls.__normalize__definition__() + cls.__register__() + + @classmethod + def __normalize__definition__(cls): + """Normalize class definition + + Compute the module name + Compute and validate the model name if class is a subclass + of another BaseModel; + Ensure that _inherit is a list + """ + 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) + + @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) + + """ + + _name = "odoo_orm_mode" + + class Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter diff --git a/pydantic/readme/CONTRIBUTORS.rst b/pydantic/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..172b2d223 --- /dev/null +++ b/pydantic/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Laurent Mignon diff --git a/pydantic/readme/DESCRIPTION.rst b/pydantic/readme/DESCRIPTION.rst new file mode 100644 index 000000000..3a8358b6a --- /dev/null +++ b/pydantic/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This addon allows you to define inheritable `Pydantic classes `_. diff --git a/pydantic/readme/ROADMAP.rst b/pydantic/readme/ROADMAP.rst new file mode 100644 index 000000000..0778bc3aa --- /dev/null +++ b/pydantic/readme/ROADMAP.rst @@ -0,0 +1,3 @@ +The `roadmap `_ +and `known issues `_ can +be found on GitHub. diff --git a/pydantic/readme/USAGE.rst b/pydantic/readme/USAGE.rst new file mode 100644 index 000000000..9fc225a6d --- /dev/null +++ b/pydantic/readme/USAGE.rst @@ -0,0 +1,65 @@ +To define your own pydantic model you just need to create a class that inherits from +``odoo.addons.pydantic.models.BaseModel`` + +.. code-block:: python + + 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 + city: str + phone: str = None + is_componay : bool = Field(None) + + +As for odoo models, you can extend the `base` pydantic model by inheriting of `base`. + +.. code-block:: python + + class Base(BaseModel): + _inherit = "base" + + def _my_method(self): + pass + +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' + +.. 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) + +See the official Pydantic documentation_ to discover all the available functionalities. + +.. _documentation: https://pydantic-docs.helpmanual.io/ diff --git a/pydantic/registry.py b/pydantic/registry.py new file mode 100644 index 000000000..bc367bb3a --- /dev/null +++ b/pydantic/registry.py @@ -0,0 +1,178 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +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 .models import BaseModel, ModelType + + +class PydanticClassDef(object): + name: str = None + hierarchy: List[ModelType] = None + base_names: Set[str] = None + + def __init__(self, cls: ModelType): + self.name = cls._name + self.hierarchy = [cls] + self.base_names = set(cls._inherit or []) + + def add_child(self, cls: ModelType): + self.hierarchy.append(cls) + for base in cls._inherit: + self.base_names.add(base) + + @property + def is_mixed_bases(self) -> bool: + return set(self.name) != self.base_names + + +class PydanticClassDefsRegistry(dict): + pass + + +class PydanticClassesDatabases(dict): + """ Holds a registry of pydantic classes for each database """ + + +class PydanticClassesRegistry(object): + """Store all the PydanticClasses and allow to retrieve them by name + + The key is the ``_name`` of the pydantic classes. + + The :attr:`ready` attribute must be set to ``True`` when all the pydantic classes + are loaded. + + """ + + def __init__(self): + self._pydantic_classes: Dict[str, 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: + return self._pydantic_classes[key] + + def __setitem__(self, key: str, value: 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: + return self._pydantic_classes.get(key, default) + + def __iter__(self): + return iter(self._pydantic_classes) + + def load_pydantic_classes(self, module: str): + if module in self._loaded_modules: + return + for cls in BaseModel._pydantic_classes_by_module[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) + if not class_def: + self._pydantic_class_defs[cls._name] = PydanticClassDef(cls) + else: + class_def.add_child(cls) + + def build_pydantic_classes(self): + """, + We iterate over all the class definitions and build the final + hierarchy. + """ + # we first check that all bases are defined + for class_def in self._pydantic_class_defs.values(): + for base in class_def.base_names: + if base not in self._pydantic_class_defs: + raise TypeError( + f"Pydantic class '{class_def.name}' inherits from" + f"undefined base '{base}'" + ) + to_build = self._pydantic_class_defs.items() + while to_build: + remaining = [] + for name, class_def in self._pydantic_class_defs.items(): + if not class_def.is_mixed_bases: + self.build_pydantic_class(class_def) + continue + # Generate only class with all the bases into the registry + all_in_registry = True + for base in class_def.base_names: + if base == name: + continue + if base not in self: + all_in_registry = False + break + if all_in_registry: + self.build_pydantic_class(class_def) + continue + remaining.append(name, class_def) + to_build = remaining + + def build_pydantic_class(self, class_def: PydanticClassDef) -> ModelType: + """ + Build the class hierarchy from the first one to the last one into + the hierachy definition. + """ + name = class_def.name + for cls in class_def.hierarchy: + # retrieve pydantic_parent + # determine all the classes the component should inherit from + bases = LastOrderedSet([cls]) + for base_name in cls._inherit: + if base_name not in self: + raise TypeError( + f"Pydnatic class '{name}' extends an non-existing " + f"pydantic class '{base_name}'." + ) + parent_class = self[base_name] + bases.add(parent_class) + + uniq_class_name = f"{name}_{id(cls)}" + PydanticClass = type( + name, + tuple(bases), + { + # attrs for pickle to find this class + "__module__": __name__, + "__qualname__": uniq_class_name, + }, + ) + base = PydanticClass + self[name] = base + return base + + 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) + + +# 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() + + +@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 + + +Environment.pydantic_registry = pydantic_registry diff --git a/pydantic/static/description/icon.png b/pydantic/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/pydantic/static/description/icon.png differ diff --git a/pydantic/static/description/index.html b/pydantic/static/description/index.html new file mode 100644 index 000000000..516de8f71 --- /dev/null +++ b/pydantic/static/description/index.html @@ -0,0 +1,482 @@ + + + + + + +Pydantic + + + +
+

Pydantic

+ + +

Beta License: LGPL-3 oca/rest-framework

+

This addon allows you to define inheritable Pydantic classes.

+

Table of contents

+ +
+

Usage

+

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

+
+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
+    city: str
+    phone: str = None
+    is_componay : bool = Field(None)
+
+

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

+
+class Base(BaseModel):
+    _inherit = "base"
+
+    def _my_method(self):
+        pass
+
+

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’

+
+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)
+
+

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

+
+
+

Known issues / Roadmap

+

The roadmap +and known issues can +be found on GitHub.

+
+
+

Bug Tracker

+

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.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

Current maintainer:

+

lmignon

+

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

+

You are welcome to contribute.

+
+
+
+ + diff --git a/pydantic/tests/__init__.py b/pydantic/tests/__init__.py new file mode 100644 index 000000000..caaa1df26 --- /dev/null +++ b/pydantic/tests/__init__.py @@ -0,0 +1 @@ +from . import test_pydantic diff --git a/pydantic/tests/common.py b/pydantic/tests/common.py new file mode 100644 index 000000000..0abf31268 --- /dev/null +++ b/pydantic/tests/common.py @@ -0,0 +1,232 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +import collections +from contextlib import contextmanager + +import odoo +from odoo import api +from odoo.tests import common + +from .. import registry, utils +from ..models import BaseModel + + +@contextmanager +def new_rollbacked_env(): + registry = odoo.registry(common.get_db_name()) + uid = odoo.SUPERUSER_ID + cr = registry.cursor() + try: + yield api.Environment(cr, uid, {}) + finally: + cr.rollback() # we shouldn't have to commit anything + cr.close() + + +class PydanticMixin(object): + @classmethod + def setUpPydantic(cls): + with new_rollbacked_env() as env: + builder = env["pydantic.classes.builder"] + # build the pydantic classes of every installed addons + pydantic_registry = builder._init_global_registry() + cls._pydantics_registry = pydantic_registry + # ensure that we load only the pydantic classes of the 'installed' + # modules, not 'to install', which means we load only the + # dependencies of the tested addons, not the siblings or + # chilren addons + 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) + + # pylint: disable=W8106 + def setUp(self): + # should be ready only during tests, never during installation + # of addons + self._pydantics_registry.ready = True + + @self.addCleanup + def notready(): + self._pydantics_registry.ready = False + + +class TransactionPydanticCase(common.TransactionCase, PydanticMixin): + """A TransactionCase that loads all the pydantic classes + + It it used like an usual Odoo's TransactionCase, but it ensures + that all the pydantic classes of the current addon and its dependencies + are loaded. + + """ + + @classmethod + def setUpClass(cls): + super(TransactionPydanticCase, cls).setUpClass() + cls.setUpPydantic() + + # pylint: disable=W8106 + def setUp(self): + # resolve an inheritance issue (common.TransactionCase does not call + # super) + common.TransactionCase.setUp(self) + PydanticMixin.setUp(self) + + +class SavepointPydanticCase(common.SavepointCase, PydanticMixin): + """A SavepointCase that loads all the pydantic classes + + It is used like an usual Odoo's SavepointCase, but it ensures + that all the pydantic classes of the current addon and its dependencies + are loaded. + + """ + + @classmethod + def setUpClass(cls): + super(SavepointPydanticCase, cls).setUpClass() + cls.setUpPydantic() + + # pylint: disable=W8106 + def setUp(self): + # resolve an inheritance issue (common.SavepointCase does not call + # super) + common.SavepointCase.setUp(self) + PydanticMixin.setUp(self) + + +class PydanticRegistryCase( + common.TreeCase, common.MetaCase("DummyCase", (object,), {}) +): + """This test case can be used as a base for writings tests on pydantic classes + + This test case is meant to test pydantic classes in a special pydantic registry, + where you want to have maximum control on which pydantic classes are loaded + or not, or when you want to create additional pydantic classes in your tests. + + If you only want to *use* the pydantic classes of the tested addon in your tests, + then consider using one of: + + * :class:`TransactionPydanticCase` + * :class:`SavepointPydanticCase` + + This test case creates a special + :class:`odoo.addons.pydantic.registry.PydanticClassesRegistry` for the purpose of + the tests. By default, it loads all the pydantic classes of the dependencies, but + not the pydantic classes of the current addon (which you have to handle + manually). In your tests, you can add more pydantic classes in 2 manners. + + All the pydantic classes of an Odoo module:: + + self._load_module_pydantics('my_addon') + + Only specific pydantic classes:: + + self._build_pydantic_classes(MyPydantic1, MyPydantic2) + + Note: for the lookups of the pydantic classes, the default pydantic + registry is a global registry for the database. Here, you will + need to explicitly pass ``self.pydantic_registry`` + """ + + def setUp(self): + super(PydanticRegistryCase, self).setUp() + + # keep the original classes registered by the annotation + # so we'll restore them at the end of the tests, it avoid + # to pollute it with Stub / Test pydantic classes + self.backup_registry() + # 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 + # '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 + # but the current addon (when running with pytest/nosetest, we + # simulate the --test-enable behavior by excluding the current addon + # which is in 'to install' / 'to upgrade' with --test-enable). + current_addon = utils._get_addon_name(self.__module__) + + odoo_registry = odoo.registry(common.get_db_name()) + uid = odoo.SUPERUSER_ID + cr = odoo_registry.cursor() + env = api.Environment(cr, uid, {}) + env["pydantic.classes.builder"].build_registry( + self.pydantic_registry, + states=("installed",), + exclude_addons=[current_addon], + ) + self.env = env + registry._pydantic_classes_databases[ + self.env.cr.dbname + ] = self.pydantic_registry + + @self.addCleanup + def _close_and_roolback(): + cr.rollback() # we shouldn't have to commit anything + cr.close() + + # Fake that we are ready to work with the registry + # normally, it is set to True and the end of the build + # of the pydantic classes. Here, we'll add pydantic classes later in + # the pydantic classes registry, but we don't mind for the tests. + self.pydantic_registry.ready = True + + def tearDown(self): + super(PydanticRegistryCase, self).tearDown() + self.restore_registry() + + 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() + + def backup_registry(self): + self._original_classes_by_module = collections.defaultdict(list) + for k, v in BaseModel._pydantic_classes_by_module.items(): + self._original_classes_by_module[k] = [i for i in v] + self._original_registry = registry._pydantic_classes_databases.get( + common.get_db_name() + ) + + def restore_registry(self): + BaseModel._pydantic_classes_by_module = self._original_classes_by_module + registry._pydantic_classes_databases[ + common.get_db_name() + ] = self._original_registry + + +class TransactionPydanticRegistryCase(common.TransactionCase, PydanticRegistryCase): + """ Adds Odoo Transaction in the base Pydantic TestCase """ + + # pylint: disable=W8106 + def setUp(self): + # resolve an inheritance issue (common.TransactionCase does not use + # super) + common.TransactionCase.setUp(self) + PydanticRegistryCase.setUp(self) + + def teardown(self): + common.TransactionCase.tearDown(self) + PydanticRegistryCase.tearDown(self) + + +class SavepointPydanticRegistryCase(common.SavepointCase, PydanticRegistryCase): + """ Adds Odoo Transaction with Savepoint in the base Pydantic TestCase """ + + # pylint: disable=W8106 + def setUp(self): + # resolve an inheritance issue (common.SavepointCase does not use + # super) + common.SavepointCase.setUp(self) + PydanticRegistryCase.setUp(self) + + def teardown(self): + common.SavepointCase.tearDown(self) + PydanticRegistryCase.tearDown(self) diff --git a/pydantic/tests/test_pydantic.py b/pydantic/tests/test_pydantic.py new file mode 100644 index 000000000..a7eab8fc7 --- /dev/null +++ b/pydantic/tests/test_pydantic.py @@ -0,0 +1,139 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from typing import List + +import pydantic + +from .. import models +from .common import PydanticRegistryCase, TransactionPydanticRegistryCase + + +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" + name: str + + def test(self, return_super: bool = False) -> str: + if return_super: + return super(ExtendedLocation, self).test() + return "extended" + + self._build_pydantic_classes(Location, ExtendedLocation) + ClsLocation = self.pydantic_registry["location"] + 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") + + 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"] + + 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"] + 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 """ + + 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)) + + def test_from_orm(self): + class User(models.BaseModel): + _name = "user" + _inherit = "odoo_orm_mode" + name: str + groups: List["group"] = pydantic.Field(alias="groups_id") # noqa: F821 + + class Group(models.BaseModel): + _name = "group" + _inherit = "odoo_orm_mode" + name: str + + self._build_pydantic_classes(User, Group) + ClsUser = self.pydantic_registry["user"] + 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) diff --git a/pydantic/utils.py b/pydantic/utils.py new file mode 100644 index 000000000..bee64c9e7 --- /dev/null +++ b/pydantic/utils.py @@ -0,0 +1,75 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from typing import Any + +from odoo import models + +from pydantic.utils import GetterDict + + +class GenericOdooGetter(GetterDict): + """A generic GetterDict for Odoo models + + The getter take care of casting one2many and many2many + field values to python list to allow the from_orm method from + pydantic class to work on odoo models. This getter is to specify + into the pydantic config. + + Usage: + + .. code-block:: python + + import pydantic + from odoo.addons.pydantic import models, utils + + class UserInfo(models.BaseModel): + _name = "user" + 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" + 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) + + To avoid having to repeat the specific configuration required for the + `from_orm` method into each pydantic model, "odoo_orm_mode" can be used + as parent via the `_inherit` attribute + + """ + + def get(self, key: Any, default: Any = None) -> Any: + res = getattr(self._obj, key, default) + if isinstance(self._obj, models.BaseModel) and key in self._obj._fields: + field = self._obj._fields[key] + if field.type in ["one2many", "many2many"]: + return list(res) + return res + + +# this is duplicated from odoo.models.MetaModel._get_addon_name() which we +# unfortunately can't use because it's an instance method and should have been +# a @staticmethod +def _get_addon_name(full_name: str) -> str: + # The (Odoo) module name can be in the ``odoo.addons`` namespace + # or not. For instance, module ``sale`` can be imported as + # ``odoo.addons.sale`` (the right way) or ``sale`` (for backward + # compatibility). + module_parts = full_name.split(".") + if len(module_parts) > 2 and module_parts[:2] == ["odoo", "addons"]: + addon_name = full_name.split(".")[2] + else: + addon_name = full_name.split(".")[0] + return addon_name diff --git a/requirements.txt b/requirements.txt index de503cc89..700ae5ca0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ jsondiff marshmallow marshmallow-objects>=2.0.0 parse-accept-language +pydantic pyquerystring diff --git a/setup/pydantic/odoo/addons/pydantic b/setup/pydantic/odoo/addons/pydantic new file mode 120000 index 000000000..775eac291 --- /dev/null +++ b/setup/pydantic/odoo/addons/pydantic @@ -0,0 +1 @@ +../../../../pydantic \ No newline at end of file diff --git a/setup/pydantic/setup.py b/setup/pydantic/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/pydantic/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)