diff --git a/pydantic/README.rst b/pydantic/README.rst new file mode 100644 index 000000000..e460d2c1c --- /dev/null +++ b/pydantic/README.rst @@ -0,0 +1,211 @@ +======== +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/15.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-15-0/rest-framework-15-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/15.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +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`` or a subclass of. + +.. code-block:: python + + from odoo.addons.pydantic.models import BaseModel + from pydantic import Field + + + class PartnerShortInfo(BaseModel): + id: str + name: str + + + class PartnerInfo(BaseModel): + street: str + street2: str = None + zip_code: str = None + city: str + phone: str = None + is_componay : bool = Field(None) + + +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 + + 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. + +.. 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 + + +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, the addon +provide a utility class `odoo.addons.pydantic.utils.GenericOdooGetter`. + +.. code-block:: python + + 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 Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter + + user = self.env.user + user_info = UserInfo.from_orm(user) + +See the official `Pydantic documentation`_ to discover all the available functionalities. + +.. _`Liskov substitution principle`: https://en.wikipedia.org/wiki/Liskov_substitution_principle +.. _`Pydantic 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 +~~~~~~~~~~~ + +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 `__: + +|maintainer-lmignon| + +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/__init__.py b/pydantic/__init__.py new file mode 100644 index 000000000..4dbbce515 --- /dev/null +++ b/pydantic/__init__.py @@ -0,0 +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 new file mode 100644 index 000000000..95781409f --- /dev/null +++ b/pydantic/__manifest__.py @@ -0,0 +1,21 @@ +# 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": "15.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", "contextvars", "typing-extensions>=4.0.1"] + }, + "installable": True, +} diff --git a/pydantic/builder.py b/pydantic/builder.py new file mode 100644 index 000000000..5b300bf88 --- /dev/null +++ b/pydantic/builder.py @@ -0,0 +1,83 @@ +# 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 as omodels + +from .registry import PydanticClassesRegistry, _pydantic_classes_databases + + +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 ``__xreg_name__`` and applying pydantic models with an ``__xreg_base_names__`` + 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) + + @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) + + 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 new file mode 100644 index 000000000..fc5386e7d --- /dev/null +++ b/pydantic/models.py @@ -0,0 +1,213 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) +import collections +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 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 isinstance(subclass, type) and super().__subclasscheck__(subclass) + + +class BaseModel(pydantic.BaseModel, metaclass=ExtendablePydanticModelMeta): + _pydantic_classes_by_module: OrderedDict[ + str, List[ModelType] + ] = collections.OrderedDict() + + def __new__(cls, *args, **kwargs): + if getattr(cls, "_is_aggregated_class", False): + return super().__new__(cls) + return cls._get_assembled_cls()(*args, **kwargs) + + # pylint: disable=W8110 + @classmethod + 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) + + @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 + """ + 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(":"): + 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) + + +_is_base_model_class_defined = True 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..0ffa5f54c --- /dev/null +++ b/pydantic/readme/USAGE.rst @@ -0,0 +1,119 @@ +To define your own pydantic model you just need to create a class that inherits from +``odoo.addons.pydantic.models.BaseModel`` or a subclass of. + +.. code-block:: python + + from odoo.addons.pydantic.models import BaseModel + from pydantic import Field + + + class PartnerShortInfo(BaseModel): + id: str + name: str + + + class PartnerInfo(BaseModel): + street: str + street2: str = None + zip_code: str = None + city: str + phone: str = None + is_componay : bool = Field(None) + + +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 + + 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. + +.. 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 + + +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, the addon +provide a utility class `odoo.addons.pydantic.utils.GenericOdooGetter`. + +.. code-block:: python + + 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 Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter + + user = self.env.user + user_info = UserInfo.from_orm(user) + +See the official `Pydantic documentation`_ to discover all the available functionalities. + +.. _`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 new file mode 100644 index 000000000..b1923df22 --- /dev/null +++ b/pydantic/registry.py @@ -0,0 +1,227 @@ +# 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.tools import LastOrderedSet + +from pydantic.utils import ClassAttribute + +from . import models + + +class PydanticClassDef(object): + name: str = None + hierarchy: List[models.ModelType] = None + base_names: Set[str] = None + + def __init__(self, cls): + self.name = cls.__xreg_name__ + self.hierarchy = [cls] + self.base_names = set(cls.__xreg_base_names__ or []) + + def add_child(self, cls): + self.hierarchy.append(cls) + for base in cls.__xreg_base_names__: + self.base_names.add(base) + + @property + def is_mixed_bases(self): + return set(self.name) != self.base_names + + def __repr__(self): + return f"PydanticClassDef {self.name}" + + +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 ``cls.__module__ + "." + cls.__qualname__`` 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, 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) -> models.ModelType: + return self._pydantic_classes[key] + + 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[models.ModelType] = None + ) -> models.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 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: 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.__xreg_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) -> models.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.__xreg_base_names__: + 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) + simple_name = name.split(".")[-1] + uniq_class_name = f"{simple_name}_{id(cls)}" + PydanticClass = type( + simple_name, + tuple(bases), + { + # attrs for pickle to find this class + "__module__": cls.__module__, + "__qualname__": uniq_class_name, + "_is_aggregated_class": ClassAttribute( + "_is_aggregated_class", True + ), + }, + ) + 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(): + cls.update_forward_refs() + + def resolve_submodel_fields(self): + for cls in self._pydantic_classes.values(): + cls._resolve_submodel_fields(registry=self) + + @contextmanager + def build_mode(self): + models._registry_build_mode = True + try: + yield + finally: + models._registry_build_mode = False + + 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 + + +# 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/icon.png b/pydantic/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/pydantic/static/description/icon.png differ diff --git a/pydantic/static/description/index.html b/pydantic/static/description/index.html new file mode 100644 index 000000000..79332a771 --- /dev/null +++ b/pydantic/static/description/index.html @@ -0,0 +1,536 @@ + + + + + + +Pydantic + + + +
+

Pydantic

+ + +

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

+ +
+

Usage

+

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

+
+from odoo.addons.pydantic.models import BaseModel
+from pydantic import Field
+
+
+class PartnerShortInfo(BaseModel):
+    id: str
+    name: str
+
+
+class PartnerInfo(BaseModel):
+    street: str
+    street2: str = None
+    zip_code: str = None
+    city: str
+    phone: str = None
+    is_componay : bool = Field(None)
+
+

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 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.

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

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, the addon +provide a utility class odoo.addons.pydantic.utils.GenericOdooGetter.

+
+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 Config:
+        orm_mode = True
+        getter_dict = utils.GenericOdooGetter
+
+user = self.env.user
+user_info = UserInfo.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

+

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. To learn how please visit https://odoo-community.org/page/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..6658bb7c0 --- /dev/null +++ b/pydantic/tests/common.py @@ -0,0 +1,205 @@ +# 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 ..context import odoo_pydantic_registry +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__) + pydantic_registry.init_registry([current_addon]) + + # pylint: disable=W8106 + def setUp(self): + # should be ready only during tests, never during installation + # of addons + token = odoo_pydantic_registry.set(self._pydantics_registry) + + @self.addCleanup + def notready(): + odoo_pydantic_registry.reset(token) + + +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().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 PydanticRegistryCase( + common.BaseCase, 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: + + * :class:`TransactionPydanticCase` + + 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().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' 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 + # 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 + + token = odoo_pydantic_registry.set(self.pydantic_registry) + + @self.addCleanup + def notready(): + odoo_pydantic_registry.reset(token) + + def tearDown(self): + super().tearDown() + self.restore_registry() + + def _load_module_pydantics(self, module): + self.pydantic_registry.load_pydantics(module) + + def _build_pydantic_classes(self, *classes): + 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) + 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 + @classmethod + def setUpClass(cls): + # resolve an inheritance issue (common.TransactionCase does not use + # super) + common.TransactionCase.setUpClass(cls) + PydanticRegistryCase.setUp(cls) + + @classmethod + def tearDownClass(cls): + common.TransactionCase.tearDownClass(cls) + PydanticRegistryCase.tearDown(cls) diff --git a/pydantic/tests/test_pydantic.py b/pydantic/tests/test_pydantic.py new file mode 100644 index 000000000..17f90c111 --- /dev/null +++ b/pydantic/tests/test_pydantic.py @@ -0,0 +1,177 @@ +# Copyright 2021 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from typing import List + +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal + +import pydantic + +from .. import models, utils +from .common import PydanticRegistryCase + + +class TestPydantic(PydanticRegistryCase): + # pylint: disable=W8110 + def test_simple_inheritance(self): + class Location(models.BaseModel): + lat = 0.1 + lng = 10.1 + + def test(self) -> str: + return "location" + + class ExtendedLocation(Location, extends=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.__xreg_name__] + self.assertTrue(issubclass(ClsLocation, ExtendedLocation)) + self.assertTrue(issubclass(ClsLocation, 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): + lat = 0.1 + lng = 10.1 + + class Name(models.BaseModel): + name: str + + class Location(Coordinate, Name): + pass + + self._build_pydantic_classes(Coordinate, Name, Location) + ClsLocation = self.pydantic_registry[Location.__xreg_name__] + self.assertTrue(issubclass(ClsLocation, Coordinate)) + self.assertTrue(issubclass(ClsLocation, Name)) + + # 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): + lat = 0.1 + lng = 10.1 + + 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 Group(models.BaseModel): + name: str + + 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 + + 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 + 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()) + + 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/pydantic/utils.py b/pydantic/utils.py new file mode 100644 index 000000000..20ee877ca --- /dev/null +++ b/pydantic/utils.py @@ -0,0 +1,72 @@ +# 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 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 Config: + orm_mode = True + getter_dict = utils.GenericOdooGetter + + user = self.env.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 + 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 690f7ff5f..dc3454234 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,11 @@ # generated from manifests external_dependencies apispec>=4.0.0 cerberus +contextvars;python_version<"3.7" jsondiff marshmallow marshmallow-objects>=2.0.0 parse-accept-language +pydantic pyquerystring +typing-extensions>=4.0.1 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..3665ea16f --- /dev/null +++ b/setup/pydantic/setup.py @@ -0,0 +1,10 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon={ + "external_dependencies_override": { + "python": {"contextvars": 'contextvars;python_version<"3.7"'} + } + }, +)