Source code for connector.backend

# -*- coding: utf-8 -*-
##############################################################################
#
#    Author: Guewen Baconnier
#    Copyright 2013 Camptocamp SA
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU Affero General Public License as
#    published by the Free Software Foundation, either version 3 of the
#    License, or (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU Affero General Public License for more details.
#
#    You should have received a copy of the GNU Affero General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
from functools import partial
from collections import namedtuple
from .exception import NoConnectorUnitError

__all__ = ['Backend']


class BackendRegistry(object):
    """ Hold a set of backends """
    def __init__(self):
        self.backends = set()

    def register_backend(self, backend):
        """ Register an instance of
        :py:class:`connector.backend.Backend`

        :param backend: backend to register
        :type backend: Backend
        """
        self.backends.add(backend)

    def get_backend(self, service, version=None):
        """ Return an instance of
        :py:class:`connector.backend.Backend` for a
        ``service`` and a ``version``

        :param service: name of the service to return
        :type service: str
        :param version: version of the service to return
        :type version: str
        """
        for backend in self.backends:
            if backend.match(service, version):
                return backend
        raise ValueError('No backend found for %s %s' %
                         (service, version))


BACKENDS = BackendRegistry()


def get_backend(service, version=None):
    """ Return the correct instance of
    :py:class:`connector.backend.Backend` for a
    ``service`` and a ``version``

    :param service: name of the service to return
    :type service: str
    :param version: version of the service to return
    :type version: str
    """
    return BACKENDS.get_backend(service, version)


# Represents an entry for a class in a ``Backend`` registry.
_ConnectorUnitEntry = namedtuple('_ConnectorUnitEntry',
                                 ['cls',
                                  'openerp_module',
                                  'replaced_by'])


[docs]class Backend(object): """ A backend represents a system to interact with, like Magento, Prestashop, Redmine, ... It owns 3 properties: .. attribute:: service Name of the service, for instance 'magento' .. attribute:: version The version of the service. For instance: '1.7' .. attribute:: parent A parent backend. When no :py:class:`~connector.connector.ConnectorUnit` is found for a backend, it will search it in the `parent`. The Backends structure is a key part of the framework, but is rather simple. * A ``Backend`` instance holds a registry of :py:class:`~connector.connector.ConnectorUnit` classes * It can return the appropriate :py:class:`~connector.connector.ConnectorUnit` to use for a task * If no :py:class:`~connector.connector.ConnectorUnit` is registered for a task, it will ask it to its direct parent (and so on) The Backends support 2 different extension mechanisms. One is more vertical - across the versions - and the other would be more horizontal as it allows to modify the behavior for 1 version of backend. For the sake of the example, let's say we have theses backend versions:: <Magento> | ----------------- | | <Magento 1.7> <Magento 2.0> | <Magento with specific> And here is the way they are declared in Python:: magento = Backend('magento') magento1700 = Backend(parent=magento, version='1.7') magento2000 = Backend(parent=magento, version='2.0') magento_specific = Backend(parent=magento1700, version='1.7-specific') In the graph above, ``<Magento>`` will hold all the classes shared between all the versions. Each Magento version (``<Magento 1.7>``, ``<Magento 2.0>``) will use the classes defined on ``<Magento>``, excepted if they registered their own ones instead. That's the same for ``<Magento with specific>`` but this one contains customizations which are specific to an instance (typically you want specific mappings for one instance). Here is how you would register classes on ``<Magento>`` and another on ``<Magento 1.7>``:: @magento class Synchronizer(ConnectorUnit): _model_name = 'res.partner' @magento class Mapper(ConnectorUnit): _model_name = 'res.partner' @magento1700 class Synchronizer1700(Synchronizer): _model_name = 'res.partner' Here, the :py:meth:`~get_class` called on ``magento1700`` would return:: magento1700.get_class(Synchronizer, session, 'res.partner') # => Synchronizer1700 magento1700.get_class(Mapper, session, 'res.partner') # => Mapper This is the vertical extension mechanism, it says that each child version is able to extend or replace the behavior of its parent. .. note:: when using the framework, you won't need to call :py:meth:`~get_class`, usually, you will call :py:meth:`connector.connector.Environment.get_connector_unit`. The vertical extension is the one you will probably use the most, because most of the things you will change concern your custom adaptations or different behaviors between the versions of the backend. However, some time, we need to change the behavior of a connector, by installing an addon. For example, say that we already have an ``ImportMapper`` for the products in the Magento Connector. We create a - generic - addon to handle the catalog in a more advanced manner. We redefine an ``AdvancedImportMapper``, which should be used when the addon is installed. This is the horizontal extension mechanism. Replace a :py:class:`~connector.connector.ConnectorUnit` by another one in a backend:: @backend(replacing=ImportMapper) class AdvancedImportMapper(ImportMapper): _model_name = 'product.product' .. warning:: The horizontal extension should be used sparingly and cautiously since as soon as 2 addons want to replace the same class, you'll have a conflict (which would need to create a third addon to glue them, ``replacing`` can take a tuple of classes to replace and this is exponential). This mechanism should be used only in some well placed circumstances for generic addons. """ def __init__(self, service=None, version=None, parent=None, registry=None): if service is None and parent is None: raise ValueError('A service or a parent service is expected') self._service = service self.version = version self.parent = parent self._class_entries = [] if registry is None: registry = BACKENDS registry.register_backend(self)
[docs] def match(self, service, version): """Used to find the backend for a service and a version""" return (self.service == service and self.version == version)
@property
[docs] def service(self): return self._service or self.parent.service
def __str__(self): if self.version: return 'Backend(\'%s\', \'%s\')' % (self.service, self.version) return 'Backend(\'%s\')>' % self.service def __repr__(self): if self.version: return '<Backend \'%s\', \'%s\'>' % (self.service, self.version) return '<Backend \'%s\'>' % self.service def _get_classes(self, base_class, session, model_name): def follow_replacing(entries): candidates = set() for entry in entries: replacings = None if entry.replaced_by: replacings = follow_replacing(entry.replaced_by) if replacings: candidates.update(replacings) # If all the classes supposed to replace the current class # have been discarded, the current class is a candidate. # It happens when the entries in 'replaced_by' are # in modules not installed. if not replacings: if (session.is_module_installed(entry.openerp_module) and issubclass(entry.cls, base_class) and entry.cls.match(session, model_name)): candidates.add(entry.cls) return candidates matching_classes = follow_replacing(self._class_entries) if not matching_classes and self.parent: matching_classes = self.parent._get_classes(base_class, session, model_name) return matching_classes
[docs] def get_class(self, base_class, session, model_name): """ Find a matching subclass of ``base_class`` in the registered classes. :param base_class: class (and its subclass) to search in the registry :type base_class: :py:class:`connector.connector.MetaConnectorUnit` :param session: current session :type session: :py:class:`connector.session.ConnectorSession` """ matching_classes = self._get_classes(base_class, session, model_name) if not matching_classes: raise NoConnectorUnitError('No matching class found for %s ' 'with session: %s, ' 'model name: %s' % (base_class, session, model_name)) assert len(matching_classes) == 1, ( 'Several classes found for %s ' 'with session %s, model name: %s. Found: %s' % (base_class, session, model_name, matching_classes)) return matching_classes.pop()
[docs] def register_class(self, cls, replacing=None): """ Register a class in the backend. :param cls: the ConnectorUnit class class to register :type cls: :py:class:`connector.connector.MetaConnectorUnit` :param replacing: optional, the ConnectorUnit class to replace :type replacing: :py:class:`connector.connector.MetaConnectorUnit` """ def register_replace(replacing_cls): found = False for replaced_entry in self._class_entries: if replaced_entry.cls is replacing_cls: replaced_entry.replaced_by.append(entry) found = True break if not found: raise ValueError('%s replaces an unregistered class: %s' % (cls, replacing)) entry = _ConnectorUnitEntry(cls=cls, openerp_module=cls._openerp_module_, replaced_by=[]) if replacing is not None: if replacing is cls: raise ValueError('%r cannot replace itself' % replacing) if hasattr(replacing, '__iter__'): for replacing_cls in replacing: register_replace(replacing_cls) else: register_replace(replacing) self._class_entries.append(entry)
[docs] def unregister_class(self, cls): """ Deprecated. Was used to remove a ``ConnectorUnit`` from the registry. Now, the ``replacing`` argument of the decorator or ``register_clas()`` should be used. It has been deprecated because it is not safe to unregister a ConnectorUnit due to the way OpenERP works with addons. A python module can be imported even if a addon is not installed. """ raise DeprecationWarning('Backend.unregister_class() is deprecated. ' 'You have to use the replacing argument ' 'of the register_class() method')
[docs] def registered_classes(self, base_class=None): """ Deprecated. """ raise DeprecationWarning('Backend.registered_classes() is deprecated.')
def __call__(self, cls=None, replacing=None): """ Backend decorator For a backend ``magento`` declared like this:: magento = Backend('magento') A :py:class:`connector.connector.ConnectorUnit` (like a binder, a synchronizer, a mapper, ...) can be registered as follows:: @magento class MagentoBinder(Binder): _model_name = 'a.model' # other stuff Thus, by doing:: magento.get_class(Binder, 'a.model') We get the correct class ``MagentoBinder``. Any ``ConnectorUnit`` can be replaced by another doing:: @magento(replacing=MagentoBinder) class MagentoBinder2(Binder): _model_name = 'a.model' # other stuff This is useful when working on an OpenERP module which should alter the original behavior of a connector for an existing backend. :param cls: the ConnectorUnit class class to register :type cls: :py:class:`connector.connector.MetaConnectorUnit` :param replacing: optional, the ConnectorUnit class to replace :type replacing: :py:class:`connector.connector.MetaConnectorUnit` """ if cls is None: return partial(self, replacing=replacing) def with_subscribe(): self.register_class(cls, replacing=replacing) return cls return with_subscribe()