diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG index 1611b162d7..67b0c9a8b9 100644 --- a/trytond/CHANGELOG +++ b/trytond/CHANGELOG @@ -1,3 +1,4 @@ +* Allow to include subdirectories in tryton.cfg * Add route for login / logout with cookie * Add contextual ``_log`` to force logging events * Add notify_user to ModelStorage diff --git a/trytond/doc/topics/modules/index.rst b/trytond/doc/topics/modules/index.rst index 7da570cb57..1a6fa95ce8 100644 --- a/trytond/doc/topics/modules/index.rst +++ b/trytond/doc/topics/modules/index.rst @@ -54,6 +54,15 @@ contain a ``[tryton]`` section with the following keys: They will be loaded in the given order when the module is activated or updated. +``include_dirs`` + This is a list of directories containing additional :file:`tryton.cfg` + files, organised by line. + These files follow the same structure, excluding ``version``, ``depends`` + and ``extras_depend``. + +``test_include_dirs`` + It's the same as ``include_dirs``, but only applies when running tests. + It may contain some ``[register]`` or ``[register ]`` sections with the keys ``model``, ``wizard`` and ``report`` defining the ``type_`` and qualified name of class relative to the module as a one per line list to pass diff --git a/trytond/trytond/modules/__init__.py b/trytond/trytond/modules/__init__.py index 95f667c832..c505ac555e 100644 --- a/trytond/trytond/modules/__init__.py +++ b/trytond/trytond/modules/__init__.py @@ -30,30 +30,60 @@ MODULES = [] @cache -def parse_module_config(name): +def parse_module_config(name, path=()): "Return a ConfigParser instance and directory of the module" config = configparser.ConfigParser() config.optionxform = lambda option: option - with tools.file_open(os.path.join(name, 'tryton.cfg')) as fp: + with tools.file_open(os.path.join(name, *path, 'tryton.cfg')) as fp: config.read_file(fp) directory = os.path.dirname(fp.name) return config, directory -def get_module_info(name): +def _get_subdirs(module_config, with_test): + tryton_section = module_config['tryton'] + if 'include_dirs' in tryton_section: + included_dirs = tryton_section['include_dirs'].strip().splitlines() + else: + included_dirs = [] + if with_test and 'test_include_dirs' in tryton_section: + included_dirs.extend( + tryton_section['test_include_dirs'].strip().splitlines()) + return included_dirs + + +def get_module_info(name, path=(), with_test=False): "Return the content of the tryton.cfg" - module_config, directory = parse_module_config(name) + module_config, directory = parse_module_config(name, path) + if module_config is None: + return info = dict(module_config.items('tryton')) info['directory'] = directory for key in ('depends', 'extras_depend', 'xml'): if key in info: info[key] = info[key].strip().splitlines() + if key == 'xml': + info[key] = [os.path.join(*path, f) for f in info[key]] + + for directory in _get_subdirs(module_config, with_test): + mod_info = get_module_info(name, (*path, directory), with_test) + if not mod_info: + continue + for key in ['xml']: + if key in mod_info: + if key in info: + info[key] += mod_info[key] + else: + info[key] = mod_info[key] + return info -def get_module_register(name): +def get_module_register(name, path=(), with_test=False): "Return classes to register from tryton.cfg" - module_config, _ = parse_module_config(name) + module_config, _ = parse_module_config(name, path) + if module_config is None: + return for section in module_config.sections(): if section == 'register' or section.startswith('register '): depends = section[len('register'):].strip().split() @@ -62,21 +92,32 @@ def get_module_register(name): continue classes = module_config.get( section, type_).strip().splitlines() + if path: + prefix = ".".join(path) + classes = [f'{prefix}.{c}' for c in classes] yield classes, { 'module': name, 'type_': type_, 'depends': depends, } + for directory in _get_subdirs(module_config, with_test): + yield from get_module_register( + name, (*path, directory), with_test) -def get_module_register_mixin(name): +def get_module_register_mixin(name, path=(), with_test=False): "Return classes to register_mixin from tryton.cfg" - module_config, _ = parse_module_config(name) + module_config, _ = parse_module_config(name, path) + if module_config is None: + return if module_config.has_section('register_mixin'): for mixin, classinfo in module_config.items('register_mixin'): yield [mixin, classinfo], { 'module': name, } + for directory in _get_subdirs(module_config, with_test): + yield from get_module_register_mixin( + name, (*path, directory), with_test) class Graph(dict): @@ -125,11 +166,11 @@ class Node(list): super().append(node) -def create_graph(modules): +def create_graph(modules, with_test=False): all_deps = set() graph = Graph() for module in modules: - info = get_module_info(module) + info = get_module_info(module, with_test=with_test) deps = info.get('depends', []) + [ d for d in info.get('extras_depend', []) if d in modules] node = graph.add(module, deps) @@ -394,15 +435,18 @@ def register_classes(with_test=False): logger.info("tests register") trytond.tests.register() - for node in create_graph(get_modules(with_test=with_test)): + for node in create_graph( + get_modules(with_test=with_test), with_test=with_test): module_name = node.name if module_name in base_modules: MODULES.append(module_name) continue logger.info("%s register", module_name) - for args, kwargs in get_module_register(module_name): + for args, kwargs in get_module_register( + module_name, with_test=with_test): Pool.register(*args, **kwargs) - for args, kwargs in get_module_register_mixin(module_name): + for args, kwargs in get_module_register_mixin( + module_name, with_test=with_test): Pool.register_mixin(*args, **kwargs) module = tools.import_module(module_name) if hasattr(module, 'register'): diff --git a/trytond/trytond/tests/test_module_register.py b/trytond/trytond/tests/test_module_register.py new file mode 100644 index 0000000000..c28cd92fc8 --- /dev/null +++ b/trytond/trytond/tests/test_module_register.py @@ -0,0 +1,222 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of this +# repository contains the full copyright notices and license terms. + +import io +import os.path +import pathlib +import tempfile +from unittest.mock import patch + +from trytond.modules import ( + get_module_info, get_module_register, get_module_register_mixin) +from trytond.tests.test_tryton import TestCase + +FLAT_MODULE_CFG = """ +[tryton] +depends: + flat_a + flat_b +extras_depend: + flat_extra1 + flat_extra2 +xml: + flat_file1.xml + flat_file2.xml +test_include_dirs: + tests +[register] +model: + flat_model.Model +""" + +TEST_MODULE_CFG = """ +[tryton] +xml: + test_file.xml +[register] +model: + test_model.TestModel +""" + +NESTED_MODULE_CFG = """ +[tryton] +depends: + root_a + root_b +xml: + root_file1.xml +include_dirs: + sub1 + sub2 + +[register] +model: + root_model.A +wizard: + root_wizard.wiz_A + +[register_mixin] +test.TestMixin: path.to.nested.mixin +""" + +NESTED_MODULE_SUB1_CFG = """ +[tryton] +depends: + nested_s1_1 + nested_s1_2 +xml: + nested_file_s1.xml +""" + +NESTED_MODULE_SUB2_CFG = """ +[tryton] +xml: + nested_file_s2.xml +include_dirs: + sub + +[register] +report: + nested_report.report_sub2 + +[register_mixin] +test.TestMixin: path.to.sub2.mixin +""" + +NESTED_MODULE_SUB2SUB_CFG = """ +[tryton] +xml: + nested_file_s2_s.xml + +[register depend_s2s] +model: + nested_model.s2s_A +""" + + +class ModuleRegisterTestCase(TestCase): + "Test module registration" + + @classmethod + def setUpClass(cls): + cls.tmp_modules = tempfile.TemporaryDirectory() + + modules = pathlib.Path(cls.tmp_modules.name) + flat = modules / 'flat' + tests = flat / 'tests' + nested = modules / 'nested' + nested_sub1 = nested / 'sub1' + nested_sub2 = nested / 'sub2' + nested_sub2sub = nested_sub2 / 'sub' + + for path, config in [ + (flat, FLAT_MODULE_CFG), + (tests, TEST_MODULE_CFG), + (nested, NESTED_MODULE_CFG), + (nested_sub1, NESTED_MODULE_SUB1_CFG), + (nested_sub2, NESTED_MODULE_SUB2_CFG), + (nested_sub2sub, NESTED_MODULE_SUB2SUB_CFG), + ]: + path.mkdir(parents=True) + with open(path / 'tryton.cfg', 'w') as f: + f.write(config) + + cls.file_open = patch('trytond.tools.file_open') + file_open = cls.file_open.start() + file_open.side_effect = lambda name: io.open(modules / name, 'r') + + @classmethod + def tearDownClass(cls): + cls.tmp_modules.cleanup() + cls.file_open.stop() + + def test_module_info_flat(self): + "Test fetching tryton.cfg information from a flat module" + mod_info = get_module_info('flat') + + self.assertEqual(mod_info['depends'], ['flat_a', 'flat_b']) + self.assertEqual( + mod_info['extras_depend'], ['flat_extra1', 'flat_extra2']) + self.assertEqual( + mod_info['xml'], ['flat_file1.xml', 'flat_file2.xml']) + + def test_module_info_with_test(self): + "Test fetching tryton.cfg information when in a testing context" + mod_info = get_module_info('flat', with_test=True) + + self.assertEqual( + mod_info['xml'], + ['flat_file1.xml', 'flat_file2.xml', + os.path.join('tests', 'test_file.xml')]) + + def test_module_info_nested(self): + "Test fetching tryton.cfg information from a nested module" + mod_info = get_module_info('nested') + + self.assertEqual( + mod_info['xml'], + ['root_file1.xml', + os.path.join('sub1', 'nested_file_s1.xml'), + os.path.join('sub2', 'nested_file_s2.xml'), + os.path.join('sub2', 'sub', 'nested_file_s2_s.xml'), + ]) + + def test_module_register(self): + "Test module registration" + self.assertEqual( + list(get_module_register('flat')), + [ + (['flat_model.Model'], + {'module': 'flat', 'type_': 'model', 'depends': []}), + ]) + + for registration, expected in zip( + get_module_register('nested'), + [ + (['root_model.A'], + {'module': 'nested', 'type_': 'model', 'depends': []}), + (['root_wizard.wiz_A'], + { + 'module': 'nested', + 'type_': 'wizard', + 'depends': [], + }), + (['sub2.nested_report.report_sub2'], + { + 'module': 'nested', + 'type_': 'report', + 'depends': [], + }), + (['sub2.sub.nested_model.s2s_A'], + { + 'module': 'nested', + 'type_': 'model', + 'depends': ['depend_s2s'], + }), + ]): + with self.subTest(value=expected[0]): + self.assertEqual(registration, expected) + + def test_module_register_mixin(self): + "Test module registration of mixins" + for registration, expected in zip( + get_module_register_mixin('nested'), + [ + (['test.TestMixin', 'path.to.nested.mixin'], + {'module': 'nested'}), + (['test.TestMixin', 'path.to.sub2.mixin'], + {'module': 'nested'}), + ]): + with self.subTest(value=expected[0]): + self.assertEqual(registration, expected) + + def test_module_register_with_tests(self): + "Test module registration in a testing context" + self.assertEqual( + list(get_module_register('flat', with_test=True)), + [ + (['flat_model.Model'], + {'module': 'flat', 'type_': 'model', 'depends': []}), + (['tests.test_model.TestModel'], + {'module': 'flat', 'type_': 'model', 'depends': []}), + ])