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': []}),
+ ])