module.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import ast
  4. import collections.abc
  5. import copy
  6. import functools
  7. import importlib
  8. import logging
  9. import os
  10. import pkg_resources
  11. import re
  12. import sys
  13. import warnings
  14. from os.path import join as opj, normpath
  15. import odoo
  16. import odoo.tools as tools
  17. import odoo.release as release
  18. from odoo.tools import pycompat
  19. from odoo.tools.misc import file_path
  20. MANIFEST_NAMES = ('__manifest__.py', '__openerp__.py')
  21. README = ['README.rst', 'README.md', 'README.txt']
  22. _DEFAULT_MANIFEST = {
  23. #addons_path: f'/path/to/the/addons/path/of/{module}', # automatic
  24. 'application': False,
  25. 'bootstrap': False, # web
  26. 'assets': {},
  27. 'author': 'Odoo S.A.',
  28. 'auto_install': False,
  29. 'category': 'Uncategorized',
  30. 'data': [],
  31. 'demo': [],
  32. 'demo_xml': [],
  33. 'depends': [],
  34. 'description': '',
  35. 'external_dependencies': [],
  36. #icon: f'/{module}/static/description/icon.png', # automatic
  37. 'init_xml': [],
  38. 'installable': True,
  39. 'images': [], # website
  40. 'images_preview_theme': {}, # website themes
  41. #license, mandatory
  42. 'live_test_url': '', # website themes
  43. #name, mandatory
  44. 'post_init_hook': '',
  45. 'post_load': None,
  46. 'pre_init_hook': '',
  47. 'sequence': 100,
  48. 'snippet_lists': {}, # website themes
  49. 'summary': '',
  50. 'test': [],
  51. 'update_xml': [],
  52. 'uninstall_hook': '',
  53. 'version': '1.0',
  54. 'web': False,
  55. 'website': '',
  56. }
  57. _logger = logging.getLogger(__name__)
  58. # addons path as a list
  59. # ad_paths is a deprecated alias, please use odoo.addons.__path__
  60. @tools.lazy
  61. def ad_paths():
  62. warnings.warn(
  63. '"odoo.modules.module.ad_paths" is a deprecated proxy to '
  64. '"odoo.addons.__path__".', DeprecationWarning, stacklevel=2)
  65. return odoo.addons.__path__
  66. # Modules already loaded
  67. loaded = []
  68. class AddonsHook(object):
  69. """ Makes modules accessible through openerp.addons.* """
  70. def find_module(self, name, path=None):
  71. if name.startswith('openerp.addons.') and name.count('.') == 2:
  72. warnings.warn(
  73. '"openerp.addons" is a deprecated alias to "odoo.addons".',
  74. DeprecationWarning, stacklevel=2)
  75. return self
  76. def find_spec(self, fullname, path=None, target=None):
  77. if fullname.startswith('openerp.addons.') and fullname.count('.') == 2:
  78. warnings.warn(
  79. '"openerp.addons" is a deprecated alias to "odoo.addons".',
  80. DeprecationWarning, stacklevel=2)
  81. return importlib.util.spec_from_loader(fullname, self)
  82. def load_module(self, name):
  83. assert name not in sys.modules
  84. odoo_name = re.sub(r'^openerp.addons.(\w+)$', r'odoo.addons.\g<1>', name)
  85. odoo_module = sys.modules.get(odoo_name)
  86. if not odoo_module:
  87. odoo_module = importlib.import_module(odoo_name)
  88. sys.modules[name] = odoo_module
  89. return odoo_module
  90. class OdooHook(object):
  91. """ Makes odoo package also available as openerp """
  92. def find_module(self, name, path=None):
  93. # openerp.addons.<identifier> should already be matched by AddonsHook,
  94. # only framework and subdirectories of modules should match
  95. if re.match(r'^openerp\b', name):
  96. warnings.warn(
  97. 'openerp is a deprecated alias to odoo.',
  98. DeprecationWarning, stacklevel=2)
  99. return self
  100. def find_spec(self, fullname, path=None, target=None):
  101. # openerp.addons.<identifier> should already be matched by AddonsHook,
  102. # only framework and subdirectories of modules should match
  103. if re.match(r'^openerp\b', fullname):
  104. warnings.warn(
  105. 'openerp is a deprecated alias to odoo.',
  106. DeprecationWarning, stacklevel=2)
  107. return importlib.util.spec_from_loader(fullname, self)
  108. def load_module(self, name):
  109. assert name not in sys.modules
  110. canonical = re.sub(r'^openerp(.*)', r'odoo\g<1>', name)
  111. if canonical in sys.modules:
  112. mod = sys.modules[canonical]
  113. else:
  114. # probable failure: canonical execution calling old naming -> corecursion
  115. mod = importlib.import_module(canonical)
  116. # just set the original module at the new location. Don't proxy,
  117. # it breaks *-import (unless you can find how `from a import *` lists
  118. # what's supposed to be imported by `*`, and manage to override it)
  119. sys.modules[name] = mod
  120. return sys.modules[name]
  121. class UpgradeHook(object):
  122. """Makes the legacy `migrations` package being `odoo.upgrade`"""
  123. def find_module(self, name, path=None):
  124. if re.match(r"^odoo\.addons\.base\.maintenance\.migrations\b", name):
  125. # We can't trigger a DeprecationWarning in this case.
  126. # In order to be cross-versions, the multi-versions upgrade scripts (0.0.0 scripts),
  127. # the tests, and the common files (utility functions) still needs to import from the
  128. # legacy name.
  129. return self
  130. def find_spec(self, fullname, path=None, target=None):
  131. if re.match(r"^odoo.addons.base.maintenance.migrations\b", fullname):
  132. # We can't trigger a DeprecationWarning in this case.
  133. # In order to be cross-versions, the multi-versions upgrade scripts (0.0.0 scripts),
  134. # the tests, and the common files (utility functions) still needs to import from the
  135. # legacy name.
  136. return importlib.util.spec_from_loader(fullname, self)
  137. def load_module(self, name):
  138. assert name not in sys.modules
  139. canonical_upgrade = name.replace("odoo.addons.base.maintenance.migrations", "odoo.upgrade")
  140. if canonical_upgrade in sys.modules:
  141. mod = sys.modules[canonical_upgrade]
  142. else:
  143. mod = importlib.import_module(canonical_upgrade)
  144. sys.modules[name] = mod
  145. return sys.modules[name]
  146. def initialize_sys_path():
  147. """
  148. Setup the addons path ``odoo.addons.__path__`` with various defaults
  149. and explicit directories.
  150. """
  151. # hook odoo.addons on data dir
  152. dd = os.path.normcase(tools.config.addons_data_dir)
  153. if os.access(dd, os.R_OK) and dd not in odoo.addons.__path__:
  154. odoo.addons.__path__.append(dd)
  155. # hook odoo.addons on addons paths
  156. for ad in tools.config['addons_path'].split(','):
  157. ad = os.path.normcase(os.path.abspath(tools.ustr(ad.strip())))
  158. if ad not in odoo.addons.__path__:
  159. odoo.addons.__path__.append(ad)
  160. # hook odoo.addons on base module path
  161. base_path = os.path.normcase(os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'addons')))
  162. if base_path not in odoo.addons.__path__ and os.path.isdir(base_path):
  163. odoo.addons.__path__.append(base_path)
  164. # hook odoo.upgrade on upgrade-path
  165. from odoo import upgrade
  166. legacy_upgrade_path = os.path.join(base_path, 'base', 'maintenance', 'migrations')
  167. for up in (tools.config['upgrade_path'] or legacy_upgrade_path).split(','):
  168. up = os.path.normcase(os.path.abspath(tools.ustr(up.strip())))
  169. if os.path.isdir(up) and up not in upgrade.__path__:
  170. upgrade.__path__.append(up)
  171. # create decrecated module alias from odoo.addons.base.maintenance.migrations to odoo.upgrade
  172. spec = importlib.machinery.ModuleSpec("odoo.addons.base.maintenance", None, is_package=True)
  173. maintenance_pkg = importlib.util.module_from_spec(spec)
  174. maintenance_pkg.migrations = upgrade
  175. sys.modules["odoo.addons.base.maintenance"] = maintenance_pkg
  176. sys.modules["odoo.addons.base.maintenance.migrations"] = upgrade
  177. # hook deprecated module alias from openerp to odoo and "crm"-like to odoo.addons
  178. if not getattr(initialize_sys_path, 'called', False): # only initialize once
  179. sys.meta_path.insert(0, UpgradeHook())
  180. sys.meta_path.insert(0, OdooHook())
  181. sys.meta_path.insert(0, AddonsHook())
  182. initialize_sys_path.called = True
  183. def get_module_path(module, downloaded=False, display_warning=True):
  184. """Return the path of the given module.
  185. Search the addons paths and return the first path where the given
  186. module is found. If downloaded is True, return the default addons
  187. path if nothing else is found.
  188. """
  189. if re.search(r"[\/\\]", module):
  190. return False
  191. for adp in odoo.addons.__path__:
  192. files = [opj(adp, module, manifest) for manifest in MANIFEST_NAMES] +\
  193. [opj(adp, module + '.zip')]
  194. if any(os.path.exists(f) for f in files):
  195. return opj(adp, module)
  196. if downloaded:
  197. return opj(tools.config.addons_data_dir, module)
  198. if display_warning:
  199. _logger.warning('module %s: module not found', module)
  200. return False
  201. def get_module_filetree(module, dir='.'):
  202. warnings.warn(
  203. "Since 16.0: use os.walk or a recursive glob or something",
  204. DeprecationWarning,
  205. stacklevel=2
  206. )
  207. path = get_module_path(module)
  208. if not path:
  209. return False
  210. dir = os.path.normpath(dir)
  211. if dir == '.':
  212. dir = ''
  213. if dir.startswith('..') or (dir and dir[0] == '/'):
  214. raise Exception('Cannot access file outside the module')
  215. files = odoo.tools.osutil.listdir(path, True)
  216. tree = {}
  217. for f in files:
  218. if not f.startswith(dir):
  219. continue
  220. if dir:
  221. f = f[len(dir)+int(not dir.endswith('/')):]
  222. lst = f.split(os.sep)
  223. current = tree
  224. while len(lst) != 1:
  225. current = current.setdefault(lst.pop(0), {})
  226. current[lst.pop(0)] = None
  227. return tree
  228. def get_resource_path(module, *args):
  229. """Return the full path of a resource of the given module.
  230. :param module: module name
  231. :param list(str) args: resource path components within module
  232. :rtype: str
  233. :return: absolute path to the resource
  234. """
  235. resource_path = opj(module, *args)
  236. try:
  237. return file_path(resource_path)
  238. except (FileNotFoundError, ValueError):
  239. return False
  240. def check_resource_path(mod_path, *args):
  241. resource_path = opj(mod_path, *args)
  242. try:
  243. return file_path(resource_path)
  244. except (FileNotFoundError, ValueError):
  245. return False
  246. # backwards compatibility
  247. get_module_resource = get_resource_path
  248. def get_resource_from_path(path):
  249. """Tries to extract the module name and the resource's relative path
  250. out of an absolute resource path.
  251. If operation is successful, returns a tuple containing the module name, the relative path
  252. to the resource using '/' as filesystem seperator[1] and the same relative path using
  253. os.path.sep seperators.
  254. [1] same convention as the resource path declaration in manifests
  255. :param path: absolute resource path
  256. :rtype: tuple
  257. :return: tuple(module_name, relative_path, os_relative_path) if possible, else None
  258. """
  259. resource = False
  260. sorted_paths = sorted(odoo.addons.__path__, key=len, reverse=True)
  261. for adpath in sorted_paths:
  262. # force trailing separator
  263. adpath = os.path.join(adpath, "")
  264. if os.path.commonprefix([adpath, path]) == adpath:
  265. resource = path.replace(adpath, "", 1)
  266. break
  267. if resource:
  268. relative = resource.split(os.path.sep)
  269. if not relative[0]:
  270. relative.pop(0)
  271. module = relative.pop(0)
  272. return (module, '/'.join(relative), os.path.sep.join(relative))
  273. return None
  274. def get_module_icon(module):
  275. iconpath = ['static', 'description', 'icon.png']
  276. if get_module_resource(module, *iconpath):
  277. return ('/' + module + '/') + '/'.join(iconpath)
  278. return '/base/' + '/'.join(iconpath)
  279. def get_module_icon_path(module):
  280. iconpath = ['static', 'description', 'icon.png']
  281. path = get_module_resource(module.name, *iconpath)
  282. if not path:
  283. path = get_module_resource('base', *iconpath)
  284. return path
  285. def module_manifest(path):
  286. """Returns path to module manifest if one can be found under `path`, else `None`."""
  287. if not path:
  288. return None
  289. for manifest_name in MANIFEST_NAMES:
  290. if os.path.isfile(opj(path, manifest_name)):
  291. return opj(path, manifest_name)
  292. def get_module_root(path):
  293. """
  294. Get closest module's root beginning from path
  295. # Given:
  296. # /foo/bar/module_dir/static/src/...
  297. get_module_root('/foo/bar/module_dir/static/')
  298. # returns '/foo/bar/module_dir'
  299. get_module_root('/foo/bar/module_dir/')
  300. # returns '/foo/bar/module_dir'
  301. get_module_root('/foo/bar')
  302. # returns None
  303. @param path: Path from which the lookup should start
  304. @return: Module root path or None if not found
  305. """
  306. while not module_manifest(path):
  307. new_path = os.path.abspath(opj(path, os.pardir))
  308. if path == new_path:
  309. return None
  310. path = new_path
  311. return path
  312. def load_manifest(module, mod_path=None):
  313. """ Load the module manifest from the file system. """
  314. if not mod_path:
  315. mod_path = get_module_path(module, downloaded=True)
  316. manifest_file = module_manifest(mod_path)
  317. if not manifest_file:
  318. _logger.debug('module %s: no manifest file found %s', module, MANIFEST_NAMES)
  319. return {}
  320. manifest = copy.deepcopy(_DEFAULT_MANIFEST)
  321. manifest['icon'] = get_module_icon(module)
  322. with tools.file_open(manifest_file, mode='r') as f:
  323. manifest.update(ast.literal_eval(f.read()))
  324. if not manifest['description']:
  325. readme_path = [opj(mod_path, x) for x in README
  326. if os.path.isfile(opj(mod_path, x))]
  327. if readme_path:
  328. with tools.file_open(readme_path[0]) as fd:
  329. manifest['description'] = fd.read()
  330. if not manifest.get('license'):
  331. manifest['license'] = 'LGPL-3'
  332. _logger.warning("Missing `license` key in manifest for %r, defaulting to LGPL-3", module)
  333. # auto_install is either `False` (by default) in which case the module
  334. # is opt-in, either a list of dependencies in which case the module is
  335. # automatically installed if all dependencies are (special case: [] to
  336. # always install the module), either `True` to auto-install the module
  337. # in case all dependencies declared in `depends` are installed.
  338. if isinstance(manifest['auto_install'], collections.abc.Iterable):
  339. manifest['auto_install'] = set(manifest['auto_install'])
  340. non_dependencies = manifest['auto_install'].difference(manifest['depends'])
  341. assert not non_dependencies,\
  342. "auto_install triggers must be dependencies, found " \
  343. "non-dependencies [%s] for module %s" % (
  344. ', '.join(non_dependencies), module
  345. )
  346. elif manifest['auto_install']:
  347. manifest['auto_install'] = set(manifest['depends'])
  348. manifest['version'] = adapt_version(manifest['version'])
  349. manifest['addons_path'] = normpath(opj(mod_path, os.pardir))
  350. return manifest
  351. @functools.lru_cache(maxsize=None)
  352. def get_manifest(module, mod_path=None):
  353. """
  354. Get the module manifest.
  355. :param str module: The name of the module (sale, purchase, ...).
  356. :param Optional[str] mod_path: The optional path to the module on
  357. the file-system. If not set, it is determined by scanning the
  358. addons-paths.
  359. :returns: The module manifest as a dict or an empty dict
  360. when the manifest was not found.
  361. :rtype: dict
  362. """
  363. return load_manifest(module, mod_path)
  364. def load_information_from_description_file(module, mod_path=None):
  365. warnings.warn(
  366. 'load_information_from_description_file() is a deprecated '
  367. 'alias to get_manifest()', DeprecationWarning, stacklevel=2)
  368. return get_manifest(module, mod_path)
  369. def load_openerp_module(module_name):
  370. """ Load an OpenERP module, if not already loaded.
  371. This loads the module and register all of its models, thanks to either
  372. the MetaModel metaclass, or the explicit instantiation of the model.
  373. This is also used to load server-wide module (i.e. it is also used
  374. when there is no model to register).
  375. """
  376. global loaded
  377. if module_name in loaded:
  378. return
  379. try:
  380. __import__('odoo.addons.' + module_name)
  381. # Call the module's post-load hook. This can done before any model or
  382. # data has been initialized. This is ok as the post-load hook is for
  383. # server-wide (instead of registry-specific) functionalities.
  384. info = get_manifest(module_name)
  385. if info['post_load']:
  386. getattr(sys.modules['odoo.addons.' + module_name], info['post_load'])()
  387. except Exception as e:
  388. msg = "Couldn't load module %s" % (module_name)
  389. _logger.critical(msg)
  390. _logger.critical(e)
  391. raise
  392. else:
  393. loaded.append(module_name)
  394. def get_modules():
  395. """Returns the list of module names
  396. """
  397. def listdir(dir):
  398. def clean(name):
  399. name = os.path.basename(name)
  400. if name[-4:] == '.zip':
  401. name = name[:-4]
  402. return name
  403. def is_really_module(name):
  404. for mname in MANIFEST_NAMES:
  405. if os.path.isfile(opj(dir, name, mname)):
  406. return True
  407. return [
  408. clean(it)
  409. for it in os.listdir(dir)
  410. if is_really_module(it)
  411. ]
  412. plist = []
  413. for ad in odoo.addons.__path__:
  414. if not os.path.exists(ad):
  415. _logger.warning("addons path does not exist: %s", ad)
  416. continue
  417. plist.extend(listdir(ad))
  418. return list(set(plist))
  419. def get_modules_with_version():
  420. modules = get_modules()
  421. res = dict.fromkeys(modules, adapt_version('1.0'))
  422. for module in modules:
  423. try:
  424. info = get_manifest(module)
  425. res[module] = info['version']
  426. except Exception:
  427. continue
  428. return res
  429. def adapt_version(version):
  430. serie = release.major_version
  431. if version == serie or not version.startswith(serie + '.'):
  432. version = '%s.%s' % (serie, version)
  433. return version
  434. current_test = None
  435. def check_python_external_dependency(pydep):
  436. try:
  437. pkg_resources.get_distribution(pydep)
  438. except pkg_resources.DistributionNotFound as e:
  439. try:
  440. importlib.import_module(pydep)
  441. _logger.info("python external dependency on '%s' does not appear to be a valid PyPI package. Using a PyPI package name is recommended.", pydep)
  442. except ImportError:
  443. # backward compatibility attempt failed
  444. _logger.warning("DistributionNotFound: %s", e)
  445. raise Exception('Python library not installed: %s' % (pydep,))
  446. except pkg_resources.VersionConflict as e:
  447. _logger.warning("VersionConflict: %s", e)
  448. raise Exception('Python library version conflict: %s' % (pydep,))
  449. except Exception as e:
  450. _logger.warning("get_distribution(%s) failed: %s", pydep, e)
  451. raise Exception('Error finding python library %s' % (pydep,))
  452. def check_manifest_dependencies(manifest):
  453. depends = manifest.get('external_dependencies')
  454. if not depends:
  455. return
  456. for pydep in depends.get('python', []):
  457. check_python_external_dependency(pydep)
  458. for binary in depends.get('bin', []):
  459. try:
  460. tools.find_in_path(binary)
  461. except IOError:
  462. raise Exception('Unable to find %r in path' % (binary,))