diff --git a/src/ahriman/application/handlers/search.py b/src/ahriman/application/handlers/search.py index 846b7eba..2c58b314 100644 --- a/src/ahriman/application/handlers/search.py +++ b/src/ahriman/application/handlers/search.py @@ -23,7 +23,8 @@ from dataclasses import fields from typing import Callable, Iterable, List, Tuple, Type from ahriman.application.handlers.handler import Handler -from ahriman.core.alpm.aur import AUR +from ahriman.core.alpm.remote.aur import AUR +from ahriman.core.alpm.remote.official import Official from ahriman.core.configuration import Configuration from ahriman.core.exceptions import InvalidOption from ahriman.core.formatters.aur_printer import AurPrinter @@ -50,10 +51,14 @@ class Search(Handler): :param no_report: force disable reporting :param unsafe: if set no user check will be performed before path creation """ - packages_list = AUR.multisearch(*args.search) - Search.check_if_empty(args.exit_code, not packages_list) - for package in Search.sort(packages_list, args.sort_by): - AurPrinter(package).print(args.info) + official_packages_list = Official.multisearch(*args.search) + aur_packages_list = AUR.multisearch(*args.search) + Search.check_if_empty(args.exit_code, not official_packages_list and not aur_packages_list) + + for packages_list in (official_packages_list, aur_packages_list): + # keep sorting by packages source + for package in Search.sort(packages_list, args.sort_by): + AurPrinter(package).print(args.info) @staticmethod def sort(packages: Iterable[AURPackage], sort_by: str) -> List[AURPackage]: diff --git a/src/ahriman/core/alpm/remote/__init__.py b/src/ahriman/core/alpm/remote/__init__.py new file mode 100644 index 00000000..680676f1 --- /dev/null +++ b/src/ahriman/core/alpm/remote/__init__.py @@ -0,0 +1,19 @@ +# +# Copyright (c) 2021-2022 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 . +# diff --git a/src/ahriman/core/alpm/aur.py b/src/ahriman/core/alpm/remote/aur.py similarity index 67% rename from src/ahriman/core/alpm/aur.py rename to src/ahriman/core/alpm/remote/aur.py index eacde36a..01b7fe1a 100644 --- a/src/ahriman/core/alpm/aur.py +++ b/src/ahriman/core/alpm/remote/aur.py @@ -17,24 +17,21 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from __future__ import annotations - -import logging import requests -from typing import Any, Dict, List, Optional, Type +from typing import Any, Dict, List, Optional +from ahriman.core.alpm.remote.remote import Remote from ahriman.core.exceptions import InvalidPackageInfo from ahriman.core.util import exception_response_text from ahriman.models.aur_package import AURPackage -class AUR: +class AUR(Remote): """ AUR RPC wrapper :cvar DEFAULT_RPC_URL: default AUR RPC url :cvar DEFAULT_RPC_VERSION: default AUR RPC version - :ivar logger: class logger :ivar rpc_url: AUR RPC url :ivar rpc_version: AUR RPC version """ @@ -48,46 +45,9 @@ class AUR: :param rpc_url: AUR RPC url :param rpc_version: AUR RPC version """ + Remote.__init__(self) self.rpc_url = rpc_url or self.DEFAULT_RPC_URL self.rpc_version = rpc_version or self.DEFAULT_RPC_VERSION - self.logger = logging.getLogger("build_details") - - @classmethod - def info(cls: Type[AUR], package_name: str) -> AURPackage: - """ - get package info by its name - :param package_name: package name to search - :return: package which match the package name - """ - return cls().package_info(package_name) - - @classmethod - def multisearch(cls: Type[AUR], *keywords: str) -> List[AURPackage]: - """ - search in AUR by using API with multiple words. This method is required in order to handle - https://bugs.archlinux.org/task/49133. In addition short words will be dropped - :param keywords: search terms, e.g. "ahriman", "is", "cool" - :return: list of packages each of them matches all search terms - """ - instance = cls() - packages: Dict[str, AURPackage] = {} - for term in filter(lambda word: len(word) > 3, keywords): - portion = instance.search(term) - packages = { - package.package_base: package - for package in portion - if package.package_base in packages or not packages - } - return list(packages.values()) - - @classmethod - def search(cls: Type[AUR], *keywords: str) -> List[AURPackage]: - """ - search package in AUR web - :param keywords: keywords to search - :return: list of packages which match the criteria - """ - return cls().package_search(*keywords) @staticmethod def parse_response(response: Dict[str, Any]) -> List[AURPackage]: @@ -144,11 +104,10 @@ class AUR: packages = self.make_request("info", package_name) return next(package for package in packages if package.name == package_name) - def package_search(self, *keywords: str, by: str = "name-desc") -> List[AURPackage]: + def package_search(self, *keywords: str) -> List[AURPackage]: """ search package in AUR web :param keywords: keywords to search - :param by: search by the field :return: list of packages which match the criteria """ - return self.make_request("search", *keywords, by=by) + return self.make_request("search", *keywords, by="name-desc") diff --git a/src/ahriman/core/alpm/remote/official.py b/src/ahriman/core/alpm/remote/official.py new file mode 100644 index 00000000..3e20ad25 --- /dev/null +++ b/src/ahriman/core/alpm/remote/official.py @@ -0,0 +1,91 @@ +# +# Copyright (c) 2021-2022 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 requests + +from typing import Any, Dict, List, Optional + +from ahriman.core.alpm.remote.remote import Remote +from ahriman.core.exceptions import InvalidPackageInfo +from ahriman.core.util import exception_response_text +from ahriman.models.aur_package import AURPackage + + +class Official(Remote): + """ + official repository RPC wrapper + :cvar DEFAULT_RPC_URL: default AUR RPC url + :ivar rpc_url: AUR RPC url + """ + + DEFAULT_RPC_URL = "https://archlinux.org/packages/search/json" + + def __init__(self, rpc_url: Optional[str] = None) -> None: + """ + default constructor + :param rpc_url: AUR RPC url + """ + Remote.__init__(self) + self.rpc_url = rpc_url or self.DEFAULT_RPC_URL + + @staticmethod + def parse_response(response: Dict[str, Any]) -> List[AURPackage]: + """ + parse RPC response to package list + :param response: RPC response json + :return: list of parsed packages + """ + if not response["valid"]: + raise InvalidPackageInfo("API validation error") + return [AURPackage.from_repo(package) for package in response["results"]] + + def make_request(self, *args: str, by: str) -> List[AURPackage]: + """ + perform request to official repositories RPC + :param args: list of arguments to be passed as args query parameter + :param by: search by the field + :return: response parsed to package list + """ + try: + response = requests.get(self.rpc_url, params={by: args}) + response.raise_for_status() + return self.parse_response(response.json()) + except requests.HTTPError as e: + self.logger.exception("could not perform request: %s", exception_response_text(e)) + raise + except Exception: + self.logger.exception("could not perform request") + raise + + def package_info(self, package_name: str) -> AURPackage: + """ + get package info by its name + :param package_name: package name to search + :return: package which match the package name + """ + packages = self.make_request(package_name, by="name") + return next(package for package in packages if package.name == package_name) + + def package_search(self, *keywords: str) -> List[AURPackage]: + """ + search package in AUR web + :param keywords: keywords to search + :return: list of packages which match the criteria + """ + return self.make_request(*keywords, by="q") diff --git a/src/ahriman/core/alpm/remote/remote.py b/src/ahriman/core/alpm/remote/remote.py new file mode 100644 index 00000000..7ac55488 --- /dev/null +++ b/src/ahriman/core/alpm/remote/remote.py @@ -0,0 +1,92 @@ +# +# Copyright (c) 2021-2022 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 __future__ import annotations + +import logging + +from typing import Dict, List, Type + +from ahriman.models.aur_package import AURPackage + + +class Remote: + """ + base class for remote package search + :ivar logger: class logger + """ + + def __init__(self) -> None: + """ + default constructor + """ + self.logger = logging.getLogger("build_details") + + @classmethod + def info(cls: Type[Remote], package_name: str) -> AURPackage: + """ + get package info by its name + :param package_name: package name to search + :return: package which match the package name + """ + return cls().package_info(package_name) + + @classmethod + def multisearch(cls: Type[Remote], *keywords: str) -> List[AURPackage]: + """ + search in remote repository by using API with multiple words. This method is required in order to handle + https://bugs.archlinux.org/task/49133. In addition, short words will be dropped + :param keywords: search terms, e.g. "ahriman", "is", "cool" + :return: list of packages each of them matches all search terms + """ + instance = cls() + packages: Dict[str, AURPackage] = {} + for term in filter(lambda word: len(word) > 3, keywords): + portion = instance.search(term) + packages = { + package.name: package # not mistake to group them by name + for package in portion + if package.name in packages or not packages + } + return list(packages.values()) + + @classmethod + def search(cls: Type[Remote], *keywords: str) -> List[AURPackage]: + """ + search package in AUR web + :param keywords: keywords to search + :return: list of packages which match the criteria + """ + return cls().package_search(*keywords) + + def package_info(self, package_name: str) -> AURPackage: + """ + get package info by its name + :param package_name: package name to search + :return: package which match the package name + """ + raise NotImplementedError + + def package_search(self, *keywords: str) -> List[AURPackage]: + """ + search package in AUR web + :param keywords: keywords to search + :return: list of packages which match the criteria + """ + raise NotImplementedError diff --git a/src/ahriman/core/util.py b/src/ahriman/core/util.py index 7021a251..70bde8f7 100644 --- a/src/ahriman/core/util.py +++ b/src/ahriman/core/util.py @@ -116,6 +116,18 @@ def filter_json(source: Dict[str, Any], known_fields: Iterable[str]) -> Dict[str return {key: value for key, value in source.items() if key in known_fields and value is not None} +def full_version(epoch: Union[str, int, None], pkgver: str, pkgrel: str) -> str: + """ + generate full version from components + :param epoch: package epoch if any + :param pkgver: package version + :param pkgrel: package release version (arch linux specific) + :return: generated version + """ + prefix = f"{epoch}:" if epoch else "" + return f"{prefix}{pkgver}-{pkgrel}" + + def package_like(filename: Path) -> bool: """ check if file looks like package diff --git a/src/ahriman/models/aur_package.py b/src/ahriman/models/aur_package.py index 487dce1e..de2514ff 100644 --- a/src/ahriman/models/aur_package.py +++ b/src/ahriman/models/aur_package.py @@ -25,7 +25,7 @@ import inflection from dataclasses import dataclass, field, fields from typing import Any, Callable, Dict, List, Optional, Type -from ahriman.core.util import filter_json +from ahriman.core.util import filter_json, full_version @dataclass @@ -59,12 +59,12 @@ class AURPackage: package_base_id: int package_base: str version: str - description: str num_votes: int popularity: float first_submitted: datetime.datetime last_modified: datetime.datetime url_path: str + description: str = "" # despite the fact that the field is required some packages don't have it url: Optional[str] = None out_of_date: Optional[datetime.datetime] = None maintainer: Optional[str] = None @@ -88,6 +88,39 @@ class AURPackage: properties = cls.convert(dump) return cls(**filter_json(properties, known_fields)) + @classmethod + def from_repo(cls: Type[AURPackage], dump: Dict[str, Any]) -> AURPackage: + """ + construct package descriptor from official repository RPC properties + :param dump: json dump body + :return: AUR package descriptor + """ + return cls( + id=0, + name=dump["pkgname"], + package_base_id=0, + package_base=dump["pkgbase"], + version=full_version(dump["epoch"], dump["pkgver"], dump["pkgrel"]), + description=dump["pkgdesc"], + num_votes=0, + popularity=0.0, + first_submitted=datetime.datetime.utcfromtimestamp(0), + last_modified=datetime.datetime.strptime(dump["last_update"], "%Y-%m-%dT%H:%M:%S.%fZ"), + url_path="", + url=dump["url"], + out_of_date=datetime.datetime.strptime( + dump["flag_date"], + "%Y-%m-%dT%H:%M:%S.%fZ") if dump["flag_date"] is not None else None, + maintainer=next(iter(dump["maintainers"]), None), + depends=dump["depends"], + make_depends=dump["makedepends"], + opt_depends=dump["optdepends"], + conflicts=dump["conflicts"], + provides=dump["provides"], + license=dump["licenses"], + keywords=[], + ) + @staticmethod def convert(descriptor: Dict[str, Any]) -> Dict[str, Any]: """ diff --git a/src/ahriman/models/package.py b/src/ahriman/models/package.py index 8c915f30..9c723bdb 100644 --- a/src/ahriman/models/package.py +++ b/src/ahriman/models/package.py @@ -26,12 +26,13 @@ from dataclasses import asdict, dataclass from pathlib import Path from pyalpm import vercmp # type: ignore from srcinfo.parse import parse_srcinfo # type: ignore -from typing import Any, Dict, Iterable, List, Optional, Set, Type +from typing import Any, Dict, Iterable, List, Set, Type -from ahriman.core.alpm.aur import AUR from ahriman.core.alpm.pacman import Pacman +from ahriman.core.alpm.remote.aur import AUR +from ahriman.core.alpm.remote.official import Official from ahriman.core.exceptions import InvalidPackageInfo -from ahriman.core.util import check_output +from ahriman.core.util import check_output, full_version from ahriman.models.package_description import PackageDescription from ahriman.models.package_source import PackageSource from ahriman.models.repository_paths import RepositoryPaths @@ -144,7 +145,7 @@ class Package: if errors: raise InvalidPackageInfo(errors) packages = {key: PackageDescription() for key in srcinfo["packages"]} - version = cls.full_version(srcinfo.get("epoch"), srcinfo["pkgver"], srcinfo["pkgrel"]) + version = full_version(srcinfo.get("epoch"), srcinfo["pkgver"], srcinfo["pkgrel"]) return cls(srcinfo["pkgbase"], version, aur_url, packages) @@ -165,6 +166,17 @@ class Package: aur_url=dump["aur_url"], packages=packages) + @classmethod + def from_official(cls: Type[Package], name: str, aur_url: str) -> Package: + """ + construct package properties from official repository page + :param name: package name (either base or normal name) + :param aur_url: AUR root url + :return: package properties + """ + package = Official.info(name) + return cls(package.package_base, package.version, aur_url, {package.name: PackageDescription()}) + @classmethod def load(cls: Type[Package], package: str, source: PackageSource, pacman: Pacman, aur_url: str) -> Package: """ @@ -183,6 +195,8 @@ class Package: return cls.from_aur(package, aur_url) if resolved_source == PackageSource.Local: return cls.from_build(Path(package), aur_url) + if resolved_source == PackageSource.Repository: + return cls.from_official(package, aur_url) raise InvalidPackageInfo(f"Unsupported local package source {resolved_source}") except InvalidPackageInfo: raise @@ -215,18 +229,6 @@ class Package: full_list = set(depends + makedepends) - packages return {trim_version(package_name) for package_name in full_list} - @staticmethod - def full_version(epoch: Optional[str], pkgver: str, pkgrel: str) -> str: - """ - generate full version from components - :param epoch: package epoch if any - :param pkgver: package version - :param pkgrel: package release version (arch linux specific) - :return: generated version - """ - prefix = f"{epoch}:" if epoch else "" - return f"{prefix}{pkgver}-{pkgrel}" - def actual_version(self, paths: RepositoryPaths) -> str: """ additional method to handle VCS package versions @@ -252,7 +254,7 @@ class Package: if errors: raise InvalidPackageInfo(errors) - return self.full_version(srcinfo.get("epoch"), srcinfo["pkgver"], srcinfo["pkgrel"]) + return full_version(srcinfo.get("epoch"), srcinfo["pkgver"], srcinfo["pkgrel"]) except Exception: logger.exception("cannot determine version of VCS package, make sure that you have VCS tools installed") diff --git a/src/ahriman/web/views/service/search.py b/src/ahriman/web/views/service/search.py index 0a50d3a6..3d222e67 100644 --- a/src/ahriman/web/views/service/search.py +++ b/src/ahriman/web/views/service/search.py @@ -20,7 +20,7 @@ from aiohttp.web import HTTPNotFound, Response, json_response from typing import Callable, List -from ahriman.core.alpm.aur import AUR +from ahriman.core.alpm.remote.aur import AUR from ahriman.models.aur_package import AURPackage from ahriman.models.user_access import UserAccess from ahriman.web.views.base import BaseView diff --git a/tests/ahriman/application/handlers/test_handler_search.py b/tests/ahriman/application/handlers/test_handler_search.py index 3ec8bbe8..d5996d60 100644 --- a/tests/ahriman/application/handlers/test_handler_search.py +++ b/tests/ahriman/application/handlers/test_handler_search.py @@ -3,6 +3,7 @@ import dataclasses import pytest from pytest_mock import MockerFixture +from unittest import mock from ahriman.application.handlers import Search from ahriman.core.configuration import Configuration @@ -29,14 +30,17 @@ def test_run(args: argparse.Namespace, configuration: Configuration, aur_package must run command """ args = _default_args(args) - search_mock = mocker.patch("ahriman.core.alpm.aur.AUR.multisearch", return_value=[aur_package_ahriman]) + aur_search_mock = mocker.patch("ahriman.core.alpm.remote.aur.AUR.multisearch", return_value=[aur_package_ahriman]) + official_search_mock = mocker.patch("ahriman.core.alpm.remote.official.Official.multisearch", + return_value=[aur_package_ahriman]) check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_if_empty") print_mock = mocker.patch("ahriman.core.formatters.printer.Printer.print") Search.run(args, "x86_64", configuration, True, False) - search_mock.assert_called_once_with("ahriman") + aur_search_mock.assert_called_once_with("ahriman") + official_search_mock.assert_called_once_with("ahriman") check_mock.assert_called_once_with(False, False) - print_mock.assert_called_once_with(False) + print_mock.assert_has_calls([mock.call(False), mock.call(False)]) def test_run_empty_exception(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: @@ -45,7 +49,8 @@ def test_run_empty_exception(args: argparse.Namespace, configuration: Configurat """ args = _default_args(args) args.exit_code = True - mocker.patch("ahriman.core.alpm.aur.AUR.multisearch", return_value=[]) + mocker.patch("ahriman.core.alpm.remote.aur.AUR.multisearch", return_value=[]) + mocker.patch("ahriman.core.alpm.remote.official.Official.multisearch", return_value=[]) mocker.patch("ahriman.core.formatters.printer.Printer.print") check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_if_empty") @@ -59,11 +64,15 @@ def test_run_sort(args: argparse.Namespace, configuration: Configuration, aur_pa must run command with sorting """ args = _default_args(args) - mocker.patch("ahriman.core.alpm.aur.AUR.multisearch", return_value=[aur_package_ahriman]) + mocker.patch("ahriman.core.alpm.remote.aur.AUR.multisearch", return_value=[aur_package_ahriman]) + mocker.patch("ahriman.core.alpm.remote.official.Official.multisearch", return_value=[]) sort_mock = mocker.patch("ahriman.application.handlers.search.Search.sort") Search.run(args, "x86_64", configuration, True, False) - sort_mock.assert_called_once_with([aur_package_ahriman], "name") + sort_mock.assert_has_calls([ + mock.call([], "name"), mock.call().__iter__(), + mock.call([aur_package_ahriman], "name"), mock.call().__iter__() + ]) def test_run_sort_by(args: argparse.Namespace, configuration: Configuration, aur_package_ahriman: AURPackage, @@ -73,11 +82,15 @@ def test_run_sort_by(args: argparse.Namespace, configuration: Configuration, aur """ args = _default_args(args) args.sort_by = "field" - mocker.patch("ahriman.core.alpm.aur.AUR.multisearch", return_value=[aur_package_ahriman]) + mocker.patch("ahriman.core.alpm.remote.aur.AUR.multisearch", return_value=[aur_package_ahriman]) + mocker.patch("ahriman.core.alpm.remote.official.Official.multisearch", return_value=[]) sort_mock = mocker.patch("ahriman.application.handlers.search.Search.sort") Search.run(args, "x86_64", configuration, True, False) - sort_mock.assert_called_once_with([aur_package_ahriman], "field") + sort_mock.assert_has_calls([ + mock.call([], "field"), mock.call().__iter__(), + mock.call([aur_package_ahriman], "field"), mock.call().__iter__() + ]) def test_sort(aur_package_ahriman: AURPackage) -> None: diff --git a/tests/ahriman/conftest.py b/tests/ahriman/conftest.py index 301edbda..a02c633a 100644 --- a/tests/ahriman/conftest.py +++ b/tests/ahriman/conftest.py @@ -124,6 +124,50 @@ def aur_package_ahriman() -> AURPackage: ) +@pytest.fixture +def aur_package_akonadi() -> AURPackage: + """ + fixture for AUR package + :return: AUR package test instance + """ + return AURPackage( + id=0, + name="akonadi", + package_base_id=0, + package_base="akonadi", + version="21.12.3-2", + description="PIM layer, which provides an asynchronous API to access all kind of PIM data", + num_votes=0, + popularity=0, + first_submitted=datetime.datetime(1970, 1, 1, 0, 0, 0), + last_modified=datetime.datetime(2022, 3, 6, 8, 39, 50, 610000), + url_path="", + url="https://kontact.kde.org", + out_of_date=None, + maintainer="felixonmars", + depends=[ + "libakonadi", + "mariadb", + ], + make_depends=[ + "boost", + "doxygen", + "extra-cmake-modules", + "kaccounts-integration", + "kitemmodels", + "postgresql", + "qt5-tools", + ], + opt_depends=[ + "postgresql: PostgreSQL backend", + ], + conflicts=[], + provides=[], + license=["LGPL"], + keywords=[], + ) + + @pytest.fixture def auth(configuration: Configuration) -> Auth: """ diff --git a/tests/ahriman/core/alpm/conftest.py b/tests/ahriman/core/alpm/conftest.py deleted file mode 100644 index 6f0a021a..00000000 --- a/tests/ahriman/core/alpm/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -import pytest - -from ahriman.core.alpm.aur import AUR - - -@pytest.fixture -def aur() -> AUR: - """ - aur helper fixture - :return: aur helper instance - """ - return AUR() diff --git a/tests/ahriman/core/alpm/remote/conftest.py b/tests/ahriman/core/alpm/remote/conftest.py new file mode 100644 index 00000000..d94a04ef --- /dev/null +++ b/tests/ahriman/core/alpm/remote/conftest.py @@ -0,0 +1,32 @@ +import pytest + +from ahriman.core.alpm.remote.aur import AUR +from ahriman.core.alpm.remote.official import Official +from ahriman.core.alpm.remote.remote import Remote + + +@pytest.fixture +def aur() -> AUR: + """ + aur helper fixture + :return: aur helper instance + """ + return AUR() + + +@pytest.fixture +def official() -> Official: + """ + official repository fixture + :return: official repository helper instance + """ + return Official() + + +@pytest.fixture +def remote() -> Remote: + """ + official repository fixture + :return: official repository helper instance + """ + return Remote() diff --git a/tests/ahriman/core/alpm/test_aur.py b/tests/ahriman/core/alpm/remote/test_aur.py similarity index 68% rename from tests/ahriman/core/alpm/test_aur.py rename to tests/ahriman/core/alpm/remote/test_aur.py index bf9f9541..2d5e14c9 100644 --- a/tests/ahriman/core/alpm/test_aur.py +++ b/tests/ahriman/core/alpm/remote/test_aur.py @@ -4,10 +4,9 @@ import requests from pathlib import Path from pytest_mock import MockerFixture -from unittest import mock from unittest.mock import MagicMock -from ahriman.core.alpm.aur import AUR +from ahriman.core.alpm.remote.aur import AUR from ahriman.core.exceptions import InvalidPackageInfo from ahriman.models.aur_package import AURPackage @@ -21,55 +20,6 @@ def _get_response(resource_path_root: Path) -> str: return (resource_path_root / "models" / "package_ahriman_aur").read_text() -def test_info(mocker: MockerFixture) -> None: - """ - must call info method - """ - info_mock = mocker.patch("ahriman.core.alpm.aur.AUR.package_info") - AUR.info("ahriman") - info_mock.assert_called_once_with("ahriman") - - -def test_multisearch(aur_package_ahriman: AURPackage, mocker: MockerFixture) -> None: - """ - must search in AUR with multiple words - """ - terms = ["ahriman", "is", "cool"] - search_mock = mocker.patch("ahriman.core.alpm.aur.AUR.search", return_value=[aur_package_ahriman]) - - assert AUR.multisearch(*terms) == [aur_package_ahriman] - search_mock.assert_has_calls([mock.call("ahriman"), mock.call("cool")]) - - -def test_multisearch_empty(mocker: MockerFixture) -> None: - """ - must return empty list if no long terms supplied - """ - terms = ["it", "is"] - search_mock = mocker.patch("ahriman.core.alpm.aur.AUR.search") - - assert AUR.multisearch(*terms) == [] - search_mock.assert_not_called() - - -def test_multisearch_single(aur_package_ahriman: AURPackage, mocker: MockerFixture) -> None: - """ - must search in AUR with one word - """ - search_mock = mocker.patch("ahriman.core.alpm.aur.AUR.search", return_value=[aur_package_ahriman]) - assert AUR.multisearch("ahriman") == [aur_package_ahriman] - search_mock.assert_called_once_with("ahriman") - - -def test_search(mocker: MockerFixture) -> None: - """ - must call search method - """ - search_mock = mocker.patch("ahriman.core.alpm.aur.AUR.package_search") - AUR.search("ahriman") - search_mock.assert_called_once_with("ahriman") - - def test_parse_response(aur_package_ahriman: AURPackage, resource_path_root: Path) -> None: """ must parse success response @@ -87,7 +37,7 @@ def test_parse_response_error(resource_path_root: Path) -> None: AUR.parse_response(json.loads(response)) -def test_parse_response_unknown_error(resource_path_root: Path) -> None: +def test_parse_response_unknown_error() -> None: """ must raise exception on invalid response with empty error message """ @@ -159,7 +109,7 @@ def test_package_info(aur: AUR, aur_package_ahriman: AURPackage, mocker: MockerF """ must make request for info """ - request_mock = mocker.patch("ahriman.core.alpm.aur.AUR.make_request", return_value=[aur_package_ahriman]) + request_mock = mocker.patch("ahriman.core.alpm.remote.aur.AUR.make_request", return_value=[aur_package_ahriman]) assert aur.package_info(aur_package_ahriman.name) == aur_package_ahriman request_mock.assert_called_once_with("info", aur_package_ahriman.name) @@ -168,6 +118,6 @@ def test_package_search(aur: AUR, aur_package_ahriman: AURPackage, mocker: Mocke """ must make request for search """ - request_mock = mocker.patch("ahriman.core.alpm.aur.AUR.make_request", return_value=[aur_package_ahriman]) - assert aur.package_search(aur_package_ahriman.name, by="name") == [aur_package_ahriman] - request_mock.assert_called_once_with("search", aur_package_ahriman.name, by="name") + request_mock = mocker.patch("ahriman.core.alpm.remote.aur.AUR.make_request", return_value=[aur_package_ahriman]) + assert aur.package_search(aur_package_ahriman.name) == [aur_package_ahriman] + request_mock.assert_called_once_with("search", aur_package_ahriman.name, by="name-desc") diff --git a/tests/ahriman/core/alpm/remote/test_official.py b/tests/ahriman/core/alpm/remote/test_official.py new file mode 100644 index 00000000..e34376b7 --- /dev/null +++ b/tests/ahriman/core/alpm/remote/test_official.py @@ -0,0 +1,88 @@ +import json +import pytest +import requests + +from pathlib import Path +from pytest_mock import MockerFixture +from unittest.mock import MagicMock + +from ahriman.core.alpm.remote.official import Official +from ahriman.core.exceptions import InvalidPackageInfo +from ahriman.models.aur_package import AURPackage + + +def _get_response(resource_path_root: Path) -> str: + """ + load response from resource file + :param resource_path_root: path to resource root + :return: response text + """ + return (resource_path_root / "models" / "package_akonadi_aur").read_text() + + +def test_parse_response(aur_package_akonadi: AURPackage, resource_path_root: Path) -> None: + """ + must parse success response + """ + response = _get_response(resource_path_root) + assert Official.parse_response(json.loads(response)) == [aur_package_akonadi] + + +def test_parse_response_unknown_error(resource_path_root: Path) -> None: + """ + must raise exception on invalid response with empty error message + """ + response = (resource_path_root / "models" / "official_error").read_text() + with pytest.raises(InvalidPackageInfo, match="API validation error"): + Official.parse_response(json.loads(response)) + + +def test_make_request(official: Official, aur_package_akonadi: AURPackage, + mocker: MockerFixture, resource_path_root: Path) -> None: + """ + must perform request to official repositories + """ + response_mock = MagicMock() + response_mock.json.return_value = json.loads(_get_response(resource_path_root)) + request_mock = mocker.patch("requests.get", return_value=response_mock) + + assert official.make_request("akonadi", by="q") == [aur_package_akonadi] + request_mock.assert_called_once_with("https://archlinux.org/packages/search/json", params={"q": ("akonadi",)}) + + +def test_make_request_failed(official: Official, mocker: MockerFixture) -> None: + """ + must reraise generic exception + """ + mocker.patch("requests.get", side_effect=Exception()) + with pytest.raises(Exception): + official.make_request("akonadi", by="q") + + +def test_make_request_failed_http_error(official: Official, mocker: MockerFixture) -> None: + """ + must reraise http exception + """ + mocker.patch("requests.get", side_effect=requests.exceptions.HTTPError()) + with pytest.raises(requests.exceptions.HTTPError): + official.make_request("akonadi", by="q") + + +def test_package_info(official: Official, aur_package_akonadi: AURPackage, mocker: MockerFixture) -> None: + """ + must make request for info + """ + request_mock = mocker.patch("ahriman.core.alpm.remote.official.Official.make_request", + return_value=[aur_package_akonadi]) + assert official.package_info(aur_package_akonadi.name) == aur_package_akonadi + request_mock.assert_called_once_with(aur_package_akonadi.name, by="name") + + +def test_package_search(official: Official, aur_package_akonadi: AURPackage, mocker: MockerFixture) -> None: + """ + must make request for search + """ + request_mock = mocker.patch("ahriman.core.alpm.remote.official.Official.make_request", + return_value=[aur_package_akonadi]) + assert official.package_search(aur_package_akonadi.name) == [aur_package_akonadi] + request_mock.assert_called_once_with(aur_package_akonadi.name, by="q") diff --git a/tests/ahriman/core/alpm/remote/test_remote.py b/tests/ahriman/core/alpm/remote/test_remote.py new file mode 100644 index 00000000..a23e84d7 --- /dev/null +++ b/tests/ahriman/core/alpm/remote/test_remote.py @@ -0,0 +1,72 @@ +import pytest + +from pytest_mock import MockerFixture +from unittest import mock + +from ahriman.core.alpm.remote.remote import Remote +from ahriman.models.aur_package import AURPackage + + +def test_info(mocker: MockerFixture) -> None: + """ + must call info method + """ + info_mock = mocker.patch("ahriman.core.alpm.remote.remote.Remote.package_info") + Remote.info("ahriman") + info_mock.assert_called_once_with("ahriman") + + +def test_multisearch(aur_package_ahriman: AURPackage, mocker: MockerFixture) -> None: + """ + must search in AUR with multiple words + """ + terms = ["ahriman", "is", "cool"] + search_mock = mocker.patch("ahriman.core.alpm.remote.remote.Remote.search", return_value=[aur_package_ahriman]) + + assert Remote.multisearch(*terms) == [aur_package_ahriman] + search_mock.assert_has_calls([mock.call("ahriman"), mock.call("cool")]) + + +def test_multisearch_empty(mocker: MockerFixture) -> None: + """ + must return empty list if no long terms supplied + """ + terms = ["it", "is"] + search_mock = mocker.patch("ahriman.core.alpm.remote.remote.Remote.search") + + assert Remote.multisearch(*terms) == [] + search_mock.assert_not_called() + + +def test_multisearch_single(aur_package_ahriman: AURPackage, mocker: MockerFixture) -> None: + """ + must search in AUR with one word + """ + search_mock = mocker.patch("ahriman.core.alpm.remote.remote.Remote.search", return_value=[aur_package_ahriman]) + assert Remote.multisearch("ahriman") == [aur_package_ahriman] + search_mock.assert_called_once_with("ahriman") + + +def test_search(mocker: MockerFixture) -> None: + """ + must call search method + """ + search_mock = mocker.patch("ahriman.core.alpm.remote.remote.Remote.package_search") + Remote.search("ahriman") + search_mock.assert_called_once_with("ahriman") + + +def test_package_info(remote: Remote) -> None: + """ + must raise NotImplemented for missing package info method + """ + with pytest.raises(NotImplementedError): + remote.package_info("package") + + +def test_package_search(remote: Remote) -> None: + """ + must raise NotImplemented for missing package search method + """ + with pytest.raises(NotImplementedError): + remote.package_search("package") diff --git a/tests/ahriman/core/test_util.py b/tests/ahriman/core/test_util.py index a748b4c9..4248fb62 100644 --- a/tests/ahriman/core/test_util.py +++ b/tests/ahriman/core/test_util.py @@ -9,8 +9,8 @@ from pytest_mock import MockerFixture from unittest.mock import MagicMock from ahriman.core.exceptions import BuildFailed, InvalidOption, UnsafeRun -from ahriman.core.util import check_output, check_user, exception_response_text, filter_json, package_like, \ - pretty_datetime, pretty_size, tmpdir, walk +from ahriman.core.util import check_output, check_user, exception_response_text, filter_json, full_version, \ + package_like, pretty_datetime, pretty_size, tmpdir, walk from ahriman.models.package import Package from ahriman.models.repository_paths import RepositoryPaths @@ -177,6 +177,16 @@ def test_filter_json_empty_value(package_ahriman: Package) -> None: assert "base" not in filter_json(probe, probe.keys()) +def test_full_version() -> None: + """ + must construct full version + """ + assert full_version("1", "r2388.d30e3201", "1") == "1:r2388.d30e3201-1" + assert full_version(None, "0.12.1", "1") == "0.12.1-1" + assert full_version(0, "0.12.1", "1") == "0.12.1-1" + assert full_version(1, "0.12.1", "1") == "1:0.12.1-1" + + def test_package_like(package_ahriman: Package) -> None: """ package_like must return true for archives @@ -298,24 +308,26 @@ def test_walk(resource_path_root: Path) -> None: must traverse directory recursively """ expected = sorted([ - resource_path_root / "core/ahriman.ini", - resource_path_root / "core/logging.ini", - resource_path_root / "models/aur_error", - resource_path_root / "models/big_file_checksum", - resource_path_root / "models/empty_file_checksum", - resource_path_root / "models/package_ahriman_aur", - resource_path_root / "models/package_ahriman_srcinfo", - resource_path_root / "models/package_tpacpi-bat-git_srcinfo", - resource_path_root / "models/package_yay_srcinfo", - resource_path_root / "web/templates/build-status/login-modal.jinja2", - resource_path_root / "web/templates/build-status/package-actions-modals.jinja2", - resource_path_root / "web/templates/build-status/package-actions-script.jinja2", - resource_path_root / "web/templates/static/favicon.ico", - resource_path_root / "web/templates/utils/bootstrap-scripts.jinja2", - resource_path_root / "web/templates/utils/style.jinja2", - resource_path_root / "web/templates/build-status.jinja2", - resource_path_root / "web/templates/email-index.jinja2", - resource_path_root / "web/templates/repo-index.jinja2", + resource_path_root / "core" / "ahriman.ini", + resource_path_root / "core" / "logging.ini", + resource_path_root / "models" / "aur_error", + resource_path_root / "models" / "big_file_checksum", + resource_path_root / "models" / "empty_file_checksum", + resource_path_root / "models" / "official_error", + resource_path_root / "models" / "package_ahriman_aur", + resource_path_root / "models" / "package_akonadi_aur", + resource_path_root / "models" / "package_ahriman_srcinfo", + resource_path_root / "models" / "package_tpacpi-bat-git_srcinfo", + resource_path_root / "models" / "package_yay_srcinfo", + resource_path_root / "web" / "templates" / "build-status" / "login-modal.jinja2", + resource_path_root / "web" / "templates" / "build-status" / "package-actions-modals.jinja2", + resource_path_root / "web" / "templates" / "build-status" / "package-actions-script.jinja2", + resource_path_root / "web" / "templates" / "static" / "favicon.ico", + resource_path_root / "web" / "templates" / "utils" / "bootstrap-scripts.jinja2", + resource_path_root / "web" / "templates" / "utils" / "style.jinja2", + resource_path_root / "web" / "templates" / "build-status.jinja2", + resource_path_root / "web" / "templates" / "email-index.jinja2", + resource_path_root / "web" / "templates" / "repo-index.jinja2", ]) local_files = list(sorted(walk(resource_path_root))) assert local_files == expected diff --git a/tests/ahriman/models/test_aur_package.py b/tests/ahriman/models/test_aur_package.py index 96029b42..c50dbee0 100644 --- a/tests/ahriman/models/test_aur_package.py +++ b/tests/ahriman/models/test_aur_package.py @@ -9,7 +9,7 @@ from typing import Any, Dict from ahriman.models.aur_package import AURPackage -def _get_data(resource_path_root: Path) -> Dict[str, Any]: +def _get_aur_data(resource_path_root: Path) -> Dict[str, Any]: """ load package description from resource file :param resource_path_root: path to resource root @@ -19,11 +19,21 @@ def _get_data(resource_path_root: Path) -> Dict[str, Any]: return json.loads(response)["results"][0] +def _get_official_data(resource_path_root: Path) -> Dict[str, Any]: + """ + load package description from resource file + :param resource_path_root: path to resource root + :return: json descriptor + """ + response = (resource_path_root / "models" / "package_akonadi_aur").read_text() + return json.loads(response)["results"][0] + + def test_from_json(aur_package_ahriman: AURPackage, resource_path_root: Path) -> None: """ must load package from json """ - model = _get_data(resource_path_root) + model = _get_aur_data(resource_path_root) assert AURPackage.from_json(model) == aur_package_ahriman @@ -35,11 +45,19 @@ def test_from_json_2(aur_package_ahriman: AURPackage, mocker: MockerFixture) -> assert AURPackage.from_json(asdict(aur_package_ahriman)) == aur_package_ahriman +def test_from_repo(aur_package_akonadi: AURPackage, resource_path_root: Path) -> None: + """ + must load package from repository api json + """ + model = _get_official_data(resource_path_root) + assert AURPackage.from_repo(model) == aur_package_akonadi + + def test_convert(aur_package_ahriman: AURPackage, resource_path_root: Path) -> None: """ must convert fields to snakecase and also apply converters """ - model = _get_data(resource_path_root) + model = _get_aur_data(resource_path_root) converted = AURPackage.convert(model) known_fields = [pair.name for pair in fields(AURPackage)] assert all(field in known_fields for field in converted) diff --git a/tests/ahriman/models/test_package.py b/tests/ahriman/models/test_package.py index 61454993..2af0b70e 100644 --- a/tests/ahriman/models/test_package.py +++ b/tests/ahriman/models/test_package.py @@ -101,7 +101,7 @@ def test_from_aur(package_ahriman: Package, aur_package_ahriman: AURPackage, moc """ must construct package from aur """ - mocker.patch("ahriman.core.alpm.aur.AUR.info", return_value=aur_package_ahriman) + mocker.patch("ahriman.core.alpm.remote.aur.AUR.info", return_value=aur_package_ahriman) package = Package.from_aur(package_ahriman.base, package_ahriman.aur_url) assert package_ahriman.base == package.base @@ -154,6 +154,18 @@ def test_from_json_view_3(package_tpacpi_bat_git: Package) -> None: assert Package.from_json(package_tpacpi_bat_git.view()) == package_tpacpi_bat_git +def test_from_official(package_ahriman: Package, aur_package_ahriman: AURPackage, mocker: MockerFixture) -> None: + """ + must construct package from official repository + """ + mocker.patch("ahriman.core.alpm.remote.official.Official.info", return_value=aur_package_ahriman) + + package = Package.from_official(package_ahriman.base, package_ahriman.aur_url) + assert package_ahriman.base == package.base + assert package_ahriman.version == package.version + assert package_ahriman.packages.keys() == package.packages.keys() + + def test_load_resolve(package_ahriman: Package, pyalpm_handle: MagicMock, mocker: MockerFixture) -> None: """ must resolve source before package loading @@ -193,6 +205,15 @@ def test_load_from_build(package_ahriman: Package, pyalpm_handle: MagicMock, moc load_mock.assert_called_once_with(Path("path"), package_ahriman.aur_url) +def test_load_from_official(package_ahriman: Package, pyalpm_handle: MagicMock, mocker: MockerFixture) -> None: + """ + must load package from AUR + """ + load_mock = mocker.patch("ahriman.models.package.Package.from_official") + Package.load("path", PackageSource.Repository, pyalpm_handle, package_ahriman.aur_url) + load_mock.assert_called_once_with("path", package_ahriman.aur_url) + + def test_load_failure(package_ahriman: Package, pyalpm_handle: MagicMock, mocker: MockerFixture) -> None: """ must raise InvalidPackageInfo on exception @@ -240,14 +261,6 @@ def test_dependencies_with_version(mocker: MockerFixture, resource_path_root: Pa assert Package.dependencies(Path("path")) == {"git", "go", "pacman"} -def test_full_version() -> None: - """ - must construct full version - """ - assert Package.full_version("1", "r2388.d30e3201", "1") == "1:r2388.d30e3201-1" - assert Package.full_version(None, "0.12.1", "1") == "0.12.1-1" - - def test_actual_version(package_ahriman: Package, repository_paths: RepositoryPaths) -> None: """ must return same actual_version as version is diff --git a/tests/ahriman/web/views/service/test_views_service_search.py b/tests/ahriman/web/views/service/test_views_service_search.py index 7ba037e6..13eefa50 100644 --- a/tests/ahriman/web/views/service/test_views_service_search.py +++ b/tests/ahriman/web/views/service/test_views_service_search.py @@ -21,7 +21,7 @@ async def test_get(client: TestClient, aur_package_ahriman: AURPackage, mocker: """ must call get request correctly """ - mocker.patch("ahriman.core.alpm.aur.AUR.multisearch", return_value=[aur_package_ahriman]) + mocker.patch("ahriman.core.alpm.remote.aur.AUR.multisearch", return_value=[aur_package_ahriman]) response = await client.get("/service-api/v1/search", params={"for": "ahriman"}) assert response.ok @@ -33,7 +33,7 @@ async def test_get_exception(client: TestClient, mocker: MockerFixture) -> None: """ must raise 400 on empty search string """ - search_mock = mocker.patch("ahriman.core.alpm.aur.AUR.multisearch", return_value=[]) + search_mock = mocker.patch("ahriman.core.alpm.remote.aur.AUR.multisearch", return_value=[]) response = await client.get("/service-api/v1/search") assert response.status == 404 @@ -44,7 +44,7 @@ async def test_get_join(client: TestClient, mocker: MockerFixture) -> None: """ must join search args with space """ - search_mock = mocker.patch("ahriman.core.alpm.aur.AUR.multisearch") + search_mock = mocker.patch("ahriman.core.alpm.remote.aur.AUR.multisearch") response = await client.get("/service-api/v1/search", params=[("for", "ahriman"), ("for", "maybe")]) assert response.ok diff --git a/tests/testresources/models/official_error b/tests/testresources/models/official_error new file mode 100644 index 00000000..0f84c09b --- /dev/null +++ b/tests/testresources/models/official_error @@ -0,0 +1,8 @@ +{ + "version": 2, + "limit": 250, + "valid": false, + "results": [], + "num_pages": 1, + "page": 1 +} diff --git a/tests/testresources/models/package_akonadi_aur b/tests/testresources/models/package_akonadi_aur new file mode 100644 index 00000000..b58190fb --- /dev/null +++ b/tests/testresources/models/package_akonadi_aur @@ -0,0 +1,55 @@ +{ + "version": 2, + "limit": 250, + "valid": true, + "results": [ + { + "pkgname": "akonadi", + "pkgbase": "akonadi", + "repo": "extra", + "arch": "x86_64", + "pkgver": "21.12.3", + "pkgrel": "2", + "epoch": 0, + "pkgdesc": "PIM layer, which provides an asynchronous API to access all kind of PIM data", + "url": "https://kontact.kde.org", + "filename": "akonadi-21.12.3-2-x86_64.pkg.tar.zst", + "compressed_size": 789510, + "installed_size": 2592656, + "build_date": "2022-03-04T11:50:03Z", + "last_update": "2022-03-06T08:39:50.610Z", + "flag_date": null, + "maintainers": [ + "felixonmars", + "arojas" + ], + "packager": "arojas", + "groups": [], + "licenses": [ + "LGPL" + ], + "conflicts": [], + "provides": [], + "replaces": [], + "depends": [ + "libakonadi", + "mariadb" + ], + "optdepends": [ + "postgresql: PostgreSQL backend" + ], + "makedepends": [ + "boost", + "doxygen", + "extra-cmake-modules", + "kaccounts-integration", + "kitemmodels", + "postgresql", + "qt5-tools" + ], + "checkdepends": [] + } + ], + "num_pages": 1, + "page": 1 +}