From 9780b9821b6c6ebf21432c44a42fa577d840cbe0 Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Mon, 10 Aug 2026 01:37:24 +0300 Subject: [PATCH] feat: read includes from list of directories --- .../share/ahriman/settings/ahriman.ini | 2 +- .../src/ahriman/application/handlers/setup.py | 6 +++-- .../core/configuration/configuration.py | 26 ++++++++----------- .../src/ahriman/core/configuration/schema.py | 11 +++++--- .../ahriman/core/configuration/validator.py | 2 +- .../handlers/test_handler_setup.py | 6 ++++- .../core/configuration/test_configuration.py | 18 +++---------- .../core/configuration/test_validator.py | 4 +++ docs/configuration.rst | 2 +- 9 files changed, 37 insertions(+), 40 deletions(-) diff --git a/ahriman-core/package/share/ahriman/settings/ahriman.ini b/ahriman-core/package/share/ahriman/settings/ahriman.ini index b7679328..9bce65fc 100644 --- a/ahriman-core/package/share/ahriman/settings/ahriman.ini +++ b/ahriman-core/package/share/ahriman/settings/ahriman.ini @@ -1,6 +1,6 @@ [settings] ; Relative path to directory with configuration files overrides. Overrides will be applied in alphabetic order. -include = ahriman.ini.d +include = ahriman.ini.d $HOME/.config/ahriman.ini.d ; Relative path to configuration used by logging package. logging = ahriman.ini.d/logging.ini ; Perform database migrations on the application start. Do not touch this option unless you know what you are doing. diff --git a/ahriman-core/src/ahriman/application/handlers/setup.py b/ahriman-core/src/ahriman/application/handlers/setup.py index b424685b..00a84db3 100644 --- a/ahriman-core/src/ahriman/application/handlers/setup.py +++ b/ahriman-core/src/ahriman/application/handlers/setup.py @@ -18,6 +18,7 @@ # along with this program. If not, see . # import argparse +import os from pathlib import Path from pwd import getpwuid @@ -161,8 +162,9 @@ class Setup(Handler): if args.generate_salt: configuration.set_option("auth", "salt", User.generate_password(20)) - (root.include / "00-setup-overrides.ini").unlink(missing_ok=True) # remove old-style configuration - target = root.include / f"00-setup-overrides-{repository_id.id}.ini" + include_path = next(path for path in root.include if os.access(path, os.W_OK)) + (include_path / "00-setup-overrides.ini").unlink(missing_ok=True) # remove old-style configuration + target = include_path / f"00-setup-overrides-{repository_id.id}.ini" with target.open("w", encoding="utf8") as ahriman_configuration: configuration.write(ahriman_configuration) diff --git a/ahriman-core/src/ahriman/core/configuration/configuration.py b/ahriman-core/src/ahriman/core/configuration/configuration.py index 0ccd5815..69f88ef1 100644 --- a/ahriman-core/src/ahriman/core/configuration/configuration.py +++ b/ahriman-core/src/ahriman/core/configuration/configuration.py @@ -109,14 +109,14 @@ class Configuration(configparser.RawConfigParser): return repository_id.architecture @property - def include(self) -> Path: + def include(self) -> list[Path]: """ - get full path to include directory + get full path to include directory(ies) Returns: - Path: path to directory with configuration includes + list[Path]: path to directory with configuration includes """ - return self.getpath("settings", "include") + return self.getpathlist("settings", "include") @property def logging_path(self) -> Path: @@ -325,23 +325,19 @@ class Configuration(configparser.RawConfigParser): section, key = name.rsplit(":", maxsplit=1) self.set_option(section, key, value) - def load_includes(self, path: Path | None = None) -> None: + def load_includes(self) -> None: """ load configuration includes from specified path - - Args: - path(Path | None, optional): path to directory with include files. If none set, the default path will be - used (Default value = None) """ self.includes = [] # reset state try: - path = path or self.include - for include in sorted(path.glob("*.ini")): - if include == self.logging_path: - continue # we don't want to load logging explicitly - self.read(include) - self.includes.append(include) + for path in self.include: # pylint: disable=not-an-iterable + for include in sorted(path.glob("*.ini")): + if include == self.logging_path: + continue # we don't want to load logging explicitly + self.read(include) + self.includes.append(include) except (FileNotFoundError, configparser.NoOptionError, configparser.NoSectionError): pass diff --git a/ahriman-core/src/ahriman/core/configuration/schema.py b/ahriman-core/src/ahriman/core/configuration/schema.py index 328ec73a..09e85f85 100644 --- a/ahriman-core/src/ahriman/core/configuration/schema.py +++ b/ahriman-core/src/ahriman/core/configuration/schema.py @@ -40,10 +40,13 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "required": True, }, "include": { - "type": "path", - "coerce": "absolute_path", - "path_exists": True, - "path_type": "dir", + "type": "list", + "coerce": "list", + "schema": { + "type": "path", + "coerce": "absolute_path", + "path_type": "dir", + }, }, "logging": { "type": "path", diff --git a/ahriman-core/src/ahriman/core/configuration/validator.py b/ahriman-core/src/ahriman/core/configuration/validator.py index aea43554..552f5ee9 100644 --- a/ahriman-core/src/ahriman/core/configuration/validator.py +++ b/ahriman-core/src/ahriman/core/configuration/validator.py @@ -190,5 +190,5 @@ class Validator(RootValidator): {"type": "string"} """ fn = getattr(value, f"is_{constraint}") - if not fn(): + if value.exists() and not fn(): self._error(field, f"Path {value} must be type of {constraint}") diff --git a/ahriman-core/tests/ahriman/application/handlers/test_handler_setup.py b/ahriman-core/tests/ahriman/application/handlers/test_handler_setup.py index fe8a984d..f8a78d6d 100644 --- a/ahriman-core/tests/ahriman/application/handlers/test_handler_setup.py +++ b/ahriman-core/tests/ahriman/application/handlers/test_handler_setup.py @@ -137,7 +137,11 @@ def test_configuration_create_ahriman(args: argparse.Namespace, configuration: C MockCall("auth", "salt", pytest.helpers.anyvar(str, strict=True)), ]) write_mock.assert_called_once_with(pytest.helpers.anyvar(int)) - remove_mock.assert_called_once_with(configuration.include / "00-setup-overrides.ini", missing_ok=True) + remove_mock.assert_called_once_with( + next( + path for path in configuration.include) / + "00-setup-overrides.ini", + missing_ok=True) def test_configuration_create_ahriman_no_multilib(args: argparse.Namespace, configuration: Configuration, diff --git a/ahriman-core/tests/ahriman/core/configuration/test_configuration.py b/ahriman-core/tests/ahriman/core/configuration/test_configuration.py index 86b638e0..6abf7fcf 100644 --- a/ahriman-core/tests/ahriman/core/configuration/test_configuration.py +++ b/ahriman-core/tests/ahriman/core/configuration/test_configuration.py @@ -374,17 +374,16 @@ def test_load_environment(configuration: Configuration) -> None: assert configuration.get("section:identifier", "key") == "value2" -def test_load_includes(mocker: MockerFixture) -> None: +def test_load_includes(configuration: Configuration, mocker: MockerFixture) -> None: """ must load includes """ mocker.patch.object(Configuration, "logging_path", Path("logging")) read_mock = mocker.patch("ahriman.core.configuration.Configuration.read") glob_mock = mocker.patch("pathlib.Path.glob", autospec=True, return_value=[Path("include"), Path("logging")]) - configuration = Configuration() - configuration.load_includes(Path("path")) - glob_mock.assert_called_once_with(Path("path"), "*.ini") + configuration.load_includes() + glob_mock.assert_called_once_with(configuration.path.absolute().parent, "*.ini") read_mock.assert_called_once_with(Path("include")) assert configuration.includes == [Path("include")] @@ -415,17 +414,6 @@ def test_load_includes_no_section() -> None: configuration.load_includes() -def test_load_includes_default_path(mocker: MockerFixture) -> None: - """ - must load includes from default path - """ - mocker.patch.object(Configuration, "include", Path("path")) - glob_mock = mocker.patch("pathlib.Path.glob", autospec=True, return_value=[]) - - Configuration().load_includes() - glob_mock.assert_called_once_with(Path("path"), "*.ini") - - def test_merge_sections_missing(configuration: Configuration) -> None: """ must merge create section if not exists diff --git a/ahriman-core/tests/ahriman/core/configuration/test_validator.py b/ahriman-core/tests/ahriman/core/configuration/test_validator.py index 039e4bb6..d2fd6cbd 100644 --- a/ahriman-core/tests/ahriman/core/configuration/test_validator.py +++ b/ahriman-core/tests/ahriman/core/configuration/test_validator.py @@ -134,6 +134,10 @@ def test_validate_path_type(validator: Validator, mocker: MockerFixture) -> None """ error_mock = mocker.patch("ahriman.core.configuration.validator.Validator._error") + validator._validate_path_type("file", "field", Path("42")) + + mocker.patch("pathlib.Path.exists", return_value=True) + mocker.patch("pathlib.Path.is_file", return_value=True) validator._validate_path_type("file", "field", Path("1")) diff --git a/docs/configuration.rst b/docs/configuration.rst index 14f81fec..e1df5824 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -82,7 +82,7 @@ Base configuration settings. * ``apply_migrations`` - perform database migrations on the application start, boolean, optional, default ``yes``. Useful if you are using git version. Note, however, that this option must be changed only if you know what to do and going to handle migrations manually. * ``database`` - path to the application SQLite database, string, required. -* ``include`` - path to directory with configuration files overrides, string, optional. Files will be read in alphabetical order. +* ``include`` - path to directories with configuration files overrides, space separated list of strings, optional. Files will be read in alphabetical order. * ``logging`` - path to logging configuration, string, required. Check ``logging.ini`` for reference. ``alpm:*`` groups