Source code for odoo.addons.component.core

# -*- coding: utf-8 -*-
# Copyright 2017 Camptocamp SA
# Copyright 2017 Odoo
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)

"""

Core
====

Core classes for the components.
The most common classes used publicly are:

* :class:`Component`
* :class:`AbstractComponent`
* :class:`WorkContext`

"""

import logging
import operator

from collections import defaultdict, OrderedDict

from odoo import models
from odoo.tools import OrderedSet, LastOrderedSet
from .exception import NoComponentError, SeveralComponentError


_logger = logging.getLogger(__name__)

try:
    from cachetools import LRUCache, cachedmethod
except ImportError:
    _logger.debug("Cannot import 'cachetools'.")


# The Cache size represents the number of items, so the number
# of components (include abstract components) we will keep in the LRU
# cache. We would need stats to know what is the average but this is a bit
# early.
DEFAULT_CACHE_SIZE = 512


# 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):
    # 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


[docs]class ComponentDatabases(dict): """ Holds a registry of components for each database """
[docs]class ComponentRegistry(object): """ Store all the components and allow to find them using criteria The key is the ``_name`` of the components. This is an OrderedDict, because we want to keep the registration order of the components, addons loaded first have their components found first. The :attr:`ready` attribute must be set to ``True`` when all the components are loaded. """ def __init__(self, cachesize=DEFAULT_CACHE_SIZE): self._cache = LRUCache(maxsize=cachesize) self._components = OrderedDict() self._loaded_modules = set() self.ready = False def __getitem__(self, key): return self._components[key] def __setitem__(self, key, value): self._components[key] = value def __contains__(self, key): return key in self._components
[docs] def get(self, key, default=None): return self._components.get(key, default)
def __iter__(self): return iter(self._components)
[docs] def load_components(self, module): if module in self._loaded_modules: return for component_class in MetaComponent._modules_components[module]: component_class._build_component(self) self._loaded_modules.add(module)
[docs] @cachedmethod(operator.attrgetter('_cache')) def lookup(self, collection_name=None, usage=None, model_name=None): """ Find and return a list of components for a usage If a component is not registered in a particular collection (no ``_collection``), it will be returned in any case (as far as the ``usage`` and ``model_name`` match). This is useful to share generic components across different collections. If no collection name is given, components from any collection will be returned. Then, the components of a collection are filtered by usage and/or model. The ``_usage`` is mandatory on the components. When the ``_model_name`` is empty, it means it can be used for every models, and it will ignore the ``model_name`` argument. The abstract components are never returned. This is a rather low-level function, usually you will use the high-level :meth:`AbstractComponent.component`, :meth:`AbstractComponent.many_components` or even :meth:`AbstractComponent.component_by_name`. :param collection_name: the name of the collection the component is registered into. :param usage: the usage of component we are looking for :param model_name: filter on components that apply on this model """ # keep the order so addons loaded first have components used first candidates = ( component for component in self._components.values() if not component._abstract ) if collection_name is not None: candidates = ( component for component in candidates if (component._collection == collection_name or component._collection is None) ) if usage is not None: candidates = (component for component in candidates if component._usage == usage) if model_name is not None: candidates = (c for c in candidates if c.apply_on_models is None or model_name in c.apply_on_models) return list(candidates)
# We will store a ComponentRegistry per database here, # it will be cleared and updated when the odoo's registry is rebuilt _component_databases = ComponentDatabases()
[docs]class WorkContext(object): """ Transport the context required to work with components It is propagated through all the components, so any data or instance (like a random RPC client) that need to be propagated transversally to the components should be kept here. Including: .. attribute:: model_name Name of the model we are working with. It means that any lookup for a component will be done for this model. It also provides a shortcut as a `model` attribute to use directly with the Odoo model from the components .. attribute:: collection The collection we are working with. The collection is an Odoo Model that inherit from 'collection.base'. The collection attribute can be a record or an "empty" model. .. attribute:: model Odoo Model for ``model_name`` with the same Odoo :class:`~odoo.api.Environment` than the ``collection`` attribute. This is also the entrypoint to work with the components. :: collection = self.env['my.collection'].browse(1) work = WorkContext(model_name='res.partner', collection=collection) component = work.component(usage='record.importer') Usually you will use the context manager on the ``collection.base`` Model: :: collection = self.env['my.collection'].browse(1) with collection.work_on('res.partner') as work: component = work.component(usage='record.importer') It supports any arbitrary keyword arguments that will become attributes of the instance, and be propagated throughout all the components. :: collection = self.env['my.collection'].browse(1) with collection.work_on('res.partner', hello='world') as work: assert work.hello == 'world' When you need to work on a different model, a new work instance will be created for you when you are using the high-level API. This is what happens under the hood: :: collection = self.env['my.collection'].browse(1) with collection.work_on('res.partner', hello='world') as work: assert work.model_name == 'res.partner' assert work.hello == 'world' work2 = work.work_on('res.users') # => spawn a new WorkContext with a copy of the attributes assert work2.model_name == 'res.users' assert work2.hello == 'world' """ def __init__(self, model_name=None, collection=None, components_registry=None, **kwargs): self.collection = collection self.model_name = model_name self.model = self.env[model_name] # lookup components in an alternative registry, used by the tests if components_registry is not None: self.components_registry = components_registry else: dbname = self.env.cr.dbname try: self.components_registry = _component_databases[dbname] except KeyError: _logger.error( 'No component registry for database %s. ' 'Probably because the Odoo registry has not been built ' 'yet.', dbname ) raise self._propagate_kwargs = [ 'collection', 'model_name', 'components_registry', ] for attr_name, value in kwargs.items(): setattr(self, attr_name, value) self._propagate_kwargs.append(attr_name) @property def env(self): """ Return the current Odoo env This is the environment of the current collection. """ return self.collection.env
[docs] def work_on(self, model_name=None, collection=None): """ Create a new work context for another model keeping attributes Used when one need to lookup components for another model. """ kwargs = {attr_name: getattr(self, attr_name) for attr_name in self._propagate_kwargs} if collection is not None: kwargs['collection'] = collection if model_name is not None: kwargs['model_name'] = model_name return self.__class__(**kwargs)
def _component_class_by_name(self, name): components_registry = self.components_registry component_class = components_registry.get(name) if not component_class: raise NoComponentError("No component with name '%s' found." % name) return component_class
[docs] def component_by_name(self, name, model_name=None): """ Return a component by its name If the component exists, an instance of it will be returned, initialized with the current :class:`WorkContext`. A :exc:`odoo.addons.component.exception.NoComponentError` is raised if: * no component with this name exists * the ``_apply_on`` of the found component does not match with the current working model In the latter case, it can be an indication that you need to switch to a different model, you can do so by providing the ``model_name`` argument. """ if isinstance(model_name, models.BaseModel): model_name = model_name._name component_class = self._component_class_by_name(name) work_model = model_name or self.model_name if (component_class._collection and self.collection._name != component_class._collection): raise NoComponentError( "Component with name '%s' can't be used for collection '%s'." (name, self.collection._name) ) if (component_class.apply_on_models and work_model not in component_class.apply_on_models): if len(component_class.apply_on_models) == 1: hint_models = "'%s'" % (component_class.apply_on_models[0],) else: hint_models = "<one of %r>" % ( component_class.apply_on_models, ) raise NoComponentError( "Component with name '%s' can't be used for model '%s'.\n" "Hint: you might want to use: " "component_by_name('%s', model_name=%s)" % (name, work_model, name, hint_models) ) if work_model == self.model_name: work_context = self else: work_context = self.work_on(model_name) return component_class(work_context)
def _lookup_components(self, usage=None, model_name=None): component_classes = self.components_registry.lookup( self.collection._name, usage=usage, model_name=model_name, ) return [cls for cls in component_classes if cls._component_match(self)]
[docs] def component(self, usage=None, model_name=None): """ Find a component by usage and model for the current collection It searches a component using the rules of :meth:`ComponentRegistry.lookup`. When a component is found, it initialize it with the current :class:`WorkContext` and returned. A component with a ``_apply_on`` matching the asked ``model_name`` takes precedence over a generic component without ``_apply_on``. A component with a ``_collection`` matching the current collection takes precedence over a generic component without ``_collection``. This behavior allows to define generic components across collections and/or models and override them only for a particular collection and/or model. A :exc:`odoo.addons.component.exception.SeveralComponentError` is raised if more than one component match for the provided ``usage``/``model_name``. A :exc:`odoo.addons.component.exception.NoComponentError` is raised if no component is found for the provided ``usage``/``model_name``. """ if isinstance(model_name, models.BaseModel): model_name = model_name._name model_name = model_name or self.model_name component_classes = self._lookup_components( usage=usage, model_name=model_name ) if not component_classes: raise NoComponentError( "No component found for collection '%s', " "usage '%s', model_name '%s'." % (self.collection._name, usage, model_name) ) elif len(component_classes) > 1: # If we have more than one component, try to find the one # specifically linked to the collection... component_classes = [ c for c in component_classes if c._collection == self.collection._name] if len(component_classes) > 1: # ... or try to find the one specifically linked to the model component_classes = [ c for c in component_classes if c.apply_on_models and model_name in c.apply_on_models ] if len(component_classes) != 1: raise SeveralComponentError( "Several components found for collection '%s', " "usage '%s', model_name '%s'. Found: %r" % (self.collection._name, usage or '', model_name or '', component_classes) ) if model_name == self.model_name: work_context = self else: work_context = self.work_on(model_name) return component_classes[0](work_context)
[docs] def many_components(self, usage=None, model_name=None): """ Find many components by usage and model for the current collection It searches a component using the rules of :meth:`ComponentRegistry.lookup`. When components are found, they initialized with the current :class:`WorkContext` and returned as a list. If no component is found, an empty list is returned. """ if isinstance(model_name, models.BaseModel): model_name = model_name._name model_name = model_name or self.model_name component_classes = self._lookup_components( usage=usage, model_name=model_name ) if model_name == self.model_name: work_context = self else: work_context = self.work_on(model_name) return [comp(work_context) for comp in component_classes]
def __str__(self): return "WorkContext(%s, %s)" % (self.model_name, repr(self.collection)) __repr__ = __str__
[docs]class MetaComponent(type): """ Metaclass for Components Every new :class:`Component` will be added to ``_modules_components``, that will be used by the component builder. """ _modules_components = defaultdict(list) def __init__(self, name, bases, attrs): if not self._register: self._register = True super(MetaComponent, self).__init__(name, bases, attrs) return # If components are declared in tests, exclude them from the # "components of the addon" list. If not, when we use the # "load_components" method, all the test components would be loaded. # This should never be an issue when running the app normally, as the # Python tests should never be executed. But this is an issue when a # test creates a test components for the purpose of the test, then a # second tests uses the "load_components" to load all the addons of the # module: it will load the component of the previous test. if 'tests' in self.__module__.split('.'): return if not hasattr(self, '_module'): self._module = _get_addon_name(self.__module__) self._modules_components[self._module].append(self) @property def apply_on_models(self): # None means all models if self._apply_on is None: return None # always return a list, used for the lookup elif isinstance(self._apply_on, str): return [self._apply_on] return self._apply_on
[docs]class AbstractComponent(object, metaclass=MetaComponent): """ Main Component Model All components have a Python inheritance either on :class:`AbstractComponent` or either on :class:`Component`. Abstract Components will not be returned by lookups on components, however they can be used as a base for other Components through inheritance (using ``_inherit``). Inheritance mechanism The inheritance mechanism is like the Odoo's one for Models. Each component has a ``_name``. This is the absolute minimum in a Component class. :: class MyComponent(Component): _name = 'my.component' def speak(self, message): print message Every component implicitly inherit from the `'base'` component. There are two close but distinct inheritance types, which look familiar if you already know Odoo. The first uses ``_inherit`` with an existing name, the name of the component we want to extend. With the following example, ``my.component`` is now able to speak and to yell. :: class MyComponent(Component): # name of the class does not matter _inherit = 'my.component' def yell(self, message): print message.upper() The second has a different ``_name``, it creates a new component, including the behavior of the inherited component, but without modifying it. In the following example, ``my.component`` is still able to speak and to yell (brough by the previous inherit), but not to sing. ``another.component`` is able to speak, to yell and to sing. :: class AnotherComponent(Component): _name = 'another.component' _inherit = 'my.component' def sing(self, message): print message.upper() Registration and lookups It is handled by 3 attributes on the class: _collection The name of the collection where we want to register the component. This is not strictly mandatory as a component can be shared across several collections. But usually, you want to set a collection to segregate the components for a domain. A collection can be for instance ``magento.backend``. It is also the name of a model that inherits from ``collection.base``. See also :class:`~WorkContext` and :class:`~odoo.addons.component.models.collection.Collection`. _apply_on List of names or name of the Odoo model(s) for which the component can be used. When not set, the component can be used on any model. _usage The collection and the model (``_apply_on``) will help to filter the candidate components according to our working context (e.g. I'm working on ``magento.backend`` with the model ``magento.res.partner``). The usage will define **what** kind of task the component we are looking for serves to. For instance, it might be ``record.importer``, ``export.mapper```... but you can be as creative as you want. Now, to get a component, you'll likely use :meth:`WorkContext.component` when you start to work with components in your flow, but then from within your components, you are more likely to use one of: * :meth:`component` * :meth:`many_components` * :meth:`component_by_name` (more rarely though) Declaration of some Components can look like:: class FooBar(models.Model): _name = 'foo.bar.collection' _inherit = 'collection.base' # this inherit is required class FooBarBase(AbstractComponent): _name = 'foo.bar.base' _collection = 'foo.bar.collection' # name of the model above class Foo(Component): _name = 'foo' _inherit = 'foo.bar.base' # we will inherit the _collection _apply_on = 'res.users' _usage = 'speak' def utter(self, message): print message class Bar(Component): _name = 'bar' _inherit = 'foo.bar.base' # we will inherit the _collection _apply_on = 'res.users' _usage = 'yell' def utter(self, message): print message.upper() + '!!!' class Vocalizer(Component): _name = 'vocalizer' _inherit = 'foo.bar.base' _usage = 'vocalizer' # can be used for any model def vocalize(action, message): self.component(usage=action).utter(message) And their usage:: >>> coll = self.env['foo.bar.collection'].browse(1) >>> with coll.work_on('res.users') as work: ... vocalizer = work.component(usage='vocalizer') ... vocalizer.vocalize('speak', 'hello world') ... hello world ... vocalizer.vocalize('yell', 'hello world') HELLO WORLD!!! Hints: * If you want to create components without ``_apply_on``, choose a ``_usage`` that will not conflict other existing components. * Unless this is what you want and in that case you use :meth:`many_components` which will return all components for a usage with a matching or a not set ``_apply_on``. * It is advised to namespace the names of the components (e.g. ``magento.xxx``) to prevent conflicts between addons. """ _register = False _abstract = True # used for inheritance _name = None #: Name of the component #: Name or list of names of the component(s) to inherit from _inherit = None #: name of the collection to subscribe in _collection = None #: List of models on which the component can be applied. #: None means any Model, can be a list ['res.users', ...] _apply_on = None #: Component purpose ('import.mapper', ...). _usage = None def __init__(self, work_context): super(AbstractComponent, self).__init__() self.work = work_context @classmethod def _component_match(cls, work): """ Evaluated on candidate components When a component lookup is done and candidate(s) have been found for a usage, a final call is done on this method. If the method return False, the candidate component is ignored. It can be used for instance to dynamically choose a component according to a value in the :class:`WorkContext`. Beware, if the lookups from usage, model and collection are cached, the calls to :meth:`_component_match` are executed each time we get components. Heavy computation should be avoided. :param work: the :class:`WorkContext` we are working with """ return True @property def collection(self): """ Collection we are working with """ return self.work.collection @property def env(self): """ Current Odoo environment, the one of the collection record """ return self.work.env @property def model(self): """ The model instance we are working with """ return self.work.model
[docs] def component_by_name(self, name, model_name=None): """ Return a component by its name Shortcut to meth:`~WorkContext.component_by_name` """ return self.work.component_by_name(name, model_name=model_name)
[docs] def component(self, usage=None, model_name=None): """ Return a component Shortcut to meth:`~WorkContext.component` """ return self.work.component(usage=usage, model_name=model_name)
[docs] def many_components(self, usage=None, model_name=None): """ Return several components Shortcut to meth:`~WorkContext.many_components` """ return self.work.many_components(usage=usage, model_name=model_name)
def __str__(self): return "Component(%s)" % self._name __repr__ = __str__ @classmethod def _build_component(cls, registry): """ Instantiate a given Component in the components registry. This method is called at the end of the Odoo's registry build. The caller is :meth:`component.builder.ComponentBuilder.load_components`. It generates new classes, which will be the Component classes we will be using. The new classes are generated following the inheritance of ``_inherit``. It ensures that the ``__bases__`` of the generated Component classes follow the ``_inherit`` chain. Once a Component class is created, it adds it in the Component Registry (:class:`ComponentRegistry`), so it will be available for lookups. At the end of new class creation, a hook method :meth:`_complete_component_build` is called, so you can customize further the created components. An example can be found in :meth:`odoo.addons.connector.components.mapper.Mapper._complete_component_build` The following code is roughly the same than the Odoo's one for building Models. """ # In the simplest case, the component's registry class inherits from # cls and the other classes that define the component in a flat # hierarchy. The registry contains the instance ``component`` (on the # left). Its class, ``ComponentClass``, carries inferred metadata that # is shared between all the component's instances for this registry # only. # # class A1(Component): Component # _name = 'a' / | \ # A3 A2 A1 # class A2(Component): \ | / # _inherit = 'a' ComponentClass # # class A3(Component): # _inherit = 'a' # # When a component is extended by '_inherit', its base classes are # modified to include the current class and the other inherited # component classes. # Note that we actually inherit from other ``ComponentClass``, so that # extensions to an inherited component are immediately visible in the # current component class, like in the following example: # # class A1(Component): # _name = 'a' Component # / / \ \ # class B1(Component): / A2 A1 \ # _name = 'b' / \ / \ # B2 ComponentA B1 # class B2(Component): \ | / # _name = 'b' \ | / # _inherit = ['b', 'a'] \ | / # ComponentB # class A2(Component): # _inherit = 'a' # determine inherited components parents = cls._inherit if isinstance(parents, str): parents = [parents] elif parents is None: parents = [] if cls._name in registry and not parents: raise TypeError('Component %r (in class %r) already exists. ' 'Consider using _inherit instead of _name ' 'or using a different _name.' % (cls._name, cls)) # determine the component's name name = cls._name or (len(parents) == 1 and parents[0]) if not name: raise TypeError('Component %r must have a _name' % cls) # all components except 'base' implicitly inherit from 'base' if name != 'base': parents = list(parents) + ['base'] # create or retrieve the component's class if name in parents: if name not in registry: raise TypeError("Component %r does not exist in registry." % name) ComponentClass = registry[name] ComponentClass._build_component_check_base(cls) check_parent = ComponentClass._build_component_check_parent else: ComponentClass = type( name, (AbstractComponent,), {'_name': name, '_register': False, # names of children component '_inherit_children': OrderedSet()}, ) check_parent = cls._build_component_check_parent # determine all the classes the component should inherit from bases = LastOrderedSet([cls]) for parent in parents: if parent not in registry: raise TypeError( "Component %r inherits from non-existing component %r." % (name, parent) ) parent_class = registry[parent] if parent == name: for base in parent_class.__bases__: bases.add(base) else: check_parent(cls, parent_class) bases.add(parent_class) parent_class._inherit_children.add(name) ComponentClass.__bases__ = tuple(bases) ComponentClass._complete_component_build() registry[name] = ComponentClass return ComponentClass @classmethod def _build_component_check_base(cls, extend_cls): """ Check whether ``cls`` can be extended with ``extend_cls``. """ if cls._abstract and not extend_cls._abstract: msg = ("%s transforms the abstract component %r into a " "non-abstract component. " "That class should either inherit from AbstractComponent, " "or set a different '_name'.") raise TypeError(msg % (extend_cls, cls._name)) @classmethod def _build_component_check_parent(component_class, cls, parent_class): """ Check whether ``model_class`` can inherit from ``parent_class``. """ if component_class._abstract and not parent_class._abstract: msg = ("In %s, the abstract Component %r cannot inherit " "from the non-abstract Component %r.") raise TypeError( msg % (cls, component_class._name, parent_class._name) ) @classmethod def _complete_component_build(cls): """ Complete build of the new component class After the component has been built from its bases, this method is called, and can be used to customize the class before it can be used. Nothing is done in the base Component, but a Component can inherit the method to add its own behavior. """
[docs]class Component(AbstractComponent): """ Concrete Component class This is the class you inherit from when you want your component to be registered in the component collections. Look in :class:`AbstractComponent` for more details. """ _register = False _abstract = False