From af1803ed264f8c7899d0d88dbaf86e13881330b1 Mon Sep 17 00:00:00 2001 From: Evgeniy Alekseev Date: Sun, 13 Aug 2023 15:26:45 +0300 Subject: [PATCH] add remote call trigger implementation --- CONTRIBUTING.md | 2 +- src/ahriman/application/handlers/status.py | 6 +- .../application/handlers/status_update.py | 6 +- src/ahriman/application/lock.py | 22 +- src/ahriman/core/log/http_log_handler.py | 15 +- src/ahriman/core/report/remote_call.py | 123 ++++++++ src/ahriman/core/report/report.py | 3 + src/ahriman/core/report/report_trigger.py | 25 ++ src/ahriman/core/repository/executor.py | 2 +- src/ahriman/core/status/client.py | 54 ++-- src/ahriman/core/status/web_client.py | 114 ++++---- src/ahriman/models/report_settings.py | 4 + src/ahriman/models/waiter.py | 72 +++++ .../handlers/test_handler_status.py | 14 +- .../handlers/test_handler_status_update.py | 6 +- .../handlers/test_handler_validate.py | 1 + tests/ahriman/application/test_lock.py | 31 +- .../ahriman/core/log/test_http_log_handler.py | 36 +-- tests/ahriman/core/report/conftest.py | 20 ++ tests/ahriman/core/report/test_remote_call.py | 85 ++++++ tests/ahriman/core/report/test_report.py | 13 + .../ahriman/core/repository/test_executor.py | 8 +- tests/ahriman/core/status/test_client.py | 66 ++--- tests/ahriman/core/status/test_web_client.py | 275 +++++++++++------- tests/ahriman/models/test_report_settings.py | 3 + tests/ahriman/models/test_waiter.py | 29 ++ tests/testresources/core/ahriman.ini | 3 + 27 files changed, 705 insertions(+), 333 deletions(-) create mode 100644 src/ahriman/core/report/remote_call.py create mode 100644 src/ahriman/models/waiter.py create mode 100644 tests/ahriman/core/report/conftest.py create mode 100644 tests/ahriman/core/report/test_remote_call.py create mode 100644 tests/ahriman/models/test_waiter.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9dbc8876..e9ae316b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,7 @@ Again, the most checks can be performed by `make check` command, though some add Args: *args(Any): positional arguments - **kwargs(Any): keyword arguments + **kwargs(Any): keyword arguments """ self.instance_attribute = "" ``` diff --git a/src/ahriman/application/handlers/status.py b/src/ahriman/application/handlers/status.py index 61266636..477d0a3f 100644 --- a/src/ahriman/application/handlers/status.py +++ b/src/ahriman/application/handlers/status.py @@ -50,14 +50,14 @@ class Status(Handler): # we are using reporter here client = Application(architecture, configuration, report=True).repository.reporter if args.ahriman: - service_status = client.get_internal() + service_status = client.status_get() StatusPrinter(service_status.status).print(verbose=args.info) if args.package: packages: list[tuple[Package, BuildStatus]] = sum( - (client.get(base) for base in args.package), + (client.package_get(base) for base in args.package), start=[]) else: - packages = client.get(None) + packages = client.package_get(None) Status.check_if_empty(args.exit_code, not packages) diff --git a/src/ahriman/application/handlers/status_update.py b/src/ahriman/application/handlers/status_update.py index 06b9ec68..ef3ebecf 100644 --- a/src/ahriman/application/handlers/status_update.py +++ b/src/ahriman/application/handlers/status_update.py @@ -49,10 +49,10 @@ class StatusUpdate(Handler): if args.action == Action.Update and args.package: # update packages statuses for package in args.package: - client.update(package, args.status) + client.package_update(package, args.status) elif args.action == Action.Update: # update service status - client.update_self(args.status) + client.status_update(args.status) elif args.action == Action.Remove: for package in args.package: - client.remove(package) + client.package_remove(package) diff --git a/src/ahriman/application/lock.py b/src/ahriman/application/lock.py index d8b0fcc8..d2eb22d0 100644 --- a/src/ahriman/application/lock.py +++ b/src/ahriman/application/lock.py @@ -18,7 +18,6 @@ # along with this program. If not, see . # import argparse -import time from pathlib import Path from types import TracebackType @@ -31,6 +30,7 @@ from ahriman.core.log import LazyLogging from ahriman.core.status.client import Client from ahriman.core.util import check_user from ahriman.models.build_status import BuildStatusEnum +from ahriman.models.waiter import Waiter class Lock(LazyLogging): @@ -81,7 +81,7 @@ class Lock(LazyLogging): """ check web server version """ - status = self.reporter.get_internal() + status = self.reporter.status_get() if status.version is not None and status.version != __version__: self.logger.warning("status watcher version mismatch, our %s, their %s", __version__, status.version) @@ -115,26 +115,18 @@ class Lock(LazyLogging): except FileExistsError: raise DuplicateRunError() - def watch(self, interval: int = 10) -> None: + def watch(self) -> None: """ watch until lock disappear - - Args: - interval(int, optional): interval to check in seconds (Default value = 10) """ - def is_timed_out(start: float) -> bool: - since_start: float = time.monotonic() - start - return self.wait_timeout != 0 and since_start > self.wait_timeout - # there are reasons why we are not using inotify here. First of all, if we would use it, it would bring to # race conditions because multiple processes will be notified in the same time. Secondly, it is good library, # but platform-specific, and we only need to check if file exists if self.path is None: return - start_time = time.monotonic() - while not is_timed_out(start_time) and self.path.is_file(): - time.sleep(interval) + waiter = Waiter(self.wait_timeout) + waiter.wait(self.path.is_file) def __enter__(self) -> Self: """ @@ -154,7 +146,7 @@ class Lock(LazyLogging): self.check_version() self.watch() self.create() - self.reporter.update_self(BuildStatusEnum.Building) + self.reporter.status_update(BuildStatusEnum.Building) return self def __exit__(self, exc_type: type[Exception] | None, exc_val: Exception | None, @@ -172,5 +164,5 @@ class Lock(LazyLogging): """ self.clear() status = BuildStatusEnum.Success if exc_val is None else BuildStatusEnum.Failed - self.reporter.update_self(status) + self.reporter.status_update(status) return False diff --git a/src/ahriman/core/log/http_log_handler.py b/src/ahriman/core/log/http_log_handler.py index bff807a5..0b19dea1 100644 --- a/src/ahriman/core/log/http_log_handler.py +++ b/src/ahriman/core/log/http_log_handler.py @@ -31,17 +31,15 @@ class HttpLogHandler(logging.Handler): Attributes: reporter(Client): build status reporter instance - suppress_errors(bool): suppress logging errors (e.g. if no web server available) """ - def __init__(self, configuration: Configuration, *, report: bool, suppress_errors: bool) -> None: + def __init__(self, configuration: Configuration, *, report: bool) -> None: """ default constructor Args: configuration(Configuration): configuration instance report(bool): force enable or disable reporting - suppress_errors(bool): suppress logging errors (e.g. if no web server available) """ # we don't really care about those parameters because they will be handled by the reporter logging.Handler.__init__(self) @@ -49,7 +47,6 @@ class HttpLogHandler(logging.Handler): # client has to be imported here because of circular imports from ahriman.core.status.client import Client self.reporter = Client.load(configuration, report=report) - self.suppress_errors = suppress_errors @classmethod def load(cls, configuration: Configuration, *, report: bool) -> Self: @@ -68,8 +65,7 @@ class HttpLogHandler(logging.Handler): if (handler := next((handler for handler in root.handlers if isinstance(handler, cls)), None)) is not None: return handler # there is already registered instance - suppress_errors = configuration.getboolean("settings", "suppress_http_log_errors", fallback=False) - handler = cls(configuration, report=report, suppress_errors=suppress_errors) + handler = cls(configuration, report=report) root.addHandler(handler) return handler @@ -85,9 +81,4 @@ class HttpLogHandler(logging.Handler): if package_base is None: return # in case if no package base supplied we need just skip log message - try: - self.reporter.logs(package_base, record) - except Exception: - if self.suppress_errors: - return - self.handleError(record) + self.reporter.package_logs(package_base, record) diff --git a/src/ahriman/core/report/remote_call.py b/src/ahriman/core/report/remote_call.py new file mode 100644 index 00000000..222b365b --- /dev/null +++ b/src/ahriman/core/report/remote_call.py @@ -0,0 +1,123 @@ +# +# Copyright (c) 2021-2023 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from ahriman.core.configuration import Configuration +from ahriman.core.report.report import Report +from ahriman.core.status.web_client import WebClient +from ahriman.models.package import Package +from ahriman.models.result import Result +from ahriman.models.waiter import Waiter + + +class RemoteCall(Report): + """ + trigger implementation which call remote service with update + + Attributes: + client(WebClient): web client instance + update_aur(bool): check for AUR updates + update_local(bool): check for local packages update + update_manual(bool): check for manually built packages + wait_timeout(int): timeout to wait external process + """ + + def __init__(self, architecture: str, configuration: Configuration, section: str) -> None: + """ + default constructor + + Args: + architecture(str): repository architecture + configuration(Configuration): configuration instance + section(str): settings section name + """ + Report.__init__(self, architecture, configuration) + + self.client = WebClient(configuration) + + self.update_aur = configuration.getboolean(section, "aur", fallback=False) + self.update_local = configuration.getboolean(section, "local", fallback=False) + self.update_manual = configuration.getboolean(section, "manual", fallback=False) + + self.wait_timeout = configuration.getint(section, "wait_timeout", fallback=-1) + + def generate(self, packages: list[Package], result: Result) -> None: + """ + generate report for the specified packages + + Args: + packages(list[Package]): list of packages to generate report + result(Result): build result + """ + process_id = self.remote_update() + self.remote_wait(process_id) + + def is_process_alive(self, process_id: str) -> bool: + """ + check if process is alive + + Args: + process_id(str): remote process id + + Returns: + bool: True in case if remote process is alive and False otherwise + """ + response = self.client.make_request("GET", f"{self.client.address}/api/v1/service/process/{process_id}") + if response is None: + return False + + response_json = response.json() + is_alive: bool = response_json["is_alive"] + + return is_alive + + def remote_update(self) -> str | None: + """ + call remote server for update + + Returns: + str | None: remote process id on success and ``None`` otherwise + """ + response = self.client.make_request( + "POST", + f"{self.client.address}/api/v1/service/update", + json={ + "aur": self.update_aur, + "local": self.update_local, + "manual": self.update_manual, + } + ) + if response is None: + return None # request terminated with error + + response_json = response.json() + process_id: str = response_json["process_id"] + return process_id + + def remote_wait(self, process_id: str | None) -> None: + """ + wait for remote process termination + + Args: + process_id(str | None): remote process id + """ + if process_id is None: + return # nothing to track + + waiter = Waiter(self.wait_timeout) + waiter.wait(self.is_process_alive, process_id) diff --git a/src/ahriman/core/report/report.py b/src/ahriman/core/report/report.py index f512a5d7..94a7d361 100644 --- a/src/ahriman/core/report/report.py +++ b/src/ahriman/core/report/report.py @@ -93,6 +93,9 @@ class Report(LazyLogging): if provider == ReportSettings.Telegram: from ahriman.core.report.telegram import Telegram return Telegram(architecture, configuration, section) + if provider == ReportSettings.RemoteCall: + from ahriman.core.report.remote_call import RemoteCall + return RemoteCall(architecture, configuration, section) return Report(architecture, configuration) # should never happen def generate(self, packages: list[Package], result: Result) -> None: diff --git a/src/ahriman/core/report/report_trigger.py b/src/ahriman/core/report/report_trigger.py index 9a4691af..e46a4b1b 100644 --- a/src/ahriman/core/report/report_trigger.py +++ b/src/ahriman/core/report/report_trigger.py @@ -191,6 +191,31 @@ class ReportTrigger(Trigger): }, }, }, + "remote-call": { + "type": "dict", + "schema": { + "type": { + "type": "string", + "allowed": ["remote-call"], + }, + "aur": { + "type": "boolean", + "coerce": "boolean", + }, + "local": { + "type": "boolean", + "coerce": "boolean", + }, + "manual": { + "type": "boolean", + "coerce": "boolean", + }, + "wait_timeout": { + "type": "integer", + "coerce": "integer", + }, + }, + } } def __init__(self, architecture: str, configuration: Configuration) -> None: diff --git a/src/ahriman/core/repository/executor.py b/src/ahriman/core/repository/executor.py index 47f6b19d..d22d4301 100644 --- a/src/ahriman/core/repository/executor.py +++ b/src/ahriman/core/repository/executor.py @@ -121,7 +121,7 @@ class Executor(Cleaner): self.database.build_queue_clear(package_base) self.database.patches_remove(package_base, []) self.database.logs_remove(package_base, None) - self.reporter.remove(package_base) # we only update status page in case of base removal + self.reporter.package_remove(package_base) # we only update status page in case of base removal except Exception: self.logger.exception("could not remove base %s", package_base) diff --git a/src/ahriman/core/status/client.py b/src/ahriman/core/status/client.py index d3efafca..ccecd794 100644 --- a/src/ahriman/core/status/client.py +++ b/src/ahriman/core/status/client.py @@ -60,7 +60,7 @@ class Client: return WebClient(configuration) return Client() - def add(self, package: Package, status: BuildStatusEnum) -> None: + def package_add(self, package: Package, status: BuildStatusEnum) -> None: """ add new package with status @@ -69,7 +69,7 @@ class Client: status(BuildStatusEnum): current package build status """ - def get(self, package_base: str | None) -> list[tuple[Package, BuildStatus]]: + def package_get(self, package_base: str | None) -> list[tuple[Package, BuildStatus]]: """ get package status @@ -82,16 +82,7 @@ class Client: del package_base return [] - def get_internal(self) -> InternalStatus: - """ - get internal service status - - Returns: - InternalStatus: current internal (web) service status - """ - return InternalStatus(status=BuildStatus()) - - def logs(self, package_base: str, record: logging.LogRecord) -> None: + def package_logs(self, package_base: str, record: logging.LogRecord) -> None: """ post log record @@ -100,7 +91,7 @@ class Client: record(logging.LogRecord): log record to post to api """ - def remove(self, package_base: str) -> None: + def package_remove(self, package_base: str) -> None: """ remove packages from watcher @@ -108,7 +99,7 @@ class Client: package_base(str): package base to remove """ - def update(self, package_base: str, status: BuildStatusEnum) -> None: + def package_update(self, package_base: str, status: BuildStatusEnum) -> None: """ update package build status. Unlike ``add`` it does not update package properties @@ -117,14 +108,6 @@ class Client: status(BuildStatusEnum): current package build status """ - def update_self(self, status: BuildStatusEnum) -> None: - """ - update ahriman status itself - - Args: - status(BuildStatusEnum): current ahriman status - """ - def set_building(self, package_base: str) -> None: """ set package status to building @@ -132,7 +115,7 @@ class Client: Args: package_base(str): package base to update """ - return self.update(package_base, BuildStatusEnum.Building) + return self.package_update(package_base, BuildStatusEnum.Building) def set_failed(self, package_base: str) -> None: """ @@ -141,7 +124,7 @@ class Client: Args: package_base(str): package base to update """ - return self.update(package_base, BuildStatusEnum.Failed) + return self.package_update(package_base, BuildStatusEnum.Failed) def set_pending(self, package_base: str) -> None: """ @@ -150,7 +133,7 @@ class Client: Args: package_base(str): package base to update """ - return self.update(package_base, BuildStatusEnum.Pending) + return self.package_update(package_base, BuildStatusEnum.Pending) def set_success(self, package: Package) -> None: """ @@ -159,7 +142,7 @@ class Client: Args: package(Package): current package properties """ - return self.add(package, BuildStatusEnum.Success) + return self.package_add(package, BuildStatusEnum.Success) def set_unknown(self, package: Package) -> None: """ @@ -168,4 +151,21 @@ class Client: Args: package(Package): current package properties """ - return self.add(package, BuildStatusEnum.Unknown) + return self.package_add(package, BuildStatusEnum.Unknown) + + def status_get(self) -> InternalStatus: + """ + get internal service status + + Returns: + InternalStatus: current internal (web) service status + """ + return InternalStatus(status=BuildStatus()) + + def status_update(self, status: BuildStatusEnum) -> None: + """ + update ahriman status itself + + Args: + status(BuildStatusEnum): current ahriman status + """ diff --git a/src/ahriman/core/status/web_client.py b/src/ahriman/core/status/web_client.py index 55e1cdc4..a69fa047 100644 --- a/src/ahriman/core/status/web_client.py +++ b/src/ahriman/core/status/web_client.py @@ -22,6 +22,7 @@ import logging import requests from collections.abc import Generator +from typing import Any, Literal from urllib.parse import quote_plus as urlencode from ahriman import __version__ @@ -164,10 +165,7 @@ class WebClient(Client, LazyLogging): "username": self.user.username, "password": self.user.password } - - with self.__get_session(session): - response = session.post(self._login_url, json=payload) - response.raise_for_status() + self.make_request("POST", self._login_url, json=payload, session=session) def _logs_url(self, package_base: str) -> str: """ @@ -195,7 +193,31 @@ class WebClient(Client, LazyLogging): suffix = f"/{package_base}" if package_base else "" return f"{self.address}/api/v1/packages{suffix}" - def add(self, package: Package, status: BuildStatusEnum) -> None: + def make_request(self, method: Literal["DELETE", "GET", "POST"], url: str, + params: list[tuple[str, str]] | None = None, json: dict[str, Any] | None = None, + session: requests.Session | None = None) -> requests.Response | None: + """ + perform request with specified parameters + + Args: + method(Literal["DELETE", "GET", "POST"]): HTTP method to call + url(str): remote url to call + params(list[tuple[str, str]] | None, optional): request query parameters (Default value = None) + json(dict[str, Any] | None, optional): request json parameters (Default value = None) + session(requests.Session | None, optional): session object if any (Default value = None) + + Returns: + requests.Response | None: response object or None in case of errors + """ + with self.__get_session(session) as _session: + response = _session.request(method, url, params=params, json=json) + response.raise_for_status() + return response + + # noinspection PyUnreachableCode + return None + + def package_add(self, package: Package, status: BuildStatusEnum) -> None: """ add new package with status @@ -207,12 +229,9 @@ class WebClient(Client, LazyLogging): "status": status.value, "package": package.view() } + self.make_request("POST", self._package_url(package.base), json=payload) - with self.__get_session() as session: - response = session.post(self._package_url(package.base), json=payload) - response.raise_for_status() - - def get(self, package_base: str | None) -> list[tuple[Package, BuildStatus]]: + def package_get(self, package_base: str | None) -> list[tuple[Package, BuildStatus]]: """ get package status @@ -222,37 +241,17 @@ class WebClient(Client, LazyLogging): Returns: list[tuple[Package, BuildStatus]]: list of current package description and status if it has been found """ - with self.__get_session() as session: - response = session.get(self._package_url(package_base or "")) - response.raise_for_status() + response = self.make_request("GET", self._package_url(package_base or "")) + if response is None: + return [] - status_json = response.json() - return [ - (Package.from_json(package["package"]), BuildStatus.from_json(package["status"])) - for package in status_json - ] + response_json = response.json() + return [ + (Package.from_json(package["package"]), BuildStatus.from_json(package["status"])) + for package in response_json + ] - # noinspection PyUnreachableCode - return [] - - def get_internal(self) -> InternalStatus: - """ - get internal service status - - Returns: - InternalStatus: current internal (web) service status - """ - with self.__get_session() as session: - response = session.get(self._status_url) - response.raise_for_status() - - status_json = response.json() - return InternalStatus.from_json(status_json) - - # noinspection PyUnreachableCode - return InternalStatus(status=BuildStatus()) - - def logs(self, package_base: str, record: logging.LogRecord) -> None: + def package_logs(self, package_base: str, record: logging.LogRecord) -> None: """ post log record @@ -265,23 +264,18 @@ class WebClient(Client, LazyLogging): "message": record.getMessage(), "process_id": record.process, } + self.make_request("POST", self._logs_url(package_base), json=payload) - # in this method exception has to be handled outside in logger handler - response = self.__session.post(self._logs_url(package_base), json=payload) - response.raise_for_status() - - def remove(self, package_base: str) -> None: + def package_remove(self, package_base: str) -> None: """ remove packages from watcher Args: package_base(str): basename to remove """ - with self.__get_session() as session: - response = session.delete(self._package_url(package_base)) - response.raise_for_status() + self.make_request("DELETE", self._package_url(package_base)) - def update(self, package_base: str, status: BuildStatusEnum) -> None: + def package_update(self, package_base: str, status: BuildStatusEnum) -> None: """ update package build status. Unlike ``add`` it does not update package properties @@ -290,12 +284,23 @@ class WebClient(Client, LazyLogging): status(BuildStatusEnum): current package build status """ payload = {"status": status.value} + self.make_request("POST", self._package_url(package_base), json=payload) - with self.__get_session() as session: - response = session.post(self._package_url(package_base), json=payload) - response.raise_for_status() + def status_get(self) -> InternalStatus: + """ + get internal service status - def update_self(self, status: BuildStatusEnum) -> None: + Returns: + InternalStatus: current internal (web) service status + """ + response = self.make_request("GET", self._status_url) + if response is None: + return InternalStatus(status=BuildStatus()) + + response_json = response.json() + return InternalStatus.from_json(response_json) + + def status_update(self, status: BuildStatusEnum) -> None: """ update ahriman status itself @@ -303,7 +308,4 @@ class WebClient(Client, LazyLogging): status(BuildStatusEnum): current ahriman status """ payload = {"status": status.value} - - with self.__get_session() as session: - response = session.post(self._status_url, json=payload) - response.raise_for_status() + self.make_request("POST", self._status_url, json=payload) diff --git a/src/ahriman/models/report_settings.py b/src/ahriman/models/report_settings.py index 258e0386..5b37473b 100644 --- a/src/ahriman/models/report_settings.py +++ b/src/ahriman/models/report_settings.py @@ -32,6 +32,7 @@ class ReportSettings(str, Enum): Email(ReportSettings): (class attribute) email report generation Console(ReportSettings): (class attribute) print result to console Telegram(ReportSettings): (class attribute) markdown report to telegram channel + RemoteCall(ReportSettings): (class attribute) remote server call """ Disabled = "disabled" # for testing purpose @@ -39,6 +40,7 @@ class ReportSettings(str, Enum): Email = "email" Console = "console" Telegram = "telegram" + RemoteCall = "remote-call" @staticmethod def from_option(value: str) -> ReportSettings: @@ -59,4 +61,6 @@ class ReportSettings(str, Enum): return ReportSettings.Console if value.lower() in ("telegram",): return ReportSettings.Telegram + if value.lower() in ("remote-call",): + return ReportSettings.RemoteCall return ReportSettings.Disabled diff --git a/src/ahriman/models/waiter.py b/src/ahriman/models/waiter.py new file mode 100644 index 00000000..18ec7efc --- /dev/null +++ b/src/ahriman/models/waiter.py @@ -0,0 +1,72 @@ +# +# Copyright (c) 2021-2023 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import time + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import ParamSpec + + +Params = ParamSpec("Params") + + +@dataclass(frozen=True) +class Waiter: + """ + simple waiter implementation + + Attributes: + interval(int): interval in seconds between checks + start_time(float): monotonic time of the waiter start. More likely must not be assigned explicitly + wait_timeout(int): timeout in seconds to wait for. Negative value will result in immediate exit. Zero value + means infinite timeout + """ + + wait_timeout: int + start_time: float = field(default_factory=time.monotonic, kw_only=True) + interval: int = field(default=10, kw_only=True) + + def is_timed_out(self) -> bool: + """ + check if timer is out + + Returns: + bool: True in case current monotonic time is more than ``Waiter.start_time`` and + ``Waiter.wait_timeout`` doesn't equal to 0 + """ + since_start: float = time.monotonic() - self.start_time + return self.wait_timeout != 0 and since_start > self.wait_timeout + + def wait(self, in_progress: Callable[Params, bool], *args: Params.args, **kwargs: Params.kwargs) -> float: + """ + wait until requirements are not met + + Args: + in_progress(Callable[Params, bool]): function to check if timer should wait for another cycle + *args(Params.args): positional arguments for check call + **kwargs(Params.kwargs): keyword arguments for check call + + Returns: + float: consumed time in seconds + """ + while not self.is_timed_out() and in_progress(*args, **kwargs): + time.sleep(self.interval) + + return time.monotonic() - self.start_time diff --git a/tests/ahriman/application/handlers/test_handler_status.py b/tests/ahriman/application/handlers/test_handler_status.py index 695c031b..4c2bf40e 100644 --- a/tests/ahriman/application/handlers/test_handler_status.py +++ b/tests/ahriman/application/handlers/test_handler_status.py @@ -36,8 +36,8 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository: """ args = _default_args(args) mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - application_mock = mocker.patch("ahriman.core.status.client.Client.get_internal") - packages_mock = mocker.patch("ahriman.core.status.client.Client.get", + application_mock = mocker.patch("ahriman.core.status.client.Client.status_get") + packages_mock = mocker.patch("ahriman.core.status.client.Client.package_get", return_value=[(package_ahriman, BuildStatus(BuildStatusEnum.Success)), (package_python_schedule, BuildStatus(BuildStatusEnum.Failed))]) check_mock = mocker.patch("ahriman.application.handlers.Handler.check_if_empty") @@ -58,8 +58,8 @@ def test_run_empty_exception(args: argparse.Namespace, configuration: Configurat args = _default_args(args) args.exit_code = True mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - mocker.patch("ahriman.core.status.client.Client.get_internal") - mocker.patch("ahriman.core.status.client.Client.get", return_value=[]) + mocker.patch("ahriman.core.status.client.Client.status_get") + mocker.patch("ahriman.core.status.client.Client.package_get", return_value=[]) check_mock = mocker.patch("ahriman.application.handlers.Handler.check_if_empty") Status.run(args, "x86_64", configuration, report=False) @@ -74,7 +74,7 @@ def test_run_verbose(args: argparse.Namespace, configuration: Configuration, rep args = _default_args(args) args.info = True mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - mocker.patch("ahriman.core.status.client.Client.get", + mocker.patch("ahriman.core.status.client.Client.package_get", return_value=[(package_ahriman, BuildStatus(BuildStatusEnum.Success))]) print_mock = mocker.patch("ahriman.core.formatters.Printer.print") @@ -90,7 +90,7 @@ def test_run_with_package_filter(args: argparse.Namespace, configuration: Config args = _default_args(args) args.package = [package_ahriman.base] mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - packages_mock = mocker.patch("ahriman.core.status.client.Client.get", + packages_mock = mocker.patch("ahriman.core.status.client.Client.package_get", return_value=[(package_ahriman, BuildStatus(BuildStatusEnum.Success))]) Status.run(args, "x86_64", configuration, report=False) @@ -104,7 +104,7 @@ def test_run_by_status(args: argparse.Namespace, configuration: Configuration, r """ args = _default_args(args) args.status = BuildStatusEnum.Failed - mocker.patch("ahriman.core.status.client.Client.get", + mocker.patch("ahriman.core.status.client.Client.package_get", return_value=[(package_ahriman, BuildStatus(BuildStatusEnum.Success)), (package_python_schedule, BuildStatus(BuildStatusEnum.Failed))]) mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) diff --git a/tests/ahriman/application/handlers/test_handler_status_update.py b/tests/ahriman/application/handlers/test_handler_status_update.py index 679fbb37..5e307115 100644 --- a/tests/ahriman/application/handlers/test_handler_status_update.py +++ b/tests/ahriman/application/handlers/test_handler_status_update.py @@ -34,7 +34,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository: """ args = _default_args(args) mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - update_self_mock = mocker.patch("ahriman.core.status.client.Client.update_self") + update_self_mock = mocker.patch("ahriman.core.status.client.Client.status_update") StatusUpdate.run(args, "x86_64", configuration, report=False) update_self_mock.assert_called_once_with(args.status) @@ -48,7 +48,7 @@ def test_run_packages(args: argparse.Namespace, configuration: Configuration, re args = _default_args(args) args.package = [package_ahriman.base] mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - update_mock = mocker.patch("ahriman.core.status.client.Client.update") + update_mock = mocker.patch("ahriman.core.status.client.Client.package_update") StatusUpdate.run(args, "x86_64", configuration, report=False) update_mock.assert_called_once_with(package_ahriman.base, args.status) @@ -63,7 +63,7 @@ def test_run_remove(args: argparse.Namespace, configuration: Configuration, repo args.package = [package_ahriman.base] args.action = Action.Remove mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - update_mock = mocker.patch("ahriman.core.status.client.Client.remove") + update_mock = mocker.patch("ahriman.core.status.client.Client.package_remove") StatusUpdate.run(args, "x86_64", configuration, report=False) update_mock.assert_called_once_with(package_ahriman.base) diff --git a/tests/ahriman/application/handlers/test_handler_validate.py b/tests/ahriman/application/handlers/test_handler_validate.py index cab07531..62391380 100644 --- a/tests/ahriman/application/handlers/test_handler_validate.py +++ b/tests/ahriman/application/handlers/test_handler_validate.py @@ -67,6 +67,7 @@ def test_schema(configuration: Configuration) -> None: assert schema.pop("keyring-generator") assert schema.pop("mirrorlist") assert schema.pop("mirrorlist-generator") + assert schema.pop("remote-call") assert schema.pop("remote-pull") assert schema.pop("remote-push") assert schema.pop("report") diff --git a/tests/ahriman/application/test_lock.py b/tests/ahriman/application/test_lock.py index ae1ef2c0..088f6b5c 100644 --- a/tests/ahriman/application/test_lock.py +++ b/tests/ahriman/application/test_lock.py @@ -32,7 +32,7 @@ def test_check_version(lock: Lock, mocker: MockerFixture) -> None: """ must check version correctly """ - mocker.patch("ahriman.core.status.client.Client.get_internal", + mocker.patch("ahriman.core.status.client.Client.status_get", return_value=InternalStatus(status=BuildStatus(), version=__version__)) logging_mock = mocker.patch("logging.Logger.warning") @@ -44,7 +44,7 @@ def test_check_version_mismatch(lock: Lock, mocker: MockerFixture) -> None: """ must check mismatched version correctly """ - mocker.patch("ahriman.core.status.client.Client.get_internal", + mocker.patch("ahriman.core.status.client.Client.status_get", return_value=InternalStatus(status=BuildStatus(), version="version")) logging_mock = mocker.patch("logging.Logger.warning") @@ -156,30 +156,13 @@ def test_create_unsafe(lock: Lock) -> None: def test_watch(lock: Lock, mocker: MockerFixture) -> None: """ - must check if lock file exists in cycle + must check if lock file exists """ - mocker.patch("pathlib.Path.is_file", return_value=False) - lock.watch() - - -def test_watch_wait(lock: Lock, mocker: MockerFixture) -> None: - """ - must wait until file will disappear - """ - mocker.patch("pathlib.Path.is_file", side_effect=[True, False]) + wait_mock = mocker.patch("ahriman.models.waiter.Waiter.wait") lock.path = Path(tempfile.mktemp()) # nosec - lock.wait_timeout = 1 - lock.watch(1) - - -def test_watch_empty_timeout(lock: Lock, mocker: MockerFixture) -> None: - """ - must skip watch on empty timeout - """ - mocker.patch("pathlib.Path.is_file", return_value=True) - lock.path = Path(tempfile.mktemp()) # nosec lock.watch() + wait_mock.assert_called_once_with(lock.path.is_file) def test_watch_skip(lock: Lock, mocker: MockerFixture) -> None: @@ -199,7 +182,7 @@ def test_enter(lock: Lock, mocker: MockerFixture) -> None: watch_mock = mocker.patch("ahriman.application.lock.Lock.watch") clear_mock = mocker.patch("ahriman.application.lock.Lock.clear") create_mock = mocker.patch("ahriman.application.lock.Lock.create") - update_status_mock = mocker.patch("ahriman.core.status.client.Client.update_self") + update_status_mock = mocker.patch("ahriman.core.status.client.Client.status_update") with lock: pass @@ -218,7 +201,7 @@ def test_exit_with_exception(lock: Lock, mocker: MockerFixture) -> None: mocker.patch("ahriman.application.lock.Lock.check_user") mocker.patch("ahriman.application.lock.Lock.clear") mocker.patch("ahriman.application.lock.Lock.create") - update_status_mock = mocker.patch("ahriman.core.status.client.Client.update_self") + update_status_mock = mocker.patch("ahriman.core.status.client.Client.status_update") with pytest.raises(Exception): with lock: diff --git a/tests/ahriman/core/log/test_http_log_handler.py b/tests/ahriman/core/log/test_http_log_handler.py index 5927b1fc..340b33bc 100644 --- a/tests/ahriman/core/log/test_http_log_handler.py +++ b/tests/ahriman/core/log/test_http_log_handler.py @@ -40,48 +40,20 @@ def test_emit(configuration: Configuration, log_record: logging.LogRecord, packa must emit log record to reporter """ log_record.package_base = package_ahriman.base - log_mock = mocker.patch("ahriman.core.status.client.Client.logs") + log_mock = mocker.patch("ahriman.core.status.client.Client.package_logs") - handler = HttpLogHandler(configuration, report=False, suppress_errors=False) + handler = HttpLogHandler(configuration, report=False) handler.emit(log_record) log_mock.assert_called_once_with(package_ahriman.base, log_record) -def test_emit_failed(configuration: Configuration, log_record: logging.LogRecord, package_ahriman: Package, - mocker: MockerFixture) -> None: - """ - must call handle error on exception - """ - log_record.package_base = package_ahriman.base - mocker.patch("ahriman.core.status.client.Client.logs", side_effect=Exception()) - handle_error_mock = mocker.patch("logging.Handler.handleError") - handler = HttpLogHandler(configuration, report=False, suppress_errors=False) - - handler.emit(log_record) - handle_error_mock.assert_called_once_with(log_record) - - -def test_emit_suppress_failed(configuration: Configuration, log_record: logging.LogRecord, package_ahriman: Package, - mocker: MockerFixture) -> None: - """ - must not call handle error on exception if suppress flag is set - """ - log_record.package_base = package_ahriman.base - mocker.patch("ahriman.core.status.client.Client.logs", side_effect=Exception()) - handle_error_mock = mocker.patch("logging.Handler.handleError") - handler = HttpLogHandler(configuration, report=False, suppress_errors=True) - - handler.emit(log_record) - handle_error_mock.assert_not_called() - - def test_emit_skip(configuration: Configuration, log_record: logging.LogRecord, mocker: MockerFixture) -> None: """ must skip log record posting if no package base set """ - log_mock = mocker.patch("ahriman.core.status.client.Client.logs") - handler = HttpLogHandler(configuration, report=False, suppress_errors=False) + log_mock = mocker.patch("ahriman.core.status.client.Client.package_logs") + handler = HttpLogHandler(configuration, report=False) handler.emit(log_record) log_mock.assert_not_called() diff --git a/tests/ahriman/core/report/conftest.py b/tests/ahriman/core/report/conftest.py new file mode 100644 index 00000000..a2927ba0 --- /dev/null +++ b/tests/ahriman/core/report/conftest.py @@ -0,0 +1,20 @@ +import pytest + +from ahriman.core.configuration import Configuration +from ahriman.core.report.remote_call import RemoteCall + + +@pytest.fixture +def remote_call(configuration: Configuration) -> RemoteCall: + """ + fixture for remote update trigger + + Args: + configuration(Configuration): configuration fixture + + Returns: + RemoteCall: remote update trigger test instance + """ + configuration.set_option("web", "host", "localhost") + configuration.set_option("web", "port", "8080") + return RemoteCall("x86_64", configuration, "remote-call") diff --git a/tests/ahriman/core/report/test_remote_call.py b/tests/ahriman/core/report/test_remote_call.py new file mode 100644 index 00000000..666df97c --- /dev/null +++ b/tests/ahriman/core/report/test_remote_call.py @@ -0,0 +1,85 @@ +import pytest +import requests + +from pytest_mock import MockerFixture + +from ahriman.core.report.remote_call import RemoteCall +from ahriman.models.result import Result + + +def test_generate(remote_call: RemoteCall, mocker: MockerFixture) -> None: + """ + must correctly call client + """ + update_mock = mocker.patch("ahriman.core.report.remote_call.RemoteCall.remote_update", return_value="id") + wait_mock = mocker.patch("ahriman.core.report.remote_call.RemoteCall.remote_wait") + + remote_call.generate([], Result()) + update_mock.assert_called_once_with() + wait_mock.assert_called_once_with("id") + + +def test_is_process_alive(remote_call: RemoteCall, mocker: MockerFixture) -> None: + """ + must correctly define if process is alive + """ + response_obj = requests.Response() + response_obj._content = """{"is_alive": true}""".encode("utf8") + response_obj.status_code = 200 + + request_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request", return_value=response_obj) + + assert remote_call.is_process_alive("id") + request_mock.assert_called_once_with("GET", f"{remote_call.client.address}/api/v1/service/process/id") + + +def test_is_process_alive_unknown(remote_call: RemoteCall, mocker: MockerFixture) -> None: + """ + must correctly define if process is unknown + """ + mocker.patch("ahriman.core.status.web_client.WebClient.make_request", return_value=None) + assert not remote_call.is_process_alive("id") + + +def test_remote_update(remote_call: RemoteCall, mocker: MockerFixture) -> None: + """ + must call remote server for update process + """ + response_obj = requests.Response() + response_obj._content = """{"process_id": "id"}""".encode("utf8") + response_obj.status_code = 200 + + request_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request", return_value=response_obj) + + assert remote_call.remote_update() == "id" + request_mock.assert_called_once_with("POST", f"{remote_call.client.address}/api/v1/service/update", json={ + "aur": False, + "local": False, + "manual": True, + }) + + +def test_remote_update_failed(remote_call: RemoteCall, mocker: MockerFixture) -> None: + """ + must return empty process id in case of errors + """ + mocker.patch("ahriman.core.status.web_client.WebClient.make_request", return_value=None) + assert remote_call.generate([], Result()) is None + + +def test_remote_wait(remote_call: RemoteCall, mocker: MockerFixture) -> None: + """ + must wait for remote process to success + """ + wait_mock = mocker.patch("ahriman.models.waiter.Waiter.wait") + remote_call.remote_wait("id") + wait_mock.assert_called_once_with(pytest.helpers.anyvar(int), "id") + + +def test_remote_wait_skip(remote_call: RemoteCall, mocker: MockerFixture) -> None: + """ + must skip wait if process id is unknown + """ + wait_mock = mocker.patch("ahriman.models.waiter.Waiter.wait") + remote_call.remote_wait(None) + wait_mock.assert_not_called() diff --git a/tests/ahriman/core/report/test_report.py b/tests/ahriman/core/report/test_report.py index 5715c712..c47f97a3 100644 --- a/tests/ahriman/core/report/test_report.py +++ b/tests/ahriman/core/report/test_report.py @@ -24,6 +24,7 @@ def test_report_dummy(configuration: Configuration, result: Result, mocker: Mock """ mocker.patch("ahriman.models.report_settings.ReportSettings.from_option", return_value=ReportSettings.Disabled) report_mock = mocker.patch("ahriman.core.report.report.Report.generate") + Report.load("x86_64", configuration, "disabled").run(result, []) report_mock.assert_called_once_with([], result) @@ -55,6 +56,18 @@ def test_report_html(configuration: Configuration, result: Result, mocker: Mocke report_mock.assert_called_once_with([], result) +def test_report_remote_call(configuration: Configuration, result: Result, mocker: MockerFixture) -> None: + """ + must instantiate remote call trigger + """ + configuration.set_option("web", "host", "localhost") + configuration.set_option("web", "port", "8080") + report_mock = mocker.patch("ahriman.core.report.remote_call.RemoteCall.generate") + + Report.load("x86_64", configuration, "remote-call").run(result, []) + report_mock.assert_called_once_with([], result) + + def test_report_telegram(configuration: Configuration, result: Result, mocker: MockerFixture) -> None: """ must generate telegram report diff --git a/tests/ahriman/core/repository/test_executor.py b/tests/ahriman/core/repository/test_executor.py index b093a3aa..768258c1 100644 --- a/tests/ahriman/core/repository/test_executor.py +++ b/tests/ahriman/core/repository/test_executor.py @@ -85,7 +85,7 @@ def test_process_remove_base(executor: Executor, package_ahriman: Package, mocke build_queue_mock = mocker.patch("ahriman.core.database.SQLite.build_queue_clear") patches_mock = mocker.patch("ahriman.core.database.SQLite.patches_remove") logs_mock = mocker.patch("ahriman.core.database.SQLite.logs_remove") - status_client_mock = mocker.patch("ahriman.core.status.client.Client.remove") + status_client_mock = mocker.patch("ahriman.core.status.client.Client.package_remove") executor.process_remove([package_ahriman.base]) # must remove via alpm wrapper @@ -106,7 +106,7 @@ def test_process_remove_base_multiple(executor: Executor, package_python_schedul """ mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_python_schedule]) repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove") - status_client_mock = mocker.patch("ahriman.core.status.client.Client.remove") + status_client_mock = mocker.patch("ahriman.core.status.client.Client.package_remove") executor.process_remove([package_python_schedule.base]) # must remove via alpm wrapper @@ -125,7 +125,7 @@ def test_process_remove_base_single(executor: Executor, package_python_schedule: """ mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_python_schedule]) repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove") - status_client_mock = mocker.patch("ahriman.core.status.client.Client.remove") + status_client_mock = mocker.patch("ahriman.core.status.client.Client.package_remove") executor.process_remove(["python2-schedule"]) # must remove via alpm wrapper @@ -171,7 +171,7 @@ def test_process_remove_unknown(executor: Executor, package_ahriman: Package, mo """ mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[]) repo_remove_mock = mocker.patch("ahriman.core.alpm.repo.Repo.remove") - status_client_mock = mocker.patch("ahriman.core.status.client.Client.remove") + status_client_mock = mocker.patch("ahriman.core.status.client.Client.package_remove") executor.process_remove([package_ahriman.base]) repo_remove_mock.assert_not_called() diff --git a/tests/ahriman/core/status/test_client.py b/tests/ahriman/core/status/test_client.py index de9a91da..f6c0c20a 100644 --- a/tests/ahriman/core/status/test_client.py +++ b/tests/ahriman/core/status/test_client.py @@ -51,64 +51,47 @@ def test_load_full_client_from_unix_socket(configuration: Configuration) -> None assert isinstance(Client.load(configuration, report=True), WebClient) -def test_add(client: Client, package_ahriman: Package) -> None: +def test_package_add(client: Client, package_ahriman: Package) -> None: """ must process package addition without errors """ - client.add(package_ahriman, BuildStatusEnum.Unknown) + client.package_add(package_ahriman, BuildStatusEnum.Unknown) -def test_get(client: Client, package_ahriman: Package) -> None: +def test_package_get(client: Client, package_ahriman: Package) -> None: """ must return empty package list """ - assert client.get(package_ahriman.base) == [] - assert client.get(None) == [] + assert client.package_get(package_ahriman.base) == [] + assert client.package_get(None) == [] -def test_get_internal(client: Client) -> None: - """ - must return dummy status for web service - """ - actual = client.get_internal() - expected = InternalStatus(status=BuildStatus(timestamp=actual.status.timestamp)) - - assert actual == expected - - -def test_log(client: Client, package_ahriman: Package, log_record: logging.LogRecord) -> None: +def test_package_log(client: Client, package_ahriman: Package, log_record: logging.LogRecord) -> None: """ must process log record without errors """ - client.logs(package_ahriman.base, log_record) + client.package_logs(package_ahriman.base, log_record) -def test_remove(client: Client, package_ahriman: Package) -> None: +def test_package_remove(client: Client, package_ahriman: Package) -> None: """ must process remove without errors """ - client.remove(package_ahriman.base) + client.package_remove(package_ahriman.base) -def test_update(client: Client, package_ahriman: Package) -> None: +def test_package_update(client: Client, package_ahriman: Package) -> None: """ must update package status without errors """ - client.update(package_ahriman.base, BuildStatusEnum.Unknown) - - -def test_update_self(client: Client) -> None: - """ - must update self status without errors - """ - client.update_self(BuildStatusEnum.Unknown) + client.package_update(package_ahriman.base, BuildStatusEnum.Unknown) def test_set_building(client: Client, package_ahriman: Package, mocker: MockerFixture) -> None: """ must set building status to the package """ - update_mock = mocker.patch("ahriman.core.status.client.Client.update") + update_mock = mocker.patch("ahriman.core.status.client.Client.package_update") client.set_building(package_ahriman.base) update_mock.assert_called_once_with(package_ahriman.base, BuildStatusEnum.Building) @@ -118,7 +101,7 @@ def test_set_failed(client: Client, package_ahriman: Package, mocker: MockerFixt """ must set failed status to the package """ - update_mock = mocker.patch("ahriman.core.status.client.Client.update") + update_mock = mocker.patch("ahriman.core.status.client.Client.package_update") client.set_failed(package_ahriman.base) update_mock.assert_called_once_with(package_ahriman.base, BuildStatusEnum.Failed) @@ -128,7 +111,7 @@ def test_set_pending(client: Client, package_ahriman: Package, mocker: MockerFix """ must set building status to the package """ - update_mock = mocker.patch("ahriman.core.status.client.Client.update") + update_mock = mocker.patch("ahriman.core.status.client.Client.package_update") client.set_pending(package_ahriman.base) update_mock.assert_called_once_with(package_ahriman.base, BuildStatusEnum.Pending) @@ -138,7 +121,7 @@ def test_set_success(client: Client, package_ahriman: Package, mocker: MockerFix """ must set success status to the package """ - add_mock = mocker.patch("ahriman.core.status.client.Client.add") + add_mock = mocker.patch("ahriman.core.status.client.Client.package_add") client.set_success(package_ahriman) add_mock.assert_called_once_with(package_ahriman, BuildStatusEnum.Success) @@ -148,7 +131,24 @@ def test_set_unknown(client: Client, package_ahriman: Package, mocker: MockerFix """ must add new package with unknown status """ - add_mock = mocker.patch("ahriman.core.status.client.Client.add") + add_mock = mocker.patch("ahriman.core.status.client.Client.package_add") client.set_unknown(package_ahriman) add_mock.assert_called_once_with(package_ahriman, BuildStatusEnum.Unknown) + + +def test_status_get(client: Client) -> None: + """ + must return dummy status for web service + """ + actual = client.status_get() + expected = InternalStatus(status=BuildStatus(timestamp=actual.status.timestamp)) + + assert actual == expected + + +def test_status_update(client: Client) -> None: + """ + must update self status without errors + """ + client.status_update(BuildStatusEnum.Unknown) diff --git a/tests/ahriman/core/status/test_web_client.py b/tests/ahriman/core/status/test_web_client.py index b4756be9..7b2b9712 100644 --- a/tests/ahriman/core/status/test_web_client.py +++ b/tests/ahriman/core/status/test_web_client.py @@ -5,7 +5,7 @@ import requests import requests_unixsocket from pytest_mock import MockerFixture -from requests import Response +from unittest.mock import call as MockCall from ahriman.core.configuration import Configuration from ahriman.core.status.web_client import WebClient @@ -74,14 +74,14 @@ def test_login(web_client: WebClient, user: User, mocker: MockerFixture) -> None must login user """ web_client.user = user - requests_mock = mocker.patch("requests.Session.post") + requests_mock = mocker.patch("requests.Session.request") payload = { "username": user.username, "password": user.password } web_client._login(requests.Session()) - requests_mock.assert_called_once_with(pytest.helpers.anyvar(str, True), json=payload) + requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True), params=None, json=payload) def test_login_failed(web_client: WebClient, user: User, mocker: MockerFixture) -> None: @@ -89,7 +89,7 @@ def test_login_failed(web_client: WebClient, user: User, mocker: MockerFixture) must suppress any exception happened during login """ web_client.user = user - mocker.patch("requests.Session.post", side_effect=Exception()) + mocker.patch("requests.Session.request", side_effect=Exception()) web_client._login(requests.Session()) @@ -98,7 +98,7 @@ def test_login_failed_http_error(web_client: WebClient, user: User, mocker: Mock must suppress HTTP exception happened during login """ web_client.user = user - mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError()) + mocker.patch("requests.Session.request", side_effect=requests.exceptions.HTTPError()) web_client._login(requests.Session()) @@ -106,7 +106,7 @@ def test_login_skip(web_client: WebClient, mocker: MockerFixture) -> None: """ must skip login if no user set """ - requests_mock = mocker.patch("requests.Session.post") + requests_mock = mocker.patch("requests.Session.request") web_client._login(requests.Session()) requests_mock.assert_not_called() @@ -130,241 +130,292 @@ def test_package_url(web_client: WebClient, package_ahriman: Package) -> None: assert web_client._package_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}") -def test_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_make_request(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must make HTTP request + """ + request_mock = mocker.patch("requests.Session.request") + + assert web_client.make_request("GET", "url") is not None + assert web_client.make_request("GET", "url", params=[("param", "value")]) is not None + + assert web_client.make_request("POST", "url") is not None + assert web_client.make_request("POST", "url", json={"param": "value"}) is not None + + assert web_client.make_request("DELETE", "url") is not None + + request_mock.assert_has_calls([ + MockCall("GET", "url", params=None, json=None), + MockCall().raise_for_status(), + MockCall("GET", "url", params=[("param", "value")], json=None), + MockCall().raise_for_status(), + MockCall("POST", "url", params=None, json=None), + MockCall().raise_for_status(), + MockCall("POST", "url", params=None, json={"param": "value"}), + MockCall().raise_for_status(), + MockCall("DELETE", "url", params=None, json=None), + MockCall().raise_for_status(), + ]) + + +def test_make_request_failed(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must make HTTP request + """ + mocker.patch("requests.Session.request", side_effect=Exception()) + assert web_client.make_request("GET", "url") is None + + +def test_package_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must process package addition """ - requests_mock = mocker.patch("requests.Session.post") + requests_mock = mocker.patch("requests.Session.request") payload = pytest.helpers.get_package_status(package_ahriman) - web_client.add(package_ahriman, BuildStatusEnum.Unknown) - requests_mock.assert_called_once_with(pytest.helpers.anyvar(str, True), json=payload) + web_client.package_add(package_ahriman, BuildStatusEnum.Unknown) + requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True), params=None, json=payload) -def test_add_failed(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_package_add_failed(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must suppress any exception happened during addition """ - mocker.patch("requests.Session.post", side_effect=Exception()) - web_client.add(package_ahriman, BuildStatusEnum.Unknown) + mocker.patch("requests.Session.request", side_effect=Exception()) + web_client.package_add(package_ahriman, BuildStatusEnum.Unknown) -def test_add_failed_http_error(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_package_add_failed_http_error(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must suppress HTTP exception happened during addition """ - mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError()) - web_client.add(package_ahriman, BuildStatusEnum.Unknown) + mocker.patch("requests.Session.request", side_effect=requests.exceptions.HTTPError()) + web_client.package_add(package_ahriman, BuildStatusEnum.Unknown) -def test_add_failed_suppress(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_package_add_failed_suppress(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must suppress any exception happened during addition and don't log """ web_client.suppress_errors = True - mocker.patch("requests.Session.post", side_effect=Exception()) + mocker.patch("requests.Session.request", side_effect=Exception()) logging_mock = mocker.patch("logging.exception") - web_client.add(package_ahriman, BuildStatusEnum.Unknown) + web_client.package_add(package_ahriman, BuildStatusEnum.Unknown) logging_mock.assert_not_called() -def test_add_failed_http_error_suppress(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_package_add_failed_http_error_suppress(web_client: WebClient, package_ahriman: Package, + mocker: MockerFixture) -> None: """ must suppress HTTP exception happened during addition and don't log """ web_client.suppress_errors = True - mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError()) + mocker.patch("requests.Session.request", side_effect=requests.exceptions.HTTPError()) logging_mock = mocker.patch("logging.exception") - web_client.add(package_ahriman, BuildStatusEnum.Unknown) + web_client.package_add(package_ahriman, BuildStatusEnum.Unknown) logging_mock.assert_not_called() -def test_get_all(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_package_get_all(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must return all packages status """ response = [pytest.helpers.get_package_status_extended(package_ahriman)] - response_obj = Response() + response_obj = requests.Response() response_obj._content = json.dumps(response).encode("utf8") response_obj.status_code = 200 - requests_mock = mocker.patch("requests.Session.get", return_value=response_obj) + requests_mock = mocker.patch("requests.Session.request", return_value=response_obj) - result = web_client.get(None) - requests_mock.assert_called_once_with(web_client._package_url()) + result = web_client.package_get(None) + requests_mock.assert_called_once_with("GET", web_client._package_url(), params=None, json=None) assert len(result) == len(response) assert (package_ahriman, BuildStatusEnum.Unknown) in [(package, status.status) for package, status in result] -def test_get_failed(web_client: WebClient, mocker: MockerFixture) -> None: +def test_package_get_failed(web_client: WebClient, mocker: MockerFixture) -> None: """ must suppress any exception happened during status getting """ - mocker.patch("requests.Session.get", side_effect=Exception()) - assert web_client.get(None) == [] + mocker.patch("requests.Session.request", side_effect=Exception()) + assert web_client.package_get(None) == [] -def test_get_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None: +def test_package_get_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None: """ must suppress HTTP exception happened during status getting """ - mocker.patch("requests.Session.get", side_effect=requests.exceptions.HTTPError()) - assert web_client.get(None) == [] + mocker.patch("requests.Session.request", side_effect=requests.exceptions.HTTPError()) + assert web_client.package_get(None) == [] -def test_get_single(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_package_get_single(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must return single package status """ response = [pytest.helpers.get_package_status_extended(package_ahriman)] - response_obj = Response() + response_obj = requests.Response() response_obj._content = json.dumps(response).encode("utf8") response_obj.status_code = 200 - requests_mock = mocker.patch("requests.Session.get", return_value=response_obj) + requests_mock = mocker.patch("requests.Session.request", return_value=response_obj) - result = web_client.get(package_ahriman.base) - requests_mock.assert_called_once_with(web_client._package_url(package_ahriman.base)) + result = web_client.package_get(package_ahriman.base) + requests_mock.assert_called_once_with("GET", web_client._package_url(package_ahriman.base), + params=None, json=None) assert len(result) == len(response) assert (package_ahriman, BuildStatusEnum.Unknown) in [(package, status.status) for package, status in result] -def test_get_internal(web_client: WebClient, mocker: MockerFixture) -> None: - """ - must return web service status - """ - status = InternalStatus(status=BuildStatus(), architecture="x86_64") - response_obj = Response() - response_obj._content = json.dumps(status.view()).encode("utf8") - response_obj.status_code = 200 - - requests_mock = mocker.patch("requests.Session.get", return_value=response_obj) - - result = web_client.get_internal() - requests_mock.assert_called_once_with(web_client._status_url) - assert result.architecture == "x86_64" - - -def test_get_internal_failed(web_client: WebClient, mocker: MockerFixture) -> None: - """ - must suppress any exception happened during web service status getting - """ - mocker.patch("requests.Session.get", side_effect=Exception()) - assert web_client.get_internal().architecture is None - - -def test_get_internal_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None: - """ - must suppress HTTP exception happened during web service status getting - """ - mocker.patch("requests.Session.get", side_effect=requests.exceptions.HTTPError()) - assert web_client.get_internal().architecture is None - - -def test_logs(web_client: WebClient, log_record: logging.LogRecord, package_ahriman: Package, - mocker: MockerFixture) -> None: +def test_package_logs(web_client: WebClient, log_record: logging.LogRecord, package_ahriman: Package, + mocker: MockerFixture) -> None: """ must process log record """ - requests_mock = mocker.patch("requests.Session.post") + requests_mock = mocker.patch("requests.Session.request") payload = { "created": log_record.created, "message": log_record.getMessage(), "process_id": log_record.process, } - web_client.logs(package_ahriman.base, log_record) - requests_mock.assert_called_once_with(pytest.helpers.anyvar(str, True), json=payload) + web_client.package_logs(package_ahriman.base, log_record) + requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True), params=None, json=payload) -def test_log_failed(web_client: WebClient, log_record: logging.LogRecord, package_ahriman: Package, - mocker: MockerFixture) -> None: +def test_package_logs_failed(web_client: WebClient, log_record: logging.LogRecord, package_ahriman: Package, + mocker: MockerFixture) -> None: """ must pass exception during log post """ - mocker.patch("requests.Session.post", side_effect=Exception()) + mocker.patch("requests.Session.request", side_effect=Exception()) log_record.package_base = package_ahriman.base - with pytest.raises(Exception): - web_client.logs(package_ahriman.base, log_record) + web_client.package_logs(package_ahriman.base, log_record) -def test_remove(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_package_logs_failed_http_error(web_client: WebClient, log_record: logging.LogRecord, package_ahriman: Package, + mocker: MockerFixture) -> None: + """ + must pass exception during log post + """ + mocker.patch("requests.Session.request", side_effect=requests.exceptions.HTTPError()) + log_record.package_base = package_ahriman.base + web_client.package_logs(package_ahriman.base, log_record) + + +def test_package_remove(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must process package removal """ - requests_mock = mocker.patch("requests.Session.delete") + requests_mock = mocker.patch("requests.Session.request") - web_client.remove(package_ahriman.base) - requests_mock.assert_called_once_with(pytest.helpers.anyvar(str, True)) + web_client.package_remove(package_ahriman.base) + requests_mock.assert_called_once_with("DELETE", pytest.helpers.anyvar(str, True), params=None, json=None) -def test_remove_failed(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_package_remove_failed(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must suppress any exception happened during removal """ - mocker.patch("requests.Session.delete", side_effect=Exception()) - web_client.remove(package_ahriman.base) + mocker.patch("requests.Session.request", side_effect=Exception()) + web_client.package_remove(package_ahriman.base) -def test_remove_failed_http_error(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_package_remove_failed_http_error(web_client: WebClient, package_ahriman: Package, + mocker: MockerFixture) -> None: """ must suppress HTTP exception happened during removal """ - mocker.patch("requests.Session.delete", side_effect=requests.exceptions.HTTPError()) - web_client.remove(package_ahriman.base) + mocker.patch("requests.Session.request", side_effect=requests.exceptions.HTTPError()) + web_client.package_remove(package_ahriman.base) -def test_update(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_package_update(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must process package update """ - requests_mock = mocker.patch("requests.Session.post") + requests_mock = mocker.patch("requests.Session.request") - web_client.update(package_ahriman.base, BuildStatusEnum.Unknown) - requests_mock.assert_called_once_with(pytest.helpers.anyvar(str, True), json={ - "status": BuildStatusEnum.Unknown.value}) + web_client.package_update(package_ahriman.base, BuildStatusEnum.Unknown) + requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True), params=None, json={ + "status": BuildStatusEnum.Unknown.value + }) -def test_update_failed(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_package_update_failed(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must suppress any exception happened during update """ - mocker.patch("requests.Session.post", side_effect=Exception()) - web_client.update(package_ahriman.base, BuildStatusEnum.Unknown) + mocker.patch("requests.Session.request", side_effect=Exception()) + web_client.package_update(package_ahriman.base, BuildStatusEnum.Unknown) -def test_update_failed_http_error(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_package_update_failed_http_error(web_client: WebClient, package_ahriman: Package, + mocker: MockerFixture) -> None: """ must suppress HTTP exception happened during update """ - mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError()) - web_client.update(package_ahriman.base, BuildStatusEnum.Unknown) + mocker.patch("requests.Session.request", side_effect=requests.exceptions.HTTPError()) + web_client.package_update(package_ahriman.base, BuildStatusEnum.Unknown) -def test_update_self(web_client: WebClient, mocker: MockerFixture) -> None: +def test_status_get(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must return web service status + """ + status = InternalStatus(status=BuildStatus(), architecture="x86_64") + response_obj = requests.Response() + response_obj._content = json.dumps(status.view()).encode("utf8") + response_obj.status_code = 200 + + requests_mock = mocker.patch("requests.Session.request", return_value=response_obj) + + result = web_client.status_get() + requests_mock.assert_called_once_with("GET", web_client._status_url, params=None, json=None) + assert result.architecture == "x86_64" + + +def test_status_get_failed(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress any exception happened during web service status getting + """ + mocker.patch("requests.Session.request", side_effect=Exception()) + assert web_client.status_get().architecture is None + + +def test_status_get_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress HTTP exception happened during web service status getting + """ + mocker.patch("requests.Session.request", side_effect=requests.exceptions.HTTPError()) + assert web_client.status_get().architecture is None + + +def test_status_update(web_client: WebClient, mocker: MockerFixture) -> None: """ must process service update """ - requests_mock = mocker.patch("requests.Session.post") + requests_mock = mocker.patch("requests.Session.request") - web_client.update_self(BuildStatusEnum.Unknown) - requests_mock.assert_called_once_with(pytest.helpers.anyvar(str, True), json={ - "status": BuildStatusEnum.Unknown.value}) + web_client.status_update(BuildStatusEnum.Unknown) + requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True), params=None, json={ + "status": BuildStatusEnum.Unknown.value + }) -def test_update_self_failed(web_client: WebClient, mocker: MockerFixture) -> None: +def test_status_update_self_failed(web_client: WebClient, mocker: MockerFixture) -> None: """ must suppress any exception happened during service update """ - mocker.patch("requests.Session.post", side_effect=Exception()) - web_client.update_self(BuildStatusEnum.Unknown) + mocker.patch("requests.Session.request", side_effect=Exception()) + web_client.status_update(BuildStatusEnum.Unknown) -def test_update_self_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None: +def test_status_update_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None: """ must suppress HTTP exception happened during service update """ - mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError()) - web_client.update_self(BuildStatusEnum.Unknown) + mocker.patch("requests.Session.request", side_effect=requests.exceptions.HTTPError()) + web_client.status_update(BuildStatusEnum.Unknown) diff --git a/tests/ahriman/models/test_report_settings.py b/tests/ahriman/models/test_report_settings.py index 1b14caea..527eb1ee 100644 --- a/tests/ahriman/models/test_report_settings.py +++ b/tests/ahriman/models/test_report_settings.py @@ -23,3 +23,6 @@ def test_from_option_valid() -> None: assert ReportSettings.from_option("telegram") == ReportSettings.Telegram assert ReportSettings.from_option("TElegraM") == ReportSettings.Telegram + + assert ReportSettings.from_option("remote-call") == ReportSettings.RemoteCall + assert ReportSettings.from_option("reMOte-cALL") == ReportSettings.RemoteCall diff --git a/tests/ahriman/models/test_waiter.py b/tests/ahriman/models/test_waiter.py new file mode 100644 index 00000000..b98e757c --- /dev/null +++ b/tests/ahriman/models/test_waiter.py @@ -0,0 +1,29 @@ +import time + +from ahriman.models.waiter import Waiter + + +def test_is_timed_out() -> None: + """ + must correctly check if timer runs out + """ + assert Waiter(-1).is_timed_out() + assert Waiter(1, start_time=time.monotonic() - 10.0).is_timed_out() + assert not Waiter(1, start_time=time.monotonic() + 10.0).is_timed_out() + + +def test_is_timed_out_infinite() -> None: + """ + must treat 0 wait timeout as infinite + """ + assert not Waiter(0).is_timed_out() + assert not Waiter(0, start_time=time.monotonic() - 10.0).is_timed_out() + + +def test_wait() -> None: + """ + must wait until file will disappear + """ + results = iter([True, False]) + waiter = Waiter(1, interval=1) + assert waiter.wait(lambda: next(results)) > 0 diff --git a/tests/testresources/core/ahriman.ini b/tests/testresources/core/ahriman.ini index b614fe30..cac44f1c 100644 --- a/tests/testresources/core/ahriman.ini +++ b/tests/testresources/core/ahriman.ini @@ -74,6 +74,9 @@ homepage = link_path = template_path = ../web/templates/repo-index.jinja2 +[remote-call] +manual = yes + [telegram] api_key = apikey chat_id = @ahrimantestchat