Compare commits

...

9 Commits
1.1.0 ... 1.2.0

Author SHA1 Message Date
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
63 changed files with 666 additions and 356 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

@ -21,5 +21,6 @@ jobs:
/bin/bash -c "pacman --noconfirm -Syu base-devel python python-pip && \ /bin/bash -c "pacman --noconfirm -Syu base-devel python python-pip && \
pip install -e .[web] && \ pip install -e .[web] && \
pip install -e .[check] && \ pip install -e .[check] && \
pip install -e .[s3] && \
pip install -e .[test] && \ pip install -e .[test] && \
make check tests" make check tests"

View File

@ -22,7 +22,7 @@ ignore-patterns=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use. # number of processors available to use.
jobs=1 jobs=0
# Control the amount of potential inferred values when inferring a single # Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or # object. This can help the performance when dealing with large functions or

View File

@ -89,10 +89,13 @@ Group name must refer to architecture, e.g. it should be `rsync:x86_64` for x86_
### `s3:*` groups ### `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. * `access_key` - AWS access key ID, string, required.
* `bucket` - bucket name (e.g. `s3://bucket/path`), 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 ## `web:*` groups

View File

@ -24,8 +24,10 @@ archlinux: archive
sed -i "s/pkgver=[0-9.]*/pkgver=$(VERSION)/" package/archlinux/PKGBUILD sed -i "s/pkgver=[0-9.]*/pkgver=$(VERSION)/" package/archlinux/PKGBUILD
check: clean mypy check: clean mypy
find "src/$(PROJECT)" "tests/$(PROJECT)" -name "*.py" -execdir autopep8 --exit-code --max-line-length 120 -aa -i {} + autopep8 --exit-code --max-line-length 120 -aa -i -j 0 -r "src/$(PROJECT)" "tests/$(PROJECT)"
cd src && pylint --rcfile=../.pylintrc "$(PROJECT)" pylint --rcfile=.pylintrc "src/$(PROJECT)"
bandit -c .bandit.yml -r "src/$(PROJECT)"
bandit -c .bandit-test.yml -r "tests/$(PROJECT)"
clean: clean:
find . -type f -name "$(PROJECT)-*-src.tar.xz" -delete find . -type f -name "$(PROJECT)-*-src.tar.xz" -delete
@ -35,7 +37,7 @@ directory: clean
mkdir "$(PROJECT)" mkdir "$(PROJECT)"
mypy: mypy:
cd src && echo y | mypy --implicit-reexport --strict -p "$(PROJECT)" --install-types || true cd src && mypy --implicit-reexport --strict -p "$(PROJECT)" --install-types --non-interactive || true
cd src && mypy --implicit-reexport --strict -p "$(PROJECT)" cd src && mypy --implicit-reexport --strict -p "$(PROJECT)"
push: archlinux push: archlinux

View File

@ -1,7 +1,7 @@
# Maintainer: Evgeniy Alekseev # Maintainer: Evgeniy Alekseev
pkgname='ahriman' pkgname='ahriman'
pkgver=1.1.0 pkgver=1.2.0
pkgrel=1 pkgrel=1
pkgdesc="ArcHlinux ReposItory MANager" pkgdesc="ArcHlinux ReposItory MANager"
arch=('any') arch=('any')
@ -9,13 +9,13 @@ url="https://github.com/arcan1s/ahriman"
license=('GPL3') license=('GPL3')
depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-srcinfo') depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-srcinfo')
makedepends=('python-pip') makedepends=('python-pip')
optdepends=('aws-cli: sync to s3' optdepends=('breezy: -bzr packages support'
'breezy: -bzr packages support'
'darcs: -darcs packages support' 'darcs: -darcs packages support'
'gnupg: package and repository sign' 'gnupg: package and repository sign'
'mercurial: -hg packages support' 'mercurial: -hg packages support'
'python-aiohttp: web server' 'python-aiohttp: web server'
'python-aiohttp-jinja2: web server' 'python-aiohttp-jinja2: web server'
'python-boto3: sync to s3'
'python-jinja: html report generation' 'python-jinja: html report generation'
'rsync: sync by using rsync' 'rsync: sync by using rsync'
'subversion: -svn packages support') 'subversion: -svn packages support')

View File

@ -40,8 +40,8 @@ target =
command = rsync --archive --compress --partial --delete command = rsync --archive --compress --partial --delete
[s3] [s3]
command = aws s3 sync --quiet --delete chunk_size = 8388608
[web] [web]
host = 0.0.0.0 host = 127.0.0.1
templates = /usr/share/ahriman templates = /usr/share/ahriman

View File

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

View File

@ -1,7 +1,7 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<title>{{ repository|e }}</title> <title>{{ repository }}</title>
{% include "style.jinja2" %} {% include "style.jinja2" %}
@ -18,13 +18,13 @@
<section class="element"> <section class="element">
{% if pgp_key is not none %} {% 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 %} {% endif %}
<code> <code>
$ cat /etc/pacman.conf<br> $ cat /etc/pacman.conf<br>
[{{ repository|e }}]<br> [{{ repository }}]<br>
Server = {{ link_path|e }}<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 SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Package{% if has_package_signed %}Required{% else %}Never{% endif %} TrustedOnly
</code> </code>
</section> </section>
@ -44,11 +44,11 @@
{% for package in packages %} {% for package in packages %}
<tr class="package"> <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 class="include-search"><a href="{{ link_path }}/{{ package.filename }}" title="{{ package.name }}">{{ package.name }}</a></td>
<td>{{ package.version|e }}</td> <td>{{ package.version }}</td>
<td>{{ package.archive_size|e }}</td> <td>{{ package.archive_size }}</td>
<td>{{ package.installed_size|e }}</td> <td>{{ package.installed_size }}</td>
<td>{{ package.build_date|e }}</td> <td>{{ package.build_date }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
@ -58,7 +58,7 @@
<footer> <footer>
<ul class="navigation"> <ul class="navigation">
{% if homepage is not none %} {% 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 %} {% endif %}
</ul> </ul>
</footer> </footer>

View File

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

View File

@ -74,9 +74,13 @@ setup(
extras_require={ extras_require={
"check": [ "check": [
"autopep8", "autopep8",
"bandit",
"mypy", "mypy",
"pylint", "pylint",
], ],
"s3": [
"boto3",
],
"test": [ "test": [
"pytest", "pytest",
"pytest-aiohttp", "pytest-aiohttp",

View File

@ -19,6 +19,7 @@
# #
import argparse import argparse
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
@ -44,7 +45,14 @@ def _parser() -> argparse.ArgumentParser:
action="append") action="append")
parser.add_argument("-c", "--configuration", help="configuration path", type=Path, default=Path("/etc/ahriman.ini")) parser.add_argument("-c", "--configuration", help="configuration path", type=Path, default=Path("/etc/ahriman.ini"))
parser.add_argument("--force", help="force run, remove file lock", action="store_true") parser.add_argument("--force", help="force run, remove file lock", action="store_true")
parser.add_argument("-l", "--lock", help="lock file", type=Path, default=Path("/tmp/ahriman.lock")) parser.add_argument(
"-l",
"--lock",
help="lock file",
type=Path,
default=Path(
tempfile.gettempdir()) /
"ahriman.lock")
parser.add_argument("--no-log", help="redirect all log messages to stderr", action="store_true") 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("--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") parser.add_argument("--unsafe", help="allow to run ahriman as non-ahriman user", action="store_true")

View File

@ -211,6 +211,8 @@ class Application:
:param updates: list of packages to update :param updates: list of packages to update
""" """
def process_update(paths: Iterable[Path]) -> None: 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] updated = [Package.load(path, self.repository.pacman, self.repository.aur_url) for path in paths]
self.repository.process_update(paths) self.repository.process_update(paths)
self._finalize(updated) self._finalize(updated)

View File

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

View File

@ -17,24 +17,24 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import boto3 # type: ignore
import hashlib
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Any, Dict, Generator, Iterable
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.upload.upload import Upload from ahriman.core.upload.upload import Upload
from ahriman.core.util import check_output
from ahriman.models.package import Package from ahriman.models.package import Package
class S3(Upload): class S3(Upload):
""" """
aws-cli wrapper aws-cli wrapper
:ivar bucket: full bucket name :ivar bucket: boto3 S3 bucket object
:ivar command: command arguments for sync :ivar chunk_size: chunk size for calculating checksums
""" """
_check_output = check_output
def __init__(self, architecture: str, configuration: Configuration) -> None: def __init__(self, architecture: str, configuration: Configuration) -> None:
""" """
default constructor default constructor
@ -42,8 +42,69 @@ class S3(Upload):
:param configuration: configuration instance :param configuration: configuration instance
""" """
Upload.__init__(self, architecture, configuration) Upload.__init__(self, architecture, configuration)
self.bucket = configuration.get("s3", "bucket") self.bucket = self.get_bucket(configuration)
self.command = configuration.getlist("s3", "command") 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"))
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: def sync(self, path: Path, built_packages: Iterable[Package]) -> None:
""" """
@ -51,5 +112,21 @@ class S3(Upload):
:param path: local path to sync :param path: local path to sync
:param built_packages: list of packages which has just been built :param built_packages: list of packages which has just been built
""" """
# TODO rewrite to boto, but it is bullshit remote_objects = self.get_remote_objects()
S3._check_output(*self.command, str(path), self.bucket, exception=None, logger=self.logger) local_files = self.get_local_files(path)
# sync to remotes first
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
remote_path = Path(self.architecture) / local_file
self.bucket.upload_file(str(path / local_file), str(remote_path))
# remove files which were removed locally
for local_file, remote_object in remote_objects.items():
if local_file in local_files:
continue
remote_object.delete()

View File

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

View File

@ -13,17 +13,32 @@ from ahriman.models.package import Package
@pytest.fixture @pytest.fixture
def application(configuration: Configuration, mocker: MockerFixture) -> Application: 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") mocker.patch("pathlib.Path.mkdir")
return Application("x86_64", configuration) return Application("x86_64", configuration)
@pytest.fixture @pytest.fixture
def args() -> argparse.Namespace: 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) return argparse.Namespace(lock=None, force=False, unsafe=False, no_report=True)
@pytest.fixture @pytest.fixture
def aur_package_ahriman(package_ahriman: Package) -> aur.Package: 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( return aur.Package(
num_votes=None, num_votes=None,
description=package_ahriman.packages[package_ahriman.base].description, description=package_ahriman.packages[package_ahriman.base].description,
@ -44,9 +59,19 @@ def aur_package_ahriman(package_ahriman: Package) -> aur.Package:
@pytest.fixture @pytest.fixture
def lock(args: argparse.Namespace, configuration: Configuration) -> Lock: 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) return Lock(args, "x86_64", configuration)
@pytest.fixture @pytest.fixture
def parser() -> argparse.ArgumentParser: def parser() -> argparse.ArgumentParser:
"""
fixture for command line arguments parser
:return: command line arguments parser test instance
"""
return _parser() return _parser()

View File

@ -7,6 +7,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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.package = []
args.now = False args.now = False
args.without_dependencies = 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: 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_build = False
args.no_cache = False args.no_cache = False
args.no_chroot = False args.no_chroot = False

View File

@ -7,6 +7,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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 = "0xE989490C"
args.key_server = "keys.gnupg.net" args.key_server = "keys.gnupg.net"
return args return args

View File

@ -8,6 +8,11 @@ from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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.depends_on = [] args.depends_on = []
return args return args

View File

@ -7,6 +7,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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.package = []
return args return args

View File

@ -7,6 +7,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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 = [] args.target = []
return args return args

View File

@ -8,6 +8,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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"] args.search = ["ahriman"]
return args return args

View File

@ -11,6 +11,11 @@ from ahriman.models.sign_settings import SignSettings
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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.build_command = "ahriman"
args.from_configuration = Path("/usr/share/devtools/pacman-extra.conf") args.from_configuration = Path("/usr/share/devtools/pacman-extra.conf")
args.no_multilib = False args.no_multilib = False

View File

@ -7,6 +7,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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.package = []
return args return args

View File

@ -9,6 +9,11 @@ from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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.ahriman = True
args.package = [] args.package = []
return args return args

View File

@ -9,6 +9,11 @@ from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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.status = BuildStatusEnum.Success
args.package = None args.package = None
args.remove = False args.remove = False

View File

@ -7,6 +7,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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 = [] args.target = []
return args return args

View File

@ -8,6 +8,11 @@ from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace: 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.package = []
args.dry_run = False args.dry_run = False
args.no_aur = False args.no_aur = False

View File

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

View File

@ -111,7 +111,7 @@ def test_clear(lock: Lock) -> None:
""" """
must remove lock file must remove lock file
""" """
lock.path = Path(tempfile.mktemp()) lock.path = Path(tempfile.mktemp()) # nosec
lock.path.touch() lock.path.touch()
lock.clear() lock.clear()
@ -122,7 +122,7 @@ def test_clear_missing(lock: Lock) -> None:
""" """
must not fail on lock removal if file is missing must not fail on lock removal if file is missing
""" """
lock.path = Path(tempfile.mktemp()) lock.path = Path(tempfile.mktemp()) # nosec
lock.clear() lock.clear()
@ -139,7 +139,7 @@ def test_create(lock: Lock) -> None:
""" """
must create lock must create lock
""" """
lock.path = Path(tempfile.mktemp()) lock.path = Path(tempfile.mktemp()) # nosec
lock.create() lock.create()
assert lock.path.is_file() assert lock.path.is_file()
@ -150,7 +150,7 @@ def test_create_exception(lock: Lock) -> None:
""" """
must raise exception if file already exists must raise exception if file already exists
""" """
lock.path = Path(tempfile.mktemp()) lock.path = Path(tempfile.mktemp()) # nosec
lock.path.touch() lock.path.touch()
with pytest.raises(DuplicateRun): with pytest.raises(DuplicateRun):
@ -172,7 +172,7 @@ def test_create_unsafe(lock: Lock) -> None:
must not raise exception if force flag set must not raise exception if force flag set
""" """
lock.force = True lock.force = True
lock.path = Path(tempfile.mktemp()) lock.path = Path(tempfile.mktemp()) # nosec
lock.path.touch() lock.path.touch()
lock.create() lock.create()

View File

@ -1,3 +1,5 @@
from unittest.mock import MagicMock
import pytest import pytest
from pathlib import Path from pathlib import Path
@ -10,6 +12,7 @@ from ahriman.models.package import Package
from ahriman.models.package_description import PackageDescription from ahriman.models.package_description import PackageDescription
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
T = TypeVar("T") T = TypeVar("T")
@ -17,21 +20,63 @@ T = TypeVar("T")
# https://stackoverflow.com/a/21611963 # https://stackoverflow.com/a/21611963
@pytest.helpers.register @pytest.helpers.register
def anyvar(cls: Type[T], strict: bool = False) -> T: 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): class AnyVar(cls):
"""
any value wrapper
"""
def __eq__(self, other: Any) -> bool: 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 not strict or isinstance(other, cls)
return AnyVar() return AnyVar()
@pytest.helpers.register
class AsyncMock(MagicMock):
"""
async magic mock object
"""
async def __call__(self, *args: Any, **kwargs: Any) -> Any:
"""
async call function
:param args:
:param kwargs:
:return:
"""
return MagicMock.__call__(self, *args, **kwargs)
# generic fixtures # generic fixtures
@pytest.fixture @pytest.fixture
def configuration(resource_path_root: Path) -> Configuration: 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" path = resource_path_root / "core" / "ahriman.ini"
return Configuration.from_path(path=path, architecture="x86_64", logfile=False) return Configuration.from_path(path=path, architecture="x86_64", logfile=False)
@pytest.fixture @pytest.fixture
def package_ahriman(package_description_ahriman: PackageDescription) -> Package: 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} packages = {"ahriman": package_description_ahriman}
return Package( return Package(
base="ahriman", base="ahriman",
@ -44,6 +89,12 @@ def package_ahriman(package_description_ahriman: PackageDescription) -> Package:
def package_python_schedule( def package_python_schedule(
package_description_python_schedule: PackageDescription, package_description_python_schedule: PackageDescription,
package_description_python2_schedule: PackageDescription) -> Package: 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 = { packages = {
"python-schedule": package_description_python_schedule, "python-schedule": package_description_python_schedule,
"python2-schedule": package_description_python2_schedule "python2-schedule": package_description_python2_schedule
@ -57,6 +108,10 @@ def package_python_schedule(
@pytest.fixture @pytest.fixture
def package_description_ahriman() -> PackageDescription: def package_description_ahriman() -> PackageDescription:
"""
package description fixture
:return: package description test instance
"""
return PackageDescription( return PackageDescription(
architecture="x86_64", architecture="x86_64",
archive_size=4200, archive_size=4200,
@ -72,6 +127,10 @@ def package_description_ahriman() -> PackageDescription:
@pytest.fixture @pytest.fixture
def package_description_python_schedule() -> PackageDescription: def package_description_python_schedule() -> PackageDescription:
"""
package description fixture
:return: package description test instance
"""
return PackageDescription( return PackageDescription(
architecture="x86_64", architecture="x86_64",
archive_size=4201, archive_size=4201,
@ -87,6 +146,10 @@ def package_description_python_schedule() -> PackageDescription:
@pytest.fixture @pytest.fixture
def package_description_python2_schedule() -> PackageDescription: def package_description_python2_schedule() -> PackageDescription:
"""
package description fixture
:return: package description test instance
"""
return PackageDescription( return PackageDescription(
architecture="x86_64", architecture="x86_64",
archive_size=4202, archive_size=4202,
@ -102,6 +165,10 @@ def package_description_python2_schedule() -> PackageDescription:
@pytest.fixture @pytest.fixture
def repository_paths(configuration: Configuration) -> RepositoryPaths: def repository_paths(configuration: Configuration) -> RepositoryPaths:
"""
repository paths fixture
:return: repository paths test instance
"""
return RepositoryPaths( return RepositoryPaths(
architecture="x86_64", architecture="x86_64",
root=configuration.getpath("repository", "root")) root=configuration.getpath("repository", "root"))
@ -109,5 +176,11 @@ def repository_paths(configuration: Configuration) -> RepositoryPaths:
@pytest.fixture @pytest.fixture
def watcher(configuration: Configuration, mocker: MockerFixture) -> Watcher: 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") mocker.patch("pathlib.Path.mkdir")
return Watcher("x86_64", configuration) return Watcher("x86_64", configuration)

View File

@ -14,4 +14,4 @@ def test_all_packages_with_provides(pacman: Pacman) -> None:
""" """
package list must contain provides packages package list must contain provides packages
""" """
assert 'sh' in pacman.all_packages() assert "sh" in pacman.all_packages()

View File

@ -11,24 +11,52 @@ from ahriman.models.repository_paths import RepositoryPaths
@pytest.fixture @pytest.fixture
def leaf_ahriman(package_ahriman: Package) -> Leaf: 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()) return Leaf(package_ahriman, set())
@pytest.fixture @pytest.fixture
def leaf_python_schedule(package_python_schedule: Package) -> Leaf: 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()) return Leaf(package_python_schedule, set())
@pytest.fixture @pytest.fixture
def pacman(configuration: Configuration) -> Pacman: def pacman(configuration: Configuration) -> Pacman:
"""
fixture for pacman wrapper
:param configuration: configuration fixture
:return: pacman wrapper test instance
"""
return Pacman(configuration) return Pacman(configuration)
@pytest.fixture @pytest.fixture
def repo(configuration: Configuration, repository_paths: RepositoryPaths) -> Repo: 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, []) return Repo(configuration.get("repository", "name"), repository_paths, [])
@pytest.fixture @pytest.fixture
def task_ahriman(package_ahriman: Package, configuration: Configuration, repository_paths: RepositoryPaths) -> Task: 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) return Task(package_ahriman, configuration, repository_paths)

View File

@ -12,12 +12,24 @@ from ahriman.core.repository.update_handler import UpdateHandler
@pytest.fixture @pytest.fixture
def cleaner(configuration: Configuration, mocker: MockerFixture) -> Cleaner: 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") mocker.patch("pathlib.Path.mkdir")
return Cleaner("x86_64", configuration) return Cleaner("x86_64", configuration)
@pytest.fixture @pytest.fixture
def executor(configuration: Configuration, mocker: MockerFixture) -> Executor: 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("pathlib.Path.mkdir")
mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_build") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_build")
mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_cache") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_cache")
@ -29,17 +41,34 @@ def executor(configuration: Configuration, mocker: MockerFixture) -> Executor:
@pytest.fixture @pytest.fixture
def repository(configuration: Configuration, mocker: MockerFixture) -> Repository: 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") mocker.patch("pathlib.Path.mkdir")
return Repository("x86_64", configuration) return Repository("x86_64", configuration)
@pytest.fixture @pytest.fixture
def properties(configuration: Configuration) -> Properties: def properties(configuration: Configuration) -> Properties:
"""
fixture for properties
:param configuration: configuration fixture
:return: properties test instance
"""
return Properties("x86_64", configuration) return Properties("x86_64", configuration)
@pytest.fixture @pytest.fixture
def update_handler(configuration: Configuration, mocker: MockerFixture) -> UpdateHandler: 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("pathlib.Path.mkdir")
mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_build") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_build")
mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_cache") 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: 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("pathlib.Path.iterdir", return_value=[Path("a"), Path("b"), Path("c")])
mocker.patch("shutil.rmtree") mocker.patch("shutil.rmtree")
def _mock_clear_check() -> None: def _mock_clear_check() -> None:
"""
mocker helper for clear tests
"""
shutil.rmtree.assert_has_calls([ shutil.rmtree.assert_has_calls([
mock.call(Path("a")), mock.call(Path("a")),
mock.call(Path("b")), mock.call(Path("b")),

View File

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

View File

@ -11,20 +11,38 @@ from ahriman.models.package import Package
# helpers # helpers
@pytest.helpers.register @pytest.helpers.register
def get_package_status(package: Package) -> Dict[str, Any]: 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()} return {"status": BuildStatusEnum.Unknown.value, "package": package.view()}
@pytest.helpers.register @pytest.helpers.register
def get_package_status_extended(package: Package) -> Dict[str, Any]: 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()} return {"status": BuildStatus().view(), "package": package.view()}
# fixtures # fixtures
@pytest.fixture @pytest.fixture
def client() -> Client: def client() -> Client:
"""
fixture for dummy client
:return: dummy client test instance
"""
return Client() return Client()
@pytest.fixture @pytest.fixture
def web_client() -> WebClient: def web_client() -> WebClient:
"""
fixture for web client
:return: web client test instance
"""
return WebClient("localhost", 8080) return WebClient("localhost", 8080)

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 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", mocker.patch("ahriman.core.status.watcher.Watcher.cache_path",
new_callable=PropertyMock, return_value=dump_file) new_callable=PropertyMock, return_value=dump_file)
known_current = {package_ahriman.base: (package_ahriman, BuildStatus())} known_current = {package_ahriman.base: (package_ahriman, BuildStatus())}

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,98 @@
from pathlib import Path from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from typing import Any, List
from unittest import mock
from unittest.mock import MagicMock
from ahriman.core.configuration import Configuration
from ahriman.core.upload.s3 import S3 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_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, s3_remote_objects: List[Any], mocker: MockerFixture) -> None:
""" """
must run sync command must run sync command
""" """
check_output_mock = mocker.patch("ahriman.core.upload.s3.S3._check_output") 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}
print(local_files)
print(remote_objects)
upload = S3("x86_64", configuration) local_files_mock = mocker.patch("ahriman.core.upload.s3.S3.get_local_files", return_value=local_files)
upload.sync(Path("path"), []) remote_objects_mock = mocker.patch("ahriman.core.upload.s3.S3.get_remote_objects", return_value=remote_objects)
check_output_mock.assert_called_once() upload_mock = s3.bucket = MagicMock()
s3.sync(root, [])
local_files_mock.assert_called_once()
remote_objects_mock.assert_called_once()
upload_mock.upload_file.assert_has_calls([
mock.call(str(root / s3.architecture / "b"), f"{s3.architecture}/{s3.architecture}/b"),
mock.call(str(root / s3.architecture / "d"), f"{s3.architecture}/{s3.architecture}/d"),
], any_order=True)
remote_objects[Path("x86_64/a")].delete.assert_called_once()

View File

@ -12,11 +12,19 @@ from ahriman.models.package_description import PackageDescription
@pytest.fixture @pytest.fixture
def build_status_failed() -> BuildStatus: def build_status_failed() -> BuildStatus:
"""
build result fixture with failed status
:return: failed build status test instance
"""
return BuildStatus(BuildStatusEnum.Failed, 42) return BuildStatus(BuildStatusEnum.Failed, 42)
@pytest.fixture @pytest.fixture
def counters() -> Counters: def counters() -> Counters:
"""
counters fixture
:return: counters test instance
"""
return Counters(total=10, return Counters(total=10,
unknown=1, unknown=1,
pending=2, pending=2,
@ -27,6 +35,11 @@ def counters() -> Counters:
@pytest.fixture @pytest.fixture
def internal_status(counters: Counters) -> InternalStatus: def internal_status(counters: Counters) -> InternalStatus:
"""
internal status fixture
:param counters: counters fixture
:return: internal status test instance
"""
return InternalStatus(architecture="x86_64", return InternalStatus(architecture="x86_64",
packages=counters, packages=counters,
version=version.__version__, version=version.__version__,
@ -35,6 +48,10 @@ def internal_status(counters: Counters) -> InternalStatus:
@pytest.fixture @pytest.fixture
def package_tpacpi_bat_git() -> Package: def package_tpacpi_bat_git() -> Package:
"""
git package fixture
:return: git package test instance
"""
return Package( return Package(
base="tpacpi-bat-git", base="tpacpi-bat-git",
version="3.1.r12.g4959b52-1", version="3.1.r12.g4959b52-1",
@ -44,6 +61,11 @@ def package_tpacpi_bat_git() -> Package:
@pytest.fixture @pytest.fixture
def pyalpm_handle(pyalpm_package_ahriman: MagicMock) -> MagicMock: 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 = MagicMock()
mock.handle.load_pkg.return_value = pyalpm_package_ahriman mock.handle.load_pkg.return_value = pyalpm_package_ahriman
return mock return mock
@ -51,6 +73,11 @@ def pyalpm_handle(pyalpm_package_ahriman: MagicMock) -> MagicMock:
@pytest.fixture @pytest.fixture
def pyalpm_package_ahriman(package_ahriman: Package) -> MagicMock: def pyalpm_package_ahriman(package_ahriman: Package) -> MagicMock:
"""
mock object for pyalpm package
:param package_ahriman: package fixture
:return: pyalpm package mock
"""
mock = MagicMock() mock = MagicMock()
type(mock).base = PropertyMock(return_value=package_ahriman.base) type(mock).base = PropertyMock(return_value=package_ahriman.base)
type(mock).name = PropertyMock(return_value=package_ahriman.base) type(mock).name = PropertyMock(return_value=package_ahriman.base)
@ -60,6 +87,11 @@ def pyalpm_package_ahriman(package_ahriman: Package) -> MagicMock:
@pytest.fixture @pytest.fixture
def pyalpm_package_description_ahriman(package_description_ahriman: PackageDescription) -> MagicMock: 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() mock = MagicMock()
type(mock).arch = PropertyMock(return_value=package_description_ahriman.architecture) type(mock).arch = PropertyMock(return_value=package_description_ahriman.architecture)
type(mock).builddate = PropertyMock(return_value=package_description_ahriman.build_date) type(mock).builddate = PropertyMock(return_value=package_description_ahriman.build_date)

View File

@ -9,5 +9,11 @@ from ahriman.web.web import setup_service
@pytest.fixture @pytest.fixture
def application(configuration: Configuration, mocker: MockerFixture) -> web.Application: 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") mocker.patch("pathlib.Path.mkdir")
return setup_service("x86_64", configuration) 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,48 @@
import logging
import pytest
from aiohttp.web_exceptions import HTTPBadRequest
from pytest_mock import MockerFixture
from typing import Any
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 = pytest.helpers.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 = pytest.helpers.AsyncMock()
request_handler.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 = pytest.helpers.AsyncMock()
request_handler.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_application_mock = mocker.patch("aiohttp.web.run_app")
run_server(application) 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)) handle_signals=False, access_log=pytest.helpers.anyvar(int))

View File

@ -10,5 +10,13 @@ from typing import Any
@pytest.fixture @pytest.fixture
def client(application: web.Application, loop: BaseEventLoop, def client(application: web.Application, loop: BaseEventLoop,
aiohttp_client: Any, mocker: MockerFixture) -> TestClient: 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=[]) mocker.patch("pathlib.Path.iterdir", return_value=[])
return loop.run_until_complete(aiohttp_client(application)) return loop.run_until_complete(aiohttp_client(application))

View File

@ -26,7 +26,7 @@ target =
target = target =
[email] [email]
host = 0.0.0.0 host = 127.0.0.1
link_path = link_path =
no_empty_report = no no_empty_report = no
port = 587 port = 587
@ -48,9 +48,11 @@ command = rsync --archive --verbose --compress --partial --delete
remote = remote =
[s3] [s3]
bucket = access_key =
command = aws s3 sync --quiet --delete bucket = bucket
region = eu-central-1
secret_key =
[web] [web]
host = 0.0.0.0 host = 127.0.0.1
templates = ../web/templates templates = ../web/templates

Binary file not shown.

View File

@ -0,0 +1 @@
../../../package/share/ahriman

View File

@ -1,54 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ repository|e }}</title>
{% include "style.jinja2" %}
{% include "sorttable.jinja2" %}
{% include "search.jinja2" %}
</head>
<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 }}">
</h1>
{% include "search-line.jinja2" %}
<section class="element">
<table class="sortable search-table">
<tr class="header">
<th>package base</th>
<th>packages</th>
<th>version</th>
<th>last update</th>
<th>status</th>
</tr>
{% 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">{{ 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>
</tr>
{% endfor %}
</table>
</section>
<footer>
<ul class="navigation">
<li><a href="https://github.com/arcan1s/ahriman" title="sources">ahriman</a></li>
<li><a href="https://github.com/arcan1s/ahriman/releases" title="releases list">releases</a></li>
<li><a href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li>
</ul>
</footer>
</div>
</body>
</html>

View File

@ -1,62 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ repository|e }}</title>
{% include "style.jinja2" %}
{% include "sorttable.jinja2" %}
{% include "search.jinja2" %}
</head>
<body>
<div class="root">
<h1>Archlinux user repository</h1>
<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>
{% endif %}
<code>
$ cat /etc/pacman.conf<br>
[{{ repository|e }}]<br>
Server = {{ link_path|e }}<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" %}
<section class="element">
<table class="sortable search-table">
<tr class="header">
<th>package</th>
<th>version</th>
<th>archive size</th>
<th>installed size</th>
<th>build date</th>
</tr>
{% 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>
</tr>
{% endfor %}
</table>
</section>
<footer>
<ul class="navigation">
{% if homepage is not none %}
<li><a href="{{ homepage|e }}" title="homepage">Homepage</a></li>
{% endif %}
</ul>
</footer>
</div>
</body>
</html>

View File

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

View File

@ -1,25 +0,0 @@
<script type="text/javascript">
function searchInTable() {
const input = document.getElementById("search");
const filter = input.value.toLowerCase();
const tables = document.getElementsByClassName("search-table");
for (let i = 0; i < tables.length; i++) {
const tr = tables[i].getElementsByTagName("tr");
// from 1 coz of header
for (let i = 1; i < tr.length; i++) {
let td = tr[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) {
display = "";
break;
}
}
}
tr[i].style.display = display;
}
}
}
</script>

View File

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

View File

@ -1,136 +0,0 @@
<style>
:root {
--color-building: 255, 255, 146;
--color-failed: 255, 94, 94;
--color-pending: 255, 255, 146;
--color-success: 94, 255, 94;
--color-unknown: 225, 225, 225;
--color-header: 200, 200, 255;
--color-hover: 255, 255, 225;
--color-line-blue: 235, 235, 255;
--color-line-white: 255, 255, 255;
}
@keyframes blink-building {
0% { background-color: rgba(var(--color-building), 1.0); }
10% { background-color: rgba(var(--color-building), 0.9); }
20% { background-color: rgba(var(--color-building), 0.8); }
30% { background-color: rgba(var(--color-building), 0.7); }
40% { background-color: rgba(var(--color-building), 0.6); }
50% { background-color: rgba(var(--color-building), 0.5); }
60% { background-color: rgba(var(--color-building), 0.4); }
70% { background-color: rgba(var(--color-building), 0.3); }
80% { background-color: rgba(var(--color-building), 0.2); }
90% { background-color: rgba(var(--color-building), 0.1); }
100% { background-color: rgba(var(--color-building), 0.0); }
}
div.root {
width: 70%;
padding: 15px 15% 0;
}
section.element, footer {
width: 100%;
padding: 10px 0;
}
code, input, table {
width: inherit;
}
th, td {
padding: 5px;
}
tr.package:nth-child(odd) {
background-color: rgba(var(--color-line-white), 1.0);
}
tr.package:nth-child(even) {
background-color: rgba(var(--color-line-blue), 1.0);
}
tr.package:hover {
background-color: rgba(var(--color-hover), 1.0);
}
tr.header{
background-color: rgba(var(--color-header), 1.0);
}
td.status {
text-align: center;
}
td.package-unknown {
background-color: rgba(var(--color-unknown), 1.0);
}
td.package-pending {
background-color: rgba(var(--color-pending), 1.0);
}
td.package-building {
background-color: rgba(var(--color-building), 1.0);
animation-name: blink-building;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-direction: alternate;
}
td.package-failed {
background-color: rgba(var(--color-failed), 1.0);
}
td.package-success {
background-color: rgba(var(--color-success), 1.0);
}
li.service-unknown {
background-color: rgba(var(--color-unknown), 1.0);
}
li.service-building {
background-color: rgba(var(--color-building), 1.0);
animation-name: blink-building;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-direction: alternate;
}
li.service-failed {
background-color: rgba(var(--color-failed), 1.0);
}
li.service-success {
background-color: rgba(var(--color-success), 1.0);
}
ul.navigation {
list-style-type: none;
margin: 0;
padding: 0;
overflow: hidden;
background-color: rgba(var(--color-header), 1.0);
}
ul.navigation li {
float: left;
}
ul.navigation li.status {
display: block;
text-align: center;
text-decoration: none;
padding: 14px 16px;
}
ul.navigation li a {
display: block;
color: black;
text-align: center;
text-decoration: none;
padding: 14px 16px;
}
ul.navigation li a:hover {
background-color: rgba(var(--color-hover), 1.0);
}
</style>