Compare commits

..

12 Commits

54 changed files with 5629 additions and 5291 deletions

View File

@ -10,7 +10,7 @@ echo -e '[arcanisrepo]\nServer = http://repo.arcanis.me/$arch\nSigLevel = Never'
# refresh the image
pacman --noconfirm -Syu
# main dependencies
pacman --noconfirm -Sy base-devel devtools git pyalpm python-cerberus python-inflection python-passlib python-requests python-srcinfo python-systemd sudo
pacman --noconfirm -Sy base-devel devtools git pyalpm python-cerberus python-inflection python-passlib python-requests python-srcinfo python-tenacity sudo
# make dependencies
pacman --noconfirm -Sy python-build python-flit python-installer python-wheel
# optional dependencies

View File

@ -122,7 +122,7 @@ Again, the most checks can be performed by `make check` command, though some add
def __hash__(self) -> int: ... # basically any magic (or look-alike) method
```
Methods inside one group should be ordered alphabetically, the only exceptions are `__init__` (`__post_init__` for dataclasses) and `__new__` methods which should be defined first. For test methods it is recommended to follow the order in which functions are defined.
Methods inside one group should be ordered alphabetically, the only exceptions are `__init__` (`__post_init__` for dataclasses), `__new__` and `__del__` methods which should be defined first. For test methods it is recommended to follow the order in which functions are defined.
Though, we would like to highlight abstract methods (i.e. ones which raise `NotImplementedError`), we still keep in global order at the moment.
@ -225,3 +225,25 @@ Again, the most checks can be performed by `make check` command, though some add
### Other checks
The projects also uses typing checks (provided by `mypy`) and some linter checks provided by `pylint` and `bandit`. Those checks must be passed successfully for any open pull requests.
## Developers how to
### Run automated checks
```shell
make check tests
```
### Generate documentation templates
```shell
make specification
```
### Create release
```shell
make VERSION=x.y.z check tests release
```
The command above will also run checks first and will generate documentation, tags, etc., and will push them to GitHub. Other things will be handled by GitHub workflows automatically.

View File

@ -31,7 +31,7 @@ RUN useradd -m -d "/home/build" -s "/usr/bin/nologin" build && \
COPY "docker/install-aur-package.sh" "/usr/local/bin/install-aur-package"
## install package dependencies
## darcs is not installed by reasons, because it requires a lot haskell packages which dramatically increase image size
RUN pacman -Sy --noconfirm --asdeps devtools git pyalpm python-cerberus python-inflection python-passlib python-requests python-srcinfo && \
RUN pacman -Sy --noconfirm --asdeps devtools git pyalpm python-cerberus python-inflection python-passlib python-requests python-srcinfo python-tenacity && \
pacman -Sy --noconfirm --asdeps python-build python-flit python-installer python-wheel && \
pacman -Sy --noconfirm --asdeps breezy mercurial python-aiohttp python-aiohttp-cors python-boto3 python-cryptography python-jinja python-requests-unixsocket python-systemd rsync subversion && \
runuser -u build -- install-aur-package python-aioauth-client python-aiohttp-apispec-git python-aiohttp-jinja2 \

View File

@ -1,4 +1,4 @@
.PHONY: archive archlinux check clean directory html push specification tests version
.PHONY: archive archlinux check clean directory html release specification tests version
.DEFAULT_GOAL := archlinux
PROJECT := ahriman
@ -37,7 +37,7 @@ html: specification
rm -rf docs/html
tox -e docs-html
push: specification archlinux
release: specification archlinux
git add package/archlinux/PKGBUILD src/ahriman/__init__.py docs/ahriman-architecture.svg package/share/man/man1/ahriman.1 package/share/bash-completion/completions/_ahriman package/share/zsh/site-functions/_ahriman
git commit -m "Release $(VERSION)"
git tag "$(VERSION)"

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 993 KiB

View File

@ -4,6 +4,14 @@ ahriman.core.http package
Submodules
----------
ahriman.core.http.sync\_ahriman\_client module
----------------------------------------------
.. automodule:: ahriman.core.http.sync_ahriman_client
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.http.sync\_http\_client module
-------------------------------------------

View File

@ -43,7 +43,6 @@ Base configuration settings.
* ``database`` - path to SQLite database, string, required.
* ``include`` - path to directory with configuration files overrides, string, optional.
* ``logging`` - path to logging configuration, string, required. Check ``logging.ini`` for reference.
* ``suppress_http_log_errors`` - suppress http log errors, boolean, optional, default ``no``. If set to ``yes``, any http log errors (e.g. if web server is not available, but http logging is enabled) will be suppressed.
``alpm:*`` groups
-----------------
@ -86,7 +85,7 @@ Build related configuration. Group name can refer to architecture, e.g. ``build:
* ``makechrootpkg_flags`` - additional flags passed to ``makechrootpkg`` command, space separated list of strings, optional.
* ``triggers`` - list of ``ahriman.core.triggers.Trigger`` class implementation (e.g. ``ahriman.core.report.ReportTrigger ahriman.core.upload.UploadTrigger``) which will be loaded and run at the end of processing, space separated list of strings, optional. You can also specify triggers by their paths, e.g. ``/usr/lib/python3.10/site-packages/ahriman/core/report/report.py.ReportTrigger``. Triggers are run in the order of mention.
* ``triggers_known`` - optional list of ``ahriman.core.triggers.Trigger`` class implementations which are not run automatically and used only for trigger discovery and configuration validation.
* ``vcs_allowed_age`` - maximal age in seconds of the VCS packages before their version will be updated with its remote source, int, optional, default ``604800``.
* ``vcs_allowed_age`` - maximal age in seconds of the VCS packages before their version will be updated with its remote source, integer, optional, default ``604800``.
``repository`` group
--------------------
@ -103,6 +102,18 @@ Settings for signing packages or repository. Group name can refer to architectur
* ``target`` - configuration flag to enable signing, space separated list of strings, required. Allowed values are ``package`` (sign each package separately), ``repository`` (sign repository database file).
* ``key`` - default PGP key, string, required. This key will also be used for database signing if enabled.
``status`` group
----------------
Reporting to web service related settings. In most cases there is fallback to web section settings.
* ``enabled`` - enable reporting to web service, boolean, optional, default ``yes`` for backward compatibility.
* ``address`` - remote web service address with protocol, string, optional. In case of websocket, the ``http+unix`` scheme and url encoded address (e.g. ``%2Fvar%2Flib%2Fahriman`` for ``/var/lib/ahriman``) must be used, e.g. ``http+unix://%2Fvar%2Flib%2Fahriman%2Fsocket``. In case if none set, it will be guessed from ``web`` section.
* ``password`` - password to authorize in web service in order to update service status, string, required in case if authorization enabled.
* ``suppress_http_log_errors`` - suppress http log errors, boolean, optional, default ``no``. If set to ``yes``, any http log errors (e.g. if web server is not available, but http logging is enabled) will be suppressed.
* ``timeout`` - HTTP request timeout in seconds, integer, optional, default is ``30``.
* ``username`` - username to authorize in web service in order to update service status, string, required in case if authorization enabled.
``web`` group
-------------
@ -116,15 +127,12 @@ Web server settings. If any of ``host``/``port`` is not set, web integration wil
* ``host`` - host to bind, string, optional.
* ``index_url`` - full url of the repository index page, string, optional.
* ``max_body_size`` - max body size in bytes to be validated for archive upload, integer, optional. If not set, validation will be disabled.
* ``password`` - password to authorize in web service in order to update service status, string, required in case if authorization enabled.
* ``port`` - port to bind, int, optional.
* ``port`` - port to bind, integer, optional.
* ``static_path`` - path to directory with static files, string, required.
* ``templates`` - path to templates directories, space separated list of strings, required.
* ``timeout`` - HTTP request timeout in seconds, int, optional, default is ``30``.
* ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization.
* ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration.
* ``username`` - username to authorize in web service in order to update service status, string, required in case if authorization enabled.
* ``wait_timeout`` - wait timeout in seconds, maximum amount of time to be waited before lock will be free, int, optional.
* ``wait_timeout`` - wait timeout in seconds, maximum amount of time to be waited before lock will be free, integer, optional.
``keyring`` group
--------------------
@ -237,7 +245,7 @@ Section name must be either ``email`` (plus optional architecture name, e.g. ``e
* ``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.
* ``port`` - SMTP port for sending emails, integer, required.
* ``receivers`` - SMTP receiver addresses, space separated list of strings, required.
* ``sender`` - SMTP sender address, string, required.
* ``ssl`` - SSL mode for SMTP connection, one of ``ssl``, ``starttls``, ``disabled``, optional, default ``disabled``.
@ -267,7 +275,7 @@ Section name must be either ``remote-call`` (plus optional architecture name, e.
* ``aur`` - check for AUR packages updates, boolean, optional, default ``no``.
* ``local`` - check for local packages updates, boolean, optional, default ``no``.
* ``manual`` - update manually built packages, boolean, optional, default ``no``.
* ``wait_timeout`` - maximum amount of time in seconds to be waited before remote process will be terminated, int, optional, default ``-1``.
* ``wait_timeout`` - maximum amount of time in seconds to be waited before remote process will be terminated, integer, optional, default ``-1``.
``telegram`` type
^^^^^^^^^^^^^^^^^
@ -282,7 +290,7 @@ Section name must be either ``telegram`` (plus optional architecture name, e.g.
* ``template`` - Jinja2 template name, string, required.
* ``template_type`` - ``parse_mode`` to be passed to telegram API, one of ``MarkdownV2``, ``HTML``, ``Markdown``, string, optional, default ``HTML``.
* ``templates`` - path to templates directories, space separated list of strings, required.
* ``timeout`` - HTTP request timeout in seconds, int, optional, default is ``30``.
* ``timeout`` - HTTP request timeout in seconds, integer, optional, default is ``30``.
``upload`` group
----------------
@ -312,7 +320,7 @@ This feature requires GitHub key creation (see below). Section name must be eith
#. Generate new token. Required scope is ``public_repo`` (or ``repo`` for private repository support).
* ``repository`` - GitHub repository name, string, required. Repository must be created before any action and must have active branch (e.g. with readme).
* ``timeout`` - HTTP request timeout in seconds, int, optional, default is ``30``.
* ``timeout`` - HTTP request timeout in seconds, integer, optional, default is ``30``.
* ``use_full_release_name`` - if set to ``yes``, the release will contain both repository name and architecture, and only architecture otherwise, boolean, optional, default ``no`` (legacy behavior).
* ``username`` - GitHub authorization user, string, required. Basically the same as ``owner``.
@ -322,7 +330,7 @@ This feature requires GitHub key creation (see below). Section name must be eith
Section name must be either ``remote-service`` (plus optional architecture name, e.g. ``remote-service:x86_64``) or random name with ``type`` set.
* ``type`` - type of the report, string, optional, must be set to ``remote-service`` if exists.
* ``timeout`` - HTTP request timeout in seconds, int, optional, default is ``30``.
* ``timeout`` - HTTP request timeout in seconds, integer, optional, default is ``30``.
``rsync`` type
^^^^^^^^^^^^^^
@ -341,7 +349,7 @@ Requires ``boto3`` library to be installed. Section name must be either ``s3`` (
* ``type`` - type of the upload, string, optional, must be set to ``s3`` if exists.
* ``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.
* ``chunk_size`` - chunk size for calculating entity tags, integer, optional, default 8 * 1024 * 1024.
* ``object_path`` - path prefix for stored objects, string, optional. If none set, the prefix as in repository tree will be used.
* ``region`` - bucket region (e.g. ``eu-central-1``), string, required.
* ``secret_key`` - AWS secret access key, string, required.

View File

@ -869,12 +869,12 @@ Worker nodes configuration
.. code-block:: ini
[web]
address = master.example.com
[status]
address = https://master.example.com
username = worker-user
password = very-secure-password
As it has been mentioned above, ``web.address`` must be available for workers. In case if unix socket is used, it can be passed as ``web.unix_socket`` variable as usual. Optional ``web.username``/``web.password`` can be supplied in case if authentication was enabled on master node.
As it has been mentioned above, ``status.address`` must be available for workers. In case if unix socket is used, it can be passed in the same option as usual. Optional ``status.username``/``status.password`` can be supplied in case if authentication was enabled on master node.
#.
Each worker must call master node on success:
@ -958,7 +958,7 @@ The user ``worker-user`` has been created additionally. Worker node config (``wo
.. code-block:: ini
[web]
[status]
address = http://172.17.0.1:8080
username = worker-user
password = very-secure-password
@ -1142,7 +1142,7 @@ How to enable basic authorization
.. code-block:: ini
[web]
[status]
username = api
password = pa55w0rd

View File

@ -1,13 +1,13 @@
# Maintainer: Evgeniy Alekseev
pkgname='ahriman'
pkgver=2.12.0
pkgver=2.12.2
pkgrel=1
pkgdesc="ArcH linux ReposItory MANager"
arch=('any')
url="https://github.com/arcan1s/ahriman"
license=('GPL3')
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-cerberus' 'python-inflection' 'python-passlib' 'python-requests' 'python-srcinfo')
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-cerberus' 'python-inflection' 'python-passlib' 'python-requests' 'python-srcinfo' 'python-tenacity')
makedepends=('python-build' 'python-flit' 'python-installer' 'python-wheel')
optdepends=('breezy: -bzr packages support'
'darcs: -darcs packages support'

View File

@ -3,7 +3,6 @@ include = ahriman.ini.d
logging = ahriman.ini.d/logging.ini
apply_migrations = yes
database = /var/lib/ahriman/ahriman.db
suppress_http_log_errors = yes
[alpm]
database = /var/lib/pacman
@ -62,6 +61,10 @@ ssl = disabled
template = repo-index.jinja2
templates = /usr/share/ahriman/templates
[status]
enabled = yes
suppress_http_log_errors = yes
[telegram]
template = telegram-index.jinja2
templates = /usr/share/ahriman/templates

View File

@ -182,7 +182,12 @@
const description = response.find(Boolean);
const packages = Object.keys(description.package.packages);
const aurUrl = description.package.remote.web_url;
const upstreamUrls = Object.values(description.package.packages).map(single => single.url);
const upstreamUrls = Array.from(
new Set(
Object.values(description.package.packages)
.map(single => single.url)
)
).sort();
packageInfo.text(`${description.package.base} ${description.status.status} at ${new Date(1000 * description.status.timestamp).toISOStringShort()}`);

View File

@ -2,7 +2,7 @@
_shtab_ahriman_subparsers=('aur-search' 'search' 'help-commands-unsafe' 'help' 'help-updates' 'help-version' 'version' 'package-add' 'add' 'package-update' 'package-remove' 'remove' 'package-status' 'status' 'package-status-remove' 'package-status-update' 'status-update' 'patch-add' 'patch-list' 'patch-remove' 'patch-set-add' 'repo-backup' 'repo-check' 'check' 'repo-create-keyring' 'repo-create-mirrorlist' 'repo-daemon' 'daemon' 'repo-rebuild' 'rebuild' 'repo-remove-unknown' 'remove-unknown' 'repo-report' 'report' 'repo-restore' 'repo-sign' 'sign' 'repo-status-update' 'repo-sync' 'sync' 'repo-tree' 'repo-triggers' 'repo-update' 'update' 'service-clean' 'clean' 'repo-clean' 'service-config' 'config' 'repo-config' 'service-config-validate' 'config-validate' 'repo-config-validate' 'service-key-import' 'key-import' 'service-repositories' 'service-run' 'run' 'service-setup' 'init' 'repo-init' 'repo-setup' 'setup' 'service-shell' 'shell' 'service-tree-migrate' 'user-add' 'user-list' 'user-remove' 'web')
_shtab_ahriman_option_strings=('-h' '--help' '-a' '--architecture' '-c' '--configuration' '--force' '-l' '--lock' '--log-handler' '-q' '--quiet' '--report' '--no-report' '-r' '--repository' '--unsafe' '--wait-timeout' '-V' '--version')
_shtab_ahriman_option_strings=('-h' '--help' '-a' '--architecture' '-c' '--configuration' '--force' '-l' '--lock' '--log-handler' '-q' '--quiet' '--report' '--no-report' '-r' '--repository' '--unsafe' '-V' '--version' '--wait-timeout')
_shtab_ahriman_aur_search_option_strings=('-h' '--help' '-e' '--exit-code' '--info' '--no-info' '--sort-by')
_shtab_ahriman_search_option_strings=('-h' '--help' '-e' '--exit-code' '--info' '--no-info' '--sort-by')
_shtab_ahriman_help_commands_unsafe_option_strings=('-h' '--help')

View File

@ -1,9 +1,9 @@
.TH AHRIMAN "1" "2023\-11\-06" "ahriman" "Generated Python Manual"
.TH AHRIMAN "1" "2023\-11\-13" "ahriman" "Generated Python Manual"
.SH NAME
ahriman
.SH SYNOPSIS
.B ahriman
[-h] [-a ARCHITECTURE] [-c CONFIGURATION] [--force] [-l LOCK] [--log-handler {console,syslog,journald}] [-q] [--report | --no-report] [-r REPOSITORY] [--unsafe] [--wait-timeout WAIT_TIMEOUT] [-V] {aur-search,search,help-commands-unsafe,help,help-updates,help-version,version,package-add,add,package-update,package-remove,remove,package-status,status,package-status-remove,package-status-update,status-update,patch-add,patch-list,patch-remove,patch-set-add,repo-backup,repo-check,check,repo-create-keyring,repo-create-mirrorlist,repo-daemon,daemon,repo-rebuild,rebuild,repo-remove-unknown,remove-unknown,repo-report,report,repo-restore,repo-sign,sign,repo-status-update,repo-sync,sync,repo-tree,repo-triggers,repo-update,update,service-clean,clean,repo-clean,service-config,config,repo-config,service-config-validate,config-validate,repo-config-validate,service-key-import,key-import,service-repositories,service-run,run,service-setup,init,repo-init,repo-setup,setup,service-shell,shell,service-tree-migrate,user-add,user-list,user-remove,web} ...
[-h] [-a ARCHITECTURE] [-c CONFIGURATION] [--force] [-l LOCK] [--log-handler {console,syslog,journald}] [-q] [--report | --no-report] [-r REPOSITORY] [--unsafe] [-V] [--wait-timeout WAIT_TIMEOUT] {aur-search,search,help-commands-unsafe,help,help-updates,help-version,version,package-add,add,package-update,package-remove,remove,package-status,status,package-status-remove,package-status-update,status-update,patch-add,patch-list,patch-remove,patch-set-add,repo-backup,repo-check,check,repo-create-keyring,repo-create-mirrorlist,repo-daemon,daemon,repo-rebuild,rebuild,repo-remove-unknown,remove-unknown,repo-report,report,repo-restore,repo-sign,sign,repo-status-update,repo-sync,sync,repo-tree,repo-triggers,repo-update,update,service-clean,clean,repo-clean,service-config,config,repo-config,service-config-validate,config-validate,repo-config-validate,service-key-import,key-import,service-repositories,service-run,run,service-setup,init,repo-init,repo-setup,setup,service-shell,shell,service-tree-migrate,user-add,user-list,user-remove,web} ...
.SH DESCRIPTION
ArcH linux ReposItory MANager
@ -44,15 +44,15 @@ filter by target repository
\fB\-\-unsafe\fR
allow to run ahriman as non\-ahriman user. Some actions might be unavailable
.TP
\fB\-V\fR, \fB\-\-version\fR
show program's version number and exit
.TP
\fB\-\-wait\-timeout\fR \fI\,WAIT_TIMEOUT\/\fR
wait for lock to be free. Negative value will lead to immediate application run even if there is lock file. In case of
zero value, the application will wait infinitely
.TP
\fB\-V\fR, \fB\-\-version\fR
show program's version number and exit
.SH
COMMAND
.TP
@ -208,22 +208,22 @@ sort field by this field. In case if two packages have the same value of the spe
by name
.SH COMMAND \fI\,'ahriman help\-commands\-unsafe'\/\fR
usage: ahriman help\-commands\-unsafe [\-h] [command ...]
usage: ahriman help\-commands\-unsafe [\-h] [subcommand ...]
list unsafe commands as defined in default args
.TP
\fBcommand\fR
\fBsubcommand\fR
instead of showing commands, just test command line for unsafe subcommand and return 0 in case if command is safe and 1
otherwise
.SH COMMAND \fI\,'ahriman help'\/\fR
usage: ahriman help [\-h] [command]
usage: ahriman help [\-h] [subcommand]
show help message for application or command and exit
.TP
\fBcommand\fR
\fBsubcommand\fR
show help message for specific command
.SH COMMAND \fI\,'ahriman help\-updates'\/\fR

View File

@ -90,8 +90,8 @@ _shtab_ahriman_options=(
{--report,--no-report}"[force enable or disable reporting to web service (default\: True)]:report:"
{-r,--repository}"[filter by target repository (default\: None)]:repository:"
"--unsafe[allow to run ahriman as non-ahriman user. Some actions might be unavailable (default\: False)]"
"--wait-timeout[wait for lock to be free. Negative value will lead to immediate application run even if there is lock file. In case of zero value, the application will wait infinitely (default\: -1)]:wait_timeout:"
"(- : *)"{-V,--version}"[show program\'s version number and exit]"
"--wait-timeout[wait for lock to be free. Negative value will lead to immediate application run even if there is lock file. In case of zero value, the application will wait infinitely (default\: -1)]:wait_timeout:"
)
_shtab_ahriman_add_options=(

View File

@ -31,6 +31,7 @@ class MethodTypeOrder(StrEnum):
Attributes:
Class(MethodTypeOrder): (class attribute) class method
Delete(MethodTypeOrder): (class attribute) destructor-like methods
Init(MethodTypeOrder): (class attribute) initialization method
Magic(MethodTypeOrder): (class attribute) other magical methods
New(MethodTypeOrder): (class attribute) constructor method
@ -40,6 +41,7 @@ class MethodTypeOrder(StrEnum):
"""
Class = "classmethod"
Delete = "del"
Init = "init"
Magic = "magic"
New = "new"
@ -76,8 +78,9 @@ class DefinitionOrder(BaseRawFileChecker):
"method-type-order",
{
"default": [
"new",
"init",
"new",
"del",
"property",
"classmethod",
"staticmethod",
@ -122,10 +125,12 @@ class DefinitionOrder(BaseRawFileChecker):
MethodTypeOrder: resolved function type
"""
# init methods
if function.name in ("__new__",):
return MethodTypeOrder.New
if function.name in ("__init__", "__post_init__"):
return MethodTypeOrder.Init
if function.name in ("__new__",):
return MethodTypeOrder.New
if function.name in ("__del__",):
return MethodTypeOrder.Delete
# decorated methods
decorators = []

View File

@ -21,6 +21,7 @@ dependencies = [
"passlib",
"requests",
"srcinfo",
"tenacity",
]
dynamic = ["version"]

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__ = "2.12.0"
__version__ = "2.12.2"

View File

@ -87,11 +87,11 @@ def _parser() -> argparse.ArgumentParser:
parser.add_argument("--repository-id", help=argparse.SUPPRESS)
parser.add_argument("--unsafe", help="allow to run ahriman as non-ahriman user. Some actions might be unavailable",
action="store_true")
parser.add_argument("-V", "--version", action="version", version=__version__)
parser.add_argument("--wait-timeout", help="wait for lock to be free. Negative value will lead to "
"immediate application run even if there is lock file. "
"In case of zero value, the application will wait infinitely",
type=int, default=-1)
parser.add_argument("-V", "--version", action="version", version=__version__)
subparsers = parser.add_subparsers(title="command", help="command to run", dest="command")
@ -178,8 +178,8 @@ def _set_help_commands_unsafe_parser(root: SubParserAction) -> argparse.Argument
"""
parser = root.add_parser("help-commands-unsafe", help="list unsafe commands",
description="list unsafe commands as defined in default args", formatter_class=_formatter)
parser.add_argument("command", help="instead of showing commands, just test command line for unsafe subcommand "
"and return 0 in case if command is safe and 1 otherwise", nargs="*")
parser.add_argument("subcommand", help="instead of showing commands, just test command line for unsafe subcommand "
"and return 0 in case if command is safe and 1 otherwise", nargs="*")
parser.set_defaults(handler=handlers.UnsafeCommands, architecture="", lock=None, quiet=True, report=False,
repository="", unsafe=True, parser=_parser)
return parser
@ -198,7 +198,7 @@ def _set_help_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("help", help="show help message",
description="show help message for application or command and exit",
formatter_class=_formatter)
parser.add_argument("command", help="show help message for specific command", nargs="?")
parser.add_argument("subcommand", help="show help message for specific command", nargs="?")
parser.set_defaults(handler=handlers.Help, architecture="", lock=None, quiet=True, report=False, repository="",
unsafe=True, parser=_parser)
return parser

View File

@ -167,12 +167,16 @@ class ApplicationPackages(ApplicationProperties):
"""
raise NotImplementedError
def remove(self, names: Iterable[str]) -> None:
def remove(self, names: Iterable[str]) -> Result:
"""
remove packages from repository
Args:
names(Iterable[str]): list of packages (either base or name) to remove
Returns:
Result: removal result
"""
self.repository.process_remove(names)
self.on_result(Result())
result = self.repository.process_remove(names)
self.on_result(result)
return result

View File

@ -44,7 +44,7 @@ class Help(Handler):
report(bool): force enable or disable reporting
"""
parser: argparse.ArgumentParser = args.parser()
if args.command is None:
if args.subcommand is None:
parser.parse_args(["--help"])
else:
parser.parse_args([args.command, "--help"])
parser.parse_args([args.subcommand, "--help"])

View File

@ -24,6 +24,7 @@ from ahriman.application.handlers import Handler
from ahriman.core.configuration import Configuration
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
from ahriman.models.packagers import Packagers
from ahriman.models.repository_id import RepositoryId
@ -55,7 +56,7 @@ class Rebuild(Handler):
application.print_updates(updates, log_fn=print)
return
result = application.update(updates, args.username, bump_pkgrel=args.increment)
result = application.update(updates, Packagers(args.username), bump_pkgrel=args.increment)
Rebuild.check_if_empty(args.exit_code, result.is_empty)
@staticmethod

View File

@ -21,6 +21,7 @@ import argparse
from pathlib import Path
from pwd import getpwuid
from urllib.parse import quote_plus as urlencode
from ahriman.application.application import Application
from ahriman.application.handlers import Handler
@ -128,8 +129,12 @@ class Setup(Handler):
if args.web_port is not None:
configuration.set_option("web", "port", str(args.web_port))
if (host := root.get("web", "host", fallback=None)) is not None:
configuration.set_option("status", "address", f"http://{host}:{args.web_port}")
if args.web_unix_socket is not None:
configuration.set_option("web", "unix_socket", str(args.web_unix_socket))
unix_socket = str(args.web_unix_socket)
configuration.set_option("web", "unix_socket", unix_socket)
configuration.set_option("status", "address", f"http+unix://{urlencode(unix_socket)}")
if args.generate_salt:
configuration.set_option("auth", "salt", User.generate_password(20))

View File

@ -46,8 +46,8 @@ class UnsafeCommands(Handler):
"""
parser = args.parser()
unsafe_commands = UnsafeCommands.get_unsafe_commands(parser)
if args.command:
UnsafeCommands.check_unsafe(args.command, unsafe_commands, parser)
if args.subcommand:
UnsafeCommands.check_unsafe(args.subcommand, unsafe_commands, parser)
else:
for command in unsafe_commands:
StringPrinter(command)(verbose=True)

View File

@ -249,6 +249,37 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
},
},
},
"status": {
"type": "dict",
"schema": {
"enabled": {
"type": "boolean",
"coerce": "boolean",
},
"address": {
"type": "string",
"empty": False,
"is_url": [],
},
"password": {
"type": "string",
"empty": False,
},
"suppress_http_log_errors": {
"type": "boolean",
"coerce": "boolean",
},
"timeout": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
"username": {
"type": "string",
"empty": False,
},
},
},
"web": {
"type": "dict",
"schema": {

View File

@ -316,21 +316,6 @@ class UnknownPackageError(ValueError):
ValueError.__init__(self, f"Package base {package_base} is unknown")
class UnprocessedPackageStatusError(ValueError):
"""
exception for merging invalid statues
"""
def __init__(self, package_base: str) -> None:
"""
default constructor
Args:
package_base(str): package base name
"""
ValueError.__init__(self, f"Package base {package_base} had status failed, but new status is success")
class UnsafeRunError(RuntimeError):
"""
exception which will be raised in case if user is not owner of repository

View File

@ -17,4 +17,5 @@
# 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 ahriman.core.http.sync_ahriman_client import SyncAhrimanClient
from ahriman.core.http.sync_http_client import MultipartType, SyncHttpClient

View File

@ -0,0 +1,134 @@
#
# Copyright (c) 2021-2023 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 contextlib
import requests
import tenacity
from functools import cached_property
from typing import Any
from urllib.parse import urlparse
from ahriman import __version__
from ahriman.core.http.sync_http_client import SyncHttpClient
class SyncAhrimanClient(SyncHttpClient):
"""
wrapper for ahriman web service
Attributes:
address(str): address of the web service
"""
address: str
@cached_property
def session(self) -> requests.Session:
"""
get or create session
Returns:
request.Session: created session object
"""
if urlparse(self.address).scheme == "http+unix":
import requests_unixsocket # type: ignore[import-untyped]
session: requests.Session = requests_unixsocket.Session()
session.headers["User-Agent"] = f"ahriman/{__version__}"
return session
session = requests.Session()
session.headers["User-Agent"] = f"ahriman/{__version__}"
self._login(session)
return session
@staticmethod
def is_retry_allowed(exception: BaseException) -> bool:
"""
check if retry is allowed for the exception
Args:
exception(BaseException): exception raised
Returns:
bool: True in case if exception is in white list and false otherwise
"""
if not isinstance(exception, requests.RequestException):
return False # not a request exception
status_code = exception.response.status_code if exception.response is not None else None
return status_code in (401,)
@staticmethod
def on_retry(state: tenacity.RetryCallState) -> None:
"""
action to be called before retry
Args:
state(tenacity.RetryCallState): current retry call state
"""
instance = next(arg for arg in state.args if isinstance(arg, SyncAhrimanClient))
if hasattr(instance, "session"): # only if it was initialized
del instance.session # clear session
def _login(self, session: requests.Session) -> None:
"""
process login to the service
Args:
session(requests.Session): request session to login
"""
if self.auth is None:
return # no auth configured
username, password = self.auth
payload = {
"username": username,
"password": password,
}
with contextlib.suppress(Exception):
self.make_request("POST", self._login_url(), json=payload, session=session)
def _login_url(self) -> str:
"""
get url for the login api
Returns:
str: full url for web service to log in
"""
return f"{self.address}/api/v1/login"
@tenacity.retry(
stop=tenacity.stop_after_attempt(2),
retry=tenacity.retry_if_exception(is_retry_allowed),
after=on_retry,
reraise=True,
)
def make_request(self, *args: Any, **kwargs: Any) -> requests.Response:
"""
perform request with specified parameters
Args:
*args(Any): request method positional arguments
**kwargs(Any): request method keyword arguments
Returns:
requests.Response: response object
"""
return SyncHttpClient.make_request(self, *args, **kwargs)

View File

@ -77,12 +77,12 @@ class SyncHttpClient(LazyLogging):
return session
@staticmethod
def exception_response_text(exception: requests.exceptions.RequestException) -> str:
def exception_response_text(exception: requests.RequestException) -> str:
"""
safe response exception text generation
Args:
exception(requests.exceptions.RequestException): exception raised
exception(requests.RequestException): exception raised
Returns:
str: text of the response if it is not None and empty string otherwise

View File

@ -72,7 +72,9 @@ class HttpLogHandler(logging.Handler):
if (handler := next((handler for handler in root.handlers if isinstance(handler, cls)), None)) is not None:
return handler # there is already registered instance
suppress_errors = configuration.getboolean("settings", "suppress_http_log_errors", fallback=False)
suppress_errors = configuration.getboolean( # read old-style first and then fallback to new style
"settings", "suppress_http_log_errors",
fallback=configuration.getboolean("status", "suppress_http_log_errors", fallback=False))
handler = cls(repository_id, configuration, report=report, suppress_errors=suppress_errors)
root.addHandler(handler)

View File

@ -120,6 +120,6 @@ class Email(Report, JinjaTemplate):
text = self.make_html(result, self.template)
attachments = {}
if self.template_full is not None:
attachments["index.html"] = self.make_html(Result(success=packages), self.template_full)
attachments["index.html"] = self.make_html(Result(updated=packages), self.template_full)
self._send(text, attachments)

View File

@ -58,5 +58,5 @@ class HTML(Report, JinjaTemplate):
packages(list[Package]): list of packages to generate report
result(Result): build result
"""
html = self.make_html(Result(success=packages), self.template)
html = self.make_html(Result(updated=packages), self.template)
self.report_path.write_text(html, encoding="utf8")

View File

@ -98,7 +98,7 @@ class Executor(Cleaner):
try:
packager = self.packager(packagers, single.base)
build_single(single, Path(dir_name), packager.packager_id)
result.add_success(single)
result.add_updated(single)
except Exception:
self.reporter.set_failed(single.base)
result.add_failed(single)
@ -106,7 +106,7 @@ class Executor(Cleaner):
return result
def process_remove(self, packages: Iterable[str]) -> Path:
def process_remove(self, packages: Iterable[str]) -> Result:
"""
remove packages from list
@ -114,7 +114,7 @@ class Executor(Cleaner):
packages(Iterable[str]): list of package names or bases to remove
Returns:
Path: path to repository database
Result: remove result
"""
def remove_base(package_base: str) -> None:
try:
@ -126,9 +126,9 @@ class Executor(Cleaner):
except Exception:
self.logger.exception("could not remove base %s", package_base)
def remove_package(package: str, fn: Path) -> None:
def remove_package(package: str, archive_path: Path) -> None:
try:
self.repo.remove(package, fn) # remove the package itself
self.repo.remove(package, archive_path) # remove the package itself
except Exception:
self.logger.exception("could not remove %s", package)
@ -136,6 +136,7 @@ class Executor(Cleaner):
bases_to_remove: list[str] = []
# build package list based on user input
result = Result()
requested = set(packages)
for local in self.packages():
if local.base in packages or all(package in requested for package in local.packages):
@ -145,6 +146,7 @@ class Executor(Cleaner):
if properties.filepath is not None
})
bases_to_remove.append(local.base)
result.add_removed(local)
elif requested.intersection(local.packages.keys()):
packages_to_remove.update({
package: properties.filepath
@ -167,7 +169,7 @@ class Executor(Cleaner):
for package in bases_to_remove:
remove_base(package)
return self.repo.repo_path
return result
def process_update(self, packages: Iterable[Path], packagers: Packagers | None = None) -> Result:
"""
@ -219,7 +221,7 @@ class Executor(Cleaner):
rename(description, local.base)
update_single(description.filename, local.base, packager.key)
self.reporter.set_success(local)
result.add_success(local)
result.add_updated(local)
current_package_archives: set[str] = set()
if local.base in current_packages:

View File

@ -49,16 +49,19 @@ class Client:
"""
if not report:
return Client()
if not configuration.getboolean("status", "enabled", fallback=True): # global switch
return Client()
address = configuration.get("web", "address", fallback=None)
# new-style section
address = configuration.get("status", "address", fallback=None)
# old-style section
legacy_address = configuration.get("web", "address", fallback=None)
host = configuration.get("web", "host", fallback=None)
port = configuration.getint("web", "port", fallback=None)
socket = configuration.get("web", "unix_socket", fallback=None)
# basically we just check if there is something we can use for interaction with remote server
# at the moment (end of 2022) I think it would be much better idea to introduce flag like `enabled`,
# but it will totally break used experience
if address or (host and port) or socket:
if address or legacy_address or (host and port) or socket:
from ahriman.core.status.web_client import WebClient
return WebClient(repository_id, configuration)
return Client()

View File

@ -19,14 +19,11 @@
#
import contextlib
import logging
import requests
from functools import cached_property
from urllib.parse import quote_plus as urlencode
from ahriman import __version__
from ahriman.core.configuration import Configuration
from ahriman.core.http import SyncHttpClient
from ahriman.core.http import SyncAhrimanClient
from ahriman.core.status.client import Client
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.internal_status import InternalStatus
@ -35,14 +32,12 @@ from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId
class WebClient(Client, SyncHttpClient):
class WebClient(Client, SyncAhrimanClient):
"""
build status reporter web client
Attributes:
address(str): address of the web service
repository_id(RepositoryId): repository unique identifier
use_unix_socket(bool): use websocket or not
"""
def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:
@ -53,92 +48,40 @@ class WebClient(Client, SyncHttpClient):
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
"""
suppress_errors = configuration.getboolean("settings", "suppress_http_log_errors", fallback=False)
SyncHttpClient.__init__(self, configuration, "web", suppress_errors=suppress_errors)
section, self.address = self.parse_address(configuration)
suppress_errors = configuration.getboolean( # read old-style first and then fallback to new style
"settings", "suppress_http_log_errors",
fallback=configuration.getboolean("status", "suppress_http_log_errors", fallback=False))
SyncAhrimanClient.__init__(self, configuration, section, suppress_errors=suppress_errors)
self.repository_id = repository_id
self.address, self.use_unix_socket = self.parse_address(configuration)
@cached_property
def session(self) -> requests.Session:
"""
get or create session
Returns:
request.Session: created session object
"""
return self._create_session(use_unix_socket=self.use_unix_socket)
@staticmethod
def parse_address(configuration: Configuration) -> tuple[str, bool]:
def parse_address(configuration: Configuration) -> tuple[str, str]:
"""
parse address from configuration
parse address from legacy configuration
Args:
configuration(Configuration): configuration instance
Returns:
tuple[str, bool]: tuple of server address and socket flag (True in case if unix socket must be used)
tuple[str, str]: tuple of section name and server address
"""
# new-style section
if (address := configuration.get("status", "address", fallback=None)) is not None:
return "status", address
# legacy-style section
if (unix_socket := configuration.get("web", "unix_socket", fallback=None)) is not None:
# special pseudo-protocol which is used for unix sockets
return f"http+unix://{urlencode(unix_socket)}", True
return "web", f"http+unix://{urlencode(unix_socket)}"
address = configuration.get("web", "address", fallback=None)
if not address:
# build address from host and port directly
host = configuration.get("web", "host")
port = configuration.getint("web", "port")
address = f"http://{host}:{port}"
return address, False
def _create_session(self, *, use_unix_socket: bool) -> requests.Session:
"""
generate new request session
Args:
use_unix_socket(bool): if set to True then unix socket session will be generated instead of native requests
Returns:
requests.Session: generated session object
"""
if use_unix_socket:
import requests_unixsocket # type: ignore[import-untyped]
session: requests.Session = requests_unixsocket.Session()
session.headers["User-Agent"] = f"ahriman/{__version__}"
return session
session = requests.Session()
session.headers["User-Agent"] = f"ahriman/{__version__}"
self._login(session)
return session
def _login(self, session: requests.Session) -> None:
"""
process login to the service
Args:
session(requests.Session): request session to login
"""
if self.auth is None:
return # no auth configured
username, password = self.auth
payload = {
"username": username,
"password": password,
}
with contextlib.suppress(Exception):
self.make_request("POST", self._login_url(), json=payload, session=session)
def _login_url(self) -> str:
"""
get url for the login api
Returns:
str: full url for web service to log in
"""
return f"{self.address}/api/v1/login"
return "web", address
def _logs_url(self, package_base: str) -> str:
"""

View File

@ -65,6 +65,14 @@ class TriggerLoader(LazyLogging):
self._on_stop_requested = False
self.triggers: list[Trigger] = []
def __del__(self) -> None:
"""
custom destructor object which calls on_stop in case if it was requested
"""
if not self._on_stop_requested:
return
self.on_stop()
@classmethod
def load(cls, repository_id: RepositoryId, configuration: Configuration) -> Self:
"""
@ -257,11 +265,3 @@ class TriggerLoader(LazyLogging):
for trigger in self.triggers:
with self.__execute_trigger(trigger):
trigger.on_stop()
def __del__(self) -> None:
"""
custom destructor object which calls on_stop in case if it was requested
"""
if not self._on_stop_requested:
return
self.on_stop()

View File

@ -19,28 +19,50 @@
#
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from collections.abc import Iterable, Callable
from typing import Any, Self
from ahriman.core.exceptions import UnprocessedPackageStatusError
from ahriman.models.package import Package
class Result:
"""
build result class holder
Attributes:
STATUS_PRIORITIES(list[str]): (class attribute) list of statues according to their priorities
"""
def __init__(self, success: Iterable[Package] | None = None, failed: Iterable[Package] | None = None) -> None:
STATUS_PRIORITIES = [
"failed",
"removed",
"updated",
"added",
]
def __init__(self, *, added: Iterable[Package] | None = None, updated: Iterable[Package] | None = None,
removed: Iterable[Package] | None = None, failed: Iterable[Package] | None = None) -> None:
"""
default constructor
Args:
success(Iterable[Package] | None, optional): initial list of successes packages (Default value = None)
addded(Iterable[Package] | None, optional): initial list of successfully added packages
(Default value = None)
updated(Iterable[Package] | None, optional): initial list of successfully updated packages
(Default value = None)
removed(Iterable[Package] | None, optional): initial list of successfully removed packages
(Default value = None)
failed(Iterable[Package] | None, optional): initial list of failed packages (Default value = None)
"""
success = success or []
self._success = {package.base: package for package in success}
added = added or []
self._added = {package.base: package for package in added}
updated = updated or []
self._updated = {package.base: package for package in updated}
removed = removed or []
self._removed = {package.base: package for package in removed}
failed = failed or []
self._failed = {package.base: package for package in failed}
@ -62,7 +84,17 @@ class Result:
Returns:
bool: True in case if success list is empty and False otherwise
"""
return not bool(self._success)
return not self._added and not self._updated
@property
def removed(self) -> list[Package]:
"""
get list of removed packages
Returns:
list[Package]: list of packages successfully removed
"""
return list(self._removed.values())
@property
def success(self) -> list[Package]:
@ -72,7 +104,16 @@ class Result:
Returns:
list[Package]: list of packages with success result
"""
return list(self._success.values())
return list(self._added.values()) + list(self._updated.values())
def add_added(self, package: Package) -> None:
"""
add new package to new packages list
Args:
package(Package): package removed
"""
self._added[package.base] = package
def add_failed(self, package: Package) -> None:
"""
@ -83,17 +124,26 @@ class Result:
"""
self._failed[package.base] = package
def add_success(self, package: Package) -> None:
def add_removed(self, package: Package) -> None:
"""
add new package to removed list
Args:
package(Package): package removed
"""
self._removed[package.base] = package
def add_updated(self, package: Package) -> None:
"""
add new package to success built
Args:
package(Package): package built
"""
self._success[package.base] = package
self._updated[package.base] = package
# pylint: disable=protected-access
def merge(self, other: Result) -> Result:
def merge(self, other: Result) -> Self:
"""
merge other result into this one. This method assumes that other has fresh info about status and override it
@ -101,19 +151,35 @@ class Result:
other(Result): instance of the newest result
Returns:
Result: updated instance
Raises:
UnprocessedPackageStatusError: if there is previously failed package which is masked as success
Self: updated instance
"""
for base, package in other._failed.items():
if base in self._success:
del self._success[base]
self.add_failed(package)
for base, package in other._success.items():
if base in self._failed:
raise UnprocessedPackageStatusError(base)
self.add_success(package)
for status in self.STATUS_PRIORITIES:
new_packages: Iterable[Package] = getattr(other, f"_{status}", {}).values()
insert_package: Callable[[Package], None] = getattr(self, f"add_{status}")
for package in new_packages:
insert_package(package)
return self.refine()
def refine(self) -> Self:
"""
merge packages between different results (e.g. remove failed from added, etc.) removing duplicates
Returns:
Self: updated instance
"""
for index, base_status in enumerate(self.STATUS_PRIORITIES):
# extract top-level packages
base_packages: Iterable[str] = getattr(self, f"_{base_status}", {}).keys()
# extract packages for each bottom-level
for status in self.STATUS_PRIORITIES[index + 1:]:
packages: dict[str, Package] = getattr(self, f"_{status}", {})
# if there is top-level package in bottom-level, then remove it
for base in base_packages:
if base in packages:
del packages[base]
return self
# required for tests at least
@ -129,4 +195,7 @@ class Result:
"""
if not isinstance(other, Result):
return False
return self.success == other.success and self.failed == other.failed
return self._added == other._added \
and self._removed == other._removed \
and self._updated == other._updated \
and self._failed == other._failed

View File

@ -17,7 +17,7 @@
# 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 HTTPFound
from aiohttp.web import HTTPFound, HTTPNotFound
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
@ -40,5 +40,8 @@ class StaticView(BaseView):
Raises:
HTTPFound: on success response
HTTPNotFound: if path is invalid or unknown
"""
raise HTTPFound(f"/static{self.request.path}")
if self.request.path in self.ROUTES: # explicit validation
raise HTTPFound(f"/static{self.request.path}")
raise HTTPNotFound

View File

@ -228,7 +228,7 @@ def test_remove(application_packages: ApplicationPackages, mocker: MockerFixture
"""
must remove package
"""
executor_mock = mocker.patch("ahriman.core.repository.executor.Executor.process_remove")
executor_mock = mocker.patch("ahriman.core.repository.executor.Executor.process_remove", return_value=Result())
on_result_mock = mocker.patch("ahriman.application.application.application_packages.ApplicationPackages.on_result")
application_packages.remove([])

View File

@ -77,7 +77,7 @@ def test_run_with_updates(args: argparse.Namespace, configuration: Configuration
args = _default_args(args)
args.now = True
result = Result()
result.add_success(package_ahriman)
result.add_updated(package_ahriman)
mocker.patch("ahriman.application.application.Application.add")
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
application_mock = mocker.patch("ahriman.application.application.Application.update", return_value=result)

View File

@ -18,7 +18,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
argparse.Namespace: generated arguments for these test cases
"""
args.parser = _parser
args.command = None
args.subcommand = None
return args
@ -39,7 +39,7 @@ def test_run_command(args: argparse.Namespace, configuration: Configuration, moc
must run command for specific subcommand
"""
args = _default_args(args)
args.command = "aur-search"
args.subcommand = "aur-search"
parse_mock = mocker.patch("argparse.ArgumentParser.parse_args")
_, repository_id = configuration.check_loaded()

View File

@ -10,6 +10,7 @@ from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.package import Package
from ahriman.models.packagers import Packagers
from ahriman.models.result import Result
@ -40,7 +41,7 @@ def test_run(args: argparse.Namespace, package_ahriman: Package, configuration:
"""
args = _default_args(args)
result = Result()
result.add_success(package_ahriman)
result.add_updated(package_ahriman)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
extract_mock = mocker.patch("ahriman.application.handlers.Rebuild.extract_packages", return_value=[package_ahriman])
application_packages_mock = mocker.patch("ahriman.core.repository.repository.Repository.packages_depend_on",
@ -53,7 +54,7 @@ def test_run(args: argparse.Namespace, package_ahriman: Package, configuration:
Rebuild.run(args, repository_id, configuration, report=False)
extract_mock.assert_called_once_with(pytest.helpers.anyvar(int), args.status, from_database=args.from_database)
application_packages_mock.assert_called_once_with([package_ahriman], None)
application_mock.assert_called_once_with([package_ahriman], args.username, bump_pkgrel=args.increment)
application_mock.assert_called_once_with([package_ahriman], Packagers(args.username), bump_pkgrel=args.increment)
check_mock.assert_has_calls([MockCall(False, False), MockCall(False, False)])
on_start_mock.assert_called_once_with()

View File

@ -5,6 +5,7 @@ from pathlib import Path
from pytest_mock import MockerFixture
from typing import Any
from unittest.mock import call as MockCall
from urllib.parse import quote_plus as urlencode
from ahriman.application.handlers import Setup
from ahriman.core.configuration import Configuration
@ -145,7 +146,9 @@ def test_configuration_create_ahriman(args: argparse.Namespace, configuration: C
MockCall(Configuration.section_name("sign", repository_id.name, repository_id.architecture), "key",
args.sign_key),
MockCall("web", "port", str(args.web_port)),
MockCall("status", "address", f"http://127.0.0.1:{str(args.web_port)}"),
MockCall("web", "unix_socket", str(args.web_unix_socket)),
MockCall("status", "address", f"http+unix://{urlencode(str(args.web_unix_socket))}"),
MockCall("auth", "salt", pytest.helpers.anyvar(str, strict=True)),
])
write_mock.assert_called_once_with(pytest.helpers.anyvar(int))

View File

@ -19,7 +19,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
argparse.Namespace: generated arguments for these test cases
"""
args.parser = _parser
args.command = []
args.subcommand = []
return args
@ -43,7 +43,7 @@ def test_run_check(args: argparse.Namespace, configuration: Configuration, mocke
must run command and check if command is unsafe
"""
args = _default_args(args)
args.command = ["clean"]
args.subcommand = ["clean"]
commands_mock = mocker.patch("ahriman.application.handlers.UnsafeCommands.get_unsafe_commands",
return_value=["command"])
check_mock = mocker.patch("ahriman.application.handlers.UnsafeCommands.check_unsafe")

View File

@ -44,7 +44,7 @@ def test_run(args: argparse.Namespace, package_ahriman: Package, configuration:
"""
args = _default_args(args)
result = Result()
result.add_success(package_ahriman)
result.add_updated(package_ahriman)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
application_mock = mocker.patch("ahriman.application.application.Application.update", return_value=result)
check_mock = mocker.patch("ahriman.application.handlers.Handler.check_if_empty")

View File

@ -88,7 +88,7 @@ def test_clear(lock: Lock) -> None:
"""
must remove lock file
"""
lock.path = Path(tempfile.mktemp()) # nosec
lock.path = Path(tempfile.gettempdir()) / "ahriman-test.lock"
lock.path.touch()
lock.clear()
@ -99,7 +99,7 @@ def test_clear_missing(lock: Lock) -> None:
"""
must not fail on lock removal if file is missing
"""
lock.path = Path(tempfile.mktemp()) # nosec
lock.path = Path(tempfile.gettempdir()) / "ahriman-test.lock"
lock.clear()
@ -116,7 +116,7 @@ def test_create(lock: Lock) -> None:
"""
must create lock
"""
lock.path = Path(tempfile.mktemp()) # nosec
lock.path = Path(tempfile.gettempdir()) / "ahriman-test.lock"
lock.create()
assert lock.path.is_file()
@ -127,7 +127,7 @@ def test_create_exception(lock: Lock) -> None:
"""
must raise exception if file already exists
"""
lock.path = Path(tempfile.mktemp()) # nosec
lock.path = Path(tempfile.gettempdir()) / "ahriman-test.lock"
lock.path.touch()
with pytest.raises(DuplicateRunError):
@ -149,7 +149,7 @@ def test_create_unsafe(lock: Lock) -> None:
must not raise exception if force flag set
"""
lock.force = True
lock.path = Path(tempfile.mktemp()) # nosec
lock.path = Path(tempfile.gettempdir()) / "ahriman-test.lock"
lock.path.touch()
lock.create()
@ -161,7 +161,7 @@ def test_watch(lock: Lock, mocker: MockerFixture) -> None:
must check if lock file exists
"""
wait_mock = mocker.patch("ahriman.models.waiter.Waiter.wait")
lock.path = Path(tempfile.mktemp()) # nosec
lock.path = Path(tempfile.gettempdir()) / "ahriman-test.lock"
lock.watch()
wait_mock.assert_called_once_with(lock.path.is_file)

View File

@ -526,7 +526,7 @@ def result(package_ahriman: Package) -> Result:
Result: result test instance
"""
result = Result()
result.add_success(package_ahriman)
result.add_updated(package_ahriman)
return result

View File

@ -0,0 +1,21 @@
import pytest
from ahriman.core.configuration import Configuration
from ahriman.core.http import SyncAhrimanClient
from ahriman.core.status.web_client import WebClient
@pytest.fixture
def ahriman_client(configuration: Configuration) -> SyncAhrimanClient:
"""
ahriman web client fixture
Args:
configuration(Configuration): configuration fixture
Returns:
SyncAhrimanClient: ahriman web client test instance
"""
configuration.set("web", "port", "8080")
_, repository_id = configuration.check_loaded()
return WebClient(repository_id, configuration)

View File

@ -0,0 +1,146 @@
import pytest
import requests
import requests_unixsocket
import tenacity
from pytest_mock import MockerFixture
from unittest.mock import call as MockCall
from ahriman.core.http import SyncAhrimanClient
from ahriman.models.user import User
def test_session(ahriman_client: SyncAhrimanClient, mocker: MockerFixture) -> None:
"""
must create normal requests session
"""
login_mock = mocker.patch("ahriman.core.http.SyncAhrimanClient._login")
assert isinstance(ahriman_client.session, requests.Session)
assert not isinstance(ahriman_client.session, requests_unixsocket.Session)
login_mock.assert_called_once_with(pytest.helpers.anyvar(int))
def test_session_unix_socket(ahriman_client: SyncAhrimanClient, mocker: MockerFixture) -> None:
"""
must create unix socket session
"""
login_mock = mocker.patch("ahriman.core.http.SyncAhrimanClient._login")
ahriman_client.address = "http+unix://path"
assert isinstance(ahriman_client.session, requests_unixsocket.Session)
login_mock.assert_not_called()
def test_is_retry_allowed() -> None:
"""
must allow retries on 401 errors
"""
assert not SyncAhrimanClient.is_retry_allowed(Exception())
response = requests.Response()
response.status_code = 400
assert not SyncAhrimanClient.is_retry_allowed(requests.HTTPError(response=response))
response.status_code = 401
assert SyncAhrimanClient.is_retry_allowed(requests.HTTPError(response=response))
response.status_code = 403
assert not SyncAhrimanClient.is_retry_allowed(requests.HTTPError(response=response))
def test_on_retry(ahriman_client: SyncAhrimanClient) -> None:
"""
must remove session on retry
"""
SyncAhrimanClient.on_retry(
tenacity.RetryCallState(
retry_object=tenacity.Retrying(),
fn=SyncAhrimanClient.make_request,
args=(ahriman_client,),
kwargs={},
)
)
def test_login(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixture) -> None:
"""
must login user
"""
ahriman_client.auth = (user.username, user.password)
requests_mock = mocker.patch("ahriman.core.http.SyncAhrimanClient.make_request")
payload = {
"username": user.username,
"password": user.password
}
session = requests.Session()
ahriman_client._login(session)
requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True), json=payload, session=session)
def test_login_failed(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during login
"""
ahriman_client.user = user
mocker.patch("requests.Session.request", side_effect=Exception())
ahriman_client._login(requests.Session())
def test_login_failed_http_error(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixture) -> None:
"""
must suppress HTTP exception happened during login
"""
ahriman_client.user = user
mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
ahriman_client._login(requests.Session())
def test_login_skip(ahriman_client: SyncAhrimanClient, mocker: MockerFixture) -> None:
"""
must skip login if no user set
"""
requests_mock = mocker.patch("requests.Session.request")
ahriman_client._login(requests.Session())
requests_mock.assert_not_called()
def test_login_url(ahriman_client: SyncAhrimanClient) -> None:
"""
must generate login url correctly
"""
assert ahriman_client._login_url().startswith(ahriman_client.address)
assert ahriman_client._login_url().endswith("/api/v1/login")
def test_make_request_retry(ahriman_client: SyncAhrimanClient, mocker: MockerFixture) -> None:
"""
must retry HTTP request
"""
response_ok = requests.Response()
response_ok.status_code = 200
response_error = requests.Response()
response_error.status_code = 401
# login on init -> request with error -> login on error -> request without error
request_mock = mocker.patch("requests.Session.request", side_effect=[
response_ok,
response_error,
response_ok,
response_ok,
])
ahriman_client.auth = ("username", "password")
assert ahriman_client.make_request("GET", "url") is not None
request_mock.assert_has_calls([
MockCall("POST", "http://127.0.0.1:8080/api/v1/login", params=None, data=None, headers=None, files=None,
json={"username": "username", "password": "password"},
auth=ahriman_client.auth, timeout=ahriman_client.timeout),
MockCall("GET", "url", params=None, data=None, headers=None, files=None, json=None,
auth=ahriman_client.auth, timeout=ahriman_client.timeout),
MockCall("POST", "http://127.0.0.1:8080/api/v1/login", params=None, data=None, headers=None, files=None,
json={"username": "username", "password": "password"},
auth=ahriman_client.auth, timeout=ahriman_client.timeout),
MockCall("GET", "url", params=None, data=None, headers=None, files=None, json=None,
auth=ahriman_client.auth, timeout=ahriman_client.timeout),
])

View File

@ -11,7 +11,7 @@ def test_generate(configuration: Configuration, package_ahriman: Package) -> Non
name = configuration.getpath("html", "template")
_, repository_id = configuration.check_loaded()
report = JinjaTemplate(repository_id, configuration, "html")
assert report.make_html(Result(success=[package_ahriman]), name)
assert report.make_html(Result(updated=[package_ahriman]), name)
def test_generate_from_path(configuration: Configuration, package_ahriman: Package) -> None:
@ -21,4 +21,4 @@ def test_generate_from_path(configuration: Configuration, package_ahriman: Packa
path = configuration.getpath("html", "templates") / configuration.get("html", "template")
_, repository_id = configuration.check_loaded()
report = JinjaTemplate(repository_id, configuration, "html")
assert report.make_html(Result(success=[package_ahriman]), path)
assert report.make_html(Result(updated=[package_ahriman]), path)

View File

@ -30,9 +30,30 @@ def test_load_dummy_client_disabled(configuration: Configuration) -> None:
assert not isinstance(Client.load(repository_id, configuration, report=False), WebClient)
def test_load_full_client(configuration: Configuration) -> None:
def test_load_dummy_client_disabled_in_configuration(configuration: Configuration) -> None:
"""
must load full client if settings set
must load dummy client if disabled in configuration
"""
configuration.set_option("web", "host", "localhost")
configuration.set_option("web", "port", "8080")
configuration.set_option("status", "enabled", "no")
_, repository_id = configuration.check_loaded()
assert not isinstance(Client.load(repository_id, configuration, report=True), WebClient)
def test_load_full_client_from_address(configuration: Configuration) -> None:
"""
must load full client by using address
"""
configuration.set_option("status", "address", "http://localhost:8080")
_, repository_id = configuration.check_loaded()
assert isinstance(Client.load(repository_id, configuration, report=True), WebClient)
def test_load_full_client_from_legacy_host(configuration: Configuration) -> None:
"""
must load full client if host and port settings set
"""
configuration.set_option("web", "host", "localhost")
configuration.set_option("web", "port", "8080")
@ -41,16 +62,16 @@ def test_load_full_client(configuration: Configuration) -> None:
assert isinstance(Client.load(repository_id, configuration, report=True), WebClient)
def test_load_full_client_from_address(configuration: Configuration) -> None:
def test_load_full_client_from_legacy_address(configuration: Configuration) -> None:
"""
must load full client by using address
must load full client by using legacy address
"""
configuration.set_option("web", "address", "http://localhost:8080")
_, repository_id = configuration.check_loaded()
assert isinstance(Client.load(repository_id, configuration, report=True), WebClient)
def test_load_full_client_from_unix_socket(configuration: Configuration) -> None:
def test_load_full_client_from_legacy_unix_socket(configuration: Configuration) -> None:
"""
must load full client by using unix socket
"""

View File

@ -2,7 +2,6 @@ import json
import logging
import pytest
import requests
import requests_unixsocket
from pytest_mock import MockerFixture
@ -12,7 +11,6 @@ from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package
from ahriman.models.user import User
def test_parse_address(configuration: Configuration) -> None:
@ -21,87 +19,16 @@ def test_parse_address(configuration: Configuration) -> None:
"""
configuration.set_option("web", "host", "localhost")
configuration.set_option("web", "port", "8080")
assert WebClient.parse_address(configuration) == ("http://localhost:8080", False)
assert WebClient.parse_address(configuration) == ("web", "http://localhost:8080")
configuration.set_option("web", "address", "http://localhost:8081")
assert WebClient.parse_address(configuration) == ("http://localhost:8081", False)
assert WebClient.parse_address(configuration) == ("web", "http://localhost:8081")
configuration.set_option("web", "unix_socket", "/run/ahriman.sock")
assert WebClient.parse_address(configuration) == ("http+unix://%2Frun%2Fahriman.sock", True)
assert WebClient.parse_address(configuration) == ("web", "http+unix://%2Frun%2Fahriman.sock")
def test_create_session(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must create normal requests session
"""
login_mock = mocker.patch("ahriman.core.status.web_client.WebClient._login")
session = web_client._create_session(use_unix_socket=False)
assert isinstance(session, requests.Session)
assert not isinstance(session, requests_unixsocket.Session)
login_mock.assert_called_once_with(pytest.helpers.anyvar(int))
def test_create_session_unix_socket(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must create unix socket session
"""
login_mock = mocker.patch("ahriman.core.status.web_client.WebClient._login")
session = web_client._create_session(use_unix_socket=True)
assert isinstance(session, requests_unixsocket.Session)
login_mock.assert_not_called()
def test_login(web_client: WebClient, user: User, mocker: MockerFixture) -> None:
"""
must login user
"""
web_client.auth = (user.username, user.password)
requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request")
payload = {
"username": user.username,
"password": user.password
}
session = requests.Session()
web_client._login(session)
requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True), json=payload, session=session)
def test_login_failed(web_client: WebClient, user: User, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during login
"""
web_client.user = user
mocker.patch("requests.Session.request", side_effect=Exception())
web_client._login(requests.Session())
def test_login_failed_http_error(web_client: WebClient, user: User, mocker: MockerFixture) -> None:
"""
must suppress HTTP exception happened during login
"""
web_client.user = user
mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
web_client._login(requests.Session())
def test_login_skip(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must skip login if no user set
"""
requests_mock = mocker.patch("requests.Session.request")
web_client._login(requests.Session())
requests_mock.assert_not_called()
def test_login_url(web_client: WebClient) -> None:
"""
must generate login url correctly
"""
assert web_client._login_url().startswith(web_client.address)
assert web_client._login_url().endswith("/api/v1/login")
configuration.set_option("status", "address", "http://localhost:8082")
assert WebClient.parse_address(configuration) == ("status", "http://localhost:8082")
def test_status_url(web_client: WebClient) -> None:
@ -375,11 +302,13 @@ def test_status_update(web_client: WebClient, mocker: MockerFixture) -> None:
requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request")
web_client.status_update(BuildStatusEnum.Unknown)
requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True),
params=web_client.repository_id.query(),
json={
"status": BuildStatusEnum.Unknown.value,
})
requests_mock.assert_called_once_with(
"POST", pytest.helpers.anyvar(str, True),
params=web_client.repository_id.query(),
json={
"status": BuildStatusEnum.Unknown.value,
}
)
def test_status_update_self_failed(web_client: WebClient, mocker: MockerFixture) -> None:

View File

@ -8,13 +8,11 @@ from ahriman.core.upload.remote_service import RemoteService
from ahriman.models.package import Package
def test_session(remote_service: RemoteService, mocker: MockerFixture) -> None:
def test_session(remote_service: RemoteService) -> None:
"""
must generate ahriman session
"""
upload_mock = mocker.patch("ahriman.core.status.web_client.WebClient._create_session")
assert remote_service.session
upload_mock.assert_called_once_with(use_unix_socket=False)
assert remote_service.session == remote_service.client.session
def test_package_upload(remote_service: RemoteService, package_ahriman: Package, mocker: MockerFixture) -> None:

View File

@ -1,6 +1,3 @@
import pytest
from ahriman.core.exceptions import UnprocessedPackageStatusError
from ahriman.models.package import Package
from ahriman.models.result import Result
@ -18,7 +15,7 @@ def test_non_empty_success(package_ahriman: Package) -> None:
must be non-empty if there is success build
"""
result = Result()
result.add_success(package_ahriman)
result.add_updated(package_ahriman)
assert not result.is_empty
@ -37,11 +34,22 @@ def test_non_empty_full(package_ahriman: Package) -> None:
"""
result = Result()
result.add_failed(package_ahriman)
result.add_success(package_ahriman)
result.add_updated(package_ahriman)
assert not result.is_empty
def test_add_added(package_ahriman: Package) -> None:
"""
must add package to new packages list
"""
result = Result()
result.add_added(package_ahriman)
assert not result.failed
assert not result.removed
assert result.success == [package_ahriman]
def test_add_failed(package_ahriman: Package) -> None:
"""
must add package to failed list
@ -49,17 +57,30 @@ def test_add_failed(package_ahriman: Package) -> None:
result = Result()
result.add_failed(package_ahriman)
assert result.failed == [package_ahriman]
assert not result.removed
assert not result.success
def test_add_success(package_ahriman: Package) -> None:
def test_add_removed(package_ahriman: Package) -> None:
"""
must add package to removed list
"""
result = Result()
result.add_removed(package_ahriman)
assert not result.failed
assert result.removed == [package_ahriman]
assert not result.success
def test_add_updated(package_ahriman: Package) -> None:
"""
must add package to success list
"""
result = Result()
result.add_success(package_ahriman)
assert result.success == [package_ahriman]
result.add_updated(package_ahriman)
assert not result.failed
assert not result.removed
assert result.success == [package_ahriman]
def test_merge(package_ahriman: Package, package_python_schedule: Package) -> None:
@ -67,9 +88,9 @@ def test_merge(package_ahriman: Package, package_python_schedule: Package) -> No
must merge success packages
"""
left = Result()
left.add_success(package_ahriman)
left.add_updated(package_ahriman)
right = Result()
right.add_success(package_python_schedule)
right.add_updated(package_python_schedule)
result = left.merge(right)
assert result.success == [package_ahriman, package_python_schedule]
@ -81,7 +102,7 @@ def test_merge_failed(package_ahriman: Package) -> None:
must merge and remove failed packages from success list
"""
left = Result()
left.add_success(package_ahriman)
left.add_updated(package_ahriman)
right = Result()
right.add_failed(package_ahriman)
@ -90,28 +111,15 @@ def test_merge_failed(package_ahriman: Package) -> None:
assert not left.success
def test_merge_exception(package_ahriman: Package) -> None:
"""
must raise exception in case if package was failed
"""
left = Result()
left.add_failed(package_ahriman)
right = Result()
right.add_success(package_ahriman)
with pytest.raises(UnprocessedPackageStatusError):
left.merge(right)
def test_eq(package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must return True for same objects
"""
left = Result()
left.add_success(package_ahriman)
left.add_updated(package_ahriman)
left.add_failed(package_python_schedule)
right = Result()
right.add_success(package_ahriman)
right.add_updated(package_ahriman)
right.add_failed(package_python_schedule)
assert left == right
@ -122,7 +130,7 @@ def test_eq_false(package_ahriman: Package) -> None:
must return False in case if lists do not match
"""
left = Result()
left.add_success(package_ahriman)
left.add_updated(package_ahriman)
right = Result()
right.add_failed(package_ahriman)
@ -144,7 +152,7 @@ def test_eq_false_success(package_ahriman: Package) -> None:
must return False in case if success does not match
"""
left = Result()
left.add_success(package_ahriman)
left.add_updated(package_ahriman)
assert left != Result()

View File

@ -24,8 +24,19 @@ def test_routes() -> None:
async def test_get(client_with_auth: TestClient) -> None:
"""
must generate status page correctly (/)
must redirect favicon to static files
"""
response = await client_with_auth.get("/favicon.ico", allow_redirects=False)
assert response.status == 302
assert response.headers["Location"] == "/static/favicon.ico"
async def test_get_not_found(client_with_auth: TestClient) -> None:
"""
must raise not found if path is invalid
"""
for route in client_with_auth.app.router.routes():
if hasattr(route.handler, "ROUTES"):
route.handler.ROUTES = []
response = await client_with_auth.get("/favicon.ico", allow_redirects=False)
assert response.status == 404