add support of officiall repositories api

This commit is contained in:
Evgenii Alekseev 2022-04-07 04:19:37 +03:00
parent 6946745153
commit e200ac9776
22 changed files with 689 additions and 183 deletions

View File

@ -23,7 +23,8 @@ from dataclasses import fields
from typing import Callable, Iterable, List, Tuple, Type from typing import Callable, Iterable, List, Tuple, Type
from ahriman.application.handlers.handler import Handler 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.configuration import Configuration
from ahriman.core.exceptions import InvalidOption from ahriman.core.exceptions import InvalidOption
from ahriman.core.formatters.aur_printer import AurPrinter from ahriman.core.formatters.aur_printer import AurPrinter
@ -50,10 +51,14 @@ class Search(Handler):
:param no_report: force disable reporting :param no_report: force disable reporting
:param unsafe: if set no user check will be performed before path creation :param unsafe: if set no user check will be performed before path creation
""" """
packages_list = AUR.multisearch(*args.search) official_packages_list = Official.multisearch(*args.search)
Search.check_if_empty(args.exit_code, not packages_list) aur_packages_list = AUR.multisearch(*args.search)
for package in Search.sort(packages_list, args.sort_by): Search.check_if_empty(args.exit_code, not official_packages_list and not aur_packages_list)
AurPrinter(package).print(args.info)
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 @staticmethod
def sort(packages: Iterable[AURPackage], sort_by: str) -> List[AURPackage]: def sort(packages: Iterable[AURPackage], sort_by: str) -> List[AURPackage]:

View File

@ -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 <http://www.gnu.org/licenses/>.
#

View File

@ -17,24 +17,21 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from __future__ import annotations
import logging
import requests 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.exceptions import InvalidPackageInfo
from ahriman.core.util import exception_response_text from ahriman.core.util import exception_response_text
from ahriman.models.aur_package import AURPackage from ahriman.models.aur_package import AURPackage
class AUR: class AUR(Remote):
""" """
AUR RPC wrapper AUR RPC wrapper
:cvar DEFAULT_RPC_URL: default AUR RPC url :cvar DEFAULT_RPC_URL: default AUR RPC url
:cvar DEFAULT_RPC_VERSION: default AUR RPC version :cvar DEFAULT_RPC_VERSION: default AUR RPC version
:ivar logger: class logger
:ivar rpc_url: AUR RPC url :ivar rpc_url: AUR RPC url
:ivar rpc_version: AUR RPC version :ivar rpc_version: AUR RPC version
""" """
@ -48,46 +45,9 @@ class AUR:
:param rpc_url: AUR RPC url :param rpc_url: AUR RPC url
:param rpc_version: AUR RPC version :param rpc_version: AUR RPC version
""" """
Remote.__init__(self)
self.rpc_url = rpc_url or self.DEFAULT_RPC_URL self.rpc_url = rpc_url or self.DEFAULT_RPC_URL
self.rpc_version = rpc_version or self.DEFAULT_RPC_VERSION 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 @staticmethod
def parse_response(response: Dict[str, Any]) -> List[AURPackage]: def parse_response(response: Dict[str, Any]) -> List[AURPackage]:
@ -144,11 +104,10 @@ class AUR:
packages = self.make_request("info", package_name) packages = self.make_request("info", package_name)
return next(package for package in packages if package.name == 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 search package in AUR web
:param keywords: keywords to search :param keywords: keywords to search
:param by: search by the field
:return: list of packages which match the criteria :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")

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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")

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -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} 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: def package_like(filename: Path) -> bool:
""" """
check if file looks like package check if file looks like package

View File

@ -25,7 +25,7 @@ import inflection
from dataclasses import dataclass, field, fields from dataclasses import dataclass, field, fields
from typing import Any, Callable, Dict, List, Optional, Type 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 @dataclass
@ -59,12 +59,12 @@ class AURPackage:
package_base_id: int package_base_id: int
package_base: str package_base: str
version: str version: str
description: str
num_votes: int num_votes: int
popularity: float popularity: float
first_submitted: datetime.datetime first_submitted: datetime.datetime
last_modified: datetime.datetime last_modified: datetime.datetime
url_path: str url_path: str
description: str = "" # despite the fact that the field is required some packages don't have it
url: Optional[str] = None url: Optional[str] = None
out_of_date: Optional[datetime.datetime] = None out_of_date: Optional[datetime.datetime] = None
maintainer: Optional[str] = None maintainer: Optional[str] = None
@ -88,6 +88,39 @@ class AURPackage:
properties = cls.convert(dump) properties = cls.convert(dump)
return cls(**filter_json(properties, known_fields)) 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 @staticmethod
def convert(descriptor: Dict[str, Any]) -> Dict[str, Any]: def convert(descriptor: Dict[str, Any]) -> Dict[str, Any]:
""" """

View File

@ -26,12 +26,13 @@ from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from pyalpm import vercmp # type: ignore from pyalpm import vercmp # type: ignore
from srcinfo.parse import parse_srcinfo # 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.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.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_description import PackageDescription
from ahriman.models.package_source import PackageSource from ahriman.models.package_source import PackageSource
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
@ -144,7 +145,7 @@ class Package:
if errors: if errors:
raise InvalidPackageInfo(errors) raise InvalidPackageInfo(errors)
packages = {key: PackageDescription() for key in srcinfo["packages"]} 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) return cls(srcinfo["pkgbase"], version, aur_url, packages)
@ -165,6 +166,17 @@ class Package:
aur_url=dump["aur_url"], aur_url=dump["aur_url"],
packages=packages) 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 @classmethod
def load(cls: Type[Package], package: str, source: PackageSource, pacman: Pacman, aur_url: str) -> Package: 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) return cls.from_aur(package, aur_url)
if resolved_source == PackageSource.Local: if resolved_source == PackageSource.Local:
return cls.from_build(Path(package), aur_url) 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}") raise InvalidPackageInfo(f"Unsupported local package source {resolved_source}")
except InvalidPackageInfo: except InvalidPackageInfo:
raise raise
@ -215,18 +229,6 @@ class Package:
full_list = set(depends + makedepends) - packages full_list = set(depends + makedepends) - packages
return {trim_version(package_name) for package_name in full_list} 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: def actual_version(self, paths: RepositoryPaths) -> str:
""" """
additional method to handle VCS package versions additional method to handle VCS package versions
@ -252,7 +254,7 @@ class Package:
if errors: if errors:
raise InvalidPackageInfo(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: except Exception:
logger.exception("cannot determine version of VCS package, make sure that you have VCS tools installed") logger.exception("cannot determine version of VCS package, make sure that you have VCS tools installed")

View File

@ -20,7 +20,7 @@
from aiohttp.web import HTTPNotFound, Response, json_response from aiohttp.web import HTTPNotFound, Response, json_response
from typing import Callable, List 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.aur_package import AURPackage
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView

View File

@ -3,6 +3,7 @@ import dataclasses
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest import mock
from ahriman.application.handlers import Search from ahriman.application.handlers import Search
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -29,14 +30,17 @@ def test_run(args: argparse.Namespace, configuration: Configuration, aur_package
must run command must run command
""" """
args = _default_args(args) 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") check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_if_empty")
print_mock = mocker.patch("ahriman.core.formatters.printer.Printer.print") print_mock = mocker.patch("ahriman.core.formatters.printer.Printer.print")
Search.run(args, "x86_64", configuration, True, False) 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) 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: 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 = _default_args(args)
args.exit_code = True 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") mocker.patch("ahriman.core.formatters.printer.Printer.print")
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_if_empty") 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 must run command with sorting
""" """
args = _default_args(args) 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") sort_mock = mocker.patch("ahriman.application.handlers.search.Search.sort")
Search.run(args, "x86_64", configuration, True, False) 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, 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 = _default_args(args)
args.sort_by = "field" 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") sort_mock = mocker.patch("ahriman.application.handlers.search.Search.sort")
Search.run(args, "x86_64", configuration, True, False) 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: def test_sort(aur_package_ahriman: AURPackage) -> None:

View File

@ -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 @pytest.fixture
def auth(configuration: Configuration) -> Auth: def auth(configuration: Configuration) -> Auth:
""" """

View File

@ -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()

View File

@ -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()

View File

@ -4,10 +4,9 @@ import requests
from pathlib import Path from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest import mock
from unittest.mock import MagicMock 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.core.exceptions import InvalidPackageInfo
from ahriman.models.aur_package import AURPackage 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() 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: def test_parse_response(aur_package_ahriman: AURPackage, resource_path_root: Path) -> None:
""" """
must parse success response must parse success response
@ -87,7 +37,7 @@ def test_parse_response_error(resource_path_root: Path) -> None:
AUR.parse_response(json.loads(response)) 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 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 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 assert aur.package_info(aur_package_ahriman.name) == aur_package_ahriman
request_mock.assert_called_once_with("info", aur_package_ahriman.name) 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 must make request for search
""" """
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_search(aur_package_ahriman.name, by="name") == [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") request_mock.assert_called_once_with("search", aur_package_ahriman.name, by="name-desc")

View File

@ -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")

View File

@ -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")

View File

@ -9,8 +9,8 @@ from pytest_mock import MockerFixture
from unittest.mock import MagicMock from unittest.mock import MagicMock
from ahriman.core.exceptions import BuildFailed, InvalidOption, UnsafeRun from ahriman.core.exceptions import BuildFailed, InvalidOption, UnsafeRun
from ahriman.core.util import check_output, check_user, exception_response_text, filter_json, package_like, \ from ahriman.core.util import check_output, check_user, exception_response_text, filter_json, full_version, \
pretty_datetime, pretty_size, tmpdir, walk package_like, pretty_datetime, pretty_size, tmpdir, walk
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.repository_paths import RepositoryPaths 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()) 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: def test_package_like(package_ahriman: Package) -> None:
""" """
package_like must return true for archives package_like must return true for archives
@ -298,24 +308,26 @@ def test_walk(resource_path_root: Path) -> None:
must traverse directory recursively must traverse directory recursively
""" """
expected = sorted([ expected = sorted([
resource_path_root / "core/ahriman.ini", resource_path_root / "core" / "ahriman.ini",
resource_path_root / "core/logging.ini", resource_path_root / "core" / "logging.ini",
resource_path_root / "models/aur_error", resource_path_root / "models" / "aur_error",
resource_path_root / "models/big_file_checksum", resource_path_root / "models" / "big_file_checksum",
resource_path_root / "models/empty_file_checksum", resource_path_root / "models" / "empty_file_checksum",
resource_path_root / "models/package_ahriman_aur", resource_path_root / "models" / "official_error",
resource_path_root / "models/package_ahriman_srcinfo", resource_path_root / "models" / "package_ahriman_aur",
resource_path_root / "models/package_tpacpi-bat-git_srcinfo", resource_path_root / "models" / "package_akonadi_aur",
resource_path_root / "models/package_yay_srcinfo", resource_path_root / "models" / "package_ahriman_srcinfo",
resource_path_root / "web/templates/build-status/login-modal.jinja2", resource_path_root / "models" / "package_tpacpi-bat-git_srcinfo",
resource_path_root / "web/templates/build-status/package-actions-modals.jinja2", resource_path_root / "models" / "package_yay_srcinfo",
resource_path_root / "web/templates/build-status/package-actions-script.jinja2", resource_path_root / "web" / "templates" / "build-status" / "login-modal.jinja2",
resource_path_root / "web/templates/static/favicon.ico", resource_path_root / "web" / "templates" / "build-status" / "package-actions-modals.jinja2",
resource_path_root / "web/templates/utils/bootstrap-scripts.jinja2", resource_path_root / "web" / "templates" / "build-status" / "package-actions-script.jinja2",
resource_path_root / "web/templates/utils/style.jinja2", resource_path_root / "web" / "templates" / "static" / "favicon.ico",
resource_path_root / "web/templates/build-status.jinja2", resource_path_root / "web" / "templates" / "utils" / "bootstrap-scripts.jinja2",
resource_path_root / "web/templates/email-index.jinja2", resource_path_root / "web" / "templates" / "utils" / "style.jinja2",
resource_path_root / "web/templates/repo-index.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))) local_files = list(sorted(walk(resource_path_root)))
assert local_files == expected assert local_files == expected

View File

@ -9,7 +9,7 @@ from typing import Any, Dict
from ahriman.models.aur_package import AURPackage 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 load package description from resource file
:param resource_path_root: path to resource root :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] 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: def test_from_json(aur_package_ahriman: AURPackage, resource_path_root: Path) -> None:
""" """
must load package from json 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 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 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: def test_convert(aur_package_ahriman: AURPackage, resource_path_root: Path) -> None:
""" """
must convert fields to snakecase and also apply converters 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) converted = AURPackage.convert(model)
known_fields = [pair.name for pair in fields(AURPackage)] known_fields = [pair.name for pair in fields(AURPackage)]
assert all(field in known_fields for field in converted) assert all(field in known_fields for field in converted)

View File

@ -101,7 +101,7 @@ def test_from_aur(package_ahriman: Package, aur_package_ahriman: AURPackage, moc
""" """
must construct package from aur 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) package = Package.from_aur(package_ahriman.base, package_ahriman.aur_url)
assert package_ahriman.base == package.base 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 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: def test_load_resolve(package_ahriman: Package, pyalpm_handle: MagicMock, mocker: MockerFixture) -> None:
""" """
must resolve source before package loading 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) 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: def test_load_failure(package_ahriman: Package, pyalpm_handle: MagicMock, mocker: MockerFixture) -> None:
""" """
must raise InvalidPackageInfo on exception 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"} 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: def test_actual_version(package_ahriman: Package, repository_paths: RepositoryPaths) -> None:
""" """
must return same actual_version as version is must return same actual_version as version is

View File

@ -21,7 +21,7 @@ async def test_get(client: TestClient, aur_package_ahriman: AURPackage, mocker:
""" """
must call get request correctly 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"}) response = await client.get("/service-api/v1/search", params={"for": "ahriman"})
assert response.ok assert response.ok
@ -33,7 +33,7 @@ async def test_get_exception(client: TestClient, mocker: MockerFixture) -> None:
""" """
must raise 400 on empty search string 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") response = await client.get("/service-api/v1/search")
assert response.status == 404 assert response.status == 404
@ -44,7 +44,7 @@ async def test_get_join(client: TestClient, mocker: MockerFixture) -> None:
""" """
must join search args with space 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")]) response = await client.get("/service-api/v1/search", params=[("for", "ahriman"), ("for", "maybe")])
assert response.ok assert response.ok

View File

@ -0,0 +1,8 @@
{
"version": 2,
"limit": 250,
"valid": false,
"results": [],
"num_pages": 1,
"page": 1
}

View File

@ -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
}