Compare commits

...

6 Commits

Author SHA1 Message Date
68d1046dca refine package logging 2025-02-24 17:01:09 +02:00
08640d9108 feat: add dashboard (#139) 2025-02-24 00:10:15 +02:00
65324633b4 feat: add counters to repository stats overview 2025-02-24 00:10:15 +02:00
ed67898012 fix: parse non-utf pkgbuilds as well (#140)
it has been reported that duriing reading pkgbuilds with latin-1 charset
the execption will be raised. Well, it is one more point to rewrite
parser to use own impl instead of shlex and parse raw byte array instead
2025-02-24 00:10:15 +02:00
a1a8dd68e8 type: remove unused ignore directive 2025-02-24 00:10:15 +02:00
a9505386c2 fix: force dry run build on task initialization for VCS packages
Previously if package is VCS and version in PKGBUILD doesn't match to
AUR one, then makepkg will update pkgbuild ignoring all previous pkgrel
patches

With this change during task init dry ryn process is always run for vcs
packages
2025-02-24 00:10:15 +02:00
68 changed files with 1617 additions and 198 deletions

View File

@ -92,6 +92,14 @@ ahriman.core.formatters.repository\_printer module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.formatters.repository\_stats\_printer module
---------------------------------------------------------
.. automodule:: ahriman.core.formatters.repository_stats_printer
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.status\_printer module ahriman.core.formatters.status\_printer module
---------------------------------------------- ----------------------------------------------

View File

@ -236,6 +236,14 @@ ahriman.models.repository\_paths module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.models.repository\_stats module
---------------------------------------
.. automodule:: ahriman.models.repository_stats
:members:
:no-undoc-members:
:show-inheritance:
ahriman.models.result module ahriman.models.result module
---------------------------- ----------------------------
@ -252,6 +260,14 @@ ahriman.models.scan\_paths module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.models.series\_statistics module
----------------------------------------
.. automodule:: ahriman.models.series_statistics
:members:
:no-undoc-members:
:show-inheritance:
ahriman.models.sign\_settings module ahriman.models.sign\_settings module
------------------------------------ ------------------------------------

View File

@ -260,6 +260,14 @@ ahriman.web.schemas.repository\_id\_schema module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.schemas.repository\_stats\_schema module
----------------------------------------------------
.. automodule:: ahriman.web.schemas.repository_stats_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.search\_schema module ahriman.web.schemas.search\_schema 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

@ -36,7 +36,9 @@
<div class="container"> <div class="container">
<div id="toolbar" class="dropdown"> <div id="toolbar" class="dropdown">
<a id="badge-status" tabindex="0" role="button" class="btn btn-outline-secondary" data-bs-toggle="popover" data-bs-trigger="focus" data-bs-content="no run data"><i class="bi bi-info-circle"></i></a> <button id="dashboard-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#dashboard-modal">
<i class="bi bi-info-circle"></i>
</button>
{% if not auth.enabled or auth.username is not none %} {% if not auth.enabled or auth.username is not none %}
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"> <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
@ -152,6 +154,7 @@
{% include "build-status/alerts.jinja2" %} {% include "build-status/alerts.jinja2" %}
{% include "build-status/dashboard.jinja2" %}
{% include "build-status/package-add-modal.jinja2" %} {% include "build-status/package-add-modal.jinja2" %}
{% include "build-status/package-rebuild-modal.jinja2" %} {% include "build-status/package-rebuild-modal.jinja2" %}
{% include "build-status/key-import-modal.jinja2" %} {% include "build-status/key-import-modal.jinja2" %}

View File

@ -0,0 +1,76 @@
<div id="dashboard-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div id="dashboard-modal-header" class="modal-header">
<h4 class="modal-title">System health</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row mt-2">
<div class="col-4 col-lg-2" style="text-align: right">Repository name</div>
<div id="dashboard-name" class="col-8 col-lg-3"></div>
<div class="col-4 col-lg-2" style="text-align: right">Repository architecture</div>
<div id="dashboard-architecture" class="col-8 col-lg-3"></div>
</div>
<div class="form-group row mt-2">
<div class="col-4 col-lg-2" style="text-align: right">Current status</div>
<div id="dashboard-status" class="col-8 col-lg-3"></div>
<div class="col-4 col-lg-2" style="text-align: right">Updated at</div>
<div id="dashboard-status-timestamp" class="col-8 col-lg-3"></div>
</div>
<div id="dashboard-canvas" class="form-group row mt-2">
<div class="col-8 col-lg-6">
<canvas id="dashboard-packages-count-chart"></canvas>
</div>
<div class="col-8 col-lg-6">
<canvas id="dashboard-packages-statuses-chart"></canvas>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal"><i class="bi bi-x"></i><span class="d-none d-sm-inline"> close</span></button>
</div>
</div>
</div>
</div>
<script>
const dashboardModal = document.getElementById("dashboard-modal");
const dashboardModalHeader = document.getElementById("dashboard-modal-header");
const dashboardName = document.getElementById("dashboard-name");
const dashboardArchitecture = document.getElementById("dashboard-architecture");
const dashboardStatus = document.getElementById("dashboard-status");
const dashboardStatusTimestamp = document.getElementById("dashboard-status-timestamp");
const dashboardCanvas = document.getElementById("dashboard-canvas");
const dashboardPackagesStatusesChartCanvas = document.getElementById("dashboard-packages-statuses-chart");
let dashboardPackagesStatusesChart = null;
const dashboardPackagesCountChartCanvas = document.getElementById("dashboard-packages-count-chart");
let dashboardPackagesCountChart = null;
ready(_ => {
dashboardPackagesStatusesChart = new Chart(dashboardPackagesStatusesChartCanvas, {
type: "pie",
data: {},
options: {
responsive: true,
},
});
dashboardPackagesCountChart = new Chart(dashboardPackagesCountChartCanvas, {
type: "bar",
data: {},
options: {
maintainAspectRatio: false,
responsive: true,
scales: {
x: {
stacked: true,
},
},
},
});
});
</script>

View File

@ -296,14 +296,6 @@
} }
function loadPackage(packageBase, onFailure) { function loadPackage(packageBase, onFailure) {
const headerClass = status => {
if (status === "pending") return ["bg-warning"];
if (status === "building") return ["bg-warning"];
if (status === "failed") return ["bg-danger", "text-white"];
if (status === "success") return ["bg-success", "text-white"];
return ["bg-secondary", "text-white"];
};
makeRequest( makeRequest(
`/api/v1/packages/${packageBase}`, `/api/v1/packages/${packageBase}`,
{ {

View File

@ -7,7 +7,7 @@
// so far bootstrap-table only operates with jquery elements // so far bootstrap-table only operates with jquery elements
const table = $(document.getElementById("packages")); const table = $(document.getElementById("packages"));
const statusBadge = document.getElementById("badge-status"); const dashboardButton = document.getElementById("dashboard-button");
const versionBadge = document.getElementById("badge-version"); const versionBadge = document.getElementById("badge-version");
function doPackageAction(uri, packages, repository, successText, failureText, data) { function doPackageAction(uri, packages, repository, successText, failureText, data) {
@ -141,14 +141,62 @@
data => { data => {
versionBadge.innerHTML = `<i class="bi bi-github"></i> ahriman ${safe(data.version)}`; versionBadge.innerHTML = `<i class="bi bi-github"></i> ahriman ${safe(data.version)}`;
statusBadge.classList.remove(...statusBadge.classList); dashboardButton.classList.remove(...dashboardButton.classList);
statusBadge.classList.add("btn"); dashboardButton.classList.add("btn");
statusBadge.classList.add(badgeClass(data.status.status)); dashboardButton.classList.add(badgeClass(data.status.status));
const popover = bootstrap.Popover.getOrCreateInstance(statusBadge); dashboardModalHeader.classList.remove(...dashboardModalHeader.classList);
popover.dispose(); dashboardModalHeader.classList.add("modal-header");
statusBadge.dataset.bsContent = `${data.status.status} at ${new Date(1000 * data.status.timestamp).toISOStringShort()}`; headerClass(data.status.status).forEach(clz => dashboardModalHeader.classList.add(clz));
bootstrap.Popover.getOrCreateInstance(statusBadge);
dashboardName.textContent = data.repository;
dashboardArchitecture.textContent = data.architecture;
dashboardStatus.textContent = data.status.status;
dashboardStatusTimestamp.textContent = new Date(1000 * data.status.timestamp).toISOStringShort();
if (dashboardPackagesStatusesChart) {
const labels = [
"unknown",
"pending",
"building",
"failed",
"success",
];
dashboardPackagesStatusesChart.config.data = {
labels: labels,
datasets: [{
label: "packages in status",
data: labels.map(label => data.packages[label]),
backgroundColor: [
"rgb(55, 58, 60)",
"rgb(255, 117, 24)",
"rgb(255, 117, 24)",
"rgb(255, 0, 57)",
"rgb(63, 182, 24)", // copy-paste from current style
],
}],
};
dashboardPackagesStatusesChart.update();
}
if (dashboardPackagesCountChart) {
dashboardPackagesCountChart.config.data = {
labels: ["packages"],
datasets: [
{
label: "archives",
data: [data.stats.packages],
},
{
label: "bases",
data: [data.stats.bases],
},
],
};
dashboardPackagesCountChart.update();
}
dashboardCanvas.hidden = data.status.total > 0;
}, },
); );
} }
@ -227,7 +275,6 @@
}); });
}); });
bootstrap.Popover.getOrCreateInstance(statusBadge);
selectRepository(); selectRepository();
}); });
</script> </script>

View File

@ -58,6 +58,14 @@
return value.includes(dataList[index].toLowerCase()); return value.includes(dataList[index].toLowerCase());
} }
function headerClass(status) {
if (status === "pending") return ["bg-warning"];
if (status === "building") return ["bg-warning"];
if (status === "failed") return ["bg-danger", "text-white"];
if (status === "success") return ["bg-success", "text-white"];
return ["bg-secondary", "text-white"];
}
function listToTable(data) { function listToTable(data) {
return Array.from(new Set(data)) return Array.from(new Set(data))
.sort() .sort()

View File

@ -27,7 +27,7 @@ from pathlib import Path
from ahriman.application.application import Application from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.formatters import EventStatsPrinter, PackageStatsPrinter from ahriman.core.formatters import EventStatsPrinter, PackageStatsPrinter, RepositoryStatsPrinter
from ahriman.core.utils import enum_values, pretty_datetime from ahriman.core.utils import enum_values, pretty_datetime
from ahriman.models.event import Event, EventType from ahriman.models.event import Event, EventType
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -64,6 +64,7 @@ class Statistics(Handler):
match args.package: match args.package:
case None: case None:
RepositoryStatsPrinter(repository_id, application.reporter.statistics())(verbose=True)
Statistics.stats_per_package(args.event, events, args.chart) Statistics.stats_per_package(args.event, events, args.chart)
case _: case _:
Statistics.stats_for_package(args.event, events, args.chart) Statistics.stats_for_package(args.event, events, args.chart)

View File

@ -149,8 +149,11 @@ class Task(LazyLogging):
str | None: current commit sha if available str | None: current commit sha if available
""" """
last_commit_sha = Sources.load(sources_dir, self.package, patches, self.paths) last_commit_sha = Sources.load(sources_dir, self.package, patches, self.paths)
if local_version is None: if self.package.is_vcs: # if package is VCS, then make sure to update PKGBUILD to the latest version
return last_commit_sha # there is no local package or pkgrel increment is disabled self.build(sources_dir, dry_run=True)
if local_version is None: # there is no local package or pkgrel increment is disabled
return last_commit_sha
# load fresh package # load fresh package
loaded_package = Package.from_build(sources_dir, self.architecture, None) loaded_package = Package.from_build(sources_dir, self.architecture, None)

View File

@ -57,7 +57,7 @@ class ConfigurationMultiDict(dict[str, Any]):
OptionError: if the key already exists in the dictionary, but not a single value list or a string OptionError: if the key already exists in the dictionary, but not a single value list or a string
""" """
match self.get(key): match self.get(key):
case [current_value] | str(current_value): # type: ignore[misc] case [current_value] | str(current_value):
value = f"{current_value} {value}" value = f"{current_value} {value}"
case None: case None:
pass pass

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

@ -0,0 +1,27 @@
#
# 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 ''
""",
]

View File

@ -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[tuple[LogRecordId, float, str]]:
""" """
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[tuple[LogRecordId, float, str]]: sorted package log records and their timestamps
""" """
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[tuple[LogRecordId, float, str]]:
return [ return [
(row["created"], row["record"]) (LogRecordId(package_base, row["version"], row["process_id"]), row["created"], row["record"])
for row in connection.execute( for row in connection.execute(
""" """
select created, record from ( select created, record, 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
@ -83,9 +83,9 @@ class LogsOperations(Operations):
connection.execute( connection.execute(
""" """
insert into logs insert into logs
(package_base, version, created, record, repository) (package_base, version, created, record, repository, process_id)
values values
(:package_base, :version, :created, :record, :repository) (:package_base, :version, :created, :record, :repository, :process_id)
""", """,
{ {
"package_base": log_record_id.package_base, "package_base": log_record_id.package_base,
@ -93,6 +93,7 @@ class LogsOperations(Operations):
"created": created, "created": created,
"record": record, "record": record,
"repository": repository_id.id, "repository": repository_id.id,
"process_id": log_record_id.process_id,
} }
) )
@ -125,3 +126,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

@ -95,6 +95,19 @@ class DuplicateRunError(RuntimeError):
self, "Another application instance is run. This error can be suppressed by using --force flag.") self, "Another application instance is run. This error can be suppressed by using --force flag.")
class EncodeError(ValueError):
"""
exception used for bytes encoding errors
"""
def __init__(self, encodings: list[str]) -> None:
"""
Args:
encodings(list[str]): list of encodings tried
"""
ValueError.__init__(self, f"Could not encode bytes by using {encodings}")
class ExitCode(RuntimeError): class ExitCode(RuntimeError):
""" """
special exception which has to be thrown to return non-zero status without error message special exception which has to be thrown to return non-zero status without error message

View File

@ -28,6 +28,7 @@ from ahriman.core.formatters.package_stats_printer import PackageStatsPrinter
from ahriman.core.formatters.patch_printer import PatchPrinter from ahriman.core.formatters.patch_printer import PatchPrinter
from ahriman.core.formatters.printer import Printer from ahriman.core.formatters.printer import Printer
from ahriman.core.formatters.repository_printer import RepositoryPrinter from ahriman.core.formatters.repository_printer import RepositoryPrinter
from ahriman.core.formatters.repository_stats_printer import RepositoryStatsPrinter
from ahriman.core.formatters.status_printer import StatusPrinter from ahriman.core.formatters.status_printer import StatusPrinter
from ahriman.core.formatters.string_printer import StringPrinter from ahriman.core.formatters.string_printer import StringPrinter
from ahriman.core.formatters.tree_printer import TreePrinter from ahriman.core.formatters.tree_printer import TreePrinter

View File

@ -17,11 +17,9 @@
# 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 statistics
from ahriman.core.formatters.string_printer import StringPrinter from ahriman.core.formatters.string_printer import StringPrinter
from ahriman.core.utils import minmax
from ahriman.models.property import Property from ahriman.models.property import Property
from ahriman.models.series_statistics import SeriesStatistics
class EventStatsPrinter(StringPrinter): class EventStatsPrinter(StringPrinter):
@ -29,7 +27,7 @@ class EventStatsPrinter(StringPrinter):
print event statistics print event statistics
Attributes: Attributes:
events(list[float | int]): event values to build statistics statistics(SeriesStatistics): statistics object
""" """
def __init__(self, event_type: str, events: list[float | int]) -> None: def __init__(self, event_type: str, events: list[float | int]) -> None:
@ -39,7 +37,7 @@ class EventStatsPrinter(StringPrinter):
events(list[float | int]): event values to build statistics events(list[float | int]): event values to build statistics
""" """
StringPrinter.__init__(self, event_type) StringPrinter.__init__(self, event_type)
self.events = events self.statistics = SeriesStatistics(events)
def properties(self) -> list[Property]: def properties(self) -> list[Property]:
""" """
@ -49,24 +47,17 @@ class EventStatsPrinter(StringPrinter):
list[Property]: list of content properties list[Property]: list of content properties
""" """
properties = [ properties = [
Property("total", len(self.events)), Property("total", self.statistics.total),
] ]
# time statistics # time statistics
if self.events: if self.statistics:
min_time, max_time = minmax(self.events) mean = self.statistics.mean
mean = statistics.mean(self.events)
if len(self.events) > 1:
st_dev = statistics.stdev(self.events)
average = f"{mean:.3f} ± {st_dev:.3f}"
else:
average = f"{mean:.3f}"
properties.extend([ properties.extend([
Property("min", min_time), Property("min", self.statistics.min),
Property("average", average), Property("average", f"{mean:.3f} ± {self.statistics.st_dev:.3f}"),
Property("max", max_time), Property("max", self.statistics.max),
]) ])
return properties return properties

View File

@ -0,0 +1,53 @@
#
# 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 ahriman.core.formatters.string_printer import StringPrinter
from ahriman.core.utils import pretty_size
from ahriman.models.property import Property
from ahriman.models.repository_id import RepositoryId
from ahriman.models.repository_stats import RepositoryStats
class RepositoryStatsPrinter(StringPrinter):
"""
print repository statistics
Attributes:
statistics(RepositoryStats): repository statistics
"""
def __init__(self, repository_id: RepositoryId, statistics: RepositoryStats) -> None:
"""
Args:
statistics(RepositoryStats): repository statistics
"""
StringPrinter.__init__(self, str(repository_id))
self.statistics = statistics
def properties(self) -> list[Property]:
"""
convert content into printable data
Returns:
list[Property]: list of content properties
"""
return [
Property("Packages", self.statistics.bases),
Property("Repository size", pretty_size(self.statistics.archive_size)),
]

View File

@ -17,6 +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/>.
# #
import atexit
import logging import logging
from typing import Self from typing import Self
@ -33,6 +34,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 +53,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 +79,8 @@ 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)
atexit.register(handler.rotate)
return handler return handler
def emit(self, record: logging.LogRecord) -> None: def emit(self, record: logging.LogRecord) -> None:
@ -95,3 +100,9 @@ class HttpLogHandler(logging.Handler):
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

@ -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

@ -75,7 +75,7 @@ class Executor(PackageInfo, Cleaner):
result = Result() result = Result()
for single in updates: for single in updates:
with self.in_package_context(single.base, local_versions.get(single.base)), \ with self.in_package_context(single.base, single.version), \
TemporaryDirectory(ignore_cleanup_errors=True) as dir_name: TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
try: try:
with self.in_event(single.base, EventType.PackageUpdated, failure=EventType.PackageUpdateFailed): with self.in_event(single.base, EventType.PackageUpdated, failure=EventType.PackageUpdateFailed):
@ -194,7 +194,6 @@ class Executor(PackageInfo, Cleaner):
self.repo.add(package_path) self.repo.add(package_path)
current_packages = {package.base: package for package in self.packages()} current_packages = {package.base: package for package in self.packages()}
local_versions = {package_base: package.version for package_base, package in current_packages.items()}
removed_packages: list[str] = [] # list of packages which have been removed from the base removed_packages: list[str] = [] # list of packages which have been removed from the base
updates = self.load_archives(packages) updates = self.load_archives(packages)
@ -202,7 +201,7 @@ class Executor(PackageInfo, Cleaner):
result = Result() result = Result()
for local in updates: for local in updates:
with self.in_package_context(local.base, local_versions.get(local.base)): with self.in_package_context(local.base, local.version):
try: try:
packager = self.packager(packagers, local.base) packager = self.packager(packagers, local.base)

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

@ -31,6 +31,7 @@ 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
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
from ahriman.models.repository_stats import RepositoryStats
class Client: class Client:
@ -114,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
@ -196,7 +205,8 @@ class Client:
""" """
# 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[tuple[LogRecordId, float, str]]:
""" """
get package logs get package logs
@ -206,7 +216,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[tuple[LogRecordId, float, str]]: package logs
Raises: Raises:
NotImplementedError: not implemented method NotImplementedError: not implemented method
@ -354,6 +364,16 @@ class Client:
return # skip update in case if package is already known return # skip update in case if package is already known
self.package_update(package, BuildStatusEnum.Unknown) self.package_update(package, BuildStatusEnum.Unknown)
def statistics(self) -> RepositoryStats:
"""
get repository statistics
Returns:
RepositoryStats: repository statistics object
"""
packages = [package for package, _ in self.package_get(None)]
return RepositoryStats.from_packages(packages)
def status_get(self) -> InternalStatus: def status_get(self) -> InternalStatus:
""" """
get internal service status get internal service status

View File

@ -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
@ -145,7 +154,8 @@ class LocalClient(Client):
""" """
self.database.logs_insert(log_record_id, created, message, self.repository_id) self.database.logs_insert(log_record_id, created, message, 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[tuple[LogRecordId, float, str]]:
""" """
get package logs get package logs
@ -155,7 +165,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[tuple[LogRecordId, float, str]]: 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

@ -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[[LogRecordId, float, str], None]
"""
make new log record into database
Args: package_logs_get: Callable[[str, int, int], list[tuple[LogRecordId, float, str]]]
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

@ -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
@ -306,6 +318,7 @@ class WebClient(Client, SyncAhrimanClient):
payload = { payload = {
"created": created, "created": created,
"message": message, "message": message,
"process_id": log_record_id.process_id,
"version": log_record_id.version, "version": log_record_id.version,
} }
@ -315,7 +328,8 @@ class WebClient(Client, SyncAhrimanClient):
self.make_request("POST", self._logs_url(log_record_id.package_base), self.make_request("POST", self._logs_url(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[tuple[LogRecordId, float, str]]:
""" """
get package logs get package logs
@ -325,7 +339,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[tuple[LogRecordId, float, str]]: 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 +347,13 @@ 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 [
(
LogRecordId(package_base, record["version"], record["process_id"]),
record["created"],
record["message"]
) for record in response_json
]
return [] return []

View File

@ -23,6 +23,7 @@ from typing import Any, Self
from ahriman.core.utils import dataclass_view from ahriman.core.utils import dataclass_view
from ahriman.models.build_status import BuildStatus from ahriman.models.build_status import BuildStatus
from ahriman.models.counters import Counters from ahriman.models.counters import Counters
from ahriman.models.repository_stats import RepositoryStats
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@ -35,6 +36,7 @@ class InternalStatus:
architecture(str | None): repository architecture architecture(str | None): repository architecture
packages(Counters): packages statuses counter object packages(Counters): packages statuses counter object
repository(str | None): repository name repository(str | None): repository name
stats(RepositoryStats | None): repository stats
version(str | None): service version version(str | None): service version
""" """
@ -42,6 +44,7 @@ class InternalStatus:
architecture: str | None = None architecture: str | None = None
packages: Counters = field(default=Counters(total=0)) packages: Counters = field(default=Counters(total=0))
repository: str | None = None repository: str | None = None
stats: RepositoryStats | None = None
version: str | None = None version: str | None = None
@classmethod @classmethod
@ -56,11 +59,13 @@ class InternalStatus:
Self: internal status Self: internal status
""" """
counters = Counters.from_json(dump["packages"]) if "packages" in dump else Counters(total=0) counters = Counters.from_json(dump["packages"]) if "packages" in dump else Counters(total=0)
stats = RepositoryStats.from_json(dump["stats"]) if "stats" in dump else None
build_status = dump.get("status") or {} build_status = dump.get("status") or {}
return cls(status=BuildStatus.from_json(build_status), return cls(status=BuildStatus.from_json(build_status),
architecture=dump.get("architecture"), architecture=dump.get("architecture"),
packages=counters, packages=counters,
repository=dump.get("repository"), repository=dump.get("repository"),
stats=stats,
version=dump.get("version")) version=dump.get("version"))
def view(self) -> dict[str, Any]: def view(self) -> dict[str, Any]:

View File

@ -17,7 +17,9 @@
# 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 dataclasses import dataclass import uuid
from dataclasses import dataclass, field
@dataclass(frozen=True) @dataclass(frozen=True)
@ -28,7 +30,12 @@ class LogRecordId:
Attributes: Attributes:
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
# this is not mistake, this value is kind of global identifier, which is generated
# upon the process start
process_id: str = field(default=str(uuid.uuid4()))

View File

@ -429,14 +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")
task.build(paths.cache_for(self.base), dry_run=True)
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

@ -24,6 +24,7 @@ from pathlib import Path
from typing import Any, IO, Self from typing import Any, 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.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@ -33,11 +34,14 @@ class Pkgbuild(Mapping[str, Any]):
model and proxy for PKGBUILD properties model and proxy for PKGBUILD properties
Attributes: Attributes:
DEFAULT_ENCODINGS(list[str]): (class attribute) list of encoding to be applied on the file content
fields(dict[str, PkgbuildPatch]): PKGBUILD fields fields(dict[str, PkgbuildPatch]): PKGBUILD fields
""" """
fields: dict[str, PkgbuildPatch] fields: dict[str, PkgbuildPatch]
DEFAULT_ENCODINGS = ["utf8", "latin-1"]
@property @property
def variables(self) -> dict[str, str]: def variables(self) -> dict[str, str]:
""" """
@ -54,18 +58,34 @@ class Pkgbuild(Mapping[str, Any]):
} }
@classmethod @classmethod
def from_file(cls, path: Path) -> Self: def from_file(cls, path: Path, encodings: list[str] | None = None) -> Self:
""" """
parse PKGBUILD from the file parse PKGBUILD from the file
Args: Args:
path(Path): path to the PKGBUILD file path(Path): path to the PKGBUILD file
encodings(list[str] | None, optional): the encoding of the file (Default value = None)
Returns: Returns:
Self: constructed instance of self Self: constructed instance of self
Raises:
EncodeError: if encoding is unknown
""" """
with path.open(encoding="utf8") as input_file: # read raw bytes from file
return cls.from_io(input_file) with path.open("rb") as input_file:
content = input_file.read()
# decode bytes content based on either
encodings = encodings or cls.DEFAULT_ENCODINGS
for encoding in encodings:
try:
io = StringIO(content.decode(encoding))
return cls.from_io(io)
except ValueError:
pass
raise EncodeError(encodings)
@classmethod @classmethod
def from_io(cls, stream: IO[str]) -> Self: def from_io(cls, stream: IO[str]) -> Self:

View File

@ -97,3 +97,12 @@ class RepositoryId:
raise ValueError(f"'<' not supported between instances of '{type(self)}' and '{type(other)}'") raise ValueError(f"'<' not supported between instances of '{type(self)}' and '{type(other)}'")
return (self.name, self.architecture) < (other.name, other.architecture) return (self.name, self.architecture) < (other.name, other.architecture)
def __str__(self) -> str:
"""
string representation of the repository identifier
Returns:
str: string view of the repository identifier
"""
return f"{self.name} ({self.architecture})"

View File

@ -0,0 +1,77 @@
#
# 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, fields
from typing import Any, Self
from ahriman.core.utils import filter_json
from ahriman.models.package import Package
@dataclass(frozen=True, kw_only=True)
class RepositoryStats:
"""
repository stats representation
"""
bases: int
packages: int
archive_size: int
installed_size: int
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""
construct counters from json dump
Args:
dump(dict[str, Any]): json dump body
Returns:
Self: status counters
"""
# filter to only known fields
known_fields = [pair.name for pair in fields(cls)]
return cls(**filter_json(dump, known_fields))
@classmethod
def from_packages(cls, packages: list[Package]) -> Self:
"""
construct statistics from list of repository packages
Args:
packages(list[Packages]): list of repository packages
Returns:
Self: constructed statistics object
"""
return cls(
bases=len(packages),
packages=sum(len(package.packages) for package in packages),
archive_size=sum(
archive.archive_size or 0
for package in packages
for archive in package.packages.values()
),
installed_size=sum(
archive.installed_size or 0
for package in packages
for archive in package.packages.values()
),
)

View File

@ -0,0 +1,104 @@
#
# 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/>.
#
import statistics
from dataclasses import dataclass
@dataclass(frozen=True)
class SeriesStatistics:
"""
series statistics helper
Attributes:
series(list[float | int]): list of values to be processed
"""
series: list[float | int]
@property
def max(self) -> float | int | None:
"""
get max value in series
Returns:
float | int | None: ``None`` if series is empty and maximal value otherwise``
"""
if self:
return max(self.series)
return None
@property
def mean(self) -> float | int | None:
"""
get mean value in series
Returns:
float | int | None: ``None`` if series is empty and mean value otherwise
"""
if self:
return statistics.mean(self.series)
return None
@property
def min(self) -> float | int | None:
"""
get min value in series
Returns:
float | int | None: ``None`` if series is empty and minimal value otherwise
"""
if self:
return min(self.series)
return None
@property
def st_dev(self) -> float | None:
"""
get standard deviation in series
Returns:
float | None: ``None`` if series size is less than 1, 0 if series contains single element and standard
deviation otherwise
"""
if not self:
return None
if len(self.series) > 1:
return statistics.stdev(self.series)
return 0.0
@property
def total(self) -> int:
"""
retrieve amount of elements
Returns:
int: the series collection size
"""
return len(self.series)
def __bool__(self) -> bool:
"""
check if series is empty or not
Returns:
bool: ``True`` if series contains elements and ``False`` otherwise
"""
return bool(self.total)

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
@ -49,8 +50,8 @@ from ahriman.web.schemas.process_id_schema import ProcessIdSchema
from ahriman.web.schemas.process_schema import ProcessSchema from ahriman.web.schemas.process_schema import ProcessSchema
from ahriman.web.schemas.remote_schema import RemoteSchema from ahriman.web.schemas.remote_schema import RemoteSchema
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
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

@ -25,6 +25,6 @@ class FileSchema(Schema):
request file upload schema request file upload schema
""" """
archive = fields.Field(required=True, metadata={ archive = fields.Raw(required=True, metadata={
"description": "Package archive to be uploaded", "description": "Package archive to be uploaded",
}) })

View File

@ -21,6 +21,7 @@ from ahriman import __version__
from ahriman.web.apispec import fields from ahriman.web.apispec import fields
from ahriman.web.schemas.counters_schema import CountersSchema from ahriman.web.schemas.counters_schema import CountersSchema
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
from ahriman.web.schemas.repository_stats_schema import RepositoryStatsSchema
from ahriman.web.schemas.status_schema import StatusSchema from ahriman.web.schemas.status_schema import StatusSchema
@ -32,6 +33,9 @@ class InternalStatusSchema(RepositoryIdSchema):
packages = fields.Nested(CountersSchema(), required=True, metadata={ packages = fields.Nested(CountersSchema(), required=True, metadata={
"description": "Repository package counters", "description": "Repository package counters",
}) })
stats = fields.Nested(RepositoryStatsSchema(), required=True, metadata={
"description": "Repository stats",
})
status = fields.Nested(StatusSchema(), required=True, metadata={ status = fields.Nested(StatusSchema(), required=True, metadata={
"description": "Repository status as stored by web service", "description": "Repository status as stored by web service",
}) })

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

@ -0,0 +1,43 @@
#
# 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 ahriman.web.apispec import Schema, fields
class RepositoryStatsSchema(Schema):
"""
response repository stats schema
"""
bases = fields.Int(metadata={
"description": "Amount of unique packages bases",
"example": 2,
})
packages = fields.Int(metadata={
"description": "Amount of unique packages",
"example": 4,
})
archive_size = fields.Int(metadata={
"description": "Total archive size of the packages in bytes",
"example": 42000,
})
installed_size = fields.Int(metadata={
"description": "Total installed size of the packages in bytes",
"example": 42000000,
})

View File

@ -24,8 +24,7 @@ from ahriman.core.utils import pretty_datetime
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
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
@ -97,7 +96,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(created)}] {message}" for _, created, message in logs)
} }
return json_response(response) return json_response(response)
@ -109,7 +108,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:
""" """
@ -129,6 +128,8 @@ class LogsView(StatusViewGuard, BaseView):
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) # either read from process identifier from payload or assign to the current process identifier
process_id = data.get("process_id", LogRecordId("", "").process_id)
self.service().package_logs_add(LogRecordId(package_base, version, process_id), created, record)
raise HTTPNoContent raise HTTPNoContent

View File

@ -0,0 +1,63 @@
#
# 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 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 = 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

@ -23,6 +23,7 @@ from ahriman import __version__
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.counters import Counters from ahriman.models.counters import Counters
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.repository_stats import RepositoryStats
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 InternalStatusSchema, RepositoryIdSchema, StatusSchema from ahriman.web.schemas import InternalStatusSchema, RepositoryIdSchema, StatusSchema
@ -60,12 +61,16 @@ class StatusView(StatusViewGuard, BaseView):
Response: 200 with service status object Response: 200 with service status object
""" """
repository_id = self.repository_id() repository_id = self.repository_id()
counters = Counters.from_packages(self.service(repository_id).packages) packages = self.service(repository_id).packages
counters = Counters.from_packages(packages)
stats = RepositoryStats.from_packages([package for package, _ in packages])
status = InternalStatus( status = InternalStatus(
status=self.service(repository_id).status, status=self.service(repository_id).status,
architecture=repository_id.architecture, architecture=repository_id.architecture,
packages=counters, packages=counters,
repository=repository_id.name, repository=repository_id.name,
stats=stats,
version=__version__, version=__version__,
) )

View File

@ -67,6 +67,8 @@ class LogsView(StatusViewGuard, BaseView):
{ {
"created": created, "created": created,
"message": message, "message": message,
} for created, message in logs "version": log_record_id.version,
"process_id": log_record_id.process_id,
} for log_record_id, created, message in logs
] ]
return json_response(response) return json_response(response)

View File

@ -11,6 +11,7 @@ from ahriman.core.repository import Repository
from ahriman.core.utils import pretty_datetime, utcnow from ahriman.core.utils import pretty_datetime, utcnow
from ahriman.models.event import Event, EventType from ahriman.models.event import Event, EventType
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.repository_stats import RepositoryStats
def _default_args(args: argparse.Namespace) -> argparse.Namespace: def _default_args(args: argparse.Namespace) -> argparse.Namespace:
@ -40,13 +41,16 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository:
""" """
args = _default_args(args) args = _default_args(args)
events = [Event("1", "1"), Event("2", "2")] events = [Event("1", "1"), Event("2", "2")]
stats = RepositoryStats(bases=1, packages=2, archive_size=3, installed_size=4)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
events_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.event_get", return_value=events) events_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.event_get", return_value=events)
stats_mock = mocker.patch("ahriman.core.status.client.Client.statistics", return_value=stats)
application_mock = mocker.patch("ahriman.application.handlers.statistics.Statistics.stats_per_package") application_mock = mocker.patch("ahriman.application.handlers.statistics.Statistics.stats_per_package")
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
Statistics.run(args, repository_id, configuration, report=False) Statistics.run(args, repository_id, configuration, report=False)
events_mock.assert_called_once_with(args.event, args.package, None, None, args.limit, args.offset) events_mock.assert_called_once_with(args.event, args.package, None, None, args.limit, args.offset)
stats_mock.assert_called_once_with()
application_mock.assert_called_once_with(args.event, events, args.chart) application_mock.assert_called_once_with(args.event, events, args.chart)

View File

@ -117,6 +117,21 @@ def test_init(task_ahriman: Task, mocker: MockerFixture) -> None:
load_mock.assert_called_once_with(Path("ahriman"), task_ahriman.package, patches, task_ahriman.paths) load_mock.assert_called_once_with(Path("ahriman"), task_ahriman.package, patches, task_ahriman.paths)
def test_init_vcs(task_ahriman: Task, mocker: MockerFixture) -> None:
"""
must copy tree instead of fetch
"""
task_ahriman.package.base += "-git"
mocker.patch("ahriman.models.package.Package.from_build", return_value=task_ahriman.package)
load_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.load", return_value="sha")
build_mock = mocker.patch("ahriman.core.build_tools.task.Task.build")
local = Path("ahriman")
assert task_ahriman.init(local, [], None) == "sha"
load_mock.assert_called_once_with(local, task_ahriman.package, [], task_ahriman.paths)
build_mock.assert_called_once_with(local, dry_run=True)
def test_init_bump_pkgrel(task_ahriman: Task, mocker: MockerFixture) -> None: def test_init_bump_pkgrel(task_ahriman: Task, mocker: MockerFixture) -> None:
""" """
must bump pkgrel if it is same as provided must bump pkgrel if it is same as provided

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

@ -14,8 +14,12 @@ def test_logs_insert_remove_version(database: SQLite, package_ahriman: Package,
database.logs_insert(LogRecordId(package_python_schedule.base, "1"), 42.0, "message 3") database.logs_insert(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")] (LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"),
]
assert database.logs_get(package_python_schedule.base) == [
(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:
@ -28,7 +32,7 @@ def test_logs_insert_remove_multi(database: SQLite, package_ahriman: Package) ->
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) == [(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:
@ -41,7 +45,9 @@ def test_logs_insert_remove_full(database: SQLite, package_ahriman: Package, pac
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) == [
(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:
@ -50,7 +56,10 @@ def test_logs_insert_get(database: SQLite, package_ahriman: Package) -> None:
""" """
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2") database.logs_insert(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2")
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1") database.logs_insert(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) == [
(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1"),
(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:
@ -59,7 +68,9 @@ def test_logs_insert_get_pagination(database: SQLite, package_ahriman: Package)
""" """
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1") database.logs_insert(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1")
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2") database.logs_insert(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) == [
(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:
@ -71,5 +82,40 @@ def test_logs_insert_get_multi(database: SQLite, package_ahriman: Package) -> No
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")] (LogRecordId(package_ahriman.base, "1"), 43.0, "message 2"),
]
assert database.logs_get(package_ahriman.base) == [
(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(LogRecordId(package_ahriman.base, "1"), 42.0, "message 1")
database.logs_insert(LogRecordId(package_ahriman.base, "1"), 43.0, "message 2")
database.logs_insert(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(LogRecordId(package_ahriman.base, "1", "p1"), 42.0, "message 1")
database.logs_insert(LogRecordId(package_ahriman.base, "1", "p2"), 43.0, "message 2")
database.logs_insert(LogRecordId(package_ahriman.base, "1", "p3"), 44.0, "message 3")
database.logs_insert(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 == [
(LogRecordId(package_ahriman.base, "1", "p3"), 44.0, "message 3"),
(LogRecordId(package_ahriman.base, "2", "p1"), 45.0, "message 4"),
]

View File

@ -12,6 +12,7 @@ from ahriman.core.formatters import \
PackageStatsPrinter, \ PackageStatsPrinter, \
PatchPrinter, \ PatchPrinter, \
RepositoryPrinter, \ RepositoryPrinter, \
RepositoryStatsPrinter, \
StatusPrinter, \ StatusPrinter, \
StringPrinter, \ StringPrinter, \
TreePrinter, \ TreePrinter, \
@ -25,6 +26,7 @@ from ahriman.models.changes import Changes
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
from ahriman.models.repository_stats import RepositoryStats
from ahriman.models.user import User from ahriman.models.user import User
@ -134,12 +136,29 @@ def repository_printer(repository_id: RepositoryId) -> RepositoryPrinter:
""" """
fixture for repository printer fixture for repository printer
Args:
repository_id(RepositoryId): repository identifier fixture
Returns: Returns:
RepositoryPrinter: repository printer test instance RepositoryPrinter: repository printer test instance
""" """
return RepositoryPrinter(repository_id) return RepositoryPrinter(repository_id)
@pytest.fixture
def repository_stats_printer(repository_id: RepositoryId) -> RepositoryStatsPrinter:
"""
fixture for repository stats printer
Args:
repository_id(RepositoryId): repository identifier fixture
Returns:
RepositoryStatsPrinter: repository stats printer test instance
"""
return RepositoryStatsPrinter(repository_id, RepositoryStats(bases=1, packages=2, archive_size=3, installed_size=4))
@pytest.fixture @pytest.fixture
def status_printer() -> StatusPrinter: def status_printer() -> StatusPrinter:
""" """

View File

@ -15,13 +15,6 @@ def test_properties_empty() -> None:
assert EventStatsPrinter("event", []).properties() assert EventStatsPrinter("event", []).properties()
def test_properties_single() -> None:
"""
must skip calculation of the standard deviation for single event
"""
assert EventStatsPrinter("event", [1]).properties()
def test_title(event_stats_printer: EventStatsPrinter) -> None: def test_title(event_stats_printer: EventStatsPrinter) -> None:
""" """
must return non-empty title must return non-empty title

View File

@ -0,0 +1,15 @@
from ahriman.core.formatters import RepositoryStatsPrinter
def test_properties(repository_stats_printer: RepositoryStatsPrinter) -> None:
"""
must return non-empty properties list
"""
assert repository_stats_printer.properties()
def test_title(repository_stats_printer: RepositoryStatsPrinter) -> None:
"""
must return non-empty title
"""
assert repository_stats_printer.title()

View File

@ -19,12 +19,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:
@ -93,3 +95,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

@ -16,6 +16,7 @@ from ahriman.models.internal_status import InternalStatus
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
from ahriman.models.repository_stats import RepositoryStats
def test_load_dummy_client(configuration: Configuration) -> None: def test_load_dummy_client(configuration: Configuration) -> None:
@ -111,6 +112,13 @@ def test_event_get(client: Client) -> None:
client.event_get(None, None) client.event_get(None, None)
def test_logs_rotate(client: Client, package_ahriman: Package) -> 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
@ -285,6 +293,14 @@ def test_set_unknown_skip(client: Client, package_ahriman: Package, mocker: Mock
update_mock.assert_not_called() update_mock.assert_not_called()
def test_statistics(client: Client, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must correctly fetch statistics
"""
mocker.patch("ahriman.core.status.Client.package_get", return_value=[(package_ahriman, None)])
assert client.statistics() == RepositoryStats(bases=1, packages=1, archive_size=4200, installed_size=4200000)
def test_status_get(client: Client) -> None: def test_status_get(client: Client) -> None:
""" """
must return dummy status for web service must return dummy status for web service

View File

@ -34,6 +34,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

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

@ -257,6 +257,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,6 +602,7 @@ 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.process_id,
"version": package_ahriman.version, "version": package_ahriman.version,
} }
@ -588,7 +640,12 @@ def test_package_logs_get(web_client: WebClient, package_ahriman: Package, mocke
""" """
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.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 +655,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 == [
(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

@ -471,6 +471,7 @@ def test_walk(resource_path_root: Path) -> None:
resource_path_root / "models" / "package_ahriman_pkgbuild", resource_path_root / "models" / "package_ahriman_pkgbuild",
resource_path_root / "models" / "package_gcc10_pkgbuild", resource_path_root / "models" / "package_gcc10_pkgbuild",
resource_path_root / "models" / "package_jellyfin-ffmpeg6-bin_pkgbuild", resource_path_root / "models" / "package_jellyfin-ffmpeg6-bin_pkgbuild",
resource_path_root / "models" / "package_pacman-static_pkgbuild",
resource_path_root / "models" / "package_python-pytest-loop_pkgbuild", resource_path_root / "models" / "package_python-pytest-loop_pkgbuild",
resource_path_root / "models" / "package_tpacpi-bat-git_pkgbuild", resource_path_root / "models" / "package_tpacpi-bat-git_pkgbuild",
resource_path_root / "models" / "package_vim-youcompleteme-git_pkgbuild", resource_path_root / "models" / "package_vim-youcompleteme-git_pkgbuild",
@ -478,6 +479,7 @@ def test_walk(resource_path_root: Path) -> None:
resource_path_root / "models" / "pkgbuild", resource_path_root / "models" / "pkgbuild",
resource_path_root / "models" / "utf8", resource_path_root / "models" / "utf8",
resource_path_root / "web" / "templates" / "build-status" / "alerts.jinja2", resource_path_root / "web" / "templates" / "build-status" / "alerts.jinja2",
resource_path_root / "web" / "templates" / "build-status" / "dashboard.jinja2",
resource_path_root / "web" / "templates" / "build-status" / "key-import-modal.jinja2", resource_path_root / "web" / "templates" / "build-status" / "key-import-modal.jinja2",
resource_path_root / "web" / "templates" / "build-status" / "login-modal.jinja2", resource_path_root / "web" / "templates" / "build-status" / "login-modal.jinja2",
resource_path_root / "web" / "templates" / "build-status" / "package-add-modal.jinja2", resource_path_root / "web" / "templates" / "build-status" / "package-add-modal.jinja2",

View File

@ -14,6 +14,7 @@ from ahriman.models.package_description import PackageDescription
from ahriman.models.package_source import PackageSource from ahriman.models.package_source import PackageSource
from ahriman.models.pkgbuild import Pkgbuild from ahriman.models.pkgbuild import Pkgbuild
from ahriman.models.remote_source import RemoteSource from ahriman.models.remote_source import RemoteSource
from ahriman.models.repository_stats import RepositoryStats
@pytest.fixture @pytest.fixture
@ -71,8 +72,9 @@ def internal_status(counters: Counters) -> InternalStatus:
status=BuildStatus(), status=BuildStatus(),
architecture="x86_64", architecture="x86_64",
packages=counters, packages=counters,
version=__version__,
repository="aur", repository="aur",
stats=RepositoryStats(bases=1, packages=2, archive_size=3, installed_size=4),
version=__version__,
) )

View File

@ -359,13 +359,10 @@ def test_actual_version_vcs(package_tpacpi_bat_git: Package, configuration: Conf
mocker.patch("ahriman.models.pkgbuild.Pkgbuild.from_file", return_value=Pkgbuild.from_file(pkgbuild)) mocker.patch("ahriman.models.pkgbuild.Pkgbuild.from_file", return_value=Pkgbuild.from_file(pkgbuild))
mocker.patch("pathlib.Path.glob", return_value=[Path("local")]) mocker.patch("pathlib.Path.glob", return_value=[Path("local")])
init_mock = mocker.patch("ahriman.core.build_tools.task.Task.init") init_mock = mocker.patch("ahriman.core.build_tools.task.Task.init")
build_mock = mocker.patch("ahriman.core.build_tools.task.Task.build")
unlink_mock = mocker.patch("pathlib.Path.unlink") unlink_mock = mocker.patch("pathlib.Path.unlink")
assert package_tpacpi_bat_git.actual_version(configuration) == "3.1.r13.g4959b52-1" assert package_tpacpi_bat_git.actual_version(configuration) == "3.1.r13.g4959b52-1"
init_mock.assert_called_once_with(configuration.repository_paths.cache_for(package_tpacpi_bat_git.base), [], None) init_mock.assert_called_once_with(configuration.repository_paths.cache_for(package_tpacpi_bat_git.base), [], None)
build_mock.assert_called_once_with(configuration.repository_paths.cache_for(package_tpacpi_bat_git.base),
dry_run=True)
unlink_mock.assert_called_once_with() unlink_mock.assert_called_once_with()

View File

@ -1,9 +1,11 @@
import pytest import pytest
from io import StringIO from io import BytesIO, StringIO
from pathlib import Path from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest.mock import MagicMock
from ahriman.core.exceptions import EncodeError
from ahriman.models.pkgbuild import Pkgbuild from ahriman.models.pkgbuild import Pkgbuild
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@ -23,13 +25,39 @@ def test_from_file(pkgbuild_ahriman: Pkgbuild, mocker: MockerFixture) -> None:
must correctly load from file must correctly load from file
""" """
open_mock = mocker.patch("pathlib.Path.open") open_mock = mocker.patch("pathlib.Path.open")
open_mock.return_value.__enter__.return_value = BytesIO(b"content")
load_mock = mocker.patch("ahriman.models.pkgbuild.Pkgbuild.from_io", return_value=pkgbuild_ahriman) load_mock = mocker.patch("ahriman.models.pkgbuild.Pkgbuild.from_io", return_value=pkgbuild_ahriman)
assert Pkgbuild.from_file(Path("local")) assert Pkgbuild.from_file(Path("local"))
open_mock.assert_called_once_with(encoding="utf8") open_mock.assert_called_once_with("rb")
load_mock.assert_called_once_with(pytest.helpers.anyvar(int)) load_mock.assert_called_once_with(pytest.helpers.anyvar(int))
def test_from_file_latin(pkgbuild_ahriman: Pkgbuild, mocker: MockerFixture) -> None:
"""
must correctly load from file with latin encoding
"""
open_mock = mocker.patch("pathlib.Path.open")
open_mock.return_value.__enter__.return_value = BytesIO("contént".encode("latin-1"))
load_mock = mocker.patch("ahriman.models.pkgbuild.Pkgbuild.from_io", return_value=pkgbuild_ahriman)
assert Pkgbuild.from_file(Path("local"))
open_mock.assert_called_once_with("rb")
load_mock.assert_called_once_with(pytest.helpers.anyvar(int))
def test_from_file_unknown_encoding(pkgbuild_ahriman: Pkgbuild, mocker: MockerFixture) -> None:
"""
must raise exception when encoding is unknown
"""
open_mock = mocker.patch("pathlib.Path.open")
io_mock = open_mock.return_value.__enter__.return_value = MagicMock()
io_mock.read.return_value.decode.side_effect = EncodeError(pkgbuild_ahriman.DEFAULT_ENCODINGS)
with pytest.raises(EncodeError):
assert Pkgbuild.from_file(Path("local"))
def test_from_io(pkgbuild_ahriman: Pkgbuild, mocker: MockerFixture) -> None: def test_from_io(pkgbuild_ahriman: Pkgbuild, mocker: MockerFixture) -> None:
""" """
must correctly load from io must correctly load from io
@ -461,9 +489,7 @@ def test_parse_python_pytest_loop(resource_path_root: Path) -> None:
"pkgbase": "python-pytest-loop", "pkgbase": "python-pytest-loop",
"_pname": "${pkgbase#python-}", "_pname": "${pkgbase#python-}",
"_pyname": "${_pname//-/_}", "_pyname": "${_pname//-/_}",
"pkgname": [ "pkgname": ["python-${_pname}"],
"python-${_pname}",
],
"pkgver": "1.0.13", "pkgver": "1.0.13",
"pkgrel": "1", "pkgrel": "1",
"pkgdesc": "Pytest plugin for looping test execution.", "pkgdesc": "Pytest plugin for looping test execution.",
@ -487,3 +513,98 @@ def test_parse_python_pytest_loop(resource_path_root: Path) -> None:
"98365f49606d5068f92350f1d2569a5f", "98365f49606d5068f92350f1d2569a5f",
], ],
} }
def test_parse_pacman_static(resource_path_root: Path) -> None:
"""
must parse real PKGBUILDs correctly (pacman-static)
"""
pkgbuild = Pkgbuild.from_file(resource_path_root / "models" / "package_pacman-static_pkgbuild")
values = {key: value.value for key, value in pkgbuild.fields.items() if not value.is_function}
print(values)
assert values == {
"pkgbase": "pacman-static",
"pkgname": "pacman-static",
"pkgver": "7.0.0.r6.gc685ae6",
"_cares_ver": "1.34.4",
"_nghttp2_ver": "1.64.0",
"_curlver": "8.12.1",
"_sslver": "3.4.1",
"_zlibver": "1.3.1",
"_xzver": "5.6.4",
"_bzipver": "1.0.8",
"_zstdver": "1.5.6",
"_libarchive_ver": "3.7.7",
"_gpgerrorver": "1.51",
"_libassuanver": "3.0.0",
"_gpgmever": "1.24.2",
"pkgrel": "15",
"_git_tag": "7.0.0",
"_git_patch_level_commit": "c685ae6412af04cae1eaa5d6bda8c277c7ffb8c8",
"pkgdesc": "Statically-compiled pacman (to fix or install systems without libc)",
"arch": [
"i486",
"i686",
"pentium4",
"x86_64",
"arm",
"armv6h",
"armv7h",
"aarch64"
],
"url": "https://www.archlinux.org/pacman/",
"license": ["GPL-2.0-or-later"],
"depends": ["pacman"],
"makedepends": [
"meson",
"musl",
"kernel-headers-musl",
"git",
],
"options": [
"!emptydirs",
"!lto",
],
"source": [
"git+https://gitlab.archlinux.org/pacman/pacman.git#tag=v${_git_tag}?signed",
"pacman-revertme-makepkg-remove-libdepends-and-libprovides.patch::https://gitlab.archlinux.org/pacman/pacman/-/commit/354a300cd26bb1c7e6551473596be5ecced921de.patch",
],
"validpgpkeys": [
"6645B0A8C7005E78DB1D7864F99FFE0FEAE999BD",
"B8151B117037781095514CA7BBDFFC92306B1121",
],
"sha512sums": [
"44e00c2bc259fe6a85de71f7fd8a43fcfd1b8fb7d920d2267bd5b347e02f1dab736b3d96e31faf7b535480398e2348f7c0b9914e51ca7e12bab2d5b8003926b4",
"1a108c4384b6104e627652488659de0b1ac3330640fc3250f0a283af7c5884daab187c1efc024b2545262da1911d2b0b7b0d5e4e5b68bb98db25a760c9f1fb1a",
"b544196c3b7a55faacd11700d11e2fe4f16a7418282c9abb24a668544a15293580fd1a2cc5f93367c8a17c7ee45335c6d2f5c68a72dd176d516fd033f203eeec",
"3285e14d94bc736d6caddfe7ad7e3c6a6e69d49b079c989bb3e8aba4da62c022e38229d1e691aaa030b7d3bcd89e458d203f260806149a71ad9adb31606eae02",
"SKIP",
"9fcdcceab8bce43e888db79a38c775ff15790a806d3cc5cc96f396a829c6da2383b258481b5642153da14087943f6ef607af0aa3b75df6f41b95c6cd61d835eb",
"SKIP",
"1de6307c587686711f05d1e96731c43526fa3af51e4cd94c06c880954b67f6eb4c7db3177f0ea5937d41bc1f8cadcf5bce75025b5c1a46a469376960f1001c5f",
"SKIP",
"b1873dbb7a49460b007255689102062756972de5cc2d38b12cc9f389b6be412da6797579b1acd3717a8cd2ee118fd9801b94e55f063d4328f050f0876a5eb53c",
"b5887ea77417fae49b6cb1e9fa782d3021f268d5219701d87a092235964f73fa72a31428b630445517f56f2bb69dcbbb24119ef9dbf8b4e40a753369a9f9a16f",
"580677aad97093829090d4b605ac81c50327e74a6c2de0b85dd2e8525553f3ddde17556ea46f8f007f89e435493c9a20bc997d1ef1c1c2c23274528e3c46b94f",
"SKIP",
"e3216eca5fae2c9ce419e698bfbe186903088dad0a579749cb49bcde8f9d4073b98bf1570fe69190a9a41feb2a7c9814498ec9b867527de1c74ff75a1cbdfc17",
"083f5e675d73f3233c7930ebe20425a533feedeaaa9d8cc86831312a6581cefbe6ed0d08d2fa89be81082f2a5abdabca8b3c080bf97218a1bd59dc118a30b9f3",
"SKIP",
"21f9da445afd76acaf3acb22d216c2b584d95e8c68e00f5cb3f6673f2d556dd14a7593344adf8ffd194bba3314387ee0e486d6248f6c935abca2edd8a4cf95ed",
"SKIP",
"4489f615c6a0389577a7d1fd7d3917517bb2fe032abd9a6d87dfdbd165dabcf53f8780645934020bf27517b67a064297475888d5b368176cf06bc22f1e735e2b",
"SKIP",
"7c5c95c1b85bef2d4890c068a5a8ea8a1fe0d8def6ab09e5f34fc2746d8808bbb0fc168e3bd66d52ee5ed799dcf9f258f4125cda98c8384f6411bcad8d8b3139",
"SKIP",
"ad69101d1fceef6cd1dd6d5348f6f2be06912da6b6a7d0fece3ce08cf35054e6953b80ca9c4748554882892faa44e7c54e705cf25bbf2b796cd4ad12b09da185",
"SKIP",
"2524f71f4c2ebc254a1927279be3394e820d0a0c6dec7ef835a862aa08c35756edaa4208bcdc710dd092872b59c200b555b78670372e2830822e278ff1ec4e4a",
"SKIP",
],
"LDFLAGS": "$LDFLAGS -static",
"CC": "musl-gcc -fno-stack-protector",
"CXX": "musl-gcc -fno-stack-protector",
"CFLAGS": "${CFLAGS/-fstack-protector-strong/}",
"CXXFLAGS": "${CXXFLAGS/-fstack-protector-strong/}",
"PKGEXT": ".pkg.tar.xz",
}

View File

@ -68,3 +68,10 @@ def test_lt_invalid() -> None:
""" """
with pytest.raises(ValueError): with pytest.raises(ValueError):
assert RepositoryId("x86_64", "a") < 42 assert RepositoryId("x86_64", "a") < 42
def test_str() -> None:
"""
must convert identifier to string
"""
assert str(RepositoryId("x86_64", "a")) == "a (x86_64)"

View File

@ -0,0 +1,24 @@
from dataclasses import asdict
from ahriman.models.package import Package
from ahriman.models.repository_stats import RepositoryStats
def test_repository_stats_from_json_view(package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must construct same object from json
"""
stats = RepositoryStats.from_packages([package_ahriman, package_python_schedule])
assert RepositoryStats.from_json(asdict(stats)) == stats
def test_from_packages(package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must generate stats from packages list
"""
assert RepositoryStats.from_packages([package_ahriman, package_python_schedule]) == RepositoryStats(
bases=2,
packages=3,
archive_size=12603,
installed_size=12600003,
)

View File

@ -0,0 +1,80 @@
from ahriman.models.series_statistics import SeriesStatistics
def test_max() -> None:
"""
must return maximal value
"""
assert SeriesStatistics([1, 3, 2]).max == 3
def test_max_empty() -> None:
"""
must return None as maximal value if series is empty
"""
assert SeriesStatistics([]).max is None
def test_mean() -> None:
"""
must return mean value
"""
assert SeriesStatistics([1, 3, 2]).mean == 2
def test_mean_empty() -> None:
"""
must return None as mean value if series is empty
"""
assert SeriesStatistics([]).mean is None
def test_min() -> None:
"""
must return minimal value
"""
assert SeriesStatistics([1, 3, 2]).min == 1
def test_min_empty() -> None:
"""
must return None as minimal value if series is empty
"""
assert SeriesStatistics([]).min is None
def test_st_dev() -> None:
"""
must return standard deviation
"""
assert SeriesStatistics([1, 3, 2]).st_dev == 1
def test_st_dev_empty() -> None:
"""
must return None as standard deviation if series is empty
"""
assert SeriesStatistics([]).st_dev is None
def test_st_dev_single() -> None:
"""
must return 0 as standard deviation if series contains only one element
"""
assert SeriesStatistics([1]).st_dev == 0
def test_total() -> None:
"""
must return size of collection
"""
assert SeriesStatistics([1]).total == 1
assert SeriesStatistics([]).total == 0
def test_bool() -> None:
"""
must correctly define empty collection
"""
assert SeriesStatistics([1])
assert not SeriesStatistics([])

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@ -0,0 +1,356 @@
# Maintainer: Eli Schwartz <eschwartz@archlinux.org>
# All my PKGBUILDs are managed at https://github.com/eli-schwartz/pkgbuilds
pkgname=pacman-static
pkgver=7.0.0.r6.gc685ae6
_cares_ver=1.34.4
_nghttp2_ver=1.64.0
_curlver=8.12.1
_sslver=3.4.1
_zlibver=1.3.1
_xzver=5.6.4
_bzipver=1.0.8
_zstdver=1.5.6
_libarchive_ver=3.7.7
_gpgerrorver=1.51
_libassuanver=3.0.0
_gpgmever=1.24.2
pkgrel=15
# use annotated tag and patch level commit from release branch (can be empty for no patches)
_git_tag=7.0.0
_git_patch_level_commit=c685ae6412af04cae1eaa5d6bda8c277c7ffb8c8
pkgdesc="Statically-compiled pacman (to fix or install systems without libc)"
arch=('i486' 'i686' 'pentium4' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64')
url="https://www.archlinux.org/pacman/"
license=('GPL-2.0-or-later')
depends=('pacman')
makedepends=('meson' 'musl' 'kernel-headers-musl' 'git')
options=('!emptydirs' '!lto')
# pacman
source=("git+https://gitlab.archlinux.org/pacman/pacman.git#tag=v${_git_tag}?signed"
pacman-revertme-makepkg-remove-libdepends-and-libprovides.patch::https://gitlab.archlinux.org/pacman/pacman/-/commit/354a300cd26bb1c7e6551473596be5ecced921de.patch)
validpgpkeys=('6645B0A8C7005E78DB1D7864F99FFE0FEAE999BD' # Allan McRae <allan@archlinux.org>
'B8151B117037781095514CA7BBDFFC92306B1121') # Andrew Gregory (pacman) <andrew@archlinux.org>
# nghttp2
source+=("https://github.com/nghttp2/nghttp2/releases/download/v$_nghttp2_ver/nghttp2-$_nghttp2_ver.tar.xz")
# c-ares
source+=("https://github.com/c-ares/c-ares/releases/download/v${_cares_ver}/c-ares-${_cares_ver}.tar.gz"{,.asc})
validpgpkeys+=('27EDEAF22F3ABCEB50DB9A125CC908FDB71E12C2' # Daniel Stenberg <daniel@haxx.se>
'DA7D64E4C82C6294CB73A20E22E3D13B5411B7CA') # Brad House <brad@brad-house.com>
# curl
source+=("https://curl.haxx.se/download/curl-${_curlver}.tar.gz"{,.asc})
validpgpkeys+=('27EDEAF22F3ABCEB50DB9A125CC908FDB71E12C2') # Daniel Stenberg
# openssl
source+=("https://github.com/openssl/openssl/releases/download/openssl-${_sslver}/openssl-${_sslver}.tar.gz"{,.asc}
"ca-dir.patch"
"openssl-3.0.7-no-atomic.patch")
validpgpkeys+=('8657ABB260F056B1E5190839D9C4D26D0E604491'
'7953AC1FBC3DC8B3B292393ED5E9E43F7DF9EE8C'
'A21FAB74B0088AA361152586B8EF1A6BA9DA2D5C'
'EFC0A467D613CB83C7ED6D30D894E2CE8B3D79F5'
'BA5473A2B0587B07FB27CF2D216094DFD0CB81EF')
validpgpkeys+=('8657ABB260F056B1E5190839D9C4D26D0E604491' # Matt Caswell <matt@openssl.org>
'7953AC1FBC3DC8B3B292393ED5E9E43F7DF9EE8C' # Matt Caswell <matt@openssl.org>
'A21FAB74B0088AA361152586B8EF1A6BA9DA2D5C' # Tomá? Mráz <tm@t8m.info>
'EFC0A467D613CB83C7ED6D30D894E2CE8B3D79F5') # OpenSSL security team key
# zlib
source+=("https://zlib.net/zlib-${_zlibver}.tar.gz"{,.asc})
validpgpkeys+=('5ED46A6721D365587791E2AA783FCD8E58BCAFBA') # Mark Adler <madler@alumni.caltech.edu>
# xz
source+=("git+https://github.com/tukaani-project/xz#tag=v${_xzver}")
validpgpkeys+=('3690C240CE51B4670D30AD1C38EE757D69184620') # Lasse Collin <lasse.collin@tukaani.org>
# bzip2
source+=("https://sourceware.org/pub/bzip2/bzip2-${_bzipver}.tar.gz"{,.sig})
validpgpkeys+=('EC3CFE88F6CA0788774F5C1D1AA44BE649DE760A') # Mark Wielaard <mark@klomp.org>
# zstd
source+=("https://github.com/facebook/zstd/releases/download/v${_zstdver}/zstd-${_zstdver}.tar.zst"{,.sig})
validpgpkeys+=('4EF4AC63455FC9F4545D9B7DEF8FE99528B52FFD') # Zstandard Release Signing Key <signing@zstd.net>
# libgpg-error
source+=("https://gnupg.org/ftp/gcrypt/libgpg-error/libgpg-error-${_gpgerrorver}.tar.bz2"{,.sig})
validpgpkeys+=('D8692123C4065DEA5E0F3AB5249B39D24F25E3B6' # Werner Koch
'031EC2536E580D8EA286A9F22071B08A33BD3F06' # NIIBE Yutaka (GnuPG Release Key) <gniibe@fsij.org>
'6DAA6E64A76D2840571B4902528897B826403ADA') # "Werner Koch (dist signing 2020)"
# libassuan
source+=("https://gnupg.org/ftp/gcrypt/libassuan/libassuan-${_libassuanver}.tar.bz2"{,.sig})
# gpgme
source+=("https://www.gnupg.org/ftp/gcrypt/gpgme/gpgme-${_gpgmever}.tar.bz2"{,.sig})
validpgpkeys+=('AC8E115BF73E2D8D47FA9908E98E9B2D19C6C8BD') # Niibe Yutaka (GnuPG Release Key)
# libarchive
source+=("https://github.com/libarchive/libarchive/releases/download/v${_libarchive_ver}/libarchive-${_libarchive_ver}.tar.xz"{,.asc})
validpgpkeys+=('A5A45B12AD92D964B89EEE2DEC560C81CEC2276E' # Martin Matuska <mm@FreeBSD.org>
'DB2C7CF1B4C265FAEF56E3FC5848A18B8F14184B') # Martin Matuska <martin@matuska.org>
sha512sums=('44e00c2bc259fe6a85de71f7fd8a43fcfd1b8fb7d920d2267bd5b347e02f1dab736b3d96e31faf7b535480398e2348f7c0b9914e51ca7e12bab2d5b8003926b4'
'1a108c4384b6104e627652488659de0b1ac3330640fc3250f0a283af7c5884daab187c1efc024b2545262da1911d2b0b7b0d5e4e5b68bb98db25a760c9f1fb1a'
'b544196c3b7a55faacd11700d11e2fe4f16a7418282c9abb24a668544a15293580fd1a2cc5f93367c8a17c7ee45335c6d2f5c68a72dd176d516fd033f203eeec'
'3285e14d94bc736d6caddfe7ad7e3c6a6e69d49b079c989bb3e8aba4da62c022e38229d1e691aaa030b7d3bcd89e458d203f260806149a71ad9adb31606eae02'
'SKIP'
'9fcdcceab8bce43e888db79a38c775ff15790a806d3cc5cc96f396a829c6da2383b258481b5642153da14087943f6ef607af0aa3b75df6f41b95c6cd61d835eb'
'SKIP'
'1de6307c587686711f05d1e96731c43526fa3af51e4cd94c06c880954b67f6eb4c7db3177f0ea5937d41bc1f8cadcf5bce75025b5c1a46a469376960f1001c5f'
'SKIP'
'b1873dbb7a49460b007255689102062756972de5cc2d38b12cc9f389b6be412da6797579b1acd3717a8cd2ee118fd9801b94e55f063d4328f050f0876a5eb53c'
'b5887ea77417fae49b6cb1e9fa782d3021f268d5219701d87a092235964f73fa72a31428b630445517f56f2bb69dcbbb24119ef9dbf8b4e40a753369a9f9a16f'
'580677aad97093829090d4b605ac81c50327e74a6c2de0b85dd2e8525553f3ddde17556ea46f8f007f89e435493c9a20bc997d1ef1c1c2c23274528e3c46b94f'
'SKIP'
'e3216eca5fae2c9ce419e698bfbe186903088dad0a579749cb49bcde8f9d4073b98bf1570fe69190a9a41feb2a7c9814498ec9b867527de1c74ff75a1cbdfc17'
'083f5e675d73f3233c7930ebe20425a533feedeaaa9d8cc86831312a6581cefbe6ed0d08d2fa89be81082f2a5abdabca8b3c080bf97218a1bd59dc118a30b9f3'
'SKIP'
'21f9da445afd76acaf3acb22d216c2b584d95e8c68e00f5cb3f6673f2d556dd14a7593344adf8ffd194bba3314387ee0e486d6248f6c935abca2edd8a4cf95ed'
'SKIP'
'4489f615c6a0389577a7d1fd7d3917517bb2fe032abd9a6d87dfdbd165dabcf53f8780645934020bf27517b67a064297475888d5b368176cf06bc22f1e735e2b'
'SKIP'
'7c5c95c1b85bef2d4890c068a5a8ea8a1fe0d8def6ab09e5f34fc2746d8808bbb0fc168e3bd66d52ee5ed799dcf9f258f4125cda98c8384f6411bcad8d8b3139'
'SKIP'
'ad69101d1fceef6cd1dd6d5348f6f2be06912da6b6a7d0fece3ce08cf35054e6953b80ca9c4748554882892faa44e7c54e705cf25bbf2b796cd4ad12b09da185'
'SKIP'
'2524f71f4c2ebc254a1927279be3394e820d0a0c6dec7ef835a862aa08c35756edaa4208bcdc710dd092872b59c200b555b78670372e2830822e278ff1ec4e4a'
'SKIP')
export LDFLAGS="$LDFLAGS -static"
export CC=musl-gcc
export CXX=musl-gcc
# https://www.openwall.com/lists/musl/2014/11/05/3
# fstack-protector and musl do not get along but only on i686
if [[ $CARCH = i686 || $CARCH = pentium4 || $CARCH = i486 ]]; then
# silly build systems have configure checks or buildtime programs that don't CFLAGS but do do CC
export CC="musl-gcc -fno-stack-protector"
export CXX="musl-gcc -fno-stack-protector"
export CFLAGS="${CFLAGS/-fstack-protector-strong/}"
export CXXFLAGS="${CXXFLAGS/-fstack-protector-strong/}"
fi
# to enable func64 interface in musl for 64-bit file system functions
export CFLAGS+=' -D_LARGEFILE64_SOURCE'
export CXXFLAGS+=' -D_LARGEFILE64_SOURCE'
# keep using xz-compressed packages, because one use of the package is to
# recover on systems with broken zstd support in libarchive
[[ $PKGEXT = .pkg.tar.zst ]] && PKGEXT=.pkg.tar.xz
prepare() {
cd "${srcdir}/pacman"
# apply patch level commits on top of annotated tag for pacman
if [[ -n ${_git_patch_level_commit} ]]; then
if [[ v${_git_tag} != $(git describe --tags --abbrev=0 "${_git_patch_level_commit}") ]] then
error "patch level commit ${_git_patch_level_commit} is not a descendant of v${_git_tag}"
exit 1
fi
git rebase "${_git_patch_level_commit}"
fi
# handle local pacman patches
local -a patches
patches=($(printf '%s\n' "${source[@]}" | grep 'pacman-.*.patch'))
patches=("${patches[@]%%::*}")
patches=("${patches[@]##*/}")
if (( ${#patches[@]} != 0 )); then
for patch in "${patches[@]}"; do
if [[ $patch =~ revertme-* ]]; then
msg2 "Reverting patch $patch..."
patch -RNp1 < "../$patch"
else
msg2 "Applying patch $patch..."
patch -Np1 < "../$patch"
fi
done
fi
# openssl
cd "${srcdir}"/openssl-${_sslver}
patch -Np1 -i "${srcdir}/ca-dir.patch"
case ${CARCH} in
arm|armv6h|armv7h)
# special patch to omit -latomic when installing pkgconfig files
msg2 "Applying openssl patch openssl-3.0.7-no-atomic.patch..."
patch -Np1 -i "${srcdir}/openssl-3.0.7-no-atomic.patch"
esac
}
build() {
export PKG_CONFIG_PATH="${srcdir}"/temp/usr/lib/pkgconfig
export PATH="${srcdir}/temp/usr/bin:${PATH}"
# openssl
cd "${srcdir}"/openssl-${_sslver}
case ${CARCH} in
x86_64)
openssltarget='linux-x86_64'
optflags='enable-ec_nistp_64_gcc_128'
;;
pentium4)
openssltarget='linux-elf'
optflags=''
;;
i686)
openssltarget='linux-elf'
optflags='no-sse2'
;;
i486)
openssltarget='linux-elf'
optflags='386 no-threads'
;;
arm|armv6h|armv7h)
openssltarget='linux-armv4'
optflags=''
;;
aarch64)
openssltarget='linux-aarch64'
optflags='no-afalgeng'
;;
esac
# mark stack as non-executable: http://bugs.archlinux.org/task/12434
./Configure --prefix="${srcdir}"/temp/usr \
--openssldir=/etc/ssl \
--libdir=lib \
-static \
no-ssl3-method \
${optflags} \
"${openssltarget}" \
"-Wa,--noexecstack ${CPPFLAGS} ${CFLAGS} ${LDFLAGS}"
make build_libs
make install_dev
# xz
cd "${srcdir}"/xz
./autogen.sh --no-po4a --no-doxygen
./configure --prefix="${srcdir}"/temp/usr \
--disable-shared
cd src/liblzma
make
make install
# bzip2
cd "${srcdir}"/bzip2-${_bzipver}
sed -i "s|-O2|${CFLAGS}|g;s|CC=gcc|CC=${CC}|g" Makefile
make libbz2.a
install -Dvm644 bzlib.h "${srcdir}"/temp/usr/include/
install -Dvm644 libbz2.a "${srcdir}"/temp/usr/lib/
cd "${srcdir}"/zstd-${_zstdver}/lib
make libzstd.a
make PREFIX="${srcdir}"/temp/usr install-pc install-static install-includes
# zlib
cd "${srcdir}/"zlib-${_zlibver}
./configure --prefix="${srcdir}"/temp/usr \
--static
make libz.a
make install
# libarchive
cd "${srcdir}"/libarchive-${_libarchive_ver}
CPPFLAGS="-I${srcdir}/temp/usr/include" CFLAGS="-L${srcdir}/temp/usr/lib" \
./configure --prefix="${srcdir}"/temp/usr \
--without-xml2 \
--without-nettle \
--disable-{bsdtar,bsdcat,bsdcpio,bsdunzip} \
--without-expat \
--disable-shared
make
make install-{includeHEADERS,libLTLIBRARIES,pkgconfigDATA,includeHEADERS}
# nghttp2
cd "${srcdir}"/nghttp2-${_nghttp2_ver}
./configure --prefix="${srcdir}"/temp/usr \
--disable-shared \
--disable-examples \
--disable-python-bindings
make -C lib
make -C lib install
# c-ares
# needed for curl, which does not use it in the repos
# but seems to be needed for static builds
cd "${srcdir}"/c-ares-${_cares_ver}
./configure --prefix="${srcdir}"/temp/usr \
--disable-shared
make -C src/lib
make install-pkgconfigDATA
make -C src/lib install
make -C include install
# curl
cd "${srcdir}"/curl-${_curlver}
# c-ares is not detected via pkg-config :(
./configure --prefix="${srcdir}"/temp/usr \
--disable-shared \
--with-ca-bundle=/etc/ssl/certs/ca-certificates.crt \
--disable-{dict,gopher,imap,ldap,ldaps,manual,pop3,rtsp,smb,smtp,telnet,tftp} \
--without-{brotli,libidn2,librtmp,libssh2,libpsl} \
--disable-libcurl-option \
--with-openssl \
--enable-ares="${srcdir}"/temp/usr
make -C lib
make install-pkgconfigDATA
make -C lib install
make -C include install
# libgpg-error
cd "${srcdir}"/libgpg-error-${_gpgerrorver}
./configure --prefix="${srcdir}"/temp/usr \
--disable-shared
make -C src
make -C src install-{binSCRIPTS,libLTLIBRARIES,nodist_includeHEADERS,pkgconfigDATA}
# libassuan
cd "${srcdir}"/libassuan-${_libassuanver}
./configure --prefix="${srcdir}"/temp/usr \
--disable-shared
make -C src
make -C src install-{binSCRIPTS,libLTLIBRARIES,nodist_includeHEADERS,pkgconfigDATA}
# gpgme
cd "${srcdir}"/gpgme-${_gpgmever}
./configure --prefix="${srcdir}"/temp/usr \
--disable-fd-passing \
--disable-shared \
--disable-languages
make -C src
make -C src install-{binSCRIPTS,libLTLIBRARIES,nodist_includeHEADERS,pkgconfigDATA}
# ew libtool
rm "${srcdir}"/temp/usr/lib/lib*.la
# Finally, it's a pacman!
mkdir -p "${srcdir}"/pacman
cd "${srcdir}"/pacman
meson --prefix=/usr \
--includedir=lib/pacman/include \
--libdir=lib/pacman/lib \
--buildtype=plain \
-Dbuildstatic=true \
-Ddefault_library=static \
-Ddoc=disabled \
-Ddoxygen=disabled \
-Dldconfig=/usr/bin/ldconfig \
-Dscriptlet-shell=/usr/bin/bash \
build
meson compile -C build
}
package() {
cd "${srcdir}"/pacman
DESTDIR="${pkgdir}" meson install -C build
rm -rf "${pkgdir}"/usr/share "${pkgdir}"/etc
for exe in "${pkgdir}"/usr/bin/*; do
if [[ -f ${exe} && $(head -c4 "${exe}") = $'\x7fELF' ]]; then
mv "${exe}" "${exe}"-static
else
rm "${exe}"
fi
done
cp -a "${srcdir}"/temp/usr/{bin,include,lib} "${pkgdir}"/usr/lib/pacman/
sed -i "s@${srcdir}/temp/usr@/usr/lib/pacman@g" \
"${pkgdir}"/usr/lib/pacman/lib/pkgconfig/*.pc \
"${pkgdir}"/usr/lib/pacman/bin/*
}