[IMP] modules: add support for loading module description from README.{md,rst,txt}
[odoo/odoo.git] / openerp / modules / module.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
6 #    Copyright (C) 2010-2014 OpenERP s.a. (<http://openerp.com>).
7 #
8 #    This program is free software: you can redistribute it and/or modify
9 #    it under the terms of the GNU Affero General Public License as
10 #    published by the Free Software Foundation, either version 3 of the
11 #    License, or (at your option) any later version.
12 #
13 #    This program is distributed in the hope that it will be useful,
14 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
15 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 #    GNU Affero General Public License for more details.
17 #
18 #    You should have received a copy of the GNU Affero General Public License
19 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
20 #
21 ##############################################################################
22
23 import functools
24 import imp
25 import itertools
26 import logging
27 import os
28 import re
29 import sys
30 import time
31 import unittest
32 from os.path import join as opj
33
34 import unittest2
35
36 import openerp
37 import openerp.tools as tools
38 import openerp.release as release
39 from openerp.tools.safe_eval import safe_eval as eval
40
41 MANIFEST = '__openerp__.py'
42 README = ['README.rst', 'README.md', 'README.txt']
43
44 _logger = logging.getLogger(__name__)
45
46 # addons path as a list
47 ad_paths = []
48 hooked = False
49
50 # Modules already loaded
51 loaded = []
52
53 class AddonsImportHook(object):
54     """
55     Import hook to load OpenERP addons from multiple paths.
56
57     OpenERP implements its own import-hook to load its addons. OpenERP
58     addons are Python modules. Originally, they were each living in their
59     own top-level namespace, e.g. the sale module, or the hr module. For
60     backward compatibility, `import <module>` is still supported. Now they
61     are living in `openerp.addons`. The good way to import such modules is
62     thus `import openerp.addons.module`.
63     """
64
65     def find_module(self, module_name, package_path):
66         module_parts = module_name.split('.')
67         if len(module_parts) == 3 and module_name.startswith('openerp.addons.'):
68             return self # We act as a loader too.
69
70     def load_module(self, module_name):
71         if module_name in sys.modules:
72             return sys.modules[module_name]
73
74         _1, _2, module_part = module_name.split('.')
75         # Note: we don't support circular import.
76         f, path, descr = imp.find_module(module_part, ad_paths)
77         mod = imp.load_module('openerp.addons.' + module_part, f, path, descr)
78         sys.modules['openerp.addons.' + module_part] = mod
79         return mod
80
81 def initialize_sys_path():
82     """
83     Setup an import-hook to be able to import OpenERP addons from the different
84     addons paths.
85
86     This ensures something like ``import crm`` (or even
87     ``import openerp.addons.crm``) works even if the addons are not in the
88     PYTHONPATH.
89     """
90     global ad_paths
91     global hooked
92
93     dd = tools.config.addons_data_dir
94     if dd not in ad_paths:
95         ad_paths.append(dd)
96
97     for ad in tools.config['addons_path'].split(','):
98         ad = os.path.abspath(tools.ustr(ad.strip()))
99         if ad not in ad_paths:
100             ad_paths.append(ad)
101
102     # add base module path
103     base_path = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'addons'))
104     if base_path not in ad_paths:
105         ad_paths.append(base_path)
106
107     if not hooked:
108         sys.meta_path.append(AddonsImportHook())
109         hooked = True
110
111 def get_module_path(module, downloaded=False, display_warning=True):
112     """Return the path of the given module.
113
114     Search the addons paths and return the first path where the given
115     module is found. If downloaded is True, return the default addons
116     path if nothing else is found.
117
118     """
119     initialize_sys_path()
120     for adp in ad_paths:
121         if os.path.exists(opj(adp, module)) or os.path.exists(opj(adp, '%s.zip' % module)):
122             return opj(adp, module)
123
124     if downloaded:
125         return opj(tools.config.addons_data_dir, module)
126     if display_warning:
127         _logger.warning('module %s: module not found', module)
128     return False
129
130 def get_module_filetree(module, dir='.'):
131     path = get_module_path(module)
132     if not path:
133         return False
134
135     dir = os.path.normpath(dir)
136     if dir == '.':
137         dir = ''
138     if dir.startswith('..') or (dir and dir[0] == '/'):
139         raise Exception('Cannot access file outside the module')
140
141     files = openerp.tools.osutil.listdir(path, True)
142
143     tree = {}
144     for f in files:
145         if not f.startswith(dir):
146             continue
147
148         if dir:
149             f = f[len(dir)+int(not dir.endswith('/')):]
150         lst = f.split(os.sep)
151         current = tree
152         while len(lst) != 1:
153             current = current.setdefault(lst.pop(0), {})
154         current[lst.pop(0)] = None
155
156     return tree
157
158 def get_module_resource(module, *args):
159     """Return the full path of a resource of the given module.
160
161     :param module: module name
162     :param list(str) args: resource path components within module
163
164     :rtype: str
165     :return: absolute path to the resource
166
167     TODO name it get_resource_path
168     TODO make it available inside on osv object (self.get_resource_path)
169     """
170     mod_path = get_module_path(module)
171     if not mod_path: return False
172     resource_path = opj(mod_path, *args)
173     if os.path.isdir(mod_path):
174         # the module is a directory - ignore zip behavior
175         if os.path.exists(resource_path):
176             return resource_path
177     return False
178
179 def get_module_icon(module):
180     iconpath = ['static', 'description', 'icon.png']
181     if get_module_resource(module, *iconpath):
182         return ('/' + module + '/') + '/'.join(iconpath)
183     return '/base/'  + '/'.join(iconpath)
184
185 def get_module_root(path):
186     """
187     Get closest module's root begining from path
188
189         # Given:
190         # /foo/bar/module_dir/static/src/...
191
192         get_module_root('/foo/bar/module_dir/static/')
193         # returns '/foo/bar/module_dir'
194
195         get_module_root('/foo/bar/module_dir/')
196         # returns '/foo/bar/module_dir'
197
198         get_module_root('/foo/bar')
199         # returns None
200
201     @param path: Path from which the lookup should start
202
203     @return:  Module root path or None if not found
204     """
205     while not os.path.exists(os.path.join(path, MANIFEST)):
206         new_path = os.path.abspath(os.path.join(path, os.pardir))
207         if path == new_path:
208             return None
209         path = new_path
210     return path
211
212 def load_information_from_description_file(module, mod_path=None):
213     """
214     :param module: The name of the module (sale, purchase, ...)
215     :param mod_path: Physical path of module, if not providedThe name of the module (sale, purchase, ...)
216     """
217
218     if not mod_path:
219         mod_path = get_module_path(module)
220     terp_file = mod_path and opj(mod_path, MANIFEST) or False
221     if terp_file:
222         info = {}
223         if os.path.isfile(terp_file):
224             # default values for descriptor
225             info = {
226                 'application': False,
227                 'author': '',
228                 'auto_install': False,
229                 'category': 'Uncategorized',
230                 'depends': [],
231                 'description': '',
232                 'icon': get_module_icon(module),
233                 'installable': True,
234                 'license': 'AGPL-3',
235                 'post_load': None,
236                 'version': '1.0',
237                 'web': False,
238                 'website': '',
239                 'sequence': 100,
240                 'summary': '',
241             }
242             info.update(itertools.izip(
243                 'depends data demo test init_xml update_xml demo_xml'.split(),
244                 iter(list, None)))
245
246             f = tools.file_open(terp_file)
247             try:
248                 info.update(eval(f.read()))
249             finally:
250                 f.close()
251
252             if not info.get('description'):
253                 readme_path = [opj(mod_path, x) for x in README
254                                if os.path.isfile(opj(mod_path, x))]
255                 if readme_path:
256                     readme_text = tools.file_open(readme_path[0]).read()
257                     info['description'] = readme_text
258
259             if 'active' in info:
260                 # 'active' has been renamed 'auto_install'
261                 info['auto_install'] = info['active']
262
263             info['version'] = adapt_version(info['version'])
264             return info
265
266     #TODO: refactor the logger in this file to follow the logging guidelines
267     #      for 6.0
268     _logger.debug('module %s: no %s file found.', module, MANIFEST)
269     return {}
270
271 def init_module_models(cr, module_name, obj_list):
272     """ Initialize a list of models.
273
274     Call _auto_init and init on each model to create or update the
275     database tables supporting the models.
276
277     TODO better explanation of _auto_init and init.
278
279     """
280     _logger.info('module %s: creating or updating database tables', module_name)
281     todo = []
282     for obj in obj_list:
283         result = obj._auto_init(cr, {'module': module_name})
284         if result:
285             todo += result
286         if hasattr(obj, 'init'):
287             obj.init(cr)
288         cr.commit()
289     for obj in obj_list:
290         obj._auto_end(cr, {'module': module_name})
291         cr.commit()
292     todo.sort(key=lambda x: x[0])
293     for t in todo:
294         t[1](cr, *t[2])
295     cr.commit()
296
297 def load_openerp_module(module_name):
298     """ Load an OpenERP module, if not already loaded.
299
300     This loads the module and register all of its models, thanks to either
301     the MetaModel metaclass, or the explicit instantiation of the model.
302     This is also used to load server-wide module (i.e. it is also used
303     when there is no model to register).
304     """
305     global loaded
306     if module_name in loaded:
307         return
308
309     initialize_sys_path()
310     try:
311         mod_path = get_module_path(module_name)
312         __import__('openerp.addons.' + module_name)
313
314         # Call the module's post-load hook. This can done before any model or
315         # data has been initialized. This is ok as the post-load hook is for
316         # server-wide (instead of registry-specific) functionalities.
317         info = load_information_from_description_file(module_name)
318         if info['post_load']:
319             getattr(sys.modules['openerp.addons.' + module_name], info['post_load'])()
320
321     except Exception, e:
322         msg = "Couldn't load module %s" % (module_name)
323         _logger.critical(msg)
324         _logger.critical(e)
325         raise
326     else:
327         loaded.append(module_name)
328
329 def get_modules():
330     """Returns the list of module names
331     """
332     def listdir(dir):
333         def clean(name):
334             name = os.path.basename(name)
335             if name[-4:] == '.zip':
336                 name = name[:-4]
337             return name
338
339         def is_really_module(name):
340             manifest_name = opj(dir, name, MANIFEST)
341             zipfile_name = opj(dir, name)
342             return os.path.isfile(manifest_name)
343         return map(clean, filter(is_really_module, os.listdir(dir)))
344
345     plist = []
346     initialize_sys_path()
347     for ad in ad_paths:
348         plist.extend(listdir(ad))
349     return list(set(plist))
350
351 def get_modules_with_version():
352     modules = get_modules()
353     res = dict.fromkeys(modules, adapt_version('1.0'))
354     for module in modules:
355         try:
356             info = load_information_from_description_file(module)
357             res[module] = info['version']
358         except Exception:
359             continue
360     return res
361
362 def adapt_version(version):
363     serie = release.major_version
364     if version == serie or not version.startswith(serie + '.'):
365         version = '%s.%s' % (serie, version)
366     return version
367
368 def get_test_modules(module):
369     """ Return a list of module for the addons potentialy containing tests to
370     feed unittest2.TestLoader.loadTestsFromModule() """
371     # Try to import the module
372     module = 'openerp.addons.' + module + '.tests'
373     try:
374         __import__(module)
375     except Exception, e:
376         # If module has no `tests` sub-module, no problem.
377         if str(e) != 'No module named tests':
378             _logger.exception('Can not `import %s`.', module)
379         return []
380
381     # include submodules too
382     result = [mod_obj for name, mod_obj in sys.modules.iteritems()
383               if mod_obj # mod_obj can be None
384               if name.startswith(module)
385               if re.search(r'test_\w+$', name)]
386     return result
387
388 # Use a custom stream object to log the test executions.
389 class TestStream(object):
390     def __init__(self, logger_name='openerp.tests'):
391         self.logger = logging.getLogger(logger_name)
392         self.r = re.compile(r'^-*$|^ *... *$|^ok$')
393     def flush(self):
394         pass
395     def write(self, s):
396         if self.r.match(s):
397             return
398         first = True
399         level = logging.ERROR if s.startswith(('ERROR', 'FAIL', 'Traceback')) else logging.INFO
400         for c in s.splitlines():
401             if not first:
402                 c = '` ' + c
403             first = False
404             self.logger.log(level, c)
405
406 current_test = None
407
408 def runs_at(test, hook, default):
409     # by default, tests do not run post install
410     test_runs = getattr(test, hook, default)
411
412     # for a test suite, we're done
413     if not isinstance(test, unittest.TestCase):
414         return test_runs
415
416     # otherwise check the current test method to see it's been set to a
417     # different state
418     method = getattr(test, test._testMethodName)
419     return getattr(method, hook, test_runs)
420
421 runs_at_install = functools.partial(runs_at, hook='at_install', default=True)
422 runs_post_install = functools.partial(runs_at, hook='post_install', default=False)
423
424 def run_unit_tests(module_name, dbname, position=runs_at_install):
425     """
426     :returns: ``True`` if all of ``module_name``'s tests succeeded, ``False``
427               if any of them failed.
428     :rtype: bool
429     """
430     global current_test
431     current_test = module_name
432     mods = get_test_modules(module_name)
433     r = True
434     for m in mods:
435         tests = unwrap_suite(unittest2.TestLoader().loadTestsFromModule(m))
436         suite = unittest2.TestSuite(itertools.ifilter(position, tests))
437
438         if suite.countTestCases():
439             t0 = time.time()
440             t0_sql = openerp.sql_db.sql_counter
441             _logger.info('%s running tests.', m.__name__)
442             result = unittest2.TextTestRunner(verbosity=2, stream=TestStream(m.__name__)).run(suite)
443             if time.time() - t0 > 5:
444                 _logger.log(25, "%s tested in %.2fs, %s queries", m.__name__, time.time() - t0, openerp.sql_db.sql_counter - t0_sql)
445             if not result.wasSuccessful():
446                 r = False
447                 _logger.error("Module %s: %d failures, %d errors", module_name, len(result.failures), len(result.errors))
448
449     current_test = None
450     return r
451
452 def unwrap_suite(test):
453     """
454     Attempts to unpack testsuites (holding suites or cases) in order to
455     generate a single stream of terminals (either test cases or customized
456     test suites). These can then be checked for run/skip attributes
457     individually.
458
459     An alternative would be to use a variant of @unittest2.skipIf with a state
460     flag of some sort e.g. @unittest2.skipIf(common.runstate != 'at_install'),
461     but then things become weird with post_install as tests should *not* run
462     by default there
463     """
464     if isinstance(test, unittest.TestCase):
465         yield test
466         return
467
468     subtests = list(test)
469     # custom test suite (no test cases)
470     if not len(subtests):
471         yield test
472         return
473
474     for item in itertools.chain.from_iterable(
475             itertools.imap(unwrap_suite, subtests)):
476         yield item
477
478 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: