Compare commits

..

16 Commits

Author SHA1 Message Date
952b55f707 Release 1.1.0 2021-07-05 22:11:14 +03:00
b9b012be53 handle provides list 2021-07-05 22:08:04 +03:00
b8036649ab install types for mypy 2021-06-28 02:54:20 +03:00
c90e20587e remove type: ignore for newest python 2021-06-28 02:32:54 +03:00
3e020ec141 Feature/all archs (#21)
* add init subcommand

* add also init command to repository object

* add ability to generate list of architectures

* check if architecture list is not empty
2021-05-23 16:40:40 +03:00
783b7d043d imply no-log for every unsafe parser (#20) 2021-05-19 23:30:59 +03:00
5c297d1c67 allow to specify list of package dependencies in rebuild target
also replace nargs= by action=append in non-positional args. It is
required to make arguments parsing result more predictable and
consistent
2021-04-18 13:34:27 +03:00
b0d1f3c091 Release 1.0.0 2021-04-10 01:38:55 +03:00
50e219fda5 import pgp key implementation (#17)
* import pgp key implementation

* do not ask confirmation for local sign. Also add argparser test

* superseed requests by python-aur package

* ...and drop --skippgpcheck makgepkg flag by default
2021-04-10 01:37:45 +03:00
75298d1b8a better naming for actions 2021-04-09 20:02:17 +03:00
8196dcc8a0 add search subparser (#15) 2021-04-09 11:57:06 +03:00
f634f1df58 Add web status route (#13)
* add status route

* typed status and get status at the start of application
2021-04-08 01:48:53 +03:00
32df4fc54f Move search line inside extended report option 2021-04-06 17:03:34 +03:00
11ae930c59 Release 0.22.1 2021-04-06 05:54:04 +03:00
9c332c23d2 format long line 2021-04-06 05:53:38 +03:00
4ed0a49a44 add ability to skip email report generation for empty update list 2021-04-06 05:51:50 +03:00
55 changed files with 1123 additions and 87 deletions

View File

@ -1,4 +1,4 @@
name: create release name: release
on: on:
push: push:

View File

@ -1,5 +1,4 @@
# based on https://github.com/actions/starter-workflows/blob/main/ci/python-app.yml name: tests
name: check commit
on: on:
push: push:

View File

@ -56,6 +56,7 @@ Group name must refer to architecture, e.g. it should be `email:x86_64` for x86_
* `homepage` - link to homepage, string, optional. * `homepage` - link to homepage, string, optional.
* `host` - SMTP host for sending emails, string, required. * `host` - SMTP host for sending emails, string, required.
* `link_path` - prefix for HTML links, string, required. * `link_path` - prefix for HTML links, string, required.
* `no_empty_report` - skip report generation for empty packages list, boolean, optional, default `yes`.
* `password` - SMTP password to authenticate, string, optional. * `password` - SMTP password to authenticate, string, optional.
* `port` - SMTP port for sending emails, int, required. * `port` - SMTP port for sending emails, int, required.
* `receivers` - SMTP receiver addresses, space separated list of strings, required. * `receivers` - SMTP receiver addresses, space separated list of strings, required.

View File

@ -23,8 +23,7 @@ archive_directory: $(TARGET_FILES)
archlinux: archive archlinux: archive
sed -i "s/pkgver=[0-9.]*/pkgver=$(VERSION)/" package/archlinux/PKGBUILD sed -i "s/pkgver=[0-9.]*/pkgver=$(VERSION)/" package/archlinux/PKGBUILD
check: clean check: clean mypy
cd src && mypy --implicit-reexport --strict -p "$(PROJECT)"
find "src/$(PROJECT)" "tests/$(PROJECT)" -name "*.py" -execdir autopep8 --exit-code --max-line-length 120 -aa -i {} + find "src/$(PROJECT)" "tests/$(PROJECT)" -name "*.py" -execdir autopep8 --exit-code --max-line-length 120 -aa -i {} +
cd src && pylint --rcfile=../.pylintrc "$(PROJECT)" cd src && pylint --rcfile=../.pylintrc "$(PROJECT)"
@ -35,6 +34,10 @@ clean:
directory: clean directory: clean
mkdir "$(PROJECT)" mkdir "$(PROJECT)"
mypy:
cd src && echo y | mypy --implicit-reexport --strict -p "$(PROJECT)" --install-types || true
cd src && mypy --implicit-reexport --strict -p "$(PROJECT)"
push: archlinux push: archlinux
git add package/archlinux/PKGBUILD src/ahriman/version.py git add package/archlinux/PKGBUILD src/ahriman/version.py
git commit -m "Release $(VERSION)" git commit -m "Release $(VERSION)"

View File

@ -1,7 +1,7 @@
# Maintainer: Evgeniy Alekseev # Maintainer: Evgeniy Alekseev
pkgname='ahriman' pkgname='ahriman'
pkgver=0.22.0 pkgver=1.1.0
pkgrel=1 pkgrel=1
pkgdesc="ArcHlinux ReposItory MANager" pkgdesc="ArcHlinux ReposItory MANager"
arch=('any') arch=('any')
@ -17,7 +17,6 @@ optdepends=('aws-cli: sync to s3'
'python-aiohttp: web server' 'python-aiohttp: web server'
'python-aiohttp-jinja2: web server' 'python-aiohttp-jinja2: web server'
'python-jinja: html report generation' 'python-jinja: html report generation'
'python-requests: web server'
'rsync: sync by using rsync' 'rsync: sync by using rsync'
'subversion: -svn packages support') 'subversion: -svn packages support')
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz" source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz"

View File

@ -13,7 +13,7 @@ archbuild_flags =
build_command = extra-x86_64-build build_command = extra-x86_64-build
ignore_packages = ignore_packages =
makechrootpkg_flags = makechrootpkg_flags =
makepkg_flags = --skippgpcheck makepkg_flags =
[repository] [repository]
name = aur-clone name = aur-clone
@ -26,6 +26,7 @@ target =
target = target =
[email] [email]
no_empty_report = yes
template_path = /usr/share/ahriman/repo-index.jinja2 template_path = /usr/share/ahriman/repo-index.jinja2
ssl = disabled ssl = disabled
@ -36,7 +37,7 @@ template_path = /usr/share/ahriman/repo-index.jinja2
target = target =
[rsync] [rsync]
command = rsync --archive --verbose --compress --partial --delete command = rsync --archive --compress --partial --delete
[s3] [s3]
command = aws s3 sync --quiet --delete command = aws s3 sync --quiet --delete

View File

@ -28,10 +28,10 @@
SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Package{% if has_package_signed %}Required{% else %}Never{% endif %} TrustedOnly SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Package{% if has_package_signed %}Required{% else %}Never{% endif %} TrustedOnly
</code> </code>
</section> </section>
{% include "search-line.jinja2" %}
{% endif %} {% endif %}
{% include "search-line.jinja2" %}
<section class="element"> <section class="element">
<table class="sortable search-table"> <table class="sortable search-table">
<tr class="header"> <tr class="header">

View File

@ -29,6 +29,7 @@ setup(
install_requires=[ install_requires=[
"aur", "aur",
"pyalpm", "pyalpm",
"requests",
"srcinfo", "srcinfo",
], ],
setup_requires=[ setup_requires=[
@ -89,7 +90,6 @@ setup(
"Jinja2", "Jinja2",
"aiohttp", "aiohttp",
"aiohttp_jinja2", "aiohttp_jinja2",
"requests",
], ],
}, },
) )

View File

@ -22,9 +22,8 @@ import sys
from pathlib import Path from pathlib import Path
import ahriman.application.handlers as handlers from ahriman import version
import ahriman.version as version from ahriman.application import handlers
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@ -42,7 +41,7 @@ def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="ahriman", description="ArcHlinux ReposItory MANager", parser = argparse.ArgumentParser(prog="ahriman", description="ArcHlinux ReposItory MANager",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("-a", "--architecture", help="target architectures (can be used multiple times)", parser.add_argument("-a", "--architecture", help="target architectures (can be used multiple times)",
action="append", required=True) action="append")
parser.add_argument("-c", "--configuration", help="configuration path", type=Path, default=Path("/etc/ahriman.ini")) parser.add_argument("-c", "--configuration", help="configuration path", type=Path, default=Path("/etc/ahriman.ini"))
parser.add_argument("--force", help="force run, remove file lock", action="store_true") parser.add_argument("--force", help="force run, remove file lock", action="store_true")
parser.add_argument("-l", "--lock", help="lock file", type=Path, default=Path("/tmp/ahriman.lock")) parser.add_argument("-l", "--lock", help="lock file", type=Path, default=Path("/tmp/ahriman.lock"))
@ -57,9 +56,12 @@ def _parser() -> argparse.ArgumentParser:
_set_check_parser(subparsers) _set_check_parser(subparsers)
_set_clean_parser(subparsers) _set_clean_parser(subparsers)
_set_config_parser(subparsers) _set_config_parser(subparsers)
_set_init_parser(subparsers)
_set_key_import_parser(subparsers)
_set_rebuild_parser(subparsers) _set_rebuild_parser(subparsers)
_set_remove_parser(subparsers) _set_remove_parser(subparsers)
_set_report_parser(subparsers) _set_report_parser(subparsers)
_set_search_parser(subparsers)
_set_setup_parser(subparsers) _set_setup_parser(subparsers)
_set_sign_parser(subparsers) _set_sign_parser(subparsers)
_set_status_parser(subparsers) _set_status_parser(subparsers)
@ -82,7 +84,7 @@ def _set_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("package", help="package base/name or archive path", nargs="+") parser.add_argument("package", help="package base/name or archive path", nargs="+")
parser.add_argument("--now", help="run update function after", action="store_true") parser.add_argument("--now", help="run update function after", action="store_true")
parser.add_argument("--without-dependencies", help="do not add dependencies", action="store_true") parser.add_argument("--without-dependencies", help="do not add dependencies", action="store_true")
parser.set_defaults(handler=handlers.Add) parser.set_defaults(handler=handlers.Add, architecture=[])
return parser return parser
@ -97,7 +99,7 @@ def _set_check_parser(root: SubParserAction) -> argparse.ArgumentParser:
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("package", help="filter check by package base", nargs="*") parser.add_argument("package", help="filter check by package base", nargs="*")
parser.add_argument("--no-vcs", help="do not check VCS packages", action="store_true") parser.add_argument("--no-vcs", help="do not check VCS packages", action="store_true")
parser.set_defaults(handler=handlers.Update, no_aur=False, no_manual=True, dry_run=True) parser.set_defaults(handler=handlers.Update, architecture=[], no_aur=False, no_manual=True, dry_run=True)
return parser return parser
@ -114,7 +116,7 @@ def _set_clean_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("--no-chroot", help="do not clear build chroot", action="store_true") parser.add_argument("--no-chroot", help="do not clear build chroot", action="store_true")
parser.add_argument("--no-manual", help="do not clear directory with manually added packages", action="store_true") parser.add_argument("--no-manual", help="do not clear directory with manually added packages", action="store_true")
parser.add_argument("--no-packages", help="do not clear directory with built packages", action="store_true") parser.add_argument("--no-packages", help="do not clear directory with built packages", action="store_true")
parser.set_defaults(handler=handlers.Clean, unsafe=True) parser.set_defaults(handler=handlers.Clean, architecture=[], no_log=True, unsafe=True)
return parser return parser
@ -127,7 +129,35 @@ def _set_config_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("config", help="dump configuration", parser = root.add_parser("config", help="dump configuration",
description="dump configuration for specified architecture", description="dump configuration for specified architecture",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.set_defaults(handler=handlers.Dump, lock=None, no_report=True, unsafe=True) parser.set_defaults(handler=handlers.Dump, lock=None, no_log=True, no_report=True, unsafe=True)
return parser
def _set_init_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for init subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("init", help="create repository tree",
description="create empty repository tree. Optional command for auto architecture support",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.set_defaults(handler=handlers.Init, no_report=True)
return parser
def _set_key_import_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for key import subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("key-import", help="import PGP key",
description="import PGP key from public sources to repository user",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--key-server", help="key server for key import", default="keys.gnupg.net")
parser.add_argument("key", help="PGP key to import from public server")
parser.set_defaults(handler=handlers.KeyImport, architecture=[""], lock=None, no_report=True)
return parser return parser
@ -139,8 +169,8 @@ def _set_rebuild_parser(root: SubParserAction) -> argparse.ArgumentParser:
""" """
parser = root.add_parser("rebuild", help="rebuild repository", description="rebuild whole repository", parser = root.add_parser("rebuild", help="rebuild repository", description="rebuild whole repository",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--depends-on", help="only rebuild packages that depend on specified package") parser.add_argument("--depends-on", help="only rebuild packages that depend on specified package", action="append")
parser.set_defaults(handler=handlers.Rebuild) parser.set_defaults(handler=handlers.Rebuild, architecture=[])
return parser return parser
@ -153,7 +183,7 @@ def _set_remove_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("remove", help="remove package", description="remove package", parser = root.add_parser("remove", help="remove package", description="remove package",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("package", help="package name or base", nargs="+") parser.add_argument("package", help="package name or base", nargs="+")
parser.set_defaults(handler=handlers.Remove) parser.set_defaults(handler=handlers.Remove, architecture=[])
return parser return parser
@ -166,7 +196,19 @@ def _set_report_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("report", help="generate report", description="generate report", parser = root.add_parser("report", help="generate report", description="generate report",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("target", help="target to generate report", nargs="*") parser.add_argument("target", help="target to generate report", nargs="*")
parser.set_defaults(handler=handlers.Report) parser.set_defaults(handler=handlers.Report, architecture=[])
return parser
def _set_search_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for search subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("search", help="search for package", description="search for package in AUR using API")
parser.add_argument("search", help="search terms, can be specified multiple times", nargs="+")
parser.set_defaults(handler=handlers.Search, architecture=[""], lock=None, no_log=True, no_report=True, unsafe=True)
return parser return parser
@ -187,9 +229,9 @@ def _set_setup_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("--repository", help="repository name", required=True) parser.add_argument("--repository", help="repository name", required=True)
parser.add_argument("--sign-key", help="sign key id") parser.add_argument("--sign-key", help="sign key id")
parser.add_argument("--sign-target", help="sign options", type=SignSettings.from_option, parser.add_argument("--sign-target", help="sign options", type=SignSettings.from_option,
choices=SignSettings, nargs="*") choices=SignSettings, action="append")
parser.add_argument("--web-port", help="port of the web service", type=int) parser.add_argument("--web-port", help="port of the web service", type=int)
parser.set_defaults(handler=handlers.Setup, lock=None, no_report=True, unsafe=True) parser.set_defaults(handler=handlers.Setup, lock=None, no_log=True, no_report=True, unsafe=True)
return parser return parser
@ -202,7 +244,7 @@ def _set_sign_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("sign", help="sign packages", description="(re-)sign packages and repository database", parser = root.add_parser("sign", help="sign packages", description="(re-)sign packages and repository database",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("package", help="sign only specified packages", nargs="*") parser.add_argument("package", help="sign only specified packages", nargs="*")
parser.set_defaults(handler=handlers.Sign) parser.set_defaults(handler=handlers.Sign, architecture=[])
return parser return parser
@ -216,7 +258,7 @@ def _set_status_parser(root: SubParserAction) -> argparse.ArgumentParser:
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--ahriman", help="get service status itself", action="store_true") parser.add_argument("--ahriman", help="get service status itself", action="store_true")
parser.add_argument("package", help="filter status by package base", nargs="*") parser.add_argument("package", help="filter status by package base", nargs="*")
parser.set_defaults(handler=handlers.Status, lock=None, no_report=True, unsafe=True) parser.set_defaults(handler=handlers.Status, lock=None, no_log=True, no_report=True, unsafe=True)
return parser return parser
@ -235,7 +277,7 @@ def _set_status_update_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("--status", help="new status", choices=BuildStatusEnum, parser.add_argument("--status", help="new status", choices=BuildStatusEnum,
type=BuildStatusEnum, default=BuildStatusEnum.Success) type=BuildStatusEnum, default=BuildStatusEnum.Success)
parser.add_argument("--remove", help="remove package status page", action="store_true") parser.add_argument("--remove", help="remove package status page", action="store_true")
parser.set_defaults(handler=handlers.StatusUpdate, lock=None, no_report=True, unsafe=True) parser.set_defaults(handler=handlers.StatusUpdate, lock=None, no_log=True, no_report=True, unsafe=True)
return parser return parser
@ -248,7 +290,7 @@ def _set_sync_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("sync", help="sync repository", description="sync packages to remote server", parser = root.add_parser("sync", help="sync repository", description="sync packages to remote server",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("target", help="target to sync", nargs="*") parser.add_argument("target", help="target to sync", nargs="*")
parser.set_defaults(handler=handlers.Sync) parser.set_defaults(handler=handlers.Sync, architecture=[])
return parser return parser
@ -265,7 +307,7 @@ def _set_update_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("--no-aur", help="do not check for AUR updates. Implies --no-vcs", action="store_true") parser.add_argument("--no-aur", help="do not check for AUR updates. Implies --no-vcs", action="store_true")
parser.add_argument("--no-manual", help="do not include manual updates", action="store_true") parser.add_argument("--no-manual", help="do not include manual updates", action="store_true")
parser.add_argument("--no-vcs", help="do not check VCS packages", action="store_true") parser.add_argument("--no-vcs", help="do not check VCS packages", action="store_true")
parser.set_defaults(handler=handlers.Update) parser.set_defaults(handler=handlers.Update, architecture=[])
return parser return parser

View File

@ -65,8 +65,10 @@ class Application:
""" """
known_packages: Set[str] = set() known_packages: Set[str] = set()
# local set # local set
for package in self.repository.packages(): for base in self.repository.packages():
known_packages.update(package.packages.keys()) for package, properties in base.packages.items():
known_packages.add(package)
known_packages.update(properties.provides)
known_packages.update(self.repository.pacman.all_packages()) known_packages.update(self.repository.pacman.all_packages())
return known_packages return known_packages

View File

@ -22,9 +22,12 @@ from ahriman.application.handlers.handler import Handler
from ahriman.application.handlers.add import Add from ahriman.application.handlers.add import Add
from ahriman.application.handlers.clean import Clean from ahriman.application.handlers.clean import Clean
from ahriman.application.handlers.dump import Dump from ahriman.application.handlers.dump import Dump
from ahriman.application.handlers.init import Init
from ahriman.application.handlers.key_import import KeyImport
from ahriman.application.handlers.rebuild import Rebuild from ahriman.application.handlers.rebuild import Rebuild
from ahriman.application.handlers.remove import Remove from ahriman.application.handlers.remove import Remove
from ahriman.application.handlers.report import Report from ahriman.application.handlers.report import Report
from ahriman.application.handlers.search import Search
from ahriman.application.handlers.setup import Setup from ahriman.application.handlers.setup import Setup
from ahriman.application.handlers.sign import Sign from ahriman.application.handlers.sign import Sign
from ahriman.application.handlers.status import Status from ahriman.application.handlers.status import Status

View File

@ -23,10 +23,12 @@ import argparse
import logging import logging
from multiprocessing import Pool from multiprocessing import Pool
from typing import Type from typing import Set, Type
from ahriman.application.lock import Lock from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import MissingArchitecture
from ahriman.models.repository_paths import RepositoryPaths
class Handler: class Handler:
@ -58,11 +60,33 @@ class Handler:
:param args: command line args :param args: command line args
:return: 0 on success, 1 otherwise :return: 0 on success, 1 otherwise
""" """
with Pool(len(args.architecture)) as pool: architectures = cls.extract_architectures(args)
with Pool(len(architectures)) as pool:
result = pool.starmap( result = pool.starmap(
cls._call, [(args, architecture) for architecture in set(args.architecture)]) cls._call, [(args, architecture) for architecture in architectures])
return 0 if all(result) else 1 return 0 if all(result) else 1
@classmethod
def extract_architectures(cls: Type[Handler], args: argparse.Namespace) -> Set[str]:
"""
get known architectures
:param args: command line args
:return: list of architectures for which tree is created
"""
if args.architecture is None:
raise MissingArchitecture(args.command)
if args.architecture:
return set(args.architecture)
config = Configuration()
config.load(args.configuration)
root = config.getpath("repository", "root")
architectures = RepositoryPaths.known_architectures(root)
if not architectures:
raise MissingArchitecture(args.command)
return architectures
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """

View File

@ -0,0 +1,42 @@
#
# Copyright (c) 2021 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 argparse
from typing import Type
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
class Init(Handler):
"""
repository init handler
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
"""
Application(architecture, configuration).repository.repo.init()

View File

@ -0,0 +1,42 @@
#
# Copyright (c) 2021 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 argparse
from typing import Type
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
class KeyImport(Handler):
"""
key import packages handler
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
"""
Application(architecture, configuration).repository.sign.import_key(args.key_server, args.key)

View File

@ -39,10 +39,12 @@ class Rebuild(Handler):
:param architecture: repository architecture :param architecture: repository architecture
:param configuration: configuration instance :param configuration: configuration instance
""" """
depends_on = set(args.depends_on) if args.depends_on else None
application = Application(architecture, configuration) application = Application(architecture, configuration)
packages = [ packages = [
package package
for package in application.repository.packages() for package in application.repository.packages()
if args.depends_on is None or args.depends_on in package.depends if depends_on is None or depends_on.intersection(package.depends)
] # we have to use explicit list here for testing purpose ] # we have to use explicit list here for testing purpose
application.update(packages) application.update(packages)

View File

@ -0,0 +1,58 @@
#
# Copyright (c) 2021 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 argparse
import aur # type: ignore
from typing import Callable, Type
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
class Search(Handler):
"""
packages search handler
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
"""
search = " ".join(args.search)
packages = aur.search(search)
# it actually always should return string
# explicit cast to string just to avoid mypy warning for untyped library
comparator: Callable[[aur.Package], str] = lambda item: str(item.package_base)
for package in sorted(packages, key=comparator):
Search.log_fn(package)
@staticmethod
def log_fn(package: aur.Package) -> None:
"""
log package information
:param package: package object as from AUR
"""
print(f"=> {package.package_base} {package.version}")
print(f" {package.description}")

View File

@ -20,12 +20,14 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import logging
import os import os
from pathlib import Path from pathlib import Path
from types import TracebackType from types import TracebackType
from typing import Literal, Optional, Type from typing import Literal, Optional, Type
from ahriman import version
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import DuplicateRun, UnsafeRun from ahriman.core.exceptions import DuplicateRun, UnsafeRun
from ahriman.core.status.client import Client from ahriman.core.status.client import Client
@ -61,12 +63,13 @@ class Lock:
default workflow is the following: default workflow is the following:
check user UID check user UID
remove lock file if force flag is set
check if there is lock file check if there is lock file
check web status watcher status
create lock file create lock file
report to web if enabled report to web if enabled
""" """
self.check_user() self.check_user()
self.check_version()
self.create() self.create()
self.reporter.update_self(BuildStatusEnum.Building) self.reporter.update_self(BuildStatusEnum.Building)
return self return self
@ -85,6 +88,15 @@ class Lock:
self.reporter.update_self(status) self.reporter.update_self(status)
return False return False
def check_version(self) -> None:
"""
check web server version
"""
status = self.reporter.get_internal()
if status.version is not None and status.version != version.__version__:
logging.getLogger("root").warning(f"status watcher version mismatch, "
f"our {version.__version__}, their {status.version}")
def check_user(self) -> None: def check_user(self) -> None:
""" """
check if current user is actually owner of ahriman root check if current user is actually owner of ahriman root

View File

@ -18,7 +18,7 @@
# 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 pyalpm import Handle # type: ignore from pyalpm import Handle # type: ignore
from typing import List, Set from typing import Set
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -40,13 +40,15 @@ class Pacman:
for repository in configuration.getlist("alpm", "repositories"): for repository in configuration.getlist("alpm", "repositories"):
self.handle.register_syncdb(repository, 0) # 0 is pgp_level self.handle.register_syncdb(repository, 0) # 0 is pgp_level
def all_packages(self) -> List[str]: def all_packages(self) -> Set[str]:
""" """
get list of packages known for alpm get list of packages known for alpm
:return: list of package names :return: list of package names
""" """
result: Set[str] = set() result: Set[str] = set()
for database in self.handle.get_syncdbs(): for database in self.handle.get_syncdbs():
result.update({package.name for package in database.pkgcache}) for package in database.pkgcache:
result.add(package.name) # package itself
result.update(package.provides) # provides list for meta-packages
return list(result) return result

View File

@ -68,6 +68,16 @@ class Repo:
cwd=self.paths.repository, cwd=self.paths.repository,
logger=self.logger) logger=self.logger)
def init(self) -> None:
"""
create empty repository database
"""
Repo._check_output(
"repo-add", *self.sign_args, str(self.repo_path),
exception=None,
cwd=self.paths.repository,
logger=self.logger)
def remove(self, package: str, filename: Path) -> None: def remove(self, package: str, filename: Path) -> None:
""" """
remove package from repository remove package from repository

View File

@ -72,7 +72,8 @@ class Configuration(configparser.RawConfigParser):
:return: configuration instance :return: configuration instance
""" """
config = cls() config = cls()
config.load(path, architecture) config.load(path)
config.merge_sections(architecture)
config.load_logging(logfile) config.load_logging(logfile)
return config return config
@ -120,16 +121,14 @@ class Configuration(configparser.RawConfigParser):
return value return value
return self.path.parent / value return self.path.parent / value
def load(self, path: Path, architecture: str) -> None: def load(self, path: Path) -> None:
""" """
fully load configuration fully load configuration
:param path: path to root configuration file :param path: path to root configuration file
:param architecture: repository architecture
""" """
self.path = path self.path = path
self.read(self.path) self.read(self.path)
self.load_includes() self.load_includes()
self.merge_sections(architecture)
def load_includes(self) -> None: def load_includes(self) -> None:
""" """
@ -140,7 +139,7 @@ class Configuration(configparser.RawConfigParser):
if path == self.logging_path: if path == self.logging_path:
continue # we don't want to load logging explicitly continue # we don't want to load logging explicitly
self.read(path) self.read(path)
except (FileNotFoundError, configparser.NoOptionError): except (FileNotFoundError, configparser.NoOptionError, configparser.NoSectionError):
pass pass
def load_logging(self, logfile: bool) -> None: def load_logging(self, logfile: bool) -> None:

View File

@ -83,6 +83,19 @@ class InvalidPackageInfo(Exception):
Exception.__init__(self, f"There are errors during reading package information: `{details}`") Exception.__init__(self, f"There are errors during reading package information: `{details}`")
class MissingArchitecture(Exception):
"""
exception which will be raised if architecture is required, but missing
"""
def __init__(self, command: str) -> None:
"""
default constructor
:param command: command name which throws exception
"""
Exception.__init__(self, f"Architecture required for subcommand {command}, but missing")
class ReportFailed(Exception): class ReportFailed(Exception):
""" """
report generation exception report generation exception

View File

@ -36,6 +36,7 @@ class Email(Report, JinjaTemplate):
""" """
email report generator email report generator
:ivar host: SMTP host to connect :ivar host: SMTP host to connect
:ivar no_empty_report: skip empty report generation
:ivar password: password to authenticate via SMTP :ivar password: password to authenticate via SMTP
:ivar port: SMTP port to connect :ivar port: SMTP port to connect
:ivar receivers: list of receivers emails :ivar receivers: list of receivers emails
@ -55,6 +56,7 @@ class Email(Report, JinjaTemplate):
# base smtp settings # base smtp settings
self.host = configuration.get("email", "host") self.host = configuration.get("email", "host")
self.no_empty_report = configuration.getboolean("email", "no_empty_report", fallback=True)
self.password = configuration.get("email", "password", fallback=None) self.password = configuration.get("email", "password", fallback=None)
self.port = configuration.getint("email", "port") self.port = configuration.getint("email", "port")
self.receivers = configuration.getlist("email", "receivers") self.receivers = configuration.getlist("email", "receivers")
@ -96,6 +98,8 @@ class Email(Report, JinjaTemplate):
:param packages: list of packages to generate report :param packages: list of packages to generate report
:param built_packages: list of packages which has just been built :param built_packages: list of packages which has just been built
""" """
if self.no_empty_report and not built_packages:
return
text = self.make_html(built_packages, False) text = self.make_html(built_packages, False)
attachments = {"index.html": self.make_html(packages, True)} attachments = {"index.html": self.make_html(packages, True)}
self._send(text, attachments) self._send(text, attachments)

View File

@ -18,13 +18,14 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import logging import logging
import requests
from pathlib import Path from pathlib import Path
from typing import List, Optional, Set, Tuple from typing import List, Optional, Set, Tuple
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import BuildFailed from ahriman.core.exceptions import BuildFailed
from ahriman.core.util import check_output from ahriman.core.util import check_output, exception_response_text
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@ -87,6 +88,36 @@ class GPG:
default_key = configuration.get("sign", "key") if targets else None default_key = configuration.get("sign", "key") if targets else None
return targets, default_key return targets, default_key
def download_key(self, server: str, key: str) -> str:
"""
download key from public PGP server
:param server: public PGP server which will be used to download the key
:param key: key ID to download
:return: key as plain text
"""
key = key if key.startswith("0x") else f"0x{key}"
try:
response = requests.get(f"http://{server}/pks/lookup", params={
"op": "get",
"options": "mr",
"search": key
})
response.raise_for_status()
except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not download key {key} from {server}: {exception_response_text(e)}")
raise
return response.text
def import_key(self, server: str, key: str) -> None:
"""
import key to current user and sign it locally
:param server: public PGP server which will be used to download the key
:param key: key ID to import
"""
key_body = self.download_key(server, key)
GPG._check_output("gpg", "--import", input_data=key_body, exception=None, logger=self.logger)
GPG._check_output("gpg", "--quick-lsign-key", key, exception=None, logger=self.logger)
def process(self, path: Path, key: str) -> List[Path]: def process(self, path: Path, key: str) -> List[Path]:
""" """
gpg command wrapper gpg command wrapper

View File

@ -23,6 +23,7 @@ from typing import List, Optional, Tuple, Type
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.internal_status import InternalStatus
from ahriman.models.package import Package from ahriman.models.package import Package
@ -62,6 +63,14 @@ class Client:
del base del base
return [] return []
# pylint: disable=no-self-use
def get_internal(self) -> InternalStatus:
"""
get internal service status
:return: current internal (web) service status
"""
return InternalStatus()
# pylint: disable=no-self-use # pylint: disable=no-self-use
def get_self(self) -> BuildStatus: def get_self(self) -> BuildStatus:
""" """

View File

@ -23,7 +23,9 @@ import requests
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from ahriman.core.status.client import Client from ahriman.core.status.client import Client
from ahriman.core.util import exception_response_text
from ahriman.models.build_status import BuildStatusEnum, BuildStatus from ahriman.models.build_status import BuildStatusEnum, BuildStatus
from ahriman.models.internal_status import InternalStatus
from ahriman.models.package import Package from ahriman.models.package import Package
@ -45,16 +47,6 @@ class WebClient(Client):
self.host = host self.host = host
self.port = port self.port = port
@staticmethod
def _exception_response_text(exception: requests.exceptions.HTTPError) -> str:
"""
safe response exception text generation
:param exception: exception raised
:return: text of the response if it is not None and empty string otherwise
"""
result: str = exception.response.text if exception.response is not None else ""
return result
def _ahriman_url(self) -> str: def _ahriman_url(self) -> str:
""" """
url generator url generator
@ -70,6 +62,13 @@ class WebClient(Client):
""" """
return f"http://{self.host}:{self.port}/api/v1/packages/{base}" return f"http://{self.host}:{self.port}/api/v1/packages/{base}"
def _status_url(self) -> str:
"""
url generator
:return: full url for web service for status
"""
return f"http://{self.host}:{self.port}/api/v1/status"
def add(self, package: Package, status: BuildStatusEnum) -> None: def add(self, package: Package, status: BuildStatusEnum) -> None:
""" """
add new package with status add new package with status
@ -85,7 +84,7 @@ class WebClient(Client):
response = requests.post(self._package_url(package.base), json=payload) response = requests.post(self._package_url(package.base), json=payload)
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not add {package.base}: {WebClient._exception_response_text(e)}") self.logger.exception(f"could not add {package.base}: {exception_response_text(e)}")
except Exception: except Exception:
self.logger.exception(f"could not add {package.base}") self.logger.exception(f"could not add {package.base}")
@ -105,11 +104,28 @@ class WebClient(Client):
for package in status_json for package in status_json
] ]
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not get {base}: {WebClient._exception_response_text(e)}") self.logger.exception(f"could not get {base}: {exception_response_text(e)}")
except Exception: except Exception:
self.logger.exception(f"could not get {base}") self.logger.exception(f"could not get {base}")
return [] return []
def get_internal(self) -> InternalStatus:
"""
get internal service status
:return: current internal (web) service status
"""
try:
response = requests.get(self._status_url())
response.raise_for_status()
status_json = response.json()
return InternalStatus.from_json(status_json)
except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not get web service status: {exception_response_text(e)}")
except Exception:
self.logger.exception("could not get web service status")
return InternalStatus()
def get_self(self) -> BuildStatus: def get_self(self) -> BuildStatus:
""" """
get ahriman status itself get ahriman status itself
@ -122,7 +138,7 @@ class WebClient(Client):
status_json = response.json() status_json = response.json()
return BuildStatus.from_json(status_json) return BuildStatus.from_json(status_json)
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not get service status: {WebClient._exception_response_text(e)}") self.logger.exception(f"could not get service status: {exception_response_text(e)}")
except Exception: except Exception:
self.logger.exception("could not get service status") self.logger.exception("could not get service status")
return BuildStatus() return BuildStatus()
@ -136,7 +152,7 @@ class WebClient(Client):
response = requests.delete(self._package_url(base)) response = requests.delete(self._package_url(base))
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not delete {base}: {WebClient._exception_response_text(e)}") self.logger.exception(f"could not delete {base}: {exception_response_text(e)}")
except Exception: except Exception:
self.logger.exception(f"could not delete {base}") self.logger.exception(f"could not delete {base}")
@ -152,7 +168,7 @@ class WebClient(Client):
response = requests.post(self._package_url(base), json=payload) response = requests.post(self._package_url(base), json=payload)
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not update {base}: {WebClient._exception_response_text(e)}") self.logger.exception(f"could not update {base}: {exception_response_text(e)}")
except Exception: except Exception:
self.logger.exception(f"could not update {base}") self.logger.exception(f"could not update {base}")
@ -167,6 +183,6 @@ class WebClient(Client):
response = requests.post(self._ahriman_url(), json=payload) response = requests.post(self._ahriman_url(), json=payload)
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not update service status: {WebClient._exception_response_text(e)}") self.logger.exception(f"could not update service status: {exception_response_text(e)}")
except Exception: except Exception:
self.logger.exception("could not update service status") self.logger.exception("could not update service status")

View File

@ -19,6 +19,7 @@
# #
import datetime import datetime
import subprocess import subprocess
import requests
from logging import Logger from logging import Logger
from pathlib import Path from pathlib import Path
@ -27,29 +28,42 @@ from typing import Optional, Union
from ahriman.core.exceptions import InvalidOption from ahriman.core.exceptions import InvalidOption
def check_output(*args: str, exception: Optional[Exception], def check_output(*args: str, exception: Optional[Exception], cwd: Optional[Path] = None,
cwd: Optional[Path] = None, logger: Optional[Logger] = None) -> str: input_data: Optional[str] = None, logger: Optional[Logger] = None) -> str:
""" """
subprocess wrapper subprocess wrapper
:param args: command line arguments :param args: command line arguments
:param exception: exception which has to be reraised instead of default subprocess exception :param exception: exception which has to be reraised instead of default subprocess exception
:param cwd: current working directory :param cwd: current working directory
:param input_data: data which will be written to command stdin
:param logger: logger to log command result if required :param logger: logger to log command result if required
:return: command output :return: command output
""" """
try: try:
result = subprocess.check_output(args, cwd=cwd, stderr=subprocess.STDOUT).decode("utf8").strip() # universal_newlines is required to read input from string
result: str = subprocess.check_output(args, cwd=cwd, input=input_data, stderr=subprocess.STDOUT,
universal_newlines=True).strip()
if logger is not None: if logger is not None:
for line in result.splitlines(): for line in result.splitlines():
logger.debug(line) logger.debug(line)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
if e.output is not None and logger is not None: if e.output is not None and logger is not None:
for line in e.output.decode("utf8").splitlines(): for line in e.output.splitlines():
logger.debug(line) logger.debug(line)
raise exception or e raise exception or e
return result return result
def exception_response_text(exception: requests.exceptions.HTTPError) -> str:
"""
safe response exception text generation
:param exception: exception raised
:return: text of the response if it is not None and empty string otherwise
"""
result: str = exception.response.text if exception.response is not None else ""
return result
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

@ -0,0 +1,71 @@
#
# Copyright (c) 2021 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
from dataclasses import dataclass, fields
from typing import Any, Dict, List, Tuple, Type
from ahriman.models.build_status import BuildStatus
from ahriman.models.package import Package
@dataclass
class Counters:
"""
package counters
:ivar total: total packages count
:ivar unknown: packages in unknown status count
:ivar pending: packages in pending status count
:ivar building: packages in building status count
:ivar failed: packages in failed status count
:ivar success: packages in success status count
"""
total: int
unknown: int = 0
pending: int = 0
building: int = 0
failed: int = 0
success: int = 0
@classmethod
def from_json(cls: Type[Counters], dump: Dict[str, Any]) -> Counters:
"""
construct counters from json dump
:param dump: json dump body
:return: status counters
"""
# filter to only known fields
known_fields = [pair.name for pair in fields(cls)]
dump = {key: value for key, value in dump.items() if key in known_fields}
return cls(**dump)
@classmethod
def from_packages(cls: Type[Counters], packages: List[Tuple[Package, BuildStatus]]) -> Counters:
"""
construct counters from packages statuses
:param packages: list of package and their status as per watcher property
:return: status counters
"""
per_status = {"total": len(packages)}
for _, status in packages:
key = status.status.name.lower()
per_status.setdefault(key, 0)
per_status[key] += 1
return cls(**per_status)

View File

@ -0,0 +1,60 @@
#
# Copyright (c) 2021 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
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, Optional, Type
from ahriman.models.counters import Counters
@dataclass
class InternalStatus:
"""
internal server status
:ivar architecture: repository architecture
:ivar packages: packages statuses counter object
:ivar repository: repository name
:ivar version: service version
"""
architecture: Optional[str] = None
packages: Counters = field(default=Counters(total=0))
repository: Optional[str] = None
version: Optional[str] = None
@classmethod
def from_json(cls: Type[InternalStatus], dump: Dict[str, Any]) -> InternalStatus:
"""
construct internal status from json dump
:param dump: json dump body
:return: internal status
"""
counters = Counters.from_json(dump["packages"]) if "packages" in dump else Counters(total=0)
return cls(architecture=dump.get("architecture"),
packages=counters,
repository=dump.get("repository"),
version=dump.get("version"))
def view(self) -> Dict[str, Any]:
"""
generate json status view
:return: json-friendly dictionary
"""
return asdict(self)

View File

@ -38,6 +38,7 @@ class PackageDescription:
:ivar groups: package groups :ivar groups: package groups
:ivar installed_size: package installed size :ivar installed_size: package installed size
:ivar licenses: package licenses list :ivar licenses: package licenses list
:ivar provides: list of provided packages
:ivar url: package url :ivar url: package url
""" """
@ -50,6 +51,7 @@ class PackageDescription:
groups: List[str] = field(default_factory=list) groups: List[str] = field(default_factory=list)
installed_size: Optional[int] = None installed_size: Optional[int] = None
licenses: List[str] = field(default_factory=list) licenses: List[str] = field(default_factory=list)
provides: List[str] = field(default_factory=list)
url: Optional[str] = None url: Optional[str] = None
@property @property
@ -89,4 +91,5 @@ class PackageDescription:
groups=package.groups, groups=package.groups,
installed_size=package.isize, installed_size=package.isize,
licenses=package.licenses, licenses=package.licenses,
provides=package.provides,
url=package.url) url=package.url)

View File

@ -17,9 +17,11 @@
# 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 pathlib import Path from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Set, Type
@dataclass @dataclass
@ -76,6 +78,20 @@ class RepositoryPaths:
""" """
return self.root / "sources" / self.architecture return self.root / "sources" / self.architecture
@classmethod
def known_architectures(cls: Type[RepositoryPaths], root: Path) -> Set[str]:
"""
get known architectures
:param root: repository root
:return: list of architectures for which tree is created
"""
paths = cls(root, "")
return {
path.name
for path in paths.repository.iterdir()
if path.is_dir()
}
def create_tree(self) -> None: def create_tree(self) -> None:
""" """
create ahriman working tree create ahriman working tree

View File

@ -17,4 +17,4 @@
# 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/>.
# #
__version__ = "0.22.0" __version__ = "1.1.0"

View File

@ -23,6 +23,7 @@ from ahriman.web.views.ahriman import AhrimanView
from ahriman.web.views.index import IndexView from ahriman.web.views.index import IndexView
from ahriman.web.views.package import PackageView from ahriman.web.views.package import PackageView
from ahriman.web.views.packages import PackagesView from ahriman.web.views.packages import PackagesView
from ahriman.web.views.status import StatusView
def setup_routes(application: Application) -> None: def setup_routes(application: Application) -> None:
@ -44,6 +45,8 @@ def setup_routes(application: Application) -> None:
GET /api/v1/package/:base get package base status GET /api/v1/package/:base get package base status
POST /api/v1/package/:base update package base status POST /api/v1/package/:base update package base status
GET /api/v1/status get web service status itself
:param application: web application instance :param application: web application instance
""" """
application.router.add_get("/", IndexView) application.router.add_get("/", IndexView)
@ -58,3 +61,5 @@ def setup_routes(application: Application) -> None:
application.router.add_delete("/api/v1/packages/{package}", PackageView) application.router.add_delete("/api/v1/packages/{package}", PackageView)
application.router.add_get("/api/v1/packages/{package}", PackageView) application.router.add_get("/api/v1/packages/{package}", PackageView)
application.router.add_post("/api/v1/packages/{package}", PackageView) application.router.add_post("/api/v1/packages/{package}", PackageView)
application.router.add_get("/api/v1/status", StatusView)

View File

@ -21,8 +21,7 @@ import aiohttp_jinja2
from typing import Any, Dict from typing import Any, Dict
import ahriman.version as version from ahriman import version
from ahriman.core.util import pretty_datetime from ahriman.core.util import pretty_datetime
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView

View File

@ -0,0 +1,45 @@
#
# Copyright (c) 2021 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 aiohttp.web import Response, json_response
from ahriman import version
from ahriman.models.counters import Counters
from ahriman.models.internal_status import InternalStatus
from ahriman.web.views.base import BaseView
class StatusView(BaseView):
"""
web service status web view
"""
async def get(self) -> Response:
"""
get current service status
:return: 200 with service status object
"""
counters = Counters.from_packages(self.service.packages)
status = InternalStatus(
architecture=self.service.architecture,
packages=counters,
repository=self.service.repository.name,
version=version.__version__)
return json_response(status.view())

View File

@ -1,4 +1,5 @@
import argparse import argparse
import aur
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
@ -7,6 +8,7 @@ from ahriman.application.ahriman import _parser
from ahriman.application.application import Application from ahriman.application.application import Application
from ahriman.application.lock import Lock from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.package import Package
@pytest.fixture @pytest.fixture
@ -20,6 +22,26 @@ def args() -> argparse.Namespace:
return argparse.Namespace(lock=None, force=False, unsafe=False, no_report=True) return argparse.Namespace(lock=None, force=False, unsafe=False, no_report=True)
@pytest.fixture
def aur_package_ahriman(package_ahriman: Package) -> aur.Package:
return aur.Package(
num_votes=None,
description=package_ahriman.packages[package_ahriman.base].description,
url_path=package_ahriman.web_url,
last_modified=None,
name=package_ahriman.base,
out_of_date=None,
id=None,
first_submitted=None,
maintainer=None,
version=package_ahriman.version,
license=package_ahriman.packages[package_ahriman.base].licenses,
url=None,
package_base=package_ahriman.base,
package_base_id=None,
category_id=None)
@pytest.fixture @pytest.fixture
def lock(args: argparse.Namespace, configuration: Configuration) -> Lock: def lock(args: argparse.Namespace, configuration: Configuration) -> Lock:
return Lock(args, "x86_64", configuration) return Lock(args, "x86_64", configuration)

View File

@ -6,6 +6,7 @@ from pytest_mock import MockerFixture
from ahriman.application.handlers import Handler from ahriman.application.handlers import Handler
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import MissingArchitecture
def test_call(args: argparse.Namespace, mocker: MockerFixture) -> None: def test_call(args: argparse.Namespace, mocker: MockerFixture) -> None:
@ -43,7 +44,52 @@ def test_execute(args: argparse.Namespace, mocker: MockerFixture) -> None:
starmap_mock.assert_called_once() starmap_mock.assert_called_once()
def test_packages(args: argparse.Namespace, configuration: Configuration) -> None: def test_extract_architectures(args: argparse.Namespace, mocker: MockerFixture) -> None:
"""
must generate list of available architectures
"""
args.architecture = []
args.configuration = Path("")
mocker.patch("ahriman.core.configuration.Configuration.getpath")
known_architectures_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.known_architectures")
Handler.extract_architectures(args)
known_architectures_mock.assert_called_once()
def test_extract_architectures_empty(args: argparse.Namespace, mocker: MockerFixture) -> None:
"""
must raise exception if no available architectures found
"""
args.architecture = []
args.command = "config"
args.configuration = Path("")
mocker.patch("ahriman.core.configuration.Configuration.getpath")
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.known_architectures", return_value=set())
with pytest.raises(MissingArchitecture):
Handler.extract_architectures(args)
def test_extract_architectures_exception(args: argparse.Namespace) -> None:
"""
must raise exception on missing architectures
"""
args.command = "config"
args.architecture = None
with pytest.raises(MissingArchitecture):
Handler.extract_architectures(args)
def test_extract_architectures_specified(args: argparse.Namespace) -> None:
"""
must return architecture list if it has been specified
"""
architectures = args.architecture = ["i686", "x86_64"]
assert Handler.extract_architectures(args) == set(architectures)
def test_run(args: argparse.Namespace, configuration: Configuration) -> None:
""" """
must raise NotImplemented for missing method must raise NotImplemented for missing method
""" """

View File

@ -0,0 +1,18 @@
import argparse
from pytest_mock import MockerFixture
from ahriman.application.handlers import Init
from ahriman.core.configuration import Configuration
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command
"""
create_tree_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.create_tree")
init_mock = mocker.patch("ahriman.core.alpm.repo.Repo.init")
Init.run(args, "x86_64", configuration)
create_tree_mock.assert_called_once()
init_mock.assert_called_once()

View File

@ -0,0 +1,24 @@
import argparse
from pytest_mock import MockerFixture
from ahriman.application.handlers import KeyImport
from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
args.key = "0xE989490C"
args.key_server = "keys.gnupg.net"
return args
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("pathlib.Path.mkdir")
application_mock = mocker.patch("ahriman.core.sign.gpg.GPG.import_key")
KeyImport.run(args, "x86_64", configuration)
application_mock.assert_called_once()

View File

@ -8,7 +8,7 @@ from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace: def _default_args(args: argparse.Namespace) -> argparse.Namespace:
args.depends_on = None args.depends_on = []
return args return args
@ -33,7 +33,7 @@ def test_run_filter(args: argparse.Namespace, configuration: Configuration,
must run command with depends filter must run command with depends filter
""" """
args = _default_args(args) args = _default_args(args)
args.depends_on = "python-aur" args.depends_on = ["python-aur"]
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
mocker.patch("ahriman.core.repository.repository.Repository.packages", mocker.patch("ahriman.core.repository.repository.Repository.packages",
return_value=[package_ahriman, package_python_schedule]) return_value=[package_ahriman, package_python_schedule])

View File

@ -0,0 +1,50 @@
import argparse
import aur
from pytest_mock import MockerFixture
from ahriman.application.handlers import Search
from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
args.search = ["ahriman"]
return args
def test_run(args: argparse.Namespace, configuration: Configuration, aur_package_ahriman: aur.Package,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("aur.search", return_value=[aur_package_ahriman])
log_mock = mocker.patch("ahriman.application.handlers.search.Search.log_fn")
Search.run(args, "x86_64", configuration)
log_mock.assert_called_once()
def test_run_multiple_search(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command with multiple search arguments
"""
args = _default_args(args)
args.search = ["ahriman", "is", "cool"]
search_mock = mocker.patch("aur.search")
Search.run(args, "x86_64", configuration)
search_mock.assert_called_with(" ".join(args.search))
def test_log_fn(args: argparse.Namespace, configuration: Configuration, aur_package_ahriman: aur.Package,
mocker: MockerFixture) -> None:
"""
log function must call print built-in
"""
args = _default_args(args)
mocker.patch("aur.search", return_value=[aur_package_ahriman])
print_mock = mocker.patch("builtins.print")
Search.run(args, "x86_64", configuration)
print_mock.assert_called() # we don't really care about call details tbh

View File

@ -29,9 +29,9 @@ def test_parser_option_lock(parser: argparse.ArgumentParser) -> None:
""" """
must convert lock option to Path instance must convert lock option to Path instance
""" """
args = parser.parse_args(["-a", "x86_64", "update"]) args = parser.parse_args(["update"])
assert isinstance(args.lock, Path) assert isinstance(args.lock, Path)
args = parser.parse_args(["-a", "x86_64", "-l", "ahriman.lock", "update"]) args = parser.parse_args(["-l", "ahriman.lock", "update"])
assert isinstance(args.lock, Path) assert isinstance(args.lock, Path)
@ -43,11 +43,20 @@ def test_multiple_architectures(parser: argparse.ArgumentParser) -> None:
assert len(args.architecture) == 2 assert len(args.architecture) == 2
def test_subparsers_add(parser: argparse.ArgumentParser) -> None:
"""
add command must imply empty architectures list
"""
args = parser.parse_args(["add", "ahriman"])
assert args.architecture == []
def test_subparsers_check(parser: argparse.ArgumentParser) -> None: def test_subparsers_check(parser: argparse.ArgumentParser) -> None:
""" """
check command must imply no_aur, no_manual and dry_run check command must imply empty architecture list, no-aur, no-manual and dry-run
""" """
args = parser.parse_args(["-a", "x86_64", "check"]) args = parser.parse_args(["check"])
assert args.architecture == []
assert not args.no_aur assert not args.no_aur
assert args.no_manual assert args.no_manual
assert args.dry_run assert args.dry_run
@ -55,29 +64,87 @@ def test_subparsers_check(parser: argparse.ArgumentParser) -> None:
def test_subparsers_clean(parser: argparse.ArgumentParser) -> None: def test_subparsers_clean(parser: argparse.ArgumentParser) -> None:
""" """
clean command must imply unsafe clean command must imply empty architectures list, unsafe and no-log
""" """
args = parser.parse_args(["-a", "x86_64", "clean"]) args = parser.parse_args(["clean"])
assert args.architecture == []
assert args.no_log
assert args.unsafe assert args.unsafe
def test_subparsers_config(parser: argparse.ArgumentParser) -> None: def test_subparsers_config(parser: argparse.ArgumentParser) -> None:
""" """
config command must imply lock, no_report and unsafe config command must imply lock, no-log, no-report and unsafe
""" """
args = parser.parse_args(["-a", "x86_64", "config"]) args = parser.parse_args(["config"])
assert args.lock is None assert args.lock is None
assert args.no_log
assert args.no_report
assert args.unsafe
def test_subparsers_init(parser: argparse.ArgumentParser) -> None:
"""
init command must imply no_report
"""
args = parser.parse_args(["init"])
assert args.no_report
def test_subparsers_key_import(parser: argparse.ArgumentParser) -> None:
"""
key-import command must imply architecture list, lock and no-report
"""
args = parser.parse_args(["key-import", "key"])
assert args.architecture == [""]
assert args.lock is None
assert args.no_report
def test_subparsers_rebuild(parser: argparse.ArgumentParser) -> None:
"""
rebuild command must imply empty architectures list
"""
args = parser.parse_args(["rebuild"])
assert args.architecture == []
def test_subparsers_remove(parser: argparse.ArgumentParser) -> None:
"""
remove command must imply empty architectures list
"""
args = parser.parse_args(["remove", "ahriman"])
assert args.architecture == []
def test_subparsers_report(parser: argparse.ArgumentParser) -> None:
"""
report command must imply empty architectures list
"""
args = parser.parse_args(["report"])
assert args.architecture == []
def test_subparsers_search(parser: argparse.ArgumentParser) -> None:
"""
search command must imply architecture list, lock, no-log, no-report and unsafe
"""
args = parser.parse_args(["search", "ahriman"])
assert args.architecture == [""]
assert args.lock is None
assert args.no_log
assert args.no_report assert args.no_report
assert args.unsafe assert args.unsafe
def test_subparsers_setup(parser: argparse.ArgumentParser) -> None: def test_subparsers_setup(parser: argparse.ArgumentParser) -> None:
""" """
setup command must imply lock, no_report and unsafe setup command must imply lock, no-log, no-report and unsafe
""" """
args = parser.parse_args(["-a", "x86_64", "setup", "--packager", "John Doe <john@doe.com>", args = parser.parse_args(["-a", "x86_64", "setup", "--packager", "John Doe <john@doe.com>",
"--repository", "aur-clone"]) "--repository", "aur-clone"])
assert args.lock is None assert args.lock is None
assert args.no_log
assert args.no_report assert args.no_report
assert args.unsafe assert args.unsafe
@ -104,22 +171,32 @@ def test_subparsers_setup_option_sign_target(parser: argparse.ArgumentParser) ->
assert all(isinstance(target, SignSettings) for target in args.sign_target) assert all(isinstance(target, SignSettings) for target in args.sign_target)
def test_subparsers_sign(parser: argparse.ArgumentParser) -> None:
"""
sign command must imply empty architectures list
"""
args = parser.parse_args(["sign"])
assert args.architecture == []
def test_subparsers_status(parser: argparse.ArgumentParser) -> None: def test_subparsers_status(parser: argparse.ArgumentParser) -> None:
""" """
status command must imply lock, no_report and unsafe status command must imply lock, no-log, no-report and unsafe
""" """
args = parser.parse_args(["-a", "x86_64", "status"]) args = parser.parse_args(["-a", "x86_64", "status"])
assert args.lock is None assert args.lock is None
assert args.no_log
assert args.no_report assert args.no_report
assert args.unsafe assert args.unsafe
def test_subparsers_status_update(parser: argparse.ArgumentParser) -> None: def test_subparsers_status_update(parser: argparse.ArgumentParser) -> None:
""" """
status-update command must imply lock, no_report and unsafe status-update command must imply lock, no-log, no-report and unsafe
""" """
args = parser.parse_args(["-a", "x86_64", "status-update"]) args = parser.parse_args(["-a", "x86_64", "status-update"])
assert args.lock is None assert args.lock is None
assert args.no_log
assert args.no_report assert args.no_report
assert args.unsafe assert args.unsafe
@ -134,6 +211,22 @@ def test_subparsers_status_update_option_status(parser: argparse.ArgumentParser)
assert isinstance(args.status, BuildStatusEnum) assert isinstance(args.status, BuildStatusEnum)
def test_subparsers_sync(parser: argparse.ArgumentParser) -> None:
"""
sync command must imply empty architectures list
"""
args = parser.parse_args(["sync"])
assert args.architecture == []
def test_subparsers_update(parser: argparse.ArgumentParser) -> None:
"""
update command must imply empty architectures list
"""
args = parser.parse_args(["update"])
assert args.architecture == []
def test_subparsers_web(parser: argparse.ArgumentParser) -> None: def test_subparsers_web(parser: argparse.ArgumentParser) -> None:
""" """
web command must imply lock and no_report web command must imply lock and no_report

View File

@ -5,9 +5,11 @@ from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest import mock from unittest import mock
from ahriman import version
from ahriman.application.lock import Lock from ahriman.application.lock import Lock
from ahriman.core.exceptions import DuplicateRun, UnsafeRun from ahriman.core.exceptions import DuplicateRun, UnsafeRun
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.internal_status import InternalStatus
def test_enter(lock: Lock, mocker: MockerFixture) -> None: def test_enter(lock: Lock, mocker: MockerFixture) -> None:
@ -15,6 +17,7 @@ def test_enter(lock: Lock, mocker: MockerFixture) -> None:
must process with context manager must process with context manager
""" """
check_user_mock = mocker.patch("ahriman.application.lock.Lock.check_user") check_user_mock = mocker.patch("ahriman.application.lock.Lock.check_user")
check_version_mock = mocker.patch("ahriman.application.lock.Lock.check_version")
clear_mock = mocker.patch("ahriman.application.lock.Lock.clear") clear_mock = mocker.patch("ahriman.application.lock.Lock.clear")
create_mock = mocker.patch("ahriman.application.lock.Lock.create") create_mock = mocker.patch("ahriman.application.lock.Lock.create")
update_status_mock = mocker.patch("ahriman.core.status.client.Client.update_self") update_status_mock = mocker.patch("ahriman.core.status.client.Client.update_self")
@ -24,6 +27,7 @@ def test_enter(lock: Lock, mocker: MockerFixture) -> None:
check_user_mock.assert_called_once() check_user_mock.assert_called_once()
clear_mock.assert_called_once() clear_mock.assert_called_once()
create_mock.assert_called_once() create_mock.assert_called_once()
check_version_mock.assert_called_once()
update_status_mock.assert_has_calls([ update_status_mock.assert_has_calls([
mock.call(BuildStatusEnum.Building), mock.call(BuildStatusEnum.Building),
mock.call(BuildStatusEnum.Success) mock.call(BuildStatusEnum.Success)
@ -48,6 +52,30 @@ def test_exit_with_exception(lock: Lock, mocker: MockerFixture) -> None:
]) ])
def test_check_version(lock: Lock, mocker: MockerFixture) -> None:
"""
must check version correctly
"""
mocker.patch("ahriman.core.status.client.Client.get_internal",
return_value=InternalStatus(version=version.__version__))
logging_mock = mocker.patch("logging.Logger.warning")
lock.check_version()
logging_mock.assert_not_called()
def test_check_version_mismatch(lock: Lock, mocker: MockerFixture) -> None:
"""
must check version correctly
"""
mocker.patch("ahriman.core.status.client.Client.get_internal",
return_value=InternalStatus(version="version"))
logging_mock = mocker.patch("logging.Logger.warning")
lock.check_version()
logging_mock.assert_called_once()
def test_check_user(lock: Lock, mocker: MockerFixture) -> None: def test_check_user(lock: Lock, mocker: MockerFixture) -> None:
""" """
must check user correctly must check user correctly

View File

@ -8,3 +8,10 @@ def test_all_packages(pacman: Pacman) -> None:
packages = pacman.all_packages() packages = pacman.all_packages()
assert packages assert packages
assert "pacman" in packages assert "pacman" in packages
def test_all_packages_with_provides(pacman: Pacman) -> None:
"""
package list must contain provides packages
"""
assert 'sh' in pacman.all_packages()

View File

@ -24,6 +24,17 @@ def test_repo_add(repo: Repo, mocker: MockerFixture) -> None:
assert check_output_mock.call_args[0][0] == "repo-add" assert check_output_mock.call_args[0][0] == "repo-add"
def test_repo_init(repo: Repo, mocker: MockerFixture) -> None:
"""
must call repo-add with empty package list on repo initializing
"""
check_output_mock = mocker.patch("ahriman.core.alpm.repo.Repo._check_output")
repo.init()
check_output_mock.assert_called_once()
assert check_output_mock.call_args[0][0] == "repo-add"
def test_repo_remove(repo: Repo, mocker: MockerFixture) -> None: def test_repo_remove(repo: Repo, mocker: MockerFixture) -> None:
""" """
must call repo-remove on package addition must call repo-remove on package addition

View File

@ -103,3 +103,28 @@ def test_generate_with_built(configuration: Configuration, package_ahriman: Pack
report = Email("x86_64", configuration) report = Email("x86_64", configuration)
report.generate([package_ahriman], [package_ahriman]) report.generate([package_ahriman], [package_ahriman])
send_mock.assert_called_once() send_mock.assert_called_once()
def test_generate_no_empty(configuration: Configuration, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must not generate report with built packages if no_empty_report is set
"""
configuration.set("email", "no_empty_report", "yes")
send_mock = mocker.patch("ahriman.core.report.email.Email._send")
report = Email("x86_64", configuration)
report.generate([package_ahriman], [])
send_mock.assert_not_called()
def test_generate_no_empty_with_built(configuration: Configuration, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must generate report with built packages if no_empty_report is set
"""
configuration.set("email", "no_empty_report", "yes")
send_mock = mocker.patch("ahriman.core.report.email.Email._send")
report = Email("x86_64", configuration)
report.generate([package_ahriman], [package_ahriman])
send_mock.assert_called_once()

View File

@ -1,5 +1,9 @@
import pytest
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 ahriman.core.sign.gpg import GPG from ahriman.core.sign.gpg import GPG
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@ -60,6 +64,38 @@ def test_sign_command(gpg_with_key: GPG) -> None:
assert gpg_with_key.sign_command(Path("a"), gpg_with_key.default_key) assert gpg_with_key.sign_command(Path("a"), gpg_with_key.default_key)
def test_download_key(gpg: GPG, mocker: MockerFixture) -> None:
"""
must download the key from public server
"""
requests_mock = mocker.patch("requests.get")
gpg.download_key("keys.gnupg.net", "0xE989490C")
requests_mock.assert_called_once()
def test_download_key_failure(gpg: GPG, mocker: MockerFixture) -> None:
"""
must download the key from public server and log error if any (and raise it again)
"""
mocker.patch("requests.get", side_effect=requests.exceptions.HTTPError())
with pytest.raises(requests.exceptions.HTTPError):
gpg.download_key("keys.gnupg.net", "0xE989490C")
def test_import_key(gpg: GPG, mocker: MockerFixture) -> None:
"""
must import PGP key from the server
"""
mocker.patch("ahriman.core.sign.gpg.GPG.download_key", return_value="key")
check_output_mock = mocker.patch("ahriman.core.sign.gpg.GPG._check_output")
gpg.import_key("keys.gnupg.net", "0xE989490C")
check_output_mock.assert_has_calls([
mock.call("gpg", "--import", input_data="key", exception=None, logger=pytest.helpers.anyvar(int)),
mock.call("gpg", "--quick-lsign-key", "0xE989490C", exception=None, logger=pytest.helpers.anyvar(int))
])
def test_process(gpg_with_key: GPG, mocker: MockerFixture) -> None: def test_process(gpg_with_key: GPG, mocker: MockerFixture) -> None:
""" """
must call process method correctly must call process method correctly

View File

@ -4,6 +4,7 @@ from ahriman.core.configuration import Configuration
from ahriman.core.status.client import Client from ahriman.core.status.client import Client
from ahriman.core.status.web_client import WebClient from ahriman.core.status.web_client import WebClient
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.internal_status import InternalStatus
from ahriman.models.package import Package from ahriman.models.package import Package
@ -38,6 +39,13 @@ def test_get(client: Client, package_ahriman: Package) -> None:
assert client.get(None) == [] assert client.get(None) == []
def test_get_internal(client: Client) -> None:
"""
must return dummy status for web service
"""
assert client.get_internal() == InternalStatus()
def test_get_self(client: Client) -> None: def test_get_self(client: Client) -> None:
""" """
must return unknown status for service must return unknown status for service

View File

@ -7,6 +7,7 @@ from requests import Response
from ahriman.core.status.web_client import WebClient from ahriman.core.status.web_client import WebClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.internal_status import InternalStatus
from ahriman.models.package import Package from ahriman.models.package import Package
@ -26,6 +27,14 @@ def test_package_url(web_client: WebClient, package_ahriman: Package) -> None:
assert web_client._package_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}") assert web_client._package_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}")
def test_status_url(web_client: WebClient) -> None:
"""
must generate service status url correctly
"""
assert web_client._status_url().startswith(f"http://{web_client.host}:{web_client.port}")
assert web_client._status_url().endswith("/api/v1/status")
def test_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: def test_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must process package addition must process package addition
@ -103,6 +112,37 @@ def test_get_single(web_client: WebClient, package_ahriman: Package, mocker: Moc
assert (package_ahriman, BuildStatusEnum.Unknown) in [(package, status.status) for package, status in result] assert (package_ahriman, BuildStatusEnum.Unknown) in [(package, status.status) for package, status in result]
def test_get_internal(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must return web service status
"""
response_obj = Response()
response_obj._content = json.dumps(InternalStatus(architecture="x86_64").view()).encode("utf8")
response_obj.status_code = 200
requests_mock = mocker.patch("requests.get", return_value=response_obj)
result = web_client.get_internal()
requests_mock.assert_called_once()
assert result.architecture == "x86_64"
def test_get_internal_failed(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during web service status getting
"""
mocker.patch("requests.get", side_effect=Exception())
assert web_client.get_internal() == InternalStatus()
def test_get_internal_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during web service status getting
"""
mocker.patch("requests.get", side_effect=requests.exceptions.HTTPError())
assert web_client.get_internal() == InternalStatus()
def test_get_self(web_client: WebClient, mocker: MockerFixture) -> None: def test_get_self(web_client: WebClient, mocker: MockerFixture) -> None:
""" """
must return service status must return service status

View File

@ -113,6 +113,14 @@ def test_load_includes_no_option(configuration: Configuration) -> None:
configuration.load_includes() configuration.load_includes()
def test_load_includes_no_section(configuration: Configuration) -> None:
"""
must not fail if no option set
"""
configuration.remove_section("settings")
configuration.load_includes()
def test_load_logging_fallback(configuration: Configuration, mocker: MockerFixture) -> None: def test_load_logging_fallback(configuration: Configuration, mocker: MockerFixture) -> None:
""" """
must fallback to stderr without errors must fallback to stderr without errors

View File

@ -2,7 +2,10 @@ import pytest
from unittest.mock import MagicMock, PropertyMock from unittest.mock import MagicMock, PropertyMock
from ahriman import version
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.counters import Counters
from ahriman.models.internal_status import InternalStatus
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_description import PackageDescription from ahriman.models.package_description import PackageDescription
@ -12,6 +15,24 @@ def build_status_failed() -> BuildStatus:
return BuildStatus(BuildStatusEnum.Failed, 42) return BuildStatus(BuildStatusEnum.Failed, 42)
@pytest.fixture
def counters() -> Counters:
return Counters(total=10,
unknown=1,
pending=2,
building=3,
failed=4,
success=0)
@pytest.fixture
def internal_status(counters: Counters) -> InternalStatus:
return InternalStatus(architecture="x86_64",
packages=counters,
version=version.__version__,
repository="aur-clone")
@pytest.fixture @pytest.fixture
def package_tpacpi_bat_git() -> Package: def package_tpacpi_bat_git() -> Package:
return Package( return Package(
@ -48,5 +69,6 @@ def pyalpm_package_description_ahriman(package_description_ahriman: PackageDescr
type(mock).isize = PropertyMock(return_value=package_description_ahriman.installed_size) type(mock).isize = PropertyMock(return_value=package_description_ahriman.installed_size)
type(mock).licenses = PropertyMock(return_value=package_description_ahriman.licenses) type(mock).licenses = PropertyMock(return_value=package_description_ahriman.licenses)
type(mock).size = PropertyMock(return_value=package_description_ahriman.archive_size) type(mock).size = PropertyMock(return_value=package_description_ahriman.archive_size)
type(mock).provides = PropertyMock(return_value=package_description_ahriman.provides)
type(mock).url = PropertyMock(return_value=package_description_ahriman.url) type(mock).url = PropertyMock(return_value=package_description_ahriman.url)
return mock return mock

View File

@ -0,0 +1,31 @@
from dataclasses import asdict
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.counters import Counters
from ahriman.models.package import Package
def test_counters_from_json_view(counters: Counters) -> None:
"""
must construct same object from json
"""
assert Counters.from_json(asdict(counters)) == counters
def test_counters_from_packages(package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must construct object from list of packages with their statuses
"""
payload = [
(package_ahriman, BuildStatus(status=BuildStatusEnum.Success)),
(package_python_schedule, BuildStatus(status=BuildStatusEnum.Failed)),
]
counters = Counters.from_packages(payload)
assert counters.total == 2
assert counters.success == 1
assert counters.failed == 1
json = asdict(counters)
total = json.pop("total")
assert total == sum(i for i in json.values())

View File

@ -0,0 +1,8 @@
from ahriman.models.internal_status import InternalStatus
def test_internal_status_from_json_view(internal_status: InternalStatus) -> None:
"""
must construct same object from json
"""
assert InternalStatus.from_json(internal_status.view()) == internal_status

View File

@ -4,6 +4,15 @@ from unittest import mock
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
def test_known_architectures(repository_paths: RepositoryPaths, mocker: MockerFixture) -> None:
"""
must list available directory paths
"""
iterdir_mock = mocker.patch("pathlib.Path.iterdir")
repository_paths.known_architectures(repository_paths.root)
iterdir_mock.assert_called_once()
def test_create_tree(repository_paths: RepositoryPaths, mocker: MockerFixture) -> None: def test_create_tree(repository_paths: RepositoryPaths, mocker: MockerFixture) -> None:
""" """
must create whole tree must create whole tree
@ -11,7 +20,7 @@ def test_create_tree(repository_paths: RepositoryPaths, mocker: MockerFixture) -
paths = { paths = {
prop prop
for prop in dir(repository_paths) for prop in dir(repository_paths)
if not prop.startswith("_") and prop not in ("architecture", "create_tree", "root") if not prop.startswith("_") and prop not in ("architecture", "create_tree", "known_architectures", "root")
} }
mkdir_mock = mocker.patch("pathlib.Path.mkdir") mkdir_mock = mocker.patch("pathlib.Path.mkdir")

View File

@ -0,0 +1,22 @@
from pytest_aiohttp import TestClient
import ahriman.version as version
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
async def test_get(client: TestClient, package_ahriman: Package) -> None:
"""
must generate web service status correctly
"""
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
response = await client.get("/api/v1/status")
assert response.status == 200
json = await response.json()
assert json["version"] == version.__version__
assert json["packages"]
assert json["packages"]["total"] == 1

View File

@ -28,6 +28,7 @@ target =
[email] [email]
host = 0.0.0.0 host = 0.0.0.0
link_path = link_path =
no_empty_report = no
port = 587 port = 587
receivers = mail@example.com receivers = mail@example.com
sender = mail@example.com sender = mail@example.com