details:   https://code.tryton.org/tryton/commit/7e5cc250906c
branch:    default
user:      Nicolas Évrard <[email protected]>
date:      Sat Feb 28 00:16:45 2026 +0100
description:
        Include subdirectories tryton.cfg when activating a module

        Closes #14625
diffstat:

 trytond/CHANGELOG                             |    1 +
 trytond/doc/topics/modules/index.rst          |    9 +
 trytond/trytond/modules/__init__.py           |   70 ++++++-
 trytond/trytond/tests/test_module_register.py |  222 ++++++++++++++++++++++++++
 4 files changed, 289 insertions(+), 13 deletions(-)

diffs (394 lines):

diff -r 375fb083e95a -r 7e5cc250906c trytond/CHANGELOG
--- a/trytond/CHANGELOG Mon Mar 30 16:24:44 2026 +0200
+++ b/trytond/CHANGELOG Sat Feb 28 00:16:45 2026 +0100
@@ -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 -r 375fb083e95a -r 7e5cc250906c trytond/doc/topics/modules/index.rst
--- a/trytond/doc/topics/modules/index.rst      Mon Mar 30 16:24:44 2026 +0200
+++ b/trytond/doc/topics/modules/index.rst      Sat Feb 28 00:16:45 2026 +0100
@@ -54,6 +54,15 @@
    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 <module> <module>]`` 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 -r 375fb083e95a -r 7e5cc250906c trytond/trytond/modules/__init__.py
--- a/trytond/trytond/modules/__init__.py       Mon Mar 30 16:24:44 2026 +0200
+++ b/trytond/trytond/modules/__init__.py       Sat Feb 28 00:16:45 2026 +0100
@@ -30,30 +30,60 @@
 
 
 @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 @@
                     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 @@
         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 @@
         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 -r 375fb083e95a -r 7e5cc250906c 
trytond/trytond/tests/test_module_register.py
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/trytond/tests/test_module_register.py     Sat Feb 28 00:16:45 
2026 +0100
@@ -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': []}),
+                ])

Reply via email to