Include subdirectories tryton.cfg when activating a module

Closes #14625
This commit is contained in:
Nicolas Évrard
2026-02-28 00:16:45 +01:00
parent b403fe8f39
commit 652f497110
4 changed files with 289 additions and 13 deletions
+1
View File
@@ -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
+9
View File
@@ -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 <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
+57 -13
View File
@@ -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'):
@@ -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': []}),
])