diff --git a/base_manifest_extension/README.rst b/base_manifest_extension/README.rst new file mode 100644 index 000000000..da70ea7ca --- /dev/null +++ b/base_manifest_extension/README.rst @@ -0,0 +1,79 @@ +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.svg + :target: https://www.gnu.org/licenses/lgpl.html + :alt: License: LGPL-3 + +=============================== +Module Manifest - Extra Options +=============================== + +This is a technical module that allows developers to make use of extra keys in +module manifests. The following keys are available currently: + +* ``depends_if_installed`` - Your module will depend on modules listed here but + only if those modules are already installed. This is useful if your module + needs to override the behavior of a certain module (dependencies determine + load order) but would also work without it. +* ``rdepends_if_installed`` - The modules listed here will depend on your + module if they are already installed. This is useful if you want your module + to be higher in the inheritance chain than the target modules and do not want + to or cannot make changes to those modules. + +Usage +===== + +Add this module as a dependency and use the keys described above. + +Roadmap +======= + +Add support for the following additional keys: + +* ``breaks`` - Used to mark some modules as being incompatible with the + current one. This could be set up to support versioning (e.g. ``'breaks': + ['my_module<<0.4.2']``). +* ``demo_if_installed``, ``data_if_installed``, ``qweb_if_installed`` - Dicts + with module names as keys and lists of files as values. Used to load files + only if some other module is installed. +* ``_if_module`` - Used on models to load them only if the appropriate module + is installed. + +Bug Tracker +=========== + +Bugs are tracked on +`GitHub Issues `_. In case of +trouble, please check there if your issue has already been reported. If you +spotted it first, help us smash it by providing detailed and welcome feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: + `Icon `_. + +Contributors +------------ + +* Holger Brunn +* Oleg Bulkin + +Do not contact contributors directly about support or help with technical +issues. + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/base_manifest_extension/__init__.py b/base_manifest_extension/__init__.py new file mode 100644 index 000000000..87ad052e9 --- /dev/null +++ b/base_manifest_extension/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Therp BV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from .hooks import post_load_hook diff --git a/base_manifest_extension/__openerp__.py b/base_manifest_extension/__openerp__.py new file mode 100644 index 000000000..892c18bd7 --- /dev/null +++ b/base_manifest_extension/__openerp__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Therp BV +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +{ + 'name': 'Module Manifest - Extra Options', + 'version': '9.0.1.0.0', + 'website': 'https://github.com/OCA/server-tools', + 'author': 'Therp BV, LasLabs, Odoo Community Association (OCA)', + 'license': 'LGPL-3', + 'category': 'Hidden/Dependency', + 'summary': 'Adds useful keys to manifest files', + 'post_load': 'post_load_hook', +} diff --git a/base_manifest_extension/hooks.py b/base_manifest_extension/hooks.py new file mode 100644 index 000000000..2e0c4d12d --- /dev/null +++ b/base_manifest_extension/hooks.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Therp BV +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +import inspect +from werkzeug.local import Local +from openerp.sql_db import Cursor +from openerp.modules import module +from openerp.modules.graph import Graph + +original = module.load_information_from_description_file +local = Local() +local.rdepends_to_process = {} + + +def load_information_from_description_file(module, mod_path=None): + result = original(module, mod_path=mod_path) + + # add the keys you want to react on here + if result.get('depends_if_installed'): + cr = _get_cr() + if cr: + _handle_depends_if_installed(cr, result) + if result.get('rdepends_if_installed'): + cr = _get_cr() + if cr: + _handle_rdepends_if_installed(cr, result, module) + + # Apply depends specified in other modules as rdepends + extra_depends = local.rdepends_to_process.get(module) + if extra_depends: + result['depends'] += extra_depends + + return result + + +def _handle_depends_if_installed(cr, manifest): + if not manifest.get('depends_if_installed'): + return + + added_depends = manifest.pop('depends_if_installed') + added_depends = _installed_modules(cr, added_depends) + + depends = manifest.setdefault('depends', []) + depends.extend(added_depends) + + +def _handle_rdepends_if_installed(cr, manifest, current_module): + graph = _get_graph() + if not graph: + return + + rdepends = manifest.pop('rdepends_if_installed') + rdepends = _installed_modules(cr, rdepends) + + for rdepend in rdepends: + to_process = local.rdepends_to_process.get(rdepend, set([])) + local.rdepends_to_process[rdepend] = to_process | set([current_module]) + # If rdepend already in graph, reload it so new depend is applied + if graph.get(rdepend): + del graph[rdepend] + graph.add_module(cr, rdepend) + + +def _installed_modules(cr, modules): + if not modules: + return [] + + cr.execute( + 'SELECT name FROM ir_module_module ' + 'WHERE state IN %s AND name IN %s', + ( + tuple(['installed', 'to install', 'to upgrade']), + tuple(modules), + ), + ) + return [module for module, in cr.fetchall()] + + +def _get_cr(): + cr = None + for frame, filename, lineno, funcname, line, index in inspect.stack(): + # walk up the stack until we've found a cursor + if 'cr' in frame.f_locals and isinstance(frame.f_locals['cr'], Cursor): + cr = frame.f_locals['cr'] + break + return cr + + +def _get_graph(): + graph = None + for frame, filename, lineno, funcname, line, index in inspect.stack(): + # walk up the stack until we've found a graph + if 'graph' in frame.f_locals and isinstance( + frame.f_locals['graph'], Graph + ): + graph = frame.f_locals['graph'] + break + return graph + + +def post_load_hook(): + cr = _get_cr() + if not cr: + return + + # do nothing if we're not installed + installed = _installed_modules(cr, ['base_manifest_extension']) + if not installed: + return + + module.load_information_from_description_file =\ + load_information_from_description_file + + # here stuff can become tricky: On the python level, modules + # are not loaded in dependency order. This means that there might + # be modules loaded depending on us before we could patch the function + # above. So we reload the module graph for all modules coming after us + + graph = _get_graph() + if not graph: + return + + this = graph['base_manifest_extension'] + to_reload = [] + for node in graph.itervalues(): + if node.depth > this.depth: + to_reload.append(node.name) + for module_name in to_reload: + del graph[module_name] + graph.add_modules(cr, to_reload) diff --git a/base_manifest_extension/static/description/icon.png b/base_manifest_extension/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/base_manifest_extension/static/description/icon.png differ diff --git a/base_manifest_extension/tests/__init__.py b/base_manifest_extension/tests/__init__.py new file mode 100644 index 000000000..663f751fb --- /dev/null +++ b/base_manifest_extension/tests/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Therp BV +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from . import test_hooks diff --git a/base_manifest_extension/tests/test_hooks.py b/base_manifest_extension/tests/test_hooks.py new file mode 100644 index 000000000..3e06b8cab --- /dev/null +++ b/base_manifest_extension/tests/test_hooks.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Therp BV +# Copyright 2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from mock import Mock, patch +import os +import tempfile +from openerp.modules.module import load_information_from_description_file,\ + get_module_path, MANIFEST +from openerp.tests.common import TransactionCase +from ..hooks import _handle_rdepends_if_installed, _installed_modules + +MOCK_PATH = 'openerp.addons.base_manifest_extension.hooks' + + +class TestHooks(TransactionCase): + def setUp(self): + super(TestHooks, self).setUp() + + self.test_cr = self.env.cr + self.test_rdepends = [ + 'base', + 'base_manifest_extension', + 'not_installed', + ] + self.test_manifest = { + 'rdepends_if_installed': self.test_rdepends, + 'depends': [], + } + self.test_module_name = 'base_manifest_extension' + self.test_call = ( + self.test_cr, + self.test_manifest, + self.test_module_name, + ) + + def test_base_manifest_extension(self): + # write a test manifest + module_path = tempfile.mkdtemp(dir=os.path.join( + get_module_path('base_manifest_extension'), 'static' + )) + with open(os.path.join(module_path, MANIFEST), 'w') as manifest: + manifest.write(repr({ + 'depends_if_installed': [ + 'base_manifest_extension', + 'not installed', + ], + })) + # parse it + parsed = load_information_from_description_file( + # this name won't really be used, but avoids a warning + 'base', mod_path=module_path, + ) + self.assertIn('base_manifest_extension', parsed['depends']) + self.assertNotIn('not installed', parsed['depends']) + self.assertNotIn('depends_if_installed', parsed) + + def test_installed_modules_correct_result(self): + """It should return only installed modules in list""" + result = _installed_modules(self.test_cr, self.test_rdepends) + + expected = self.test_rdepends[:2] + self.assertEqual(result, expected) + + def test_installed_modules_empty_starting_list(self): + """It should safely handle being passed an empty module list""" + result = _installed_modules(self.test_cr, []) + + self.assertEqual(result, []) + + @patch(MOCK_PATH + '._get_graph') + def test_handle_rdepends_if_installed_graph_call(self, graph_mock): + """It should call graph helper and return early if graph not found""" + graph_mock.return_value = None + graph_mock.reset_mock() + self.test_cr = Mock() + self.test_cr.reset_mock() + _handle_rdepends_if_installed(*self.test_call) + + graph_mock.assert_called_once() + self.test_cr.assert_not_called() + + @patch(MOCK_PATH + '._get_graph') + def test_handle_rdepends_if_installed_clean_manifest(self, graph_mock): + """It should remove rdepends key from manifest""" + _handle_rdepends_if_installed(*self.test_call) + + self.assertEqual(self.test_manifest, {'depends': []}) + + @patch(MOCK_PATH + '.local.rdepends_to_process', new_callable=dict) + @patch(MOCK_PATH + '._get_graph') + def test_handle_rdepends_if_installed_list(self, graph_mock, dict_mock): + """It should correctly add all installed rdepends to processing dict""" + _handle_rdepends_if_installed(*self.test_call) + + expected_result = { + 'base': set([self.test_module_name]), + 'base_manifest_extension': set([self.test_module_name]), + } + self.assertEqual(dict_mock, expected_result) + + @patch(MOCK_PATH + '.local.rdepends_to_process', new_callable=dict) + @patch(MOCK_PATH + '._get_graph') + def test_handle_rdepends_if_installed_dupes(self, graph_mock, dict_mock): + """It should correctly handle multiple calls with same rdepends""" + for __ in range(2): + _handle_rdepends_if_installed(*self.test_call) + self.test_manifest['rdepends_if_installed'] = self.test_rdepends + test_module_name_2 = 'test_module_name_2' + _handle_rdepends_if_installed( + self.test_cr, + self.test_manifest, + test_module_name_2, + ) + + expected_set = set([self.test_module_name, test_module_name_2]) + expected_result = { + 'base': expected_set, + 'base_manifest_extension': expected_set, + } + self.assertEqual(dict_mock, expected_result) + + @patch(MOCK_PATH + '._get_graph') + def test_handle_rdepends_if_installed_graph_reload(self, graph_mock): + """It should reload installed rdepends already in module graph""" + class TestGraph(dict): + pass + + test_graph = TestGraph(base='Test Value') + test_graph.add_module = Mock() + graph_mock.return_value = test_graph + _handle_rdepends_if_installed(*self.test_call) + + self.assertEqual(test_graph, {}) + test_graph.add_module.assert_called_once_with(self.cr, 'base') + + @patch(MOCK_PATH + '._handle_rdepends_if_installed') + @patch(MOCK_PATH + '._get_cr') + @patch(MOCK_PATH + '.original') + def test_load_information_from_description_file_rdepends_key( + self, super_mock, cr_mock, helper_mock + ): + """It should correctly call rdepends helper if key present""" + super_mock.return_value = self.test_manifest + cr_mock.return_value = self.cr + helper_mock.reset_mock() + load_information_from_description_file(self.test_module_name) + + helper_mock.assert_called_once_with(*self.test_call) + + @patch(MOCK_PATH + '._handle_rdepends_if_installed') + @patch(MOCK_PATH + '._get_cr') + @patch(MOCK_PATH + '.original') + def test_load_information_from_description_file_no_rdepends_key( + self, super_mock, cr_mock, helper_mock + ): + """It should not call rdepends helper if key not present""" + del self.test_manifest['rdepends_if_installed'] + super_mock.return_value = self.test_manifest + cr_mock.return_value = self.cr + helper_mock.reset_mock() + load_information_from_description_file(self.test_module_name) + + helper_mock.assert_not_called() + + @patch(MOCK_PATH + '._get_cr') + @patch(MOCK_PATH + '.original') + def test_load_information_from_description_file_rdepends_to_process( + self, super_mock, cr_mock + ): + """It should correctly add pending rdepends to manifest""" + del self.test_manifest['rdepends_if_installed'] + super_mock.return_value = self.test_manifest + cr_mock.return_value = self.cr + test_depends = set(['Test Depend 1', 'Test Depend 2']) + test_rdepend_dict = { + self.test_module_name: test_depends, + 'Other Module': set(['Other Depend']), + } + dict_path = MOCK_PATH + '.local.rdepends_to_process' + with patch.dict(dict_path, test_rdepend_dict, clear=True): + load_information_from_description_file(self.test_module_name) + + self.assertEqual(self.test_manifest['depends'], list(test_depends))