Compare commits

..

18 Commits

Author SHA1 Message Date
70e29c2ff0 support archive loading and management 2026-03-12 10:30:34 +02:00
81aeb56ba3 refactor: lightweight Package.from_archive method 2026-03-11 23:00:55 +02:00
2cd4ef5e86 refactor: separate package_archives method from trigger 2026-03-11 13:36:59 +02:00
998ed48dde feat: add pkgbuild subscommands 2026-03-11 13:31:19 +02:00
021d88dc4c feat: store and show pkgbuild (implements #157) 2026-03-11 01:05:06 +02:00
9012ee7144 feat: add dark theme support and increase contrast 2026-03-08 22:59:31 +02:00
945ddb2942 Release 2.20.0 2026-03-08 16:51:28 +02:00
9cd0926588 docs: add http client description 2026-03-08 16:49:46 +02:00
f5c12d03ec Release 2.20.0rc7 2026-03-08 12:18:03 +02:00
606ee2fb0e docs: architecture spec update 2026-03-08 12:16:26 +02:00
875e9cf58a docs: update web preview picture 2026-03-08 03:33:49 +02:00
a809a4b67f feat: support request-id header 2026-03-08 02:51:30 +02:00
437ae2c16c style: some minor style improvements 2026-03-06 21:38:57 +02:00
10661f3127 fix: catch some StopIteration exceptions 2026-03-06 17:04:19 +02:00
88ac3d82a0 feat: explicitly disable archive rotation if option is set to 0 2026-03-06 12:59:48 +02:00
386765ab4e fix: fix dashboard visual 2026-03-06 01:48:11 +02:00
78998182b8 Release 2.20.0rc6 2026-03-06 01:32:13 +02:00
2563391aaf build: correct order of building 2026-03-06 01:31:53 +02:00
121 changed files with 1989 additions and 613 deletions

View File

@@ -138,11 +138,12 @@ digraph G {
ahriman_core_http [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nhttp",shape="box"]; ahriman_core_http [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nhttp",shape="box"];
ahriman_core_http_sync_ahriman_client [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nhttp\.\nsync_ahriman_client",shape="box"]; ahriman_core_http_sync_ahriman_client [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nhttp\.\nsync_ahriman_client",shape="box"];
ahriman_core_http_sync_http_client [fillcolor="#933f24",fontcolor="#ffffff",label="ahriman\.\ncore\.\nhttp\.\nsync_http_client"]; ahriman_core_http_sync_http_client [fillcolor="#933f24",fontcolor="#ffffff",label="ahriman\.\ncore\.\nhttp\.\nsync_http_client"];
ahriman_core_log [fillcolor="#e53b05",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog"]; ahriman_core_log [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nlog",shape="box"];
ahriman_core_log_http_log_handler [fillcolor="#914730",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nhttp_log_handler"]; ahriman_core_log_http_log_handler [fillcolor="#914730",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nhttp_log_handler"];
ahriman_core_log_journal_handler [fillcolor="#ac6149",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\njournal_handler"]; ahriman_core_log_journal_handler [fillcolor="#ac6149",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\njournal_handler"];
ahriman_core_log_lazy_logging [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nlazy_logging"]; ahriman_core_log_lazy_logging [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nlog\.\nlazy_logging",shape="box"];
ahriman_core_log_log_loader [fillcolor="#82402b",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nlog_loader"]; ahriman_core_log_log_context [fillcolor="#db582f",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nlog_context"];
ahriman_core_log_log_loader [fillcolor="#7a3c28",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nlog_loader"];
ahriman_core_module_loader [fillcolor="#ce5e3b",fontcolor="#ffffff",label="ahriman\.\ncore\.\nmodule_loader"]; ahriman_core_module_loader [fillcolor="#ce5e3b",fontcolor="#ffffff",label="ahriman\.\ncore\.\nmodule_loader"];
ahriman_core_report [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nreport",shape="box"]; ahriman_core_report [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nreport",shape="box"];
ahriman_core_report_console [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nreport\.\nconsole",shape="box"]; ahriman_core_report_console [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nreport\.\nconsole",shape="box"];
@@ -242,10 +243,11 @@ digraph G {
ahriman_web_apispec_info [fillcolor="#a14f35",fontcolor="#ffffff",label="ahriman\.\nweb\.\napispec\.\ninfo"]; ahriman_web_apispec_info [fillcolor="#a14f35",fontcolor="#ffffff",label="ahriman\.\nweb\.\napispec\.\ninfo"];
ahriman_web_cors [fillcolor="#b0573a",fontcolor="#ffffff",label="ahriman\.\nweb\.\ncors"]; ahriman_web_cors [fillcolor="#b0573a",fontcolor="#ffffff",label="ahriman\.\nweb\.\ncors"];
ahriman_web_keys [fillcolor="#823017",fontcolor="#ffffff",label="ahriman\.\nweb\.\nkeys"]; ahriman_web_keys [fillcolor="#823017",fontcolor="#ffffff",label="ahriman\.\nweb\.\nkeys"];
ahriman_web_middlewares [fillcolor="#e9410c",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares"]; ahriman_web_middlewares [fillcolor="#ef3e06",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares"];
ahriman_web_middlewares_auth_handler [fillcolor="#733826",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nauth_handler"]; ahriman_web_middlewares_auth_handler [fillcolor="#733826",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nauth_handler"];
ahriman_web_middlewares_exception_handler [fillcolor="#994b33",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nexception_handler"]; ahriman_web_middlewares_exception_handler [fillcolor="#994b33",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nexception_handler"];
ahriman_web_middlewares_metrics_handler [fillcolor="#a34628",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nmetrics_handler"]; ahriman_web_middlewares_metrics_handler [fillcolor="#a34628",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nmetrics_handler"];
ahriman_web_middlewares_request_id_handler [fillcolor="#8a442e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nmiddlewares\.\nrequest_id_handler"];
ahriman_web_routes [fillcolor="#8a442e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nroutes"]; ahriman_web_routes [fillcolor="#8a442e",fontcolor="#ffffff",label="ahriman\.\nweb\.\nroutes"];
ahriman_web_schemas [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas",shape="box"]; ahriman_web_schemas [fillcolor="blue",fontcolor="white",label="ahriman\.\nweb\.\nschemas",shape="box"];
ahriman_web_schemas_any_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nany_schema"]; ahriman_web_schemas_any_schema [fillcolor="#b85a3d",fontcolor="#ffffff",label="ahriman\.\nweb\.\nschemas\.\nany_schema"];
@@ -493,6 +495,7 @@ digraph G {
ahriman_core -> ahriman_models_worker [fillcolor="#ef3e06",minlen="2"]; ahriman_core -> ahriman_models_worker [fillcolor="#ef3e06",minlen="2"];
ahriman_core -> ahriman_web_keys [fillcolor="#ef3e06",minlen="2"]; ahriman_core -> ahriman_web_keys [fillcolor="#ef3e06",minlen="2"];
ahriman_core -> ahriman_web_middlewares_auth_handler [fillcolor="#ef3e06",minlen="3"]; ahriman_core -> ahriman_web_middlewares_auth_handler [fillcolor="#ef3e06",minlen="3"];
ahriman_core -> ahriman_web_middlewares_request_id_handler [fillcolor="#ef3e06",minlen="3"];
ahriman_core -> ahriman_web_routes [fillcolor="#ef3e06",minlen="2"]; ahriman_core -> ahriman_web_routes [fillcolor="#ef3e06",minlen="2"];
ahriman_core -> ahriman_web_server_info [fillcolor="#ef3e06",minlen="2"]; ahriman_core -> ahriman_web_server_info [fillcolor="#ef3e06",minlen="2"];
ahriman_core -> ahriman_web_views_api_docs [fillcolor="#ef3e06",minlen="3"]; ahriman_core -> ahriman_web_views_api_docs [fillcolor="#ef3e06",minlen="3"];
@@ -848,35 +851,39 @@ digraph G {
ahriman_core_http_sync_ahriman_client -> ahriman_core_http [fillcolor="blue",weight="3"]; ahriman_core_http_sync_ahriman_client -> ahriman_core_http [fillcolor="blue",weight="3"];
ahriman_core_http_sync_http_client -> ahriman_core_http [fillcolor="#933f24",weight="3"]; ahriman_core_http_sync_http_client -> ahriman_core_http [fillcolor="#933f24",weight="3"];
ahriman_core_http_sync_http_client -> ahriman_core_http_sync_ahriman_client [fillcolor="#933f24",weight="3"]; ahriman_core_http_sync_http_client -> ahriman_core_http_sync_ahriman_client [fillcolor="#933f24",weight="3"];
ahriman_core_log -> ahriman_application_application_application_properties [fillcolor="#e53b05",minlen="3"]; ahriman_core_log -> ahriman_application_application_application_properties [fillcolor="blue",minlen="3"];
ahriman_core_log -> ahriman_application_application_workers_updater [fillcolor="#e53b05",minlen="3"]; ahriman_core_log -> ahriman_application_application_workers_updater [fillcolor="blue",minlen="3"];
ahriman_core_log -> ahriman_application_handlers_handler [fillcolor="#e53b05",minlen="3"]; ahriman_core_log -> ahriman_application_handlers_handler [fillcolor="blue",minlen="3"];
ahriman_core_log -> ahriman_application_lock [fillcolor="#e53b05",minlen="2"]; ahriman_core_log -> ahriman_application_lock [fillcolor="blue",minlen="2"];
ahriman_core_log -> ahriman_core_alpm_pacman [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_alpm_pacman [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_alpm_repo [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_alpm_repo [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_archive_archive_tree [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_archive_archive_tree [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_auth_auth [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_auth_auth [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_build_tools_package_version [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_build_tools_package_version [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_build_tools_sources [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_build_tools_sources [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_build_tools_task [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_build_tools_task [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_database_migrations [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_database_migrations [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_database_operations_operations [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_database_operations_operations [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_distributed_workers_cache [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_distributed_workers_cache [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_gitremote_remote_pull [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_gitremote_remote_pull [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_gitremote_remote_push [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_gitremote_remote_push [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_http_sync_http_client [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_http_sync_http_client [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_report_report [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_report_report [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_repository_repository_properties [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_repository_repository_properties [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_spawn [fillcolor="#e53b05",weight="2"]; ahriman_core_log -> ahriman_core_spawn [fillcolor="blue",weight="2"];
ahriman_core_log -> ahriman_core_status_watcher [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_status_watcher [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_triggers_trigger [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_triggers_trigger [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_triggers_trigger_loader [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_triggers_trigger_loader [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_core_upload_upload [fillcolor="#e53b05",minlen="2",weight="2"]; ahriman_core_log -> ahriman_core_upload_upload [fillcolor="blue",minlen="2",weight="2"];
ahriman_core_log -> ahriman_models_package [fillcolor="#e53b05",minlen="2"]; ahriman_core_log -> ahriman_models_package [fillcolor="blue",minlen="2"];
ahriman_core_log -> ahriman_models_repository_paths [fillcolor="#e53b05",minlen="2"]; ahriman_core_log -> ahriman_models_repository_paths [fillcolor="blue",minlen="2"];
ahriman_core_log -> ahriman_web_middlewares_request_id_handler [fillcolor="blue",minlen="3"];
ahriman_core_log_http_log_handler -> ahriman_core_log_log_loader [fillcolor="#914730",weight="3"]; ahriman_core_log_http_log_handler -> ahriman_core_log_log_loader [fillcolor="#914730",weight="3"];
ahriman_core_log_lazy_logging -> ahriman_core_log [fillcolor="#b85a3d",weight="3"]; ahriman_core_log_lazy_logging -> ahriman_core_log [fillcolor="blue",weight="3"];
ahriman_core_log_log_loader -> ahriman_application_handlers_handler [fillcolor="#82402b",minlen="3"]; ahriman_core_log_log_context -> ahriman_core_log_lazy_logging [fillcolor="#db582f",weight="3"];
ahriman_core_log_log_context -> ahriman_core_log_log_loader [fillcolor="#db582f",weight="3"];
ahriman_core_log_log_context -> ahriman_web_middlewares_request_id_handler [fillcolor="#db582f",minlen="3"];
ahriman_core_log_log_loader -> ahriman_application_handlers_handler [fillcolor="#7a3c28",minlen="3"];
ahriman_core_module_loader -> ahriman_application_ahriman [fillcolor="#ce5e3b",minlen="2"]; ahriman_core_module_loader -> ahriman_application_ahriman [fillcolor="#ce5e3b",minlen="2"];
ahriman_core_module_loader -> ahriman_web_routes [fillcolor="#ce5e3b",minlen="2"]; ahriman_core_module_loader -> ahriman_web_routes [fillcolor="#ce5e3b",minlen="2"];
ahriman_core_report_console -> ahriman_core_report_report [fillcolor="blue",weight="3"]; ahriman_core_report_console -> ahriman_core_report_report [fillcolor="blue",weight="3"];
@@ -1779,12 +1786,13 @@ digraph G {
ahriman_web_keys -> ahriman_web_apispec_info [fillcolor="#823017",minlen="2",weight="2"]; ahriman_web_keys -> ahriman_web_apispec_info [fillcolor="#823017",minlen="2",weight="2"];
ahriman_web_keys -> ahriman_web_views_base [fillcolor="#823017",minlen="2",weight="2"]; ahriman_web_keys -> ahriman_web_views_base [fillcolor="#823017",minlen="2",weight="2"];
ahriman_web_keys -> ahriman_web_web [fillcolor="#823017",weight="2"]; ahriman_web_keys -> ahriman_web_web [fillcolor="#823017",weight="2"];
ahriman_web_middlewares -> ahriman_web_views_v1_status_metrics [fillcolor="#e9410c",minlen="2",weight="2"]; ahriman_web_middlewares -> ahriman_web_views_v1_status_metrics [fillcolor="#ef3e06",minlen="2",weight="2"];
ahriman_web_middlewares -> ahriman_web_web [fillcolor="#e9410c",weight="2"]; ahriman_web_middlewares -> ahriman_web_web [fillcolor="#ef3e06",weight="2"];
ahriman_web_middlewares_auth_handler -> ahriman_web_web [fillcolor="#733826",minlen="2",weight="2"]; ahriman_web_middlewares_auth_handler -> ahriman_web_web [fillcolor="#733826",minlen="2",weight="2"];
ahriman_web_middlewares_exception_handler -> ahriman_web_web [fillcolor="#994b33",minlen="2",weight="2"]; ahriman_web_middlewares_exception_handler -> ahriman_web_web [fillcolor="#994b33",minlen="2",weight="2"];
ahriman_web_middlewares_metrics_handler -> ahriman_web_views_v1_status_metrics [fillcolor="#a34628",minlen="2",weight="2"]; ahriman_web_middlewares_metrics_handler -> ahriman_web_views_v1_status_metrics [fillcolor="#a34628",minlen="2",weight="2"];
ahriman_web_middlewares_metrics_handler -> ahriman_web_web [fillcolor="#a34628",minlen="2",weight="2"]; ahriman_web_middlewares_metrics_handler -> ahriman_web_web [fillcolor="#a34628",minlen="2",weight="2"];
ahriman_web_middlewares_request_id_handler -> ahriman_web_web [fillcolor="#8a442e",minlen="2",weight="2"];
ahriman_web_routes -> ahriman_web_web [fillcolor="#8a442e",weight="2"]; ahriman_web_routes -> ahriman_web_web [fillcolor="#8a442e",weight="2"];
ahriman_web_schemas -> ahriman_web_apispec_decorators [fillcolor="blue",minlen="2",weight="2"]; ahriman_web_schemas -> ahriman_web_apispec_decorators [fillcolor="blue",minlen="2",weight="2"];
ahriman_web_schemas -> ahriman_web_views_v1_auditlog_events [fillcolor="blue",minlen="2",weight="2"]; ahriman_web_schemas -> ahriman_web_views_v1_auditlog_events [fillcolor="blue",minlen="2",weight="2"];
@@ -1939,6 +1947,7 @@ digraph G {
aiohttp -> ahriman_web_middlewares_auth_handler [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_middlewares_auth_handler [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_middlewares_exception_handler [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_middlewares_exception_handler [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_middlewares_metrics_handler [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_middlewares_metrics_handler [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_middlewares_request_id_handler [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_routes [fillcolor="#f9b506",minlen="2"]; aiohttp -> ahriman_web_routes [fillcolor="#f9b506",minlen="2"];
aiohttp -> ahriman_web_views_api_swagger [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_views_api_swagger [fillcolor="#f9b506",minlen="3"];
aiohttp -> ahriman_web_views_base [fillcolor="#f9b506",minlen="3"]; aiohttp -> ahriman_web_views_base [fillcolor="#f9b506",minlen="3"];

View File

@@ -92,6 +92,14 @@ ahriman.application.handlers.patch module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.application.handlers.pkgbuild module
--------------------------------------------
.. automodule:: ahriman.application.handlers.pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.rebuild module ahriman.application.handlers.rebuild module
------------------------------------------- -------------------------------------------

View File

@@ -28,6 +28,14 @@ ahriman.core.alpm.pacman\_database module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.alpm.pacman\_handle module
---------------------------------------
.. automodule:: ahriman.core.alpm.pacman_handle
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.alpm.pkgbuild\_parser module ahriman.core.alpm.pkgbuild\_parser module
----------------------------------------- -----------------------------------------

View File

@@ -140,6 +140,14 @@ ahriman.core.database.migrations.m016\_archive module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.database.migrations.m017\_pkgbuild module
------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m017_pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@@ -76,6 +76,14 @@ ahriman.core.formatters.patch\_printer module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.formatters.pkgbuild\_printer module
------------------------------------------------
.. automodule:: ahriman.core.formatters.pkgbuild_printer
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.printer module ahriman.core.formatters.printer module
-------------------------------------- --------------------------------------

View File

@@ -28,6 +28,14 @@ ahriman.core.log.lazy\_logging module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.log.log\_context module
------------------------------------
.. automodule:: ahriman.core.log.log_context
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.log.log\_loader module ahriman.core.log.log\_loader module
----------------------------------- -----------------------------------

View File

@@ -28,6 +28,14 @@ ahriman.web.middlewares.metrics\_handler module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.middlewares.request\_id\_handler module
---------------------------------------------------
.. automodule:: ahriman.web.middlewares.request_id_handler
:members:
:no-undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@@ -9,7 +9,8 @@ Packages have strict rules of importing:
* ``ahriman.application`` package must not be used outside of this package. * ``ahriman.application`` package must not be used outside of this package.
* ``ahriman.core`` and ``ahriman.models`` packages don't have any import restriction. Actually we would like to totally restrict importing of ``core`` package from ``models``, but it is impossible at the moment. * ``ahriman.core`` and ``ahriman.models`` packages don't have any import restriction. Actually we would like to totally restrict importing of ``core`` package from ``models``, but it is impossible at the moment.
* ``ahriman.web`` package is allowed to be imported from ``ahriman.application`` (web handler only, only ``ahriman.web.web`` methods). * ``ahriman.web`` package is allowed to be imported from ``ahriman.application`` (web handler only, only ``ahriman.web.web`` methods).
* The idea remains the same for all imports, if an package requires some specific dependencies, it must be imported locally to keep dependencies optional.
The idea remains the same for all imports, if a package requires some specific dependencies, it must be imported locally to keep dependencies optional.
Full dependency diagram: Full dependency diagram:
@@ -42,7 +43,7 @@ This package contains everything required for the most of application actions an
* ``ahriman.core.gitremote`` is a package with remote PKGBUILD triggers. Should not be called directly. * ``ahriman.core.gitremote`` is a package with remote PKGBUILD triggers. Should not be called directly.
* ``ahriman.core.housekeeping`` package provides few triggers for removing old data. * ``ahriman.core.housekeeping`` package provides few triggers for removing old data.
* ``ahriman.core.http`` package provides HTTP clients which can be used later by other classes. * ``ahriman.core.http`` package provides HTTP clients which can be used later by other classes.
* ``ahriman.core.log`` is a log utils package. It includes logger loader class, custom HTTP based logger and some wrappers. * ``ahriman.core.log`` is a log utils package. It includes logger loader class, custom HTTP based logger, log context for injecting context variables into log records and some wrappers.
* ``ahriman.core.report`` is a package with reporting triggers. Should not be called directly. * ``ahriman.core.report`` is a package with reporting triggers. Should not be called directly.
* ``ahriman.core.repository`` contains several traits and base repository (``ahriman.core.repository.Repository`` class) implementation. * ``ahriman.core.repository`` contains several traits and base repository (``ahriman.core.repository.Repository`` class) implementation.
* ``ahriman.core.sign`` package provides sign feature (only gpg calls are available). * ``ahriman.core.sign`` package provides sign feature (only gpg calls are available).
@@ -85,6 +86,7 @@ Application run
#. Call ``Handler.execute`` method. #. Call ``Handler.execute`` method.
#. Define list of architectures to run. In case if there is more than one architecture specified run several subprocesses or continue in current process otherwise. Class attribute ``ALLOW_MULTI_ARCHITECTURE_RUN`` controls whether the application can be run in multiple processes or not - this feature is required for some handlers (e.g. ``Config``, which utilizes stdout to print messages). #. Define list of architectures to run. In case if there is more than one architecture specified run several subprocesses or continue in current process otherwise. Class attribute ``ALLOW_MULTI_ARCHITECTURE_RUN`` controls whether the application can be run in multiple processes or not - this feature is required for some handlers (e.g. ``Config``, which utilizes stdout to print messages).
#. In each child process call lock functions. #. In each child process call lock functions.
#. Load configuration and install logging.
#. After success checks pass control to ``Handler.run`` method defined by specific handler class. #. After success checks pass control to ``Handler.run`` method defined by specific handler class.
#. Return result (success or failure) of each subprocess and exit from application. #. Return result (success or failure) of each subprocess and exit from application.
#. Some handlers may override their status and throw ``ExitCode`` exception. This exception is just silently suppressed and changes application exit code to ``1``. #. Some handlers may override their status and throw ``ExitCode`` exception. This exception is just silently suppressed and changes application exit code to ``1``.
@@ -159,12 +161,12 @@ Having default root as ``/var/lib/ahriman`` (differs from container though), the
├── aur.files -> aur.files.tar.gz ├── aur.files -> aur.files.tar.gz
└── aur.files.tar.gz └── aur.files.tar.gz
There are multiple subdirectories, some of them are commons for any repository, but some of them are not. There are multiple subdirectories, some of them are common for any repository, but some of them are not.
* ``archive`` is the package archive directory. It is common for all repositories and architectures and contains two subdirectories: * ``archive`` is the package archive directory. It is common for all repositories and architectures and contains two subdirectories:
* ``archive/packages/{first_letter}/{package_base}`` stores the actual built package files and their signatures. * ``archive/packages/{first_letter}/{package_base}`` stores the actual built package files and their signatures.
* ``archive/repos/{YYYY}/{MM}/{DD}/{repository}/{architecture}`` contains daily repository snapshots. Each snapshot is a repository database with symlinks pointing to the corresponding packages in the ``archive/packages`` tree. * ``archive/repos/{YYYY}/{MM}/{DD}/{repository}/{architecture}`` contains daily repository snapshots. Each snapshot is a repository database with symlinks pointing to the corresponding packages in the ``archive/packages`` tree. These directories only appear if ``ahriman.core.archive.ArchiveTrigger`` is enabled.
The archive also allows the build process to skip rebuilding a package if a matching version already exists. The archive also allows the build process to skip rebuilding a package if a matching version already exists.
@@ -239,9 +241,9 @@ Check outdated packages
There are few ways for packages to be marked as out-of-date and hence requiring rebuild. Those are following: There are few ways for packages to be marked as out-of-date and hence requiring rebuild. Those are following:
#. User requested update of the package. It can be caused by calling ``package-add`` subcommand (or ``package-update`` with arguments). #. User requested update of the package. It can be caused by calling ``package-add`` subcommand (or ``package-update`` with arguments).
#. The most common way for packages to be marked as out-of-dated is that the version in AUR (or the official repositories) is newer than in the repository. #. The most common way for packages to be marked as out-of-date is that the version in AUR (or the official repositories) is newer than in the repository.
#. In addition to the above, if package is named as VCS (e.g. has suffix ``-git``) and the last update was more than specified threshold ago, the service will also try to fetch sources and check if the revision is newer than the built one. #. In addition to the above, if package is named as VCS (e.g. has suffix ``-git``) and the last update was more than specified threshold ago, the service will also try to fetch sources and check if the revision is newer than the built one.
#. In addition, there is ability to check if the dependencies of the package have been updated (e.g. if linked library has been renamed or the modules directory - e.g. in case of python and ruby packages - has been changed). And if so, the package will be marked as out-of-dated as well. #. In addition, there is ability to check if the dependencies of the package have been updated (e.g. if linked library has been renamed or the modules directory - e.g. in case of python and ruby packages - has been changed). And if so, the package will be marked as out-of-date as well.
Update packages Update packages
^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^
@@ -255,6 +257,7 @@ This feature is divided into the following stages: check AUR for updates and run
#. Download package data from AUR. #. Download package data from AUR.
#. Bump ``pkgrel`` if there is duplicate version in the local repository (see explanation below). #. Bump ``pkgrel`` if there is duplicate version in the local repository (see explanation below).
#. Check if there is already built package of the same version in archive (cross-repository support). If so, then just copy built archives and skip steps below.
#. Build every package in clean chroot. #. Build every package in clean chroot.
#. Sign packages if required. #. Sign packages if required.
#. Add packages to database and sign database if required. #. Add packages to database and sign database if required.
@@ -313,7 +316,7 @@ Having the initial dependencies tree, the application is looking for packages wh
Those paths are also filtered by regular expressions set in the configuration. Those paths are also filtered by regular expressions set in the configuration.
All those implicit dependencies are stored in the database and extracted on each check. In case if any of the repository packages doesn't contain any entry anymore (e.g. so version has been changed or modules directory has been changed), the dependent package will be marked as out-of-dated. All those implicit dependencies are stored in the database and extracted on each check. In case if any of the repository packages doesn't contain any entry anymore (e.g. so version has been changed or modules directory has been changed), the dependent package will be marked as out-of-date.
Core functions reference Core functions reference
------------------------ ------------------------
@@ -344,6 +347,8 @@ The ``_Context`` class itself mimics default collection interface (as is ``Mappi
In order to provide statically typed interface, the ``ahriman.models.context_key.ContextKey`` class is used for both ``_Content.get`` and ``_Content.set`` methods; the context instance itself, however, does not store information about types. In order to provide statically typed interface, the ``ahriman.models.context_key.ContextKey`` class is used for both ``_Content.get`` and ``_Content.set`` methods; the context instance itself, however, does not store information about types.
Logging module has its own context variables, which are required to be registered in advance to avoid possible race conditions.
Submodules Submodules
^^^^^^^^^^ ^^^^^^^^^^
@@ -370,7 +375,7 @@ Passwords must be stored in database as ``hash(password + salt)``, where ``passw
means that there is user ``username`` with ``read`` access and password ``password`` hashed by ``sha512`` with salt ``salt``. means that there is user ``username`` with ``read`` access and password ``password`` hashed by ``sha512`` with salt ``salt``.
OAuth provider uses library definitions (``aioauth-client``) in order *authenticate* users. It still requires user permission to be set in database, thus it inherits mapping provider without any changes. Whereas we could override ``check_credentials`` (authentication method) by something custom, OAuth flow is a bit more complex than just forward request, thus we have to implement the flow in login form. OAuth provider uses library definitions (``aioauth-client``) in order to *authenticate* users. It still requires user permission to be set in database, thus it inherits mapping provider without any changes. Whereas we could override ``check_credentials`` (authentication method) by something custom, OAuth flow is a bit more complex than just forward request, thus we have to implement the flow in login form.
OAuth's implementation also allows authenticating users via username + password (in the same way as mapping does) though it is not recommended for end-users and password must be left blank. In particular this feature can be used by service reporting (aka robots). OAuth's implementation also allows authenticating users via username + password (in the same way as mapping does) though it is not recommended for end-users and password must be left blank. In particular this feature can be used by service reporting (aka robots).
@@ -383,7 +388,7 @@ Triggers
Triggers are extensions which can be used in order to perform any actions on application start, after the update process and, finally, before the application exit. Triggers are extensions which can be used in order to perform any actions on application start, after the update process and, finally, before the application exit.
The main idea is to load classes by their full path (e.g. ``ahriman.core.upload.UploadTrigger``) by using ``importlib``: get the last part of the import and treat it as class name, join remain part by ``.`` and interpret as module path, import module and extract attribute from it. The main idea is to load classes by their full path (e.g. ``ahriman.core.upload.UploadTrigger``) by using ``importlib``: get the last part of the import and treat it as class name, join the remaining part by ``.`` and interpret as module path, import module and extract attribute from it.
The loaded triggers will be called with ``ahriman.models.result.Result`` and ``list[Packages]`` arguments, which describes the process result and current repository packages respectively. Any exception raised will be suppressed and will generate an exception message in logs. The loaded triggers will be called with ``ahriman.models.result.Result`` and ``list[Packages]`` arguments, which describes the process result and current repository packages respectively. Any exception raised will be suppressed and will generate an exception message in logs.
@@ -407,7 +412,7 @@ PKGBUILD parsing
The application provides a house-made shell parser ``ahriman.core.alpm.pkgbuild_parser.PkgbuildParser`` to process PKGBUILDs and extract package data from them. It relies on the ``shlex.shlex`` parser with some configuration tweaks and adds some token post-processing. The application provides a house-made shell parser ``ahriman.core.alpm.pkgbuild_parser.PkgbuildParser`` to process PKGBUILDs and extract package data from them. It relies on the ``shlex.shlex`` parser with some configuration tweaks and adds some token post-processing.
#. During the parser process, firstly, it extract next token from the source file (basically, the word) and tries to match it to the variable assignment. If so, then just processes accordingly. #. During the parser process, firstly, it extracts the next token from the source file (basically, the word) and tries to match it to the variable assignment. If so, then just processes accordingly.
#. If it is not an assignment, the parser checks if the token was quoted. #. If it is not an assignment, the parser checks if the token was quoted.
#. If it wasn't quoted then the parser tries to match the array starts (two consecutive tokens like ``array=`` and ``(``) or it is function (``function``, ``()`` and ``{``). #. If it wasn't quoted then the parser tries to match the array starts (two consecutive tokens like ``array=`` and ``(``) or it is function (``function``, ``()`` and ``{``).
#. The arrays are processed until the next closing bracket ``)``. After extraction, the parser tries to expand an array according to bash rules (``prefix{first,second}suffix`` constructions). #. The arrays are processed until the next closing bracket ``)``. After extraction, the parser tries to expand an array according to bash rules (``prefix{first,second}suffix`` constructions).
@@ -420,6 +425,15 @@ The PKGBUILD class also provides some additional functions on top of that:
* Ability to extract fields defined inside ``package*()`` functions, which are in particular used for the multi-packages. * Ability to extract fields defined inside ``package*()`` functions, which are in particular used for the multi-packages.
* Shell substitution, which supports constructions ``$var`` (including ``${var}``), ``${var#(#)pattern}``, ``${var%(%)pattern}`` and ``${var/(/)pattern/replacement}`` (including ``#pattern`` and ``%pattern``). * Shell substitution, which supports constructions ``$var`` (including ``${var}``), ``${var#(#)pattern}``, ``${var%(%)pattern}`` and ``${var/(/)pattern/replacement}`` (including ``#pattern`` and ``%pattern``).
HTTP client
^^^^^^^^^^^
The ``ahriman.core.http`` package provides a HTTP client built on top of the ``requests`` library.
The base class ``ahriman.core.http.SyncHttpClient`` wraps ``requests.Session`` and provides common features for all HTTP interactions: configurable timeouts, retry policies with exponential backoff (using ``urllib3.util.retry.Retry``), basic authentication, custom User-Agent header, error processing, and ``make_request`` method. The session is lazily created (via ``cached_property``).
On top of that, ``ahriman.core.http.SyncAhrimanClient`` extends the base client for communication with the ahriman web service specifically. It adds automatic login on session creation (using configured credentials), ``X-Request-ID`` header injection and Unix socket transport support (via ``requests-unixsocket2``) if required.
Additional features Additional features
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
@@ -445,7 +459,7 @@ Web application requires the following python packages to be installed:
Middlewares Middlewares
^^^^^^^^^^^ ^^^^^^^^^^^
Service provides some custom middlewares, e.g. logging every exception (except for user ones) and user authorization. Service provides some custom middlewares, e.g. logging every exception (except for user ones), user authorization and request tracing via ``X-Request-ID`` header.
HEAD and OPTIONS requests HEAD and OPTIONS requests
^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -457,7 +471,7 @@ On the other side, ``OPTIONS`` method is implemented in the ``ahriman.web.middle
Web views Web views
^^^^^^^^^ ^^^^^^^^^
All web views are defined in separated package and derived from ``ahriman.web.views.base.Base`` class which provides typed interfaces for web application. All web views are defined in a separate package and derived from ``ahriman.web.views.base.Base`` class which provides typed interfaces for web application.
REST API supports only JSON data. REST API supports only JSON data.
@@ -476,7 +490,7 @@ The views are also divided by supporting API versions (e.g. ``v1``, ``v2``).
Templating Templating
^^^^^^^^^^ ^^^^^^^^^^
Package provides base jinja templates which can be overridden by settings. Vanilla templates actively use bootstrap library. Package provides base jinja templates which can be overridden by settings. The default web interface is a React application. The classic bootstrap-based template is still available as ``build-status-classic.jinja2`` and can be enabled via the ``web.template`` configuration option.
Requests and scopes Requests and scopes
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^

View File

@@ -27,21 +27,26 @@ export default tseslint.config(
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// imports // imports
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error", "simple-import-sort/exports": "error",
"simple-import-sort/imports": "error",
// brackets // core
"curly": "error", "curly": "error",
"@stylistic/brace-style": ["error", "1tbs"], "eqeqeq": "error",
"no-console": "error",
"no-eval": "error",
// stylistic // stylistic
"@stylistic/array-bracket-spacing": ["error", "never"], "@stylistic/array-bracket-spacing": ["error", "never"],
"@stylistic/arrow-parens": ["error", "as-needed"], "@stylistic/arrow-parens": ["error", "as-needed"],
"@stylistic/brace-style": ["error", "1tbs"],
"@stylistic/comma-dangle": ["error", "always-multiline"], "@stylistic/comma-dangle": ["error", "always-multiline"],
"@stylistic/comma-spacing": ["error", { before: false, after: true }], "@stylistic/comma-spacing": ["error", { before: false, after: true }],
"@stylistic/eol-last": ["error", "always"], "@stylistic/eol-last": ["error", "always"],
"@stylistic/indent": ["error", 4], "@stylistic/indent": ["error", 4],
"@stylistic/jsx-curly-brace-presence": ["error", { props: "never", children: "never" }],
"@stylistic/jsx-quotes": ["error", "prefer-double"], "@stylistic/jsx-quotes": ["error", "prefer-double"],
"@stylistic/jsx-self-closing-comp": ["error", { component: true, html: true }],
"@stylistic/max-len": ["error", { "@stylistic/max-len": ["error", {
code: 120, code: 120,
ignoreComments: true, ignoreComments: true,
@@ -49,6 +54,7 @@ export default tseslint.config(
ignoreTemplateLiterals: true, ignoreTemplateLiterals: true,
ignoreUrls: true, ignoreUrls: true,
}], }],
"@stylistic/member-delimiter-style": ["error", { multiline: { delimiter: "semi" }, singleline: { delimiter: "semi" } }],
"@stylistic/no-extra-parens": ["error", "all"], "@stylistic/no-extra-parens": ["error", "all"],
"@stylistic/no-multi-spaces": "error", "@stylistic/no-multi-spaces": "error",
"@stylistic/no-multiple-empty-lines": ["error", { max: 1 }], "@stylistic/no-multiple-empty-lines": ["error", { max: 1 }],
@@ -58,10 +64,14 @@ export default tseslint.config(
"@stylistic/semi": ["error", "always"], "@stylistic/semi": ["error", "always"],
// typescript // typescript
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }], "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }], "@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
"@typescript-eslint/no-deprecated": "error", "@typescript-eslint/no-deprecated": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
}, },
}, },
); );

View File

@@ -2,7 +2,7 @@
"name": "ahriman-frontend", "name": "ahriman-frontend",
"private": true, "private": true,
"type": "module", "type": "module",
"version": "2.20.0-rc4", "version": "2.20.0",
"scripts": { "scripts": {
"build": "tsc && vite build", "build": "tsc && vite build",
"dev": "vite", "dev": "vite",
@@ -11,30 +11,30 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8", "@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.8", "@mui/material": "^7.3.9",
"@mui/x-data-grid": "^8.27.3", "@mui/x-data-grid": "^8.27.4",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.90.21",
"chart.js": "^4.5.0", "chart.js": "^4.5.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-syntax-highlighter": "^16.1.1" "react-syntax-highlighter": "^16.1.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin": "^5.9.0", "@stylistic/eslint-plugin": "^5.10.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.39.3", "eslint": "^9.39.3",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"typescript": "^5.3.0", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-tsconfig-paths": "^6.1.1" "vite-tsconfig-paths": "^6.1.1"

View File

@@ -17,16 +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/>.
*/ */
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AppLayout from "components/layout/AppLayout"; import AppLayout from "components/layout/AppLayout";
import { AuthProvider } from "contexts/AuthProvider"; import { AuthProvider } from "contexts/AuthProvider";
import { ClientProvider } from "contexts/ClientProvider"; import { ClientProvider } from "contexts/ClientProvider";
import { NotificationProvider } from "contexts/NotificationProvider"; import { NotificationProvider } from "contexts/NotificationProvider";
import { RepositoryProvider } from "contexts/RepositoryProvider"; import { RepositoryProvider } from "contexts/RepositoryProvider";
import { ThemeProvider } from "contexts/ThemeProvider";
import type React from "react"; import type React from "react";
import Theme from "theme/Theme";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -39,8 +37,7 @@ const queryClient = new QueryClient({
export default function App(): React.JSX.Element { export default function App(): React.JSX.Element {
return <QueryClientProvider client={queryClient}> return <QueryClientProvider client={queryClient}>
<ThemeProvider theme={Theme}> <ThemeProvider>
<CssBaseline />
<NotificationProvider> <NotificationProvider>
<ClientProvider> <ClientProvider>
<AuthProvider> <AuthProvider>

View File

@@ -40,6 +40,7 @@ export class Client {
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: "application/json", Accept: "application/json",
"X-Request-ID": crypto.randomUUID(),
}; };
if (json !== undefined) { if (json !== undefined) {
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
@@ -49,7 +50,7 @@ export class Client {
const timeoutId = setTimeout(() => controller.abort(), timeout); const timeoutId = setTimeout(() => controller.abort(), timeout);
const requestInit: RequestInit = { const requestInit: RequestInit = {
method: method || (json ? "POST" : "GET"), method: method ?? (json ? "POST" : "GET"),
headers, headers,
signal: controller.signal, signal: controller.signal,
}; };

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 { blue } from "@mui/material/colors";
import type { Event } from "models/Event"; import type { Event } from "models/Event";
import type React from "react"; import type React from "react";
import { Line } from "react-chartjs-2"; import { Line } from "react-chartjs-2";
@@ -33,6 +34,8 @@ export default function EventDurationLineChart({ events }: EventDurationLineChar
{ {
label: "update duration, s", label: "update duration, s",
data: updateEvents.map(event => event.data?.took ?? 0), data: updateEvents.map(event => event.data?.took ?? 0),
borderColor: blue[500],
backgroundColor: blue[200],
cubicInterpolationMode: "monotone" as const, cubicInterpolationMode: "monotone" as const,
tension: 0.4, tension: 0.4,
}, },

View File

@@ -31,16 +31,16 @@ export default function PackageCountBarChart({ stats }: PackageCountBarChartProp
data={{ data={{
labels: ["packages"], labels: ["packages"],
datasets: [ datasets: [
{
label: "archives",
data: [stats.packages ?? 0],
backgroundColor: blue[500],
},
{ {
label: "bases", label: "bases",
data: [stats.bases ?? 0], data: [stats.bases ?? 0],
backgroundColor: indigo[300], backgroundColor: indigo[300],
}, },
{
label: "archives",
data: [stats.packages ?? 0],
backgroundColor: blue[500],
},
], ],
}} }}
options={{ options={{
@@ -48,7 +48,7 @@ export default function PackageCountBarChart({ stats }: PackageCountBarChartProp
responsive: true, responsive: true,
scales: { scales: {
x: { stacked: true }, x: { stacked: true },
y: { stacked: true }, y: { stacked: false },
}, },
}} }}
/>; />;

View File

@@ -47,6 +47,7 @@ export default function AutoRefreshControl({
<Tooltip title="Auto-refresh"> <Tooltip title="Auto-refresh">
<IconButton <IconButton
size="small" size="small"
aria-label="Auto-refresh"
onClick={event => setAnchorEl(event.currentTarget)} onClick={event => setAnchorEl(event.currentTarget)}
color={enabled ? "primary" : "default"} color={enabled ? "primary" : "default"}
> >

View File

@@ -17,47 +17,57 @@
* 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 { Box } from "@mui/material"; import "components/common/syntaxLanguages";
import { Box, useTheme } from "@mui/material";
import CopyButton from "components/common/CopyButton"; import CopyButton from "components/common/CopyButton";
import { useThemeMode } from "hooks/useThemeMode";
import React, { type RefObject } from "react"; import React, { type RefObject } from "react";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { githubGist, vs2015 } from "react-syntax-highlighter/dist/esm/styles/hljs";
interface CodeBlockProps { interface CodeBlockProps {
preRef?: RefObject<HTMLElement | null>; content: string;
getText: () => string;
height?: number | string; height?: number | string;
language?: string;
onScroll?: () => void; onScroll?: () => void;
wordBreak?: boolean; preRef?: RefObject<HTMLElement | null>;
} }
export default function CodeBlock({ export default function CodeBlock({
preRef, content,
getText,
height, height,
language = "text",
onScroll, onScroll,
wordBreak, preRef,
}: CodeBlockProps): React.JSX.Element { }: CodeBlockProps): React.JSX.Element {
const { mode } = useThemeMode();
const theme = useTheme();
return <Box sx={{ position: "relative" }}> return <Box sx={{ position: "relative" }}>
<Box <Box
ref={preRef} ref={preRef}
component="pre"
onScroll={onScroll} onScroll={onScroll}
sx={{ sx={{ overflow: "auto", height }}
backgroundColor: "grey.100", >
p: 2, <SyntaxHighlighter
borderRadius: 1, language={language}
overflow: "auto", style={mode === "dark" ? vs2015 : githubGist}
height, wrapLongLines
customStyle={{
padding: theme.spacing(2),
borderRadius: `${theme.shape.borderRadius}px`,
fontSize: "0.8rem", fontSize: "0.8rem",
fontFamily: "monospace", fontFamily: "monospace",
...wordBreak ? { whiteSpace: "pre-wrap", wordBreak: "break-all" } : {}, margin: 0,
minHeight: "100%",
}} }}
> >
<code> {content}
{getText()} </SyntaxHighlighter>
</code>
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={getText} />
</Box> </Box>
{content && <Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton text={content} />
</Box>}
</Box>; </Box>;
} }

View File

@@ -23,17 +23,17 @@ import { IconButton, Tooltip } from "@mui/material";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
interface CopyButtonProps { interface CopyButtonProps {
getText: () => string; text: string;
} }
export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element { export default function CopyButton({ text }: CopyButtonProps): React.JSX.Element {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined); const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []); useEffect(() => () => clearTimeout(timer.current), []);
const handleCopy: () => Promise<void> = async () => { const handleCopy: () => Promise<void> = async () => {
await navigator.clipboard.writeText(getText()); await navigator.clipboard.writeText(text);
setCopied(true); setCopied(true);
clearTimeout(timer.current); clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 2000); timer.current = setTimeout(() => setCopied(false), 2000);

View File

@@ -25,12 +25,12 @@ import type React from "react";
export default function RepositorySelect({ export default function RepositorySelect({
repositorySelect, repositorySelect,
}: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element { }: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element {
const { repositories, current } = useRepository(); const { repositories, currentRepository } = useRepository();
return <FormControl fullWidth margin="normal"> return <FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel> <InputLabel>repository</InputLabel>
<Select <Select
value={repositorySelect.selectedKey || (current?.key ?? "")} value={repositorySelect.selectedKey || (currentRepository?.key ?? "")}
label="repository" label="repository"
onChange={event => repositorySelect.setSelectedKey(event.target.value)} onChange={event => repositorySelect.setSelectedKey(event.target.value)}
> >

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2021-2026 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 { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import bash from "react-syntax-highlighter/dist/esm/languages/hljs/bash";
import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff";
import plaintext from "react-syntax-highlighter/dist/esm/languages/hljs/plaintext";
SyntaxHighlighter.registerLanguage("bash", bash);
SyntaxHighlighter.registerLanguage("diff", diff);
SyntaxHighlighter.registerLanguage("text", plaintext);

View File

@@ -36,11 +36,11 @@ interface DashboardDialogProps {
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element { export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { current } = useRepository(); const { currentRepository } = useRepository();
const { data: status } = useQuery<InternalStatus>({ const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"], queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken, queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
enabled: open, enabled: open,
}); });
@@ -86,12 +86,12 @@ export default function DashboardDialog({ open, onClose }: DashboardDialogProps)
<Grid container spacing={2} sx={{ mt: 2 }}> <Grid container spacing={2} sx={{ mt: 2 }}>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ maxHeight: 300 }}> <Box sx={{ height: 300 }}>
<PackageCountBarChart stats={status.stats} /> <PackageCountBarChart stats={status.stats} />
</Box> </Box>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ maxHeight: 300, display: "flex", justifyContent: "center", alignItems: "center" }}> <Box sx={{ height: 300, display: "flex", justifyContent: "center", alignItems: "center" }}>
<StatusPieChart counters={status.packages} /> <StatusPieChart counters={status.packages} />
</Box> </Box>
</Grid> </Grid>

View File

@@ -105,7 +105,7 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
/> />
{keyBody && {keyBody &&
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<CodeBlock getText={() => keyBody} height={300} /> <CodeBlock content={keyBody} height={300} />
</Box> </Box>
} }
</DialogContent> </DialogContent>

View File

@@ -27,6 +27,8 @@ import EventsTab from "components/package/EventsTab";
import PackageDetailsGrid from "components/package/PackageDetailsGrid"; import PackageDetailsGrid from "components/package/PackageDetailsGrid";
import PackageInfoActions from "components/package/PackageInfoActions"; import PackageInfoActions from "components/package/PackageInfoActions";
import PackagePatchesList from "components/package/PackagePatchesList"; import PackagePatchesList from "components/package/PackagePatchesList";
import PkgbuildTab from "components/package/PkgbuildTab";
import { type TabKey, tabs } from "components/package/TabKey";
import { QueryKeys } from "hooks/QueryKeys"; import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth"; import { useAuth } from "hooks/useAuth";
import { useAutoRefresh } from "hooks/useAutoRefresh"; import { useAutoRefresh } from "hooks/useAutoRefresh";
@@ -55,7 +57,7 @@ export default function PackageInfoDialog({
autoRefreshIntervals, autoRefreshIntervals,
}: PackageInfoDialogProps): React.JSX.Element { }: PackageInfoDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { current } = useRepository(); const { currentRepository } = useRepository();
const { isAuthorized } = useAuth(); const { isAuthorized } = useAuth();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -65,11 +67,11 @@ export default function PackageInfoDialog({
setLocalPackageBase(packageBase); setLocalPackageBase(packageBase);
} }
const [tabIndex, setTabIndex] = useState(0); const [activeTab, setActiveTab] = useState<TabKey>("logs");
const [refreshDatabase, setRefreshDatabase] = useState(true); const [refreshDatabase, setRefreshDatabase] = useState(true);
const handleClose = (): void => { const handleClose = (): void => {
setTabIndex(0); setActiveTab("logs");
setRefreshDatabase(true); setRefreshDatabase(true);
onClose(); onClose();
}; };
@@ -77,16 +79,17 @@ export default function PackageInfoDialog({
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals)); const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packageData } = useQuery<PackageStatus[]>({ const { data: packageData } = useQuery<PackageStatus[]>({
queryKey: localPackageBase && current ? QueryKeys.package(localPackageBase, current) : ["packages"], queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
queryFn: localPackageBase && current ? () => client.fetch.fetchPackage(localPackageBase, current) : skipToken, queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
enabled: open, enabled: open,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: dependencies } = useQuery<Dependencies>({ const { data: dependencies } = useQuery<Dependencies>({
queryKey: localPackageBase && current ? QueryKeys.dependencies(localPackageBase, current) : ["dependencies"], queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"],
queryFn: localPackageBase && current queryFn: localPackageBase && currentRepository ?
? () => client.fetch.fetchPackageDependencies(localPackageBase, current) : skipToken, () => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken,
enabled: open, enabled: open,
}); });
@@ -102,11 +105,12 @@ export default function PackageInfoDialog({
const headerStyle = status ? StatusHeaderStyles[status.status] : {}; const headerStyle = status ? StatusHeaderStyles[status.status] : {};
const handleUpdate: () => Promise<void> = async () => { const handleUpdate: () => Promise<void> = async () => {
if (!localPackageBase || !current) { if (!localPackageBase || !currentRepository) {
return; return;
} }
try { try {
await client.service.servicePackageAdd(current, { packages: [localPackageBase], refresh: refreshDatabase }); await client.service.servicePackageAdd(
currentRepository, { packages: [localPackageBase], refresh: refreshDatabase });
showSuccess("Success", `Run update for packages ${localPackageBase}`); showSuccess("Success", `Run update for packages ${localPackageBase}`);
} catch (exception) { } catch (exception) {
showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`); showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`);
@@ -114,11 +118,11 @@ export default function PackageInfoDialog({
}; };
const handleRemove: () => Promise<void> = async () => { const handleRemove: () => Promise<void> = async () => {
if (!localPackageBase || !current) { if (!localPackageBase || !currentRepository) {
return; return;
} }
try { try {
await client.service.servicePackageRemove(current, [localPackageBase]); await client.service.servicePackageRemove(currentRepository, [localPackageBase]);
showSuccess("Success", `Packages ${localPackageBase} have been removed`); showSuccess("Success", `Packages ${localPackageBase} have been removed`);
onClose(); onClose();
} catch (exception) { } catch (exception) {
@@ -156,25 +160,26 @@ export default function PackageInfoDialog({
/> />
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}> <Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
<Tabs value={tabIndex} onChange={(_, index: number) => setTabIndex(index)}> <Tabs value={activeTab} onChange={(_, tab: TabKey) => setActiveTab(tab)}>
<Tab label="Build logs" /> {tabs.map(({ key, label }) => <Tab key={key} value={key} label={label} />)}
<Tab label="Changes" />
<Tab label="Events" />
</Tabs> </Tabs>
</Box> </Box>
{tabIndex === 0 && localPackageBase && current && {activeTab === "logs" && localPackageBase && currentRepository &&
<BuildLogsTab <BuildLogsTab
packageBase={localPackageBase} packageBase={localPackageBase}
repository={current} repository={currentRepository}
refreshInterval={autoRefresh.interval} refreshInterval={autoRefresh.interval}
/> />
} }
{tabIndex === 1 && localPackageBase && current && {activeTab === "changes" && localPackageBase && currentRepository &&
<ChangesTab packageBase={localPackageBase} repository={current} /> <ChangesTab packageBase={localPackageBase} repository={currentRepository} />
} }
{tabIndex === 2 && localPackageBase && current && {activeTab === "pkgbuild" && localPackageBase && currentRepository &&
<EventsTab packageBase={localPackageBase} repository={current} /> <PkgbuildTab packageBase={localPackageBase} repository={currentRepository} />
}
{activeTab === "events" && localPackageBase && currentRepository &&
<EventsTab packageBase={localPackageBase} repository={currentRepository} />
} }
</> </>
} }

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/>.
*/ */
import { Box, Container } from "@mui/material"; import Brightness4Icon from "@mui/icons-material/Brightness4";
import Brightness7Icon from "@mui/icons-material/Brightness7";
import { Box, Container, IconButton, Tooltip } from "@mui/material";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import LoginDialog from "components/dialogs/LoginDialog"; import LoginDialog from "components/dialogs/LoginDialog";
import Footer from "components/layout/Footer"; import Footer from "components/layout/Footer";
@@ -27,6 +29,7 @@ import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth"; import { useAuth } from "hooks/useAuth";
import { useClient } from "hooks/useClient"; import { useClient } from "hooks/useClient";
import { useRepository } from "hooks/useRepository"; import { useRepository } from "hooks/useRepository";
import { useThemeMode } from "hooks/useThemeMode";
import type { InfoResponse } from "models/InfoResponse"; import type { InfoResponse } from "models/InfoResponse";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
@@ -34,6 +37,7 @@ export default function AppLayout(): React.JSX.Element {
const client = useClient(); const client = useClient();
const { setAuthState } = useAuth(); const { setAuthState } = useAuth();
const { setRepositories } = useRepository(); const { setRepositories } = useRepository();
const { mode, toggleTheme } = useThemeMode();
const [loginOpen, setLoginOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false);
const { data: info } = useQuery<InfoResponse>({ const { data: info } = useQuery<InfoResponse>({
@@ -52,12 +56,17 @@ export default function AppLayout(): React.JSX.Element {
return <Container maxWidth="xl"> return <Container maxWidth="xl">
<Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}>
<a href="https://github.com/arcan1s/ahriman" title="logo"> <a href="https://ahriman.readthedocs.io/" title="logo">
<img src="/static/logo.svg" width={30} height={30} alt="" /> <img src="/static/logo.svg" width={30} height={30} alt="" />
</a> </a>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Navbar /> <Navbar />
</Box> </Box>
<Tooltip title="Toggle theme">
<IconButton aria-label="Toggle theme" onClick={toggleTheme}>
{mode === "dark" ? <Brightness7Icon /> : <Brightness4Icon />}
</IconButton>
</Tooltip>
</Box> </Box>
<PackageTable <PackageTable

View File

@@ -22,14 +22,15 @@ import { useRepository } from "hooks/useRepository";
import type React from "react"; import type React from "react";
export default function Navbar(): React.JSX.Element | null { export default function Navbar(): React.JSX.Element | null {
const { repositories, current, setCurrent } = useRepository(); const { repositories, currentRepository, setCurrentRepository } = useRepository();
if (repositories.length === 0 || !current) { if (repositories.length === 0 || !currentRepository) {
return null; return null;
} }
const currentIndex = repositories.findIndex(repository => const currentIndex = repositories.findIndex(repository =>
repository.architecture === current.architecture && repository.repository === current.repository, repository.architecture === currentRepository.architecture &&
repository.repository === currentRepository.repository,
); );
return <Box sx={{ borderBottom: 1, borderColor: "divider" }}> return <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
@@ -38,7 +39,7 @@ export default function Navbar(): React.JSX.Element | null {
onChange={(_, newValue: number) => { onChange={(_, newValue: number) => {
const repository = repositories[newValue]; const repository = repositories[newValue];
if (repository) { if (repository) {
setCurrent(repository); setCurrentRepository(repository);
} }
}} }}
variant="scrollable" variant="scrollable"

View File

@@ -175,10 +175,9 @@ export default function BuildLogsTab({
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<CodeBlock <CodeBlock
preRef={preRef} preRef={preRef}
getText={() => displayedLogs} content={displayedLogs}
height={400} height={400}
onScroll={handleScroll} onScroll={handleScroll}
wordBreak
/> />
</Box> </Box>
</Box>; </Box>;

View File

@@ -17,19 +17,10 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Box } from "@mui/material"; import CodeBlock from "components/common/CodeBlock";
import { useQuery } from "@tanstack/react-query"; import { usePackageChanges } from "hooks/usePackageChanges";
import CopyButton from "components/common/CopyButton";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Changes } from "models/Changes";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
import React from "react"; import React from "react";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff";
import { githubGist } from "react-syntax-highlighter/dist/esm/styles/hljs";
SyntaxHighlighter.registerLanguage("diff", diff);
interface ChangesTabProps { interface ChangesTabProps {
packageBase: string; packageBase: string;
@@ -37,34 +28,7 @@ interface ChangesTabProps {
} }
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element { export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
const client = useClient(); const data = usePackageChanges(packageBase, repository);
const { data } = useQuery<Changes>({ return <CodeBlock language="diff" content={data?.changes ?? ""} height={400} />;
queryKey: QueryKeys.changes(packageBase, repository),
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
enabled: !!packageBase,
});
const changesText = data?.changes ?? "";
return <Box sx={{ position: "relative", mt: 1 }}>
<SyntaxHighlighter
language="diff"
style={githubGist}
customStyle={{
padding: "16px",
borderRadius: "4px",
overflow: "auto",
height: 400,
fontSize: "0.8rem",
fontFamily: "monospace",
margin: 0,
}}
>
{changesText}
</SyntaxHighlighter>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={() => changesText} />
</Box>
</Box>;
} }

View File

@@ -55,7 +55,7 @@ export default function PackagePatchesList({
sx={{ flex: 1 }} sx={{ flex: 1 }}
/> />
{editable && {editable &&
<IconButton size="small" color="error" onClick={() => onDelete(patch.key)}> <IconButton size="small" color="error" aria-label="Remove patch" onClick={() => onDelete(patch.key)}>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
} }

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2021-2026 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 CodeBlock from "components/common/CodeBlock";
import { usePackageChanges } from "hooks/usePackageChanges";
import type { RepositoryId } from "models/RepositoryId";
import React from "react";
interface PkgbuildTabProps {
packageBase: string;
repository: RepositoryId;
}
export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element {
const data = usePackageChanges(packageBase, repository);
return <CodeBlock language="bash" content={data?.pkgbuild ?? ""} height={400} />;
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2021-2026 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/>.
*/
export type TabKey = "logs" | "changes" | "pkgbuild" | "events";
export const tabs: { key: TabKey; label: string }[] = [
{ key: "logs", label: "Build logs" },
{ key: "changes", label: "Changes" },
{ key: "pkgbuild", label: "PKGBUILD" },
{ key: "events", label: "Events" },
];

View File

@@ -22,9 +22,9 @@ import { createContext } from "react";
export interface RepositoryContextValue { export interface RepositoryContextValue {
repositories: RepositoryId[]; repositories: RepositoryId[];
current: RepositoryId | null; currentRepository: RepositoryId | null;
setRepositories: (repositories: RepositoryId[]) => void; setRepositories: (repositories: RepositoryId[]) => void;
setCurrent: (repository: RepositoryId) => void; setCurrentRepository: (repository: RepositoryId) => void;
} }
export const RepositoryContext = createContext<RepositoryContextValue | null>(null); export const RepositoryContext = createContext<RepositoryContextValue | null>(null);

View File

@@ -34,20 +34,20 @@ export function RepositoryProvider({ children }: { children: ReactNode }): React
const [repositories, setRepositories] = useState<RepositoryId[]>([]); const [repositories, setRepositories] = useState<RepositoryId[]>([]);
const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot); const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
const current = useMemo(() => { const currentRepository = useMemo(() => {
if (repositories.length === 0) { if (repositories.length === 0) {
return null; return null;
} }
return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null; return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null;
}, [repositories, hash]); }, [repositories, hash]);
const setCurrent = useCallback((repository: RepositoryId) => { const setCurrentRepository = useCallback((repository: RepositoryId) => {
window.location.hash = repository.key; window.location.hash = repository.key;
}, []); }, []);
const value = useMemo(() => ({ const value = useMemo(() => ({
repositories, current, setRepositories, setCurrent, repositories, currentRepository, setRepositories, setCurrentRepository,
}), [repositories, current, setCurrent]); }), [repositories, currentRepository, setCurrentRepository]);
return <RepositoryContext.Provider value={value}>{children}</RepositoryContext.Provider>; return <RepositoryContext.Provider value={value}>{children}</RepositoryContext.Provider>;
} }

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2021-2026 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 { createContext } from "react";
export interface ThemeContextValue {
mode: "light" | "dark";
toggleTheme: () => void;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);

View File

@@ -0,0 +1,56 @@
/*
* Copyright (c) 2021-2026 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 CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider as MuiThemeProvider } from "@mui/material/styles";
import { defaults as chartDefaults } from "chart.js";
import { ThemeContext } from "contexts/ThemeContext";
import { useLocalStorage } from "hooks/useLocalStorage";
import React, { useCallback, useEffect, useMemo } from "react";
import { createAppTheme } from "theme/Theme";
function systemPreference(): "light" | "dark" {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
const [mode, setMode] = useLocalStorage<"light" | "dark">("theme-mode", systemPreference());
const toggleTheme = useCallback(() => {
setMode(prev => prev === "light" ? "dark" : "light");
}, [setMode]);
const theme = useMemo(() => createAppTheme(mode), [mode]);
useEffect(() => {
const textColor = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)";
const gridColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
chartDefaults.color = textColor;
chartDefaults.borderColor = gridColor;
}, [mode]);
const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]);
return <ThemeContext.Provider value={value}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
</ThemeContext.Provider>;
}

View File

@@ -37,7 +37,7 @@ export function usePackageActions(
setSelectionModel: (model: string[]) => void, setSelectionModel: (model: string[]) => void,
): UsePackageActionsResult { ): UsePackageActionsResult {
const client = useClient(); const client = useClient();
const { current } = useRepository(); const { currentRepository } = useRepository();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -50,13 +50,13 @@ export function usePackageActions(
action: (repository: RepositoryId) => Promise<string>, action: (repository: RepositoryId) => Promise<string>,
errorMessage: string, errorMessage: string,
): Promise<void> => { ): Promise<void> => {
if (!current) { if (!currentRepository) {
return; return;
} }
try { try {
const successMessage = await action(current); const successMessage = await action(currentRepository);
showSuccess("Success", successMessage); showSuccess("Success", successMessage);
invalidate(current); invalidate(currentRepository);
setSelectionModel([]); setSelectionModel([]);
} catch (exception) { } catch (exception) {
showError("Action failed", `${errorMessage}: ${ApiError.errorDetail(exception)}`); showError("Action failed", `${errorMessage}: ${ApiError.errorDetail(exception)}`);
@@ -64,8 +64,8 @@ export function usePackageActions(
}; };
const handleReload: () => void = () => { const handleReload: () => void = () => {
if (current !== null) { if (currentRepository !== null) {
invalidate(current); invalidate(currentRepository);
} }
}; };

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2021-2026 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 { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Changes } from "models/Changes";
import type { RepositoryId } from "models/RepositoryId";
export function usePackageChanges(packageBase: string, repository: RepositoryId): Changes | undefined {
const client = useClient();
const { data } = useQuery<Changes>({
queryKey: QueryKeys.changes(packageBase, repository),
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
enabled: !!packageBase,
});
return data;
}

View File

@@ -39,20 +39,20 @@ export interface UsePackageDataResult {
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult { export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
const client = useClient(); const client = useClient();
const { current } = useRepository(); const { currentRepository } = useRepository();
const { isAuthorized } = useAuth(); const { isAuthorized } = useAuth();
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals)); const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packages = [], isLoading } = useQuery({ const { data: packages = [], isLoading } = useQuery({
queryKey: current ? QueryKeys.packages(current) : ["packages"], queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
queryFn: current ? () => client.fetch.fetchPackages(current) : skipToken, queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: status } = useQuery({ const { data: status } = useQuery({
queryKey: current ? QueryKeys.status(current) : ["status"], queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken, queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });

View File

@@ -29,10 +29,10 @@ export interface SelectedRepositoryResult {
} }
export function useSelectedRepository(): SelectedRepositoryResult { export function useSelectedRepository(): SelectedRepositoryResult {
const { repositories, current } = useRepository(); const { repositories, currentRepository } = useRepository();
const [selectedKey, setSelectedKey] = useState(""); const [selectedKey, setSelectedKey] = useState("");
let selectedRepository: RepositoryId | null = current; let selectedRepository: RepositoryId | null = currentRepository;
if (selectedKey) { if (selectedKey) {
const repository = repositories.find(repository => repository.key === selectedKey); const repository = repositories.find(repository => repository.key === selectedKey);
if (repository) { if (repository) {

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2021-2026 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 { ThemeContext, type ThemeContextValue } from "contexts/ThemeContext";
import { useContextNotNull } from "hooks/useContextNotNull";
export function useThemeMode(): ThemeContextValue {
return useContextNotNull(ThemeContext);
}

View File

@@ -20,4 +20,5 @@
export interface Changes { export interface Changes {
changes?: string; changes?: string;
last_commit_sha?: string; last_commit_sha?: string;
pkgbuild?: string;
} }

View File

@@ -21,15 +21,15 @@ import { amber, green, grey, orange, red } from "@mui/material/colors";
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
const base: Record<BuildStatus, string> = { const base: Record<BuildStatus, string> = {
unknown: grey[800], unknown: grey[600],
pending: amber[900], pending: amber[700],
building: orange[900], building: orange[800],
failed: red[900], failed: red[700],
success: green[800], success: green[700],
}; };
const headerBase: Record<BuildStatus, string> = { const headerBase: Record<BuildStatus, string> = {
unknown: grey[800], unknown: grey[600],
pending: amber[700], pending: amber[700],
building: orange[600], building: orange[600],
failed: red[500], failed: red[500],

View File

@@ -17,9 +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/>.
*/ */
import { createTheme } from "@mui/material/styles"; import { createTheme, type Theme } from "@mui/material/styles";
const Theme = createTheme({ export function createAppTheme(mode: "light" | "dark"): Theme {
return createTheme({
palette: {
mode,
},
components: { components: {
MuiDialog: { MuiDialog: {
defaultProps: { defaultProps: {
@@ -28,6 +32,5 @@ const Theme = createTheme({
}, },
}, },
}, },
}); });
}
export default Theme;

View File

@@ -1,22 +1,22 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "path"; import path from "path";
import { defineConfig, type Plugin } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
function renameHtml(newName: string): Plugin { function rename(oldName: string, newName: string): Plugin {
return { return {
name: "rename-html", name: "rename",
enforce: "post", enforce: "post",
generateBundle(_, bundle) { generateBundle(_, bundle) {
if (bundle["index.html"]) { if (bundle[oldName]) {
bundle["index.html"].fileName = newName; bundle[oldName].fileName = newName;
} }
}, },
}; };
} }
export default defineConfig({ export default defineConfig({
plugins: [react(), tsconfigPaths(), renameHtml("build-status.jinja2")], plugins: [react(), tsconfigPaths(), rename("index.html", "build-status.jinja2")],
base: "/", base: "/",
build: { build: {
chunkSizeWarningLimit: 10000, chunkSizeWarningLimit: 10000,

View File

@@ -2,7 +2,7 @@
pkgbase='ahriman' pkgbase='ahriman'
pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web') pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web')
pkgver=2.20.0rc5 pkgver=2.20.0
pkgrel=1 pkgrel=1
pkgdesc="ArcH linux ReposItory MANager" pkgdesc="ArcH linux ReposItory MANager"
arch=('any') arch=('any')
@@ -17,11 +17,10 @@ source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgbase-$
build() { build() {
cd "$pkgbase-$pkgver" cd "$pkgbase-$pkgver"
python -m build --wheel --no-isolation npm --prefix "frontend" install --cache "$srcdir/npm-cache"
npm --prefix "frontend" run build
cd "frontend" python -m build --wheel --no-isolation
npm install --cache "$srcdir/npm-cache"
npm run build
} }
package_ahriman() { package_ahriman() {

View File

@@ -26,10 +26,12 @@ formatter = syslog_format
args = ("/dev/log",) args = ("/dev/log",)
[formatter_generic_format] [formatter_generic_format]
format = [%(levelname)s %(asctime)s] [%(name)s]: %(message)s format = [{levelname} {asctime}] [{name}]: {message}
style = {
[formatter_syslog_format] [formatter_syslog_format]
format = [%(levelname)s] [%(name)s]: %(message)s format = [{levelname}] [{name}]: {message}
style = {
[logger_root] [logger_root]
level = DEBUG level = DEBUG

View File

@@ -1,4 +1,4 @@
.TH AHRIMAN "1" "2026\-03\-06" "ahriman 2.20.0rc5" "ArcH linux ReposItory MANager" .TH AHRIMAN "1" "2026\-03\-08" "ahriman 2.20.0" "ArcH linux ReposItory MANager"
.SH NAME .SH NAME
ahriman \- ArcH linux ReposItory MANager ahriman \- ArcH linux ReposItory MANager
.SH SYNOPSIS .SH SYNOPSIS

View File

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

View File

@@ -45,7 +45,7 @@ class ApplicationRepository(ApplicationProperties):
if last_commit_sha is None: if last_commit_sha is None:
continue # skip check in case if we can't calculate diff continue # skip check in case if we can't calculate diff
changes = self.repository.package_changes(package, last_commit_sha) if (changes := self.repository.package_changes(package, last_commit_sha)) is not None:
self.repository.reporter.package_changes_update(package.base, changes) self.repository.reporter.package_changes_update(package.base, changes)
def clean(self, *, cache: bool, chroot: bool, manual: bool, packages: bool, pacman: bool) -> None: def clean(self, *, cache: bool, chroot: bool, manual: bool, packages: bool, pacman: bool) -> None:

View File

@@ -54,7 +54,7 @@ class Change(Handler):
case Action.List: case Action.List:
changes = client.package_changes_get(args.package) changes = client.package_changes_get(args.package)
ChangesPrinter(changes)(verbose=True, separator="") ChangesPrinter(changes)(verbose=True, separator="")
Change.check_status(args.exit_code, not changes.is_empty) Change.check_status(args.exit_code, changes.changes is not None)
case Action.Remove: case Action.Remove:
client.package_changes_update(args.package, Changes()) client.package_changes_update(args.package, Changes())

View File

@@ -0,0 +1,101 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
from dataclasses import replace
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration
from ahriman.core.formatters import PkgbuildPrinter
from ahriman.models.action import Action
from ahriman.models.repository_id import RepositoryId
class Pkgbuild(Handler):
"""
package pkgbuild handler
"""
ALLOW_MULTI_ARCHITECTURE_RUN = False # conflicting io
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
application = Application(repository_id, configuration, report=True)
client = application.repository.reporter
match args.action:
case Action.List:
changes = client.package_changes_get(args.package)
PkgbuildPrinter(changes)(verbose=True, separator="")
Pkgbuild.check_status(args.exit_code, changes.pkgbuild is not None)
case Action.Remove:
changes = client.package_changes_get(args.package)
client.package_changes_update(args.package, replace(changes, pkgbuild=None))
@staticmethod
def _set_package_pkgbuild_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package pkgbuild subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-pkgbuild", help="get package pkgbuild",
description="retrieve package PKGBUILD stored in database",
epilog="This command requests package status from the web interface "
"if it is available.")
parser.add_argument("package", help="package base")
parser.add_argument("-e", "--exit-code", help="return non-zero exit status if result is empty",
action="store_true")
parser.set_defaults(action=Action.List, lock=None, quiet=True, report=False, unsafe=True)
return parser
@staticmethod
def _set_package_pkgbuild_remove_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package pkgbuild remove subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-pkgbuild-remove", help="remove package pkgbuild",
description="remove the package PKGBUILD stored remotely")
parser.add_argument("package", help="package base")
parser.set_defaults(action=Action.Remove, exit_code=False, lock=None, quiet=True, report=False, unsafe=True)
return parser
arguments = [_set_package_pkgbuild_parser, _set_package_pkgbuild_remove_parser]

View File

@@ -24,10 +24,11 @@ import tarfile
from collections.abc import Iterable, Iterator from collections.abc import Iterable, Iterator
from functools import cached_property from functools import cached_property
from pathlib import Path from pathlib import Path
from pyalpm import DB, Handle, Package, SIG_DATABASE_OPTIONAL, SIG_PACKAGE_OPTIONAL # type: ignore[import-not-found] from pyalpm import DB, Package, SIG_DATABASE_OPTIONAL, SIG_PACKAGE_OPTIONAL # type: ignore[import-not-found]
from string import Template from string import Template
from ahriman.core.alpm.pacman_database import PacmanDatabase from ahriman.core.alpm.pacman_database import PacmanDatabase
from ahriman.core.alpm.pacman_handle import PacmanHandle
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.utils import trim_package from ahriman.core.utils import trim_package
@@ -61,16 +62,16 @@ class Pacman(LazyLogging):
self.refresh_database = refresh_database self.refresh_database = refresh_database
@cached_property @cached_property
def handle(self) -> Handle: def handle(self) -> PacmanHandle:
""" """
pyalpm handle pyalpm handle
Returns: Returns:
Handle: generated pyalpm handle instance PacmanHandle: generated pyalpm handle instance
""" """
return self.__create_handle(refresh_database=self.refresh_database) return self.__create_handle(refresh_database=self.refresh_database)
def __create_handle(self, *, refresh_database: PacmanSynchronization) -> Handle: def __create_handle(self, *, refresh_database: PacmanSynchronization) -> PacmanHandle:
""" """
create lazy handle function create lazy handle function
@@ -78,14 +79,14 @@ class Pacman(LazyLogging):
refresh_database(PacmanSynchronization): synchronize local cache to remote refresh_database(PacmanSynchronization): synchronize local cache to remote
Returns: Returns:
Handle: fully initialized pacman handle PacmanHandle: fully initialized pacman handle
""" """
pacman_root = self.configuration.getpath("alpm", "database") pacman_root = self.configuration.getpath("alpm", "database")
use_ahriman_cache = self.configuration.getboolean("alpm", "use_ahriman_cache") use_ahriman_cache = self.configuration.getboolean("alpm", "use_ahriman_cache")
database_path = self.repository_paths.pacman if use_ahriman_cache else pacman_root database_path = self.repository_paths.pacman if use_ahriman_cache else pacman_root
root = self.configuration.getpath("alpm", "root") root = self.configuration.getpath("alpm", "root")
handle = Handle(str(root), str(database_path)) handle = PacmanHandle(str(root), str(database_path))
for repository in self.configuration.getlist("alpm", "repositories"): for repository in self.configuration.getlist("alpm", "repositories"):
database = self.database_init(handle, repository, self.repository_id.architecture) database = self.database_init(handle, repository, self.repository_id.architecture)
@@ -99,12 +100,12 @@ class Pacman(LazyLogging):
return handle return handle
def database_copy(self, handle: Handle, database: DB, pacman_root: Path, *, use_ahriman_cache: bool) -> None: def database_copy(self, handle: PacmanHandle, database: DB, pacman_root: Path, *, use_ahriman_cache: bool) -> None:
""" """
copy database from the operating system root to the ahriman local home copy database from the operating system root to the ahriman local home
Args: Args:
handle(Handle): pacman handle which will be used for database copying handle(PacmanHandle): pacman handle which will be used for database copying
database(DB): pacman database instance to be copied database(DB): pacman database instance to be copied
pacman_root(Path): operating system pacman root pacman_root(Path): operating system pacman root
use_ahriman_cache(bool): use local ahriman cache instead of system one use_ahriman_cache(bool): use local ahriman cache instead of system one
@@ -133,12 +134,12 @@ class Pacman(LazyLogging):
with self.repository_paths.preserve_owner(): with self.repository_paths.preserve_owner():
shutil.copy(src, dst) shutil.copy(src, dst)
def database_init(self, handle: Handle, repository: str, architecture: str) -> DB: def database_init(self, handle: PacmanHandle, repository: str, architecture: str) -> DB:
""" """
create database instance from pacman handler and set its properties create database instance from pacman handler and set its properties
Args: Args:
handle(Handle): pacman handle which will be used for database initializing handle(PacmanHandle): pacman handle which will be used for database initializing
repository(str): pacman repository name (e.g. core) repository(str): pacman repository name (e.g. core)
architecture(str): repository architecture architecture(str): repository architecture
@@ -164,12 +165,12 @@ class Pacman(LazyLogging):
return database return database
def database_sync(self, handle: Handle, *, force: bool) -> None: def database_sync(self, handle: PacmanHandle, *, force: bool) -> None:
""" """
sync local database sync local database
Args: Args:
handle(Handle): pacman handle which will be used for database sync handle(PacmanHandle): pacman handle which will be used for database sync
force(bool): force database synchronization (same as ``pacman -Syy``) force(bool): force database synchronization (same as ``pacman -Syy``)
""" """
self.logger.info("refresh ahriman's home pacman database (force refresh %s)", force) self.logger.info("refresh ahriman's home pacman database (force refresh %s)", force)

View File

@@ -138,8 +138,14 @@ class PacmanDatabase(SyncHttpClient):
Args: Args:
force(bool): force database synchronization (same as ``pacman -Syy``) force(bool): force database synchronization (same as ``pacman -Syy``)
Raises:
PacmanError: on operation error (invalid scheme or incomplete configuration)
""" """
try:
server = next(iter(self.database.servers)) server = next(iter(self.database.servers))
except StopIteration:
raise PacmanError("No configured servers available for database") from None
filename = f"{self.database.name}.files.tar.gz" filename = f"{self.database.name}.files.tar.gz"
url = f"{server}/{filename}" url = f"{server}/{filename}"

View File

@@ -0,0 +1,81 @@
#
# Copyright (c) 2021-2026 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 pathlib import Path
from pyalpm import Handle, Package # type: ignore[import-not-found]
from tempfile import TemporaryDirectory
from typing import Any, ClassVar, Self
class PacmanHandle:
"""
lightweight wrapper for pacman handle to be used for direct alpm operations (e.g. package load)
Attributes:
handle(Handle): pyalpm handle instance
"""
_ephemeral: ClassVar[Self | None] = None
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""
Args:
*args(Any): positional arguments for :class:`pyalpm.Handle`
**kwargs(Any): keyword arguments for :class:`pyalpm.Handle`
"""
self.handle = Handle(*args, **kwargs)
@classmethod
def ephemeral(cls) -> Self:
"""
create temporary instance with no access to real databases
Returns:
Self: loaded class
"""
if cls._ephemeral is None:
# handle creates alpm version file, but we don't use it
# so it is ok to just remove it
with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
cls._ephemeral = cls("/", dir_name)
return cls._ephemeral
def package_load(self, path: Path) -> Package:
"""
load package from path to the archive
Args:
path(Path): path to package archive
Returns:
Package: package instance
"""
return self.handle.load_pkg(str(path))
def __getattr__(self, item: str) -> Any:
"""
proxy methods for :class:`pyalpm.Handle`, because it doesn't allow subclassing
Args:
item(str): property name
Returns:
Any: attribute by its name
"""
return self.handle.__getattribute__(item)

View File

@@ -58,7 +58,7 @@ class Auth(LazyLogging):
Returns: Returns:
str: login control as html code to insert str: login control as html code to insert
""" """
return """<button type="button" class="btn btn-link" data-bs-toggle="modal" data-bs-target="#login-modal" style="text-decoration: none"><i class="bi bi-box-arrow-in-right"></i> login</button>""" return "<button type=\"button\" class=\"btn btn-link\" data-bs-toggle=\"modal\" data-bs-target=\"#login-modal\" style=\"text-decoration: none\"><i class=\"bi bi-box-arrow-in-right\"></i> login</button>"
@property @property
def is_external(self) -> bool: def is_external(self) -> bool:

View File

@@ -26,6 +26,7 @@ from typing import ClassVar
from ahriman.core.exceptions import CalledProcessError from ahriman.core.exceptions import CalledProcessError
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.utils import check_output, utcnow, walk from ahriman.core.utils import check_output, utcnow, walk
from ahriman.models.changes import Changes
from ahriman.models.package import Package from ahriman.models.package import Package
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
@@ -51,24 +52,25 @@ class Sources(LazyLogging):
} }
@staticmethod @staticmethod
def changes(source_dir: Path, last_commit_sha: str | None) -> str | None: def changes(source_dir: Path, last_commit_sha: str) -> Changes:
""" """
extract changes from the last known commit if available extract changes from the last known commit if available
Args: Args:
source_dir(Path): local path to directory with source files source_dir(Path): local path to directory with source files
last_commit_sha(str | None): last known commit hash last_commit_sha(str): last known commit hash
Returns: Returns:
str | None: changes from the last commit if available or ``None`` otherwise Changes: changes from the last commit if available
""" """
if last_commit_sha is None:
return None # no previous reference found
instance = Sources() instance = Sources()
diff = None
if instance.fetch_until(source_dir, commit_sha=last_commit_sha) is not None: if instance.fetch_until(source_dir, commit_sha=last_commit_sha) is not None:
return instance.diff(source_dir, last_commit_sha) diff = instance.diff(source_dir, last_commit_sha)
return None pkgbuild = instance.read(source_dir, "HEAD", Path("PKGBUILD"))
return Changes(last_commit_sha, diff, pkgbuild)
@staticmethod @staticmethod
def extend_architectures(sources_dir: Path, architecture: str) -> list[PkgbuildPatch]: def extend_architectures(sources_dir: Path, architecture: str) -> list[PkgbuildPatch]:
@@ -413,3 +415,17 @@ class Sources(LazyLogging):
cwd=sources_dir, input_data=patch.serialize(), logger=self.logger) cwd=sources_dir, input_data=patch.serialize(), logger=self.logger)
else: else:
patch.write(sources_dir / "PKGBUILD") patch.write(sources_dir / "PKGBUILD")
def read(self, sources_dir: Path, commit_sha: str, path: Path) -> str:
"""
read file content from the specified commit
Args:
sources_dir(Path): local path to git repository
commit_sha(str): commit hash to read from
path(Path): path to file inside the repository
Returns:
str: file content at specified commit
"""
return check_output(*self.git(), "show", f"{commit_sha}:{path}", cwd=sources_dir, logger=self.logger)

View File

@@ -19,11 +19,9 @@
# #
from sqlite3 import Connection from sqlite3 import Connection
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.utils import package_like from ahriman.core.utils import package_like
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pacman_synchronization import PacmanSynchronization
__all__ = ["migrate_data", "steps"] __all__ = ["migrate_data", "steps"]
@@ -61,12 +59,9 @@ def migrate_package_depends(connection: Connection, configuration: Configuration
if not configuration.repository_paths.repository.is_dir(): if not configuration.repository_paths.repository.is_dir():
return return
_, repository_id = configuration.check_loaded()
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
package_list = [] package_list = []
for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()): for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()):
base = Package.from_archive(full_path, pacman) base = Package.from_archive(full_path)
for package, description in base.packages.items(): for package, description in base.packages.items():
package_list.append({ package_list.append({
"make_depends": description.make_depends, "make_depends": description.make_depends,

View File

@@ -19,11 +19,9 @@
# #
from sqlite3 import Connection from sqlite3 import Connection
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.utils import package_like from ahriman.core.utils import package_like
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pacman_synchronization import PacmanSynchronization
__all__ = ["migrate_data", "steps"] __all__ = ["migrate_data", "steps"]
@@ -58,12 +56,9 @@ def migrate_package_check_depends(connection: Connection, configuration: Configu
if not configuration.repository_paths.repository.is_dir(): if not configuration.repository_paths.repository.is_dir():
return return
_, repository_id = configuration.check_loaded()
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
package_list = [] package_list = []
for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()): for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()):
base = Package.from_archive(full_path, pacman) base = Package.from_archive(full_path)
for package, description in base.packages.items(): for package, description in base.packages.items():
package_list.append({ package_list.append({
"check_depends": description.check_depends, "check_depends": description.check_depends,

View File

@@ -19,11 +19,9 @@
# #
from sqlite3 import Connection from sqlite3 import Connection
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.utils import package_like from ahriman.core.utils import package_like
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pacman_synchronization import PacmanSynchronization
__all__ = ["migrate_data", "steps"] __all__ = ["migrate_data", "steps"]
@@ -64,12 +62,9 @@ def migrate_package_base_packager(connection: Connection, configuration: Configu
if not configuration.repository_paths.repository.is_dir(): if not configuration.repository_paths.repository.is_dir():
return return
_, repository_id = configuration.check_loaded()
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
package_list = [] package_list = []
for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()): for full_path in filter(package_like, configuration.repository_paths.repository.iterdir()):
package = Package.from_archive(full_path, pacman) package = Package.from_archive(full_path)
package_list.append({ package_list.append({
"package_base": package.base, "package_base": package.base,
"packager": package.packager, "packager": package.packager,

View File

@@ -20,13 +20,11 @@
from dataclasses import replace from dataclasses import replace
from sqlite3 import Connection from sqlite3 import Connection
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.repository import Explorer from ahriman.core.repository import Explorer
from ahriman.core.sign.gpg import GPG from ahriman.core.sign.gpg import GPG
from ahriman.core.utils import atomic_move, package_like, symlink_relative from ahriman.core.utils import atomic_move, package_like, symlink_relative
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pacman_synchronization import PacmanSynchronization
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
@@ -45,29 +43,27 @@ def migrate_data(connection: Connection, configuration: Configuration) -> None:
for repository_id in Explorer.repositories_extract(configuration): for repository_id in Explorer.repositories_extract(configuration):
paths = replace(configuration.repository_paths, repository_id=repository_id) paths = replace(configuration.repository_paths, repository_id=repository_id)
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
# create archive directory if required # create archive directory if required
if not paths.archive.is_dir(): if not paths.archive.is_dir():
with paths.preserve_owner(): with paths.preserve_owner():
paths.archive.mkdir(mode=0o755, parents=True) paths.archive.mkdir(mode=0o755, parents=True)
move_packages(paths, pacman) move_packages(paths)
def move_packages(repository_paths: RepositoryPaths, pacman: Pacman) -> None: def move_packages(repository_paths: RepositoryPaths) -> None:
""" """
move packages from repository to archive and create symbolic links move packages from repository to archive and create symbolic links
Args: Args:
repository_paths(RepositoryPaths): repository paths instance repository_paths(RepositoryPaths): repository paths instance
pacman(Pacman): alpm wrapper instance
""" """
for archive in filter(package_like, repository_paths.repository.iterdir()): for archive in filter(package_like, repository_paths.repository.iterdir()):
if not archive.is_file(follow_symlinks=False): if not archive.is_file(follow_symlinks=False):
continue # skip symbolic links if any continue # skip symbolic links if any
package = Package.from_archive(archive, pacman) package = Package.from_archive(archive)
artifacts = [archive] artifacts = [archive]
# check if there are signatures for this package and append it here too # check if there are signatures for this package and append it here too
if (signature := GPG.signature(archive)).exists(): if (signature := GPG.signature(archive)).exists():

View File

@@ -0,0 +1,25 @@
#
# Copyright (c) 2021-2026 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 package_changes add column pkgbuild text""",
]

View File

@@ -45,10 +45,10 @@ class ChangesOperations(Operations):
def run(connection: Connection) -> Changes: def run(connection: Connection) -> Changes:
return next( return next(
( (
Changes(row["last_commit_sha"], row["changes"] or None) Changes(row["last_commit_sha"], row["changes"] or None, row["pkgbuild"] or None)
for row in connection.execute( for row in connection.execute(
""" """
select last_commit_sha, changes from package_changes select last_commit_sha, changes, pkgbuild from package_changes
where package_base = :package_base and repository = :repository where package_base = :package_base and repository = :repository
""", """,
{ {
@@ -77,16 +77,17 @@ class ChangesOperations(Operations):
connection.execute( connection.execute(
""" """
insert into package_changes insert into package_changes
(package_base, last_commit_sha, changes, repository) (package_base, last_commit_sha, changes, pkgbuild, repository)
values values
(:package_base, :last_commit_sha, :changes ,:repository) (:package_base, :last_commit_sha, :changes, :pkgbuild, :repository)
on conflict (package_base, repository) do update set on conflict (package_base, repository) do update set
last_commit_sha = :last_commit_sha, changes = :changes last_commit_sha = :last_commit_sha, changes = :changes, pkgbuild = :pkgbuild
""", """,
{ {
"package_base": package_base, "package_base": package_base,
"last_commit_sha": changes.last_commit_sha, "last_commit_sha": changes.last_commit_sha,
"changes": changes.changes, "changes": changes.changes,
"pkgbuild": changes.pkgbuild,
"repository": repository_id.id, "repository": repository_id.id,
}) })

View File

@@ -116,6 +116,19 @@ class GitRemoteError(RuntimeError):
RuntimeError.__init__(self, "Git remote failed") RuntimeError.__init__(self, "Git remote failed")
class GPGError(RuntimeError):
"""
PGP/GPG related exception
"""
def __init__(self, details: str) -> None:
"""
Args:
details(str): details of the exception
"""
RuntimeError.__init__(self, f"GPG operation failed: {details}")
class InitializeError(RuntimeError): class InitializeError(RuntimeError):
""" """
base service initialization exception base service initialization exception

View File

@@ -26,6 +26,7 @@ from ahriman.core.formatters.event_stats_printer import EventStatsPrinter
from ahriman.core.formatters.package_printer import PackagePrinter from ahriman.core.formatters.package_printer import PackagePrinter
from ahriman.core.formatters.package_stats_printer import PackageStatsPrinter 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.pkgbuild_printer import PkgbuildPrinter
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.repository_stats_printer import RepositoryStatsPrinter

View File

@@ -45,7 +45,7 @@ class ChangesPrinter(Printer):
Returns: Returns:
list[Property]: list of content properties list[Property]: list of content properties
""" """
if self.changes.is_empty: if self.changes.changes is None:
return [] return []
return [Property("", self.changes.changes, is_required=True, indent=0)] return [Property("", self.changes.changes, is_required=True, indent=0)]
@@ -57,6 +57,6 @@ class ChangesPrinter(Printer):
Returns: Returns:
str | None: content title if it can be generated and ``None`` otherwise str | None: content title if it can be generated and ``None`` otherwise
""" """
if self.changes.is_empty: if self.changes.changes is None:
return None return None
return self.changes.last_commit_sha return self.changes.last_commit_sha

View File

@@ -0,0 +1,62 @@
#
# Copyright (c) 2021-2026 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.printer import Printer
from ahriman.models.changes import Changes
from ahriman.models.property import Property
class PkgbuildPrinter(Printer):
"""
print content of the pkgbuild stored in changes
Attributes:
changes(Changes): package changes
"""
def __init__(self, changes: Changes) -> None:
"""
Args:
changes(Changes): package changes
"""
Printer.__init__(self)
self.changes = changes
def properties(self) -> list[Property]:
"""
convert content into printable data
Returns:
list[Property]: list of content properties
"""
if self.changes.pkgbuild is None:
return []
return [Property("", self.changes.pkgbuild, is_required=True, indent=0)]
# pylint: disable=redundant-returns-doc
def title(self) -> str | None:
"""
generate entry title from content
Returns:
str | None: content title if it can be generated and ``None`` otherwise
"""
if self.changes.pkgbuild is None:
return None
return self.changes.last_commit_sha

View File

@@ -17,14 +17,10 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from collections.abc import Callable
from functools import cmp_to_key
from ahriman.core import context from ahriman.core import context
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository
from ahriman.core.triggers import Trigger from ahriman.core.triggers import Trigger
from ahriman.core.utils import package_like
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
from ahriman.models.result import Result from ahriman.models.result import Result
@@ -78,23 +74,21 @@ class ArchiveRotationTrigger(Trigger):
""" """
return list(cls.CONFIGURATION_SCHEMA.keys()) return list(cls.CONFIGURATION_SCHEMA.keys())
def archives_remove(self, package: Package, pacman: Pacman) -> None: def archives_remove(self, package: Package, repository: Repository) -> None:
""" """
remove older versions of the specified package remove older versions of the specified package
Args: Args:
package(Package): package which has been updated to check for older versions package(Package): package which has been updated to check for older versions
pacman(Pacman): alpm wrapper instance repository(Repository): repository instance
""" """
packages: dict[tuple[str, str], Package] = {} # explicit guard to skip process in case if rotation is disabled
# we can't use here load_archives, because it ignores versions # this guard is supposed to speedup process
for full_path in filter(package_like, self.paths.archive_for(package.base).iterdir()): if self.keep_built_packages == 0:
local = Package.from_archive(full_path, pacman) return
packages.setdefault((local.base, local.version), local).packages.update(local.packages)
to_remove = repository.package_archives(package.base)
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
to_remove = sorted(packages.values(), key=cmp_to_key(comparator))
# 0 will implicitly be translated into [:0], meaning we keep all packages
for single in to_remove[:-self.keep_built_packages]: for single in to_remove[:-self.keep_built_packages]:
self.logger.info("removing version %s of package %s", single.version, single.base) self.logger.info("removing version %s of package %s", single.version, single.base)
for archive in single.packages.values(): for archive in single.packages.values():
@@ -110,7 +104,7 @@ class ArchiveRotationTrigger(Trigger):
packages(list[Package]): list of all available packages packages(list[Package]): list of all available packages
""" """
ctx = context.get() ctx = context.get()
pacman = ctx.get(Pacman) repository = ctx.get(Repository)
for package in result.success: for package in result.success:
self.archives_remove(package, pacman) self.archives_remove(package, repository)

View File

@@ -19,6 +19,7 @@
# #
import contextlib import contextlib
import requests import requests
import uuid
from requests.adapters import BaseAdapter from requests.adapters import BaseAdapter
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -60,6 +61,15 @@ class SyncAhrimanClient(SyncHttpClient):
return adapters return adapters
def headers(self) -> dict[str, str]:
"""
additional request headers
Returns:
dict[str, str]: additional request headers defined by class
"""
return SyncHttpClient.headers(self) | {"X-Request-ID": str(uuid.uuid4())}
def on_session_creation(self, session: requests.Session) -> None: def on_session_creation(self, session: requests.Session) -> None:
""" """
method which will be called on session creation method which will be called on session creation

View File

@@ -144,6 +144,15 @@ class SyncHttpClient(LazyLogging):
"https://": HTTPAdapter(max_retries=self.retry), "https://": HTTPAdapter(max_retries=self.retry),
} }
def headers(self) -> dict[str, str]:
"""
additional request headers
Returns:
dict[str, str]: additional request headers defined by class
"""
return {}
def make_request(self, method: Literal["DELETE", "GET", "HEAD", "POST", "PUT"], url: str, *, def make_request(self, method: Literal["DELETE", "GET", "HEAD", "POST", "PUT"], url: str, *,
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
params: list[tuple[str, str]] | None = None, params: list[tuple[str, str]] | None = None,
@@ -178,6 +187,9 @@ class SyncHttpClient(LazyLogging):
if session is None: if session is None:
session = self.session session = self.session
if additional_headers := self.headers():
headers = additional_headers | (headers or {})
try: try:
response = session.request(method, url, params=params, data=data, headers=headers, files=files, json=json, response = session.request(method, url, params=params, data=data, headers=headers, files=files, json=json,
stream=stream, auth=self.auth, timeout=self.timeout) stream=stream, auth=self.auth, timeout=self.timeout)

View File

@@ -24,6 +24,7 @@ from collections.abc import Iterator
from functools import cached_property from functools import cached_property
from typing import Any from typing import Any
from ahriman.core.log.log_context import LogContext
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
@@ -54,30 +55,20 @@ class LazyLogging:
prefix = "" if clazz.__module__ is None else f"{clazz.__module__}." prefix = "" if clazz.__module__ is None else f"{clazz.__module__}."
return f"{prefix}{clazz.__qualname__}" return f"{prefix}{clazz.__qualname__}"
@staticmethod @contextlib.contextmanager
def _package_logger_reset() -> None: def in_context(self, name: str, value: Any) -> Iterator[None]:
""" """
reset package logger to empty one execute function while setting log context. The context will be reset after the execution
"""
logging.setLogRecordFactory(logging.LogRecord)
@staticmethod
def _package_logger_set(package_base: str, version: str | None) -> None:
"""
set package base as extra info to the logger
Args: Args:
package_base(str): package base name(str): attribute name to set on log records
version(str | None): package version if available value(Any): current value of the context variable
""" """
current_factory = logging.getLogRecordFactory() token = LogContext.set(name, value)
try:
def package_record_factory(*args: Any, **kwargs: Any) -> logging.LogRecord: yield
record = current_factory(*args, **kwargs) finally:
record.package_id = LogRecordId(package_base, version or "<unknown>") LogContext.reset(name, token)
return record
logging.setLogRecordFactory(package_record_factory)
@contextlib.contextmanager @contextlib.contextmanager
def in_package_context(self, package_base: str, version: str | None) -> Iterator[None]: def in_package_context(self, package_base: str, version: str | None) -> Iterator[None]:
@@ -94,8 +85,5 @@ class LazyLogging:
>>> with self.in_package_context(package.base, package.version): >>> with self.in_package_context(package.base, package.version):
>>> build_package(package) >>> build_package(package)
""" """
try: with self.in_context("package_id", LogRecordId(package_base, version or "<unknown>")):
self._package_logger_set(package_base, version)
yield yield
finally:
self._package_logger_reset()

View File

@@ -0,0 +1,108 @@
#
# Copyright (c) 2021-2026 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 contextvars
import logging
from typing import Any, ClassVar, TypeVar, cast
T = TypeVar("T")
class LogContext:
"""
logging context manager which provides context variables injection into log records
"""
_context: ClassVar[dict[str, contextvars.ContextVar[Any]]] = {}
@classmethod
def get(cls, name: str) -> T | None:
"""
get context variable if available
Args:
name(str): name of the context variable
Returns:
T | None: context variable if available and ``None`` otherwise
"""
if (variable := cls._context.get(name)) is not None:
return cast(T | None, variable.get())
return None
@classmethod
def log_record_factory(cls, *args: Any, **kwargs: Any) -> logging.LogRecord:
"""
log record factory which injects all registered context variables into log records
Args:
*args(Any): positional arguments for the log factory
**kwargs(Any): keyword arguments for the log factory
Returns:
logging.LogRecord: log record with context variables set as attributes
"""
record = logging.LogRecord(*args, **kwargs)
for name, variable in cls._context.items():
if (value := variable.get()) is not None:
setattr(record, name, value)
return record
@classmethod
def register(cls, name: str) -> contextvars.ContextVar[T]:
"""
(re)create context variable for log records
Args:
name(str): name of the context variable
Returns:
contextvars.ContextVar[T]: created context variable
"""
variable = cls._context[name] = contextvars.ContextVar(name, default=None)
return variable
@classmethod
def reset(cls, name: str, token: contextvars.Token[T]) -> None:
"""
reset context variable to its previous value
Args:
name(str): attribute name to reset on log records
token(contextvars.Token[T]): previously registered token
"""
cls._context[name].reset(token)
@classmethod
def set(cls, name: str, value: T) -> contextvars.Token[T]:
"""
set context variable for log records. This value will be automatically emitted with each log record
Args:
name(str): attribute name to set on log records
value(T): current value of the context variable
Returns:
contextvars.Token[T]: token created with this value
"""
return cls._context[name].set(value)

View File

@@ -21,10 +21,11 @@ import logging
from logging.config import fileConfig from logging.config import fileConfig
from pathlib import Path from pathlib import Path
from typing import ClassVar from typing import ClassVar, Literal
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.log.http_log_handler import HttpLogHandler from ahriman.core.log.http_log_handler import HttpLogHandler
from ahriman.core.log.log_context import LogContext
from ahriman.models.log_handler import LogHandler from ahriman.models.log_handler import LogHandler
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@@ -36,11 +37,13 @@ class LogLoader:
Attributes: Attributes:
DEFAULT_LOG_FORMAT(str): (class attribute) default log format (in case of fallback) DEFAULT_LOG_FORMAT(str): (class attribute) default log format (in case of fallback)
DEFAULT_LOG_LEVEL(int): (class attribute) default log level (in case of fallback) DEFAULT_LOG_LEVEL(int): (class attribute) default log level (in case of fallback)
DEFAULT_LOG_STYLE(str): (class attribute) default log style (in case of fallback)
DEFAULT_SYSLOG_DEVICE(Path): (class attribute) default path to syslog device DEFAULT_SYSLOG_DEVICE(Path): (class attribute) default path to syslog device
""" """
DEFAULT_LOG_FORMAT: ClassVar[str] = "[%(levelname)s %(asctime)s] [%(name)s]: %(message)s" DEFAULT_LOG_FORMAT: ClassVar[str] = "[{levelname} {asctime}] [{name}]: {message}"
DEFAULT_LOG_LEVEL: ClassVar[int] = logging.DEBUG DEFAULT_LOG_LEVEL: ClassVar[int] = logging.DEBUG
DEFAULT_LOG_STYLE: ClassVar[Literal["%", "{", "$"]] = "{"
DEFAULT_SYSLOG_DEVICE: ClassVar[Path] = Path("/") / "dev" / "log" DEFAULT_SYSLOG_DEVICE: ClassVar[Path] = Path("/") / "dev" / "log"
@staticmethod @staticmethod
@@ -100,10 +103,22 @@ class LogLoader:
fileConfig(log_configuration, disable_existing_loggers=True) fileConfig(log_configuration, disable_existing_loggers=True)
logging.debug("using %s logger", default_handler) logging.debug("using %s logger", default_handler)
except Exception: except Exception:
logging.basicConfig(filename=None, format=LogLoader.DEFAULT_LOG_FORMAT, level=LogLoader.DEFAULT_LOG_LEVEL) logging.basicConfig(filename=None, format=LogLoader.DEFAULT_LOG_FORMAT,
style=LogLoader.DEFAULT_LOG_STYLE, level=LogLoader.DEFAULT_LOG_LEVEL)
logging.exception("could not load logging from configuration, fallback to stderr") logging.exception("could not load logging from configuration, fallback to stderr")
HttpLogHandler.load(repository_id, configuration, report=report) HttpLogHandler.load(repository_id, configuration, report=report)
LogLoader.register_context()
if quiet: if quiet:
logging.disable(logging.WARNING) # only print errors here logging.disable(logging.WARNING) # only print errors here
@staticmethod
def register_context() -> None:
"""
register logging context
"""
# predefined context variables
for variable in ("package_id", "request_id"):
LogContext.register(variable)
logging.setLogRecordFactory(LogContext.log_record_factory)

View File

@@ -57,7 +57,7 @@ class Executor(PackageInfo, Cleaner):
for path in filter(package_like, archive.iterdir()): for path in filter(package_like, archive.iterdir()):
# check if package version is the same # check if package version is the same
built = Package.from_archive(path, self.pacman) built = Package.from_archive(path)
if built.version != package.version: if built.version != package.version:
continue continue
@@ -117,7 +117,7 @@ class Executor(PackageInfo, Cleaner):
else: else:
built = task.build(path, PACKAGER=packager) built = task.build(path, PACKAGER=packager)
package.with_packages(built, self.pacman) package.with_packages(built)
for src in built: for src in built:
dst = self.paths.packages / src.name dst = self.paths.packages / src.name
atomic_move(src, dst) atomic_move(src, dst)
@@ -204,7 +204,8 @@ class Executor(PackageInfo, Cleaner):
# update commit hash for changes keeping current diff if there is any # update commit hash for changes keeping current diff if there is any
changes = self.reporter.package_changes_get(single.base) changes = self.reporter.package_changes_get(single.base)
self.reporter.package_changes_update(single.base, Changes(commit_sha, changes.changes)) self.reporter.package_changes_update(
single.base, Changes(commit_sha, changes.changes, changes.pkgbuild))
# update dependencies list # update dependencies list
package_archive = PackageArchive(self.paths.build_root, single, self.pacman, self.scan_paths) package_archive = PackageArchive(self.paths.build_root, single, self.pacman, self.scan_paths)

View File

@@ -19,23 +19,36 @@
# #
import copy import copy
from collections.abc import Iterable from collections.abc import Callable, Iterable
from functools import cmp_to_key
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.build_tools.package_version import PackageVersion from ahriman.core.build_tools.package_version import PackageVersion
from ahriman.core.build_tools.sources import Sources from ahriman.core.build_tools.sources import Sources
from ahriman.core.repository.repository_properties import RepositoryProperties from ahriman.core.configuration import Configuration
from ahriman.core.log import LazyLogging
from ahriman.core.status import Client
from ahriman.core.utils import package_like from ahriman.core.utils import package_like
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.package import Package from ahriman.models.package import Package
class PackageInfo(RepositoryProperties): class PackageInfo(LazyLogging):
""" """
handler for the package information handler for the package information
Attributes:
configuration(Configuration): configuration instance
pacman(Pacman): alpm wrapper instance
reporter(Client): build status reporter instance
""" """
configuration: Configuration
pacman: Pacman
reporter: Client
def full_depends(self, package: Package, packages: Iterable[Package]) -> list[str]: def full_depends(self, package: Package, packages: Iterable[Package]) -> list[str]:
""" """
generate full dependencies list including transitive dependencies generate full dependencies list including transitive dependencies
@@ -86,7 +99,7 @@ class PackageInfo(RepositoryProperties):
# we are iterating over bases, not single packages # we are iterating over bases, not single packages
for full_path in packages: for full_path in packages:
try: try:
local = Package.from_archive(full_path, self.pacman) local = Package.from_archive(full_path)
if (source := sources.get(local.base)) is not None: # update source with remote if (source := sources.get(local.base)) is not None: # update source with remote
local.remote = source local.remote = source
@@ -102,27 +115,48 @@ class PackageInfo(RepositoryProperties):
self.logger.exception("could not load package from %s", full_path) self.logger.exception("could not load package from %s", full_path)
return list(result.values()) return list(result.values())
def package_changes(self, package: Package, last_commit_sha: str | None) -> Changes: def package_archives(self, package_base: str) -> list[Package]:
"""
load list of packages known for this package base. This method unlike
:func:`ahriman.core.repository.package_info.PackageInfo.load_archives` scans archive directory and loads all
versions available for the ``package_base``
Args:
package_base(str): package base
Returns:
list[Package]: list of packages belonging to this base, sorted by version by ascension
"""
paths = self.configuration.repository_paths
packages: dict[tuple[str, str], Package] = {}
# we can't use here load_archives, because it ignores versions
for full_path in filter(package_like, paths.archive_for(package_base).iterdir()):
local = Package.from_archive(full_path)
packages.setdefault((local.base, local.version), local).packages.update(local.packages)
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
return sorted(packages.values(), key=cmp_to_key(comparator))
def package_changes(self, package: Package, last_commit_sha: str) -> Changes | None:
""" """
extract package change for the package since last commit if available extract package change for the package since last commit if available
Args: Args:
package(Package): package properties package(Package): package properties
last_commit_sha(str | None): last known commit hash last_commit_sha(str): last known commit hash
Returns: Returns:
Changes: changes if available Changes | None: changes if available
""" """
with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name: with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
dir_path = Path(dir_name) dir_path = Path(dir_name)
patches = self.reporter.package_patches_get(package.base, None) patches = self.reporter.package_patches_get(package.base, None)
current_commit_sha = Sources.load(dir_path, package, patches, self.paths) current_commit_sha = Sources.load(dir_path, package, patches, self.configuration.repository_paths)
changes: str | None = None
if current_commit_sha != last_commit_sha: if current_commit_sha != last_commit_sha:
changes = Sources.changes(dir_path, last_commit_sha) return Sources.changes(dir_path, last_commit_sha)
return None
return Changes(last_commit_sha, changes)
def packages(self, filter_packages: Iterable[str] | None = None) -> list[Package]: def packages(self, filter_packages: Iterable[str] | None = None) -> list[Package]:
""" """
@@ -134,7 +168,7 @@ class PackageInfo(RepositoryProperties):
Returns: Returns:
list[Package]: list of packages properties list[Package]: list of packages properties
""" """
packages = self.load_archives(filter(package_like, self.paths.repository.iterdir())) packages = self.load_archives(filter(package_like, self.configuration.repository_paths.repository.iterdir()))
if filter_packages: if filter_packages:
packages = [package for package in packages if package.base in filter_packages] packages = [package for package in packages if package.base in filter_packages]
@@ -147,7 +181,7 @@ class PackageInfo(RepositoryProperties):
Returns: Returns:
list[Path]: list of filenames from the directory list[Path]: list of filenames from the directory
""" """
return list(filter(package_like, self.paths.packages.iterdir())) return list(filter(package_like, self.configuration.repository_paths.packages.iterdir()))
def packages_depend_on(self, packages: list[Package], depends_on: Iterable[str] | None) -> list[Package]: def packages_depend_on(self, packages: list[Package], depends_on: Iterable[str] | None) -> list[Package]:
""" """

View File

@@ -20,7 +20,7 @@
from pathlib import Path from pathlib import Path
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import BuildError from ahriman.core.exceptions import BuildError, GPGError
from ahriman.core.http import SyncHttpClient from ahriman.core.http import SyncHttpClient
from ahriman.core.utils import check_output from ahriman.core.utils import check_output
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@@ -147,12 +147,19 @@ class GPG(SyncHttpClient):
Returns: Returns:
str: full PGP key fingerprint str: full PGP key fingerprint
Raises:
GPGError: if key is in wrong format
""" """
metadata = check_output("gpg", "--with-colons", "--fingerprint", key, logger=self.logger)
# fingerprint line will be like # fingerprint line will be like
# fpr:::::::::43A663569A07EE1E4ECC55CC7E3A4240CE3C45C2: # fpr:::::::::43A663569A07EE1E4ECC55CC7E3A4240CE3C45C2:
metadata = check_output("gpg", "--with-colons", "--fingerprint", key, logger=self.logger)
try:
fingerprint = next(filter(lambda line: line[:3] == "fpr", metadata.splitlines())) fingerprint = next(filter(lambda line: line[:3] == "fpr", metadata.splitlines()))
return fingerprint.split(":")[-2] return fingerprint.split(":")[-2]
except (IndexError, StopIteration):
raise GPGError(f"key {key} has invalid metadata") from None
def key_import(self, server: str, key: str) -> None: def key_import(self, server: str, key: str) -> None:
""" """

View File

@@ -23,6 +23,7 @@ from typing import Any, Self
from ahriman.core.exceptions import UnknownPackageError from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
@@ -39,15 +40,18 @@ class Watcher(LazyLogging):
Attributes: Attributes:
client(Client): reporter instance client(Client): reporter instance
package_info(PackageInfo): package info instance
status(BuildStatus): daemon status status(BuildStatus): daemon status
""" """
def __init__(self, client: Client) -> None: def __init__(self, client: Client, package_info: PackageInfo) -> None:
""" """
Args: Args:
client(Client): reporter instance client(Client): reporter instance
package_info(PackageInfo): package info instance
""" """
self.client = client self.client = client
self.package_info = package_info
self._lock = Lock() self._lock = Lock()
self._known: dict[str, tuple[Package, BuildStatus]] = {} self._known: dict[str, tuple[Package, BuildStatus]] = {}
@@ -80,6 +84,15 @@ class Watcher(LazyLogging):
logs_rotate: Callable[[int], None] logs_rotate: Callable[[int], None]
def package_archives(self, package_base: str) -> list[Package]:
"""
get known package archives
Returns:
list[Package]: list of built package for this package base
"""
return self.package_info.package_archives(package_base)
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]

View File

@@ -31,20 +31,12 @@ class Changes:
Attributes: Attributes:
last_commit_sha(str | None): last commit hash last_commit_sha(str | None): last commit hash
changes(str | None): package change since the last commit if available changes(str | None): package change since the last commit if available
pkgbuild(str | None): original PKGBUILD content if available
""" """
last_commit_sha: str | None = None last_commit_sha: str | None = None
changes: str | None = None changes: str | None = None
pkgbuild: str | None = None
@property
def is_empty(self) -> bool:
"""
validate that changes are not empty
Returns:
bool: ``True`` in case if changes are not set and ``False`` otherwise
"""
return self.changes is None
@classmethod @classmethod
def from_json(cls, dump: dict[str, Any]) -> Self: def from_json(cls, dump: dict[str, Any]) -> Self:

View File

@@ -26,6 +26,7 @@ from pyalpm import vercmp # type: ignore[import-not-found]
from typing import Any, Self from typing import Any, Self
from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.pacman_handle import PacmanHandle
from ahriman.core.alpm.remote import AUR, Official, OfficialSyncdb from ahriman.core.alpm.remote import AUR, Official, OfficialSyncdb
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.utils import dataclass_view, full_version, list_flatmap, parse_version, srcinfo_property_list from ahriman.core.utils import dataclass_view, full_version, list_flatmap, parse_version, srcinfo_property_list
@@ -186,18 +187,17 @@ class Package(LazyLogging):
return sorted(packages) return sorted(packages)
@classmethod @classmethod
def from_archive(cls, path: Path, pacman: Pacman) -> Self: def from_archive(cls, path: Path) -> Self:
""" """
construct package properties from package archive construct package properties from package archive
Args: Args:
path(Path): path to package archive path(Path): path to package archive
pacman(Pacman): alpm wrapper instance
Returns: Returns:
Self: package properties Self: package properties
""" """
package = pacman.handle.load_pkg(str(path)) package = PacmanHandle.ephemeral().package_load(path)
description = PackageDescription.from_package(package, path) description = PackageDescription.from_package(package, path)
return cls( return cls(
base=package.base or package.name, base=package.base or package.name,
@@ -400,17 +400,16 @@ class Package(LazyLogging):
""" """
return dataclass_view(self) return dataclass_view(self)
def with_packages(self, packages: Iterable[Path], pacman: Pacman) -> None: def with_packages(self, packages: Iterable[Path]) -> None:
""" """
replace packages descriptions with ones from archives replace packages descriptions with ones from archives
Args: Args:
packages(Iterable[Path]): paths to package archives packages(Iterable[Path]): paths to package archives
pacman(Pacman): alpm wrapper instance
""" """
self.packages = {} # reset state self.packages = {} # reset state
for package in packages: for package in packages:
archive = self.from_archive(package, pacman) archive = self.from_archive(package)
if archive.base != self.base: if archive.base != self.base:
continue continue

View File

@@ -88,8 +88,12 @@ class User:
""" """
if not self.password: if not self.password:
return None return None
try:
algo = next(segment for segment in self.password.split("$") if segment) algo = next(segment for segment in self.password.split("$") if segment)
return f"${algo}$" return f"${algo}$"
except StopIteration:
return None
@staticmethod @staticmethod
def generate_password(length: int) -> str: def generate_password(length: int) -> str:

View File

@@ -0,0 +1,51 @@
#
# Copyright (c) 2021-2026 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 uuid
from aiohttp.typedefs import Middleware
from aiohttp.web import Request, StreamResponse, middleware
from ahriman.core.log.log_context import LogContext
from ahriman.web.middlewares import HandlerType
__all__ = ["request_id_handler"]
def request_id_handler() -> Middleware:
"""
middleware to trace request id header
Returns:
Middleware: request id processing middleware
"""
@middleware
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
request_id = request.headers.getone("X-Request-ID", str(uuid.uuid4()))
token = LogContext.set("request_id", request_id)
try:
response = await handler(request)
response.headers["X-Request-ID"] = request_id
return response
finally:
LogContext.reset("request_id", token)
return handle

View File

@@ -25,8 +25,10 @@ class AuthInfoSchema(Schema):
authorization information schema authorization information schema
""" """
control = fields.String(required=True, metadata={ control = fields.String(
metadata={
"description": "HTML control for login interface", "description": "HTML control for login interface",
"example": "<button type=\"button\" class=\"btn btn-link\" data-bs-toggle=\"modal\" data-bs-target=\"#login-modal\" style=\"text-decoration: none\"><i class=\"bi bi-box-arrow-in-right\"></i> login</button>",
}) })
enabled = fields.Boolean(required=True, metadata={ enabled = fields.Boolean(required=True, metadata={
"description": "Whether authentication is enabled or not", "description": "Whether authentication is enabled or not",
@@ -35,5 +37,5 @@ class AuthInfoSchema(Schema):
"description": "Whether authorization provider is external (e.g. OAuth)", "description": "Whether authorization provider is external (e.g. OAuth)",
}) })
username = fields.String(metadata={ username = fields.String(metadata={
"description": "Currently authenticated username if any", "description": "Currently authenticated username if available",
}) })

View File

@@ -27,10 +27,12 @@ class AutoRefreshIntervalSchema(Schema):
interval = fields.Integer(required=True, metadata={ interval = fields.Integer(required=True, metadata={
"description": "Auto refresh interval in milliseconds", "description": "Auto refresh interval in milliseconds",
"example": "60000",
}) })
is_active = fields.Boolean(required=True, metadata={ is_active = fields.Boolean(required=True, metadata={
"description": "Whether this interval is the default active one", "description": "Whether this interval is the default active one",
}) })
text = fields.String(required=True, metadata={ text = fields.String(required=True, metadata={
"description": "Human readable interval description", "description": "Human readable interval description",
"example": "1 minute",
}) })

View File

@@ -32,3 +32,6 @@ class ChangesSchema(Schema):
"description": "Last recorded commit hash", "description": "Last recorded commit hash",
"example": "f1875edca1eb8fc0e55c41d1cae5fa05b6b7c6", "example": "f1875edca1eb8fc0e55c41d1cae5fa05b6b7c6",
}) })
pkgbuild = fields.String(metadata={
"description": "Original PKGBUILD content",
})

View File

@@ -40,6 +40,7 @@ class InfoV2Schema(Schema):
}) })
index_url = fields.String(metadata={ index_url = fields.String(metadata={
"description": "URL to the repository index page", "description": "URL to the repository index page",
"example": "https://ahriman.readthedocs.io/",
}) })
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={ repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
"description": "List of loaded repositories", "description": "List of loaded repositories",

View File

@@ -31,7 +31,7 @@ class RepositoryIdSchema(Schema):
}) })
id = fields.String(metadata={ id = fields.String(metadata={
"description": "Unique repository identifier", "description": "Unique repository identifier",
"example": "aur-x86_64", "example": "x86_64-aur",
}) })
repository = fields.String(metadata={ repository = fields.String(metadata={
"description": "Repository name", "description": "Repository name",

View File

@@ -0,0 +1,65 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import PackageNameSchema, PackageSchema, RepositoryIdSchema
from ahriman.web.views.base import BaseView
from ahriman.web.views.status_view_guard import StatusViewGuard
class Archives(StatusViewGuard, BaseView):
"""
package archives web view
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
"""
GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
ROUTES = ["/api/v1/packages/{package}/archives"]
@apidocs(
tags=["Packages"],
summary="Get package archives",
description="Retrieve built package archives for the base",
permission=GET_PERMISSION,
error_404_description="Package base and/or repository are unknown",
schema=PackageSchema(many=True),
match_schema=PackageNameSchema,
query_schema=RepositoryIdSchema,
)
async def get(self) -> Response:
"""
get package changes
Returns:
Response: 200 with package change on success
Raises:
HTTPNotFound: if package base is unknown
"""
package_base = self.request.match_info["package"]
archives = self.service(package_base=package_base).package_archives(package_base)
return self.json_response([archive.view() for archive in archives])

View File

@@ -92,10 +92,11 @@ class ChangesView(StatusViewGuard, BaseView):
data = await self.request.json() data = await self.request.json()
last_commit_sha = data.get("last_commit_sha") # empty/null meant removal last_commit_sha = data.get("last_commit_sha") # empty/null meant removal
change = data.get("changes") change = data.get("changes")
pkgbuild = data.get("pkgbuild")
except Exception as ex: except Exception as ex:
raise HTTPBadRequest(reason=str(ex)) raise HTTPBadRequest(reason=str(ex))
changes = Changes(last_commit_sha, change) changes = Changes(last_commit_sha, change, pkgbuild)
self.service().package_changes_update(package_base, changes) self.service().package_changes_update(package_base, changes)
raise HTTPNoContent raise HTTPNoContent

View File

@@ -23,12 +23,14 @@ import logging
import socket import socket
from aiohttp.web import Application, normalize_path_middleware, run_app from aiohttp.web import Application, normalize_path_middleware, run_app
from pathlib import Path
from ahriman.core.auth import Auth from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite from ahriman.core.database import SQLite
from ahriman.core.distributed import WorkersCache from ahriman.core.distributed import WorkersCache
from ahriman.core.exceptions import InitializeError from ahriman.core.exceptions import InitializeError
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
@@ -38,6 +40,7 @@ from ahriman.web.cors import setup_cors
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
from ahriman.web.middlewares.exception_handler import exception_handler from ahriman.web.middlewares.exception_handler import exception_handler
from ahriman.web.middlewares.metrics_handler import metrics_handler from ahriman.web.middlewares.metrics_handler import metrics_handler
from ahriman.web.middlewares.request_id_handler import request_id_handler
from ahriman.web.routes import setup_routes from ahriman.web.routes import setup_routes
@@ -77,6 +80,33 @@ def _create_socket(configuration: Configuration, application: Application) -> so
return sock return sock
def _create_watcher(path: Path, repository_id: RepositoryId) -> Watcher:
"""
build watcher for selected repository
Args:
path(Path): path to configuration file
repository_id(RepositoryId): repository unique identifier
Returns:
Watcher: watcher instance
"""
logging.getLogger(__name__).info("load repository %s", repository_id)
# load settings explicitly for architecture if any
configuration = Configuration.from_path(path, repository_id)
# load database instance, because it holds identifier
database = SQLite.load(configuration)
# explicitly load local client
client = Client.load(repository_id, configuration, database, report=False)
# load package info wrapper
package_info = PackageInfo()
package_info.configuration = configuration
return Watcher(client, package_info)
async def _on_shutdown(application: Application) -> None: async def _on_shutdown(application: Application) -> None:
""" """
web application shutdown handler web application shutdown handler
@@ -146,6 +176,7 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
application.on_startup.append(_on_startup) application.on_startup.append(_on_startup)
application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True)) application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True))
application.middlewares.append(request_id_handler())
application.middlewares.append(exception_handler(application.logger)) application.middlewares.append(exception_handler(application.logger))
application.middlewares.append(metrics_handler()) application.middlewares.append(metrics_handler())
@@ -166,18 +197,11 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
# package cache # package cache
if not repositories: if not repositories:
raise InitializeError("No repositories configured, exiting") raise InitializeError("No repositories configured, exiting")
watchers: dict[RepositoryId, Watcher] = {}
configuration_path, _ = configuration.check_loaded() configuration_path, _ = configuration.check_loaded()
for repository_id in repositories: application[WatcherKey] = {
application.logger.info("load repository %s", repository_id) repository_id: _create_watcher(configuration_path, repository_id)
# load settings explicitly for architecture if any for repository_id in repositories
repository_configuration = Configuration.from_path(configuration_path, repository_id) }
# load database instance, because it holds identifier
database = SQLite.load(repository_configuration)
# explicitly load local client
client = Client.load(repository_id, repository_configuration, database, report=False)
watchers[repository_id] = Watcher(client)
application[WatcherKey] = watchers
# workers cache # workers cache
application[WorkersKey] = WorkersCache(configuration) application[WorkersKey] = WorkersCache(configuration)
# process spawner # process spawner

View File

@@ -18,7 +18,7 @@ def test_changes(application_repository: ApplicationRepository, package_ahriman:
""" """
must generate changes for the packages must generate changes for the packages
""" """
changes = Changes("hash", "change") changes = Changes("sha", "change")
hashes_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes) hashes_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes)
changes_mock = mocker.patch("ahriman.core.repository.Repository.package_changes", return_value=changes) changes_mock = mocker.patch("ahriman.core.repository.Repository.package_changes", return_value=changes)
report_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update") report_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
@@ -42,6 +42,20 @@ def test_changes_skip(application_repository: ApplicationRepository, package_ahr
report_mock.assert_not_called() report_mock.assert_not_called()
def test_changes_no_update(application_repository: ApplicationRepository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must skip update if package_changes returns None (no new commits)
"""
changes = Changes("sha", "change")
mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes)
mocker.patch("ahriman.core.repository.Repository.package_changes", return_value=None)
report_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
application_repository.changes([package_ahriman])
report_mock.assert_not_called()
def test_clean_cache(application_repository: ApplicationRepository, mocker: MockerFixture) -> None: def test_clean_cache(application_repository: ApplicationRepository, mocker: MockerFixture) -> None:
""" """
must clean cache directory must clean cache directory

View File

@@ -0,0 +1,100 @@
import argparse
import pytest
from pytest_mock import MockerFixture
from ahriman.application.handlers.pkgbuild import Pkgbuild
from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.repository import Repository
from ahriman.models.action import Action
from ahriman.models.changes import Changes
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
Args:
args(argparse.Namespace): command line arguments fixture
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.action = Action.List
args.exit_code = False
args.package = "package"
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
application_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get",
return_value=Changes("sha", "change", "pkgbuild content"))
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
print_mock = mocker.patch("ahriman.core.formatters.Printer.print")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
application_mock.assert_called_once_with(args.package)
check_mock.assert_called_once_with(False, True)
print_mock.assert_called_once_with(verbose=True, log_fn=pytest.helpers.anyvar(int), separator="")
def test_run_empty_exception(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must raise ExitCode exception on empty pkgbuild result
"""
args = _default_args(args)
args.exit_code = True
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=Changes())
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
check_mock.assert_called_once_with(True, False)
def test_run_remove(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must remove package pkgbuild
"""
args = _default_args(args)
args.action = Action.Remove
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
changes = Changes("sha", "change", "pkgbuild content")
mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes)
update_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
update_mock.assert_called_once_with(args.package, Changes("sha", "change", None))
def test_imply_with_report(args: argparse.Namespace, configuration: Configuration, database: SQLite,
mocker: MockerFixture) -> None:
"""
must create application object with native reporting
"""
args = _default_args(args)
mocker.patch("ahriman.core.database.SQLite.load", return_value=database)
load_mock = mocker.patch("ahriman.core.repository.Repository.load")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
load_mock.assert_called_once_with(repository_id, configuration, database, report=True, refresh_pacman_database=0)
def test_disallow_multi_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Pkgbuild.ALLOW_MULTI_ARCHITECTURE_RUN

View File

@@ -309,6 +309,44 @@ def test_subparsers_package_changes_remove_package_changes(parser: argparse.Argu
assert dir(args) == dir(reference_args) assert dir(args) == dir(reference_args)
def test_subparsers_package_pkgbuild(parser: argparse.ArgumentParser) -> None:
"""
package-pkgbuild command must imply action, exit code, lock, quiet, report and unsafe
"""
args = parser.parse_args(["-a", "x86_64", "-r", "repo", "package-pkgbuild", "ahriman"])
assert args.action == Action.List
assert args.architecture == "x86_64"
assert not args.exit_code
assert args.lock is None
assert args.quiet
assert not args.report
assert args.repository == "repo"
assert args.unsafe
def test_subparsers_package_pkgbuild_remove(parser: argparse.ArgumentParser) -> None:
"""
package-pkgbuild-remove command must imply action, lock, quiet, report and unsafe
"""
args = parser.parse_args(["-a", "x86_64", "-r", "repo", "package-pkgbuild-remove", "ahriman"])
assert args.action == Action.Remove
assert args.architecture == "x86_64"
assert args.lock is None
assert args.quiet
assert not args.report
assert args.repository == "repo"
assert args.unsafe
def test_subparsers_package_pkgbuild_remove_package_pkgbuild(parser: argparse.ArgumentParser) -> None:
"""
package-pkgbuild-remove must have same keys as package-pkgbuild
"""
args = parser.parse_args(["-a", "x86_64", "-r", "repo", "package-pkgbuild-remove", "ahriman"])
reference_args = parser.parse_args(["-a", "x86_64", "-r", "repo", "package-pkgbuild", "ahriman"])
assert dir(args) == dir(reference_args)
def test_subparsers_package_copy_option_architecture(parser: argparse.ArgumentParser) -> None: def test_subparsers_package_copy_option_architecture(parser: argparse.ArgumentParser) -> None:
""" """
package-copy command must correctly parse architecture list package-copy command must correctly parse architecture list

View File

@@ -14,7 +14,9 @@ from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite from ahriman.core.database import SQLite
from ahriman.core.database.migrations import Migrations from ahriman.core.database.migrations import Migrations
from ahriman.core.log.log_loader import LogLoader
from ahriman.core.repository import Repository from ahriman.core.repository import Repository
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
@@ -124,6 +126,14 @@ def import_error(package: str, components: list[str], mocker: MockerFixture) ->
# generic fixtures # generic fixtures
@pytest.fixture(autouse=True)
def _register_log_context() -> None:
"""
register log context variables and factory
"""
LogLoader.register_context()
@pytest.fixture @pytest.fixture
def aur_package_ahriman() -> AURPackage: def aur_package_ahriman() -> AURPackage:
""" """
@@ -679,4 +689,5 @@ def watcher(local_client: Client) -> Watcher:
Returns: Returns:
Watcher: package status watcher test instance Watcher: package status watcher test instance
""" """
return Watcher(local_client) package_info = PackageInfo()
return Watcher(local_client, package_info)

View File

@@ -183,6 +183,15 @@ def test_sync_files_local(pacman_database: PacmanDatabase, mocker: MockerFixture
copy_mock.assert_called_once_with(Path("/var/core.files.tar.gz"), pytest.helpers.anyvar(int)) copy_mock.assert_called_once_with(Path("/var/core.files.tar.gz"), pytest.helpers.anyvar(int))
def test_sync_files_no_servers(pacman_database: PacmanDatabase) -> None:
"""
must raise PacmanError if no servers are configured
"""
pacman_database.database.servers = []
with pytest.raises(PacmanError):
pacman_database.sync_files(force=False)
def test_sync_files_unknown_source(pacman_database: PacmanDatabase) -> None: def test_sync_files_unknown_source(pacman_database: PacmanDatabase) -> None:
""" """
must raise an exception in case if server scheme is unsupported must raise an exception in case if server scheme is unsupported

View File

@@ -0,0 +1,37 @@
import pytest
from pathlib import Path
from unittest.mock import MagicMock
from ahriman.core.alpm.pacman_handle import PacmanHandle
def test_package_load() -> None:
"""
must load package from archive path
"""
local = Path("local")
instance = PacmanHandle.ephemeral()
handle_mock = instance.handle = MagicMock()
instance.package_load(local)
handle_mock.load_pkg.assert_called_once_with(str(local))
PacmanHandle._ephemeral = None
def test_getattr() -> None:
"""
must proxy attribute access to underlying handle
"""
instance = PacmanHandle.ephemeral()
assert instance.dbpath
def test_getattr_not_found() -> None:
"""
must raise AttributeError for missing handle attributes
"""
instance = PacmanHandle.ephemeral()
with pytest.raises(AttributeError):
assert instance.random_attribute

View File

@@ -6,6 +6,7 @@ from unittest.mock import call as MockCall
from ahriman.core.build_tools.sources import Sources from ahriman.core.build_tools.sources import Sources
from ahriman.core.exceptions import CalledProcessError from ahriman.core.exceptions import CalledProcessError
from ahriman.models.changes import Changes
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource from ahriman.models.package_source import PackageSource
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -19,35 +20,27 @@ def test_changes(mocker: MockerFixture) -> None:
""" """
fetch_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until") fetch_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until")
diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff", return_value="diff") diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff", return_value="diff")
read_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.read", return_value="pkgbuild")
local = Path("local") local = Path("local")
last_commit_sha = "sha" last_commit_sha = "sha"
assert Sources.changes(local, last_commit_sha) == "diff" assert Sources.changes(local, last_commit_sha) == Changes(last_commit_sha, "diff", "pkgbuild")
fetch_mock.assert_called_once_with(local, commit_sha=last_commit_sha) fetch_mock.assert_called_once_with(local, commit_sha=last_commit_sha)
diff_mock.assert_called_once_with(local, last_commit_sha) diff_mock.assert_called_once_with(local, last_commit_sha)
read_mock.assert_called_once_with(local, "HEAD", Path("PKGBUILD"))
def test_changes_skip(mocker: MockerFixture) -> None:
"""
must return none in case if commit sha is not available
"""
fetch_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until")
diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff")
assert Sources.changes(Path("local"), None) is None
fetch_mock.assert_not_called()
diff_mock.assert_not_called()
def test_changes_unknown_commit(mocker: MockerFixture) -> None: def test_changes_unknown_commit(mocker: MockerFixture) -> None:
""" """
must return none in case if commit sha wasn't found at the required depth must return changes without diff in case if commit sha wasn't found at the required depth
""" """
mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until", return_value=None) mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until", return_value=None)
diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff") diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff")
read_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.read", return_value="pkgbuild")
assert Sources.changes(Path("local"), "sha") is None assert Sources.changes(Path("local"), "sha") == Changes("sha", None, "pkgbuild")
diff_mock.assert_not_called() diff_mock.assert_not_called()
read_mock.assert_called_once_with(Path("local"), "HEAD", Path("PKGBUILD"))
def test_extend_architectures(mocker: MockerFixture) -> None: def test_extend_architectures(mocker: MockerFixture) -> None:
@@ -603,3 +596,12 @@ def test_patch_apply_function(sources: Sources, mocker: MockerFixture) -> None:
sources.patch_apply(local, patch) sources.patch_apply(local, patch)
write_mock.assert_called_once_with(local / "PKGBUILD") write_mock.assert_called_once_with(local / "PKGBUILD")
def test_read(sources: Sources, mocker: MockerFixture) -> None:
"""
must read file from commit
"""
check_output_mock = mocker.patch("ahriman.core.build_tools.sources.check_output", return_value="content")
assert sources.read(Path("local"), "sha", Path("PKGBUILD")) == "content"
check_output_mock.assert_called_once()

View File

@@ -34,8 +34,7 @@ def test_migrate_package_depends(connection: Connection, configuration: Configur
package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman) package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
migrate_package_depends(connection, configuration) migrate_package_depends(connection, configuration)
package_mock.assert_called_once_with( package_mock.assert_called_once_with(package_ahriman.packages[package_ahriman.base].filepath)
package_ahriman.packages[package_ahriman.base].filepath, pytest.helpers.anyvar(int))
connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{ connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{
"make_depends": package_ahriman.packages[package_ahriman.base].make_depends, "make_depends": package_ahriman.packages[package_ahriman.base].make_depends,
"opt_depends": package_ahriman.packages[package_ahriman.base].opt_depends, "opt_depends": package_ahriman.packages[package_ahriman.base].opt_depends,

View File

@@ -34,8 +34,7 @@ def test_migrate_package_depends(connection: Connection, configuration: Configur
package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman) package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
migrate_package_check_depends(connection, configuration) migrate_package_check_depends(connection, configuration)
package_mock.assert_called_once_with( package_mock.assert_called_once_with(package_ahriman.packages[package_ahriman.base].filepath)
package_ahriman.packages[package_ahriman.base].filepath, pytest.helpers.anyvar(int))
connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{ connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{
"check_depends": package_ahriman.packages[package_ahriman.base].check_depends, "check_depends": package_ahriman.packages[package_ahriman.base].check_depends,
"package": package_ahriman.base, "package": package_ahriman.base,

View File

@@ -34,8 +34,7 @@ def test_migrate_package_base_packager(connection: Connection, configuration: Co
package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman) package_mock = mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman)
migrate_package_base_packager(connection, configuration) migrate_package_base_packager(connection, configuration)
package_mock.assert_called_once_with( package_mock.assert_called_once_with(package_ahriman.packages[package_ahriman.base].filepath)
package_ahriman.packages[package_ahriman.base].filepath, pytest.helpers.anyvar(int))
connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{ connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{
"package_base": package_ahriman.base, "package_base": package_ahriman.base,
"packager": package_ahriman.packager, "packager": package_ahriman.packager,

View File

@@ -7,7 +7,6 @@ from sqlite3 import Connection
from typing import Any from typing import Any
from unittest.mock import call as MockCall from unittest.mock import call as MockCall
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database.migrations.m016_archive import migrate_data, move_packages from ahriman.core.database.migrations.m016_archive import migrate_data, move_packages
from ahriman.models.package import Package from ahriman.models.package import Package
@@ -28,12 +27,12 @@ def test_migrate_data(connection: Connection, configuration: Configuration, mock
migrate_data(connection, configuration) migrate_data(connection, configuration)
migration_mock.assert_has_calls([ migration_mock.assert_has_calls([
MockCall(replace(configuration.repository_paths, repository_id=repository), pytest.helpers.anyvar(int)) MockCall(replace(configuration.repository_paths, repository_id=repository))
for repository in repositories for repository in repositories
]) ])
def test_move_packages(repository_paths: RepositoryPaths, pacman: Pacman, package_ahriman: Package, def test_move_packages(repository_paths: RepositoryPaths, package_ahriman: Package,
mocker: MockerFixture) -> None: mocker: MockerFixture) -> None:
""" """
must move packages to the archive directory must move packages to the archive directory
@@ -57,9 +56,9 @@ def test_move_packages(repository_paths: RepositoryPaths, pacman: Pacman, packag
move_mock = mocker.patch("ahriman.core.database.migrations.m016_archive.atomic_move") move_mock = mocker.patch("ahriman.core.database.migrations.m016_archive.atomic_move")
symlink_mock = mocker.patch("pathlib.Path.symlink_to") symlink_mock = mocker.patch("pathlib.Path.symlink_to")
move_packages(repository_paths, pacman) move_packages(repository_paths)
archive_mock.assert_has_calls([ archive_mock.assert_has_calls([
MockCall(repository_paths.repository / filename, pacman) MockCall(repository_paths.repository / filename)
for filename in ("file.pkg.tar.xz", "file2.pkg.tar.xz") for filename in ("file.pkg.tar.xz", "file2.pkg.tar.xz")
]) ])
move_mock.assert_has_calls([ move_mock.assert_has_calls([

View File

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

View File

@@ -8,34 +8,35 @@ def test_changes_insert_get(database: SQLite, package_ahriman: Package) -> None:
""" """
must insert and get changes must insert and get changes
""" """
database.changes_insert(package_ahriman.base, Changes("sha1", "change1")) changes1 = Changes("sha1", "change1", "pkgbuild1")
assert database.changes_get(package_ahriman.base).changes == "change1" database.changes_insert(package_ahriman.base, changes1)
assert database.changes_get(package_ahriman.base) == changes1
changes2 = Changes("sha2", "change2", "pkgbuild2")
database.changes_insert(package_ahriman.base, Changes("sha2", "change2"), database.changes_insert(package_ahriman.base, changes2, RepositoryId("i686", database._repository_id.name))
RepositoryId("i686", database._repository_id.name)) assert database.changes_get(package_ahriman.base) == changes1
assert database.changes_get(package_ahriman.base).changes == "change1" assert database.changes_get(package_ahriman.base, RepositoryId("i686", database._repository_id.name)) == changes2
assert database.changes_get(
package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes == "change2"
def test_changes_insert_remove(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None: def test_changes_insert_remove(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None:
""" """
must remove changes for the package must remove changes for the package
""" """
database.changes_insert(package_ahriman.base, Changes("sha1", "change1")) changes3 = Changes("sha3", "change3", "pkgbuild3")
database.changes_insert(package_python_schedule.base, Changes("sha3", "change3")) database.changes_insert(package_ahriman.base, Changes("sha1", "change1", "pkgbuild1"))
database.changes_insert(package_ahriman.base, Changes("sha2", "change2"), database.changes_insert(package_python_schedule.base, changes3)
database.changes_insert(package_ahriman.base, Changes("sha2", "change2", "pkgbuild2"),
RepositoryId("i686", database._repository_id.name)) RepositoryId("i686", database._repository_id.name))
database.changes_remove(package_ahriman.base) database.changes_remove(package_ahriman.base)
assert database.changes_get(package_ahriman.base).changes is None assert database.changes_get(package_ahriman.base).changes is None
assert database.changes_get(package_python_schedule.base).changes == "change3" assert database.changes_get(package_python_schedule.base) == changes3
# insert null # insert null
database.changes_insert(package_ahriman.base, Changes(), RepositoryId("i686", database._repository_id.name)) database.changes_insert(package_ahriman.base, Changes(), RepositoryId("i686", database._repository_id.name))
assert database.changes_get( assert database.changes_get(
package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes is None package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes is None
assert database.changes_get(package_python_schedule.base).changes == "change3" assert database.changes_get(package_python_schedule.base) == changes3
def test_changes_insert_remove_full(database: SQLite, package_ahriman: Package, def test_changes_insert_remove_full(database: SQLite, package_ahriman: Package,
@@ -43,13 +44,12 @@ def test_changes_insert_remove_full(database: SQLite, package_ahriman: Package,
""" """
must remove all changes for the repository must remove all changes for the repository
""" """
database.changes_insert(package_ahriman.base, Changes("sha1", "change1")) changes2 = Changes("sha2", "change2", "pkgbuild2")
database.changes_insert(package_python_schedule.base, Changes("sha3", "change3")) database.changes_insert(package_ahriman.base, Changes("sha1", "change1", "pkgbuild1"))
database.changes_insert(package_ahriman.base, Changes("sha2", "change2"), database.changes_insert(package_python_schedule.base, Changes("sha3", "change3", "pkgbuild3"))
RepositoryId("i686", database._repository_id.name)) database.changes_insert(package_ahriman.base, changes2, RepositoryId("i686", database._repository_id.name))
database.changes_remove(None) database.changes_remove(None)
assert database.changes_get(package_ahriman.base).changes is None assert database.changes_get(package_ahriman.base).changes is None
assert database.changes_get(package_python_schedule.base).changes is None assert database.changes_get(package_python_schedule.base).changes is None
assert database.changes_get( assert database.changes_get(package_ahriman.base, RepositoryId("i686", database._repository_id.name)) == changes2
package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes == "change2"

View File

@@ -2,24 +2,26 @@ import pytest
from pathlib import Path from pathlib import Path
from ahriman.core.formatters import \ from ahriman.core.formatters import (
AurPrinter, \ AurPrinter,
ChangesPrinter, \ ChangesPrinter,
ConfigurationPathsPrinter, \ ConfigurationPathsPrinter,
ConfigurationPrinter, \ ConfigurationPrinter,
EventStatsPrinter, \ EventStatsPrinter,
PackagePrinter, \ PackagePrinter,
PackageStatsPrinter, \ PackageStatsPrinter,
PatchPrinter, \ PatchPrinter,
RepositoryPrinter, \ PkgbuildPrinter,
RepositoryStatsPrinter, \ RepositoryPrinter,
StatusPrinter, \ RepositoryStatsPrinter,
StringPrinter, \ StatusPrinter,
TreePrinter, \ StringPrinter,
UpdatePrinter, \ TreePrinter,
UserPrinter, \ UpdatePrinter,
ValidationPrinter, \ UserPrinter,
ValidationPrinter,
VersionPrinter VersionPrinter
)
from ahriman.models.aur_package import AURPackage from ahriman.models.aur_package import AURPackage
from ahriman.models.build_status import BuildStatus from ahriman.models.build_status import BuildStatus
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
@@ -55,6 +57,17 @@ def changes_printer() -> ChangesPrinter:
return ChangesPrinter(Changes("sha", "changes")) return ChangesPrinter(Changes("sha", "changes"))
@pytest.fixture
def pkgbuild_printer() -> PkgbuildPrinter:
"""
fixture for pkgbuild printer
Returns:
PkgbuildPrinter: pkgbuild printer test instance
"""
return PkgbuildPrinter(Changes("sha", "changes", "pkgbuild content"))
@pytest.fixture @pytest.fixture
def configuration_paths_printer() -> ConfigurationPathsPrinter: def configuration_paths_printer() -> ConfigurationPathsPrinter:
""" """

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