mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-03-12 04:53:39 +00:00
Compare commits
16 Commits
2.20.0rc6
...
feature/ar
| Author | SHA1 | Date | |
|---|---|---|---|
| 82c2218d08 | |||
| 81aeb56ba3 | |||
| 2cd4ef5e86 | |||
| 998ed48dde | |||
| 021d88dc4c | |||
| 9012ee7144 | |||
| 945ddb2942 | |||
| 9cd0926588 | |||
| f5c12d03ec | |||
| 606ee2fb0e | |||
| 875e9cf58a | |||
| a809a4b67f | |||
| 437ae2c16c | |||
| 10661f3127 | |||
| 88ac3d82a0 | |||
| 386765ab4e |
77
docs/_static/architecture.dot
vendored
77
docs/_static/architecture.dot
vendored
@@ -138,11 +138,12 @@ digraph G {
|
||||
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_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_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_log_loader [fillcolor="#82402b",fontcolor="#ffffff",label="ahriman\.\ncore\.\nlog\.\nlog_loader"];
|
||||
ahriman_core_log_lazy_logging [fillcolor="blue",fontcolor="white",label="ahriman\.\ncore\.\nlog\.\nlazy_logging",shape="box"];
|
||||
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_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"];
|
||||
@@ -242,10 +243,11 @@ digraph G {
|
||||
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_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_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_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_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"];
|
||||
@@ -493,6 +495,7 @@ digraph G {
|
||||
ahriman_core -> ahriman_models_worker [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_request_id_handler [fillcolor="#ef3e06",minlen="3"];
|
||||
ahriman_core -> ahriman_web_routes [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"];
|
||||
@@ -848,35 +851,39 @@ digraph G {
|
||||
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_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_workers_updater [fillcolor="#e53b05",minlen="3"];
|
||||
ahriman_core_log -> ahriman_application_handlers_handler [fillcolor="#e53b05",minlen="3"];
|
||||
ahriman_core_log -> ahriman_application_lock [fillcolor="#e53b05",minlen="2"];
|
||||
ahriman_core_log -> ahriman_core_alpm_pacman [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_alpm_repo [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_archive_archive_tree [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_auth_auth [fillcolor="#e53b05",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_sources [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_build_tools_task [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_database_migrations [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_database_operations_operations [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_distributed_workers_cache [fillcolor="#e53b05",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_push [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_http_sync_http_client [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_report_report [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_repository_repository_properties [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_spawn [fillcolor="#e53b05",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_status_watcher [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_triggers_trigger [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_triggers_trigger_loader [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_upload_upload [fillcolor="#e53b05",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_models_package [fillcolor="#e53b05",minlen="2"];
|
||||
ahriman_core_log -> ahriman_models_repository_paths [fillcolor="#e53b05",minlen="2"];
|
||||
ahriman_core_log -> ahriman_application_application_application_properties [fillcolor="blue",minlen="3"];
|
||||
ahriman_core_log -> ahriman_application_application_workers_updater [fillcolor="blue",minlen="3"];
|
||||
ahriman_core_log -> ahriman_application_handlers_handler [fillcolor="blue",minlen="3"];
|
||||
ahriman_core_log -> ahriman_application_lock [fillcolor="blue",minlen="2"];
|
||||
ahriman_core_log -> ahriman_core_alpm_pacman [fillcolor="blue",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="blue",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="blue",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="blue",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="blue",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="blue",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="blue",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="blue",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_spawn [fillcolor="blue",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_status_watcher [fillcolor="blue",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="blue",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_core_upload_upload [fillcolor="blue",minlen="2",weight="2"];
|
||||
ahriman_core_log -> ahriman_models_package [fillcolor="blue",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_lazy_logging -> ahriman_core_log [fillcolor="#b85a3d",weight="3"];
|
||||
ahriman_core_log_log_loader -> ahriman_application_handlers_handler [fillcolor="#82402b",minlen="3"];
|
||||
ahriman_core_log_lazy_logging -> ahriman_core_log [fillcolor="blue",weight="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_web_routes [fillcolor="#ce5e3b",minlen="2"];
|
||||
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_views_base [fillcolor="#823017",minlen="2",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_web [fillcolor="#e9410c",weight="2"];
|
||||
ahriman_web_middlewares -> ahriman_web_views_v1_status_metrics [fillcolor="#ef3e06",minlen="2",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_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_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_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"];
|
||||
@@ -1939,6 +1947,7 @@ digraph G {
|
||||
aiohttp -> ahriman_web_middlewares_auth_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_request_id_handler [fillcolor="#f9b506",minlen="3"];
|
||||
aiohttp -> ahriman_web_routes [fillcolor="#f9b506",minlen="2"];
|
||||
aiohttp -> ahriman_web_views_api_swagger [fillcolor="#f9b506",minlen="3"];
|
||||
aiohttp -> ahriman_web_views_base [fillcolor="#f9b506",minlen="3"];
|
||||
|
||||
@@ -92,6 +92,14 @@ ahriman.application.handlers.patch module
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
ahriman.application.handlers.pkgbuild module
|
||||
--------------------------------------------
|
||||
|
||||
.. automodule:: ahriman.application.handlers.pkgbuild
|
||||
:members:
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
ahriman.application.handlers.rebuild module
|
||||
-------------------------------------------
|
||||
|
||||
|
||||
@@ -28,6 +28,14 @@ ahriman.core.alpm.pacman\_database module
|
||||
:no-undoc-members:
|
||||
: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
|
||||
-----------------------------------------
|
||||
|
||||
|
||||
@@ -140,6 +140,14 @@ ahriman.core.database.migrations.m016\_archive module
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
ahriman.core.database.migrations.m017\_pkgbuild module
|
||||
------------------------------------------------------
|
||||
|
||||
.. automodule:: ahriman.core.database.migrations.m017_pkgbuild
|
||||
:members:
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
|
||||
@@ -76,6 +76,14 @@ ahriman.core.formatters.patch\_printer module
|
||||
:no-undoc-members:
|
||||
: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
|
||||
--------------------------------------
|
||||
|
||||
|
||||
@@ -28,6 +28,14 @@ ahriman.core.log.lazy\_logging module
|
||||
:no-undoc-members:
|
||||
: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
|
||||
-----------------------------------
|
||||
|
||||
|
||||
@@ -28,6 +28,14 @@ ahriman.web.middlewares.metrics\_handler module
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
ahriman.web.middlewares.request\_id\_handler module
|
||||
---------------------------------------------------
|
||||
|
||||
.. automodule:: ahriman.web.middlewares.request_id_handler
|
||||
:members:
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ Packages have strict rules of importing:
|
||||
* ``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.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:
|
||||
|
||||
@@ -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.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.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.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).
|
||||
@@ -85,6 +86,7 @@ Application run
|
||||
#. 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).
|
||||
#. 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.
|
||||
#. 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``.
|
||||
@@ -159,12 +161,12 @@ Having default root as ``/var/lib/ahriman`` (differs from container though), the
|
||||
├── aur.files -> 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/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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
#. 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, 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
|
||||
^^^^^^^^^^^^^^^
|
||||
@@ -255,6 +257,7 @@ This feature is divided into the following stages: check AUR for updates and run
|
||||
|
||||
#. Download package data from AUR.
|
||||
#. 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.
|
||||
#. Sign packages 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.
|
||||
|
||||
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
|
||||
------------------------
|
||||
@@ -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.
|
||||
|
||||
Logging module has its own context variables, which are required to be registered in advance to avoid possible race conditions.
|
||||
|
||||
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``.
|
||||
|
||||
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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
#. 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 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).
|
||||
@@ -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.
|
||||
* 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
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
@@ -445,7 +459,7 @@ Web application requires the following python packages to be installed:
|
||||
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
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -457,7 +471,7 @@ On the other side, ``OPTIONS`` method is implemented in the ``ahriman.web.middle
|
||||
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.
|
||||
|
||||
@@ -476,7 +490,7 @@ The views are also divided by supporting API versions (e.g. ``v1``, ``v2``).
|
||||
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
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
@@ -27,21 +27,26 @@ export default tseslint.config(
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
|
||||
// imports
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
"simple-import-sort/imports": "error",
|
||||
|
||||
// brackets
|
||||
// core
|
||||
"curly": "error",
|
||||
"@stylistic/brace-style": ["error", "1tbs"],
|
||||
"eqeqeq": "error",
|
||||
"no-console": "error",
|
||||
"no-eval": "error",
|
||||
|
||||
// stylistic
|
||||
"@stylistic/array-bracket-spacing": ["error", "never"],
|
||||
"@stylistic/arrow-parens": ["error", "as-needed"],
|
||||
"@stylistic/brace-style": ["error", "1tbs"],
|
||||
"@stylistic/comma-dangle": ["error", "always-multiline"],
|
||||
"@stylistic/comma-spacing": ["error", { before: false, after: true }],
|
||||
"@stylistic/eol-last": ["error", "always"],
|
||||
"@stylistic/indent": ["error", 4],
|
||||
"@stylistic/jsx-curly-brace-presence": ["error", { props: "never", children: "never" }],
|
||||
"@stylistic/jsx-quotes": ["error", "prefer-double"],
|
||||
"@stylistic/jsx-self-closing-comp": ["error", { component: true, html: true }],
|
||||
"@stylistic/max-len": ["error", {
|
||||
code: 120,
|
||||
ignoreComments: true,
|
||||
@@ -49,6 +54,7 @@ export default tseslint.config(
|
||||
ignoreTemplateLiterals: true,
|
||||
ignoreUrls: true,
|
||||
}],
|
||||
"@stylistic/member-delimiter-style": ["error", { multiline: { delimiter: "semi" }, singleline: { delimiter: "semi" } }],
|
||||
"@stylistic/no-extra-parens": ["error", "all"],
|
||||
"@stylistic/no-multi-spaces": "error",
|
||||
"@stylistic/no-multiple-empty-lines": ["error", { max: 1 }],
|
||||
@@ -58,10 +64,14 @@ export default tseslint.config(
|
||||
"@stylistic/semi": ["error", "always"],
|
||||
|
||||
// typescript
|
||||
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
|
||||
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
||||
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
|
||||
"@typescript-eslint/no-deprecated": "error",
|
||||
"@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",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ahriman-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "2.20.0-rc6",
|
||||
"version": "2.20.0",
|
||||
"scripts": {
|
||||
"build": "tsc && vite build",
|
||||
"dev": "vite",
|
||||
@@ -11,30 +11,30 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^7.3.8",
|
||||
"@mui/material": "^7.3.8",
|
||||
"@mui/x-data-grid": "^8.27.3",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.9",
|
||||
"@mui/material": "^7.3.9",
|
||||
"@mui/x-data-grid": "^8.27.4",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"chart.js": "^4.5.1",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-syntax-highlighter": "^16.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@stylistic/eslint-plugin": "^5.9.0",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"typescript": "^5.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1",
|
||||
"vite-tsconfig-paths": "^6.1.1"
|
||||
|
||||
@@ -17,16 +17,14 @@
|
||||
* 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 } from "@mui/material/styles";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import AppLayout from "components/layout/AppLayout";
|
||||
import { AuthProvider } from "contexts/AuthProvider";
|
||||
import { ClientProvider } from "contexts/ClientProvider";
|
||||
import { NotificationProvider } from "contexts/NotificationProvider";
|
||||
import { RepositoryProvider } from "contexts/RepositoryProvider";
|
||||
import { ThemeProvider } from "contexts/ThemeProvider";
|
||||
import type React from "react";
|
||||
import Theme from "theme/Theme";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -39,8 +37,7 @@ const queryClient = new QueryClient({
|
||||
|
||||
export default function App(): React.JSX.Element {
|
||||
return <QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={Theme}>
|
||||
<CssBaseline />
|
||||
<ThemeProvider>
|
||||
<NotificationProvider>
|
||||
<ClientProvider>
|
||||
<AuthProvider>
|
||||
|
||||
@@ -40,6 +40,7 @@ export class Client {
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
"X-Request-ID": crypto.randomUUID(),
|
||||
};
|
||||
if (json !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
@@ -49,7 +50,7 @@ export class Client {
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
method: method || (json ? "POST" : "GET"),
|
||||
method: method ?? (json ? "POST" : "GET"),
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { blue } from "@mui/material/colors";
|
||||
import type { Event } from "models/Event";
|
||||
import type React from "react";
|
||||
import { Line } from "react-chartjs-2";
|
||||
@@ -33,6 +34,8 @@ export default function EventDurationLineChart({ events }: EventDurationLineChar
|
||||
{
|
||||
label: "update duration, s",
|
||||
data: updateEvents.map(event => event.data?.took ?? 0),
|
||||
borderColor: blue[500],
|
||||
backgroundColor: blue[200],
|
||||
cubicInterpolationMode: "monotone" as const,
|
||||
tension: 0.4,
|
||||
},
|
||||
|
||||
@@ -31,16 +31,16 @@ export default function PackageCountBarChart({ stats }: PackageCountBarChartProp
|
||||
data={{
|
||||
labels: ["packages"],
|
||||
datasets: [
|
||||
{
|
||||
label: "archives",
|
||||
data: [stats.packages ?? 0],
|
||||
backgroundColor: blue[500],
|
||||
},
|
||||
{
|
||||
label: "bases",
|
||||
data: [stats.bases ?? 0],
|
||||
backgroundColor: indigo[300],
|
||||
},
|
||||
{
|
||||
label: "archives",
|
||||
data: [stats.packages ?? 0],
|
||||
backgroundColor: blue[500],
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={{
|
||||
@@ -48,7 +48,7 @@ export default function PackageCountBarChart({ stats }: PackageCountBarChartProp
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: { stacked: true },
|
||||
y: { stacked: true },
|
||||
y: { stacked: false },
|
||||
},
|
||||
}}
|
||||
/>;
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function AutoRefreshControl({
|
||||
<Tooltip title="Auto-refresh">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Auto-refresh"
|
||||
onClick={event => setAnchorEl(event.currentTarget)}
|
||||
color={enabled ? "primary" : "default"}
|
||||
>
|
||||
|
||||
@@ -17,47 +17,57 @@
|
||||
* 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 { Box } from "@mui/material";
|
||||
import "components/common/syntaxLanguages";
|
||||
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
import CopyButton from "components/common/CopyButton";
|
||||
import { useThemeMode } from "hooks/useThemeMode";
|
||||
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 {
|
||||
preRef?: RefObject<HTMLElement | null>;
|
||||
getText: () => string;
|
||||
content: string;
|
||||
height?: number | string;
|
||||
language?: string;
|
||||
onScroll?: () => void;
|
||||
wordBreak?: boolean;
|
||||
preRef?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export default function CodeBlock({
|
||||
preRef,
|
||||
getText,
|
||||
content,
|
||||
height,
|
||||
language = "text",
|
||||
onScroll,
|
||||
wordBreak,
|
||||
preRef,
|
||||
}: CodeBlockProps): React.JSX.Element {
|
||||
const { mode } = useThemeMode();
|
||||
const theme = useTheme();
|
||||
|
||||
return <Box sx={{ position: "relative" }}>
|
||||
<Box
|
||||
ref={preRef}
|
||||
component="pre"
|
||||
onScroll={onScroll}
|
||||
sx={{
|
||||
backgroundColor: "grey.100",
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
overflow: "auto",
|
||||
height,
|
||||
fontSize: "0.8rem",
|
||||
fontFamily: "monospace",
|
||||
...wordBreak ? { whiteSpace: "pre-wrap", wordBreak: "break-all" } : {},
|
||||
}}
|
||||
sx={{ overflow: "auto", height }}
|
||||
>
|
||||
<code>
|
||||
{getText()}
|
||||
</code>
|
||||
</Box>
|
||||
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||
<CopyButton getText={getText} />
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={mode === "dark" ? vs2015 : githubGist}
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
padding: theme.spacing(2),
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
fontSize: "0.8rem",
|
||||
fontFamily: "monospace",
|
||||
margin: 0,
|
||||
minHeight: "100%",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</SyntaxHighlighter>
|
||||
</Box>
|
||||
{content && <Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||
<CopyButton text={content} />
|
||||
</Box>}
|
||||
</Box>;
|
||||
}
|
||||
|
||||
@@ -23,17 +23,17 @@ import { IconButton, Tooltip } from "@mui/material";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
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 timer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => () => clearTimeout(timer.current), []);
|
||||
|
||||
const handleCopy: () => Promise<void> = async () => {
|
||||
await navigator.clipboard.writeText(getText());
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), 2000);
|
||||
|
||||
@@ -25,12 +25,12 @@ import type React from "react";
|
||||
export default function RepositorySelect({
|
||||
repositorySelect,
|
||||
}: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element {
|
||||
const { repositories, current } = useRepository();
|
||||
const { repositories, currentRepository } = useRepository();
|
||||
|
||||
return <FormControl fullWidth margin="normal">
|
||||
<InputLabel>repository</InputLabel>
|
||||
<Select
|
||||
value={repositorySelect.selectedKey || (current?.key ?? "")}
|
||||
value={repositorySelect.selectedKey || (currentRepository?.key ?? "")}
|
||||
label="repository"
|
||||
onChange={event => repositorySelect.setSelectedKey(event.target.value)}
|
||||
>
|
||||
|
||||
27
frontend/src/components/common/syntaxLanguages.ts
Normal file
27
frontend/src/components/common/syntaxLanguages.ts
Normal 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);
|
||||
@@ -36,11 +36,11 @@ interface DashboardDialogProps {
|
||||
|
||||
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
|
||||
const client = useClient();
|
||||
const { current } = useRepository();
|
||||
const { currentRepository } = useRepository();
|
||||
|
||||
const { data: status } = useQuery<InternalStatus>({
|
||||
queryKey: current ? QueryKeys.status(current) : ["status"],
|
||||
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
|
||||
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
|
||||
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
@@ -86,12 +86,12 @@ export default function DashboardDialog({ open, onClose }: DashboardDialogProps)
|
||||
|
||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ maxHeight: 300 }}>
|
||||
<Box sx={{ height: 300 }}>
|
||||
<PackageCountBarChart stats={status.stats} />
|
||||
</Box>
|
||||
</Grid>
|
||||
<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} />
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
|
||||
/>
|
||||
{keyBody &&
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<CodeBlock getText={() => keyBody} height={300} />
|
||||
<CodeBlock content={keyBody} height={300} />
|
||||
</Box>
|
||||
}
|
||||
</DialogContent>
|
||||
|
||||
@@ -27,6 +27,8 @@ import EventsTab from "components/package/EventsTab";
|
||||
import PackageDetailsGrid from "components/package/PackageDetailsGrid";
|
||||
import PackageInfoActions from "components/package/PackageInfoActions";
|
||||
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 { useAuth } from "hooks/useAuth";
|
||||
import { useAutoRefresh } from "hooks/useAutoRefresh";
|
||||
@@ -55,7 +57,7 @@ export default function PackageInfoDialog({
|
||||
autoRefreshIntervals,
|
||||
}: PackageInfoDialogProps): React.JSX.Element {
|
||||
const client = useClient();
|
||||
const { current } = useRepository();
|
||||
const { currentRepository } = useRepository();
|
||||
const { isAuthorized } = useAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -65,11 +67,11 @@ export default function PackageInfoDialog({
|
||||
setLocalPackageBase(packageBase);
|
||||
}
|
||||
|
||||
const [tabIndex, setTabIndex] = useState(0);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("logs");
|
||||
const [refreshDatabase, setRefreshDatabase] = useState(true);
|
||||
|
||||
const handleClose = (): void => {
|
||||
setTabIndex(0);
|
||||
setActiveTab("logs");
|
||||
setRefreshDatabase(true);
|
||||
onClose();
|
||||
};
|
||||
@@ -77,16 +79,17 @@ export default function PackageInfoDialog({
|
||||
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
|
||||
|
||||
const { data: packageData } = useQuery<PackageStatus[]>({
|
||||
queryKey: localPackageBase && current ? QueryKeys.package(localPackageBase, current) : ["packages"],
|
||||
queryFn: localPackageBase && current ? () => client.fetch.fetchPackage(localPackageBase, current) : skipToken,
|
||||
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
|
||||
queryFn: localPackageBase && currentRepository ?
|
||||
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
|
||||
enabled: open,
|
||||
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
||||
});
|
||||
|
||||
const { data: dependencies } = useQuery<Dependencies>({
|
||||
queryKey: localPackageBase && current ? QueryKeys.dependencies(localPackageBase, current) : ["dependencies"],
|
||||
queryFn: localPackageBase && current
|
||||
? () => client.fetch.fetchPackageDependencies(localPackageBase, current) : skipToken,
|
||||
queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"],
|
||||
queryFn: localPackageBase && currentRepository ?
|
||||
() => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
@@ -102,11 +105,12 @@ export default function PackageInfoDialog({
|
||||
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
|
||||
|
||||
const handleUpdate: () => Promise<void> = async () => {
|
||||
if (!localPackageBase || !current) {
|
||||
if (!localPackageBase || !currentRepository) {
|
||||
return;
|
||||
}
|
||||
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}`);
|
||||
} catch (exception) {
|
||||
showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`);
|
||||
@@ -114,11 +118,11 @@ export default function PackageInfoDialog({
|
||||
};
|
||||
|
||||
const handleRemove: () => Promise<void> = async () => {
|
||||
if (!localPackageBase || !current) {
|
||||
if (!localPackageBase || !currentRepository) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.service.servicePackageRemove(current, [localPackageBase]);
|
||||
await client.service.servicePackageRemove(currentRepository, [localPackageBase]);
|
||||
showSuccess("Success", `Packages ${localPackageBase} have been removed`);
|
||||
onClose();
|
||||
} catch (exception) {
|
||||
@@ -156,25 +160,26 @@ export default function PackageInfoDialog({
|
||||
/>
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
|
||||
<Tabs value={tabIndex} onChange={(_, index: number) => setTabIndex(index)}>
|
||||
<Tab label="Build logs" />
|
||||
<Tab label="Changes" />
|
||||
<Tab label="Events" />
|
||||
<Tabs value={activeTab} onChange={(_, tab: TabKey) => setActiveTab(tab)}>
|
||||
{tabs.map(({ key, label }) => <Tab key={key} value={key} label={label} />)}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{tabIndex === 0 && localPackageBase && current &&
|
||||
{activeTab === "logs" && localPackageBase && currentRepository &&
|
||||
<BuildLogsTab
|
||||
packageBase={localPackageBase}
|
||||
repository={current}
|
||||
repository={currentRepository}
|
||||
refreshInterval={autoRefresh.interval}
|
||||
/>
|
||||
}
|
||||
{tabIndex === 1 && localPackageBase && current &&
|
||||
<ChangesTab packageBase={localPackageBase} repository={current} />
|
||||
{activeTab === "changes" && localPackageBase && currentRepository &&
|
||||
<ChangesTab packageBase={localPackageBase} repository={currentRepository} />
|
||||
}
|
||||
{tabIndex === 2 && localPackageBase && current &&
|
||||
<EventsTab packageBase={localPackageBase} repository={current} />
|
||||
{activeTab === "pkgbuild" && localPackageBase && currentRepository &&
|
||||
<PkgbuildTab packageBase={localPackageBase} repository={currentRepository} />
|
||||
}
|
||||
{activeTab === "events" && localPackageBase && currentRepository &&
|
||||
<EventsTab packageBase={localPackageBase} repository={currentRepository} />
|
||||
}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* 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 { 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 LoginDialog from "components/dialogs/LoginDialog";
|
||||
import Footer from "components/layout/Footer";
|
||||
@@ -27,6 +29,7 @@ import { QueryKeys } from "hooks/QueryKeys";
|
||||
import { useAuth } from "hooks/useAuth";
|
||||
import { useClient } from "hooks/useClient";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { useThemeMode } from "hooks/useThemeMode";
|
||||
import type { InfoResponse } from "models/InfoResponse";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
@@ -34,6 +37,7 @@ export default function AppLayout(): React.JSX.Element {
|
||||
const client = useClient();
|
||||
const { setAuthState } = useAuth();
|
||||
const { setRepositories } = useRepository();
|
||||
const { mode, toggleTheme } = useThemeMode();
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
const { data: info } = useQuery<InfoResponse>({
|
||||
@@ -52,12 +56,17 @@ export default function AppLayout(): React.JSX.Element {
|
||||
|
||||
return <Container maxWidth="xl">
|
||||
<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="" />
|
||||
</a>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Navbar />
|
||||
</Box>
|
||||
<Tooltip title="Toggle theme">
|
||||
<IconButton aria-label="Toggle theme" onClick={toggleTheme}>
|
||||
{mode === "dark" ? <Brightness7Icon /> : <Brightness4Icon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<PackageTable
|
||||
|
||||
@@ -22,14 +22,15 @@ import { useRepository } from "hooks/useRepository";
|
||||
import type React from "react";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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" }}>
|
||||
@@ -38,7 +39,7 @@ export default function Navbar(): React.JSX.Element | null {
|
||||
onChange={(_, newValue: number) => {
|
||||
const repository = repositories[newValue];
|
||||
if (repository) {
|
||||
setCurrent(repository);
|
||||
setCurrentRepository(repository);
|
||||
}
|
||||
}}
|
||||
variant="scrollable"
|
||||
|
||||
@@ -175,10 +175,9 @@ export default function BuildLogsTab({
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<CodeBlock
|
||||
preRef={preRef}
|
||||
getText={() => displayedLogs}
|
||||
content={displayedLogs}
|
||||
height={400}
|
||||
onScroll={handleScroll}
|
||||
wordBreak
|
||||
/>
|
||||
</Box>
|
||||
</Box>;
|
||||
|
||||
@@ -17,19 +17,10 @@
|
||||
* 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 { Box } from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import CopyButton from "components/common/CopyButton";
|
||||
import { QueryKeys } from "hooks/QueryKeys";
|
||||
import { useClient } from "hooks/useClient";
|
||||
import type { Changes } from "models/Changes";
|
||||
import CodeBlock from "components/common/CodeBlock";
|
||||
import { usePackageChanges } from "hooks/usePackageChanges";
|
||||
import type { RepositoryId } from "models/RepositoryId";
|
||||
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 {
|
||||
packageBase: string;
|
||||
@@ -37,34 +28,7 @@ interface ChangesTabProps {
|
||||
}
|
||||
|
||||
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
|
||||
const client = useClient();
|
||||
const data = usePackageChanges(packageBase, repository);
|
||||
|
||||
const { data } = useQuery<Changes>({
|
||||
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>;
|
||||
return <CodeBlock language="diff" content={data?.changes ?? ""} height={400} />;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function PackagePatchesList({
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
{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" />
|
||||
</IconButton>
|
||||
}
|
||||
|
||||
34
frontend/src/components/package/PkgbuildTab.tsx
Normal file
34
frontend/src/components/package/PkgbuildTab.tsx
Normal 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} />;
|
||||
}
|
||||
27
frontend/src/components/package/TabKey.ts
Normal file
27
frontend/src/components/package/TabKey.ts
Normal 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" },
|
||||
];
|
||||
@@ -22,9 +22,9 @@ import { createContext } from "react";
|
||||
|
||||
export interface RepositoryContextValue {
|
||||
repositories: RepositoryId[];
|
||||
current: RepositoryId | null;
|
||||
currentRepository: RepositoryId | null;
|
||||
setRepositories: (repositories: RepositoryId[]) => void;
|
||||
setCurrent: (repository: RepositoryId) => void;
|
||||
setCurrentRepository: (repository: RepositoryId) => void;
|
||||
}
|
||||
|
||||
export const RepositoryContext = createContext<RepositoryContextValue | null>(null);
|
||||
|
||||
@@ -34,20 +34,20 @@ export function RepositoryProvider({ children }: { children: ReactNode }): React
|
||||
const [repositories, setRepositories] = useState<RepositoryId[]>([]);
|
||||
const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
|
||||
|
||||
const current = useMemo(() => {
|
||||
const currentRepository = useMemo(() => {
|
||||
if (repositories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null;
|
||||
}, [repositories, hash]);
|
||||
|
||||
const setCurrent = useCallback((repository: RepositoryId) => {
|
||||
const setCurrentRepository = useCallback((repository: RepositoryId) => {
|
||||
window.location.hash = repository.key;
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
repositories, current, setRepositories, setCurrent,
|
||||
}), [repositories, current, setCurrent]);
|
||||
repositories, currentRepository, setRepositories, setCurrentRepository,
|
||||
}), [repositories, currentRepository, setCurrentRepository]);
|
||||
|
||||
return <RepositoryContext.Provider value={value}>{children}</RepositoryContext.Provider>;
|
||||
}
|
||||
|
||||
27
frontend/src/contexts/ThemeContext.ts
Normal file
27
frontend/src/contexts/ThemeContext.ts
Normal 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);
|
||||
56
frontend/src/contexts/ThemeProvider.tsx
Normal file
56
frontend/src/contexts/ThemeProvider.tsx
Normal 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>;
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function usePackageActions(
|
||||
setSelectionModel: (model: string[]) => void,
|
||||
): UsePackageActionsResult {
|
||||
const client = useClient();
|
||||
const { current } = useRepository();
|
||||
const { currentRepository } = useRepository();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -50,13 +50,13 @@ export function usePackageActions(
|
||||
action: (repository: RepositoryId) => Promise<string>,
|
||||
errorMessage: string,
|
||||
): Promise<void> => {
|
||||
if (!current) {
|
||||
if (!currentRepository) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const successMessage = await action(current);
|
||||
const successMessage = await action(currentRepository);
|
||||
showSuccess("Success", successMessage);
|
||||
invalidate(current);
|
||||
invalidate(currentRepository);
|
||||
setSelectionModel([]);
|
||||
} catch (exception) {
|
||||
showError("Action failed", `${errorMessage}: ${ApiError.errorDetail(exception)}`);
|
||||
@@ -64,8 +64,8 @@ export function usePackageActions(
|
||||
};
|
||||
|
||||
const handleReload: () => void = () => {
|
||||
if (current !== null) {
|
||||
invalidate(current);
|
||||
if (currentRepository !== null) {
|
||||
invalidate(currentRepository);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
36
frontend/src/hooks/usePackageChanges.ts
Normal file
36
frontend/src/hooks/usePackageChanges.ts
Normal 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;
|
||||
}
|
||||
@@ -39,20 +39,20 @@ export interface UsePackageDataResult {
|
||||
|
||||
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
|
||||
const client = useClient();
|
||||
const { current } = useRepository();
|
||||
const { currentRepository } = useRepository();
|
||||
const { isAuthorized } = useAuth();
|
||||
|
||||
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
|
||||
|
||||
const { data: packages = [], isLoading } = useQuery({
|
||||
queryKey: current ? QueryKeys.packages(current) : ["packages"],
|
||||
queryFn: current ? () => client.fetch.fetchPackages(current) : skipToken,
|
||||
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
|
||||
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
|
||||
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
||||
});
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: current ? QueryKeys.status(current) : ["status"],
|
||||
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
|
||||
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
|
||||
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
|
||||
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,10 +29,10 @@ export interface SelectedRepositoryResult {
|
||||
}
|
||||
|
||||
export function useSelectedRepository(): SelectedRepositoryResult {
|
||||
const { repositories, current } = useRepository();
|
||||
const { repositories, currentRepository } = useRepository();
|
||||
const [selectedKey, setSelectedKey] = useState("");
|
||||
|
||||
let selectedRepository: RepositoryId | null = current;
|
||||
let selectedRepository: RepositoryId | null = currentRepository;
|
||||
if (selectedKey) {
|
||||
const repository = repositories.find(repository => repository.key === selectedKey);
|
||||
if (repository) {
|
||||
|
||||
25
frontend/src/hooks/useThemeMode.ts
Normal file
25
frontend/src/hooks/useThemeMode.ts
Normal 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);
|
||||
}
|
||||
@@ -20,4 +20,5 @@
|
||||
export interface Changes {
|
||||
changes?: string;
|
||||
last_commit_sha?: string;
|
||||
pkgbuild?: string;
|
||||
}
|
||||
|
||||
@@ -21,15 +21,15 @@ import { amber, green, grey, orange, red } from "@mui/material/colors";
|
||||
import type { BuildStatus } from "models/BuildStatus";
|
||||
|
||||
const base: Record<BuildStatus, string> = {
|
||||
unknown: grey[800],
|
||||
pending: amber[900],
|
||||
building: orange[900],
|
||||
failed: red[900],
|
||||
success: green[800],
|
||||
unknown: grey[600],
|
||||
pending: amber[700],
|
||||
building: orange[800],
|
||||
failed: red[700],
|
||||
success: green[700],
|
||||
};
|
||||
|
||||
const headerBase: Record<BuildStatus, string> = {
|
||||
unknown: grey[800],
|
||||
unknown: grey[600],
|
||||
pending: amber[700],
|
||||
building: orange[600],
|
||||
failed: red[500],
|
||||
|
||||
@@ -17,17 +17,20 @@
|
||||
* 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 { createTheme } from "@mui/material/styles";
|
||||
import { createTheme, type Theme } from "@mui/material/styles";
|
||||
|
||||
const Theme = createTheme({
|
||||
components: {
|
||||
MuiDialog: {
|
||||
defaultProps: {
|
||||
maxWidth: "lg",
|
||||
fullWidth: true,
|
||||
export function createAppTheme(mode: "light" | "dark"): Theme {
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
},
|
||||
components: {
|
||||
MuiDialog: {
|
||||
defaultProps: {
|
||||
maxWidth: "lg",
|
||||
fullWidth: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default Theme;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
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 {
|
||||
name: "rename-html",
|
||||
name: "rename",
|
||||
enforce: "post",
|
||||
generateBundle(_, bundle) {
|
||||
if (bundle["index.html"]) {
|
||||
bundle["index.html"].fileName = newName;
|
||||
if (bundle[oldName]) {
|
||||
bundle[oldName].fileName = newName;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tsconfigPaths(), renameHtml("build-status.jinja2")],
|
||||
plugins: [react(), tsconfigPaths(), rename("index.html", "build-status.jinja2")],
|
||||
base: "/",
|
||||
build: {
|
||||
chunkSizeWarningLimit: 10000,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
pkgbase='ahriman'
|
||||
pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web')
|
||||
pkgver=2.20.0rc6
|
||||
pkgver=2.20.0
|
||||
pkgrel=1
|
||||
pkgdesc="ArcH linux ReposItory MANager"
|
||||
arch=('any')
|
||||
|
||||
@@ -26,10 +26,12 @@ formatter = syslog_format
|
||||
args = ("/dev/log",)
|
||||
|
||||
[formatter_generic_format]
|
||||
format = [%(levelname)s %(asctime)s] [%(name)s]: %(message)s
|
||||
format = [{levelname} {asctime}] [{name}]: {message}
|
||||
style = {
|
||||
|
||||
[formatter_syslog_format]
|
||||
format = [%(levelname)s] [%(name)s]: %(message)s
|
||||
format = [{levelname}] [{name}]: {message}
|
||||
style = {
|
||||
|
||||
[logger_root]
|
||||
level = DEBUG
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.TH AHRIMAN "1" "2026\-03\-06" "ahriman 2.20.0rc6" "ArcH linux ReposItory MANager"
|
||||
.TH AHRIMAN "1" "2026\-03\-08" "ahriman 2.20.0" "ArcH linux ReposItory MANager"
|
||||
.SH NAME
|
||||
ahriman \- ArcH linux ReposItory MANager
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -17,4 +17,4 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "2.20.0rc6"
|
||||
__version__ = "2.20.0"
|
||||
|
||||
@@ -45,8 +45,8 @@ class ApplicationRepository(ApplicationProperties):
|
||||
if last_commit_sha is None:
|
||||
continue # skip check in case if we can't calculate diff
|
||||
|
||||
changes = self.repository.package_changes(package, last_commit_sha)
|
||||
self.repository.reporter.package_changes_update(package.base, changes)
|
||||
if (changes := self.repository.package_changes(package, last_commit_sha)) is not None:
|
||||
self.repository.reporter.package_changes_update(package.base, changes)
|
||||
|
||||
def clean(self, *, cache: bool, chroot: bool, manual: bool, packages: bool, pacman: bool) -> None:
|
||||
"""
|
||||
|
||||
@@ -54,7 +54,7 @@ class Change(Handler):
|
||||
case Action.List:
|
||||
changes = client.package_changes_get(args.package)
|
||||
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:
|
||||
client.package_changes_update(args.package, Changes())
|
||||
|
||||
|
||||
101
src/ahriman/application/handlers/pkgbuild.py
Normal file
101
src/ahriman/application/handlers/pkgbuild.py
Normal 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]
|
||||
@@ -24,10 +24,11 @@ import tarfile
|
||||
from collections.abc import Iterable, Iterator
|
||||
from functools import cached_property
|
||||
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 ahriman.core.alpm.pacman_database import PacmanDatabase
|
||||
from ahriman.core.alpm.pacman_handle import PacmanHandle
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.log import LazyLogging
|
||||
from ahriman.core.utils import trim_package
|
||||
@@ -61,16 +62,16 @@ class Pacman(LazyLogging):
|
||||
self.refresh_database = refresh_database
|
||||
|
||||
@cached_property
|
||||
def handle(self) -> Handle:
|
||||
def handle(self) -> PacmanHandle:
|
||||
"""
|
||||
pyalpm handle
|
||||
|
||||
Returns:
|
||||
Handle: generated pyalpm handle instance
|
||||
PacmanHandle: generated pyalpm handle instance
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -78,14 +79,14 @@ class Pacman(LazyLogging):
|
||||
refresh_database(PacmanSynchronization): synchronize local cache to remote
|
||||
|
||||
Returns:
|
||||
Handle: fully initialized pacman handle
|
||||
PacmanHandle: fully initialized pacman handle
|
||||
"""
|
||||
pacman_root = self.configuration.getpath("alpm", "database")
|
||||
use_ahriman_cache = self.configuration.getboolean("alpm", "use_ahriman_cache")
|
||||
|
||||
database_path = self.repository_paths.pacman if use_ahriman_cache else pacman_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"):
|
||||
database = self.database_init(handle, repository, self.repository_id.architecture)
|
||||
@@ -99,12 +100,12 @@ class Pacman(LazyLogging):
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
pacman_root(Path): operating system pacman root
|
||||
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():
|
||||
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
|
||||
|
||||
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)
|
||||
architecture(str): repository architecture
|
||||
|
||||
@@ -164,12 +165,12 @@ class Pacman(LazyLogging):
|
||||
|
||||
return database
|
||||
|
||||
def database_sync(self, handle: Handle, *, force: bool) -> None:
|
||||
def database_sync(self, handle: PacmanHandle, *, force: bool) -> None:
|
||||
"""
|
||||
sync local database
|
||||
|
||||
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``)
|
||||
"""
|
||||
self.logger.info("refresh ahriman's home pacman database (force refresh %s)", force)
|
||||
|
||||
@@ -138,8 +138,14 @@ class PacmanDatabase(SyncHttpClient):
|
||||
|
||||
Args:
|
||||
force(bool): force database synchronization (same as ``pacman -Syy``)
|
||||
|
||||
Raises:
|
||||
PacmanError: on operation error (invalid scheme or incomplete configuration)
|
||||
"""
|
||||
server = next(iter(self.database.servers))
|
||||
try:
|
||||
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"
|
||||
url = f"{server}/{filename}"
|
||||
|
||||
|
||||
81
src/ahriman/core/alpm/pacman_handle.py
Normal file
81
src/ahriman/core/alpm/pacman_handle.py
Normal 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)
|
||||
@@ -58,7 +58,7 @@ class Auth(LazyLogging):
|
||||
Returns:
|
||||
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
|
||||
def is_external(self) -> bool:
|
||||
|
||||
@@ -26,6 +26,7 @@ from typing import ClassVar
|
||||
from ahriman.core.exceptions import CalledProcessError
|
||||
from ahriman.core.log import LazyLogging
|
||||
from ahriman.core.utils import check_output, utcnow, walk
|
||||
from ahriman.models.changes import Changes
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.pkgbuild import Pkgbuild
|
||||
from ahriman.models.pkgbuild_patch import PkgbuildPatch
|
||||
@@ -51,24 +52,25 @@ class Sources(LazyLogging):
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
Args:
|
||||
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:
|
||||
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()
|
||||
|
||||
diff = None
|
||||
if instance.fetch_until(source_dir, commit_sha=last_commit_sha) is not None:
|
||||
return instance.diff(source_dir, last_commit_sha)
|
||||
return None
|
||||
diff = instance.diff(source_dir, last_commit_sha)
|
||||
pkgbuild = instance.read(source_dir, "HEAD", Path("PKGBUILD"))
|
||||
|
||||
return Changes(last_commit_sha, diff, pkgbuild)
|
||||
|
||||
@staticmethod
|
||||
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)
|
||||
else:
|
||||
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)
|
||||
|
||||
@@ -19,11 +19,9 @@
|
||||
#
|
||||
from sqlite3 import Connection
|
||||
|
||||
from ahriman.core.alpm.pacman import Pacman
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.utils import package_like
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.pacman_synchronization import PacmanSynchronization
|
||||
|
||||
|
||||
__all__ = ["migrate_data", "steps"]
|
||||
@@ -61,12 +59,9 @@ def migrate_package_depends(connection: Connection, configuration: Configuration
|
||||
if not configuration.repository_paths.repository.is_dir():
|
||||
return
|
||||
|
||||
_, repository_id = configuration.check_loaded()
|
||||
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
|
||||
|
||||
package_list = []
|
||||
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():
|
||||
package_list.append({
|
||||
"make_depends": description.make_depends,
|
||||
|
||||
@@ -19,11 +19,9 @@
|
||||
#
|
||||
from sqlite3 import Connection
|
||||
|
||||
from ahriman.core.alpm.pacman import Pacman
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.utils import package_like
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.pacman_synchronization import PacmanSynchronization
|
||||
|
||||
|
||||
__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():
|
||||
return
|
||||
|
||||
_, repository_id = configuration.check_loaded()
|
||||
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
|
||||
|
||||
package_list = []
|
||||
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():
|
||||
package_list.append({
|
||||
"check_depends": description.check_depends,
|
||||
|
||||
@@ -19,11 +19,9 @@
|
||||
#
|
||||
from sqlite3 import Connection
|
||||
|
||||
from ahriman.core.alpm.pacman import Pacman
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.utils import package_like
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.pacman_synchronization import PacmanSynchronization
|
||||
|
||||
|
||||
__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():
|
||||
return
|
||||
|
||||
_, repository_id = configuration.check_loaded()
|
||||
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
|
||||
|
||||
package_list = []
|
||||
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_base": package.base,
|
||||
"packager": package.packager,
|
||||
|
||||
@@ -20,13 +20,11 @@
|
||||
from dataclasses import replace
|
||||
from sqlite3 import Connection
|
||||
|
||||
from ahriman.core.alpm.pacman import Pacman
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.repository import Explorer
|
||||
from ahriman.core.sign.gpg import GPG
|
||||
from ahriman.core.utils import atomic_move, package_like, symlink_relative
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.pacman_synchronization import PacmanSynchronization
|
||||
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):
|
||||
paths = replace(configuration.repository_paths, repository_id=repository_id)
|
||||
pacman = Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
|
||||
|
||||
# create archive directory if required
|
||||
if not paths.archive.is_dir():
|
||||
with paths.preserve_owner():
|
||||
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
|
||||
|
||||
Args:
|
||||
repository_paths(RepositoryPaths): repository paths instance
|
||||
pacman(Pacman): alpm wrapper instance
|
||||
"""
|
||||
for archive in filter(package_like, repository_paths.repository.iterdir()):
|
||||
if not archive.is_file(follow_symlinks=False):
|
||||
continue # skip symbolic links if any
|
||||
|
||||
package = Package.from_archive(archive, pacman)
|
||||
package = Package.from_archive(archive)
|
||||
artifacts = [archive]
|
||||
# check if there are signatures for this package and append it here too
|
||||
if (signature := GPG.signature(archive)).exists():
|
||||
|
||||
25
src/ahriman/core/database/migrations/m017_pkgbuild.py
Normal file
25
src/ahriman/core/database/migrations/m017_pkgbuild.py
Normal 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""",
|
||||
]
|
||||
@@ -45,10 +45,10 @@ class ChangesOperations(Operations):
|
||||
def run(connection: Connection) -> Changes:
|
||||
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(
|
||||
"""
|
||||
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
|
||||
""",
|
||||
{
|
||||
@@ -77,16 +77,17 @@ class ChangesOperations(Operations):
|
||||
connection.execute(
|
||||
"""
|
||||
insert into package_changes
|
||||
(package_base, last_commit_sha, changes, repository)
|
||||
(package_base, last_commit_sha, changes, pkgbuild, repository)
|
||||
values
|
||||
(:package_base, :last_commit_sha, :changes ,:repository)
|
||||
(:package_base, :last_commit_sha, :changes, :pkgbuild, :repository)
|
||||
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,
|
||||
"last_commit_sha": changes.last_commit_sha,
|
||||
"changes": changes.changes,
|
||||
"pkgbuild": changes.pkgbuild,
|
||||
"repository": repository_id.id,
|
||||
})
|
||||
|
||||
|
||||
@@ -116,6 +116,19 @@ class GitRemoteError(RuntimeError):
|
||||
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):
|
||||
"""
|
||||
base service initialization exception
|
||||
|
||||
@@ -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_stats_printer import PackageStatsPrinter
|
||||
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.repository_printer import RepositoryPrinter
|
||||
from ahriman.core.formatters.repository_stats_printer import RepositoryStatsPrinter
|
||||
|
||||
@@ -45,7 +45,7 @@ class ChangesPrinter(Printer):
|
||||
Returns:
|
||||
list[Property]: list of content properties
|
||||
"""
|
||||
if self.changes.is_empty:
|
||||
if self.changes.changes is None:
|
||||
return []
|
||||
return [Property("", self.changes.changes, is_required=True, indent=0)]
|
||||
|
||||
@@ -57,6 +57,6 @@ class ChangesPrinter(Printer):
|
||||
Returns:
|
||||
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 self.changes.last_commit_sha
|
||||
|
||||
62
src/ahriman/core/formatters/pkgbuild_printer.py
Normal file
62
src/ahriman/core/formatters/pkgbuild_printer.py
Normal 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
|
||||
@@ -17,14 +17,10 @@
|
||||
# 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 collections.abc import Callable
|
||||
from functools import cmp_to_key
|
||||
|
||||
from ahriman.core import context
|
||||
from ahriman.core.alpm.pacman import Pacman
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.repository import Repository
|
||||
from ahriman.core.triggers import Trigger
|
||||
from ahriman.core.utils import package_like
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
from ahriman.models.result import Result
|
||||
@@ -78,23 +74,21 @@ class ArchiveRotationTrigger(Trigger):
|
||||
"""
|
||||
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
|
||||
|
||||
Args:
|
||||
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] = {}
|
||||
# we can't use here load_archives, because it ignores versions
|
||||
for full_path in filter(package_like, self.paths.archive_for(package.base).iterdir()):
|
||||
local = Package.from_archive(full_path, pacman)
|
||||
packages.setdefault((local.base, local.version), local).packages.update(local.packages)
|
||||
# explicit guard to skip process in case if rotation is disabled
|
||||
# this guard is supposed to speedup process
|
||||
if self.keep_built_packages == 0:
|
||||
return
|
||||
|
||||
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]:
|
||||
self.logger.info("removing version %s of package %s", single.version, single.base)
|
||||
for archive in single.packages.values():
|
||||
@@ -110,7 +104,7 @@ class ArchiveRotationTrigger(Trigger):
|
||||
packages(list[Package]): list of all available packages
|
||||
"""
|
||||
ctx = context.get()
|
||||
pacman = ctx.get(Pacman)
|
||||
repository = ctx.get(Repository)
|
||||
|
||||
for package in result.success:
|
||||
self.archives_remove(package, pacman)
|
||||
self.archives_remove(package, repository)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#
|
||||
import contextlib
|
||||
import requests
|
||||
import uuid
|
||||
|
||||
from requests.adapters import BaseAdapter
|
||||
from urllib.parse import urlparse
|
||||
@@ -60,6 +61,15 @@ class SyncAhrimanClient(SyncHttpClient):
|
||||
|
||||
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:
|
||||
"""
|
||||
method which will be called on session creation
|
||||
|
||||
@@ -144,6 +144,15 @@ class SyncHttpClient(LazyLogging):
|
||||
"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, *,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: list[tuple[str, str]] | None = None,
|
||||
@@ -178,6 +187,9 @@ class SyncHttpClient(LazyLogging):
|
||||
if session is None:
|
||||
session = self.session
|
||||
|
||||
if additional_headers := self.headers():
|
||||
headers = additional_headers | (headers or {})
|
||||
|
||||
try:
|
||||
response = session.request(method, url, params=params, data=data, headers=headers, files=files, json=json,
|
||||
stream=stream, auth=self.auth, timeout=self.timeout)
|
||||
|
||||
@@ -24,6 +24,7 @@ from collections.abc import Iterator
|
||||
from functools import cached_property
|
||||
from typing import Any
|
||||
|
||||
from ahriman.core.log.log_context import LogContext
|
||||
from ahriman.models.log_record_id import LogRecordId
|
||||
|
||||
|
||||
@@ -54,30 +55,20 @@ class LazyLogging:
|
||||
prefix = "" if clazz.__module__ is None else f"{clazz.__module__}."
|
||||
return f"{prefix}{clazz.__qualname__}"
|
||||
|
||||
@staticmethod
|
||||
def _package_logger_reset() -> None:
|
||||
@contextlib.contextmanager
|
||||
def in_context(self, name: str, value: Any) -> Iterator[None]:
|
||||
"""
|
||||
reset package logger to empty one
|
||||
"""
|
||||
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
|
||||
execute function while setting log context. The context will be reset after the execution
|
||||
|
||||
Args:
|
||||
package_base(str): package base
|
||||
version(str | None): package version if available
|
||||
name(str): attribute name to set on log records
|
||||
value(Any): current value of the context variable
|
||||
"""
|
||||
current_factory = logging.getLogRecordFactory()
|
||||
|
||||
def package_record_factory(*args: Any, **kwargs: Any) -> logging.LogRecord:
|
||||
record = current_factory(*args, **kwargs)
|
||||
record.package_id = LogRecordId(package_base, version or "<unknown>")
|
||||
return record
|
||||
|
||||
logging.setLogRecordFactory(package_record_factory)
|
||||
token = LogContext.set(name, value)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
LogContext.reset(name, token)
|
||||
|
||||
@contextlib.contextmanager
|
||||
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):
|
||||
>>> build_package(package)
|
||||
"""
|
||||
try:
|
||||
self._package_logger_set(package_base, version)
|
||||
with self.in_context("package_id", LogRecordId(package_base, version or "<unknown>")):
|
||||
yield
|
||||
finally:
|
||||
self._package_logger_reset()
|
||||
|
||||
108
src/ahriman/core/log/log_context.py
Normal file
108
src/ahriman/core/log/log_context.py
Normal 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)
|
||||
@@ -21,10 +21,11 @@ import logging
|
||||
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
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.repository_id import RepositoryId
|
||||
|
||||
@@ -36,11 +37,13 @@ class LogLoader:
|
||||
Attributes:
|
||||
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_STYLE(str): (class attribute) default log style (in case of fallback)
|
||||
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_STYLE: ClassVar[Literal["%", "{", "$"]] = "{"
|
||||
DEFAULT_SYSLOG_DEVICE: ClassVar[Path] = Path("/") / "dev" / "log"
|
||||
|
||||
@staticmethod
|
||||
@@ -100,10 +103,22 @@ class LogLoader:
|
||||
fileConfig(log_configuration, disable_existing_loggers=True)
|
||||
logging.debug("using %s logger", default_handler)
|
||||
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")
|
||||
|
||||
HttpLogHandler.load(repository_id, configuration, report=report)
|
||||
LogLoader.register_context()
|
||||
|
||||
if quiet:
|
||||
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)
|
||||
|
||||
@@ -57,7 +57,7 @@ class Executor(PackageInfo, Cleaner):
|
||||
|
||||
for path in filter(package_like, archive.iterdir()):
|
||||
# check if package version is the same
|
||||
built = Package.from_archive(path, self.pacman)
|
||||
built = Package.from_archive(path)
|
||||
if built.version != package.version:
|
||||
continue
|
||||
|
||||
@@ -117,7 +117,7 @@ class Executor(PackageInfo, Cleaner):
|
||||
else:
|
||||
built = task.build(path, PACKAGER=packager)
|
||||
|
||||
package.with_packages(built, self.pacman)
|
||||
package.with_packages(built)
|
||||
for src in built:
|
||||
dst = self.paths.packages / src.name
|
||||
atomic_move(src, dst)
|
||||
@@ -204,7 +204,8 @@ class Executor(PackageInfo, Cleaner):
|
||||
|
||||
# update commit hash for changes keeping current diff if there is any
|
||||
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
|
||||
package_archive = PackageArchive(self.paths.build_root, single, self.pacman, self.scan_paths)
|
||||
|
||||
@@ -19,23 +19,36 @@
|
||||
#
|
||||
import copy
|
||||
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from functools import cmp_to_key
|
||||
from pathlib import Path
|
||||
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.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.models.changes import Changes
|
||||
from ahriman.models.package import Package
|
||||
|
||||
|
||||
class PackageInfo(RepositoryProperties):
|
||||
class PackageInfo(LazyLogging):
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
generate full dependencies list including transitive dependencies
|
||||
@@ -86,7 +99,7 @@ class PackageInfo(RepositoryProperties):
|
||||
# we are iterating over bases, not single packages
|
||||
for full_path in packages:
|
||||
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
|
||||
local.remote = source
|
||||
|
||||
@@ -102,27 +115,48 @@ class PackageInfo(RepositoryProperties):
|
||||
self.logger.exception("could not load package from %s", full_path)
|
||||
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
|
||||
|
||||
Args:
|
||||
package(Package): package properties
|
||||
last_commit_sha(str | None): last known commit hash
|
||||
last_commit_sha(str): last known commit hash
|
||||
|
||||
Returns:
|
||||
Changes: changes if available
|
||||
Changes | None: changes if available
|
||||
"""
|
||||
with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
|
||||
dir_path = Path(dir_name)
|
||||
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:
|
||||
changes = Sources.changes(dir_path, last_commit_sha)
|
||||
|
||||
return Changes(last_commit_sha, changes)
|
||||
return Sources.changes(dir_path, last_commit_sha)
|
||||
return None
|
||||
|
||||
def packages(self, filter_packages: Iterable[str] | None = None) -> list[Package]:
|
||||
"""
|
||||
@@ -134,7 +168,7 @@ class PackageInfo(RepositoryProperties):
|
||||
Returns:
|
||||
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:
|
||||
packages = [package for package in packages if package.base in filter_packages]
|
||||
|
||||
@@ -147,7 +181,7 @@ class PackageInfo(RepositoryProperties):
|
||||
Returns:
|
||||
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]:
|
||||
"""
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
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.utils import check_output
|
||||
from ahriman.models.sign_settings import SignSettings
|
||||
@@ -147,12 +147,19 @@ class GPG(SyncHttpClient):
|
||||
|
||||
Returns:
|
||||
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
|
||||
# fpr:::::::::43A663569A07EE1E4ECC55CC7E3A4240CE3C45C2:
|
||||
fingerprint = next(filter(lambda line: line[:3] == "fpr", metadata.splitlines()))
|
||||
return fingerprint.split(":")[-2]
|
||||
metadata = check_output("gpg", "--with-colons", "--fingerprint", key, logger=self.logger)
|
||||
|
||||
try:
|
||||
fingerprint = next(filter(lambda line: line[:3] == "fpr", metadata.splitlines()))
|
||||
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:
|
||||
"""
|
||||
|
||||
@@ -23,6 +23,7 @@ from typing import Any, Self
|
||||
|
||||
from ahriman.core.exceptions import UnknownPackageError
|
||||
from ahriman.core.log import LazyLogging
|
||||
from ahriman.core.repository.package_info import PackageInfo
|
||||
from ahriman.core.status import Client
|
||||
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
||||
from ahriman.models.changes import Changes
|
||||
@@ -39,15 +40,18 @@ class Watcher(LazyLogging):
|
||||
|
||||
Attributes:
|
||||
client(Client): reporter instance
|
||||
package_info(PackageInfo): package info instance
|
||||
status(BuildStatus): daemon status
|
||||
"""
|
||||
|
||||
def __init__(self, client: Client) -> None:
|
||||
def __init__(self, client: Client, package_info: PackageInfo) -> None:
|
||||
"""
|
||||
Args:
|
||||
client(Client): reporter instance
|
||||
package_info(PackageInfo): package info instance
|
||||
"""
|
||||
self.client = client
|
||||
self.package_info = package_info
|
||||
|
||||
self._lock = Lock()
|
||||
self._known: dict[str, tuple[Package, BuildStatus]] = {}
|
||||
@@ -80,6 +84,15 @@ class Watcher(LazyLogging):
|
||||
|
||||
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_update: Callable[[str, Changes], None]
|
||||
|
||||
@@ -31,20 +31,12 @@ class Changes:
|
||||
Attributes:
|
||||
last_commit_sha(str | None): last commit hash
|
||||
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
|
||||
changes: 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
|
||||
pkgbuild: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dump: dict[str, Any]) -> Self:
|
||||
|
||||
@@ -26,6 +26,7 @@ from pyalpm import vercmp # type: ignore[import-not-found]
|
||||
from typing import Any, Self
|
||||
|
||||
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.log import LazyLogging
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
def from_archive(cls, path: Path, pacman: Pacman) -> Self:
|
||||
def from_archive(cls, path: Path) -> Self:
|
||||
"""
|
||||
construct package properties from package archive
|
||||
|
||||
Args:
|
||||
path(Path): path to package archive
|
||||
pacman(Pacman): alpm wrapper instance
|
||||
|
||||
Returns:
|
||||
Self: package properties
|
||||
"""
|
||||
package = pacman.handle.load_pkg(str(path))
|
||||
package = PacmanHandle.ephemeral().package_load(path)
|
||||
description = PackageDescription.from_package(package, path)
|
||||
return cls(
|
||||
base=package.base or package.name,
|
||||
@@ -400,17 +400,16 @@ class Package(LazyLogging):
|
||||
"""
|
||||
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
|
||||
|
||||
Args:
|
||||
packages(Iterable[Path]): paths to package archives
|
||||
pacman(Pacman): alpm wrapper instance
|
||||
"""
|
||||
self.packages = {} # reset state
|
||||
for package in packages:
|
||||
archive = self.from_archive(package, pacman)
|
||||
archive = self.from_archive(package)
|
||||
if archive.base != self.base:
|
||||
continue
|
||||
|
||||
|
||||
@@ -88,8 +88,12 @@ class User:
|
||||
"""
|
||||
if not self.password:
|
||||
return None
|
||||
algo = next(segment for segment in self.password.split("$") if segment)
|
||||
return f"${algo}$"
|
||||
|
||||
try:
|
||||
algo = next(segment for segment in self.password.split("$") if segment)
|
||||
return f"${algo}$"
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def generate_password(length: int) -> str:
|
||||
|
||||
51
src/ahriman/web/middlewares/request_id_handler.py
Normal file
51
src/ahriman/web/middlewares/request_id_handler.py
Normal 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
|
||||
@@ -25,9 +25,11 @@ class AuthInfoSchema(Schema):
|
||||
authorization information schema
|
||||
"""
|
||||
|
||||
control = fields.String(required=True, metadata={
|
||||
"description": "HTML control for login interface",
|
||||
})
|
||||
control = fields.String(
|
||||
metadata={
|
||||
"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={
|
||||
"description": "Whether authentication is enabled or not",
|
||||
})
|
||||
@@ -35,5 +37,5 @@ class AuthInfoSchema(Schema):
|
||||
"description": "Whether authorization provider is external (e.g. OAuth)",
|
||||
})
|
||||
username = fields.String(metadata={
|
||||
"description": "Currently authenticated username if any",
|
||||
"description": "Currently authenticated username if available",
|
||||
})
|
||||
|
||||
@@ -27,10 +27,12 @@ class AutoRefreshIntervalSchema(Schema):
|
||||
|
||||
interval = fields.Integer(required=True, metadata={
|
||||
"description": "Auto refresh interval in milliseconds",
|
||||
"example": "60000",
|
||||
})
|
||||
is_active = fields.Boolean(required=True, metadata={
|
||||
"description": "Whether this interval is the default active one",
|
||||
})
|
||||
text = fields.String(required=True, metadata={
|
||||
"description": "Human readable interval description",
|
||||
"example": "1 minute",
|
||||
})
|
||||
|
||||
@@ -32,3 +32,6 @@ class ChangesSchema(Schema):
|
||||
"description": "Last recorded commit hash",
|
||||
"example": "f1875edca1eb8fc0e55c41d1cae5fa05b6b7c6",
|
||||
})
|
||||
pkgbuild = fields.String(metadata={
|
||||
"description": "Original PKGBUILD content",
|
||||
})
|
||||
|
||||
@@ -40,6 +40,7 @@ class InfoV2Schema(Schema):
|
||||
})
|
||||
index_url = fields.String(metadata={
|
||||
"description": "URL to the repository index page",
|
||||
"example": "https://ahriman.readthedocs.io/",
|
||||
})
|
||||
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
|
||||
"description": "List of loaded repositories",
|
||||
|
||||
@@ -31,7 +31,7 @@ class RepositoryIdSchema(Schema):
|
||||
})
|
||||
id = fields.String(metadata={
|
||||
"description": "Unique repository identifier",
|
||||
"example": "aur-x86_64",
|
||||
"example": "x86_64-aur",
|
||||
})
|
||||
repository = fields.String(metadata={
|
||||
"description": "Repository name",
|
||||
|
||||
65
src/ahriman/web/views/v1/packages/archives.py
Normal file
65
src/ahriman/web/views/v1/packages/archives.py
Normal 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])
|
||||
@@ -92,10 +92,11 @@ class ChangesView(StatusViewGuard, BaseView):
|
||||
data = await self.request.json()
|
||||
last_commit_sha = data.get("last_commit_sha") # empty/null meant removal
|
||||
change = data.get("changes")
|
||||
pkgbuild = data.get("pkgbuild")
|
||||
except Exception as 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)
|
||||
|
||||
raise HTTPNoContent
|
||||
|
||||
@@ -23,12 +23,14 @@ import logging
|
||||
import socket
|
||||
|
||||
from aiohttp.web import Application, normalize_path_middleware, run_app
|
||||
from pathlib import Path
|
||||
|
||||
from ahriman.core.auth import Auth
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.database import SQLite
|
||||
from ahriman.core.distributed import WorkersCache
|
||||
from ahriman.core.exceptions import InitializeError
|
||||
from ahriman.core.repository.package_info import PackageInfo
|
||||
from ahriman.core.spawn import Spawn
|
||||
from ahriman.core.status import Client
|
||||
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.middlewares.exception_handler import exception_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
|
||||
|
||||
|
||||
@@ -77,6 +80,33 @@ def _create_socket(configuration: Configuration, application: Application) -> so
|
||||
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:
|
||||
"""
|
||||
web application shutdown handler
|
||||
@@ -146,6 +176,7 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
|
||||
application.on_startup.append(_on_startup)
|
||||
|
||||
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(metrics_handler())
|
||||
|
||||
@@ -166,18 +197,11 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
|
||||
# package cache
|
||||
if not repositories:
|
||||
raise InitializeError("No repositories configured, exiting")
|
||||
watchers: dict[RepositoryId, Watcher] = {}
|
||||
configuration_path, _ = configuration.check_loaded()
|
||||
for repository_id in repositories:
|
||||
application.logger.info("load repository %s", repository_id)
|
||||
# load settings explicitly for architecture if any
|
||||
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
|
||||
application[WatcherKey] = {
|
||||
repository_id: _create_watcher(configuration_path, repository_id)
|
||||
for repository_id in repositories
|
||||
}
|
||||
# workers cache
|
||||
application[WorkersKey] = WorkersCache(configuration)
|
||||
# process spawner
|
||||
|
||||
@@ -18,7 +18,7 @@ def test_changes(application_repository: ApplicationRepository, package_ahriman:
|
||||
"""
|
||||
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)
|
||||
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")
|
||||
@@ -42,6 +42,20 @@ def test_changes_skip(application_repository: ApplicationRepository, package_ahr
|
||||
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:
|
||||
"""
|
||||
must clean cache directory
|
||||
|
||||
100
tests/ahriman/application/handlers/test_handler_pkgbuild.py
Normal file
100
tests/ahriman/application/handlers/test_handler_pkgbuild.py
Normal 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
|
||||
@@ -309,6 +309,44 @@ def test_subparsers_package_changes_remove_package_changes(parser: argparse.Argu
|
||||
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:
|
||||
"""
|
||||
package-copy command must correctly parse architecture list
|
||||
|
||||
@@ -14,6 +14,7 @@ from ahriman.core.auth import Auth
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.database import SQLite
|
||||
from ahriman.core.database.migrations import Migrations
|
||||
from ahriman.core.log.log_loader import LogLoader
|
||||
from ahriman.core.repository import Repository
|
||||
from ahriman.core.spawn import Spawn
|
||||
from ahriman.core.status import Client
|
||||
@@ -124,6 +125,14 @@ def import_error(package: str, components: list[str], mocker: MockerFixture) ->
|
||||
|
||||
|
||||
# generic fixtures
|
||||
@pytest.fixture(autouse=True)
|
||||
def _register_log_context() -> None:
|
||||
"""
|
||||
register log context variables and factory
|
||||
"""
|
||||
LogLoader.register_context()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def aur_package_ahriman() -> AURPackage:
|
||||
"""
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
must raise an exception in case if server scheme is unsupported
|
||||
|
||||
37
tests/ahriman/core/alpm/test_pacman_handle.py
Normal file
37
tests/ahriman/core/alpm/test_pacman_handle.py
Normal 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
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import call as MockCall
|
||||
|
||||
from ahriman.core.build_tools.sources import Sources
|
||||
from ahriman.core.exceptions import CalledProcessError
|
||||
from ahriman.models.changes import Changes
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.package_source import PackageSource
|
||||
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")
|
||||
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")
|
||||
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)
|
||||
diff_mock.assert_called_once_with(local, last_commit_sha)
|
||||
|
||||
|
||||
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()
|
||||
read_mock.assert_called_once_with(local, "HEAD", Path("PKGBUILD"))
|
||||
|
||||
|
||||
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)
|
||||
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()
|
||||
read_mock.assert_called_once_with(Path("local"), "HEAD", Path("PKGBUILD"))
|
||||
|
||||
|
||||
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)
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
migrate_package_depends(connection, configuration)
|
||||
package_mock.assert_called_once_with(
|
||||
package_ahriman.packages[package_ahriman.base].filepath, pytest.helpers.anyvar(int))
|
||||
package_mock.assert_called_once_with(package_ahriman.packages[package_ahriman.base].filepath)
|
||||
connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{
|
||||
"make_depends": package_ahriman.packages[package_ahriman.base].make_depends,
|
||||
"opt_depends": package_ahriman.packages[package_ahriman.base].opt_depends,
|
||||
|
||||
@@ -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)
|
||||
|
||||
migrate_package_check_depends(connection, configuration)
|
||||
package_mock.assert_called_once_with(
|
||||
package_ahriman.packages[package_ahriman.base].filepath, pytest.helpers.anyvar(int))
|
||||
package_mock.assert_called_once_with(package_ahriman.packages[package_ahriman.base].filepath)
|
||||
connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{
|
||||
"check_depends": package_ahriman.packages[package_ahriman.base].check_depends,
|
||||
"package": package_ahriman.base,
|
||||
|
||||
@@ -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)
|
||||
|
||||
migrate_package_base_packager(connection, configuration)
|
||||
package_mock.assert_called_once_with(
|
||||
package_ahriman.packages[package_ahriman.base].filepath, pytest.helpers.anyvar(int))
|
||||
package_mock.assert_called_once_with(package_ahriman.packages[package_ahriman.base].filepath)
|
||||
connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), [{
|
||||
"package_base": package_ahriman.base,
|
||||
"packager": package_ahriman.packager,
|
||||
|
||||
@@ -7,7 +7,6 @@ from sqlite3 import Connection
|
||||
from typing import Any
|
||||
from unittest.mock import call as MockCall
|
||||
|
||||
from ahriman.core.alpm.pacman import Pacman
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.database.migrations.m016_archive import migrate_data, move_packages
|
||||
from ahriman.models.package import Package
|
||||
@@ -28,12 +27,12 @@ def test_migrate_data(connection: Connection, configuration: Configuration, mock
|
||||
|
||||
migrate_data(connection, configuration)
|
||||
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
|
||||
])
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
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")
|
||||
symlink_mock = mocker.patch("pathlib.Path.symlink_to")
|
||||
|
||||
move_packages(repository_paths, pacman)
|
||||
move_packages(repository_paths)
|
||||
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")
|
||||
])
|
||||
move_mock.assert_has_calls([
|
||||
|
||||
@@ -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
|
||||
@@ -8,34 +8,35 @@ def test_changes_insert_get(database: SQLite, package_ahriman: Package) -> None:
|
||||
"""
|
||||
must insert and get changes
|
||||
"""
|
||||
database.changes_insert(package_ahriman.base, Changes("sha1", "change1"))
|
||||
assert database.changes_get(package_ahriman.base).changes == "change1"
|
||||
changes1 = Changes("sha1", "change1", "pkgbuild1")
|
||||
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"),
|
||||
RepositoryId("i686", database._repository_id.name))
|
||||
assert database.changes_get(package_ahriman.base).changes == "change1"
|
||||
assert database.changes_get(
|
||||
package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes == "change2"
|
||||
database.changes_insert(package_ahriman.base, changes2, RepositoryId("i686", database._repository_id.name))
|
||||
assert database.changes_get(package_ahriman.base) == changes1
|
||||
assert database.changes_get(package_ahriman.base, RepositoryId("i686", database._repository_id.name)) == changes2
|
||||
|
||||
|
||||
def test_changes_insert_remove(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None:
|
||||
"""
|
||||
must remove changes for the package
|
||||
"""
|
||||
database.changes_insert(package_ahriman.base, Changes("sha1", "change1"))
|
||||
database.changes_insert(package_python_schedule.base, Changes("sha3", "change3"))
|
||||
database.changes_insert(package_ahriman.base, Changes("sha2", "change2"),
|
||||
changes3 = Changes("sha3", "change3", "pkgbuild3")
|
||||
database.changes_insert(package_ahriman.base, Changes("sha1", "change1", "pkgbuild1"))
|
||||
database.changes_insert(package_python_schedule.base, changes3)
|
||||
database.changes_insert(package_ahriman.base, Changes("sha2", "change2", "pkgbuild2"),
|
||||
RepositoryId("i686", database._repository_id.name))
|
||||
|
||||
database.changes_remove(package_ahriman.base)
|
||||
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
|
||||
database.changes_insert(package_ahriman.base, Changes(), RepositoryId("i686", database._repository_id.name))
|
||||
assert database.changes_get(
|
||||
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,
|
||||
@@ -43,13 +44,12 @@ def test_changes_insert_remove_full(database: SQLite, package_ahriman: Package,
|
||||
"""
|
||||
must remove all changes for the repository
|
||||
"""
|
||||
database.changes_insert(package_ahriman.base, Changes("sha1", "change1"))
|
||||
database.changes_insert(package_python_schedule.base, Changes("sha3", "change3"))
|
||||
database.changes_insert(package_ahriman.base, Changes("sha2", "change2"),
|
||||
RepositoryId("i686", database._repository_id.name))
|
||||
changes2 = Changes("sha2", "change2", "pkgbuild2")
|
||||
database.changes_insert(package_ahriman.base, Changes("sha1", "change1", "pkgbuild1"))
|
||||
database.changes_insert(package_python_schedule.base, Changes("sha3", "change3", "pkgbuild3"))
|
||||
database.changes_insert(package_ahriman.base, changes2, RepositoryId("i686", database._repository_id.name))
|
||||
|
||||
database.changes_remove(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_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes == "change2"
|
||||
assert database.changes_get(package_ahriman.base, RepositoryId("i686", database._repository_id.name)) == changes2
|
||||
|
||||
@@ -2,24 +2,26 @@ import pytest
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ahriman.core.formatters import \
|
||||
AurPrinter, \
|
||||
ChangesPrinter, \
|
||||
ConfigurationPathsPrinter, \
|
||||
ConfigurationPrinter, \
|
||||
EventStatsPrinter, \
|
||||
PackagePrinter, \
|
||||
PackageStatsPrinter, \
|
||||
PatchPrinter, \
|
||||
RepositoryPrinter, \
|
||||
RepositoryStatsPrinter, \
|
||||
StatusPrinter, \
|
||||
StringPrinter, \
|
||||
TreePrinter, \
|
||||
UpdatePrinter, \
|
||||
UserPrinter, \
|
||||
ValidationPrinter, \
|
||||
from ahriman.core.formatters import (
|
||||
AurPrinter,
|
||||
ChangesPrinter,
|
||||
ConfigurationPathsPrinter,
|
||||
ConfigurationPrinter,
|
||||
EventStatsPrinter,
|
||||
PackagePrinter,
|
||||
PackageStatsPrinter,
|
||||
PatchPrinter,
|
||||
PkgbuildPrinter,
|
||||
RepositoryPrinter,
|
||||
RepositoryStatsPrinter,
|
||||
StatusPrinter,
|
||||
StringPrinter,
|
||||
TreePrinter,
|
||||
UpdatePrinter,
|
||||
UserPrinter,
|
||||
ValidationPrinter,
|
||||
VersionPrinter
|
||||
)
|
||||
from ahriman.models.aur_package import AURPackage
|
||||
from ahriman.models.build_status import BuildStatus
|
||||
from ahriman.models.changes import Changes
|
||||
@@ -55,6 +57,17 @@ def changes_printer() -> ChangesPrinter:
|
||||
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
|
||||
def configuration_paths_printer() -> ConfigurationPathsPrinter:
|
||||
"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user