Compare commits

...

3 Commits

Author SHA1 Message Date
aaab9069bf docs: rebuild indices 2025-03-09 15:43:41 +02:00
f00b575641 type: use ClassVar decorator for class attributes 2025-03-09 15:43:27 +02:00
6f57ed550b
feat: refine log system (#142)
* refine package logging

* add interface

* revert version selection

* replace tuple with model

* rename column in logs table, add coverters

* generate process identifier for child proocesses
2025-03-09 14:46:33 +02:00
104 changed files with 935 additions and 367 deletions

View File

@ -80,7 +80,7 @@ Again, the most checks can be performed by `tox` command, though some additional
>>> clazz = Clazz() >>> clazz = Clazz()
""" """
CLAZZ_ATTRIBUTE = 42 CLAZZ_ATTRIBUTE: ClassVar[int] = 42
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
""" """
@ -96,6 +96,7 @@ Again, the most checks can be performed by `tox` command, though some additional
* Type annotations are the must, even for local functions. For the function argument `self` (for instance methods) and `cls` (for class methods) should not be annotated. * Type annotations are the must, even for local functions. For the function argument `self` (for instance methods) and `cls` (for class methods) should not be annotated.
* For collection types built-in classes must be used if possible (e.g. `dict` instead of `typing.Dict`, `tuple` instead of `typing.Tuple`). In case if built-in type is not available, but `collections.abc` provides interface, it must be used (e.g. `collections.abc.Awaitable` instead of `typing.Awaitable`, `collections.abc.Iterable` instead of `typing.Iterable`). For union classes, the bar operator (`|`) must be used (e.g. `float | int` instead of `typing.Union[float, int]`), which also includes `typing.Optional` (e.g. `str | None` instead of `Optional[str]`). * For collection types built-in classes must be used if possible (e.g. `dict` instead of `typing.Dict`, `tuple` instead of `typing.Tuple`). In case if built-in type is not available, but `collections.abc` provides interface, it must be used (e.g. `collections.abc.Awaitable` instead of `typing.Awaitable`, `collections.abc.Iterable` instead of `typing.Iterable`). For union classes, the bar operator (`|`) must be used (e.g. `float | int` instead of `typing.Union[float, int]`), which also includes `typing.Optional` (e.g. `str | None` instead of `Optional[str]`).
* `classmethod` should (almost) always return `Self`. In case of mypy warning (e.g. if there is a branch in which function doesn't return the instance of `cls`) consider using `staticmethod` instead. * `classmethod` should (almost) always return `Self`. In case of mypy warning (e.g. if there is a branch in which function doesn't return the instance of `cls`) consider using `staticmethod` instead.
* Class attributes must be decorated as `ClassVar[...]`.
* Recommended order of function definitions in class: * Recommended order of function definitions in class:
```python ```python

View File

@ -124,6 +124,14 @@ ahriman.core.database.migrations.m014\_auditlog module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.database.migrations.m015\_logs\_process\_id module
---------------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m015_logs_process_id
:members:
:no-undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@ -100,6 +100,14 @@ ahriman.models.log\_handler module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.models.log\_record module
---------------------------------
.. automodule:: ahriman.models.log_record
:members:
:no-undoc-members:
:show-inheritance:
ahriman.models.log\_record\_id module ahriman.models.log\_record\_id module
------------------------------------- -------------------------------------

View File

@ -116,6 +116,14 @@ ahriman.web.schemas.login\_schema module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.schemas.logs\_rotate\_schema module
-----------------------------------------------
.. automodule:: ahriman.web.schemas.logs_rotate_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.logs\_schema module ahriman.web.schemas.logs\_schema module
--------------------------------------- ---------------------------------------
@ -292,14 +300,6 @@ ahriman.web.schemas.update\_flags\_schema module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.schemas.versioned\_log\_schema module
-------------------------------------------------
.. automodule:: ahriman.web.schemas.versioned_log_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.worker\_schema module ahriman.web.schemas.worker\_schema module
----------------------------------------- -----------------------------------------

View File

@ -12,6 +12,14 @@ ahriman.web.views.v1.service.add module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.views.v1.service.logs module
----------------------------------------
.. automodule:: ahriman.web.views.v1.service.logs
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.service.pgp module ahriman.web.views.v1.service.pgp module
--------------------------------------- ---------------------------------------

View File

@ -7,6 +7,8 @@ logging = ahriman.ini.d/logging.ini
;apply_migrations = yes ;apply_migrations = yes
; Path to the application SQLite database. ; Path to the application SQLite database.
database = ${repository:root}/ahriman.db database = ${repository:root}/ahriman.db
; Keep last build logs for each package
keep_last_logs = 5
[alpm] [alpm]
; Path to pacman system database cache. ; Path to pacman system database cache.

View File

@ -16,11 +16,11 @@
<div class="container"> <div class="container">
<nav class="navbar navbar-expand-lg"> <nav class="navbar navbar-expand-lg">
<div class="navbar-brand"><a href="https://github.com/arcan1s/ahriman" title="logo"><img src="/static/logo.svg" width="30" height="30" alt=""></a></div> <div class="navbar-brand"><a href="https://github.com/arcan1s/ahriman" title="logo"><img src="/static/logo.svg" width="30" height="30" alt=""></a></div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#repositories-navbar-supported-content" aria-controls="repositories-navbar-supported-content" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#repositories-navbar" aria-controls="repositories-navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div id="repositories-navbar-supported-content" class="collapse navbar-collapse"> <div id="repositories-navbar" class="collapse navbar-collapse">
<ul id="repositories" class="nav nav-tabs"> <ul id="repositories" class="nav nav-tabs">
{% for repository in repositories %} {% for repository in repositories %}
<li class="nav-item"> <li class="nav-item">

View File

@ -59,7 +59,14 @@
</nav> </nav>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<div id="package-info-logs" class="tab-pane fade show active" role="tabpanel" aria-labelledby="package-info-logs-button" tabindex="0"> <div id="package-info-logs" class="tab-pane fade show active" role="tabpanel" aria-labelledby="package-info-logs-button" tabindex="0">
<pre class="language-console"><code id="package-info-logs-input" class="pre-scrollable language-console"></code><button id="package-info-logs-copy-button" type="button" class="btn language-console" onclick="copyLogs()"><i class="bi bi-clipboard"></i> copy</button></pre> <div class="row">
<div class="col-2">
<nav id="package-info-logs-versions" class="nav flex-column"></nav>
</div>
<div class="col-10">
<pre class="language-console"><code id="package-info-logs-input" class="pre-scrollable language-console"></code><button id="package-info-logs-copy-button" type="button" class="btn language-console" onclick="copyLogs()"><i class="bi bi-clipboard"></i> copy</button></pre>
</div>
</div>
</div> </div>
<div id="package-info-changes" class="tab-pane fade" role="tabpanel" aria-labelledby="package-info-changes-button" tabindex="0"> <div id="package-info-changes" class="tab-pane fade" role="tabpanel" aria-labelledby="package-info-changes-button" tabindex="0">
<pre class="language-diff"><code id="package-info-changes-input" class="pre-scrollable language-diff"></code><button id="package-info-changes-copy-button" type="button" class="btn language-diff" onclick="copyChanges()"><i class="bi bi-clipboard"></i> copy</button></pre> <pre class="language-diff"><code id="package-info-changes-input" class="pre-scrollable language-diff"></code><button id="package-info-changes-copy-button" type="button" class="btn language-diff" onclick="copyChanges()"><i class="bi bi-clipboard"></i> copy</button></pre>
@ -100,6 +107,7 @@
const packageInfoModalHeader = document.getElementById("package-info-modal-header"); const packageInfoModalHeader = document.getElementById("package-info-modal-header");
const packageInfo = document.getElementById("package-info"); const packageInfo = document.getElementById("package-info");
const packageInfoLogsVersions = document.getElementById("package-info-logs-versions");
const packageInfoLogsInput = document.getElementById("package-info-logs-input"); const packageInfoLogsInput = document.getElementById("package-info-logs-input");
const packageInfoLogsCopyButton = document.getElementById("package-info-logs-copy-button"); const packageInfoLogsCopyButton = document.getElementById("package-info-logs-copy-button");
@ -285,11 +293,45 @@
convert: response => response.json(), convert: response => response.json(),
}, },
data => { data => {
const logs = data.map(log_record => { const selectors = Object
return `[${new Date(1000 * log_record.created).toISOString()}] ${log_record.message}`; .values(
}); data.reduce((acc, log_record) => {
packageInfoLogsInput.textContent = logs.join("\n"); const id = `${log_record.version}-${log_record.process_id}`;
highlight(packageInfoLogsInput); if (acc[id])
acc[id].created = Math.min(log_record.created, acc[id].created);
else
acc[id] = log_record;
return acc;
}, {})
)
.sort(({created: left}, {created: right}) =>
right - left
)
.map(version => {
const link = document.createElement("a");
link.classList.add("nav-link");
link.textContent = version.version;
link.href = "#";
link.onclick = _ => {
const logs = data
.filter(log_record => log_record.version === version.version && log_record.process_id === version.process_id)
.map(log_record => `[${new Date(1000 * log_record.created).toISOString()}] ${log_record.message}`);
packageInfoLogsInput.textContent = logs.join("\n");
highlight(packageInfoLogsInput);
Array.from(packageInfoLogsVersions.children).forEach(el => el.classList.remove("active"));
link.classList.add("active");
return false;
};
return link;
});
packageInfoLogsVersions.replaceChildren(...selectors);
selectors.find(Boolean)?.click();
}, },
onFailure, onFailure,
); );

View File

@ -27,4 +27,10 @@
top: 0; top: 0;
right: 5px; right: 5px;
} }
.nav-link.active {
pointer-events: none;
cursor: default;
color: black !important;
}
</style> </style>

View File

@ -22,7 +22,7 @@ import logging
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from multiprocessing import Pool from multiprocessing import Pool
from typing import TypeVar from typing import ClassVar, TypeVar
from ahriman.application.lock import Lock from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -58,8 +58,8 @@ class Handler:
>>> Add.execute(args) >>> Add.execute(args)
""" """
ALLOW_MULTI_ARCHITECTURE_RUN = True ALLOW_MULTI_ARCHITECTURE_RUN: ClassVar[bool] = True
arguments: list[Callable[[SubParserAction], argparse.ArgumentParser]] arguments: ClassVar[list[Callable[[SubParserAction], argparse.ArgumentParser]]]
@classmethod @classmethod
def call(cls, args: argparse.Namespace, repository_id: RepositoryId) -> bool: def call(cls, args: argparse.Namespace, repository_id: RepositoryId) -> bool:

View File

@ -21,6 +21,7 @@ import argparse
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from dataclasses import fields from dataclasses import fields
from typing import ClassVar
from ahriman.application.handlers.handler import Handler, SubParserAction from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.alpm.remote import AUR, Official from ahriman.core.alpm.remote import AUR, Official
@ -40,7 +41,7 @@ class Search(Handler):
""" """
ALLOW_MULTI_ARCHITECTURE_RUN = False # system-wide action ALLOW_MULTI_ARCHITECTURE_RUN = False # system-wide action
SORT_FIELDS = { SORT_FIELDS: ClassVar[set[str]] = {
field.name field.name
for field in fields(AURPackage) for field in fields(AURPackage)
if field.default_factory is not list if field.default_factory is not list

View File

@ -21,6 +21,7 @@ import argparse
from pathlib import Path from pathlib import Path
from pwd import getpwuid from pwd import getpwuid
from typing import ClassVar
from urllib.parse import quote_plus as url_encode from urllib.parse import quote_plus as url_encode
from ahriman.application.application import Application from ahriman.application.application import Application
@ -46,9 +47,9 @@ class Setup(Handler):
ALLOW_MULTI_ARCHITECTURE_RUN = False # conflicting io ALLOW_MULTI_ARCHITECTURE_RUN = False # conflicting io
ARCHBUILD_COMMAND_PATH = Path("/") / "usr" / "bin" / "archbuild" ARCHBUILD_COMMAND_PATH: ClassVar[Path] = Path("/") / "usr" / "bin" / "archbuild"
MIRRORLIST_PATH = Path("/") / "etc" / "pacman.d" / "mirrorlist" MIRRORLIST_PATH: ClassVar[Path] = Path("/") / "etc" / "pacman.d" / "mirrorlist"
SUDOERS_DIR_PATH = Path("/") / "etc" / "sudoers.d" SUDOERS_DIR_PATH: ClassVar[Path] = Path("/") / "etc" / "sudoers.d"
@classmethod @classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *, def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,

View File

@ -23,6 +23,7 @@ import sys
from collections.abc import Generator from collections.abc import Generator
from importlib import metadata from importlib import metadata
from typing import ClassVar
from ahriman import __version__ from ahriman import __version__
from ahriman.application.handlers.handler import Handler, SubParserAction from ahriman.application.handlers.handler import Handler, SubParserAction
@ -36,11 +37,11 @@ class Versions(Handler):
version handler version handler
Attributes: Attributes:
PEP423_PACKAGE_NAME(str): (class attribute) special regex for valid PEP423 package name PEP423_PACKAGE_NAME(re.Pattern[str]): (class attribute) special regex for valid PEP423 package name
""" """
ALLOW_MULTI_ARCHITECTURE_RUN = False # system-wide action ALLOW_MULTI_ARCHITECTURE_RUN = False # system-wide action
PEP423_PACKAGE_NAME = re.compile(r"^[A-Za-z0-9._-]+") PEP423_PACKAGE_NAME: ClassVar[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9._-]+")
@classmethod @classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *, def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,

View File

@ -23,6 +23,7 @@ import shutil
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
from pathlib import Path from pathlib import Path
from pyalpm import DB # type: ignore[import-not-found] from pyalpm import DB # type: ignore[import-not-found]
from typing import ClassVar
from urllib.parse import urlparse from urllib.parse import urlparse
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -41,7 +42,7 @@ class PacmanDatabase(SyncHttpClient):
sync_files_database(bool): sync files database sync_files_database(bool): sync files database
""" """
LAST_MODIFIED_HEADER = "Last-Modified" LAST_MODIFIED_HEADER: ClassVar[str] = "Last-Modified"
def __init__(self, database: DB, configuration: Configuration) -> None: def __init__(self, database: DB, configuration: Configuration) -> None:
""" """

View File

@ -34,14 +34,14 @@ class PkgbuildToken(StrEnum):
well-known tokens dictionary well-known tokens dictionary
Attributes: Attributes:
ArrayEnds(PkgbuildToken): (class attribute) array ends token ArrayEnds(PkgbuildToken): array ends token
ArrayStarts(PkgbuildToken): (class attribute) array starts token ArrayStarts(PkgbuildToken): array starts token
Comma(PkgbuildToken): (class attribute) comma token Comma(PkgbuildToken): comma token
Comment(PkgbuildToken): (class attribute) comment token Comment(PkgbuildToken): comment token
FunctionDeclaration(PkgbuildToken): (class attribute) function declaration token FunctionDeclaration(PkgbuildToken): function declaration token
FunctionEnds(PkgbuildToken): (class attribute) function ends token FunctionEnds(PkgbuildToken): function ends token
FunctionStarts(PkgbuildToken): (class attribute) function starts token FunctionStarts(PkgbuildToken): function starts token
NewLine(PkgbuildToken): (class attribute) new line token NewLine(PkgbuildToken): new line token
""" """
ArrayStarts = "(" ArrayStarts = "("

View File

@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from typing import Any from typing import Any, ClassVar
from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.remote.remote import Remote from ahriman.core.alpm.remote.remote import Remote
@ -35,9 +35,9 @@ class AUR(Remote):
DEFAULT_RPC_VERSION(str): (class attribute) default AUR RPC version DEFAULT_RPC_VERSION(str): (class attribute) default AUR RPC version
""" """
DEFAULT_AUR_URL = "https://aur.archlinux.org" DEFAULT_AUR_URL: ClassVar[str] = "https://aur.archlinux.org"
DEFAULT_RPC_URL = f"{DEFAULT_AUR_URL}/rpc" DEFAULT_RPC_URL: ClassVar[str] = f"{DEFAULT_AUR_URL}/rpc"
DEFAULT_RPC_VERSION = "5" DEFAULT_RPC_VERSION: ClassVar[str] = "5"
@classmethod @classmethod
def remote_git_url(cls, package_base: str, repository: str) -> str: def remote_git_url(cls, package_base: str, repository: str) -> str:

View File

@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from typing import Any from typing import Any, ClassVar
from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.remote.remote import Remote from ahriman.core.alpm.remote.remote import Remote
@ -36,10 +36,10 @@ class Official(Remote):
DEFAULT_RPC_URL(str): (class attribute) default archlinux repositories RPC url DEFAULT_RPC_URL(str): (class attribute) default archlinux repositories RPC url
""" """
DEFAULT_ARCHLINUX_GIT_URL = "https://gitlab.archlinux.org" DEFAULT_ARCHLINUX_GIT_URL: ClassVar[str] = "https://gitlab.archlinux.org"
DEFAULT_ARCHLINUX_URL = "https://archlinux.org" DEFAULT_ARCHLINUX_URL: ClassVar[str] = "https://archlinux.org"
DEFAULT_SEARCH_REPOSITORIES = ["Core", "Extra", "Multilib"] DEFAULT_SEARCH_REPOSITORIES: ClassVar[list[str]] = ["Core", "Extra", "Multilib"]
DEFAULT_RPC_URL = "https://archlinux.org/packages/search/json" DEFAULT_RPC_URL: ClassVar[str] = "https://archlinux.org/packages/search/json"
@classmethod @classmethod
def remote_git_url(cls, package_base: str, repository: str) -> str: def remote_git_url(cls, package_base: str, repository: str) -> str:

View File

@ -21,6 +21,7 @@ import shutil
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path from pathlib import Path
from typing import ClassVar
from ahriman.core.exceptions import CalledProcessError from ahriman.core.exceptions import CalledProcessError
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
@ -42,9 +43,9 @@ class Sources(LazyLogging):
GITCONFIG(dict[str, str]): (class attribute) git config options to suppress annoying hints GITCONFIG(dict[str, str]): (class attribute) git config options to suppress annoying hints
""" """
DEFAULT_BRANCH = "master" # default fallback branch DEFAULT_BRANCH: ClassVar[str] = "master" # default fallback branch
DEFAULT_COMMIT_AUTHOR = ("ahriman", "ahriman@localhost") DEFAULT_COMMIT_AUTHOR: ClassVar[tuple[str, str]] = ("ahriman", "ahriman@localhost")
GITCONFIG = { GITCONFIG: ClassVar[dict[str, str]] = {
"init.defaultBranch": DEFAULT_BRANCH, "init.defaultBranch": DEFAULT_BRANCH,
} }

View File

@ -22,7 +22,7 @@ import shlex
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any, Self from typing import Any, ClassVar, Self
from ahriman.core.configuration.configuration_multi_dict import ConfigurationMultiDict from ahriman.core.configuration.configuration_multi_dict import ConfigurationMultiDict
from ahriman.core.configuration.shell_interpolator import ShellInterpolator from ahriman.core.configuration.shell_interpolator import ShellInterpolator
@ -65,8 +65,8 @@ class Configuration(configparser.RawConfigParser):
""" """
_LEGACY_ARCHITECTURE_SPECIFIC_SECTIONS = ["web"] _LEGACY_ARCHITECTURE_SPECIFIC_SECTIONS = ["web"]
ARCHITECTURE_SPECIFIC_SECTIONS = ["alpm", "build", "sign"] ARCHITECTURE_SPECIFIC_SECTIONS: ClassVar[list[str]] = ["alpm", "build", "sign"]
SYSTEM_CONFIGURATION_PATH = Path(sys.prefix) / "share" / "ahriman" / "settings" / "ahriman.ini" SYSTEM_CONFIGURATION_PATH: ClassVar[Path] = Path(sys.prefix) / "share" / "ahriman" / "settings" / "ahriman.ini"
def __init__(self, allow_no_value: bool = False, allow_multi_key: bool = True) -> None: def __init__(self, allow_no_value: bool = False, allow_multi_key: bool = True) -> None:
""" """

View File

@ -45,6 +45,11 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"path_exists": True, "path_exists": True,
"path_type": "dir", "path_type": "dir",
}, },
"keep_last_logs": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
"logging": { "logging": {
"type": "path", "type": "path",
"coerce": "absolute_path", "coerce": "absolute_path",

View File

@ -23,6 +23,7 @@ import sys
from collections.abc import Generator, Mapping, MutableMapping from collections.abc import Generator, Mapping, MutableMapping
from string import Template from string import Template
from typing import ClassVar
from ahriman.core.configuration.shell_template import ShellTemplate from ahriman.core.configuration.shell_template import ShellTemplate
@ -32,7 +33,7 @@ class ShellInterpolator(configparser.Interpolation):
custom string interpolator, because we cannot use defaults argument due to config validation custom string interpolator, because we cannot use defaults argument due to config validation
""" """
DATA_LINK_ESCAPE = "\x10" DATA_LINK_ESCAPE: ClassVar[str] = "\x10"
@staticmethod @staticmethod
def _extract_variables(parser: MutableMapping[str, Mapping[str, str]], value: str, def _extract_variables(parser: MutableMapping[str, Mapping[str, str]], value: str,

View File

@ -28,9 +28,6 @@ class ShellTemplate(Template):
""" """
extension to the default :class:`Template` class, which also adds additional tokens to braced regex and enables extension to the default :class:`Template` class, which also adds additional tokens to braced regex and enables
bash expansion bash expansion
Attributes:
braceidpattern(str): regular expression to match every character except for closing bracket
""" """
braceidpattern = r"(?a:[_a-z0-9][^}]*)" braceidpattern = r"(?a:[_a-z0-9][^}]*)"

View File

@ -0,0 +1,30 @@
#
# Copyright (c) 2021-2025 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/>.
#
__all__ = ["steps"]
steps = [
"""
alter table logs add column process_id text not null default ''
""",
"""
alter table logs rename column record to message
""",
]

View File

@ -20,7 +20,7 @@
from sqlite3 import Connection from sqlite3 import Connection
from ahriman.core.database.operations.operations import Operations from ahriman.core.database.operations.operations import Operations
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record import LogRecord
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -30,7 +30,7 @@ class LogsOperations(Operations):
""" """
def logs_get(self, package_base: str, limit: int = -1, offset: int = 0, def logs_get(self, package_base: str, limit: int = -1, offset: int = 0,
repository_id: RepositoryId | None = None) -> list[tuple[float, str]]: repository_id: RepositoryId | None = None) -> list[LogRecord]:
""" """
extract logs for specified package base extract logs for specified package base
@ -41,16 +41,16 @@ class LogsOperations(Operations):
repository_id(RepositoryId, optional): repository unique identifier override (Default value = None) repository_id(RepositoryId, optional): repository unique identifier override (Default value = None)
Return: Return:
list[tuple[float, str]]: sorted package log records and their timestamps list[LogRecord]: sorted package log records
""" """
repository_id = repository_id or self._repository_id repository_id = repository_id or self._repository_id
def run(connection: Connection) -> list[tuple[float, str]]: def run(connection: Connection) -> list[LogRecord]:
return [ return [
(row["created"], row["record"]) LogRecord.from_json(package_base, row)
for row in connection.execute( for row in connection.execute(
""" """
select created, record from ( select created, message, version, process_id from (
select * from logs select * from logs
where package_base = :package_base and repository = :repository where package_base = :package_base and repository = :repository
order by created desc limit :limit offset :offset order by created desc limit :limit offset :offset
@ -66,15 +66,12 @@ class LogsOperations(Operations):
return self.with_connection(run) return self.with_connection(run)
def logs_insert(self, log_record_id: LogRecordId, created: float, record: str, def logs_insert(self, log_record: LogRecord, repository_id: RepositoryId | None = None) -> None:
repository_id: RepositoryId | None = None) -> None:
""" """
write new log record to database write new log record to database
Args: Args:
log_record_id(LogRecordId): current log record id log_record(LogRecord): log record object
created(float): log created timestamp from log record attribute
record(str): log record
repository_id(RepositoryId, optional): repository unique identifier override (Default value = None) repository_id(RepositoryId, optional): repository unique identifier override (Default value = None)
""" """
repository_id = repository_id or self._repository_id repository_id = repository_id or self._repository_id
@ -83,17 +80,14 @@ class LogsOperations(Operations):
connection.execute( connection.execute(
""" """
insert into logs insert into logs
(package_base, version, created, record, repository) (package_base, version, created, message, repository, process_id)
values values
(:package_base, :version, :created, :record, :repository) (:package_base, :version, :created, :message, :repository, :process_id)
""", """,
{ {
"package_base": log_record_id.package_base, "package_base": log_record.log_record_id.package_base,
"version": log_record_id.version,
"created": created,
"record": record,
"repository": repository_id.id, "repository": repository_id.id,
} } | log_record.view()
) )
return self.with_connection(run, commit=True) return self.with_connection(run, commit=True)
@ -125,3 +119,54 @@ class LogsOperations(Operations):
) )
return self.with_connection(run, commit=True) return self.with_connection(run, commit=True)
def logs_rotate(self, keep_last_records: int, repository_id: RepositoryId | None = None) -> None:
"""
rotate logs in storage. This method will remove old logs, keeping only the last N records for each package
Args:
keep_last_records(int): number of last records to keep
repository_id(RepositoryId, optional): repository unique identifier override (Default value = None)
"""
repository_id = repository_id or self._repository_id
def remove_duplicates(connection: Connection) -> None:
connection.execute(
"""
delete from logs
where (package_base, version, repository, process_id) not in (
select package_base, version, repository, process_id from logs
where (package_base, version, repository, created) in (
select package_base, version, repository, max(created) from logs
where repository = :repository
group by package_base, version, repository
)
)
""",
{
"repository": repository_id.id,
}
)
def remove_older(connection: Connection) -> None:
connection.execute(
"""
delete from logs
where (package_base, repository, process_id) in (
select package_base, repository, process_id from logs
where repository = :repository
group by package_base, repository, process_id
order by min(created) desc limit -1 offset :offset
)
""",
{
"offset": keep_last_records,
"repository": repository_id.id,
}
)
def run(connection: Connection) -> None:
remove_duplicates(connection)
remove_older(connection)
return self.with_connection(run, commit=True)

View File

@ -22,7 +22,6 @@ import contextlib
from functools import cached_property from functools import cached_property
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.configuration.schema import ConfigurationSchema
from ahriman.core.status.web_client import WebClient from ahriman.core.status.web_client import WebClient
from ahriman.core.triggers import Trigger from ahriman.core.triggers import Trigger
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -34,7 +33,7 @@ class DistributedSystem(Trigger, WebClient):
simple class to (un)register itself as a distributed worker simple class to (un)register itself as a distributed worker
""" """
CONFIGURATION_SCHEMA: ConfigurationSchema = { CONFIGURATION_SCHEMA = {
"worker": { "worker": {
"type": "dict", "type": "dict",
"schema": { "schema": {

View File

@ -17,6 +17,8 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from typing import ClassVar
from ahriman.core.formatters.string_printer import StringPrinter from ahriman.core.formatters.string_printer import StringPrinter
from ahriman.models.property import Property from ahriman.models.property import Property
@ -31,7 +33,7 @@ class ConfigurationPrinter(StringPrinter):
values(dict[str, str]): configuration values dictionary values(dict[str, str]): configuration values dictionary
""" """
HIDE_KEYS = [ HIDE_KEYS: ClassVar[list[str]] = [
"api_key", # telegram key "api_key", # telegram key
"client_secret", # oauth secret "client_secret", # oauth secret
"cookie_secret_key", # cookie secret key "cookie_secret_key", # cookie secret key

View File

@ -17,6 +17,8 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from typing import ClassVar
from ahriman.core.formatters.string_printer import StringPrinter from ahriman.core.formatters.string_printer import StringPrinter
from ahriman.models.property import Property from ahriman.models.property import Property
@ -26,10 +28,11 @@ class PackageStatsPrinter(StringPrinter):
print packages statistics print packages statistics
Attributes: Attributes:
MAX_COUNT(int): (class attribute) maximum number of packages to print
events(dict[str, int]): map of package to its event frequency events(dict[str, int]): map of package to its event frequency
""" """
MAX_COUNT = 10 MAX_COUNT: ClassVar[int] = 10
def __init__(self, events: dict[str, int]) -> None: def __init__(self, events: dict[str, int]) -> None:
""" """

View File

@ -17,12 +17,16 @@
# 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 atexit
import logging import logging
import uuid
from typing import Self from typing import Self
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.models.log_record import LogRecord
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -33,6 +37,7 @@ class HttpLogHandler(logging.Handler):
method method
Attributes: Attributes:
keep_last_records(int): number of last records to keep
reporter(Client): build status reporter instance reporter(Client): build status reporter instance
suppress_errors(bool): suppress logging errors (e.g. if no web server available) suppress_errors(bool): suppress logging errors (e.g. if no web server available)
""" """
@ -51,6 +56,7 @@ class HttpLogHandler(logging.Handler):
self.reporter = Client.load(repository_id, configuration, report=report) self.reporter = Client.load(repository_id, configuration, report=report)
self.suppress_errors = suppress_errors self.suppress_errors = suppress_errors
self.keep_last_records = configuration.getint("settings", "keep_last_logs", fallback=0)
@classmethod @classmethod
def load(cls, repository_id: RepositoryId, configuration: Configuration, *, report: bool) -> Self: def load(cls, repository_id: RepositoryId, configuration: Configuration, *, report: bool) -> Self:
@ -76,6 +82,9 @@ class HttpLogHandler(logging.Handler):
handler = cls(repository_id, configuration, report=report, suppress_errors=suppress_errors) handler = cls(repository_id, configuration, report=report, suppress_errors=suppress_errors)
root.addHandler(handler) root.addHandler(handler)
LogRecordId.DEFAULT_PROCESS_ID = str(uuid.uuid4()) # assign default process identifier for log records
atexit.register(handler.rotate)
return handler return handler
def emit(self, record: logging.LogRecord) -> None: def emit(self, record: logging.LogRecord) -> None:
@ -90,8 +99,14 @@ class HttpLogHandler(logging.Handler):
return # in case if no package base supplied we need just skip log message return # in case if no package base supplied we need just skip log message
try: try:
self.reporter.package_logs_add(log_record_id, record.created, record.getMessage()) self.reporter.package_logs_add(LogRecord(log_record_id, record.created, record.getMessage()))
except Exception: except Exception:
if self.suppress_errors: if self.suppress_errors:
return return
self.handleError(record) self.handleError(record)
def rotate(self) -> None:
"""
rotate log records, removing older ones
"""
self.reporter.logs_rotate(self.keep_last_records)

View File

@ -74,7 +74,7 @@ class LazyLogging:
def package_record_factory(*args: Any, **kwargs: Any) -> logging.LogRecord: def package_record_factory(*args: Any, **kwargs: Any) -> logging.LogRecord:
record = current_factory(*args, **kwargs) record = current_factory(*args, **kwargs)
record.package_id = LogRecordId(package_base, version or "") record.package_id = LogRecordId(package_base, version or "<unknown>")
return record return record
logging.setLogRecordFactory(package_record_factory) logging.setLogRecordFactory(package_record_factory)
@ -99,24 +99,3 @@ class LazyLogging:
yield yield
finally: finally:
self._package_logger_reset() self._package_logger_reset()
@contextlib.contextmanager
def suppress_logging(self, log_level: int = logging.WARNING) -> Generator[None, None, None]:
"""
silence log messages in context
Args:
log_level(int, optional): the highest log level to keep (Default value = logging.WARNING)
Examples:
This function is designed to be used to suppress all log messages in context, e.g.:
>>> with self.suppress_logging():
>>> do_some_noisy_actions()
"""
current_level = self.logger.manager.disable
try:
logging.disable(log_level)
yield
finally:
logging.disable(current_level)

View File

@ -21,6 +21,7 @@ import logging
from logging.config import fileConfig from logging.config import fileConfig
from pathlib import Path from pathlib import Path
from typing import ClassVar
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.log.http_log_handler import HttpLogHandler from ahriman.core.log.http_log_handler import HttpLogHandler
@ -38,9 +39,9 @@ class LogLoader:
DEFAULT_SYSLOG_DEVICE(Path): (class attribute) default path to syslog device DEFAULT_SYSLOG_DEVICE(Path): (class attribute) default path to syslog device
""" """
DEFAULT_LOG_FORMAT = "[%(levelname)s %(asctime)s] [%(filename)s:%(lineno)d %(funcName)s]: %(message)s" DEFAULT_LOG_FORMAT: ClassVar[str] = "[%(levelname)s %(asctime)s] [%(name)s]: %(message)s"
DEFAULT_LOG_LEVEL = logging.DEBUG DEFAULT_LOG_LEVEL: ClassVar[int] = logging.DEBUG
DEFAULT_SYSLOG_DEVICE = Path("/") / "dev" / "log" DEFAULT_SYSLOG_DEVICE: ClassVar[Path] = Path("/") / "dev" / "log"
@staticmethod @staticmethod
def handler(selected: LogHandler | None) -> LogHandler: def handler(selected: LogHandler | None) -> LogHandler:

View File

@ -17,6 +17,8 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from typing import ClassVar
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.http import SyncHttpClient from ahriman.core.http import SyncHttpClient
from ahriman.core.report.jinja_template import JinjaTemplate from ahriman.core.report.jinja_template import JinjaTemplate
@ -39,8 +41,8 @@ class Telegram(Report, JinjaTemplate, SyncHttpClient):
template_type(str): template message type to be used in parse mode, one of MarkdownV2, HTML, Markdown template_type(str): template message type to be used in parse mode, one of MarkdownV2, HTML, Markdown
""" """
TELEGRAM_API_URL = "https://api.telegram.org" TELEGRAM_API_URL: ClassVar[str] = "https://api.telegram.org"
TELEGRAM_MAX_CONTENT_LENGTH = 4096 TELEGRAM_MAX_CONTENT_LENGTH: ClassVar[int] = 4096
def __init__(self, repository_id: RepositoryId, configuration: Configuration, section: str) -> None: def __init__(self, repository_id: RepositoryId, configuration: Configuration, section: str) -> None:
""" """

View File

@ -144,8 +144,7 @@ class UpdateHandler(PackageInfo, Cleaner):
branch="master", branch="master",
) )
with self.suppress_logging(): Sources.fetch(cache_dir, source)
Sources.fetch(cache_dir, source)
remote = Package.from_build(cache_dir, self.architecture, None) remote = Package.from_build(cache_dir, self.architecture, None)
local = packages.get(remote.base) local = packages.get(remote.base)

View File

@ -27,7 +27,7 @@ from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType from ahriman.models.event import Event, EventType
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record import LogRecord
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -115,6 +115,14 @@ class Client:
""" """
raise NotImplementedError raise NotImplementedError
def logs_rotate(self, keep_last_records: int) -> None:
"""
remove older logs from storage
Args:
keep_last_records(int): number of last records to keep
"""
def package_changes_get(self, package_base: str) -> Changes: def package_changes_get(self, package_base: str) -> Changes:
""" """
get package changes get package changes
@ -186,18 +194,16 @@ class Client:
""" """
raise NotImplementedError raise NotImplementedError
def package_logs_add(self, log_record_id: LogRecordId, created: float, message: str) -> None: def package_logs_add(self, log_record: LogRecord) -> None:
""" """
post log record post log record
Args: Args:
log_record_id(LogRecordId): log record id log_record(LogRecord): log record
created(float): log created timestamp
message(str): log message
""" """
# this method does not raise NotImplementedError because it is actively used as dummy client for http log # this method does not raise NotImplementedError because it is actively used as dummy client for http log
def package_logs_get(self, package_base: str, limit: int = -1, offset: int = 0) -> list[tuple[float, str]]: def package_logs_get(self, package_base: str, limit: int = -1, offset: int = 0) -> list[LogRecord]:
""" """
get package logs get package logs
@ -207,7 +213,7 @@ class Client:
offset(int, optional): records offset (Default value = 0) offset(int, optional): records offset (Default value = 0)
Returns: Returns:
list[tuple[float, str]]: package logs list[LogRecord]: package logs
Raises: Raises:
NotImplementedError: not implemented method NotImplementedError: not implemented method

View File

@ -23,7 +23,7 @@ from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType from ahriman.models.event import Event, EventType
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record import LogRecord
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -75,6 +75,15 @@ class LocalClient(Client):
""" """
return self.database.event_get(event, object_id, from_date, to_date, limit, offset, self.repository_id) return self.database.event_get(event, object_id, from_date, to_date, limit, offset, self.repository_id)
def logs_rotate(self, keep_last_records: int) -> None:
"""
remove older logs from storage
Args:
keep_last_records(int): number of last records to keep
"""
self.database.logs_rotate(keep_last_records, self.repository_id)
def package_changes_get(self, package_base: str) -> Changes: def package_changes_get(self, package_base: str) -> Changes:
""" """
get package changes get package changes
@ -134,18 +143,16 @@ class LocalClient(Client):
return packages return packages
return [(package, status) for package, status in packages if package.base == package_base] return [(package, status) for package, status in packages if package.base == package_base]
def package_logs_add(self, log_record_id: LogRecordId, created: float, message: str) -> None: def package_logs_add(self, log_record: LogRecord) -> None:
""" """
post log record post log record
Args: Args:
log_record_id(LogRecordId): log record id log_record(LogRecord): log record
created(float): log created timestamp
message(str): log message
""" """
self.database.logs_insert(log_record_id, created, message, self.repository_id) self.database.logs_insert(log_record, self.repository_id)
def package_logs_get(self, package_base: str, limit: int = -1, offset: int = 0) -> list[tuple[float, str]]: def package_logs_get(self, package_base: str, limit: int = -1, offset: int = 0) -> list[LogRecord]:
""" """
get package logs get package logs
@ -155,7 +162,7 @@ class LocalClient(Client):
offset(int, optional): records offset (Default value = 0) offset(int, optional): records offset (Default value = 0)
Returns: Returns:
list[tuple[float, str]]: package logs list[LogRecord]: package logs
""" """
return self.database.logs_get(package_base, limit, offset, self.repository_id) return self.database.logs_get(package_base, limit, offset, self.repository_id)

View File

@ -28,7 +28,7 @@ from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType from ahriman.models.event import Event, EventType
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record import LogRecord
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@ -53,9 +53,6 @@ class Watcher(LazyLogging):
self._known: dict[str, tuple[Package, BuildStatus]] = {} self._known: dict[str, tuple[Package, BuildStatus]] = {}
self.status = BuildStatus() self.status = BuildStatus()
# special variables for updating logs
self._last_log_record_id = LogRecordId("", "")
@property @property
def packages(self) -> list[tuple[Package, BuildStatus]]: def packages(self) -> list[tuple[Package, BuildStatus]]:
""" """
@ -81,6 +78,8 @@ class Watcher(LazyLogging):
for package, status in self.client.package_get(None) for package, status in self.client.package_get(None)
} }
logs_rotate: Callable[[int], None]
package_changes_get: Callable[[str], Changes] package_changes_get: Callable[[str], Changes]
package_changes_update: Callable[[str, Changes], None] package_changes_update: Callable[[str, Changes], None]
@ -108,22 +107,9 @@ class Watcher(LazyLogging):
except KeyError: except KeyError:
raise UnknownPackageError(package_base) from None raise UnknownPackageError(package_base) from None
def package_logs_add(self, log_record_id: LogRecordId, created: float, message: str) -> None: package_logs_add: Callable[[LogRecord], None]
"""
make new log record into database
Args: package_logs_get: Callable[[str, int, int], list[LogRecord]]
log_record_id(LogRecordId): log record id
created(float): log created timestamp
message(str): log message
"""
if self._last_log_record_id != log_record_id:
# there is new log record, so we remove old ones
self.package_logs_remove(log_record_id.package_base, log_record_id.version)
self._last_log_record_id = log_record_id
self.client.package_logs_add(log_record_id, created, message)
package_logs_get: Callable[[str, int, int], list[tuple[float, str]]]
package_logs_remove: Callable[[str, str | None], None] package_logs_remove: Callable[[str, str | None], None]

View File

@ -29,7 +29,7 @@ from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType from ahriman.models.event import Event, EventType
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record import LogRecord
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -210,6 +210,18 @@ class WebClient(Client, SyncAhrimanClient):
return [] return []
def logs_rotate(self, keep_last_records: int) -> None:
"""
remove older logs from storage
Args:
keep_last_records(int): number of last records to keep
"""
query = self.repository_id.query() + [("keep_last_records", str(keep_last_records))]
with contextlib.suppress(Exception):
self.make_request("DELETE", f"{self.address}/api/v1/service/logs", params=query)
def package_changes_get(self, package_base: str) -> Changes: def package_changes_get(self, package_base: str) -> Changes:
""" """
get package changes get package changes
@ -294,28 +306,27 @@ class WebClient(Client, SyncAhrimanClient):
return [] return []
def package_logs_add(self, log_record_id: LogRecordId, created: float, message: str) -> None: def package_logs_add(self, log_record: LogRecord) -> None:
""" """
post log record post log record
Args: Args:
log_record_id(LogRecordId): log record id log_record(LogRecord): log record
created(float): log created timestamp
message(str): log message
""" """
payload = { payload = {
"created": created, "created": log_record.created,
"message": message, "message": log_record.message,
"version": log_record_id.version, "process_id": log_record.log_record_id.process_id,
"version": log_record.log_record_id.version,
} }
# this is special case, because we would like to do not suppress exception here # this is special case, because we would like to do not suppress exception here
# in case of exception raised it will be handled by upstream HttpLogHandler # in case of exception raised it will be handled by upstream HttpLogHandler
# In the other hand, we force to suppress all http logs here to avoid cyclic reporting # In the other hand, we force to suppress all http logs here to avoid cyclic reporting
self.make_request("POST", self._logs_url(log_record_id.package_base), self.make_request("POST", self._logs_url(log_record.log_record_id.package_base),
params=self.repository_id.query(), json=payload, suppress_errors=True) params=self.repository_id.query(), json=payload, suppress_errors=True)
def package_logs_get(self, package_base: str, limit: int = -1, offset: int = 0) -> list[tuple[float, str]]: def package_logs_get(self, package_base: str, limit: int = -1, offset: int = 0) -> list[LogRecord]:
""" """
get package logs get package logs
@ -325,7 +336,7 @@ class WebClient(Client, SyncAhrimanClient):
offset(int, optional): records offset (Default value = 0) offset(int, optional): records offset (Default value = 0)
Returns: Returns:
list[tuple[float, str]]: package logs list[LogRecord]: package logs
""" """
query = self.repository_id.query() + [("limit", str(limit)), ("offset", str(offset))] query = self.repository_id.query() + [("limit", str(limit)), ("offset", str(offset))]
@ -333,7 +344,7 @@ class WebClient(Client, SyncAhrimanClient):
response = self.make_request("GET", self._logs_url(package_base), params=query) response = self.make_request("GET", self._logs_url(package_base), params=query)
response_json = response.json() response_json = response.json()
return [(record["created"], record["message"]) for record in response_json] return [LogRecord.from_json(package_base, record) for record in response_json]
return [] return []

View File

@ -22,6 +22,7 @@ import itertools
from collections.abc import Callable, Generator from collections.abc import Callable, Generator
from pathlib import Path from pathlib import Path
from typing import ClassVar
from ahriman.core.utils import utcnow from ahriman.core.utils import utcnow
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@ -35,7 +36,7 @@ class PkgbuildGenerator:
PKGBUILD_STATIC_PROPERTIES(list[PkgbuildPatch]): (class attribute) list of default pkgbuild static properties PKGBUILD_STATIC_PROPERTIES(list[PkgbuildPatch]): (class attribute) list of default pkgbuild static properties
""" """
PKGBUILD_STATIC_PROPERTIES = [ PKGBUILD_STATIC_PROPERTIES: ClassVar[list[PkgbuildPatch]] = [
PkgbuildPatch("pkgrel", "1"), PkgbuildPatch("pkgrel", "1"),
PkgbuildPatch("arch", ["any"]), PkgbuildPatch("arch", ["any"]),
] ]

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from collections.abc import Callable from collections.abc import Callable
from typing import ClassVar
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.configuration.schema import ConfigurationSchema from ahriman.core.configuration.schema import ConfigurationSchema
@ -56,8 +57,8 @@ class Trigger(LazyLogging):
>>> loader.on_result(Result(), []) >>> loader.on_result(Result(), [])
""" """
CONFIGURATION_SCHEMA: ConfigurationSchema = {} CONFIGURATION_SCHEMA: ClassVar[ConfigurationSchema] = {}
CONFIGURATION_SCHEMA_FALLBACK: str | None = None CONFIGURATION_SCHEMA_FALLBACK: ClassVar[str | None] = None
def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None: def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:
""" """

View File

@ -25,9 +25,9 @@ class Action(StrEnum):
base action enumeration base action enumeration
Attributes: Attributes:
List(Action): (class attribute) list available values List(Action): list available values
Remove(Action): (class attribute) remove everything from local storage Remove(Action): remove everything from local storage
Update(Action): (class attribute) update local storage or add to Update(Action): update local storage or add to
""" """
List = "list" List = "list"

View File

@ -27,10 +27,10 @@ class AuthSettings(StrEnum):
web authorization type web authorization type
Attributes: Attributes:
Disabled(AuthSettings): (class attribute) authorization is disabled Disabled(AuthSettings): authorization is disabled
Configuration(AuthSettings): (class attribute) configuration based authorization Configuration(AuthSettings): configuration based authorization
OAuth(AuthSettings): (class attribute) OAuth based provider OAuth(AuthSettings): OAuth based provider
PAM(AuthSettings): (class attribute) PAM based provider PAM(AuthSettings): PAM based provider
""" """
Disabled = "disabled" Disabled = "disabled"

View File

@ -29,11 +29,11 @@ class BuildStatusEnum(StrEnum):
build status enumeration build status enumeration
Attributes: Attributes:
Unknown(BuildStatusEnum): (class attribute) build status is unknown Unknown(BuildStatusEnum): build status is unknown
Pending(BuildStatusEnum): (class attribute) package is out-of-dated and will be built soon Pending(BuildStatusEnum): package is out-of-dated and will be built soon
Building(BuildStatusEnum): (class attribute) package is building right now Building(BuildStatusEnum): package is building right now
Failed(BuildStatusEnum): (class attribute) package build failed Failed(BuildStatusEnum): package build failed
Success(BuildStatusEnum): (class attribute) package has been built without errors Success(BuildStatusEnum): package has been built without errors
""" """
Unknown = "unknown" Unknown = "unknown"

View File

@ -28,10 +28,10 @@ class EventType(StrEnum):
predefined event types predefined event types
Attributes: Attributes:
PackageOutdated(EventType): (class attribute) package has been marked as out-of-date PackageOutdated(EventType): package has been marked as out-of-date
PackageRemoved(EventType): (class attribute) package has been removed PackageRemoved(EventType): package has been removed
PackageUpdateFailed(EventType): (class attribute) package update has been failed PackageUpdateFailed(EventType): package update has been failed
PackageUpdated(EventType): (class attribute) package has been updated PackageUpdated(EventType): package has been updated
""" """
PackageOutdated = "package-outdated" PackageOutdated = "package-outdated"
@ -78,7 +78,7 @@ class Event:
dump(dict[str, Any]): json dump body dump(dict[str, Any]): json dump body
Returns: Returns:
Self: dependencies object Self: event object
""" """
return cls( return cls(
event=dump["event"], event=dump["event"],

View File

@ -25,9 +25,9 @@ class LogHandler(StrEnum):
log handler as described by default configuration log handler as described by default configuration
Attributes: Attributes:
Console(LogHandler): (class attribute) write logs to console Console(LogHandler): write logs to console
Syslog(LogHandler): (class attribute) write logs to syslog device /dev/null Syslog(LogHandler): write logs to syslog device /dev/null
Journald(LogHandler): (class attribute) write logs to journald directly Journald(LogHandler): write logs to journald directly
""" """
Console = "console" Console = "console"

View File

@ -0,0 +1,76 @@
#
# Copyright (c) 2021-2025 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from dataclasses import dataclass
from typing import Any, Self
from ahriman.models.log_record_id import LogRecordId
@dataclass(frozen=True)
class LogRecord:
"""
log record
Attributes:
log_record_id(LogRecordId): log record identifier
created(float): log record creation timestamp
message(str): log record message
"""
log_record_id: LogRecordId
created: float
message: str
@classmethod
def from_json(cls, package_base: str, dump: dict[str, Any]) -> Self:
"""
construct log record from the json dump
Args:
package_base(str): package base for which log record belongs
dump(dict[str, Any]): json dump body
Returns:
Self: log record object
"""
if "process_id" in dump:
log_record_id = LogRecordId(package_base, dump["version"], dump["process_id"])
else:
log_record_id = LogRecordId(package_base, dump["version"])
return cls(
log_record_id=log_record_id,
created=dump["created"],
message=dump["message"],
)
def view(self) -> dict[str, Any]:
"""
generate json log record view
Returns:
dict[str, Any]: json-friendly dictionary
"""
return {
"created": self.created,
"message": self.message,
"version": self.log_record_id.version,
"process_id": self.log_record_id.process_id,
}

View File

@ -17,7 +17,10 @@
# 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 uuid
from dataclasses import dataclass from dataclasses import dataclass
from typing import ClassVar
@dataclass(frozen=True) @dataclass(frozen=True)
@ -26,9 +29,21 @@ class LogRecordId:
log record process identifier log record process identifier
Attributes: Attributes:
DEFAULT_PROCESS_ID(str): (class attribute) default process identifier
package_base(str): package base for which log record belongs package_base(str): package base for which log record belongs
version(str): package version for which log record belongs version(str): package version for which log record belongs
process_id(str, optional): unique process identifier
""" """
package_base: str package_base: str
version: str version: str
process_id: str = ""
DEFAULT_PROCESS_ID: ClassVar[str] = str(uuid.uuid4())
def __post_init__(self) -> None:
"""
assign process identifier from default if not set
"""
if not self.process_id:
object.__setattr__(self, "process_id", self.DEFAULT_PROCESS_ID)

View File

@ -429,12 +429,11 @@ class Package(LazyLogging):
task = Task(self, configuration, repository_id.architecture, paths) task = Task(self, configuration, repository_id.architecture, paths)
try: try:
with self.suppress_logging(): # create fresh chroot environment, fetch sources and - automagically - update PKGBUILD
# create fresh chroot environment, fetch sources and - automagically - update PKGBUILD task.init(paths.cache_for(self.base), [], None)
task.init(paths.cache_for(self.base), [], None) pkgbuild = Pkgbuild.from_file(paths.cache_for(self.base) / "PKGBUILD")
pkgbuild = Pkgbuild.from_file(paths.cache_for(self.base) / "PKGBUILD")
return full_version(pkgbuild.get("epoch"), pkgbuild["pkgver"], pkgbuild["pkgrel"]) return full_version(pkgbuild.get("epoch"), pkgbuild["pkgver"], pkgbuild["pkgrel"])
except Exception: except Exception:
self.logger.exception("cannot determine version of VCS package") self.logger.exception("cannot determine version of VCS package")
finally: finally:

View File

@ -32,13 +32,13 @@ class PackageSource(StrEnum):
package source for addition enumeration package source for addition enumeration
Attributes: Attributes:
Auto(PackageSource): (class attribute) automatically determine type of the source Auto(PackageSource): automatically determine type of the source
Archive(PackageSource): (class attribute) source is a package archive Archive(PackageSource): source is a package archive
AUR(PackageSource): (class attribute) source is an AUR package for which it should search AUR(PackageSource): source is an AUR package for which it should search
Directory(PackageSource): (class attribute) source is a directory which contains packages Directory(PackageSource): source is a directory which contains packages
Local(PackageSource): (class attribute) source is locally stored PKGBUILD Local(PackageSource): source is locally stored PKGBUILD
Remote(PackageSource): (class attribute) source is remote (http, ftp etc...) link Remote(PackageSource): source is remote (http, ftp etc...) link
Repository(PackageSource): (class attribute) source is official repository Repository(PackageSource): source is official repository
Examples: Examples:
In case if source is unknown the :func:`resolve()` and the source In case if source is unknown the :func:`resolve()` and the source

View File

@ -25,9 +25,9 @@ class PacmanSynchronization(IntEnum):
pacman database synchronization flag pacman database synchronization flag
Attributes: Attributes:
Disabled(PacmanSynchronization): (class attribute) do not synchronize local database Disabled(PacmanSynchronization): do not synchronize local database
Enabled(PacmanSynchronization): (class attribute) synchronize local database (same as pacman -Sy) Enabled(PacmanSynchronization): synchronize local database (same as pacman -Sy)
Force(PacmanSynchronization): (class attribute) force synchronize local database (same as pacman -Syy) Force(PacmanSynchronization): force synchronize local database (same as pacman -Syy)
""" """
Disabled = 0 Disabled = 0

View File

@ -21,7 +21,7 @@ from collections.abc import Iterator, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from io import StringIO from io import StringIO
from pathlib import Path from pathlib import Path
from typing import Any, IO, Self from typing import Any, ClassVar, IO, Self
from ahriman.core.alpm.pkgbuild_parser import PkgbuildParser, PkgbuildToken from ahriman.core.alpm.pkgbuild_parser import PkgbuildParser, PkgbuildToken
from ahriman.core.exceptions import EncodeError from ahriman.core.exceptions import EncodeError
@ -40,7 +40,7 @@ class Pkgbuild(Mapping[str, Any]):
fields: dict[str, PkgbuildPatch] fields: dict[str, PkgbuildPatch]
DEFAULT_ENCODINGS = ["utf8", "latin-1"] DEFAULT_ENCODINGS: ClassVar[list[str]] = ["utf8", "latin-1"]
@property @property
def variables(self) -> dict[str, str]: def variables(self) -> dict[str, str]:

View File

@ -27,13 +27,13 @@ class ReportSettings(StrEnum):
report targets enumeration report targets enumeration
Attributes: Attributes:
Disabled(ReportSettings): (class attribute) option which generates no report for testing purpose Disabled(ReportSettings): option which generates no report for testing purpose
HTML(ReportSettings): (class attribute) html report generation HTML(ReportSettings): html report generation
Email(ReportSettings): (class attribute) email report generation Email(ReportSettings): email report generation
Console(ReportSettings): (class attribute) print result to console Console(ReportSettings): print result to console
Telegram(ReportSettings): (class attribute) markdown report to telegram channel Telegram(ReportSettings): markdown report to telegram channel
RSS(ReportSettings): (class attribute) RSS report generation RSS(ReportSettings): RSS report generation
RemoteCall(ReportSettings): (class attribute) remote ahriman server call RemoteCall(ReportSettings): remote ahriman server call
""" """
Disabled = "disabled" # for testing purpose Disabled = "disabled" # for testing purpose

View File

@ -20,7 +20,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from typing import Any, Self from typing import Any, ClassVar, Self
from ahriman.models.package import Package from ahriman.models.package import Package
@ -33,7 +33,7 @@ class Result:
STATUS_PRIORITIES(list[str]): (class attribute) list of statues according to their priorities STATUS_PRIORITIES(list[str]): (class attribute) list of statues according to their priorities
""" """
STATUS_PRIORITIES = [ STATUS_PRIORITIES: ClassVar[list[str]] = [
"failed", "failed",
"removed", "removed",
"updated", "updated",

View File

@ -27,9 +27,9 @@ class SignSettings(StrEnum):
sign targets enumeration sign targets enumeration
Attributes: Attributes:
Disabled(SignSettings): (class attribute) option which generates no report for testing purpose Disabled(SignSettings): option which generates no report for testing purpose
Packages(SignSettings): (class attribute) sign each package Packages(SignSettings): sign each package
Repository(SignSettings): (class attribute) sign repository database file Repository(SignSettings): sign repository database file
""" """
Disabled = "disabled" Disabled = "disabled"

View File

@ -27,9 +27,9 @@ class SmtpSSLSettings(StrEnum):
SMTP SSL mode enumeration SMTP SSL mode enumeration
Attributes: Attributes:
Disabled(SmtpSSLSettings): (class attribute) no SSL enabled Disabled(SmtpSSLSettings): no SSL enabled
SSL(SmtpSSLSettings): (class attribute) use SMTP_SSL instead of normal SMTP client SSL(SmtpSSLSettings): use SMTP_SSL instead of normal SMTP client
STARTTLS(SmtpSSLSettings): (class attribute) use STARTTLS in normal SMTP client STARTTLS(SmtpSSLSettings): use STARTTLS in normal SMTP client
""" """
Disabled = "disabled" Disabled = "disabled"

View File

@ -27,11 +27,11 @@ class UploadSettings(StrEnum):
remote synchronization targets enumeration remote synchronization targets enumeration
Attributes: Attributes:
Disabled(UploadSettings): (class attribute) no sync will be performed, required for testing purpose Disabled(UploadSettings): no sync will be performed, required for testing purpose
Rsync(UploadSettings): (class attribute) sync via rsync Rsync(UploadSettings): sync via rsync
S3(UploadSettings): (class attribute) sync to Amazon S3 S3(UploadSettings): sync to Amazon S3
GitHub(UploadSettings): (class attribute) sync to GitHub releases page GitHub(UploadSettings): sync to GitHub releases page
RemoteService(UploadSettings): (class attribute) sync to another ahriman instance RemoteService(UploadSettings): sync to another ahriman instance
""" """
Disabled = "disabled" # for testing purpose Disabled = "disabled" # for testing purpose

View File

@ -21,7 +21,7 @@ import bcrypt
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from secrets import token_urlsafe as generate_password from secrets import token_urlsafe as generate_password
from typing import Self from typing import ClassVar, Self
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -69,7 +69,7 @@ class User:
packager_id: str | None = None packager_id: str | None = None
key: str | None = None key: str | None = None
SUPPORTED_ALGOS = {"$2$", "$2a$", "$2x$", "$2y$", "$2b$"} SUPPORTED_ALGOS: ClassVar[set[str]] = {"$2$", "$2a$", "$2x$", "$2y$", "$2b$"}
def __post_init__(self) -> None: def __post_init__(self) -> None:
""" """

View File

@ -27,11 +27,11 @@ class UserAccess(StrEnum):
web user access enumeration web user access enumeration
Attributes: Attributes:
Unauthorized(UserAccess): (class attribute) user can access specific resources which are marked as available Unauthorized(UserAccess): user can access specific resources which are marked as available
without authorization (e.g. login, logout, static) without authorization (e.g. login, logout, static)
Read(UserAccess): (class attribute) user can read the page Read(UserAccess): user can read the page
Reporter(UserAccess): (class attribute) user can read everything and is able to perform some modifications Reporter(UserAccess): user can read everything and is able to perform some modifications
Full(UserAccess): (class attribute) user has full access Full(UserAccess): user has full access
""" """
Unauthorized = "unauthorized" Unauthorized = "unauthorized"

View File

@ -18,7 +18,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPException from aiohttp.web import HTTPException
from typing import Any, Callable from collections.abc import Callable
from typing import Any
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec import Schema, aiohttp_apispec from ahriman.web.apispec import Schema, aiohttp_apispec

View File

@ -31,6 +31,7 @@ from ahriman.web.schemas.info_schema import InfoSchema
from ahriman.web.schemas.internal_status_schema import InternalStatusSchema from ahriman.web.schemas.internal_status_schema import InternalStatusSchema
from ahriman.web.schemas.log_schema import LogSchema from ahriman.web.schemas.log_schema import LogSchema
from ahriman.web.schemas.login_schema import LoginSchema from ahriman.web.schemas.login_schema import LoginSchema
from ahriman.web.schemas.logs_rotate_schema import LogsRotateSchema
from ahriman.web.schemas.logs_schema import LogsSchema from ahriman.web.schemas.logs_schema import LogsSchema
from ahriman.web.schemas.oauth2_schema import OAuth2Schema from ahriman.web.schemas.oauth2_schema import OAuth2Schema
from ahriman.web.schemas.package_name_schema import PackageNameSchema from ahriman.web.schemas.package_name_schema import PackageNameSchema
@ -53,5 +54,4 @@ from ahriman.web.schemas.repository_stats_schema import RepositoryStatsSchema
from ahriman.web.schemas.search_schema import SearchSchema from ahriman.web.schemas.search_schema import SearchSchema
from ahriman.web.schemas.status_schema import StatusSchema from ahriman.web.schemas.status_schema import StatusSchema
from ahriman.web.schemas.update_flags_schema import UpdateFlagsSchema from ahriman.web.schemas.update_flags_schema import UpdateFlagsSchema
from ahriman.web.schemas.versioned_log_schema import VersionedLogSchema
from ahriman.web.schemas.worker_schema import WorkerSchema from ahriman.web.schemas.worker_schema import WorkerSchema

View File

@ -17,12 +17,13 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from ahriman import __version__
from ahriman.web.apispec import Schema, fields from ahriman.web.apispec import Schema, fields
class LogSchema(Schema): class LogSchema(Schema):
""" """
request package log schema request and response package log schema
""" """
created = fields.Float(required=True, metadata={ created = fields.Float(required=True, metadata={
@ -32,3 +33,10 @@ class LogSchema(Schema):
message = fields.String(required=True, metadata={ message = fields.String(required=True, metadata={
"description": "Log message", "description": "Log message",
}) })
version = fields.String(required=True, metadata={
"description": "Package version to tag",
"example": __version__,
})
process_id = fields.String(metadata={
"description": "Process unique identifier",
})

View File

@ -17,18 +17,14 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from ahriman import __version__ from ahriman.web.apispec import Schema, fields
from ahriman.web.apispec import fields
from ahriman.web.schemas.log_schema import LogSchema
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
class VersionedLogSchema(LogSchema, RepositoryIdSchema): class LogsRotateSchema(Schema):
""" """
request package log schema request logs rotate schema
""" """
version = fields.Integer(required=True, metadata={ keep_last_records = fields.Integer(metadata={
"description": "Package version to tag", "description": "Keep the specified amount of records",
"example": __version__,
}) })

View File

@ -19,7 +19,7 @@
# #
import aiohttp_jinja2 import aiohttp_jinja2
from typing import Any from typing import Any, ClassVar
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -35,7 +35,7 @@ class DocsView(BaseView):
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
""" """
GET_PERMISSION = UserAccess.Unauthorized GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
ROUTES = ["/api-docs"] ROUTES = ["/api-docs"]
@classmethod @classmethod

View File

@ -19,6 +19,7 @@
# #
from aiohttp.web import Response, json_response from aiohttp.web import Response, json_response
from collections.abc import Callable from collections.abc import Callable
from typing import ClassVar
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.utils import partition from ahriman.core.utils import partition
@ -35,7 +36,7 @@ class SwaggerView(BaseView):
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
""" """
GET_PERMISSION = UserAccess.Unauthorized GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
ROUTES = ["/api-docs/swagger.json"] ROUTES = ["/api-docs/swagger.json"]
@classmethod @classmethod

View File

@ -20,7 +20,7 @@
from aiohttp.web import HTTPBadRequest, HTTPNotFound, Request, StreamResponse, View from aiohttp.web import HTTPBadRequest, HTTPNotFound, Request, StreamResponse, View
from aiohttp_cors import CorsViewMixin # type: ignore[import-untyped] from aiohttp_cors import CorsViewMixin # type: ignore[import-untyped]
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import TypeVar from typing import ClassVar, TypeVar
from ahriman.core.auth import Auth from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -46,8 +46,8 @@ class BaseView(View, CorsViewMixin):
ROUTES(list[str]): (class attribute) list of supported routes ROUTES(list[str]): (class attribute) list of supported routes
""" """
OPTIONS_PERMISSION = UserAccess.Unauthorized OPTIONS_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
ROUTES: list[str] = [] ROUTES: ClassVar[list[str]] = []
@property @property
def configuration(self) -> Configuration: def configuration(self) -> Configuration:

View File

@ -19,7 +19,7 @@
# #
import aiohttp_jinja2 import aiohttp_jinja2
from typing import Any from typing import Any, ClassVar
from ahriman.core.auth.helpers import authorized_userid from ahriman.core.auth.helpers import authorized_userid
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -48,7 +48,7 @@ class IndexView(BaseView):
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
""" """
GET_PERMISSION = UserAccess.Unauthorized GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
ROUTES = ["/", "/index.html"] ROUTES = ["/", "/index.html"]
@aiohttp_jinja2.template("build-status.jinja2") @aiohttp_jinja2.template("build-status.jinja2")

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPFound, HTTPNotFound from aiohttp.web import HTTPFound, HTTPNotFound
from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
@ -31,7 +32,7 @@ class StaticView(BaseView):
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
""" """
GET_PERMISSION = UserAccess.Unauthorized GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
ROUTES = ["/favicon.ico"] ROUTES = ["/favicon.ico"]
async def get(self) -> None: async def get(self) -> None:

View File

@ -17,6 +17,8 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from typing import ClassVar
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -25,7 +27,7 @@ class StatusViewGuard:
helper for check if status routes are enabled helper for check if status routes are enabled
""" """
ROUTES: list[str] ROUTES: ClassVar[list[str]]
@classmethod @classmethod
def routes(cls, configuration: Configuration) -> list[str]: def routes(cls, configuration: Configuration) -> list[str]:

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from typing import ClassVar
from ahriman.models.event import Event from ahriman.models.event import Event
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -35,7 +36,7 @@ class EventsView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
GET_PERMISSION = POST_PERMISSION = UserAccess.Full GET_PERMISSION = POST_PERMISSION = UserAccess.Full # type: ClassVar[UserAccess]
ROUTES = ["/api/v1/events"] ROUTES = ["/api/v1/events"]
@apidocs( @apidocs(

View File

@ -19,6 +19,7 @@
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from collections.abc import Callable from collections.abc import Callable
from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.models.worker import Worker from ahriman.models.worker import Worker
@ -37,7 +38,7 @@ class WorkersView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
DELETE_PERMISSION = GET_PERMISSION = POST_PERMISSION = UserAccess.Full DELETE_PERMISSION = GET_PERMISSION = POST_PERMISSION = UserAccess.Full # type: ClassVar[UserAccess]
ROUTES = ["/api/v1/distributed"] ROUTES = ["/api/v1/distributed"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from typing import ClassVar
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -36,8 +37,8 @@ class ChangesView(StatusViewGuard, BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
GET_PERMISSION = UserAccess.Reporter GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
POST_PERMISSION = UserAccess.Full POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/packages/{package}/changes"] ROUTES = ["/api/v1/packages/{package}/changes"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from typing import ClassVar
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -36,8 +37,8 @@ class DependenciesView(StatusViewGuard, BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
GET_PERMISSION = UserAccess.Reporter GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
POST_PERMISSION = UserAccess.Full POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/packages/{package}/dependencies"] ROUTES = ["/api/v1/packages/{package}/dependencies"]
@apidocs( @apidocs(

View File

@ -18,14 +18,14 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.utils import pretty_datetime from ahriman.core.utils import pretty_datetime
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record import LogRecord
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import LogsSchema, PackageNameSchema, PackageVersionSchema, RepositoryIdSchema, \ from ahriman.web.schemas import LogSchema, LogsSchema, PackageNameSchema, PackageVersionSchema, RepositoryIdSchema
VersionedLogSchema
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
from ahriman.web.views.status_view_guard import StatusViewGuard from ahriman.web.views.status_view_guard import StatusViewGuard
@ -40,8 +40,8 @@ class LogsView(StatusViewGuard, BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
DELETE_PERMISSION = POST_PERMISSION = UserAccess.Full DELETE_PERMISSION = POST_PERMISSION = UserAccess.Full # type: ClassVar[UserAccess]
GET_PERMISSION = UserAccess.Reporter GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
ROUTES = ["/api/v1/packages/{package}/logs"] ROUTES = ["/api/v1/packages/{package}/logs"]
@apidocs( @apidocs(
@ -97,7 +97,7 @@ class LogsView(StatusViewGuard, BaseView):
response = { response = {
"package_base": package_base, "package_base": package_base,
"status": status.view(), "status": status.view(),
"logs": "\n".join(f"[{pretty_datetime(created)}] {message}" for created, message in logs) "logs": "\n".join(f"[{pretty_datetime(log_record.created)}] {log_record.message}" for log_record in logs)
} }
return json_response(response) return json_response(response)
@ -109,7 +109,7 @@ class LogsView(StatusViewGuard, BaseView):
error_400_enabled=True, error_400_enabled=True,
error_404_description="Repository is unknown", error_404_description="Repository is unknown",
match_schema=PackageNameSchema, match_schema=PackageNameSchema,
body_schema=VersionedLogSchema, body_schema=LogSchema,
) )
async def post(self) -> None: async def post(self) -> None:
""" """
@ -123,12 +123,10 @@ class LogsView(StatusViewGuard, BaseView):
try: try:
data = await self.request.json() data = await self.request.json()
created = data["created"] log_record = LogRecord.from_json(package_base, data)
record = data["message"]
version = data["version"]
except Exception as ex: except Exception as ex:
raise HTTPBadRequest(reason=str(ex)) raise HTTPBadRequest(reason=str(ex))
self.service().package_logs_add(LogRecordId(package_base, version), created, record) self.service().package_logs_add(log_record)
raise HTTPNoContent raise HTTPNoContent

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError from ahriman.core.exceptions import UnknownPackageError
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
@ -40,8 +41,8 @@ class PackageView(StatusViewGuard, BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
DELETE_PERMISSION = POST_PERMISSION = UserAccess.Full DELETE_PERMISSION = POST_PERMISSION = UserAccess.Full # type: ClassVar[UserAccess]
GET_PERMISSION = UserAccess.Read GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Read
ROUTES = ["/api/v1/packages/{package}"] ROUTES = ["/api/v1/packages/{package}"]
@apidocs( @apidocs(

View File

@ -21,6 +21,7 @@ import itertools
from aiohttp.web import HTTPNoContent, Response, json_response from aiohttp.web import HTTPNoContent, Response, json_response
from collections.abc import Callable from collections.abc import Callable
from typing import ClassVar
from ahriman.models.build_status import BuildStatus from ahriman.models.build_status import BuildStatus
from ahriman.models.package import Package from ahriman.models.package import Package
@ -40,8 +41,8 @@ class PackagesView(StatusViewGuard, BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
GET_PERMISSION = UserAccess.Read GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Read
POST_PERMISSION = UserAccess.Full POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/packages"] ROUTES = ["/api/v1/packages"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response
from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
@ -35,8 +36,8 @@ class PatchView(StatusViewGuard, BaseView):
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
""" """
DELETE_PERMISSION = UserAccess.Full DELETE_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
GET_PERMISSION = UserAccess.Reporter GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
ROUTES = ["/api/v1/packages/{package}/patches/{patch}"] ROUTES = ["/api/v1/packages/{package}/patches/{patch}"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -36,8 +37,8 @@ class PatchesView(StatusViewGuard, BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
GET_PERMISSION = UserAccess.Reporter GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
POST_PERMISSION = UserAccess.Full POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/packages/{package}/patches"] ROUTES = ["/api/v1/packages/{package}/patches"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, Response, json_response from aiohttp.web import HTTPBadRequest, Response, json_response
from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -34,7 +35,7 @@ class AddView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
POST_PERMISSION = UserAccess.Full POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/service/add"] ROUTES = ["/api/v1/service/add"]
@apidocs( @apidocs(

View File

@ -0,0 +1,64 @@
#
# Copyright (c) 2021-2025 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPBadRequest, HTTPNoContent
from typing import ClassVar
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import LogsRotateSchema
from ahriman.web.views.base import BaseView
class LogsView(BaseView):
"""
logs management web view
Attributes:
DELETE_PERMISSION(UserAccess): (class attribute) delete permissions of self
"""
DELETE_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/service/logs"]
@apidocs(
tags=["Actions"],
summary="Rotate logs",
description="Remove older logs from system",
permission=DELETE_PERMISSION,
error_400_enabled=True,
error_404_description="Repository is unknown",
query_schema=LogsRotateSchema,
)
async def delete(self) -> None:
"""
rotate logs from system
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: on success response
"""
try:
keep_last_records = int(self.request.query.get("keep_last_records", 0))
except Exception as ex:
raise HTTPBadRequest(reason=str(ex))
self.service().logs_rotate(keep_last_records)
raise HTTPNoContent

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response
from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
@ -34,8 +35,8 @@ class PGPView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
GET_PERMISSION = UserAccess.Reporter GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
POST_PERMISSION = UserAccess.Full POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/service/pgp"] ROUTES = ["/api/v1/service/pgp"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPNotFound, Response, json_response from aiohttp.web import HTTPNotFound, Response, json_response
from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
@ -33,7 +34,7 @@ class ProcessView(BaseView):
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
""" """
GET_PERMISSION = UserAccess.Reporter GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
ROUTES = ["/api/v1/service/process/{process_id}"] ROUTES = ["/api/v1/service/process/{process_id}"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, Response, json_response from aiohttp.web import HTTPBadRequest, Response, json_response
from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
@ -33,7 +34,7 @@ class RebuildView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
POST_PERMISSION = UserAccess.Full POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/service/rebuild"] ROUTES = ["/api/v1/service/rebuild"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, Response, json_response from aiohttp.web import HTTPBadRequest, Response, json_response
from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
@ -33,7 +34,7 @@ class RemoveView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
POST_PERMISSION = UserAccess.Full POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/service/remove"] ROUTES = ["/api/v1/service/remove"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, Response, json_response from aiohttp.web import HTTPBadRequest, Response, json_response
from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -34,7 +35,7 @@ class RequestView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
POST_PERMISSION = UserAccess.Reporter POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
ROUTES = ["/api/v1/service/request"] ROUTES = ["/api/v1/service/request"]
@apidocs( @apidocs(

View File

@ -19,6 +19,7 @@
# #
from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response
from collections.abc import Callable from collections.abc import Callable
from typing import ClassVar
from ahriman.core.alpm.remote import AUR from ahriman.core.alpm.remote import AUR
from ahriman.models.aur_package import AURPackage from ahriman.models.aur_package import AURPackage
@ -36,7 +37,7 @@ class SearchView(BaseView):
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
""" """
GET_PERMISSION = UserAccess.Reporter GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
ROUTES = ["/api/v1/service/search"] ROUTES = ["/api/v1/service/search"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, Response, json_response from aiohttp.web import HTTPBadRequest, Response, json_response
from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
@ -33,7 +34,7 @@ class UpdateView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
POST_PERMISSION = UserAccess.Full POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/service/update"] ROUTES = ["/api/v1/service/update"]
@apidocs( @apidocs(

View File

@ -23,6 +23,7 @@ from aiohttp import BodyPartReader
from aiohttp.web import HTTPBadRequest, HTTPCreated from aiohttp.web import HTTPBadRequest, HTTPCreated
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import ClassVar
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
@ -40,7 +41,7 @@ class UploadView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
POST_PERMISSION = UserAccess.Full POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/service/upload"] ROUTES = ["/api/v1/service/upload"]
@classmethod @classmethod

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import Response, json_response from aiohttp.web import Response, json_response
from typing import ClassVar
from ahriman import __version__ from ahriman import __version__
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -34,7 +35,7 @@ class InfoView(BaseView):
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
""" """
GET_PERMISSION = UserAccess.Unauthorized GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
ROUTES = ["/api/v1/info"] ROUTES = ["/api/v1/info"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import Response, json_response from aiohttp.web import Response, json_response
from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
@ -33,7 +34,7 @@ class RepositoriesView(BaseView):
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
""" """
GET_PERMISSION = UserAccess.Read GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Read
ROUTES = ["/api/v1/repositories"] ROUTES = ["/api/v1/repositories"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from typing import ClassVar
from ahriman import __version__ from ahriman import __version__
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
@ -40,8 +41,8 @@ class StatusView(StatusViewGuard, BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
GET_PERMISSION = UserAccess.Read GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Read
POST_PERMISSION = UserAccess.Full POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/status"] ROUTES = ["/api/v1/status"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPBadRequest, HTTPFound, HTTPMethodNotAllowed, HTTPUnauthorized from aiohttp.web import HTTPBadRequest, HTTPFound, HTTPMethodNotAllowed, HTTPUnauthorized
from typing import ClassVar
from ahriman.core.auth.helpers import remember from ahriman.core.auth.helpers import remember
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -35,7 +36,7 @@ class LoginView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
GET_PERMISSION = POST_PERMISSION = UserAccess.Unauthorized GET_PERMISSION = POST_PERMISSION = UserAccess.Unauthorized # type: ClassVar[UserAccess]
ROUTES = ["/api/v1/login"] ROUTES = ["/api/v1/login"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import HTTPFound, HTTPUnauthorized from aiohttp.web import HTTPFound, HTTPUnauthorized
from typing import ClassVar
from ahriman.core.auth.helpers import check_authorized, forget from ahriman.core.auth.helpers import check_authorized, forget
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -33,7 +34,7 @@ class LogoutView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
POST_PERMISSION = UserAccess.Unauthorized POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
ROUTES = ["/api/v1/logout"] ROUTES = ["/api/v1/logout"]
@apidocs( @apidocs(

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import Response, json_response from aiohttp.web import Response, json_response
from typing import ClassVar
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs from ahriman.web.apispec.decorators import apidocs
@ -34,7 +35,7 @@ class LogsView(StatusViewGuard, BaseView):
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
""" """
GET_PERMISSION = UserAccess.Reporter GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
ROUTES = ["/api/v2/packages/{package}/logs"] ROUTES = ["/api/v2/packages/{package}/logs"]
@apidocs( @apidocs(
@ -63,10 +64,5 @@ class LogsView(StatusViewGuard, BaseView):
logs = self.service(package_base=package_base).package_logs_get(package_base, limit, offset) logs = self.service(package_base=package_base).package_logs_get(package_base, limit, offset)
response = [ response = [log_record.view() for log_record in logs]
{
"created": created,
"message": message,
} for created, message in logs
]
return json_response(response) return json_response(response)

View File

@ -0,0 +1,8 @@
from ahriman.core.database.migrations.m015_logs_process_id import steps
def test_migration_logs_process_id() -> None:
"""
migration must not be empty
"""
assert steps

View File

@ -1,4 +1,5 @@
from ahriman.core.database import SQLite from ahriman.core.database import SQLite
from ahriman.models.log_record import LogRecord
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -9,67 +10,115 @@ def test_logs_insert_remove_version(database: SQLite, package_ahriman: Package,
""" """
must clear version specific package logs must clear version specific package logs
""" """
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1") database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"))
database.logs_insert(LogRecordId(package_ahriman.base, "2"), 43.0, "message 2") database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "2"), 43.0, "message 2"))
database.logs_insert(LogRecordId(package_python_schedule.base, "1"), 42.0, "message 3") database.logs_insert(LogRecord(LogRecordId(package_python_schedule.base, "1"), 42.0, "message 3"))
database.logs_remove(package_ahriman.base, "1") database.logs_remove(package_ahriman.base, "1")
assert database.logs_get(package_ahriman.base) == [(42.0, "message 1")] assert database.logs_get(package_ahriman.base) == [
assert database.logs_get(package_python_schedule.base) == [(42.0, "message 3")] LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"),
]
assert database.logs_get(package_python_schedule.base) == [
LogRecord(LogRecordId(package_python_schedule.base, "1"), 42.0, "message 3"),
]
def test_logs_insert_remove_multi(database: SQLite, package_ahriman: Package) -> None: def test_logs_insert_remove_multi(database: SQLite, package_ahriman: Package) -> None:
""" """
must clear logs for specified repository must clear logs for specified repository
""" """
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1") database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"))
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2", database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2"),
RepositoryId("i686", database._repository_id.name)) RepositoryId("i686", database._repository_id.name))
database.logs_remove(package_ahriman.base, None, RepositoryId("i686", database._repository_id.name)) database.logs_remove(package_ahriman.base, None, RepositoryId("i686", database._repository_id.name))
assert not database.logs_get(package_ahriman.base, repository_id=RepositoryId("i686", database._repository_id.name)) assert not database.logs_get(package_ahriman.base, repository_id=RepositoryId("i686", database._repository_id.name))
assert database.logs_get(package_ahriman.base) == [(42.0, "message 1")] assert database.logs_get(package_ahriman.base) == [
LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"),
]
def test_logs_insert_remove_full(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None: def test_logs_insert_remove_full(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None:
""" """
must clear full package logs must clear full package logs
""" """
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1") database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"))
database.logs_insert(LogRecordId(package_ahriman.base, "2"), 43.0, "message 2") database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "2"), 43.0, "message 2"))
database.logs_insert(LogRecordId(package_python_schedule.base, "1"), 42.0, "message 3") database.logs_insert(LogRecord(LogRecordId(package_python_schedule.base, "1"), 42.0, "message 3"))
database.logs_remove(package_ahriman.base, None) database.logs_remove(package_ahriman.base, None)
assert not database.logs_get(package_ahriman.base) assert not database.logs_get(package_ahriman.base)
assert database.logs_get(package_python_schedule.base) == [(42.0, "message 3")] assert database.logs_get(package_python_schedule.base) == [
LogRecord(LogRecordId(package_python_schedule.base, "1"), 42.0, "message 3"),
]
def test_logs_insert_get(database: SQLite, package_ahriman: Package) -> None: def test_logs_insert_get(database: SQLite, package_ahriman: Package) -> None:
""" """
must insert and get package logs must insert and get package logs
""" """
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2") database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2"))
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1") database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"))
assert database.logs_get(package_ahriman.base) == [(42.0, "message 1"), (43.0, "message 2")] assert database.logs_get(package_ahriman.base) == [
LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"),
LogRecord(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2"),
]
def test_logs_insert_get_pagination(database: SQLite, package_ahriman: Package) -> None: def test_logs_insert_get_pagination(database: SQLite, package_ahriman: Package) -> None:
""" """
must insert and get package logs with pagination must insert and get package logs with pagination
""" """
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1") database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"))
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2") database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2"))
assert database.logs_get(package_ahriman.base, 1, 1) == [(42.0, "message 1")] assert database.logs_get(package_ahriman.base, 1, 1) == [
LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"),
]
def test_logs_insert_get_multi(database: SQLite, package_ahriman: Package) -> None: def test_logs_insert_get_multi(database: SQLite, package_ahriman: Package) -> None:
""" """
must insert and get package logs for multiple repositories must insert and get package logs for multiple repositories
""" """
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1") database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"))
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2", database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2"),
RepositoryId("i686", database._repository_id.name)) RepositoryId("i686", database._repository_id.name))
assert database.logs_get(package_ahriman.base, assert database.logs_get(package_ahriman.base,
repository_id=RepositoryId("i686", database._repository_id.name)) == [(43.0, "message 2")] repository_id=RepositoryId("i686", database._repository_id.name)) == [
assert database.logs_get(package_ahriman.base) == [(42.0, "message 1")] LogRecord(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2"),
]
assert database.logs_get(package_ahriman.base) == [
LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"),
]
def test_logs_rotate_remove_all(database: SQLite, package_ahriman: Package) -> None:
"""
must remove all records when rotating with keep_last_records is 0
"""
database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"))
database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2"))
database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "2"), 44.0, "message 3"))
database.logs_rotate(0)
assert not database.logs_get(package_ahriman.base)
def test_logs_rotate_remove_duplicates(database: SQLite, package_ahriman: Package) -> None:
"""
must remove duplicate records while preserving the most recent one for each package version
"""
database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1", "p1"), 42.0, "message 1"))
database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1", "p2"), 43.0, "message 2"))
database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1", "p3"), 44.0, "message 3"))
database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "2", "p1"), 45.0, "message 4"))
database.logs_rotate(2)
logs = database.logs_get(package_ahriman.base)
assert len(logs) == 2
assert logs == [
LogRecord(LogRecordId(package_ahriman.base, "1", "p3"), 44.0, "message 3"),
LogRecord(LogRecordId(package_ahriman.base, "2", "p1"), 45.0, "message 4"),
]

View File

@ -4,6 +4,7 @@ from pytest_mock import MockerFixture
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.log.http_log_handler import HttpLogHandler from ahriman.core.log.http_log_handler import HttpLogHandler
from ahriman.models.log_record import LogRecord
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
@ -19,12 +20,14 @@ def test_load(configuration: Configuration, mocker: MockerFixture) -> None:
add_mock = mocker.patch("logging.Logger.addHandler") add_mock = mocker.patch("logging.Logger.addHandler")
load_mock = mocker.patch("ahriman.core.status.Client.load") load_mock = mocker.patch("ahriman.core.status.Client.load")
atexit_mock = mocker.patch("atexit.register")
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
handler = HttpLogHandler.load(repository_id, configuration, report=False) handler = HttpLogHandler.load(repository_id, configuration, report=False)
assert handler assert handler
add_mock.assert_called_once_with(handler) add_mock.assert_called_once_with(handler)
load_mock.assert_called_once_with(repository_id, configuration, report=False) load_mock.assert_called_once_with(repository_id, configuration, report=False)
atexit_mock.assert_called_once_with(handler.rotate)
def test_load_exist(configuration: Configuration) -> None: def test_load_exist(configuration: Configuration) -> None:
@ -49,7 +52,7 @@ def test_emit(configuration: Configuration, log_record: logging.LogRecord, packa
handler = HttpLogHandler(repository_id, configuration, report=False, suppress_errors=False) handler = HttpLogHandler(repository_id, configuration, report=False, suppress_errors=False)
handler.emit(log_record) handler.emit(log_record)
log_mock.assert_called_once_with(log_record_id, log_record.created, log_record.getMessage()) log_mock.assert_called_once_with(LogRecord(log_record_id, log_record.created, log_record.getMessage()))
def test_emit_failed(configuration: Configuration, log_record: logging.LogRecord, package_ahriman: Package, def test_emit_failed(configuration: Configuration, log_record: logging.LogRecord, package_ahriman: Package,
@ -93,3 +96,16 @@ def test_emit_skip(configuration: Configuration, log_record: logging.LogRecord,
handler.emit(log_record) handler.emit(log_record)
log_mock.assert_not_called() log_mock.assert_not_called()
def test_rotate(configuration: Configuration, mocker: MockerFixture) -> None:
"""
must rotate logs
"""
rotate_mock = mocker.patch("ahriman.core.status.Client.logs_rotate")
_, repository_id = configuration.check_loaded()
handler = HttpLogHandler(repository_id, configuration, report=False, suppress_errors=False)
handler.rotate()
rotate_mock.assert_called_once_with(handler.keep_last_records)

View File

@ -87,13 +87,3 @@ def test_in_package_context_failed(database: SQLite, package_ahriman: Package, m
raise ValueError() raise ValueError()
reset_mock.assert_called_once_with() reset_mock.assert_called_once_with()
def test_suppress_logging(database: SQLite, mocker: MockerFixture) -> None:
"""
must temporary disable log messages
"""
disable_mock = mocker.patch("ahriman.core.log.lazy_logging.logging.disable")
with database.suppress_logging():
pass
disable_mock.assert_has_calls([MockCall(logging.WARNING), MockCall(logging.NOTSET)])

View File

@ -13,6 +13,7 @@ from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event from ahriman.models.event import Event
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record import LogRecord
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@ -112,6 +113,13 @@ def test_event_get(client: Client) -> None:
client.event_get(None, None) client.event_get(None, None)
def test_logs_rotate(client: Client) -> None:
"""
must do not raise exception on logs rotation call
"""
client.logs_rotate(1)
def test_package_changes_get(client: Client, package_ahriman: Package) -> None: def test_package_changes_get(client: Client, package_ahriman: Package) -> None:
""" """
must raise not implemented on package changes request must raise not implemented on package changes request
@ -157,7 +165,7 @@ def test_package_logs_add(client: Client, package_ahriman: Package, log_record:
must process log record addition without exception must process log record addition without exception
""" """
log_record_id = LogRecordId(package_ahriman.base, package_ahriman.version) log_record_id = LogRecordId(package_ahriman.base, package_ahriman.version)
client.package_logs_add(log_record_id, log_record.created, log_record.getMessage()) client.package_logs_add(LogRecord(log_record_id, log_record.created, log_record.getMessage()))
def test_package_logs_get(client: Client, package_ahriman: Package) -> None: def test_package_logs_get(client: Client, package_ahriman: Package) -> None:

View File

@ -8,6 +8,7 @@ from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType from ahriman.models.event import Event, EventType
from ahriman.models.log_record import LogRecord
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@ -34,6 +35,15 @@ def test_event_get(local_client: LocalClient, package_ahriman: Package, mocker:
local_client.repository_id) local_client.repository_id)
def test_logs_rotate(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must rotate logs
"""
rotate_mock = mocker.patch("ahriman.core.database.SQLite.logs_rotate")
local_client.logs_rotate(42)
rotate_mock.assert_called_once_with(42, local_client.repository_id)
def test_package_changes_get(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None: def test_package_changes_get(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must retrieve package changes must retrieve package changes
@ -103,10 +113,10 @@ def test_package_logs_add(local_client: LocalClient, package_ahriman: Package, l
""" """
logs_mock = mocker.patch("ahriman.core.database.SQLite.logs_insert") logs_mock = mocker.patch("ahriman.core.database.SQLite.logs_insert")
log_record_id = LogRecordId(package_ahriman.base, package_ahriman.version) log_record_id = LogRecordId(package_ahriman.base, package_ahriman.version)
record = LogRecord(log_record_id, log_record.created, log_record.getMessage())
local_client.package_logs_add(log_record_id, log_record.created, log_record.getMessage()) local_client.package_logs_add(record)
logs_mock.assert_called_once_with(log_record_id, log_record.created, log_record.getMessage(), logs_mock.assert_called_once_with(record, local_client.repository_id)
local_client.repository_id)
def test_package_logs_get(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None: def test_package_logs_get(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None:

View File

@ -5,7 +5,6 @@ from pytest_mock import MockerFixture
from ahriman.core.exceptions import UnknownPackageError from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
@ -64,38 +63,6 @@ def test_package_get_failed(watcher: Watcher, package_ahriman: Package) -> None:
watcher.package_get(package_ahriman.base) watcher.package_get(package_ahriman.base)
def test_package_logs_add_new(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must create package logs record for new package
"""
delete_mock = mocker.patch("ahriman.core.status.watcher.Watcher.package_logs_remove", create=True)
insert_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_logs_add")
log_record_id = LogRecordId(package_ahriman.base, watcher._last_log_record_id.version)
assert watcher._last_log_record_id != log_record_id
watcher.package_logs_add(log_record_id, 42.01, "log record")
delete_mock.assert_called_once_with(package_ahriman.base, log_record_id.version)
insert_mock.assert_called_once_with(log_record_id, 42.01, "log record")
assert watcher._last_log_record_id == log_record_id
def test_package_logs_add_update(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must create package logs record for current package
"""
delete_mock = mocker.patch("ahriman.core.status.watcher.Watcher.package_logs_remove", create=True)
insert_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_logs_add")
log_record_id = LogRecordId(package_ahriman.base, watcher._last_log_record_id.version)
watcher._last_log_record_id = log_record_id
watcher.package_logs_add(log_record_id, 42.01, "log record")
delete_mock.assert_not_called()
insert_mock.assert_called_once_with(log_record_id, 42.01, "log record")
def test_package_remove(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: def test_package_remove(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must remove package base must remove package base

View File

@ -12,6 +12,7 @@ from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType from ahriman.models.event import Event, EventType
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record import LogRecord
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@ -257,6 +258,57 @@ def test_event_get_failed_http_error_suppress(web_client: WebClient, mocker: Moc
logging_mock.assert_not_called() logging_mock.assert_not_called()
def test_logs_rotate(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must rotate logs
"""
requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request")
web_client.logs_rotate(42)
requests_mock.assert_called_once_with("DELETE", pytest.helpers.anyvar(str, True),
params=web_client.repository_id.query() + [("keep_last_records", "42")])
def test_logs_rotate_failed(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during logs rotation
"""
mocker.patch("requests.Session.request", side_effect=Exception())
web_client.logs_rotate(42)
def test_logs_rotate_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress HTTP exception happened during logs rotation
"""
mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
web_client.logs_rotate(42)
def test_logs_rotate_failed_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during logs rotation and don't log
"""
web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception())
logging_mock = mocker.patch("logging.exception")
web_client.logs_rotate(42)
logging_mock.assert_not_called()
def test_logs_rotate_failed_http_error_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress HTTP exception happened during logs rotation and don't log
"""
web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
logging_mock = mocker.patch("logging.exception")
web_client.logs_rotate(42)
logging_mock.assert_not_called()
def test_package_changes_get(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: def test_package_changes_get(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must get changes must get changes
@ -551,11 +603,13 @@ def test_package_logs_add(web_client: WebClient, log_record: logging.LogRecord,
payload = { payload = {
"created": log_record.created, "created": log_record.created,
"message": log_record.getMessage(), "message": log_record.getMessage(),
"process_id": LogRecordId.DEFAULT_PROCESS_ID,
"version": package_ahriman.version, "version": package_ahriman.version,
} }
record = LogRecord(LogRecordId(package_ahriman.base, package_ahriman.version),
log_record.created, log_record.getMessage())
web_client.package_logs_add(LogRecordId(package_ahriman.base, package_ahriman.version), web_client.package_logs_add(record)
log_record.created, log_record.getMessage())
requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True), requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True),
params=web_client.repository_id.query(), json=payload, suppress_errors=True) params=web_client.repository_id.query(), json=payload, suppress_errors=True)
@ -567,9 +621,11 @@ def test_package_logs_add_failed(web_client: WebClient, log_record: logging.LogR
""" """
mocker.patch("requests.Session.request", side_effect=Exception()) mocker.patch("requests.Session.request", side_effect=Exception())
log_record.package_base = package_ahriman.base log_record.package_base = package_ahriman.base
record = LogRecord(LogRecordId(package_ahriman.base, package_ahriman.version),
log_record.created, log_record.getMessage())
with pytest.raises(Exception): with pytest.raises(Exception):
web_client.package_logs_add(LogRecordId(package_ahriman.base, package_ahriman.version), web_client.package_logs_add(record)
log_record.created, log_record.getMessage())
def test_package_logs_add_failed_http_error(web_client: WebClient, log_record: logging.LogRecord, def test_package_logs_add_failed_http_error(web_client: WebClient, log_record: logging.LogRecord,
@ -579,16 +635,23 @@ def test_package_logs_add_failed_http_error(web_client: WebClient, log_record: l
""" """
mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
log_record.package_base = package_ahriman.base log_record.package_base = package_ahriman.base
record = LogRecord(LogRecordId(package_ahriman.base, package_ahriman.version),
log_record.created, log_record.getMessage())
with pytest.raises(Exception): with pytest.raises(Exception):
web_client.package_logs_add(LogRecordId(package_ahriman.base, package_ahriman.version), web_client.package_logs_add(record)
log_record.created, log_record.getMessage())
def test_package_logs_get(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: def test_package_logs_get(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must get logs must get logs
""" """
message = {"created": 42.0, "message": "log"} message = {
"created": 42.0,
"message": "log",
"version": package_ahriman.version,
"process_id": LogRecordId.DEFAULT_PROCESS_ID,
}
response_obj = requests.Response() response_obj = requests.Response()
response_obj._content = json.dumps([message]).encode("utf8") response_obj._content = json.dumps([message]).encode("utf8")
response_obj.status_code = 200 response_obj.status_code = 200
@ -598,7 +661,9 @@ def test_package_logs_get(web_client: WebClient, package_ahriman: Package, mocke
result = web_client.package_logs_get(package_ahriman.base, 1, 2) result = web_client.package_logs_get(package_ahriman.base, 1, 2)
requests_mock.assert_called_once_with("GET", pytest.helpers.anyvar(str, True), requests_mock.assert_called_once_with("GET", pytest.helpers.anyvar(str, True),
params=web_client.repository_id.query() + [("limit", "1"), ("offset", "2")]) params=web_client.repository_id.query() + [("limit", "1"), ("offset", "2")])
assert result == [(message["created"], message["message"])] assert result == [
LogRecord(LogRecordId(package_ahriman.base, package_ahriman.version), message["created"], message["message"]),
]
def test_package_logs_get_failed(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: def test_package_logs_get_failed(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:

View File

@ -0,0 +1,13 @@
from ahriman.models.log_record import LogRecord
from ahriman.models.log_record_id import LogRecordId
def test_log_record_from_json_view() -> None:
"""
must construct same object from json
"""
log_record = LogRecord(LogRecordId("base", "version"), 0, "message")
assert LogRecord.from_json(log_record.log_record_id.package_base, log_record.view()) == log_record
log_record = LogRecord(LogRecordId("base", "version", "process_id"), 0, "message")
assert LogRecord.from_json(log_record.log_record_id.package_base, log_record.view()) == log_record

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