Compare commits

...

4 Commits

Author SHA1 Message Date
a28589ec74 feat: add trigger loader guard 2025-09-16 16:34:24 +03:00
dfab5f56b2 feat: use atexit instead of del for triggers 2025-08-11 14:53:10 +03:00
10798b9ba3 fix: correctly process trigger repo specific settings in validator (see #154) 2025-08-01 16:53:15 +03:00
358e3dc4d2 feat: expose repository name and architecure in configuration if available
In some cases there are reference to current repository settings. In
order to handle it correctly two ro options have been added

Related to #154
2025-07-31 14:14:22 +03:00
10 changed files with 161 additions and 71 deletions

View File

@ -139,6 +139,8 @@ Build related configuration. Group name can refer to architecture, e.g. ``build:
Base repository settings.
* ``architecture`` - repository architecture, string. This field is read-only and generated automatically from run options if possible.
* ``name`` - repository name, string. This field is read-only and generated automatically from run options if possible.
* ``root`` - root path for application, string, required.
``sign:*`` groups

View File

@ -52,7 +52,7 @@ class Validate(Handler):
"""
from ahriman.core.configuration.validator import Validator
schema = Validate.schema(repository_id, configuration)
schema = Validate.schema(configuration)
validator = Validator(configuration=configuration, schema=schema)
if validator.validate(configuration.dump()):
@ -83,12 +83,11 @@ class Validate(Handler):
return parser
@staticmethod
def schema(repository_id: RepositoryId, configuration: Configuration) -> ConfigurationSchema:
def schema(configuration: Configuration) -> ConfigurationSchema:
"""
get schema with triggers
Args:
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
Returns:
@ -107,12 +106,12 @@ class Validate(Handler):
continue
# default settings if any
for schema_name, schema in trigger_class.configuration_schema(repository_id, None).items():
for schema_name, schema in trigger_class.configuration_schema(None).items():
erased = Validate.schema_erase_required(copy.deepcopy(schema))
root[schema_name] = Validate.schema_merge(root.get(schema_name, {}), erased)
# settings according to enabled triggers
for schema_name, schema in trigger_class.configuration_schema(repository_id, configuration).items():
for schema_name, schema in trigger_class.configuration_schema(configuration).items():
root[schema_name] = Validate.schema_merge(root.get(schema_name, {}), copy.deepcopy(schema))
return root

View File

@ -43,7 +43,6 @@ class Configuration(configparser.RawConfigParser):
SYSTEM_CONFIGURATION_PATH(Path): (class attribute) default system configuration path distributed by package
includes(list[Path]): list of includes which were read
path(Path | None): path to root configuration file
repository_id(RepositoryId | None): repository unique identifier
Examples:
Configuration class provides additional method in order to handle application configuration. Since this class is
@ -94,7 +93,7 @@ class Configuration(configparser.RawConfigParser):
},
)
self.repository_id: RepositoryId | None = None
self._repository_id: RepositoryId | None = None
self.path: Path | None = None
self.includes: list[Path] = []
@ -129,6 +128,32 @@ class Configuration(configparser.RawConfigParser):
"""
return self.getpath("settings", "logging")
@property
def repository_id(self) -> RepositoryId | None:
"""
repository identifier
Returns:
RepositoryId: repository unique identifier
"""
return self._repository_id
@repository_id.setter
def repository_id(self, repository_id: RepositoryId | None) -> None:
"""
setter for repository identifier
Args:
repository_id(RepositoryId | None): repository unique identifier
"""
self._repository_id = repository_id
if repository_id is None or repository_id.is_empty:
self.remove_option("repository", "name")
self.remove_option("repository", "architecture")
else:
self.set_option("repository", "name", repository_id.name)
self.set_option("repository", "architecture", repository_id.architecture)
@property
def repository_name(self) -> str:
"""

View File

@ -249,6 +249,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"repository": {
"type": "dict",
"schema": {
"architecture": {
"type": "string",
"empty": False,
},
"name": {
"type": "string",
"empty": False,

View File

@ -36,6 +36,7 @@ class Trigger(LazyLogging):
CONFIGURATION_SCHEMA(ConfigurationSchema): (class attribute) configuration schema template
CONFIGURATION_SCHEMA_FALLBACK(str | None): (class attribute) optional fallback option for defining
configuration schema type used
REQUIRES_REPOSITORY(bool): (class attribute) either trigger requires loaded repository or not
configuration(Configuration): configuration instance
repository_id(RepositoryId): repository unique identifier
@ -59,6 +60,7 @@ class Trigger(LazyLogging):
CONFIGURATION_SCHEMA: ClassVar[ConfigurationSchema] = {}
CONFIGURATION_SCHEMA_FALLBACK: ClassVar[str | None] = None
REQUIRES_REPOSITORY: ClassVar[bool] = True
def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:
"""
@ -79,9 +81,18 @@ class Trigger(LazyLogging):
"""
return self.repository_id.architecture
@property
def is_allowed_to_run(self) -> bool:
"""
whether trigger allowed to run or not
Returns:
bool: ``True`` in case if trigger allowed to run and ``False`` otherwise
"""
return not (self.REQUIRES_REPOSITORY and self.repository_id.is_empty)
@classmethod
def configuration_schema(cls, repository_id: RepositoryId,
configuration: Configuration | None) -> ConfigurationSchema:
def configuration_schema(cls, configuration: Configuration | None) -> ConfigurationSchema:
"""
configuration schema based on supplied service configuration
@ -89,7 +100,6 @@ class Trigger(LazyLogging):
Schema must be in cerberus format, for details and examples you can check built-in triggers.
Args:
repository_id(str): repository unique identifier
configuration(Configuration | None): configuration instance. If set to None, the default schema
should be returned
@ -101,10 +111,12 @@ class Trigger(LazyLogging):
result: ConfigurationSchema = {}
for target in cls.configuration_sections(configuration):
if not configuration.has_section(target):
for section in configuration.sections():
if not (section == target or section.startswith(f"{target}:")):
# either repository specific or exact name
continue
section, schema_name = configuration.gettype(
target, repository_id, fallback=cls.CONFIGURATION_SCHEMA_FALLBACK)
schema_name = configuration.get(section, "type", fallback=section)
if schema_name not in cls.CONFIGURATION_SCHEMA:
continue
result[section] = cls.CONFIGURATION_SCHEMA[schema_name]

View File

@ -17,6 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import atexit
import contextlib
import os
@ -60,17 +61,8 @@ class TriggerLoader(LazyLogging):
def __init__(self) -> None:
""""""
self._on_stop_requested = False
self.triggers: list[Trigger] = []
def __del__(self) -> None:
"""
custom destructor object which calls on_stop in case if it was requested
"""
if not self._on_stop_requested:
return
self.on_stop()
@classmethod
def load(cls, repository_id: RepositoryId, configuration: Configuration) -> Self:
"""
@ -85,8 +77,9 @@ class TriggerLoader(LazyLogging):
"""
instance = cls()
instance.triggers = [
instance.load_trigger(trigger, repository_id, configuration)
for trigger in instance.selected_triggers(configuration)
trigger
for trigger_name in instance.selected_triggers(configuration)
if (trigger := instance.load_trigger(trigger_name, repository_id, configuration)).is_allowed_to_run
]
return instance
@ -250,10 +243,11 @@ class TriggerLoader(LazyLogging):
run triggers on load
"""
self.logger.debug("executing triggers on start")
self._on_stop_requested = True
for trigger in self.triggers:
with self.__execute_trigger(trigger):
trigger.on_start()
# register on_stop call
atexit.register(self.on_stop)
def on_stop(self) -> None:
"""

View File

@ -2,6 +2,7 @@ import argparse
import json
import pytest
from pathlib import Path
from pytest_mock import MockerFixture
from ahriman.application.handlers.validate import Validate
@ -53,12 +54,50 @@ def test_run_skip(args: argparse.Namespace, configuration: Configuration, mocker
print_mock.assert_not_called()
def test_run_default(args: argparse.Namespace, configuration: Configuration) -> None:
"""
must run on default configuration without errors
"""
args.exit_code = True
_, repository_id = configuration.check_loaded()
default = Configuration.from_path(Configuration.SYSTEM_CONFIGURATION_PATH, repository_id)
# copy autogenerated values
for section, key in (("build", "build_command"), ("repository", "root")):
value = configuration.get(section, key)
default.set_option(section, key, value)
Validate.run(args, repository_id, default, report=False)
def test_run_repo_specific_triggers(args: argparse.Namespace, configuration: Configuration,
resource_path_root: Path) -> None:
"""
must correctly insert repo specific triggers
"""
args.exit_code = True
_, repository_id = configuration.check_loaded()
# remove unused sections
for section in ("customs3", "github:x86_64", "logs-rotation", "mirrorlist"):
configuration.remove_section(section)
configuration.set_option("report", "target", "test")
for section in ("test", "test:i686", "test:another-repo:x86_64"):
configuration.set_option(section, "type", "html")
configuration.set_option(section, "link_path", "http://link_path")
configuration.set_option(section, "path", "path")
configuration.set_option(section, "template", "template")
configuration.set_option(section, "templates", str(resource_path_root))
Validate.run(args, repository_id, configuration, report=False)
def test_schema(configuration: Configuration) -> None:
"""
must generate full schema correctly
"""
_, repository_id = configuration.check_loaded()
schema = Validate.schema(repository_id, configuration)
schema = Validate.schema(configuration)
# defaults
assert schema.pop("console")
@ -91,9 +130,7 @@ def test_schema_invalid_trigger(configuration: Configuration) -> None:
"""
configuration.set_option("build", "triggers", "some.invalid.trigger.path.Trigger")
configuration.remove_option("build", "triggers_known")
_, repository_id = configuration.check_loaded()
assert Validate.schema(repository_id, configuration) == CONFIGURATION_SCHEMA
assert Validate.schema(configuration) == CONFIGURATION_SCHEMA
def test_schema_erase_required() -> None:

View File

@ -20,6 +20,40 @@ def test_architecture(configuration: Configuration) -> None:
assert configuration.architecture == "x86_64"
def test_repository_id(configuration: Configuration, repository_id: RepositoryId) -> None:
"""
must return repository identifier
"""
assert configuration.repository_id == repository_id
assert configuration.get("repository", "name") == repository_id.name
assert configuration.get("repository", "architecture") == repository_id.architecture
def test_repository_id_erase(configuration: Configuration) -> None:
"""
must remove repository identifier properties if empty identifier supplied
"""
configuration.repository_id = None
assert configuration.get("repository", "name", fallback=None) is None
assert configuration.get("repository", "architecture", fallback=None) is None
configuration.repository_id = RepositoryId("", "")
assert configuration.get("repository", "name", fallback=None) is None
assert configuration.get("repository", "architecture", fallback=None) is None
def test_repository_id_update(configuration: Configuration, repository_id: RepositoryId) -> None:
"""
must update repository identifier and related configuration options
"""
repository_id = RepositoryId("i686", repository_id.name)
configuration.repository_id = repository_id
assert configuration.repository_id == repository_id
assert configuration.get("repository", "name") == repository_id.name
assert configuration.get("repository", "architecture") == repository_id.architecture
def test_repository_name(configuration: Configuration) -> None:
"""
must return valid repository name

View File

@ -3,6 +3,7 @@ from unittest.mock import MagicMock
from ahriman.core.configuration import Configuration
from ahriman.core.report import ReportTrigger
from ahriman.core.triggers import Trigger
from ahriman.models.repository_id import RepositoryId
from ahriman.models.result import Result
@ -13,16 +14,28 @@ def test_architecture(trigger: Trigger) -> None:
assert trigger.architecture == trigger.repository_id.architecture
def test_is_allowed_to_run(trigger: Trigger) -> None:
"""
must return flag correctly
"""
assert trigger.is_allowed_to_run
trigger.repository_id = RepositoryId("", "")
assert not trigger.is_allowed_to_run
trigger.REQUIRES_REPOSITORY = False
assert trigger.is_allowed_to_run
def test_configuration_schema(configuration: Configuration) -> None:
"""
must return used configuration schema
"""
section = "console"
configuration.set_option("report", "target", section)
_, repository_id = configuration.check_loaded()
expected = {section: ReportTrigger.CONFIGURATION_SCHEMA[section]}
assert ReportTrigger.configuration_schema(repository_id, configuration) == expected
assert ReportTrigger.configuration_schema(configuration) == expected
def test_configuration_schema_no_section(configuration: Configuration) -> None:
@ -31,9 +44,7 @@ def test_configuration_schema_no_section(configuration: Configuration) -> None:
"""
section = "abracadabra"
configuration.set_option("report", "target", section)
_, repository_id = configuration.check_loaded()
assert ReportTrigger.configuration_schema(repository_id, configuration) == {}
assert ReportTrigger.configuration_schema(configuration) == {}
def test_configuration_schema_no_schema(configuration: Configuration) -> None:
@ -43,17 +54,15 @@ def test_configuration_schema_no_schema(configuration: Configuration) -> None:
section = "abracadabra"
configuration.set_option("report", "target", section)
configuration.set_option(section, "key", "value")
_, repository_id = configuration.check_loaded()
assert ReportTrigger.configuration_schema(repository_id, configuration) == {}
assert ReportTrigger.configuration_schema(configuration) == {}
def test_configuration_schema_empty(configuration: Configuration) -> None:
"""
must return default schema if no configuration set
"""
_, repository_id = configuration.check_loaded()
assert ReportTrigger.configuration_schema(repository_id, None) == ReportTrigger.CONFIGURATION_SCHEMA
assert ReportTrigger.configuration_schema(None) == ReportTrigger.CONFIGURATION_SCHEMA
def test_configuration_schema_variables() -> None:

View File

@ -153,38 +153,12 @@ def test_on_start(trigger_loader: TriggerLoader, mocker: MockerFixture) -> None:
"""
upload_mock = mocker.patch("ahriman.core.upload.UploadTrigger.on_start")
report_mock = mocker.patch("ahriman.core.report.ReportTrigger.on_start")
atexit_mock = mocker.patch("atexit.register")
trigger_loader.on_start()
assert trigger_loader._on_stop_requested
report_mock.assert_called_once_with()
upload_mock.assert_called_once_with()
def test_on_stop_with_on_start(configuration: Configuration, mocker: MockerFixture) -> None:
"""
must call on_stop on exit if on_start was called
"""
mocker.patch("ahriman.core.upload.UploadTrigger.on_start")
mocker.patch("ahriman.core.report.ReportTrigger.on_start")
on_stop_mock = mocker.patch("ahriman.core.triggers.trigger_loader.TriggerLoader.on_stop")
_, repository_id = configuration.check_loaded()
trigger_loader = TriggerLoader.load(repository_id, configuration)
trigger_loader.on_start()
del trigger_loader
on_stop_mock.assert_called_once_with()
def test_on_stop_without_on_start(configuration: Configuration, mocker: MockerFixture) -> None:
"""
must call not on_stop on exit if on_start wasn't called
"""
on_stop_mock = mocker.patch("ahriman.core.triggers.trigger_loader.TriggerLoader.on_stop")
_, repository_id = configuration.check_loaded()
trigger_loader = TriggerLoader.load(repository_id, configuration)
del trigger_loader
on_stop_mock.assert_not_called()
atexit_mock.assert_called_once_with(trigger_loader.on_stop)
def test_on_stop(trigger_loader: TriggerLoader, mocker: MockerFixture) -> None: