component_package.py 15.6 KB
Newer Older
1
# -*- coding: utf-8 -*-
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
##############################################################################
#
# Copyright (c) 2012 Nexedi SARL and Contributors. All Rights Reserved.
#                    Arnaud Fontaine <arnaud.fontaine@nexedi.com>
#
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsibility of assessing all potential
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
# guarantees and support are strongly adviced to contract a Free Software
# Service Company
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
##############################################################################

30 31 32
# There is absolutely no reason to use relative imports when loading a Component
from __future__ import absolute_import

33
import sys
34
import imp
35
import collections
36

37
from Products.ERP5.ERP5Site import getSite
38
from . import aq_method_lock
39
from types import ModuleType
40
from zLOG import LOG, BLATHER, WARNING
41
from Acquisition import aq_base
42

43 44 45 46 47 48
class ComponentVersionPackage(ModuleType):
  """
  Component Version package (erp5.component.XXX.VERSION)
  """
  __path__ = []

49
class ComponentDynamicPackage(ModuleType):
50 51 52 53 54
  """
  A top-level component is a package as it contains modules, this is required
  to be able to add import hooks (as described in PEP 302) when a in the
  source code of a Component, another Component is imported.

55 56 57 58 59 60 61 62 63 64 65
  A Component is loaded when being imported, for example in a Document
  Component with ``import erp5.component.XXX.YYY'', through the Importer
  Protocol (PEP 302), by adding an instance of this class to sys.meta_path and
  through find_module() and load_module() methods. The latter method takes
  care of loading the code into a new module.

  This is required because Component classes do not have any physical location
  on the filesystem, however extra care must be taken for performances because
  load_module() will be called each time an import is done, therefore the
  loader should be added to sys.meta_path as late as possible to keep startup
  time to the minimum.
66 67 68 69 70 71
  """
  # Necessary otherwise imports will fail because an object is considered a
  # package only if __path__ is defined
  __path__ = []

  def __init__(self, namespace, portal_type):
72
    super(ComponentDynamicPackage, self).__init__(namespace)
73 74 75

    self._namespace = namespace
    self._namespace_prefix = namespace + '.'
76
    self._id_prefix = namespace.rsplit('.', 1)[1]
77
    self._portal_type = portal_type
78
    self.__version_suffix_len = len('_version')
79
    self.__fullname_source_code_dict = {}
80

81 82 83 84 85 86
    # Add this module to sys.path for future imports
    sys.modules[namespace] = self

    # Add the import hook
    sys.meta_path.append(self)

87 88
  def get_source(self, fullname):
    """
89 90
    PEP-302 function to get the source code, used mainly by linecache for
    tracebacks, pdb...
91

92 93 94 95 96
    Use internal cache rather than accessing the Component directly as this
    would require accessing ERP5 Site even though the source code may be
    retrieved outside of ERP5 (eg DeadlockDebugguer).
    """
    return self.__fullname_source_code_dict.get(fullname)
97

98
  def find_module(self, fullname, path=None):
99 100 101 102 103 104 105 106 107 108
    """
    PEP-302 Finder which determines which packages and modules will be handled
    by this class. It must be done carefully to avoid handling packages and
    modules the Loader (load_module()) will not be handled later as the latter
    would raise ImportError...

    As per PEP-302, returns None if this Finder cannot handle the given name,
    perhaps because the Finder of another Component Package could do it or
    because this is a filesystem module...
    """
109 110 111 112
    # Ignore imports with a path which are filesystem-only and any
    # absolute imports which does not start with this package prefix,
    # None there means that "normal" sys.path will be used
    if path or not fullname.startswith(self._namespace_prefix):
113 114
      return None

115 116 117 118 119 120 121 122 123 124 125 126 127
    import_lock_held = True
    try:
      imp.release_lock()
    except RuntimeError:
      import_lock_held = False

    try:
      site = getSite()

      # __import__ will first try a relative import, for example
      # erp5.component.XXX.YYY.ZZZ where erp5.component.XXX.YYY is the current
      # Component where an import is done
      name = fullname[len(self._namespace_prefix):]
128
      # name=VERSION_version.REFERENCE
129 130 131 132 133 134 135
      if '.' in name:
        try:
          version, name = name.split('.')
          version = version[:-self.__version_suffix_len]
        except ValueError:
          return None

136 137 138
        id_ = "%s.%s.%s" % (self._id_prefix, version, name)
        # aq_base() because this should not go up to ERP5Site and trigger
        # side-effects, after all this only check for existence...
139 140 141 142 143 144 145
        try:
          component_tool = aq_base(site.portal_components)
        except AttributeError:
          # For old sites, just use FS Documents...
          return None

        component = getattr(component_tool, id_, None)
146 147
        if component is None or component.getValidationState() not in ('modified',
                                                                       'validated'):
148 149 150 151 152
          return None

      # Skip unavailable components, otherwise Products for example could be
      # wrongly considered as importable and thus the actual filesystem class
      # ignored
153 154 155 156 157 158 159 160
      #
      # name=VERSION_version
      elif name.endswith('_version'):
        if name[:-self.__version_suffix_len] not in site.getVersionPriorityNameList():
          return None

      # name=REFERENCE
      else:
161 162 163 164 165 166
        try:
          component_tool = aq_base(site.portal_components)
        except AttributeError:
          # For old sites, just use FS Documents...
          return None

167 168 169 170 171 172 173 174
        for version in site.getVersionPriorityNameList():
          id_ = "%s.%s.%s" % (self._id_prefix, version, name)
          component = getattr(component_tool, id_, None)
          if component is not None and component.getValidationState() in ('modified',
                                                                          'validated'):
            break
        else:
          return None
175

176
      return self
177

178 179 180 181 182
    finally:
      # Internal release of import lock at the end of import machinery will
      # fail if the hook is not acquired
      if import_lock_held:
        imp.acquire_lock()
183

184
  def _getVersionPackage(self, version):
185 186 187 188 189 190 191
    """
    Get the version package (NAMESPACE.VERSION_version) for the given version
    and create it if it does not already exist
    """
    # Version are appended with '_version' to distinguish them from top-level
    # Component modules (Component checkConsistency() forbids Component name
    # ending with _version)
192 193 194
    version += '_version'
    version_package = getattr(self, version, None)
    if version_package is None:
195
      version_package_name = self._namespace + '.' + version
196 197 198 199 200 201 202

      version_package = ComponentVersionPackage(version_package_name)
      sys.modules[version_package_name] = version_package
      setattr(self, version, version_package)

    return version_package

203
  def __load_module(self, fullname):
204
    """
205 206 207 208 209 210 211 212 213 214 215 216
    Load a module with given fullname (see PEP 302) if it's not already in
    sys.modules. It is assumed that imports are filtered properly in
    find_module().

    Also, when the top-level Component module is requested
    (erp5.component.XXX.COMPONENT_NAME), the Component with the highest
    version priority will be loaded into the Version package
    (erp5.component.XXX.VERSION_version.COMPONENT_NAME. Therefore, the
    top-level Component module will just be an alias of the versioned one.

    As per PEP-302, raise an ImportError if the Loader could not load the
    module for any reason...
217
    """
218 219
    site = getSite()
    name = fullname[len(self._namespace_prefix):]
220

221 222 223 224 225 226
    # if only Version package (erp5.component.XXX.VERSION_version) is
    # requested to be loaded, then create it if necessary
    if name.endswith('_version'):
      version = name[:-self.__version_suffix_len]
      return (version in site.getVersionPriorityNameList() and
              self._getVersionPackage(version) or None)
227

228 229
    module_fullname_alias = None
    version_package_name = name[:-self.__version_suffix_len]
230

231 232 233 234 235 236 237 238
    # If a specific version of the Component has been requested
    if '.' in name:
      try:
        version, name = name.split('.')
        version = version[:-self.__version_suffix_len]
      except ValueError, error:
        raise ImportError("%s: should be %s.VERSION.COMPONENT_REFERENCE (%s)" % \
                            (fullname, self._namespace, error))
239

240
      component_id = "%s.%s.%s" % (self._id_prefix, version, name)
241

242 243
    # Otherwise, find the Component with the highest version priority
    else:
244
      component_tool = aq_base(site.portal_components)
245 246
      # Version priority name list is ordered in descending order
      for version in site.getVersionPriorityNameList():
247 248 249 250
        component_id = "%s.%s.%s" % (self._id_prefix, version, name)
        component = getattr(component_tool, component_id, None)
        if component is not None and component.getValidationState() in ('modified',
                                                                        'validated'):
251
          break
252
      else:
253 254
        raise ImportError("%s: no version of Component %s in Site priority" % \
                            (fullname, name))
255

256 257 258 259 260 261 262 263 264
      # Check whether this module has already been loaded before for a
      # specific version, if so, just add it to the upper level
      try:
        module = getattr(getattr(self, version + '_version'), name)
      except AttributeError:
        pass
      else:
        setattr(self, name, module)
        return module
265

266
      module_fullname_alias = self._namespace + '.' + name
267

268 269
    component = getattr(site.portal_components, component_id)
    relative_url = component.getRelativeUrl()
270

271 272
    module_fullname = '%s.%s_version.%s' % (self._namespace, version, name)
    module = ModuleType(module_fullname, component.getDescription())
273

274 275
    source_code_str = component.getTextContent(validated_only=True)
    version_package = self._getVersionPackage(version)
276 277 278

    # All the required objects have been loaded, acquire import lock to modify
    # sys.modules and execute PEP302 requisites
279
    imp.acquire_lock()
280
    try:
281 282 283
      # The module *must* be in sys.modules before executing the code in case
      # the module code imports (directly or indirectly) itself (see PEP 302)
      sys.modules[module_fullname] = module
284
      if module_fullname_alias:
285
        sys.modules[module_fullname_alias] = module
286

287
      # This must be set for imports at least (see PEP 302)
288
      module.__file__ = '<' + relative_url + '>'
289

290 291 292 293 294 295 296
      # Only useful for get_source(), do it before exec'ing the source code
      # so that the source code is properly display in case of error
      module.__loader__ = self
      module.__path__ = []
      module.__name__ = module_fullname
      self.__fullname_source_code_dict[module_fullname] = source_code_str

297 298 299
      try:
        # XXX: Any loading from ZODB while exec'ing the source code will result
        # in a deadlock
300 301
        source_code_obj = compile(source_code_str, module.__file__, 'exec')
        exec source_code_obj in module.__dict__
302 303 304 305 306
      except Exception, error:
        del sys.modules[module_fullname]
        if module_fullname_alias:
          del sys.modules[module_fullname_alias]

307 308 309
        raise ImportError(
          "%s: cannot load Component %s (%s)" % (fullname, name, error)), \
          None, sys.exc_info()[2]
310 311 312 313 314 315

      # Add the newly created module to the Version package and add it as an
      # alias to the top-level package as well
      setattr(version_package, name, module)
      if module_fullname_alias:
        setattr(self, name, module)
316

317 318
      import erp5.component
      erp5.component.ref_manager.add_module(module)
319

320 321
      return module
    finally:
322
      imp.release_lock()
323

324 325 326 327 328
  def load_module(self, fullname):
    """
    Make sure that loading module is thread-safe using aq_method_lock to make
    sure that modules do not disappear because of an ongoing reset
    """
329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348
    # In Python < 3.3, the import lock is a global lock for all modules:
    # http://bugs.python.org/issue9260
    #
    # So, release the import lock acquired by import statement on all hooks to
    # load objects from ZODB. When an object is requested from ZEO, it sends a
    # RPC request and lets the asyncore thread gets the reply. This reply may
    # be a tuple (PICKLE, TID), sent directly to the first thread, or an
    # Exception, which tries to import a ZODB module and thus creates a
    # deadlock because of the global import lock
    #
    # Also, handle the case where find_module() may be called without import
    # statement as it does not change anything in sys.modules
    import_lock_held = True
    try:
      imp.release_lock()
    except RuntimeError:
      import_lock_held = False

    aq_method_lock.acquire()
    try:
349
      return self.__load_module(fullname)
350 351 352 353 354 355 356
    finally:
      aq_method_lock.release()

      # Internal release of import lock at the end of import machinery will
      # fail if the hook is not acquired
      if import_lock_held:
        imp.acquire_lock()
357

358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383
  def find_load_module(self, name):
    """
    Find and load a Component module.

    When FS fallback is required (mainly for Document and Extension), this
    should be used over a plain import to distinguish a document not available
    as ZODB Component to an error in a Component, especially because in the
    latter case only ImportError can be raised (PEP-302).

    For example: if a Component tries to import another Component module but
    the latter has been disabled and there is a fallback on the filesystem, a
    plain import would hide the real error, instead log it...
    """
    fullname = self._namespace + '.' + name
    loader = self.find_module(fullname)
    if loader is not None:
      try:
        return loader.load_module(fullname)
      except ImportError, e:
        import traceback
        LOG("ERP5Type.dynamic", WARNING,
            "Could not load Component module '%s'\n%s" % (fullname,
                                                          traceback.format_exc()))

    return None

384 385 386 387 388 389
  def reset(self, sub_package=None):
    """
    Reset the content of the current package and its version package as well
    recursively. This method must be called within a lock to avoid side
    effects
    """
Arnaud Fontaine's avatar
Arnaud Fontaine committed
390 391 392
    if sub_package:
      package = sub_package
    else:
393
      # Clear the source code dict only once
394
      self.__fullname_source_code_dict.clear()
395 396 397 398 399 400 401 402 403 404
      package = self

    for name, module in package.__dict__.items():
      if name[0] == '_' or not isinstance(module, ModuleType):
        continue

      # Reset the content of the version package before resetting it
      elif isinstance(module, ComponentVersionPackage):
        self.reset(sub_package=module)

405
      module_name = package.__name__ + '.' + name
406 407 408 409 410
      LOG("ERP5Type.Tool.ComponentTool", BLATHER, "Resetting " + module_name)

      # The module must be deleted first from sys.modules to avoid imports in
      # the meantime
      del sys.modules[module_name]
411

412
      delattr(package, name)