Compare commits

...

34 Commits

Author SHA1 Message Date
91f66fdcee Release 1.2.3 2021-08-19 00:18:12 +03:00
bb45b1d868 split S3.sync to different methods 2021-08-18 23:59:18 +03:00
3d10fa472b guess mime type for local files 2021-08-18 05:04:26 +03:00
a90c93bbc4 add manpage generator 2021-08-17 04:05:18 +03:00
41a3c08d9f Release 1.2.2 2021-08-17 01:03:04 +03:00
cb328ad797 fix typo in log naming 2021-08-17 01:02:42 +03:00
810091cde9 Release 1.2.1 2021-08-17 00:52:09 +03:00
fc0474fa8f logging rethink
* well lets replace f-strings by %s as it is originally recommended
* use syslog handler by default
2021-08-17 00:23:34 +03:00
b94179e071 use asyncmock from unittest library 2021-08-11 21:09:10 +03:00
9c5a9f5837 Release 1.2.0 2021-08-11 05:02:23 +03:00
83047d8270 cleanup and speedup runs 2021-08-11 04:59:45 +03:00
990d5dda81 use nosec instead of disabling mktemp rule 2021-08-11 02:51:29 +03:00
48e79ce39c add bandit integration and fix its warnings 2021-08-11 02:45:13 +03:00
375d7c55e5 web templates improvements
* enable jinja autoescape by default for jinja raw generator
* allow to search by multiple strings (OR)
* replace test templates by symlink
2021-08-11 02:12:14 +03:00
db52b8e844 move web server to loopback by default 2021-08-11 02:02:18 +03:00
50af309c80 add docstrings for every fixture and test methods
also add tests for missing components
2021-08-11 01:55:27 +03:00
581401d60f skip update process if no update supplied 2021-08-10 23:25:12 +03:00
c2685f4746 Native s3 sync (#23)
* Native S3 sync implementation

* fix imports

* fix paths reading

* install s3 components duriing test stage
2021-08-10 23:18:56 +03:00
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
112 changed files with 1904 additions and 487 deletions

1
.bandit-test.yml Normal file
View File

@ -0,0 +1 @@
skips: ['B101', 'B404']

1
.bandit.yml Normal file
View File

@ -0,0 +1 @@
skips: ['B404', 'B603']

View File

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

View File

@ -1,5 +1,4 @@
# based on https://github.com/actions/starter-workflows/blob/main/ci/python-app.yml
name: check commit
name: tests
on:
push:
@ -19,8 +18,9 @@ jobs:
docker run \
-v ${{ github.workspace }}:/build -w /build \
archlinux:latest \
/bin/bash -c "pacman --noconfirm -Syu base-devel python python-pip && \
/bin/bash -c "pacman --noconfirm -Syu base-devel python-argparse-manpage python-pip && \
pip install -e .[web] && \
pip install -e .[check] && \
pip install -e .[s3] && \
pip install -e .[test] && \
make check tests"

2
.gitignore vendored
View File

@ -94,3 +94,5 @@ ENV/
.venv/
*.tar.xz
man/

View File

@ -22,7 +22,7 @@ ignore-patterns=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use.
jobs=1
jobs=0
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
@ -149,7 +149,6 @@ disable=print-statement,
too-few-public-methods,
too-many-instance-attributes,
broad-except,
logging-fstring-interpolation,
too-many-ancestors,
fixme,
too-many-arguments,

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.
* `host` - SMTP host for sending emails, 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.
* `port` - SMTP port for sending emails, int, required.
* `receivers` - SMTP receiver addresses, space separated list of strings, required.
@ -88,10 +89,13 @@ Group name must refer to architecture, e.g. it should be `rsync:x86_64` for x86_
### `s3:*` groups
Group name must refer to architecture, e.g. it should be `s3:x86_64` for x86_64 architecture. Requires `aws-cli` package to be installed. Do not forget to configure it for user `ahriman`.
Group name must refer to architecture, e.g. it should be `s3:x86_64` for x86_64 architecture.
* `command` - s3 command to run, space separated list of string, required.
* `bucket` - bucket name (e.g. `s3://bucket/path`), string, required.
* `access_key` - AWS access key ID, string, required.
* `bucket` - bucket name (e.g. `bucket`), string, required.
* `chunk_size` - chunk size for calculating entity tags, int, optional, default 8 * 1024 * 1024.
* `region` - bucket region (e.g. `eu-central-1`), string, required.
* `secret_key` - AWS secret access key, string, required.
## `web:*` groups

View File

@ -23,10 +23,11 @@ archive_directory: $(TARGET_FILES)
archlinux: archive
sed -i "s/pkgver=[0-9.]*/pkgver=$(VERSION)/" package/archlinux/PKGBUILD
check: clean
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 {} +
cd src && pylint --rcfile=../.pylintrc "$(PROJECT)"
check: clean mypy
autopep8 --exit-code --max-line-length 120 -aa -i -j 0 -r "src/$(PROJECT)" "tests/$(PROJECT)"
pylint --rcfile=.pylintrc "src/$(PROJECT)"
bandit -c .bandit.yml -r "src/$(PROJECT)"
bandit -c .bandit-test.yml -r "tests/$(PROJECT)"
clean:
find . -type f -name "$(PROJECT)-*-src.tar.xz" -delete
@ -35,6 +36,10 @@ clean:
directory: clean
mkdir "$(PROJECT)"
mypy:
cd src && mypy --implicit-reexport --strict -p "$(PROJECT)" --install-types --non-interactive || true
cd src && mypy --implicit-reexport --strict -p "$(PROJECT)"
push: archlinux
git add package/archlinux/PKGBUILD src/ahriman/version.py
git commit -m "Release $(VERSION)"

View File

@ -1,6 +1,7 @@
# ArcHlinux ReposItory MANager
[![build status](https://github.com/arcan1s/ahriman/actions/workflows/run-tests.yml/badge.svg)](https://github.com/arcan1s/ahriman/actions/workflows/run-tests.yml)
[![CodeFactor](https://www.codefactor.io/repository/github/arcan1s/ahriman/badge)](https://www.codefactor.io/repository/github/arcan1s/ahriman)
Wrapper for managing custom repository inspired by [repo-scripts](https://github.com/arcan1s/repo-scripts).

View File

@ -1,31 +1,27 @@
# Maintainer: Evgeniy Alekseev
pkgname='ahriman'
pkgver=0.22.0
pkgver=1.2.3
pkgrel=1
pkgdesc="ArcHlinux ReposItory MANager"
arch=('any')
url="https://github.com/arcan1s/ahriman"
license=('GPL3')
depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-srcinfo')
makedepends=('python-pip')
optdepends=('aws-cli: sync to s3'
'breezy: -bzr packages support'
makedepends=('python-argparse-manpage' 'python-pip')
optdepends=('breezy: -bzr packages support'
'darcs: -darcs packages support'
'gnupg: package and repository sign'
'mercurial: -hg packages support'
'python-aiohttp: web server'
'python-aiohttp-jinja2: web server'
'python-boto3: sync to s3'
'python-jinja: html report generation'
'python-requests: web server'
'rsync: sync by using rsync'
'subversion: -svn packages support')
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz"
'ahriman.sysusers'
'ahriman.tmpfiles')
sha512sums=('6ab741bfb42f92ab00d1b6ecfc44426c00e5c433486e014efbdb585715d9a12dbbafc280e5a9f85b941c8681b13a9dad41327a3e3c44a9683ae30c1d6f017f50'
'13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075'
'55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4')
backup=('etc/ahriman.ini'
'etc/ahriman.ini.d/logging.ini')
@ -43,3 +39,7 @@ package() {
install -Dm644 "$srcdir/$pkgname.sysusers" "$pkgdir/usr/lib/sysusers.d/$pkgname.conf"
install -Dm644 "$srcdir/$pkgname.tmpfiles" "$pkgdir/usr/lib/tmpfiles.d/$pkgname.conf"
}
sha512sums=('6ab741bfb42f92ab00d1b6ecfc44426c00e5c433486e014efbdb585715d9a12dbbafc280e5a9f85b941c8681b13a9dad41327a3e3c44a9683ae30c1d6f017f50'
'13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075'
'55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4')

View File

@ -1,2 +1 @@
d /var/lib/ahriman 0775 ahriman log
d /var/log/ahriman 0755 ahriman ahriman

View File

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

View File

@ -2,10 +2,10 @@
keys = root,builder,build_details,http
[handlers]
keys = console_handler,build_file_handler,file_handler,http_handler
keys = console_handler,build_file_handler,file_handler,http_handler,syslog_handler
[formatters]
keys = generic_format
keys = generic_format,syslog_format
[handler_console_handler]
class = StreamHandler
@ -31,29 +31,39 @@ level = DEBUG
formatter = generic_format
args = ("/var/log/ahriman/http.log", "a", 20971520, 20)
[handler_syslog_handler]
class = logging.handlers.SysLogHandler
level = DEBUG
formatter = syslog_format
args = ("/dev/log",)
[formatter_generic_format]
format = [%(levelname)s %(asctime)s] [%(filename)s:%(lineno)d] [%(funcName)s]: %(message)s
datefmt =
[formatter_syslog_format]
format = [%(levelname)s] [%(filename)s:%(lineno)d] [%(funcName)s]: %(message)s
datefmt =
[logger_root]
level = DEBUG
handlers = file_handler
handlers = syslog_handler
qualname = root
[logger_builder]
level = DEBUG
handlers = file_handler
handlers = syslog_handler
qualname = builder
propagate = 0
[logger_build_details]
level = DEBUG
handlers = build_file_handler
handlers = syslog_handler
qualname = build_details
propagate = 0
[logger_http]
level = DEBUG
handlers = http_handler
handlers = syslog_handler
qualname = http
propagate = 0

View File

@ -1,7 +1,7 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ repository|e }}</title>
<title>{{ repository }}</title>
{% include "style.jinja2" %}
@ -12,9 +12,9 @@
<body>
<div class="root">
<h1>ahriman
<img src="https://img.shields.io/badge/version-{{ version|e }}-informational" alt="{{ version|e }}">
<img src="https://img.shields.io/badge/architecture-{{ architecture|e }}-informational" alt="{{ architecture|e }}">
<img src="https://img.shields.io/badge/service%20status-{{ service.status|e }}-{{ service.status_color|e }}" alt="{{ service.status|e }}" title="{{ service.timestamp|e }}">
<img src="https://img.shields.io/badge/version-{{ version }}-informational" alt="{{ version }}">
<img src="https://img.shields.io/badge/architecture-{{ architecture }}-informational" alt="{{ architecture }}">
<img src="https://img.shields.io/badge/service%20status-{{ service.status }}-{{ service.status_color }}" alt="{{ service.status }}" title="{{ service.timestamp }}">
</h1>
{% include "search-line.jinja2" %}
@ -31,11 +31,11 @@
{% for package in packages %}
<tr class="package">
<td class="include-search"><a href="{{ package.web_url|e }}" title="{{ package.base|e }}">{{ package.base|e }}</a></td>
<td class="include-search"><a href="{{ package.web_url }}" title="{{ package.base }}">{{ package.base }}</a></td>
<td class="include-search">{{ package.packages|join("<br>"|safe) }}</td>
<td>{{ package.version|e }}</td>
<td>{{ package.timestamp|e }}</td>
<td class="status package-{{ package.status|e }}">{{ package.status|e }}</td>
<td>{{ package.version }}</td>
<td>{{ package.timestamp }}</td>
<td class="status package-{{ package.status }}">{{ package.status }}</td>
</tr>
{% endfor %}
</table>

View File

@ -1,7 +1,7 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ repository|e }}</title>
<title>{{ repository }}</title>
{% include "style.jinja2" %}
@ -18,20 +18,20 @@
<section class="element">
{% if pgp_key is not none %}
<p>This repository is signed with <a href="http://keys.gnupg.net/pks/lookup?search=0x{{ pgp_key|e }}&fingerprint=on&op=index" title="key search">{{ pgp_key|e }}</a> by default.</p>
<p>This repository is signed with <a href="http://keys.gnupg.net/pks/lookup?search=0x{{ pgp_key }}&fingerprint=on&op=index" title="key search">{{ pgp_key }}</a> by default.</p>
{% endif %}
<code>
$ cat /etc/pacman.conf<br>
[{{ repository|e }}]<br>
Server = {{ link_path|e }}<br>
[{{ repository }}]<br>
Server = {{ link_path }}<br>
SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Package{% if has_package_signed %}Required{% else %}Never{% endif %} TrustedOnly
</code>
</section>
{% include "search-line.jinja2" %}
{% endif %}
{% include "search-line.jinja2" %}
<section class="element">
<table class="sortable search-table">
<tr class="header">
@ -44,11 +44,11 @@
{% for package in packages %}
<tr class="package">
<td class="include-search"><a href="{{ link_path|e }}/{{ package.filename|e }}" title="{{ package.name|e }}">{{ package.name|e }}</a></td>
<td>{{ package.version|e }}</td>
<td>{{ package.archive_size|e }}</td>
<td>{{ package.installed_size|e }}</td>
<td>{{ package.build_date|e }}</td>
<td class="include-search"><a href="{{ link_path }}/{{ package.filename }}" title="{{ package.name }}">{{ package.name }}</a></td>
<td>{{ package.version }}</td>
<td>{{ package.archive_size }}</td>
<td>{{ package.installed_size }}</td>
<td>{{ package.build_date }}</td>
</tr>
{% endfor %}
</table>
@ -58,7 +58,7 @@
<footer>
<ul class="navigation">
{% if homepage is not none %}
<li><a href="{{ homepage|e }}" title="homepage">Homepage</a></li>
<li><a href="{{ homepage }}" title="homepage">Homepage</a></li>
{% endif %}
</ul>
</footer>

View File

@ -1,3 +1,3 @@
<section class="element">
<input type="search" id="search" onkeyup="searchInTable()" placeholder="search for package" title="search for package"/>
</section>
</section>

View File

@ -5,21 +5,22 @@
const tables = document.getElementsByClassName("search-table");
for (let i = 0; i < tables.length; i++) {
const tr = tables[i].getElementsByTagName("tr");
const trs = tables[i].getElementsByTagName("tr");
// from 1 coz of header
for (let i = 1; i < tr.length; i++) {
let td = tr[i].getElementsByClassName("include-search");
for (let i = 1; i < trs.length; i++) {
let tr = trs[i].getElementsByClassName("include-search");
let display = "none";
for (let j = 0; j < td.length; j++) {
if (td[j].tagName.toLowerCase() === "td") {
if (td[j].innerHTML.toLowerCase().indexOf(filter) > -1) {
for (let j = 0; j < tr.length; j++) {
if (tr[j].tagName.toLowerCase() === "td") {
let contains = (element) => tr[j].innerHTML.toLowerCase().indexOf(element) > -1
if (filter.some(contains)) {
display = "";
break;
}
}
}
tr[i].style.display = display;
trs[i].style.display = display;
}
}
}
</script>
</script>

View File

@ -1 +1 @@
<script src="https://www.kryogenix.org/code/browser/sorttable/sorttable.js"></script>
<script src="https://www.kryogenix.org/code/browser/sorttable/sorttable.js"></script>

View File

@ -133,4 +133,4 @@
ul.navigation li a:hover {
background-color: rgba(var(--color-hover), 1.0);
}
</style>
</style>

View File

@ -3,3 +3,6 @@ test = pytest
[tool:pytest]
addopts = --cov=ahriman --cov-report term-missing:skip-covered --pspec
[build_manpages]
manpages = man/ahriman.1:module=ahriman.application.ahriman:function=_parser

View File

@ -1,11 +1,16 @@
from distutils.util import convert_path
from build_manpages.build_manpages import build_manpages, get_build_py_cmd, get_install_cmd
from pathlib import Path
from setuptools import setup, find_packages
from os import path
from setuptools.command.build_py import build_py
from setuptools.command.install import install
from typing import Any, Dict
metadata_path = Path(__file__).resolve().parent / "src/ahriman/version.py"
metadata: Dict[str, Any] = dict()
with metadata_path.open() as metadata_file:
exec(metadata_file.read(), metadata) # pylint: disable=exec-used
here = path.abspath(path.dirname(__file__))
metadata = dict()
with open(convert_path("src/ahriman/version.py")) as metadata_file:
exec(metadata_file.read(), metadata)
setup(
name="ahriman",
@ -29,6 +34,7 @@ setup(
install_requires=[
"aur",
"pyalpm",
"requests",
"srcinfo",
],
setup_requires=[
@ -73,9 +79,13 @@ setup(
extras_require={
"check": [
"autopep8",
"bandit",
"mypy",
"pylint",
],
"s3": [
"boto3",
],
"test": [
"pytest",
"pytest-aiohttp",
@ -89,7 +99,12 @@ setup(
"Jinja2",
"aiohttp",
"aiohttp_jinja2",
"requests",
],
},
cmdclass={
"build_manpages": build_manpages,
"build_py": get_build_py_cmd(build_py),
"install": get_install_cmd(install),
}
)

View File

@ -19,19 +19,18 @@
#
import argparse
import sys
import tempfile
from pathlib import Path
import ahriman.application.handlers as handlers
import ahriman.version as version
from ahriman import version
from ahriman.application import handlers
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.sign_settings import SignSettings
# pylint thinks it is bad idea, but get the fuck off
# pylint: disable=protected-access
SubParserAction = argparse._SubParsersAction
SubParserAction = argparse._SubParsersAction # pylint: disable=protected-access
def _parser() -> argparse.ArgumentParser:
@ -42,10 +41,17 @@ def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="ahriman", description="ArcHlinux ReposItory MANager",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
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("--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(
tempfile.gettempdir()) /
"ahriman.lock")
parser.add_argument("--no-log", help="redirect all log messages to stderr", action="store_true")
parser.add_argument("--no-report", help="force disable reporting to web service", action="store_true")
parser.add_argument("--unsafe", help="allow to run ahriman as non-ahriman user", action="store_true")
@ -57,9 +63,12 @@ def _parser() -> argparse.ArgumentParser:
_set_check_parser(subparsers)
_set_clean_parser(subparsers)
_set_config_parser(subparsers)
_set_init_parser(subparsers)
_set_key_import_parser(subparsers)
_set_rebuild_parser(subparsers)
_set_remove_parser(subparsers)
_set_report_parser(subparsers)
_set_search_parser(subparsers)
_set_setup_parser(subparsers)
_set_sign_parser(subparsers)
_set_status_parser(subparsers)
@ -82,7 +91,7 @@ def _set_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
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("--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
@ -97,7 +106,7 @@ def _set_check_parser(root: SubParserAction) -> argparse.ArgumentParser:
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
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.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
@ -114,7 +123,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-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.set_defaults(handler=handlers.Clean, unsafe=True)
parser.set_defaults(handler=handlers.Clean, architecture=[], no_log=True, unsafe=True)
return parser
@ -127,7 +136,35 @@ def _set_config_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("config", help="dump configuration",
description="dump configuration for specified architecture",
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
@ -139,8 +176,8 @@ def _set_rebuild_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
parser = root.add_parser("rebuild", help="rebuild repository", description="rebuild whole repository",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--depends-on", help="only rebuild packages that depend on specified package")
parser.set_defaults(handler=handlers.Rebuild)
parser.add_argument("--depends-on", help="only rebuild packages that depend on specified package", action="append")
parser.set_defaults(handler=handlers.Rebuild, architecture=[])
return parser
@ -153,7 +190,7 @@ def _set_remove_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("remove", help="remove package", description="remove package",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
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
@ -166,7 +203,19 @@ def _set_report_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("report", help="generate report", description="generate report",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
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
@ -187,9 +236,9 @@ def _set_setup_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("--repository", help="repository name", required=True)
parser.add_argument("--sign-key", help="sign key id")
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.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
@ -202,7 +251,7 @@ def _set_sign_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("sign", help="sign packages", description="(re-)sign packages and repository database",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
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
@ -216,7 +265,7 @@ def _set_status_parser(root: SubParserAction) -> argparse.ArgumentParser:
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--ahriman", help="get service status itself", action="store_true")
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
@ -235,7 +284,7 @@ def _set_status_update_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("--status", help="new status", choices=BuildStatusEnum,
type=BuildStatusEnum, default=BuildStatusEnum.Success)
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
@ -248,7 +297,7 @@ def _set_sync_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("sync", help="sync repository", description="sync packages to remote server",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("target", help="target to sync", nargs="*")
parser.set_defaults(handler=handlers.Sync)
parser.set_defaults(handler=handlers.Sync, architecture=[])
return parser
@ -265,7 +314,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-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.set_defaults(handler=handlers.Update)
parser.set_defaults(handler=handlers.Update, architecture=[])
return parser

View File

@ -65,8 +65,10 @@ class Application:
"""
known_packages: Set[str] = set()
# local set
for package in self.repository.packages():
known_packages.update(package.packages.keys())
for base in self.repository.packages():
for package, properties in base.packages.items():
known_packages.add(package)
known_packages.update(properties.provides)
known_packages.update(self.repository.pacman.all_packages())
return known_packages
@ -183,7 +185,7 @@ class Application:
continue
for archive in package.packages.values():
if archive.filepath is None:
self.logger.warning(f"filepath is empty for {package.base}")
self.logger.warning("filepath is empty for %s", package.base)
continue # avoid mypy warning
src = self.repository.paths.repository / archive.filepath
dst = self.repository.paths.packages / archive.filepath
@ -209,6 +211,8 @@ class Application:
:param updates: list of packages to update
"""
def process_update(paths: Iterable[Path]) -> None:
if not paths:
return # don't need to process if no update supplied
updated = [Package.load(path, self.repository.pacman, self.repository.aur_url) for path in paths]
self.repository.process_update(paths)
self._finalize(updated)
@ -220,6 +224,6 @@ class Application:
# process manual packages
tree = Tree.load(updates)
for num, level in enumerate(tree.levels()):
self.logger.info(f"processing level #{num} {[package.base for package in level]}")
self.logger.info("processing level #%i %s", num, [package.base for package in level])
packages = self.repository.process_build(level)
process_update(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.clean import Clean
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.remove import Remove
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.sign import Sign
from ahriman.application.handlers.status import Status

View File

@ -23,10 +23,12 @@ import argparse
import logging
from multiprocessing import Pool
from typing import Type
from typing import Set, Type
from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import MissingArchitecture
from ahriman.models.repository_paths import RepositoryPaths
class Handler:
@ -58,11 +60,33 @@ class Handler:
:param args: command line args
: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(
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
@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
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 configuration: configuration instance
"""
depends_on = set(args.depends_on) if args.depends_on else None
application = Application(architecture, configuration)
packages = [
package
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
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
import argparse
import logging
import os
from pathlib import Path
from types import TracebackType
from typing import Literal, Optional, Type
from ahriman import version
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import DuplicateRun, UnsafeRun
from ahriman.core.status.client import Client
@ -61,12 +63,13 @@ class Lock:
default workflow is the following:
check user UID
remove lock file if force flag is set
check if there is lock file
check web status watcher status
create lock file
report to web if enabled
"""
self.check_user()
self.check_version()
self.create()
self.reporter.update_self(BuildStatusEnum.Building)
return self
@ -85,6 +88,17 @@ class Lock:
self.reporter.update_self(status)
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(
"status watcher version mismatch, our %s, their %s",
version.__version__,
status.version)
def check_user(self) -> None:
"""
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/>.
#
from pyalpm import Handle # type: ignore
from typing import List, Set
from typing import Set
from ahriman.core.configuration import Configuration
@ -40,13 +40,15 @@ class Pacman:
for repository in configuration.getlist("alpm", "repositories"):
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
:return: list of package names
"""
result: Set[str] = set()
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,
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:
"""
remove package from repository

View File

@ -99,7 +99,7 @@ class Task:
command.extend(self.archbuild_flags)
command.extend(["--"] + self.makechrootpkg_flags)
command.extend(["--"] + self.makepkg_flags)
self.logger.info(f"using {command} for {self.package.base}")
self.logger.info("using %s for %s", command, self.package.base)
Task._check_output(
*command,

View File

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

View File

@ -36,6 +36,7 @@ class Email(Report, JinjaTemplate):
"""
email report generator
:ivar host: SMTP host to connect
:ivar no_empty_report: skip empty report generation
:ivar password: password to authenticate via SMTP
:ivar port: SMTP port to connect
:ivar receivers: list of receivers emails
@ -55,6 +56,7 @@ class Email(Report, JinjaTemplate):
# base smtp settings
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.port = configuration.getint("email", "port")
self.receivers = configuration.getlist("email", "receivers")
@ -96,6 +98,8 @@ class Email(Report, JinjaTemplate):
:param packages: list of packages to generate report
: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)
attachments = {"index.html": self.make_html(packages, True)}
self._send(text, attachments)

View File

@ -85,7 +85,7 @@ class JinjaTemplate:
"""
# idea comes from https://stackoverflow.com/a/38642558
loader = jinja2.FileSystemLoader(searchpath=self.template_path.parent)
environment = jinja2.Environment(loader=loader)
environment = jinja2.Environment(loader=loader, autoescape=True)
template = environment.get_template(self.template_path.name)
content = [

View File

@ -61,7 +61,7 @@ class Executor(Cleaner):
build_single(single)
except Exception:
self.reporter.set_failed(single.base)
self.logger.exception(f"{single.base} ({self.architecture}) build exception")
self.logger.exception("%s (%s) build exception", single.base, self.architecture)
self.clear_build()
return self.packages_built()
@ -76,7 +76,7 @@ class Executor(Cleaner):
try:
self.repo.remove(package, fn)
except Exception:
self.logger.exception(f"could not remove {package}")
self.logger.exception("could not remove %s", package)
requested = set(packages)
for local in self.packages():
@ -132,7 +132,7 @@ class Executor(Cleaner):
"""
def update_single(fn: Optional[str], base: str) -> None:
if fn is None:
self.logger.warning(f"received empty package name for base {base}")
self.logger.warning("received empty package name for base %s", base)
return # suppress type checking, it never can be none actually
# in theory it might be NOT packages directory, but we suppose it is
full_path = self.paths.packages / fn
@ -150,7 +150,7 @@ class Executor(Cleaner):
local = Package.load(filename, self.pacman, self.aur_url)
updates.setdefault(local.base, local).packages.update(local.packages)
except Exception:
self.logger.exception(f"could not load package from {filename}")
self.logger.exception("could not load package from %s", filename)
for local in updates.values():
try:
@ -159,7 +159,7 @@ class Executor(Cleaner):
self.reporter.set_success(local)
except Exception:
self.reporter.set_failed(local.base)
self.logger.exception(f"could not process {local.base}")
self.logger.exception("could not process %s", local.base)
self.clear_packages()
return self.repo.repo_path

View File

@ -42,7 +42,7 @@ class Repository(Executor, UpdateHandler):
local = Package.load(full_path, self.pacman, self.aur_url)
result.setdefault(local.base, local).packages.update(local.packages)
except Exception:
self.logger.exception(f"could not load package from {full_path}")
self.logger.exception("could not load package from %s", full_path)
continue
return list(result.values())

View File

@ -59,7 +59,7 @@ class UpdateHandler(Cleaner):
result.append(remote)
except Exception:
self.reporter.set_failed(local.base)
self.logger.exception(f"could not load remote package {local.base}")
self.logger.exception("could not load remote package %s", local.base)
continue
return result
@ -81,7 +81,7 @@ class UpdateHandler(Cleaner):
else:
self.reporter.set_pending(local.base)
except Exception:
self.logger.exception(f"could not add package from {fn}")
self.logger.exception("could not add package from %s", fn)
self.clear_manual()
return result

View File

@ -18,13 +18,14 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import logging
import requests
from pathlib import Path
from typing import List, Optional, Set, Tuple
from ahriman.core.configuration import Configuration
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
@ -87,6 +88,36 @@ class GPG:
default_key = configuration.get("sign", "key") if targets else None
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("could not download key %s from %s: %s", key, 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]:
"""
gpg command wrapper
@ -111,7 +142,7 @@ class GPG:
return [path]
key = self.configuration.get("sign", f"key_{base}", fallback=self.default_key)
if key is None:
self.logger.error(f"no default key set, skip package {path} sign")
self.logger.error("no default key set, skip package %s sign", path)
return [path]
return self.process(path, key)

View File

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

View File

@ -90,7 +90,7 @@ class Watcher:
try:
parse_single(item)
except Exception:
self.logger.exception(f"cannot parse item f{item} to package")
self.logger.exception("cannot parse item %s to package", item)
def _cache_save(self) -> None:
"""

View File

@ -23,7 +23,9 @@ import requests
from typing import List, Optional, Tuple
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.internal_status import InternalStatus
from ahriman.models.package import Package
@ -45,16 +47,6 @@ class WebClient(Client):
self.host = host
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:
"""
url generator
@ -70,6 +62,13 @@ class WebClient(Client):
"""
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:
"""
add new package with status
@ -85,9 +84,9 @@ class WebClient(Client):
response = requests.post(self._package_url(package.base), json=payload)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not add {package.base}: {WebClient._exception_response_text(e)}")
self.logger.exception("could not add %s: %s", package.base, exception_response_text(e))
except Exception:
self.logger.exception(f"could not add {package.base}")
self.logger.exception("could not add %s", package.base)
def get(self, base: Optional[str]) -> List[Tuple[Package, BuildStatus]]:
"""
@ -105,11 +104,28 @@ class WebClient(Client):
for package in status_json
]
except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not get {base}: {WebClient._exception_response_text(e)}")
self.logger.exception("could not get %s: %s", base, exception_response_text(e))
except Exception:
self.logger.exception(f"could not get {base}")
self.logger.exception("could not get %s", base)
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("could not get web service status: %s", exception_response_text(e))
except Exception:
self.logger.exception("could not get web service status")
return InternalStatus()
def get_self(self) -> BuildStatus:
"""
get ahriman status itself
@ -122,7 +138,7 @@ class WebClient(Client):
status_json = response.json()
return BuildStatus.from_json(status_json)
except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not get service status: {WebClient._exception_response_text(e)}")
self.logger.exception("could not get service status: %s", exception_response_text(e))
except Exception:
self.logger.exception("could not get service status")
return BuildStatus()
@ -136,9 +152,9 @@ class WebClient(Client):
response = requests.delete(self._package_url(base))
response.raise_for_status()
except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not delete {base}: {WebClient._exception_response_text(e)}")
self.logger.exception("could not delete %s: %s", base, exception_response_text(e))
except Exception:
self.logger.exception(f"could not delete {base}")
self.logger.exception("could not delete %s", base)
def update(self, base: str, status: BuildStatusEnum) -> None:
"""
@ -152,9 +168,9 @@ class WebClient(Client):
response = requests.post(self._package_url(base), json=payload)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not update {base}: {WebClient._exception_response_text(e)}")
self.logger.exception("could not update %s: %s", base, exception_response_text(e))
except Exception:
self.logger.exception(f"could not update {base}")
self.logger.exception("could not update %s", base)
def update_self(self, status: BuildStatusEnum) -> None:
"""
@ -167,6 +183,6 @@ class WebClient(Client):
response = requests.post(self._ahriman_url(), json=payload)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not update service status: {WebClient._exception_response_text(e)}")
self.logger.exception("could not update service status: %s", exception_response_text(e))
except Exception:
self.logger.exception("could not update service status")

View File

@ -17,24 +17,25 @@
# 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 boto3 # type: ignore
import hashlib
import mimetypes
from pathlib import Path
from typing import Iterable
from typing import Any, Dict, Generator, Iterable
from ahriman.core.configuration import Configuration
from ahriman.core.upload.upload import Upload
from ahriman.core.util import check_output
from ahriman.models.package import Package
class S3(Upload):
"""
aws-cli wrapper
:ivar bucket: full bucket name
:ivar command: command arguments for sync
:ivar bucket: boto3 S3 bucket object
:ivar chunk_size: chunk size for calculating checksums
"""
_check_output = check_output
def __init__(self, architecture: str, configuration: Configuration) -> None:
"""
default constructor
@ -42,8 +43,81 @@ class S3(Upload):
:param configuration: configuration instance
"""
Upload.__init__(self, architecture, configuration)
self.bucket = configuration.get("s3", "bucket")
self.command = configuration.getlist("s3", "command")
self.bucket = self.get_bucket(configuration)
self.chunk_size = configuration.getint("s3", "chunk_size", fallback=8 * 1024 * 1024)
@staticmethod
def calculate_etag(path: Path, chunk_size: int) -> str:
"""
calculate amazon s3 etag
credits to https://teppen.io/2018/10/23/aws_s3_verify_etags/
For this method we have to define nosec because it is out of any security context and provided by AWS
:param path: path to local file
:param chunk_size: read chunk size, which depends on client settings
:return: calculated entity tag for local file
"""
md5s = []
with path.open("rb") as local_file:
for chunk in iter(lambda: local_file.read(chunk_size), b""):
md5s.append(hashlib.md5(chunk)) # nosec
# in case if there is only one chunk it must be just this checksum
# and checksum of joined digest otherwise (including empty list)
checksum = md5s[0] if len(md5s) == 1 else hashlib.md5(b"".join(md5.digest() for md5 in md5s)) # nosec
# in case if there are more than one chunk it should be appended with amount of chunks
suffix = f"-{len(md5s)}" if len(md5s) > 1 else ""
return f"{checksum.hexdigest()}{suffix}"
@staticmethod
def get_bucket(configuration: Configuration) -> Any:
"""
create resource client from configuration
:param configuration: configuration instance
:return: amazon client
"""
client = boto3.resource(service_name="s3",
region_name=configuration.get("s3", "region"),
aws_access_key_id=configuration.get("s3", "access_key"),
aws_secret_access_key=configuration.get("s3", "secret_key"))
return client.Bucket(configuration.get("s3", "bucket"))
@staticmethod
def remove_files(local_files: Dict[Path, str], remote_objects: Dict[Path, Any]) -> None:
"""
remove files which have been removed locally
:param local_files: map of local path object to its checksum
:param remote_objects: map of remote path object to the remote s3 object
"""
for local_file, remote_object in remote_objects.items():
if local_file in local_files:
continue
remote_object.delete()
def get_local_files(self, path: Path) -> Dict[Path, str]:
"""
get all local files and their calculated checksums
:param path: local path to sync
:return: map of path object to its checksum
"""
# credits to https://stackoverflow.com/a/64915960
def walk(directory_path: Path) -> Generator[Path, None, None]:
for element in directory_path.iterdir():
if element.is_dir():
yield from walk(element)
continue
yield element
return {
local_file.relative_to(path): self.calculate_etag(local_file, self.chunk_size)
for local_file in walk(path)
}
def get_remote_objects(self) -> Dict[Path, Any]:
"""
get all remote objects and their checksums
:return: map of path object to the remote s3 object
"""
objects = self.bucket.objects.filter(Prefix=self.architecture)
return {Path(item.key).relative_to(self.architecture): item for item in objects}
def sync(self, path: Path, built_packages: Iterable[Package]) -> None:
"""
@ -51,5 +125,29 @@ class S3(Upload):
:param path: local path to sync
:param built_packages: list of packages which has just been built
"""
# TODO rewrite to boto, but it is bullshit
S3._check_output(*self.command, str(path), self.bucket, exception=None, logger=self.logger)
remote_objects = self.get_remote_objects()
local_files = self.get_local_files(path)
self.upload_files(path, local_files, remote_objects)
self.remove_files(local_files, remote_objects)
def upload_files(self, path: Path, local_files: Dict[Path, str], remote_objects: Dict[Path, Any]) -> None:
"""
upload changed files to s3
:param path: local path to sync
:param local_files: map of local path object to its checksum
:param remote_objects: map of remote path object to the remote s3 object
"""
for local_file, checksum in local_files.items():
remote_object = remote_objects.get(local_file)
# 0 and -1 elements are " (double quote)
remote_checksum = remote_object.e_tag[1:-1] if remote_object is not None else None
if remote_checksum == checksum:
continue
local_path = path / local_file
remote_path = Path(self.architecture) / local_file
(mime, _) = mimetypes.guess_type(local_path)
extra_args = {"Content-Type": mime} if mime is not None else None
self.bucket.upload_file(Filename=str(local_path), Key=str(remote_path), ExtraArgs=extra_args)

View File

@ -19,6 +19,7 @@
#
import datetime
import subprocess
import requests
from logging import Logger
from pathlib import Path
@ -27,29 +28,42 @@ from typing import Optional, Union
from ahriman.core.exceptions import InvalidOption
def check_output(*args: str, exception: Optional[Exception],
cwd: Optional[Path] = None, logger: Optional[Logger] = None) -> str:
def check_output(*args: str, exception: Optional[Exception], cwd: Optional[Path] = None,
input_data: Optional[str] = None, logger: Optional[Logger] = None) -> str:
"""
subprocess wrapper
:param args: command line arguments
:param exception: exception which has to be reraised instead of default subprocess exception
: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
:return: command output
"""
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:
for line in result.splitlines():
logger.debug(line)
except subprocess.CalledProcessError as e:
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)
raise exception or e
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:
"""
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 installed_size: package installed size
:ivar licenses: package licenses list
:ivar provides: list of provided packages
:ivar url: package url
"""
@ -50,6 +51,7 @@ class PackageDescription:
groups: List[str] = field(default_factory=list)
installed_size: Optional[int] = None
licenses: List[str] = field(default_factory=list)
provides: List[str] = field(default_factory=list)
url: Optional[str] = None
@property
@ -89,4 +91,5 @@ class PackageDescription:
groups=package.groups,
installed_size=package.isize,
licenses=package.licenses,
provides=package.provides,
url=package.url)

View File

@ -17,9 +17,11 @@
# 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 pathlib import Path
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Set, Type
@dataclass
@ -76,6 +78,20 @@ class RepositoryPaths:
"""
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:
"""
create ahriman working tree

View File

@ -17,4 +17,4 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__version__ = "0.22.0"
__version__ = "1.2.3"

View File

@ -40,7 +40,7 @@ def exception_handler(logger: Logger) -> Callable[[Request, HandlerType], Awaita
except HTTPClientError:
raise
except Exception:
logger.exception(f"exception during performing request to {request.path}")
logger.exception("exception during performing request to %s", request.path)
raise
return handle

View File

@ -23,6 +23,7 @@ from ahriman.web.views.ahriman import AhrimanView
from ahriman.web.views.index import IndexView
from ahriman.web.views.package import PackageView
from ahriman.web.views.packages import PackagesView
from ahriman.web.views.status import StatusView
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
POST /api/v1/package/:base update package base status
GET /api/v1/status get web service status itself
:param application: web application instance
"""
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_get("/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
import ahriman.version as version
from ahriman import version
from ahriman.core.util import pretty_datetime
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 aur
import pytest
from pytest_mock import MockerFixture
@ -7,24 +8,70 @@ from ahriman.application.ahriman import _parser
from ahriman.application.application import Application
from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration
from ahriman.models.package import Package
@pytest.fixture
def application(configuration: Configuration, mocker: MockerFixture) -> Application:
"""
fixture for application
:param configuration: configuration fixture
:param mocker: mocker object
:return: application test instance
"""
mocker.patch("pathlib.Path.mkdir")
return Application("x86_64", configuration)
@pytest.fixture
def args() -> argparse.Namespace:
"""
fixture for command line arguments
:return: command line arguments test instance
"""
return argparse.Namespace(lock=None, force=False, unsafe=False, no_report=True)
@pytest.fixture
def aur_package_ahriman(package_ahriman: Package) -> aur.Package:
"""
fixture for AUR package
:param package_ahriman: package fixture
:return: AUR package test instance
"""
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
def lock(args: argparse.Namespace, configuration: Configuration) -> Lock:
"""
fixture for file lock
:param args: command line arguments fixture
:param configuration: configuration fixture
:return: file lock test instance
"""
return Lock(args, "x86_64", configuration)
@pytest.fixture
def parser() -> argparse.ArgumentParser:
"""
fixture for command line arguments parser
:return: command line arguments parser test instance
"""
return _parser()

View File

@ -6,6 +6,7 @@ from pytest_mock import MockerFixture
from ahriman.application.handlers import Handler
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import MissingArchitecture
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()
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
"""

View File

@ -7,6 +7,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.package = []
args.now = False
args.without_dependencies = False

View File

@ -7,6 +7,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.no_build = False
args.no_cache = False
args.no_chroot = False

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,29 @@
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:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
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,12 @@ from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
args.depends_on = None
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.depends_on = []
return args
@ -33,7 +38,7 @@ def test_run_filter(args: argparse.Namespace, configuration: Configuration,
must run command with depends filter
"""
args = _default_args(args)
args.depends_on = "python-aur"
args.depends_on = ["python-aur"]
mocker.patch("pathlib.Path.mkdir")
mocker.patch("ahriman.core.repository.repository.Repository.packages",
return_value=[package_ahriman, package_python_schedule])

View File

@ -7,6 +7,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.package = []
return args

View File

@ -7,6 +7,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.target = []
return args

View File

@ -0,0 +1,55 @@
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:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
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

@ -11,6 +11,11 @@ from ahriman.models.sign_settings import SignSettings
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.build_command = "ahriman"
args.from_configuration = Path("/usr/share/devtools/pacman-extra.conf")
args.no_multilib = False

View File

@ -7,6 +7,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.package = []
return args

View File

@ -9,6 +9,11 @@ from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.ahriman = True
args.package = []
return args

View File

@ -9,6 +9,11 @@ from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.status = BuildStatusEnum.Success
args.package = None
args.remove = False

View File

@ -7,6 +7,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.target = []
return args

View File

@ -8,6 +8,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.package = []
args.dry_run = False
args.no_aur = False

View File

@ -29,9 +29,9 @@ def test_parser_option_lock(parser: argparse.ArgumentParser) -> None:
"""
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)
args = parser.parse_args(["-a", "x86_64", "-l", "ahriman.lock", "update"])
args = parser.parse_args(["-l", "ahriman.lock", "update"])
assert isinstance(args.lock, Path)
@ -43,11 +43,20 @@ def test_multiple_architectures(parser: argparse.ArgumentParser) -> None:
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:
"""
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 args.no_manual
assert args.dry_run
@ -55,29 +64,87 @@ def test_subparsers_check(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
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.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.unsafe
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>",
"--repository", "aur-clone"])
assert args.lock is None
assert args.no_log
assert args.no_report
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)
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:
"""
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"])
assert args.lock is None
assert args.no_log
assert args.no_report
assert args.unsafe
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"])
assert args.lock is None
assert args.no_log
assert args.no_report
assert args.unsafe
@ -134,6 +211,22 @@ def test_subparsers_status_update_option_status(parser: argparse.ArgumentParser)
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:
"""
web command must imply lock and no_report

View File

@ -299,5 +299,5 @@ def test_update(application: Application, package_ahriman: Package, mocker: Mock
application.update([package_ahriman])
build_mock.assert_called_once()
update_mock.assert_has_calls([mock.call([]), mock.call(paths)])
finalize_mock.assert_has_calls([mock.call([]), mock.call([package_ahriman])])
update_mock.assert_called_with(paths)
finalize_mock.assert_called_with([package_ahriman])

View File

@ -5,9 +5,11 @@ from pathlib import Path
from pytest_mock import MockerFixture
from unittest import mock
from ahriman import version
from ahriman.application.lock import Lock
from ahriman.core.exceptions import DuplicateRun, UnsafeRun
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.internal_status import InternalStatus
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
"""
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")
create_mock = mocker.patch("ahriman.application.lock.Lock.create")
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()
clear_mock.assert_called_once()
create_mock.assert_called_once()
check_version_mock.assert_called_once()
update_status_mock.assert_has_calls([
mock.call(BuildStatusEnum.Building),
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:
"""
must check user correctly
@ -83,7 +111,7 @@ def test_clear(lock: Lock) -> None:
"""
must remove lock file
"""
lock.path = Path(tempfile.mktemp())
lock.path = Path(tempfile.mktemp()) # nosec
lock.path.touch()
lock.clear()
@ -94,7 +122,7 @@ def test_clear_missing(lock: Lock) -> None:
"""
must not fail on lock removal if file is missing
"""
lock.path = Path(tempfile.mktemp())
lock.path = Path(tempfile.mktemp()) # nosec
lock.clear()
@ -111,7 +139,7 @@ def test_create(lock: Lock) -> None:
"""
must create lock
"""
lock.path = Path(tempfile.mktemp())
lock.path = Path(tempfile.mktemp()) # nosec
lock.create()
assert lock.path.is_file()
@ -122,7 +150,7 @@ def test_create_exception(lock: Lock) -> None:
"""
must raise exception if file already exists
"""
lock.path = Path(tempfile.mktemp())
lock.path = Path(tempfile.mktemp()) # nosec
lock.path.touch()
with pytest.raises(DuplicateRun):
@ -144,7 +172,7 @@ def test_create_unsafe(lock: Lock) -> None:
must not raise exception if force flag set
"""
lock.force = True
lock.path = Path(tempfile.mktemp())
lock.path = Path(tempfile.mktemp()) # nosec
lock.path.touch()
lock.create()

View File

@ -1,3 +1,5 @@
from unittest.mock import MagicMock
import pytest
from pathlib import Path
@ -10,6 +12,7 @@ from ahriman.models.package import Package
from ahriman.models.package_description import PackageDescription
from ahriman.models.repository_paths import RepositoryPaths
T = TypeVar("T")
@ -17,21 +20,47 @@ T = TypeVar("T")
# https://stackoverflow.com/a/21611963
@pytest.helpers.register
def anyvar(cls: Type[T], strict: bool = False) -> T:
"""
any value helper for mocker calls check
:param cls: type class
:param strict: if True then check type of supplied argument
:return: any wrapper
"""
class AnyVar(cls):
"""
any value wrapper
"""
def __eq__(self, other: Any) -> bool:
"""
compare object to other
:param other: other object to compare
:return: True in case if objects are equal
"""
return not strict or isinstance(other, cls)
return AnyVar()
# generic fixtures
@pytest.fixture
def configuration(resource_path_root: Path) -> Configuration:
"""
configuration fixture
:param resource_path_root: resource path root directory
:return: configuration test instance
"""
path = resource_path_root / "core" / "ahriman.ini"
return Configuration.from_path(path=path, architecture="x86_64", logfile=False)
@pytest.fixture
def package_ahriman(package_description_ahriman: PackageDescription) -> Package:
"""
package fixture
:param package_description_ahriman: description fixture
:return: package test instance
"""
packages = {"ahriman": package_description_ahriman}
return Package(
base="ahriman",
@ -44,6 +73,12 @@ def package_ahriman(package_description_ahriman: PackageDescription) -> Package:
def package_python_schedule(
package_description_python_schedule: PackageDescription,
package_description_python2_schedule: PackageDescription) -> Package:
"""
multi package fixture
:param package_description_python_schedule: description fixture
:param package_description_python2_schedule: description fixture
:return: multi package test instance
"""
packages = {
"python-schedule": package_description_python_schedule,
"python2-schedule": package_description_python2_schedule
@ -57,6 +92,10 @@ def package_python_schedule(
@pytest.fixture
def package_description_ahriman() -> PackageDescription:
"""
package description fixture
:return: package description test instance
"""
return PackageDescription(
architecture="x86_64",
archive_size=4200,
@ -72,6 +111,10 @@ def package_description_ahriman() -> PackageDescription:
@pytest.fixture
def package_description_python_schedule() -> PackageDescription:
"""
package description fixture
:return: package description test instance
"""
return PackageDescription(
architecture="x86_64",
archive_size=4201,
@ -87,6 +130,10 @@ def package_description_python_schedule() -> PackageDescription:
@pytest.fixture
def package_description_python2_schedule() -> PackageDescription:
"""
package description fixture
:return: package description test instance
"""
return PackageDescription(
architecture="x86_64",
archive_size=4202,
@ -102,6 +149,10 @@ def package_description_python2_schedule() -> PackageDescription:
@pytest.fixture
def repository_paths(configuration: Configuration) -> RepositoryPaths:
"""
repository paths fixture
:return: repository paths test instance
"""
return RepositoryPaths(
architecture="x86_64",
root=configuration.getpath("repository", "root"))
@ -109,5 +160,11 @@ def repository_paths(configuration: Configuration) -> RepositoryPaths:
@pytest.fixture
def watcher(configuration: Configuration, mocker: MockerFixture) -> Watcher:
"""
package status watcher fixture
:param configuration: configuration fixture
:param mocker: mocker object
:return: package status watcher test instance
"""
mocker.patch("pathlib.Path.mkdir")
return Watcher("x86_64", configuration)

View File

@ -8,3 +8,10 @@ def test_all_packages(pacman: Pacman) -> None:
packages = pacman.all_packages()
assert 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"
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:
"""
must call repo-remove on package addition

View File

@ -11,24 +11,52 @@ from ahriman.models.repository_paths import RepositoryPaths
@pytest.fixture
def leaf_ahriman(package_ahriman: Package) -> Leaf:
"""
fixture for tree leaf with package
:param package_ahriman: package fixture
:return: tree leaf test instance
"""
return Leaf(package_ahriman, set())
@pytest.fixture
def leaf_python_schedule(package_python_schedule: Package) -> Leaf:
"""
fixture for tree leaf with package
:param package_python_schedule: package fixture
:return: tree leaf test instance
"""
return Leaf(package_python_schedule, set())
@pytest.fixture
def pacman(configuration: Configuration) -> Pacman:
"""
fixture for pacman wrapper
:param configuration: configuration fixture
:return: pacman wrapper test instance
"""
return Pacman(configuration)
@pytest.fixture
def repo(configuration: Configuration, repository_paths: RepositoryPaths) -> Repo:
"""
fixture for repository wrapper
:param configuration: configuration fixture
:param repository_paths: repository paths fixture
:return: repository wrapper test instance
"""
return Repo(configuration.get("repository", "name"), repository_paths, [])
@pytest.fixture
def task_ahriman(package_ahriman: Package, configuration: Configuration, repository_paths: RepositoryPaths) -> Task:
"""
fixture for built task
:param package_ahriman: package fixture
:param configuration: configuration fixture
:param repository_paths: repository paths fixture
:return: built task test instance
"""
return Task(package_ahriman, configuration, repository_paths)

View File

@ -103,3 +103,28 @@ def test_generate_with_built(configuration: Configuration, package_ahriman: Pack
report = Email("x86_64", configuration)
report.generate([package_ahriman], [package_ahriman])
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

@ -12,12 +12,24 @@ from ahriman.core.repository.update_handler import UpdateHandler
@pytest.fixture
def cleaner(configuration: Configuration, mocker: MockerFixture) -> Cleaner:
"""
fixture for cleaner
:param configuration: configuration fixture
:param mocker: mocker object
:return: cleaner test instance
"""
mocker.patch("pathlib.Path.mkdir")
return Cleaner("x86_64", configuration)
@pytest.fixture
def executor(configuration: Configuration, mocker: MockerFixture) -> Executor:
"""
fixture for executor
:param configuration: configuration fixture
:param mocker: mocker object
:return: executor test instance
"""
mocker.patch("pathlib.Path.mkdir")
mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_build")
mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_cache")
@ -29,17 +41,34 @@ def executor(configuration: Configuration, mocker: MockerFixture) -> Executor:
@pytest.fixture
def repository(configuration: Configuration, mocker: MockerFixture) -> Repository:
"""
fixture for repository
:param configuration: configuration fixture
:param mocker: mocker object
:return: repository test instance
"""
mocker.patch("pathlib.Path.mkdir")
return Repository("x86_64", configuration)
@pytest.fixture
def properties(configuration: Configuration) -> Properties:
"""
fixture for properties
:param configuration: configuration fixture
:return: properties test instance
"""
return Properties("x86_64", configuration)
@pytest.fixture
def update_handler(configuration: Configuration, mocker: MockerFixture) -> UpdateHandler:
"""
fixture for update handler
:param configuration: configuration fixture
:param mocker: mocker object
:return: update handler test instance
"""
mocker.patch("pathlib.Path.mkdir")
mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_build")
mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_cache")

View File

@ -9,11 +9,18 @@ from ahriman.core.repository.cleaner import Cleaner
def _mock_clear(mocker: MockerFixture) -> None:
"""
mocker helper for clear function
:param mocker: mocker object
"""
mocker.patch("pathlib.Path.iterdir", return_value=[Path("a"), Path("b"), Path("c")])
mocker.patch("shutil.rmtree")
def _mock_clear_check() -> None:
"""
mocker helper for clear tests
"""
shutil.rmtree.assert_has_calls([
mock.call(Path("a")),
mock.call(Path("b")),

View File

@ -6,10 +6,20 @@ from ahriman.core.sign.gpg import GPG
@pytest.fixture
def gpg(configuration: Configuration) -> GPG:
"""
fixture for empty GPG
:param configuration: configuration fixture
:return: GPG test instance
"""
return GPG("x86_64", configuration)
@pytest.fixture
def gpg_with_key(gpg: GPG) -> GPG:
"""
fixture for correct GPG
:param gpg: empty GPG fixture
:return: GPG test instance
"""
gpg.default_key = "key"
return gpg

View File

@ -1,5 +1,9 @@
import pytest
import requests
from pathlib import Path
from pytest_mock import MockerFixture
from unittest import mock
from ahriman.core.sign.gpg import GPG
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)
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:
"""
must call process method correctly

View File

@ -11,20 +11,38 @@ from ahriman.models.package import Package
# helpers
@pytest.helpers.register
def get_package_status(package: Package) -> Dict[str, Any]:
"""
helper to extract package status from package
:param package: package object
:return: simplified package status map (with only status and view)
"""
return {"status": BuildStatusEnum.Unknown.value, "package": package.view()}
@pytest.helpers.register
def get_package_status_extended(package: Package) -> Dict[str, Any]:
"""
helper to extract package status from package
:param package: package object
:return: full package status map (with timestamped build status and view)
"""
return {"status": BuildStatus().view(), "package": package.view()}
# fixtures
@pytest.fixture
def client() -> Client:
"""
fixture for dummy client
:return: dummy client test instance
"""
return Client()
@pytest.fixture
def web_client() -> WebClient:
"""
fixture for web client
:return: web client test instance
"""
return WebClient("localhost", 8080)

View File

@ -4,6 +4,7 @@ from ahriman.core.configuration import Configuration
from ahriman.core.status.client import Client
from ahriman.core.status.web_client import WebClient
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.internal_status import InternalStatus
from ahriman.models.package import Package
@ -38,6 +39,13 @@ def test_get(client: Client, package_ahriman: Package) -> 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:
"""
must return unknown status for service

View File

@ -106,7 +106,7 @@ def test_cache_save_load(watcher: Watcher, package_ahriman: Package, mocker: Moc
"""
must save state to cache which can be loaded later
"""
dump_file = Path(tempfile.mktemp())
dump_file = Path(tempfile.mktemp()) # nosec
mocker.patch("ahriman.core.status.watcher.Watcher.cache_path",
new_callable=PropertyMock, return_value=dump_file)
known_current = {package_ahriman.base: (package_ahriman, BuildStatus())}

View File

@ -7,6 +7,7 @@ from requests import Response
from ahriman.core.status.web_client import WebClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.internal_status import InternalStatus
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}")
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:
"""
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]
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:
"""
must return service status

View File

@ -113,6 +113,14 @@ def test_load_includes_no_option(configuration: Configuration) -> None:
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:
"""
must fallback to stderr without errors

View File

@ -0,0 +1,31 @@
import pytest
from collections import namedtuple
from typing import List
from unittest.mock import MagicMock
from ahriman.core.configuration import Configuration
from ahriman.core.upload.s3 import S3
_s3_object = namedtuple("s3_object", ["key", "e_tag", "delete"])
@pytest.fixture
def s3(configuration: Configuration) -> S3:
"""
fixture for S3 synchronization
:param configuration: configuration fixture
:return: S3 test instance
"""
return S3("x86_64", configuration)
@pytest.fixture
def s3_remote_objects() -> List[_s3_object]:
"""
fixture for boto3 like S3 objects
:return: boto3 like S3 objects test instance
"""
delete_mock = MagicMock()
return list(map(lambda item: _s3_object(f"x86_64/{item}", f"\"{item}\"", delete_mock), ["a", "b", "c"]))

View File

@ -1,16 +1,131 @@
from pathlib import Path
from pytest_mock import MockerFixture
from typing import Any, List, Optional, Tuple
from unittest import mock
from unittest.mock import MagicMock
from ahriman.core.configuration import Configuration
from ahriman.core.upload.s3 import S3
def test_sync(configuration: Configuration, mocker: MockerFixture) -> None:
_chunk_size = 8 * 1024 * 1024
def test_calculate_etag_big(resource_path_root: Path) -> None:
"""
must calculate checksum for path which is more than one chunk
"""
path = resource_path_root / "models" / "big_file_checksum"
assert S3.calculate_etag(path, _chunk_size) == "3b15154eaeed22ae19ae4667d4b98d28-2"
def test_calculate_etag_empty(resource_path_root: Path) -> None:
"""
must calculate checksum for empty file correctly
"""
path = resource_path_root / "models" / "empty_file_checksum"
assert S3.calculate_etag(path, _chunk_size) == "d41d8cd98f00b204e9800998ecf8427e"
def test_calculate_etag_small(resource_path_root: Path) -> None:
"""
must calculate checksum for path which is single chunk
"""
path = resource_path_root / "models" / "package_ahriman_srcinfo"
assert S3.calculate_etag(path, _chunk_size) == "04e75b4aa0fe6033e711e8ea98e059b2"
def test_remove_files(s3_remote_objects: List[Any]) -> None:
"""
must remove remote objects
"""
local_files = {
Path(item.key): item.e_tag for item in s3_remote_objects if item.key != "x86_64/a"
}
remote_objects = {Path(item.key): item for item in s3_remote_objects}
S3.remove_files(local_files, remote_objects)
remote_objects[Path("x86_64/a")].delete.assert_called_once()
def test_get_local_files(s3: S3, resource_path_root: Path) -> None:
"""
must get all local files recursively
"""
expected = sorted([
Path("core/ahriman.ini"),
Path("core/logging.ini"),
Path("models/big_file_checksum"),
Path("models/empty_file_checksum"),
Path("models/package_ahriman_srcinfo"),
Path("models/package_tpacpi-bat-git_srcinfo"),
Path("models/package_yay_srcinfo"),
Path("web/templates/search-line.jinja2"),
Path("web/templates/build-status.jinja2"),
Path("web/templates/repo-index.jinja2"),
Path("web/templates/sorttable.jinja2"),
Path("web/templates/style.jinja2"),
Path("web/templates/search.jinja2"),
])
local_files = list(sorted(s3.get_local_files(resource_path_root).keys()))
assert local_files == expected
def test_get_remote_objects(s3: S3, s3_remote_objects: List[Any]) -> None:
"""
must generate list of remote objects by calling boto3 function
"""
expected = {Path(item.key).relative_to(s3.architecture): item for item in s3_remote_objects}
s3.bucket = MagicMock()
s3.bucket.objects.filter.return_value = s3_remote_objects
assert s3.get_remote_objects() == expected
def test_sync(s3: S3, mocker: MockerFixture) -> None:
"""
must run sync command
"""
check_output_mock = mocker.patch("ahriman.core.upload.s3.S3._check_output")
local_files_mock = mocker.patch("ahriman.core.upload.s3.S3.get_local_files")
remote_objects_mock = mocker.patch("ahriman.core.upload.s3.S3.get_remote_objects")
remove_files_mock = mocker.patch("ahriman.core.upload.s3.S3.remove_files")
upload_files_mock = mocker.patch("ahriman.core.upload.s3.S3.upload_files")
upload = S3("x86_64", configuration)
upload.sync(Path("path"), [])
check_output_mock.assert_called_once()
s3.sync(Path("root"), [])
local_files_mock.assert_called_once()
remote_objects_mock.assert_called_once()
remove_files_mock.assert_called_once()
upload_files_mock.assert_called_once()
def test_upload_files(s3: S3, s3_remote_objects: List[Any], mocker: MockerFixture) -> None:
"""
must upload changed files
"""
def mimetype(path: Path) -> Tuple[Optional[str], None]:
return ("text/html", None) if path.name == "b" else (None, None)
root = Path("path")
local_files = {
Path(item.key.replace("a", "d")): item.e_tag.replace("b", "d").replace("\"", "")
for item in s3_remote_objects
}
remote_objects = {Path(item.key): item for item in s3_remote_objects}
mocker.patch("mimetypes.guess_type", side_effect=mimetype)
upload_mock = s3.bucket = MagicMock()
s3.upload_files(root, local_files, remote_objects)
upload_mock.upload_file.assert_has_calls(
[
mock.call(
Filename=str(root / s3.architecture / "b"),
Key=f"{s3.architecture}/{s3.architecture}/b",
ExtraArgs={"Content-Type": "text/html"}),
mock.call(
Filename=str(root / s3.architecture / "d"),
Key=f"{s3.architecture}/{s3.architecture}/d",
ExtraArgs=None),
],
any_order=True)

View File

@ -2,18 +2,56 @@ import pytest
from unittest.mock import MagicMock, PropertyMock
from ahriman import version
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_description import PackageDescription
@pytest.fixture
def build_status_failed() -> BuildStatus:
"""
build result fixture with failed status
:return: failed build status test instance
"""
return BuildStatus(BuildStatusEnum.Failed, 42)
@pytest.fixture
def counters() -> Counters:
"""
counters fixture
:return: counters test instance
"""
return Counters(total=10,
unknown=1,
pending=2,
building=3,
failed=4,
success=0)
@pytest.fixture
def internal_status(counters: Counters) -> InternalStatus:
"""
internal status fixture
:param counters: counters fixture
:return: internal status test instance
"""
return InternalStatus(architecture="x86_64",
packages=counters,
version=version.__version__,
repository="aur-clone")
@pytest.fixture
def package_tpacpi_bat_git() -> Package:
"""
git package fixture
:return: git package test instance
"""
return Package(
base="tpacpi-bat-git",
version="3.1.r12.g4959b52-1",
@ -23,6 +61,11 @@ def package_tpacpi_bat_git() -> Package:
@pytest.fixture
def pyalpm_handle(pyalpm_package_ahriman: MagicMock) -> MagicMock:
"""
mock object for pyalpm
:param pyalpm_package_ahriman: mock object for pyalpm package
:return: pyalpm mock
"""
mock = MagicMock()
mock.handle.load_pkg.return_value = pyalpm_package_ahriman
return mock
@ -30,6 +73,11 @@ def pyalpm_handle(pyalpm_package_ahriman: MagicMock) -> MagicMock:
@pytest.fixture
def pyalpm_package_ahriman(package_ahriman: Package) -> MagicMock:
"""
mock object for pyalpm package
:param package_ahriman: package fixture
:return: pyalpm package mock
"""
mock = MagicMock()
type(mock).base = PropertyMock(return_value=package_ahriman.base)
type(mock).name = PropertyMock(return_value=package_ahriman.base)
@ -39,6 +87,11 @@ def pyalpm_package_ahriman(package_ahriman: Package) -> MagicMock:
@pytest.fixture
def pyalpm_package_description_ahriman(package_description_ahriman: PackageDescription) -> MagicMock:
"""
mock object for pyalpm package description
:param package_description_ahriman: package description fixture
:return: pyalpm package description mock
"""
mock = MagicMock()
type(mock).arch = PropertyMock(return_value=package_description_ahriman.architecture)
type(mock).builddate = PropertyMock(return_value=package_description_ahriman.build_date)
@ -48,5 +101,6 @@ def pyalpm_package_description_ahriman(package_description_ahriman: PackageDescr
type(mock).isize = PropertyMock(return_value=package_description_ahriman.installed_size)
type(mock).licenses = PropertyMock(return_value=package_description_ahriman.licenses)
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)
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
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:
"""
must create whole tree
@ -11,7 +20,7 @@ def test_create_tree(repository_paths: RepositoryPaths, mocker: MockerFixture) -
paths = {
prop
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")

View File

@ -9,5 +9,11 @@ from ahriman.web.web import setup_service
@pytest.fixture
def application(configuration: Configuration, mocker: MockerFixture) -> web.Application:
"""
application fixture
:param configuration: configuration fixture
:param mocker: mocker object
:return: application test instance
"""
mocker.patch("pathlib.Path.mkdir")
return setup_service("x86_64", configuration)

View File

@ -0,0 +1,15 @@
import pytest
from collections import namedtuple
_request = namedtuple("_request", ["path"])
@pytest.fixture
def aiohttp_request() -> _request:
"""
fixture for aiohttp like object
:return: aiohttp like request test instance
"""
return _request("path")

View File

@ -0,0 +1,47 @@
import logging
import pytest
from aiohttp.web_exceptions import HTTPBadRequest
from pytest_mock import MockerFixture
from typing import Any
from unittest.mock import AsyncMock
from ahriman.web.middlewares.exception_handler import exception_handler
async def test_exception_handler(aiohttp_request: Any, mocker: MockerFixture) -> None:
"""
must pass success response
"""
request_handler = AsyncMock()
logging_mock = mocker.patch("logging.Logger.exception")
handler = exception_handler(logging.getLogger())
await handler(aiohttp_request, request_handler)
logging_mock.assert_not_called()
async def test_exception_handler_client_error(aiohttp_request: Any, mocker: MockerFixture) -> None:
"""
must pass client exception
"""
request_handler = AsyncMock(side_effect=HTTPBadRequest())
logging_mock = mocker.patch("logging.Logger.exception")
handler = exception_handler(logging.getLogger())
with pytest.raises(HTTPBadRequest):
await handler(aiohttp_request, request_handler)
logging_mock.assert_not_called()
async def test_exception_handler_server_error(aiohttp_request: Any, mocker: MockerFixture) -> None:
"""
must log server exception and re-raise it
"""
request_handler = AsyncMock(side_effect=Exception())
logging_mock = mocker.patch("logging.Logger.exception")
handler = exception_handler(logging.getLogger())
with pytest.raises(Exception):
await handler(aiohttp_request, request_handler)
logging_mock.assert_called_once()

View File

@ -0,0 +1,11 @@
from aiohttp import web
from ahriman.web.routes import setup_routes
def test_setup_routes(application: web.Application) -> None:
"""
must generate non empty list of routes
"""
setup_routes(application)
assert application.router.routes()

View File

@ -39,5 +39,5 @@ def test_run(application: web.Application, mocker: MockerFixture) -> None:
run_application_mock = mocker.patch("aiohttp.web.run_app")
run_server(application)
run_application_mock.assert_called_with(application, host="0.0.0.0", port=port,
run_application_mock.assert_called_with(application, host="127.0.0.1", port=port,
handle_signals=False, access_log=pytest.helpers.anyvar(int))

View File

@ -10,5 +10,13 @@ from typing import Any
@pytest.fixture
def client(application: web.Application, loop: BaseEventLoop,
aiohttp_client: Any, mocker: MockerFixture) -> TestClient:
"""
web client fixture
:param application: application fixture
:param loop: context event loop
:param aiohttp_client: aiohttp client fixture
:param mocker: mocker object
:return: web client test instance
"""
mocker.patch("pathlib.Path.iterdir", return_value=[])
return loop.run_until_complete(aiohttp_client(application))

Some files were not shown because too many files have changed in this diff Show More