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