gooderp18绿色标准版
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

513 lines
18KB

  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 importlib.metadata
  9. import logging
  10. import os
  11. import re
  12. import sys
  13. import traceback
  14. import warnings
  15. from os.path import join as opj, normpath
  16. import odoo
  17. import odoo.tools as tools
  18. import odoo.release as release
  19. from odoo.tools.misc import file_path
  20. try:
  21. from packaging.requirements import InvalidRequirement, Requirement
  22. except ImportError:
  23. class InvalidRequirement(Exception):
  24. ...
  25. class Requirement:
  26. def __init__(self, pydep):
  27. if not re.fullmatch(r'[\w\-]+', pydep): # check that we have no versions or marker in pydep
  28. msg = f"Package `packaging` is required to parse `{pydep}` external dependency and is not installed"
  29. raise Exception(msg)
  30. self.marker = None
  31. self.specifier = None
  32. self.name = pydep
  33. MANIFEST_NAMES = ('__manifest__.py', '__openerp__.py')
  34. README = ['README.rst', 'README.md', 'README.txt']
  35. _DEFAULT_MANIFEST = {
  36. #addons_path: f'/path/to/the/addons/path/of/{module}', # automatic
  37. 'application': False,
  38. 'bootstrap': False, # web
  39. 'assets': {},
  40. 'author': 'Odoo S.A.',
  41. 'auto_install': False,
  42. 'category': 'Uncategorized',
  43. 'cloc_exclude': [],
  44. 'configurator_snippets': {}, # website themes
  45. 'countries': [],
  46. 'data': [],
  47. 'demo': [],
  48. 'demo_xml': [],
  49. 'depends': [],
  50. 'description': '',
  51. 'external_dependencies': {},
  52. #icon: f'/{module}/static/description/icon.png', # automatic
  53. 'init_xml': [],
  54. 'installable': True,
  55. 'images': [], # website
  56. 'images_preview_theme': {}, # website themes
  57. #license, mandatory
  58. 'live_test_url': '', # website themes
  59. 'new_page_templates': {}, # website themes
  60. #name, mandatory
  61. 'post_init_hook': '',
  62. 'post_load': '',
  63. 'pre_init_hook': '',
  64. 'sequence': 100,
  65. 'summary': '',
  66. 'test': [],
  67. 'update_xml': [],
  68. 'uninstall_hook': '',
  69. 'version': '1.0',
  70. 'web': False,
  71. 'website': '',
  72. }
  73. # matches field definitions like
  74. # partner_id: base.ResPartner = fields.Many2one
  75. # partner_id = fields.Many2one[base.ResPartner]
  76. TYPED_FIELD_DEFINITION_RE = re.compile(r'''
  77. \b (?P<field_name>\w+) \s*
  78. (:\s*(?P<field_type>[^ ]*))? \s*
  79. = \s*
  80. fields\.(?P<field_class>Many2one|One2many|Many2many)
  81. (\[(?P<type_param>[^\]]+)\])?
  82. ''', re.VERBOSE)
  83. _logger = logging.getLogger(__name__)
  84. class UpgradeHook(object):
  85. """Makes the legacy `migrations` package being `odoo.upgrade`"""
  86. def find_spec(self, fullname, path=None, target=None):
  87. if re.match(r"^odoo\.addons\.base\.maintenance\.migrations\b", fullname):
  88. # We can't trigger a DeprecationWarning in this case.
  89. # In order to be cross-versions, the multi-versions upgrade scripts (0.0.0 scripts),
  90. # the tests, and the common files (utility functions) still needs to import from the
  91. # legacy name.
  92. return importlib.util.spec_from_loader(fullname, self)
  93. def load_module(self, name):
  94. assert name not in sys.modules
  95. canonical_upgrade = name.replace("odoo.addons.base.maintenance.migrations", "odoo.upgrade")
  96. if canonical_upgrade in sys.modules:
  97. mod = sys.modules[canonical_upgrade]
  98. else:
  99. mod = importlib.import_module(canonical_upgrade)
  100. sys.modules[name] = mod
  101. return sys.modules[name]
  102. def initialize_sys_path():
  103. """
  104. Setup the addons path ``odoo.addons.__path__`` with various defaults
  105. and explicit directories.
  106. """
  107. # hook odoo.addons on data dir
  108. dd = os.path.normcase(tools.config.addons_data_dir)
  109. if os.access(dd, os.R_OK) and dd not in odoo.addons.__path__:
  110. odoo.addons.__path__.append(dd)
  111. # hook odoo.addons on addons paths
  112. for ad in tools.config['addons_path'].split(','):
  113. ad = os.path.normcase(os.path.abspath(ad.strip()))
  114. if ad not in odoo.addons.__path__:
  115. odoo.addons.__path__.append(ad)
  116. # hook odoo.addons on base module path
  117. base_path = os.path.normcase(os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'addons')))
  118. if base_path not in odoo.addons.__path__ and os.path.isdir(base_path):
  119. odoo.addons.__path__.append(base_path)
  120. # hook odoo.upgrade on upgrade-path
  121. from odoo import upgrade
  122. legacy_upgrade_path = os.path.join(base_path, 'base', 'maintenance', 'migrations')
  123. for up in (tools.config['upgrade_path'] or legacy_upgrade_path).split(','):
  124. up = os.path.normcase(os.path.abspath(up.strip()))
  125. if os.path.isdir(up) and up not in upgrade.__path__:
  126. upgrade.__path__.append(up)
  127. # create decrecated module alias from odoo.addons.base.maintenance.migrations to odoo.upgrade
  128. spec = importlib.machinery.ModuleSpec("odoo.addons.base.maintenance", None, is_package=True)
  129. maintenance_pkg = importlib.util.module_from_spec(spec)
  130. maintenance_pkg.migrations = upgrade
  131. sys.modules["odoo.addons.base.maintenance"] = maintenance_pkg
  132. sys.modules["odoo.addons.base.maintenance.migrations"] = upgrade
  133. # hook deprecated module alias from openerp to odoo and "crm"-like to odoo.addons
  134. if not getattr(initialize_sys_path, 'called', False): # only initialize once
  135. sys.meta_path.insert(0, UpgradeHook())
  136. initialize_sys_path.called = True
  137. def get_module_path(module, downloaded=False, display_warning=True):
  138. """Return the path of the given module.
  139. Search the addons paths and return the first path where the given
  140. module is found. If downloaded is True, return the default addons
  141. path if nothing else is found.
  142. """
  143. if re.search(r"[\/\\]", module):
  144. return False
  145. for adp in odoo.addons.__path__:
  146. files = [opj(adp, module, manifest) for manifest in MANIFEST_NAMES] +\
  147. [opj(adp, module + '.zip')]
  148. if any(os.path.exists(f) for f in files):
  149. return opj(adp, module)
  150. if downloaded:
  151. return opj(tools.config.addons_data_dir, module)
  152. if display_warning:
  153. _logger.warning('module %s: module not found', module)
  154. return False
  155. def get_resource_path(module, *args):
  156. """Return the full path of a resource of the given module.
  157. :param module: module name
  158. :param list(str) args: resource path components within module
  159. :rtype: str
  160. :return: absolute path to the resource
  161. """
  162. warnings.warn(
  163. f"Since 17.0: use tools.misc.file_path instead of get_resource_path({module}, {args})",
  164. DeprecationWarning,
  165. )
  166. resource_path = opj(module, *args)
  167. try:
  168. return file_path(resource_path)
  169. except (FileNotFoundError, ValueError):
  170. return False
  171. # backwards compatibility
  172. get_module_resource = get_resource_path
  173. check_resource_path = get_resource_path
  174. def get_resource_from_path(path):
  175. """Tries to extract the module name and the resource's relative path
  176. out of an absolute resource path.
  177. If operation is successful, returns a tuple containing the module name, the relative path
  178. to the resource using '/' as filesystem seperator[1] and the same relative path using
  179. os.path.sep seperators.
  180. [1] same convention as the resource path declaration in manifests
  181. :param path: absolute resource path
  182. :rtype: tuple
  183. :return: tuple(module_name, relative_path, os_relative_path) if possible, else None
  184. """
  185. resource = False
  186. sorted_paths = sorted(odoo.addons.__path__, key=len, reverse=True)
  187. for adpath in sorted_paths:
  188. # force trailing separator
  189. adpath = os.path.join(adpath, "")
  190. if os.path.commonprefix([adpath, path]) == adpath:
  191. resource = path.replace(adpath, "", 1)
  192. break
  193. if resource:
  194. relative = resource.split(os.path.sep)
  195. if not relative[0]:
  196. relative.pop(0)
  197. module = relative.pop(0)
  198. return (module, '/'.join(relative), os.path.sep.join(relative))
  199. return None
  200. def get_module_icon(module):
  201. fpath = f"{module}/static/description/icon.png"
  202. try:
  203. file_path(fpath)
  204. return "/" + fpath
  205. except FileNotFoundError:
  206. return "/base/static/description/icon.png"
  207. def get_module_icon_path(module):
  208. try:
  209. return file_path(f"{module}/static/description/icon.png")
  210. except FileNotFoundError:
  211. return file_path("base/static/description/icon.png")
  212. def module_manifest(path):
  213. """Returns path to module manifest if one can be found under `path`, else `None`."""
  214. if not path:
  215. return None
  216. for manifest_name in MANIFEST_NAMES:
  217. candidate = opj(path, manifest_name)
  218. if os.path.isfile(candidate):
  219. if manifest_name == '__openerp__.py':
  220. warnings.warn(
  221. "__openerp__.py manifests are deprecated since 17.0, "
  222. f"rename {candidate!r} to __manifest__.py "
  223. "(valid since 10.0)",
  224. category=DeprecationWarning
  225. )
  226. return candidate
  227. def get_module_root(path):
  228. """
  229. Get closest module's root beginning from path
  230. # Given:
  231. # /foo/bar/module_dir/static/src/...
  232. get_module_root('/foo/bar/module_dir/static/')
  233. # returns '/foo/bar/module_dir'
  234. get_module_root('/foo/bar/module_dir/')
  235. # returns '/foo/bar/module_dir'
  236. get_module_root('/foo/bar')
  237. # returns None
  238. @param path: Path from which the lookup should start
  239. @return: Module root path or None if not found
  240. """
  241. while not module_manifest(path):
  242. new_path = os.path.abspath(opj(path, os.pardir))
  243. if path == new_path:
  244. return None
  245. path = new_path
  246. return path
  247. def load_manifest(module, mod_path=None):
  248. """ Load the module manifest from the file system. """
  249. if not mod_path:
  250. mod_path = get_module_path(module, downloaded=True)
  251. manifest_file = module_manifest(mod_path)
  252. if not manifest_file:
  253. _logger.debug('module %s: no manifest file found %s', module, MANIFEST_NAMES)
  254. return {}
  255. manifest = copy.deepcopy(_DEFAULT_MANIFEST)
  256. manifest['icon'] = get_module_icon(module)
  257. with tools.file_open(manifest_file, mode='r') as f:
  258. manifest.update(ast.literal_eval(f.read()))
  259. if not manifest['description']:
  260. readme_path = [opj(mod_path, x) for x in README
  261. if os.path.isfile(opj(mod_path, x))]
  262. if readme_path:
  263. with tools.file_open(readme_path[0]) as fd:
  264. manifest['description'] = fd.read()
  265. if not manifest.get('license'):
  266. manifest['license'] = 'LGPL-3'
  267. _logger.warning("Missing `license` key in manifest for %r, defaulting to LGPL-3", module)
  268. # auto_install is either `False` (by default) in which case the module
  269. # is opt-in, either a list of dependencies in which case the module is
  270. # automatically installed if all dependencies are (special case: [] to
  271. # always install the module), either `True` to auto-install the module
  272. # in case all dependencies declared in `depends` are installed.
  273. if isinstance(manifest['auto_install'], collections.abc.Iterable):
  274. manifest['auto_install'] = set(manifest['auto_install'])
  275. non_dependencies = manifest['auto_install'].difference(manifest['depends'])
  276. assert not non_dependencies,\
  277. "auto_install triggers must be dependencies, found " \
  278. "non-dependencies [%s] for module %s" % (
  279. ', '.join(non_dependencies), module
  280. )
  281. elif manifest['auto_install']:
  282. manifest['auto_install'] = set(manifest['depends'])
  283. try:
  284. manifest['version'] = adapt_version(manifest['version'])
  285. except ValueError as e:
  286. if manifest.get("installable", True):
  287. raise ValueError(f"Module {module}: invalid manifest") from e
  288. manifest['addons_path'] = normpath(opj(mod_path, os.pardir))
  289. return manifest
  290. def get_manifest(module, mod_path=None):
  291. """
  292. Get the module manifest.
  293. :param str module: The name of the module (sale, purchase, ...).
  294. :param Optional[str] mod_path: The optional path to the module on
  295. the file-system. If not set, it is determined by scanning the
  296. addons-paths.
  297. :returns: The module manifest as a dict or an empty dict
  298. when the manifest was not found.
  299. :rtype: dict
  300. """
  301. return copy.deepcopy(_get_manifest_cached(module, mod_path))
  302. @functools.lru_cache(maxsize=None)
  303. def _get_manifest_cached(module, mod_path=None):
  304. return load_manifest(module, mod_path)
  305. def load_openerp_module(module_name):
  306. """ Load an OpenERP module, if not already loaded.
  307. This loads the module and register all of its models, thanks to either
  308. the MetaModel metaclass, or the explicit instantiation of the model.
  309. This is also used to load server-wide module (i.e. it is also used
  310. when there is no model to register).
  311. """
  312. qualname = f'odoo.addons.{module_name}'
  313. if qualname in sys.modules:
  314. return
  315. try:
  316. __import__(qualname)
  317. # Call the module's post-load hook. This can done before any model or
  318. # data has been initialized. This is ok as the post-load hook is for
  319. # server-wide (instead of registry-specific) functionalities.
  320. info = get_manifest(module_name)
  321. if info['post_load']:
  322. getattr(sys.modules[qualname], info['post_load'])()
  323. except AttributeError as err:
  324. _logger.critical("Couldn't load module %s", module_name)
  325. trace = traceback.format_exc()
  326. match = TYPED_FIELD_DEFINITION_RE.search(trace)
  327. if match and "most likely due to a circular import" in trace:
  328. field_name = match['field_name']
  329. field_class = match['field_class']
  330. field_type = match['field_type'] or match['type_param']
  331. if "." not in field_type:
  332. field_type = f"{module_name}.{field_type}"
  333. raise AttributeError(
  334. f"{err}\n"
  335. "To avoid circular import for the the comodel use the annotation syntax:\n"
  336. f" {field_name}: {field_type} = fields.{field_class}(...)\n"
  337. "and add at the beggining of the file:\n"
  338. " from __future__ import annotations"
  339. ).with_traceback(err.__traceback__) from None
  340. raise
  341. except Exception:
  342. _logger.critical("Couldn't load module %s", module_name)
  343. raise
  344. def get_modules():
  345. """Returns the list of module names
  346. """
  347. def listdir(dir):
  348. def clean(name):
  349. name = os.path.basename(name)
  350. if name[-4:] == '.zip':
  351. name = name[:-4]
  352. return name
  353. def is_really_module(name):
  354. for mname in MANIFEST_NAMES:
  355. if os.path.isfile(opj(dir, name, mname)):
  356. return True
  357. return [
  358. clean(it)
  359. for it in os.listdir(dir)
  360. if is_really_module(it)
  361. ]
  362. plist = []
  363. for ad in odoo.addons.__path__:
  364. if not os.path.exists(ad):
  365. _logger.warning("addons path does not exist: %s", ad)
  366. continue
  367. plist.extend(listdir(ad))
  368. return sorted(set(plist))
  369. def get_modules_with_version():
  370. modules = get_modules()
  371. res = dict.fromkeys(modules, adapt_version('1.0'))
  372. for module in modules:
  373. try:
  374. info = get_manifest(module)
  375. res[module] = info['version']
  376. except Exception:
  377. continue
  378. return res
  379. def adapt_version(version):
  380. serie = release.major_version
  381. if version == serie or not version.startswith(serie + '.'):
  382. base_version = version
  383. version = '%s.%s' % (serie, version)
  384. else:
  385. base_version = version[len(serie) + 1:]
  386. if not re.match(r"^[0-9]+\.[0-9]+(?:\.[0-9]+)?$", base_version):
  387. raise ValueError(f"Invalid version {base_version!r}. Modules should have a version in format `x.y`, `x.y.z`,"
  388. f" `{serie}.x.y` or `{serie}.x.y.z`.")
  389. return version
  390. current_test = False
  391. def check_python_external_dependency(pydep):
  392. try:
  393. requirement = Requirement(pydep)
  394. except InvalidRequirement as e:
  395. msg = f"{pydep} is an invalid external dependency specification: {e}"
  396. raise Exception(msg) from e
  397. if requirement.marker and not requirement.marker.evaluate():
  398. _logger.debug(
  399. "Ignored external dependency %s because environment markers do not match",
  400. pydep
  401. )
  402. return
  403. try:
  404. version = importlib.metadata.version(requirement.name)
  405. except importlib.metadata.PackageNotFoundError as e:
  406. try:
  407. # keep compatibility with module name but log a warning instead of info
  408. importlib.import_module(pydep)
  409. _logger.warning("python external dependency on '%s' does not appear o be a valid PyPI package. Using a PyPI package name is recommended.", pydep)
  410. return
  411. except ImportError:
  412. pass
  413. msg = f"External dependency {pydep} not installed: {e}"
  414. raise Exception(msg) from e
  415. if requirement.specifier and not requirement.specifier.contains(version):
  416. msg = f"External dependency version mismatch: {pydep} (installed: {version})"
  417. raise Exception(msg)
  418. def check_manifest_dependencies(manifest):
  419. depends = manifest.get('external_dependencies')
  420. if not depends:
  421. return
  422. for pydep in depends.get('python', []):
  423. check_python_external_dependency(pydep)
  424. for binary in depends.get('bin', []):
  425. try:
  426. tools.find_in_path(binary)
  427. except IOError:
  428. raise Exception('Unable to find %r in path' % (binary,))
上海开阖软件有限公司 沪ICP备12045867号-1