diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b559bb67..1baef810 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,13 +31,15 @@ Again, the most checks can be performed by `make check` command, though some add ```python def foo(argument: str, *, flag: bool = False) -> int: """ - do foo + do foo. With very very very long + docstring Note: Very important note about this function Args: - argument(str): an argument + argument(str): an argument. This argument has + long description also flag(bool, optional): a flag (Default value = False) Returns: diff --git a/Dockerfile b/Dockerfile index a2d1ec12..34102272 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ENV AHRIMAN_REPOSITORY="aur-clone" ENV AHRIMAN_REPOSITORY_ROOT="/var/lib/ahriman/ahriman" ENV AHRIMAN_UNIX_SOCKET="" ENV AHRIMAN_USER="ahriman" -ENV AHRIMAN_VALIDATE_CONFIGURATION="" +ENV AHRIMAN_VALIDATE_CONFIGURATION="yes" # install environment ## update pacman.conf with multilib diff --git a/docs/faq.rst b/docs/faq.rst index 1530ac23..154c8bc8 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -144,52 +144,30 @@ Before using this command you will need to create local directory, put ``PKGBUIL How to fetch PKGBUILDs from remote repository ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -For that purpose you could use ``RemotePullTrigger`` trigger. To do so you will need: +For that purpose you could use ``RemotePullTrigger`` trigger. To do so you will need to configure trigger as following: -#. - Append ``triggers`` option in ``build`` section with the following line: +.. code-block:: ini - .. code-block:: ini + [remote-pull] + target = gitremote - [build] - triggers = ahriman.core.gitremote.RemotePullTrigger - -#. - Configure trigger as following: - - .. code-block:: ini - - [remote-pull] - target = gitremote - - [gitremote] - pull_url = https://github.com/username/repository + [gitremote] + pull_url = https://github.com/username/repository During the next application run it will fetch repository from the specified url and will try to find packages there which can be used as local sources. How to push updated PKGBUILDs to remote repository ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -For that purpose you'd need to use another trigger called ``RemotePushTrigger``. Configure it as following: +For that purpose you'd need to use another trigger called ``RemotePushTrigger``. Configure trigger as following: -#. - Append ``triggers`` option in ``build`` section with the trigger name: +.. code-block:: ini - .. code-block:: ini + [remote-push] + target = gitremote - [build] - triggers = ahriman.core.gitremote.RemotePushTrigger - -#. - Configure trigger as following: - - .. code-block:: ini - - [remote-push] - target = gitremote - - [gitremote] - push_url = https://github.com/username/repository + [gitremote] + push_url = https://github.com/username/repository Unlike ``RemotePullTrigger`` trigger, the ``RemotePushTrigger`` more likely will require authorization. It is highly recommended to use application tokens for that instead of using your password (e.g. for Github you can generate tokens `here `_ with scope ``public_repo``). Authorization can be supplied by using authorization part of the url, e.g. ``https://key:token@github.com/username/repository``. diff --git a/docs/triggers.rst b/docs/triggers.rst index 3c3e084a..1f4e846b 100644 --- a/docs/triggers.rst +++ b/docs/triggers.rst @@ -125,3 +125,8 @@ Setup the trigger First, put the trigger in any path it can be exported, e.g. by packing the resource into python package (which will lead to import path as ``package.slack_reporter.SlackReporter``) or just put file somewhere it can be accessed by application (e.g. ``/usr/local/lib/slack_reporter.py.SlackReporter``). After that run application as usual and receive notification in your slack channel. + +Trigger configuration schema +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Triggers can expose their configuration schema. It can be achieved by implementing ``CONFIGURATION_SCHEMA`` class variable according to `cerberus `_ documentation. For more details and examples, please refer to built-in triggers implementations. diff --git a/package/share/ahriman/settings/ahriman.ini b/package/share/ahriman/settings/ahriman.ini index 953fadc6..2288be4d 100644 --- a/package/share/ahriman/settings/ahriman.ini +++ b/package/share/ahriman/settings/ahriman.ini @@ -23,7 +23,7 @@ build_command = extra-x86_64-build ignore_packages = makechrootpkg_flags = makepkg_flags = --nocolor --ignorearch -triggers = ahriman.core.report.ReportTrigger ahriman.core.upload.UploadTrigger +triggers = ahriman.core.gitremote.RemotePullTrigger ahriman.core.report.ReportTrigger ahriman.core.upload.UploadTrigger ahriman.core.gitremote.RemotePushTrigger vcs_allowed_age = 604800 [repository] @@ -33,6 +33,12 @@ root = /var/lib/ahriman [sign] target = +[remote-pull] +target = + +[remote-push] +target = + [report] target = console diff --git a/src/ahriman/application/handlers/triggers.py b/src/ahriman/application/handlers/triggers.py index 133ed6ab..6527c29c 100644 --- a/src/ahriman/application/handlers/triggers.py +++ b/src/ahriman/application/handlers/triggers.py @@ -48,6 +48,6 @@ class Triggers(Handler): application = Application(architecture, configuration, report=report, unsafe=unsafe) if args.trigger: loader = application.repository.triggers - loader.triggers = [loader.load_trigger(trigger) for trigger in args.trigger] + loader.triggers = [loader.load_trigger(trigger, architecture, configuration) for trigger in args.trigger] application.on_start() application.on_result(Result()) diff --git a/src/ahriman/application/handlers/validate.py b/src/ahriman/application/handlers/validate.py index f3f48f36..c78bf900 100644 --- a/src/ahriman/application/handlers/validate.py +++ b/src/ahriman/application/handlers/validate.py @@ -20,16 +20,15 @@ import argparse import copy -from typing import Any, Callable, Dict, Optional, Type +from typing import Any, Dict, Type from ahriman.application.handlers import Handler from ahriman.core.configuration import Configuration -from ahriman.core.configuration.schema import CONFIGURATION_SCHEMA, \ - GITREMOTE_REMOTE_PULL_SCHEMA, GITREMOTE_REMOTE_PUSH_SCHEMA, \ - REPORT_CONSOLE_SCHEMA, REPORT_EMAIL_SCHEMA, REPORT_HTML_SCHEMA, REPORT_TELEGRAM_SCHEMA,\ - UPLOAD_GITHUB_SCHEMA, UPLOAD_RSYNC_SCHEMA, UPLOAD_S3_SCHEMA +from ahriman.core.configuration.schema import CONFIGURATION_SCHEMA, ConfigurationSchema from ahriman.core.configuration.validator import Validator +from ahriman.core.exceptions import ExtensionError from ahriman.core.formatters import ValidationPrinter +from ahriman.core.triggers import TriggerLoader class Validate(Handler): @@ -64,7 +63,7 @@ class Validate(Handler): Validate.check_if_empty(args.exit_code, True) @staticmethod - def schema(architecture: str, configuration: Configuration) -> Dict[str, Any]: + def schema(architecture: str, configuration: Configuration) -> ConfigurationSchema: """ get schema with triggers @@ -73,45 +72,39 @@ class Validate(Handler): configuration(Configuration): configuration instance Returns: - Dict[str, Any]: configuration validation schema + ConfigurationSchema: configuration validation schema """ root = copy.deepcopy(CONFIGURATION_SCHEMA) - # that's actually bad design, but in order to validate built-in triggers we need to know which are set - Validate.schema_insert(architecture, configuration, root, "remote-pull", lambda _: GITREMOTE_REMOTE_PULL_SCHEMA) - Validate.schema_insert(architecture, configuration, root, "remote-push", lambda _: GITREMOTE_REMOTE_PUSH_SCHEMA) + # create trigger loader instance + loader = TriggerLoader() + for trigger in loader.selected_triggers(configuration): + try: + trigger_class = loader.load_trigger_class(trigger) + except ExtensionError: + continue - report_schemas = { - "console": REPORT_CONSOLE_SCHEMA, - "email": REPORT_EMAIL_SCHEMA, - "html": REPORT_HTML_SCHEMA, - "telegram": REPORT_TELEGRAM_SCHEMA, - } - for schema_name, schema in report_schemas.items(): - root[schema_name] = Validate.schema_erase_required(copy.deepcopy(schema)) - Validate.schema_insert(architecture, configuration, root, "report", report_schemas.get) + # default settings if any + for schema_name, schema in trigger_class.configuration_schema(architecture, None).items(): + erased = Validate.schema_erase_required(copy.deepcopy(schema)) + root[schema_name] = Validate.schema_merge(root.get(schema_name, {}), erased) - upload_schemas = { - "github": UPLOAD_GITHUB_SCHEMA, - "rsync": UPLOAD_RSYNC_SCHEMA, - "s3": UPLOAD_S3_SCHEMA, - } - for schema_name, schema in upload_schemas.items(): - root[schema_name] = Validate.schema_erase_required(copy.deepcopy(schema)) - Validate.schema_insert(architecture, configuration, root, "upload", upload_schemas.get) + # settings according to enabled triggers + for schema_name, schema in trigger_class.configuration_schema(architecture, configuration).items(): + root[schema_name] = Validate.schema_merge(root.get(schema_name, {}), copy.deepcopy(schema)) return root @staticmethod - def schema_erase_required(schema: Dict[str, Any]) -> Dict[str, Any]: + def schema_erase_required(schema: ConfigurationSchema) -> ConfigurationSchema: """ recursively remove required field from supplied cerberus schema Args: - schema(Dict[str, Any]): source schema from which required field must be removed + schema(ConfigurationSchema): source schema from which required field must be removed Returns: - Dict[str, Any]: schema without required fields + ConfigurationSchema: schema without required fields. Note, that source schema will be modified in-place """ schema.pop("required", None) for value in filter(lambda v: isinstance(v, dict), schema.values()): @@ -119,32 +112,24 @@ class Validate(Handler): return schema @staticmethod - def schema_insert(architecture: str, configuration: Configuration, root: Dict[str, Any], root_section: str, - schema_mapping: Callable[[str], Optional[Dict[str, Any]]]) -> Dict[str, Any]: + def schema_merge(source: Dict[str, Any], schema: Dict[str, Any]) -> Dict[str, Any]: """ - insert child schema into the root schema based on mapping rules - - Notes: - Actually it is a bad design, because we are reading triggers configuration from parsers which (basically) - don't know anything about triggers. But in order to validate built-in triggers we need to know which are set + merge child schema into source. In case if source already contains values, new keys will be added + (possibly with overrides - in case if such key already set also) Args: - architecture(str): repository architecture - configuration(Configuration): configuration instance - root(Dict[str, Any]): root schema in which child schema will be inserted - root_section(str): section name in root schema - schema_mapping(Callable[[str], Optional[Dict[str, Any]]]): extractor for child schema based on trigger type + source(Dict[str, Any]): source (current) schema into which will be merged + schema(Dict[str, Any]): new schema to be merged Returns: - Dict[str, Any]: modified root schema. Note, however, that schema will be modified in place + Dict[str, Any]: schema with added elements from source schema if they were set before and not presented + in the new one. Note, that schema will be modified in-place """ - if not configuration.has_section(root_section): - return root + for key, value in source.items(): + if key not in schema: + schema[key] = value # new key found, just add it as is + elif isinstance(value, dict): + # value is dictionary, so we need to go deeper + Validate.schema_merge(value, schema[key]) - targets = configuration.getlist(root_section, "target", fallback=[]) - for target in targets: - section, schema_name = configuration.gettype(target, architecture) - if (schema := schema_mapping(schema_name)) is not None: - root[section] = copy.deepcopy(schema) - - return root + return schema diff --git a/src/ahriman/core/configuration/configuration.py b/src/ahriman/core/configuration/configuration.py index 21fb744b..2d934845 100644 --- a/src/ahriman/core/configuration/configuration.py +++ b/src/ahriman/core/configuration/configuration.py @@ -189,7 +189,7 @@ class Configuration(configparser.RawConfigParser): def getpath(self, *args: Any, **kwargs: Any) -> Path: ... # type: ignore - def gettype(self, section: str, architecture: str) -> Tuple[str, str]: + def gettype(self, section: str, architecture: str, *, fallback: Optional[str] = None) -> Tuple[str, str]: """ get type variable with fallback to old logic. Despite the fact that it has same semantics as other get* methods, but it has different argument list @@ -197,6 +197,8 @@ class Configuration(configparser.RawConfigParser): Args: section(str): section name architecture(str): repository architecture + fallback(Optional[str], optional): optional fallback type if any. If set, second element of the tuple will + be always set to this value (Default value = None) Returns: Tuple[str, str]: section name and found type name @@ -204,9 +206,8 @@ class Configuration(configparser.RawConfigParser): Raises: configparser.NoSectionError: in case if no section found """ - group_type = self.get(section, "type", fallback=None) # new-style logic - if group_type is not None: - return section, group_type + if (group_type := self.get(section, "type", fallback=fallback)) is not None: + return section, group_type # new-style logic # okay lets check for the section with architecture name full_section = self.section_name(section, architecture) if self.has_section(full_section): @@ -274,14 +275,14 @@ class Configuration(configparser.RawConfigParser): self.load(path) self.merge_sections(architecture) - def set_option(self, section: str, option: str, value: Optional[str]) -> None: + def set_option(self, section: str, option: str, value: str) -> None: """ set option. Unlike default ``configparser.RawConfigParser.set`` it also creates section if it does not exist Args: section(str): section name option(str): option name - value(Optional[str]): option value as string in parsable format + value(str): option value as string in parsable format """ if not self.has_section(section): self.add_section(section) diff --git a/src/ahriman/core/configuration/schema.py b/src/ahriman/core/configuration/schema.py index 34fc7e5e..6db0dd52 100644 --- a/src/ahriman/core/configuration/schema.py +++ b/src/ahriman/core/configuration/schema.py @@ -17,16 +17,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -# pylint: disable=too-many-lines -__all__ = [ - "CONFIGURATION_SCHEMA", - "GITREMOTE_REMOTE_PULL_SCHEMA", "GITREMOTE_REMOTE_PUSH_SCHEMA", - "REPORT_CONSOLE_SCHEMA", "REPORT_EMAIL_SCHEMA", "REPORT_HTML_SCHEMA", "REPORT_TELEGRAM_SCHEMA", - "UPLOAD_GITHUB_SCHEMA", "UPLOAD_RSYNC_SCHEMA", "UPLOAD_S3_SCHEMA", -] +from typing import Any, Dict -CONFIGURATION_SCHEMA = { +__all__ = ["CONFIGURATION_SCHEMA", "ConfigurationSchema"] + + +ConfigurationSchema = Dict[str, Dict[str, Any]] + + +CONFIGURATION_SCHEMA: ConfigurationSchema = { "settings": { "type": "dict", "schema": { @@ -292,263 +292,3 @@ CONFIGURATION_SCHEMA = { }, }, } - - -GITREMOTE_REMOTE_PULL_SCHEMA = { - "type": "dict", - "schema": { - "pull_url": { - "type": "string", - "required": True, - }, - "pull_branch": { - "type": "string", - }, - }, -} - - -GITREMOTE_REMOTE_PUSH_SCHEMA = { - "type": "dict", - "schema": { - "commit_author": { - "type": "string", - }, - "push_url": { - "type": "string", - "required": True, - }, - "push_branch": { - "type": "string", - }, - }, -} - - -REPORT_CONSOLE_SCHEMA = { - "type": "dict", - "schema": { - "type": { - "type": "string", - "allowed": ["console"], - }, - "use_utf": { - "type": "boolean", - "coerce": "boolean", - }, - }, -} - - -REPORT_EMAIL_SCHEMA = { - "type": "dict", - "schema": { - "type": { - "type": "string", - "allowed": ["email"], - }, - "full_template_path": { - "type": "path", - "coerce": "absolute_path", - "path_exists": True, - }, - "homepage": { - "type": "string", - }, - "host": { - "type": "string", - "required": True, - }, - "link_path": { - "type": "string", - "required": True, - }, - "no_empty_report": { - "type": "boolean", - "coerce": "boolean", - }, - "password": { - "type": "string", - }, - "port": { - "type": "integer", - "coerce": "integer", - "required": True, - }, - "receivers": { - "type": "list", - "coerce": "list", - "schema": {"type": "string"}, - "required": True, - "empty": False, - }, - "sender": { - "type": "string", - "required": True, - }, - "ssl": { - "type": "string", - "allowed": ["ssl", "starttls", "disabled"], - }, - "template_path": { - "type": "path", - "coerce": "absolute_path", - "required": True, - "path_exists": True, - }, - "user": { - "type": "string", - }, - }, -} - - -REPORT_HTML_SCHEMA = { - "type": "dict", - "schema": { - "type": { - "type": "string", - "allowed": ["html"], - }, - "homepage": { - "type": "string", - }, - "link_path": { - "type": "string", - "required": True, - }, - "path": { - "type": "path", - "coerce": "absolute_path", - "required": True, - }, - "template_path": { - "type": "path", - "coerce": "absolute_path", - "required": True, - "path_exists": True, - }, - }, -} - - -REPORT_TELEGRAM_SCHEMA = { - "type": "dict", - "schema": { - "type": { - "type": "string", - "allowed": ["telegram"], - }, - "api_key": { - "type": "string", - "required": True, - }, - "chat_id": { - "type": "string", - "required": True, - }, - "homepage": { - "type": "string", - }, - "link_path": { - "type": "string", - "required": True, - }, - "template_path": { - "type": "path", - "coerce": "absolute_path", - "required": True, - "path_exists": True, - }, - "template_type": { - "type": "string", - "allowed": ["MarkdownV2", "HTML", "Markdown"], - }, - "timeout": { - "type": "integer", - "coerce": "integer", - }, - }, -} - - -UPLOAD_GITHUB_SCHEMA = { - "type": "dict", - "schema": { - "type": { - "type": "string", - "allowed": ["github"], - }, - "owner": { - "type": "string", - "required": True, - }, - "password": { - "type": "string", - "required": True, - }, - "repository": { - "type": "string", - "required": True, - }, - "timeout": { - "type": "integer", - "coerce": "integer", - }, - "username": { - "type": "string", - }, - }, -} - -UPLOAD_RSYNC_SCHEMA = { - "type": "dict", - "schema": { - "type": { - "type": "string", - "allowed": ["rsync"], - }, - "command": { - "type": "list", - "coerce": "list", - "schema": {"type": "string"}, - "required": True, - "empty": False, - }, - "remote": { - "type": "string", - "required": True, - }, - }, -} - - -UPLOAD_S3_SCHEMA = { - "type": "dict", - "schema": { - "type": { - "type": "string", - "allowed": ["s3"], - }, - "access_key": { - "type": "string", - "required": True, - }, - "bucket": { - "type": "string", - "required": True, - }, - "chunk_size": { - "type": "integer", - "coerce": "integer", - }, - "region": { - "type": "string", - "required": True, - }, - "secret_key": { - "type": "string", - "required": True, - }, - }, -} diff --git a/src/ahriman/core/configuration/validator.py b/src/ahriman/core/configuration/validator.py index ca828b80..1f895119 100644 --- a/src/ahriman/core/configuration/validator.py +++ b/src/ahriman/core/configuration/validator.py @@ -84,6 +84,7 @@ class Validator(RootValidator): # type: ignore Returns: int: value converted to int according to configuration rules """ + del self return int(value) def _normalize_coerce_list(self, value: str) -> List[str]: diff --git a/src/ahriman/core/gitremote/remote_pull_trigger.py b/src/ahriman/core/gitremote/remote_pull_trigger.py index 785c3bc9..0e97c29e 100644 --- a/src/ahriman/core/gitremote/remote_pull_trigger.py +++ b/src/ahriman/core/gitremote/remote_pull_trigger.py @@ -17,6 +17,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +from typing import List, Type + from ahriman.core.configuration import Configuration from ahriman.core.gitremote.remote_pull import RemotePull from ahriman.core.triggers import Trigger @@ -30,6 +32,22 @@ class RemotePullTrigger(Trigger): targets(List[str]): git remote target list """ + CONFIGURATION_SCHEMA = { + "gitremote": { + "type": "dict", + "schema": { + "pull_url": { + "type": "string", + "required": True, + }, + "pull_branch": { + "type": "string", + }, + }, + }, + } + CONFIGURATION_SCHEMA_FALLBACK = "gitremote" + def __init__(self, architecture: str, configuration: Configuration) -> None: """ default constructor @@ -39,13 +57,27 @@ class RemotePullTrigger(Trigger): configuration(Configuration): configuration instance """ Trigger.__init__(self, architecture, configuration) - self.targets = configuration.getlist("remote-pull", "target", fallback=["gitremote"]) + self.targets = self.configuration_sections(configuration) + + @classmethod + def configuration_sections(cls: Type[Trigger], configuration: Configuration) -> List[str]: + """ + extract configuration sections from configuration + + Args: + configuration(Configuration): configuration instance + + Returns: + List[str]: read configuration sections belong to this trigger + """ + return configuration.getlist("remote-pull", "target", fallback=[]) def on_start(self) -> None: """ trigger action which will be called at the start of the application """ for target in self.targets: - section, _ = self.configuration.gettype(target, self.architecture) + section, _ = self.configuration.gettype( + target, self.architecture, fallback=self.CONFIGURATION_SCHEMA_FALLBACK) runner = RemotePull(self.configuration, section) runner.run() diff --git a/src/ahriman/core/gitremote/remote_push_trigger.py b/src/ahriman/core/gitremote/remote_push_trigger.py index 155ab0f7..b2d89420 100644 --- a/src/ahriman/core/gitremote/remote_push_trigger.py +++ b/src/ahriman/core/gitremote/remote_push_trigger.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from typing import Iterable +from typing import Iterable, List, Type from ahriman.core import context from ahriman.core.configuration import Configuration @@ -37,6 +37,25 @@ class RemotePushTrigger(Trigger): targets(List[str]): git remote target list """ + CONFIGURATION_SCHEMA = { + "gitremote": { + "type": "dict", + "schema": { + "commit_author": { + "type": "string", + }, + "push_url": { + "type": "string", + "required": True, + }, + "push_branch": { + "type": "string", + }, + }, + }, + } + CONFIGURATION_SCHEMA_FALLBACK = "gitremote" + def __init__(self, architecture: str, configuration: Configuration) -> None: """ default constructor @@ -46,7 +65,20 @@ class RemotePushTrigger(Trigger): configuration(Configuration): configuration instance """ Trigger.__init__(self, architecture, configuration) - self.targets = configuration.getlist("remote-push", "target", fallback=["gitremote"]) + self.targets = self.configuration_sections(configuration) + + @classmethod + def configuration_sections(cls: Type[Trigger], configuration: Configuration) -> List[str]: + """ + extract configuration sections from configuration + + Args: + configuration(Configuration): configuration instance + + Returns: + List[str]: read configuration sections belong to this trigger + """ + return configuration.getlist("remote-push", "target", fallback=[]) def on_result(self, result: Result, packages: Iterable[Package]) -> None: """ @@ -63,6 +95,7 @@ class RemotePushTrigger(Trigger): database = ctx.get(ContextKey("database", SQLite)) for target in self.targets: - section, _ = self.configuration.gettype(target, self.architecture) + section, _ = self.configuration.gettype( + target, self.architecture, fallback=self.CONFIGURATION_SCHEMA_FALLBACK) runner = RemotePush(self.configuration, database, section) runner.run(result) diff --git a/src/ahriman/core/report/report_trigger.py b/src/ahriman/core/report/report_trigger.py index 0d76495f..d93c7aa2 100644 --- a/src/ahriman/core/report/report_trigger.py +++ b/src/ahriman/core/report/report_trigger.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from typing import Iterable +from typing import Iterable, List, Type from ahriman.core.configuration import Configuration from ahriman.core.triggers import Trigger @@ -34,6 +34,148 @@ class ReportTrigger(Trigger): targets(List[str]): report target list """ + CONFIGURATION_SCHEMA = { + "console": { + "type": "dict", + "schema": { + "type": { + "type": "string", + "allowed": ["console"], + }, + "use_utf": { + "type": "boolean", + "coerce": "boolean", + }, + }, + }, + "email": { + "type": "dict", + "schema": { + "type": { + "type": "string", + "allowed": ["email"], + }, + "full_template_path": { + "type": "path", + "coerce": "absolute_path", + "path_exists": True, + }, + "homepage": { + "type": "string", + }, + "host": { + "type": "string", + "required": True, + }, + "link_path": { + "type": "string", + "required": True, + }, + "no_empty_report": { + "type": "boolean", + "coerce": "boolean", + }, + "password": { + "type": "string", + }, + "port": { + "type": "integer", + "coerce": "integer", + "required": True, + }, + "receivers": { + "type": "list", + "coerce": "list", + "schema": {"type": "string"}, + "required": True, + "empty": False, + }, + "sender": { + "type": "string", + "required": True, + }, + "ssl": { + "type": "string", + "allowed": ["ssl", "starttls", "disabled"], + }, + "template_path": { + "type": "path", + "coerce": "absolute_path", + "required": True, + "path_exists": True, + }, + "user": { + "type": "string", + }, + }, + }, + "html": { + "type": "dict", + "schema": { + "type": { + "type": "string", + "allowed": ["html"], + }, + "homepage": { + "type": "string", + }, + "link_path": { + "type": "string", + "required": True, + }, + "path": { + "type": "path", + "coerce": "absolute_path", + "required": True, + }, + "template_path": { + "type": "path", + "coerce": "absolute_path", + "required": True, + "path_exists": True, + }, + }, + }, + "telegram": { + "type": "dict", + "schema": { + "type": { + "type": "string", + "allowed": ["telegram"], + }, + "api_key": { + "type": "string", + "required": True, + }, + "chat_id": { + "type": "string", + "required": True, + }, + "homepage": { + "type": "string", + }, + "link_path": { + "type": "string", + "required": True, + }, + "template_path": { + "type": "path", + "coerce": "absolute_path", + "required": True, + "path_exists": True, + }, + "template_type": { + "type": "string", + "allowed": ["MarkdownV2", "HTML", "Markdown"], + }, + "timeout": { + "type": "integer", + "coerce": "integer", + }, + }, + }, + } + def __init__(self, architecture: str, configuration: Configuration) -> None: """ default constructor @@ -43,7 +185,20 @@ class ReportTrigger(Trigger): configuration(Configuration): configuration instance """ Trigger.__init__(self, architecture, configuration) - self.targets = configuration.getlist("report", "target", fallback=[]) + self.targets = self.configuration_sections(configuration) + + @classmethod + def configuration_sections(cls: Type[Trigger], configuration: Configuration) -> List[str]: + """ + extract configuration sections from configuration + + Args: + configuration(Configuration): configuration instance + + Returns: + List[str]: read configuration sections belong to this trigger + """ + return configuration.getlist("report", "target", fallback=[]) def on_result(self, result: Result, packages: Iterable[Package]) -> None: """ diff --git a/src/ahriman/core/repository/repository_properties.py b/src/ahriman/core/repository/repository_properties.py index 430a2ad0..62d2d43f 100644 --- a/src/ahriman/core/repository/repository_properties.py +++ b/src/ahriman/core/repository/repository_properties.py @@ -80,4 +80,4 @@ class RepositoryProperties(LazyLogging): self.sign = GPG(architecture, configuration) self.repo = Repo(self.name, self.paths, self.sign.repository_sign_args) self.reporter = Client.load(configuration, report=report) - self.triggers = TriggerLoader(architecture, configuration) + self.triggers = TriggerLoader.load(architecture, configuration) diff --git a/src/ahriman/core/triggers/trigger.py b/src/ahriman/core/triggers/trigger.py index 4b58c009..56e3e8ce 100644 --- a/src/ahriman/core/triggers/trigger.py +++ b/src/ahriman/core/triggers/trigger.py @@ -17,9 +17,12 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from typing import Iterable +from __future__ import annotations + +from typing import Iterable, List, Optional, Type from ahriman.core.configuration import Configuration +from ahriman.core.configuration.schema import ConfigurationSchema from ahriman.core.log import LazyLogging from ahriman.models.package import Package from ahriman.models.result import Result @@ -30,6 +33,9 @@ class Trigger(LazyLogging): trigger base class Attributes: + CONFIGURATION_SCHEMA(ConfigurationSchema): (class attribute) configuration schema template + CONFIGURATION_SCHEMA_FALLBACK(Optional[str]): (class attribute) optional fallback option for defining + configuration schema type used architecture(str): repository architecture configuration(Configuration): configuration instance @@ -47,10 +53,13 @@ class Trigger(LazyLogging): >>> configuration = Configuration() >>> configuration.set_option("build", "triggers", "my.awesome.package.CustomTrigger") >>> - >>> loader = TriggerLoader("x86_64", configuration) + >>> loader = TriggerLoader.load("x86_64", configuration) >>> loader.on_result(Result(), []) """ + CONFIGURATION_SCHEMA: ConfigurationSchema = {} + CONFIGURATION_SCHEMA_FALLBACK: Optional[str] = None + def __init__(self, architecture: str, configuration: Configuration) -> None: """ default constructor @@ -62,6 +71,60 @@ class Trigger(LazyLogging): self.architecture = architecture self.configuration = configuration + @classmethod + def configuration_schema(cls: Type[Trigger], architecture: str, + configuration: Optional[Configuration]) -> ConfigurationSchema: + """ + configuration schema based on supplied service configuration + + Notes: + Schema must be in cerberus format, for details and examples you can check built-in triggers. + + Args: + architecture(str): repository architecture + configuration(Optional[Configuration]): configuration instance. If set to None, the default schema + should be returned + + Returns: + ConfigurationSchema: configuration schema in cerberus format + """ + if configuration is None: + return cls.CONFIGURATION_SCHEMA + + result: ConfigurationSchema = {} + for target in cls.configuration_sections(configuration): + if not configuration.has_section(target): + continue + section, schema_name = configuration.gettype( + target, architecture, fallback=cls.CONFIGURATION_SCHEMA_FALLBACK) + if schema_name not in cls.CONFIGURATION_SCHEMA: + continue + result[section] = cls.CONFIGURATION_SCHEMA[schema_name] + + return result + + @classmethod + def configuration_sections(cls: Type[Trigger], configuration: Configuration) -> List[str]: + """ + extract configuration sections from configuration + + Args: + configuration(Configuration): configuration instance + + Returns: + List[str]: read configuration sections belong to this trigger + + Examples: + This method can be used in order to extract specific configuration sections which are set by user, e.g. + from sources:: + + >>> @staticmethod + >>> def configuration_sections(cls: Type[Trigger], configuration: Configuration) -> List[str]: + >>> return configuration.getlist("report", "target", fallback=[]) + """ + del configuration + return [] + def on_result(self, result: Result, packages: Iterable[Package]) -> None: """ trigger action which will be called after build process with process result diff --git a/src/ahriman/core/triggers/trigger_loader.py b/src/ahriman/core/triggers/trigger_loader.py index 701dd563..ce88f8c2 100644 --- a/src/ahriman/core/triggers/trigger_loader.py +++ b/src/ahriman/core/triggers/trigger_loader.py @@ -17,13 +17,15 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +from __future__ import annotations + import contextlib import importlib import os from pathlib import Path from types import ModuleType -from typing import Generator, Iterable +from typing import Generator, Iterable, List, Type from ahriman.core.configuration import Configuration from ahriman.core.exceptions import ExtensionError @@ -38,8 +40,6 @@ class TriggerLoader(LazyLogging): trigger loader class Attributes: - architecture(str): repository architecture - configuration(Configuration): configuration instance triggers(List[Trigger]): list of loaded triggers according to the configuration Examples: @@ -50,7 +50,7 @@ class TriggerLoader(LazyLogging): Having such configuration you can create instance of the loader:: - >>> loader = TriggerLoader("x86_64", configuration) + >>> loader = TriggerLoader.load("x86_64", configuration) >>> print(loader.triggers) After that you are free to run triggers:: @@ -58,23 +58,46 @@ class TriggerLoader(LazyLogging): >>> loader.on_result(Result(), []) """ - def __init__(self, architecture: str, configuration: Configuration) -> None: + def __init__(self) -> None: """ default constructor + """ + self._on_stop_requested = False + self.triggers: List[Trigger] = [] + + @classmethod + def load(cls: Type[TriggerLoader], architecture: str, configuration: Configuration) -> TriggerLoader: + """ + create instance from configuration Args: architecture(str): repository architecture configuration(Configuration): configuration instance - """ - self.architecture = architecture - self.configuration = configuration - self._on_stop_requested = False - self.triggers = [ - self.load_trigger(trigger) - for trigger in configuration.getlist("build", "triggers") + Returns: + TriggerLoader: fully loaded trigger instance + """ + instance = cls() + instance.triggers = [ + instance.load_trigger(trigger, architecture, configuration) + for trigger in instance.selected_triggers(configuration) ] + return instance + + @staticmethod + def selected_triggers(configuration: Configuration) -> List[str]: + """ + read configuration and return triggers which are set by settings + + Args: + configuration(Configuration): configuration instance + + Returns: + List[str]: list of triggers according to configuration + """ + return configuration.getlist("build", "triggers", fallback=[]) + @contextlib.contextmanager def __execute_trigger(self, trigger: Trigger) -> Generator[None, None, None]: """ @@ -130,16 +153,39 @@ class TriggerLoader(LazyLogging): except ModuleNotFoundError: raise ExtensionError(f"Module {package} not found") - def load_trigger(self, module_path: str) -> Trigger: + def load_trigger(self, module_path: str, architecture: str, configuration: Configuration) -> Trigger: """ load trigger by module path Args: module_path(str): module import path to load + architecture(str): repository architecture + configuration(Configuration): configuration instance Returns: Trigger: loaded trigger based on settings + Raises: + InvalidExtension: in case if trigger could not be instantiated + """ + trigger_type = self.load_trigger_class(module_path) + try: + trigger = trigger_type(architecture, configuration) + except Exception: + raise ExtensionError(f"Could not load instance of trigger from {trigger_type} loaded from {module_path}") + + return trigger + + def load_trigger_class(self, module_path: str) -> Type[Trigger]: + """ + load trigger class by module path + + Args: + module_path(str): module import path to load + + Returns: + Type[Trigger]: loaded trigger type by module path + Raises: InvalidExtension: in case if module cannot be loaded from the specified module path or is not a trigger """ @@ -156,16 +202,11 @@ class TriggerLoader(LazyLogging): trigger_type = getattr(module, class_name, None) if not isinstance(trigger_type, type): raise ExtensionError(f"{class_name} of {package_or_path} is not a type") + if not issubclass(trigger_type, Trigger): + raise ExtensionError(f"Class {class_name} of {package_or_path} is not a Trigger subclass") + self.logger.info("loaded type %s of package %s", class_name, package_or_path) - - try: - trigger = trigger_type(self.architecture, self.configuration) - except Exception: - raise ExtensionError(f"Could not load instance of trigger from {class_name} of {package_or_path}") - if not isinstance(trigger, Trigger): - raise ExtensionError(f"Class {class_name} of {package_or_path} is not a Trigger") - - return trigger + return trigger_type def on_result(self, result: Result, packages: Iterable[Package]) -> None: """ diff --git a/src/ahriman/core/upload/upload_trigger.py b/src/ahriman/core/upload/upload_trigger.py index 05c7353d..e0dddcb7 100644 --- a/src/ahriman/core/upload/upload_trigger.py +++ b/src/ahriman/core/upload/upload_trigger.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from typing import Iterable +from typing import Iterable, List, Type from ahriman.core.configuration import Configuration from ahriman.core.triggers import Trigger @@ -34,6 +34,86 @@ class UploadTrigger(Trigger): targets(List[str]): upload target list """ + CONFIGURATION_SCHEMA = { + "github": { + "type": "dict", + "schema": { + "type": { + "type": "string", + "allowed": ["github"], + }, + "owner": { + "type": "string", + "required": True, + }, + "password": { + "type": "string", + "required": True, + }, + "repository": { + "type": "string", + "required": True, + }, + "timeout": { + "type": "integer", + "coerce": "integer", + }, + "username": { + "type": "string", + }, + }, + }, + "rsync": { + "type": "dict", + "schema": { + "type": { + "type": "string", + "allowed": ["rsync"], + }, + "command": { + "type": "list", + "coerce": "list", + "schema": {"type": "string"}, + "required": True, + "empty": False, + }, + "remote": { + "type": "string", + "required": True, + }, + }, + }, + "s3": { + "type": "dict", + "schema": { + "type": { + "type": "string", + "allowed": ["s3"], + }, + "access_key": { + "type": "string", + "required": True, + }, + "bucket": { + "type": "string", + "required": True, + }, + "chunk_size": { + "type": "integer", + "coerce": "integer", + }, + "region": { + "type": "string", + "required": True, + }, + "secret_key": { + "type": "string", + "required": True, + }, + }, + }, + } + def __init__(self, architecture: str, configuration: Configuration) -> None: """ default constructor @@ -43,7 +123,20 @@ class UploadTrigger(Trigger): configuration(Configuration): configuration instance """ Trigger.__init__(self, architecture, configuration) - self.targets = configuration.getlist("upload", "target", fallback=[]) + self.targets = self.configuration_sections(configuration) + + @classmethod + def configuration_sections(cls: Type[Trigger], configuration: Configuration) -> List[str]: + """ + extract configuration sections from configuration + + Args: + configuration(Configuration): configuration instance + + Returns: + List[str]: read configuration sections belong to this trigger + """ + return configuration.getlist("upload", "target", fallback=[]) def on_result(self, result: Result, packages: Iterable[Package]) -> None: """ diff --git a/tests/ahriman/application/handlers/test_handler_validate.py b/tests/ahriman/application/handlers/test_handler_validate.py index bc92097f..57bb10fc 100644 --- a/tests/ahriman/application/handlers/test_handler_validate.py +++ b/tests/ahriman/application/handlers/test_handler_validate.py @@ -5,8 +5,9 @@ from pytest_mock import MockerFixture from ahriman.application.handlers import Validate from ahriman.core.configuration import Configuration -from ahriman.core.configuration.schema import CONFIGURATION_SCHEMA, GITREMOTE_REMOTE_PULL_SCHEMA +from ahriman.core.configuration.schema import CONFIGURATION_SCHEMA from ahriman.core.configuration.validator import Validator +from ahriman.core.gitremote import RemotePullTrigger, RemotePushTrigger def _default_args(args: argparse.Namespace) -> argparse.Namespace: @@ -60,7 +61,6 @@ def test_schema(configuration: Configuration) -> None: assert schema.pop("console") assert schema.pop("email") assert schema.pop("github") - assert schema.pop("gitremote") assert schema.pop("html") assert schema.pop("rsync") assert schema.pop("s3") @@ -69,6 +69,14 @@ def test_schema(configuration: Configuration) -> None: assert schema == CONFIGURATION_SCHEMA +def test_schema_invalid_trigger(configuration: Configuration) -> None: + """ + must skip trigger if it caused exception on load + """ + configuration.set_option("build", "triggers", "some.invalid.trigger.path.Trigger") + assert Validate.schema("x86_64", configuration) == CONFIGURATION_SCHEMA + + def test_schema_erase_required() -> None: """ must remove required field from dictionaries recursively @@ -77,24 +85,18 @@ def test_schema_erase_required() -> None: assert "required" not in json.dumps(Validate.schema_erase_required(CONFIGURATION_SCHEMA)) -def test_schema_insert(configuration: Configuration) -> None: +def test_schema_merge() -> None: """ - must insert child schema to root + must merge schemas correctly """ - result = Validate.schema_insert("x86_64", configuration, CONFIGURATION_SCHEMA, "remote-pull", - lambda _: GITREMOTE_REMOTE_PULL_SCHEMA) - assert result["gitremote"] == GITREMOTE_REMOTE_PULL_SCHEMA + erased = Validate.schema_erase_required(CONFIGURATION_SCHEMA) + assert Validate.schema_merge(erased, CONFIGURATION_SCHEMA) == CONFIGURATION_SCHEMA - -def test_schema_insert_skip(configuration: Configuration) -> None: - """ - must do nothing in case if there is no such section or option - """ - configuration.remove_section("remote-pull") - - result = Validate.schema_insert("x86_64", configuration, CONFIGURATION_SCHEMA, "remote-pull", - lambda _: GITREMOTE_REMOTE_PULL_SCHEMA) - assert result == CONFIGURATION_SCHEMA + merged = Validate.schema_merge(RemotePullTrigger.CONFIGURATION_SCHEMA, RemotePushTrigger.CONFIGURATION_SCHEMA) + for key in RemotePullTrigger.CONFIGURATION_SCHEMA["gitremote"]["schema"]: + assert key in merged["gitremote"]["schema"] + for key in RemotePushTrigger.CONFIGURATION_SCHEMA["gitremote"]["schema"]: + assert key in merged["gitremote"]["schema"] def test_disallow_auto_architecture_run() -> None: diff --git a/tests/ahriman/core/configuration/test_configuration.py b/tests/ahriman/core/configuration/test_configuration.py index 1f31e304..f56ad7a0 100644 --- a/tests/ahriman/core/configuration/test_configuration.py +++ b/tests/ahriman/core/configuration/test_configuration.py @@ -207,6 +207,15 @@ def test_gettype(configuration: Configuration) -> None: assert provider == "s3" +def test_gettype_with_fallback(configuration: Configuration) -> None: + """ + must return same provider name as in fallback + """ + section, provider = configuration.gettype("rsync", "x86_64", fallback="abracadabra") + assert section == "rsync" + assert provider == "abracadabra" + + def test_gettype_from_section(configuration: Configuration) -> None: """ must extract type from section name diff --git a/tests/ahriman/core/gitremote/test_remote_pull_trigger.py b/tests/ahriman/core/gitremote/test_remote_pull_trigger.py index 9b22eee0..f3c39f02 100644 --- a/tests/ahriman/core/gitremote/test_remote_pull_trigger.py +++ b/tests/ahriman/core/gitremote/test_remote_pull_trigger.py @@ -4,6 +4,17 @@ from ahriman.core.configuration import Configuration from ahriman.core.gitremote import RemotePullTrigger +def test_configuration_sections(configuration: Configuration) -> None: + """ + must correctly parse target list + """ + configuration.set_option("remote-pull", "target", "a b c") + assert RemotePullTrigger.configuration_sections(configuration) == ["a", "b", "c"] + + configuration.remove_option("remote-pull", "target") + assert RemotePullTrigger.configuration_sections(configuration) == [] + + def test_on_start(configuration: Configuration, mocker: MockerFixture) -> None: """ must clone repo on start diff --git a/tests/ahriman/core/gitremote/test_remote_push_trigger.py b/tests/ahriman/core/gitremote/test_remote_push_trigger.py index 0ea9a728..15acef38 100644 --- a/tests/ahriman/core/gitremote/test_remote_push_trigger.py +++ b/tests/ahriman/core/gitremote/test_remote_push_trigger.py @@ -8,6 +8,17 @@ from ahriman.models.package import Package from ahriman.models.result import Result +def test_configuration_sections(configuration: Configuration) -> None: + """ + must correctly parse target list + """ + configuration.set_option("remote-push", "target", "a b c") + assert RemotePushTrigger.configuration_sections(configuration) == ["a", "b", "c"] + + configuration.remove_option("remote-push", "target") + assert RemotePushTrigger.configuration_sections(configuration) == [] + + def test_on_result(configuration: Configuration, result: Result, package_ahriman: Package, database: SQLite, mocker: MockerFixture) -> None: """ diff --git a/tests/ahriman/core/report/test_report_trigger.py b/tests/ahriman/core/report/test_report_trigger.py index 855db02c..4e1377ba 100644 --- a/tests/ahriman/core/report/test_report_trigger.py +++ b/tests/ahriman/core/report/test_report_trigger.py @@ -5,6 +5,17 @@ from ahriman.core.report import ReportTrigger from ahriman.models.result import Result +def test_configuration_sections(configuration: Configuration) -> None: + """ + must correctly parse target list + """ + configuration.set_option("report", "target", "a b c") + assert ReportTrigger.configuration_sections(configuration) == ["a", "b", "c"] + + configuration.remove_option("report", "target") + assert ReportTrigger.configuration_sections(configuration) == [] + + def test_on_result(configuration: Configuration, mocker: MockerFixture) -> None: """ must run report for specified targets diff --git a/tests/ahriman/core/triggers/conftest.py b/tests/ahriman/core/triggers/conftest.py index 93d53629..386136f0 100644 --- a/tests/ahriman/core/triggers/conftest.py +++ b/tests/ahriman/core/triggers/conftest.py @@ -28,4 +28,4 @@ def trigger_loader(configuration: Configuration) -> TriggerLoader: Returns: TriggerLoader: trigger loader test instance """ - return TriggerLoader("x86_64", configuration) + return TriggerLoader.load("x86_64", configuration) diff --git a/tests/ahriman/core/triggers/test_trigger.py b/tests/ahriman/core/triggers/test_trigger.py index 11afa8ef..2a5e832f 100644 --- a/tests/ahriman/core/triggers/test_trigger.py +++ b/tests/ahriman/core/triggers/test_trigger.py @@ -1,9 +1,64 @@ 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.result import Result +def test_configuration_schema(configuration: Configuration) -> None: + """ + must return used configuration schema + """ + section = "console" + configuration.set_option("report", "target", section) + + expected = {section: ReportTrigger.CONFIGURATION_SCHEMA[section]} + assert ReportTrigger.configuration_schema("x86_64", configuration) == expected + + +def test_configuration_schema_no_section(configuration: Configuration) -> None: + """ + must return nothing in case if section doesn't exist + """ + section = "abracadabra" + configuration.set_option("report", "target", section) + assert ReportTrigger.configuration_schema("x86_64", configuration) == {} + + +def test_configuration_schema_no_schema(configuration: Configuration) -> None: + """ + must return nothing in case if schema doesn't exist + """ + section = "abracadabra" + configuration.set_option("report", "target", section) + configuration.set_option(section, "key", "value") + + assert ReportTrigger.configuration_schema("x86_64", configuration) == {} + + +def test_configuration_schema_empty() -> None: + """ + must return default schema if no configuration set + """ + assert ReportTrigger.configuration_schema("x86_64", None) == ReportTrigger.CONFIGURATION_SCHEMA + + +def test_configuration_schema_variables(configuration: Configuration) -> None: + """ + must return empty schema + """ + assert Trigger.CONFIGURATION_SCHEMA == {} + assert Trigger.CONFIGURATION_SCHEMA_FALLBACK is None + + +def test_configuration_sections(configuration: Configuration) -> None: + """ + must return empty section list + """ + assert Trigger.configuration_sections(configuration) == [] + + def test_on_result(trigger: Trigger) -> None: """ must pass execution nto run method diff --git a/tests/ahriman/core/triggers/test_trigger_loader.py b/tests/ahriman/core/triggers/test_trigger_loader.py index 35d0eb0d..e2396b59 100644 --- a/tests/ahriman/core/triggers/test_trigger_loader.py +++ b/tests/ahriman/core/triggers/test_trigger_loader.py @@ -5,75 +5,97 @@ from pytest_mock import MockerFixture from ahriman.core.configuration import Configuration from ahriman.core.exceptions import ExtensionError +from ahriman.core.report import ReportTrigger from ahriman.core.triggers import TriggerLoader from ahriman.models.package import Package from ahriman.models.result import Result -def test_load_trigger_package(trigger_loader: TriggerLoader) -> None: +def test_selected_triggers(configuration: Configuration) -> None: """ - must load trigger from package + must return used triggers """ - assert trigger_loader.load_trigger("ahriman.core.report.ReportTrigger") + configuration.set_option("build", "triggers", "a b c") + assert TriggerLoader.selected_triggers(configuration) == ["a", "b", "c"] + + configuration.remove_option("build", "triggers") + assert TriggerLoader.selected_triggers(configuration) == [] -def test_load_trigger_package_invalid_import(trigger_loader: TriggerLoader, mocker: MockerFixture) -> None: +def test_load_trigger(trigger_loader: TriggerLoader, configuration: Configuration) -> None: """ - must raise InvalidExtension on invalid import + must load trigger """ - mocker.patch("ahriman.core.triggers.trigger_loader.importlib.import_module", side_effect=ModuleNotFoundError()) - with pytest.raises(ExtensionError): - trigger_loader.load_trigger("random.module") + loaded = trigger_loader.load_trigger("ahriman.core.report.ReportTrigger", "x86_64", configuration) + assert loaded + assert isinstance(loaded, ReportTrigger) -def test_load_trigger_package_not_trigger(trigger_loader: TriggerLoader) -> None: - """ - must raise InvalidExtension if imported module is not a type - """ - with pytest.raises(ExtensionError): - trigger_loader.load_trigger("ahriman.core.util.check_output") - - -def test_load_trigger_package_error_on_creation(trigger_loader: TriggerLoader, mocker: MockerFixture) -> None: +def test_load_trigger_package_error_on_creation(trigger_loader: TriggerLoader, configuration: Configuration, + mocker: MockerFixture) -> None: """ must raise InvalidException on trigger initialization if any exception is thrown """ mocker.patch("ahriman.core.triggers.trigger.Trigger.__init__", side_effect=Exception()) with pytest.raises(ExtensionError): - trigger_loader.load_trigger("ahriman.core.report.ReportTrigger") + trigger_loader.load_trigger("ahriman.core.report.ReportTrigger", "x86_64", configuration) -def test_load_trigger_package_is_not_trigger(trigger_loader: TriggerLoader) -> None: +def test_load_trigger_class_package(trigger_loader: TriggerLoader) -> None: + """ + must load trigger class from package + """ + assert trigger_loader.load_trigger_class("ahriman.core.report.ReportTrigger") == ReportTrigger + + +def test_load_trigger_class_package_invalid_import(trigger_loader: TriggerLoader, mocker: MockerFixture) -> None: + """ + must raise InvalidExtension on invalid import + """ + mocker.patch("ahriman.core.triggers.trigger_loader.importlib.import_module", side_effect=ModuleNotFoundError()) + with pytest.raises(ExtensionError): + trigger_loader.load_trigger_class("random.module") + + +def test_load_trigger_class_package_not_trigger(trigger_loader: TriggerLoader) -> None: + """ + must raise InvalidExtension if imported module is not a type + """ + with pytest.raises(ExtensionError): + trigger_loader.load_trigger_class("ahriman.core.util.check_output") + + +def test_load_trigger_class_package_is_not_trigger(trigger_loader: TriggerLoader) -> None: """ must raise InvalidExtension if loaded class is not a trigger """ with pytest.raises(ExtensionError): - trigger_loader.load_trigger("ahriman.core.sign.gpg.GPG") + trigger_loader.load_trigger_class("ahriman.core.sign.gpg.GPG") -def test_load_trigger_path(trigger_loader: TriggerLoader, resource_path_root: Path) -> None: +def test_load_trigger_class_path(trigger_loader: TriggerLoader, resource_path_root: Path) -> None: """ - must load trigger from path + must load trigger class from path """ - path = resource_path_root.parent.parent / "src" / "ahriman" / "core" / "report" / "report_trigger.py" - assert trigger_loader.load_trigger(f"{path}.ReportTrigger") + path = resource_path_root.parent.parent / "src" / "ahriman" / "core" / "report" / "__init__.py" + assert trigger_loader.load_trigger_class(f"{path}.ReportTrigger") == ReportTrigger -def test_load_trigger_path_directory(trigger_loader: TriggerLoader, resource_path_root: Path) -> None: +def test_load_trigger_class_path_directory(trigger_loader: TriggerLoader, resource_path_root: Path) -> None: """ must raise InvalidExtension if provided import path is directory """ path = resource_path_root.parent.parent / "src" / "ahriman" / "core" / "report" with pytest.raises(ExtensionError): - trigger_loader.load_trigger(f"{path}.ReportTrigger") + trigger_loader.load_trigger_class(f"{path}.ReportTrigger") -def test_load_trigger_path_not_found(trigger_loader: TriggerLoader) -> None: +def test_load_trigger_class_path_not_found(trigger_loader: TriggerLoader) -> None: """ must raise InvalidExtension if file cannot be found """ with pytest.raises(ExtensionError): - trigger_loader.load_trigger("/some/random/path.py.SomeRandomModule") + trigger_loader.load_trigger_class("/some/random/path.py.SomeRandomModule") def test_on_result(trigger_loader: TriggerLoader, package_ahriman: Package, mocker: MockerFixture) -> None: @@ -119,9 +141,11 @@ def test_on_stop_with_on_start(configuration: Configuration, mocker: MockerFixtu """ 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") - trigger_loader = TriggerLoader("x86_64", configuration) + trigger_loader = TriggerLoader.load("x86_64", configuration) trigger_loader.on_start() del trigger_loader on_stop_mock.assert_called_once_with() @@ -133,7 +157,7 @@ def test_on_stop_without_on_start(configuration: Configuration, mocker: MockerFi """ on_stop_mock = mocker.patch("ahriman.core.triggers.trigger_loader.TriggerLoader.on_stop") - trigger_loader = TriggerLoader("x86_64", configuration) + trigger_loader = TriggerLoader.load("x86_64", configuration) del trigger_loader on_stop_mock.assert_not_called() diff --git a/tests/ahriman/core/upload/test_upload_trigger.py b/tests/ahriman/core/upload/test_upload_trigger.py index a1ae9f34..06c45400 100644 --- a/tests/ahriman/core/upload/test_upload_trigger.py +++ b/tests/ahriman/core/upload/test_upload_trigger.py @@ -5,6 +5,17 @@ from ahriman.core.upload import UploadTrigger from ahriman.models.result import Result +def test_configuration_sections(configuration: Configuration) -> None: + """ + must correctly parse target list + """ + configuration.set_option("upload", "target", "a b c") + assert UploadTrigger.configuration_sections(configuration) == ["a", "b", "c"] + + configuration.remove_option("upload", "target") + assert UploadTrigger.configuration_sections(configuration) == [] + + def test_on_result(configuration: Configuration, mocker: MockerFixture) -> None: """ must run report for specified targets diff --git a/tests/testresources/core/ahriman.ini b/tests/testresources/core/ahriman.ini index 1118b86b..bc7b5a85 100644 --- a/tests/testresources/core/ahriman.ini +++ b/tests/testresources/core/ahriman.ini @@ -45,7 +45,7 @@ push_url = https://github.com/arcan1s/repository.git pull_url = https://github.com/arcan1s/repository.git [report] -target = +target = console [email] host = 127.0.0.1