From 01d3772d13e6c6c8f62cf9ff1392b43aad13f9b2 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Sat, 6 Nov 2021 16:41:30 +0100 Subject: [PATCH 1/8] [ADD] pydantic: allows pydantic usage into Odoo --- pydantic/README.rst | 141 ++++++++ pydantic/__init__.py | 3 + pydantic/__manifest__.py | 23 ++ pydantic/builder.py | 104 ++++++ pydantic/models.py | 102 ++++++ pydantic/readme/CONTRIBUTORS.rst | 1 + pydantic/readme/DESCRIPTION.rst | 1 + pydantic/readme/ROADMAP.rst | 3 + pydantic/readme/USAGE.rst | 65 ++++ pydantic/registry.py | 178 +++++++++ pydantic/static/description/icon.png | Bin 0 -> 9455 bytes pydantic/static/description/index.html | 482 +++++++++++++++++++++++++ pydantic/tests/__init__.py | 1 + pydantic/tests/common.py | 232 ++++++++++++ pydantic/tests/test_pydantic.py | 139 +++++++ pydantic/utils.py | 75 ++++ requirements.txt | 1 + setup/pydantic/odoo/addons/pydantic | 1 + setup/pydantic/setup.py | 6 + 19 files changed, 1558 insertions(+) create mode 100644 pydantic/README.rst create mode 100644 pydantic/__init__.py create mode 100644 pydantic/__manifest__.py create mode 100644 pydantic/builder.py create mode 100644 pydantic/models.py create mode 100644 pydantic/readme/CONTRIBUTORS.rst create mode 100644 pydantic/readme/DESCRIPTION.rst create mode 100644 pydantic/readme/ROADMAP.rst create mode 100644 pydantic/readme/USAGE.rst create mode 100644 pydantic/registry.py create mode 100644 pydantic/static/description/icon.png create mode 100644 pydantic/static/description/index.html create mode 100644 pydantic/tests/__init__.py create mode 100644 pydantic/tests/common.py create mode 100644 pydantic/tests/test_pydantic.py create mode 100644 pydantic/utils.py create mode 120000 setup/pydantic/odoo/addons/pydantic create mode 100644 setup/pydantic/setup.py 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 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z z?n?eq1XP>p-IM$Z^C;2L3itnbJZAip*Zo0aw2bs8@(s^~*8T9go!%dHcAz2lM;`yp zD=7&xjFV$S&5uDaiScyD?B-i1ze`+CoRtz`Wn+Zl&#s4&}MO{@N!ufrzjG$B79)Y2d3tBk&)TxUTw@QS0TEL_?njX|@vq?Uz(nBFK5Pq7*xj#u*R&i|?7+6# z+|r_n#SW&LXhtheZdah{ZVoqwyT{D>MC3nkFF#N)xLi{p7J1jXlmVeb;cP5?e(=f# zuT7fvjSbjS781v?7{)-X3*?>tq?)Yd)~|1{BDS(pqC zC}~H#WXlkUW*H5CDOo<)#x7%RY)A;ShGhI5s*#cRDA8YgqG(HeKDx+#(ZQ?386dv! zlXCO)w91~Vw4AmOcATuV653fa9R$fyK8ul%rG z-wfS zihugoZyr38Im?Zuh6@RcF~t1anQu7>#lPpb#}4cOA!EM11`%f*07RqOVkmX{p~KJ9 z^zP;K#|)$`^Rb{rnHGH{~>1(fawV0*Z#)}M`m8-?ZJV<+e}s9wE# z)l&az?w^5{)`S(%MRzxdNqrs1n*-=jS^_jqE*5XDrA0+VE`5^*p3CuM<&dZEeCjoz zR;uu_H9ZPZV|fQq`Cyw4nscrVwi!fE6ciMmX$!_hN7uF;jjKG)d2@aC4ropY)8etW=xJvni)8eHi`H$%#zn^WJ5NLc-rqk|u&&4Z6fD_m&JfSI1Bvb?b<*n&sfl0^t z=HnmRl`XrFvMKB%9}>PaA`m-fK6a0(8=qPkWS5bb4=v?XcWi&hRY?O5HdulRi4?fN zlsJ*N-0Qw+Yic@s0(2uy%F@ib;GjXt01Fmx5XbRo6+n|pP(&nodMoap^z{~q ziEeaUT@Mxe3vJSfI6?uLND(CNr=#^W<1b}jzW58bIfyWTDle$mmS(|x-0|2UlX+9k zQ^EX7Nw}?EzVoBfT(-LT|=9N@^hcn-_p&sqG z&*oVs2JSU+N4ZD`FhCAWaS;>|wH2G*Id|?pa#@>tyxX`+4HyIArWDvVrX)2WAOQff z0qyHu&-S@i^MS-+j--!pr4fPBj~_8({~e1bfcl0wI1kaoN>mJL6KUPQm5N7lB(ui1 zE-o%kq)&djzWJ}ob<-GfDlkB;F31j-VHKvQUGQ3sp`CwyGJk_i!y^sD0fqC@$9|jO zOqN!r!8-p==F@ZVP=U$qSpY(gQ0)59P1&t@y?5rvg<}E+GB}26NYPp4f2YFQrQtot5mn3wu_qprZ=>Ig-$ zbW26Ws~IgY>}^5w`vTB(G`PTZaDiGBo5o(tp)qli|NeV( z@H_=R8V39rt5J5YB2Ky?4eJJ#b`_iBe2ot~6%7mLt5t8Vwi^Jy7|jWXqa3amOIoRb zOr}WVFP--DsS`1WpN%~)t3R!arKF^Q$e12KEqU36AWwnCBICpH4XCsfnyrHr>$I$4 z!DpKX$OKLWarN7nv@!uIA+~RNO)l$$w}p(;b>mx8pwYvu;dD_unryX_NhT8*Tj>BTrTTL&!?O+%Rv;b?B??gSzdp?6Uug9{ zd@V08Z$BdI?fpoCS$)t4mg4rT8Q_I}h`0d-vYZ^|dOB*Q^S|xqTV*vIg?@fVFSmMpaw0qtTRbx} z({Pg?#{2`sc9)M5N$*N|4;^t$+QP?#mov zGVC@I*lBVrOU-%2y!7%)fAKjpEFsgQc4{amtiHb95KQEwvf<(3T<9-Zm$xIew#P22 zc2Ix|App^>v6(3L_MCU0d3W##AB0M~3D00EWoKZqsJYT(#@w$Y_H7G22M~ApVFTRHMI_3be)Lkn#0F*V8Pq zc}`Cjy$bE;FJ6H7p=0y#R>`}-m4(0F>%@P|?7fx{=R^uFdISRnZ2W_xQhD{YuR3t< z{6yxu=4~JkeA;|(J6_nv#>Nvs&FuLA&PW^he@t(UwFFE8)|a!R{`E`K`i^ZnyE4$k z;(749Ix|oi$c3QbEJ3b~D_kQsPz~fIUKym($a_7dJ?o+40*OLl^{=&oq$<#Q(yyrp z{J-FAniyAw9tPbe&IhQ|a`DqFTVQGQ&Gq3!C2==4x{6EJwiPZ8zub-iXoUtkJiG{} zPaR&}_fn8_z~(=;5lD-aPWD3z8PZS@AaUiomF!G8I}Mf>e~0g#BelA-5#`cj;O5>N Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 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, +) From 1418e06b37fea46f4561ea22836374442b2fb8fa Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 22 Nov 2021 10:33:21 +0100 Subject: [PATCH 2/8] [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"'} + } + }, ) From 25dd584735eb414e99ec9cd3faed8de1564b2bd7 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 1 Dec 2021 16:06:06 +0100 Subject: [PATCH 3/8] fixup! [IMP] Pydantic: Improve API to be more pythonic Co-authored-by: Yannick Vaucher --- pydantic/readme/USAGE.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic/readme/USAGE.rst b/pydantic/readme/USAGE.rst index 0ffa5f54c..a7e0ef9b2 100644 --- a/pydantic/readme/USAGE.rst +++ b/pydantic/readme/USAGE.rst @@ -18,7 +18,7 @@ To define your own pydantic model you just need to create a class that inherits zip_code: str = None city: str phone: str = None - is_componay : bool = Field(None) + is_company : bool = Field(None) In the preceding code, 2 new models are created, one for each class. If you From bfa2925123f2e140f03d879f70334def2f6a1d6f Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 8 Dec 2021 13:35:18 +0100 Subject: [PATCH 4/8] [FIX] pydantic: Makes issubclass on BaseModel working with generics --- pydantic/__manifest__.py | 5 +---- pydantic/models.py | 2 +- pydantic/tests/test_pydantic.py | 20 ++++++++++++++++++++ requirements.txt | 1 + 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/pydantic/__manifest__.py b/pydantic/__manifest__.py index 9ed053c80..df4e562e2 100644 --- a/pydantic/__manifest__.py +++ b/pydantic/__manifest__.py @@ -15,10 +15,7 @@ "data": [], "demo": [], "external_dependencies": { - "python": [ - "pydantic", - "contextvars", - ] + "python": ["pydantic", "contextvars", "typing-extensions>=4.0.1"] }, "installable": True, } diff --git a/pydantic/models.py b/pydantic/models.py index bf5f00b6e..12f36b0c2 100644 --- a/pydantic/models.py +++ b/pydantic/models.py @@ -151,7 +151,7 @@ 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) + return isinstance(subclass, type) and super().__subclasscheck__(subclass) class BaseModel(pydantic.BaseModel, metaclass=ExtendablePydanticModelMeta): diff --git a/pydantic/tests/test_pydantic.py b/pydantic/tests/test_pydantic.py index 76d2133ef..7853e11b7 100644 --- a/pydantic/tests/test_pydantic.py +++ b/pydantic/tests/test_pydantic.py @@ -3,6 +3,11 @@ from typing import List +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal + import pydantic from .. import models, utils @@ -154,3 +159,18 @@ class ExtendedLocation(Location, extends=Location): inst2 = ExtendedLocation.construct() self.assertEqual(inst1.__class__, inst2.__class__) self.assertEqual(inst1.schema(), inst2.schema()) + + def test_issubclass(self): + """In this test we check that issublass is lenient when used with + GenericAlias + """ + self.assertFalse(issubclass(Literal["test"], models.BaseModel)) + self.assertFalse(issubclass(Literal, models.BaseModel)) + + class Location(models.BaseModel): + kind: Literal["view", "bin"] + my_list: List[str] + + self._build_pydantic_classes(Location) + schema = Location.schema() + self.assertTrue(schema) diff --git a/requirements.txt b/requirements.txt index 20c95515f..1a1ac2664 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ marshmallow-objects>=2.0.0 parse-accept-language pydantic pyquerystring +typing-extensions>=4.0.1 From 9bf49018a507b61bd0e41cdb7508c4b3b226b5fe Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 13 Dec 2021 15:17:03 +0100 Subject: [PATCH 5/8] [IMP] pydantic: Add tests on models created by inheritance --- pydantic/tests/test_pydantic.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pydantic/tests/test_pydantic.py b/pydantic/tests/test_pydantic.py index 7853e11b7..95a757715 100644 --- a/pydantic/tests/test_pydantic.py +++ b/pydantic/tests/test_pydantic.py @@ -78,6 +78,25 @@ class Location(Coordinate, Name): location.dict(), {"lat": 4.2, "lng": 5.0, "name": "name"} ) + def test_inheritance_new_model(self): + class MyModel(models.BaseModel): + value: int = 2 + + def method(self): + return self.value + + class DerivedModel(MyModel): # no extends, I want two different models here + value2: int = 3 + + def method(self): + return super().method() + self.value2 + + self._build_pydantic_classes(MyModel, DerivedModel) + self.assertTrue(DerivedModel().method(), 5) + + ClsDerivedModel = self.pydantic_registry[DerivedModel.__xreg_name__] + self.assertTrue(ClsDerivedModel().method(), 5) + def test_model_relation(self): class Coordinate(models.BaseModel): lat = 0.1 From fb20133ce3629c1847bcfdfa3a497a8543f0d5d1 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 13 Dec 2021 15:24:20 +0100 Subject: [PATCH 6/8] [IMP] pydantic: Add tests on models created by inheritance --- pydantic/tests/test_pydantic.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/pydantic/tests/test_pydantic.py b/pydantic/tests/test_pydantic.py index 95a757715..f1fb94950 100644 --- a/pydantic/tests/test_pydantic.py +++ b/pydantic/tests/test_pydantic.py @@ -97,6 +97,29 @@ def method(self): ClsDerivedModel = self.pydantic_registry[DerivedModel.__xreg_name__] self.assertTrue(ClsDerivedModel().method(), 5) + def test_inheritance_new_model_2(self): + class MyModel(models.BaseModel): + value: int = 2 + + def method(self): + return self.value + + class DerivedModel(MyModel): # no extends, I want two different models here + value2: int = 3 + + def method(self): + return super().method() + self.value2 + + class MyModelExtended(MyModel, extends=MyModel): + def method(self): + return super().method() + 1 + + self._build_pydantic_classes(MyModel, DerivedModel, MyModelExtended) + self.assertTrue(DerivedModel().method(), 6) + + ClsDerivedModel = self.pydantic_registry[DerivedModel.__xreg_name__] + self.assertTrue(ClsDerivedModel().method(), 6) + def test_model_relation(self): class Coordinate(models.BaseModel): lat = 0.1 From 9ba9dbd7fdbc9a4daa005b8c7014f7e12621501e Mon Sep 17 00:00:00 2001 From: Quentin Groulard Date: Fri, 17 Dec 2021 17:09:21 +0100 Subject: [PATCH 7/8] [IMP] Lift typing-extensions version restriction --- pydantic/__manifest__.py | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pydantic/__manifest__.py b/pydantic/__manifest__.py index df4e562e2..05f9e582d 100644 --- a/pydantic/__manifest__.py +++ b/pydantic/__manifest__.py @@ -15,7 +15,7 @@ "data": [], "demo": [], "external_dependencies": { - "python": ["pydantic", "contextvars", "typing-extensions>=4.0.1"] + "python": ["pydantic", "contextvars", "typing-extensions"] }, "installable": True, } diff --git a/requirements.txt b/requirements.txt index 1a1ac2664..2b55c1cee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,4 @@ marshmallow-objects>=2.0.0 parse-accept-language pydantic pyquerystring -typing-extensions>=4.0.1 +typing-extensions From 9719377c73685c6760aa637d493ff3a97f2d9928 Mon Sep 17 00:00:00 2001 From: Quentin Groulard Date: Fri, 17 Dec 2021 17:53:02 +0100 Subject: [PATCH 8/8] [IMP] Odoo getter: Better handle empty fields --- pydantic/utils.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pydantic/utils.py b/pydantic/utils.py index 20ee877ca..547108d41 100644 --- a/pydantic/utils.py +++ b/pydantic/utils.py @@ -3,7 +3,7 @@ from typing import Any -from odoo import models +from odoo import fields, models from pydantic.utils import GetterDict @@ -51,6 +51,18 @@ 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 res is False and field.type != "boolean": + return None + if field.type == "date" and not res: + return None + if field.type == "datetime": + if not res: + return None + # Get the timestamp converted to the client's timezone. + # This call also add the tzinfo into the datetime object + return fields.Datetime.context_timestamp(self._obj, res) + if field.type == "many2one" and not res: + return None if field.type in ["one2many", "many2many"]: return list(res) return res