mirror of
				https://github.com/arcan1s/ahriman.git
				synced 2025-10-30 21:33:43 +00:00 
			
		
		
		
	feat: implement rss generation (#130)
This commit is contained in:
		| @ -251,6 +251,7 @@ Section name must be either ``email`` (plus optional architecture name, e.g. ``e | ||||
| * ``password`` - SMTP password to authenticate, string, optional. | ||||
| * ``port`` - SMTP port for sending emails, integer, required. | ||||
| * ``receivers`` - SMTP receiver addresses, space separated list of strings, required. | ||||
| * ``rss_url`` - link to RSS feed, string, optional. | ||||
| * ``sender`` - SMTP sender address, string, required. | ||||
| * ``ssl`` - SSL mode for SMTP connection, one of ``ssl``, ``starttls``, ``disabled``, optional, default ``disabled``. | ||||
| * ``template`` - Jinja2 template name, string, required. | ||||
| @ -266,7 +267,8 @@ Section name must be either ``html`` (plus optional architecture name, e.g. ``ht | ||||
| * ``type`` - type of the report, string, optional, must be set to ``html`` if exists. | ||||
| * ``homepage`` - link to homepage, string, optional. | ||||
| * ``link_path`` - prefix for HTML links, string, required. | ||||
| * ``path`` - path to html report file, string, required. | ||||
| * ``path`` - path to HTML report file, string, required. | ||||
| * ``rss_url`` - link to RSS feed, string, optional. | ||||
| * ``template`` - Jinja2 template name, string, required. | ||||
| * ``templates`` - path to templates directories, space separated list of paths, required. | ||||
|  | ||||
| @ -281,6 +283,20 @@ Section name must be either ``remote-call`` (plus optional architecture name, e. | ||||
| * ``manual`` - update manually built packages, boolean, optional, default ``no``. | ||||
| * ``wait_timeout`` - maximum amount of time in seconds to be waited before remote process will be terminated, integer, optional, default ``-1``. | ||||
|  | ||||
| ``rss`` type | ||||
| ^^^^^^^^^^^^ | ||||
|  | ||||
| Section name must be either ``rss`` (plus optional architecture name, e.g. ``rss:x86_64``) or random name with ``type`` set. | ||||
|  | ||||
| * ``type`` - type of the report, string, optional, must be set to ``rss`` if exists. | ||||
| * ``homepage`` - link to homepage, string, optional. | ||||
| * ``link_path`` - prefix for HTML links, string, required. | ||||
| * ``max_entries`` - maximal amount of entries to be included to the report, negative means no limit, integer, optional, default ``-1``. | ||||
| * ``path`` - path to generated RSS file, string, required. | ||||
| * ``rss_url`` - link to RSS feed, string, optional. | ||||
| * ``template`` - Jinja2 template name, string, required. | ||||
| * ``templates`` - path to templates directories, space separated list of paths, required. | ||||
|  | ||||
| ``telegram`` type | ||||
| ^^^^^^^^^^^^^^^^^ | ||||
|  | ||||
| @ -291,6 +307,7 @@ Section name must be either ``telegram`` (plus optional architecture name, e.g. | ||||
| * ``chat_id`` - telegram chat id, either string with ``@`` or integer value, required. | ||||
| * ``homepage`` - link to homepage, string, optional. | ||||
| * ``link_path`` - prefix for HTML links, string, required. | ||||
| * ``rss_url`` - link to RSS feed, string, optional. | ||||
| * ``template`` - Jinja2 template name, string, required. | ||||
| * ``template_type`` - ``parse_mode`` to be passed to telegram API, one of ``MarkdownV2``, ``HTML``, ``Markdown``, string, optional, default ``HTML``. | ||||
| * ``templates`` - path to templates directories, space separated list of paths, required. | ||||
|  | ||||
| @ -17,7 +17,7 @@ mirror = https://geo.mirror.pkgbuild.com/$repo/os/$arch | ||||
| repositories = core extra multilib | ||||
| ; Pacman's root directory. In the most cases it must point to the system root. | ||||
| root = / | ||||
| ; Sync files databases too, which is required by deep dependencies check | ||||
| ; Sync files databases too, which is required by deep dependencies check. | ||||
| sync_files_database = yes | ||||
| ; Use local packages cache. If this option is enabled, the service will be able to synchronize databases (available | ||||
| ; as additional option for some subcommands). If set to no, databases must be synchronized manually. | ||||
| @ -52,17 +52,17 @@ allow_read_only = yes | ||||
| [build] | ||||
| ; List of additional flags passed to archbuild command. | ||||
| ;archbuild_flags = | ||||
| ; Path to build command | ||||
| ; Path to build command. | ||||
| ;build_command = | ||||
| ; List of packages to be ignored during automatic updates. | ||||
| ;ignore_packages = | ||||
| ; Include debug packages | ||||
| ; Include debug packages. | ||||
| ;include_debug_packages = yes | ||||
| ; List of additional flags passed to makechrootpkg command. | ||||
| ;makechrootpkg_flags = | ||||
| ; List of additional flags passed to makepkg command. | ||||
| makepkg_flags = --nocolor --ignorearch | ||||
| ; List of paths to be used for implicit dependency scan. Regular expressions are supported | ||||
| ; List of paths to be used for implicit dependency scan. Regular expressions are supported. | ||||
| scan_paths = ^usr/lib(?!/cmake).*$ | ||||
| ; List of enabled triggers in the order of calls. | ||||
| triggers = ahriman.core.gitremote.RemotePullTrigger ahriman.core.report.ReportTrigger ahriman.core.upload.UploadTrigger ahriman.core.gitremote.RemotePushTrigger | ||||
| @ -212,14 +212,14 @@ target = console | ||||
|  | ||||
| ; Console reporting trigger configuration sample. | ||||
| [console] | ||||
| ; Trigger type name | ||||
| ; Trigger type name. | ||||
| ;type = console | ||||
| ; Use utf8 symbols in output. | ||||
| use_utf = yes | ||||
|  | ||||
| ; Email reporting trigger configuration sample. | ||||
| [email] | ||||
| ; Trigger type name | ||||
| ; Trigger type name. | ||||
| ;type = email | ||||
| ; Optional URL to the repository homepage. | ||||
| ;homepage= | ||||
| @ -235,6 +235,8 @@ use_utf = yes | ||||
| ;port = | ||||
| ; List of emails to receive the reports. | ||||
| ;receivers = | ||||
| ; Optional link to the RSS feed. | ||||
| ;rss_url = | ||||
| ; Sender email. | ||||
| ;sender = | ||||
| ; SMTP server SSL mode, one of ssl, starttls, disabled. | ||||
| @ -250,7 +252,7 @@ templates = /usr/share/ahriman/templates | ||||
|  | ||||
| ; HTML reporting trigger configuration sample. | ||||
| [html] | ||||
| ; Trigger type name | ||||
| ; Trigger type name. | ||||
| ;type = html | ||||
| ; Optional URL to the repository homepage. | ||||
| ;homepage= | ||||
| @ -258,6 +260,8 @@ templates = /usr/share/ahriman/templates | ||||
| ;link_path = | ||||
| ; Output path for the HTML report. | ||||
| ;path = | ||||
| ; Optional link to the RSS feed. | ||||
| ;rss_url = | ||||
| ; Template name to be used. | ||||
| template = repo-index.jinja2 | ||||
| ; List of directories with templates. | ||||
| @ -265,7 +269,7 @@ templates = /usr/share/ahriman/templates | ||||
|  | ||||
| ; Remote service callback trigger configuration sample. | ||||
| [remote-call] | ||||
| ; Trigger type name | ||||
| ; Trigger type name. | ||||
| ;type = remote-call | ||||
| ; Call for AUR packages update. | ||||
| ;aur = no | ||||
| @ -276,9 +280,26 @@ templates = /usr/share/ahriman/templates | ||||
| ; Wait until remote process will be terminated in seconds. | ||||
| ;wait_timeout = -1 | ||||
|  | ||||
| ; RSS reporting trigger configuration sample. | ||||
| [rss] | ||||
| ; Trigger type name. | ||||
| ;type = rss | ||||
| ; Optional URL to the repository homepage. | ||||
| ;homepage= | ||||
| ; Prefix for packages links. Link to a package will be formed as link_path / filename. | ||||
| ;link_path = | ||||
| ; Output path for the RSS report. | ||||
| ;path = | ||||
| ; Optional link to the RSS feed. | ||||
| ;rss_url = | ||||
| ; Template name to be used. | ||||
| template = rss.jinja2 | ||||
| ; List of directories with templates. | ||||
| templates = /usr/share/ahriman/templates | ||||
|  | ||||
| ; Telegram reporting trigger configuration sample. | ||||
| [telegram] | ||||
| ; Trigger type name | ||||
| ; Trigger type name. | ||||
| ;type = telegram | ||||
| ; Telegram bot API key. | ||||
| ;api_key = | ||||
| @ -288,6 +309,8 @@ templates = /usr/share/ahriman/templates | ||||
| ;homepage= | ||||
| ; Prefix for packages links. Link to a package will be formed as link_path / filename. | ||||
| ;link_path = | ||||
| ; Optional link to the RSS feed. | ||||
| ;rss_url = | ||||
| ; Template name to be used. | ||||
| template = telegram-index.jinja2 | ||||
| ; Telegram specific template mode, one of MarkdownV2, HTML or Markdown. | ||||
| @ -304,7 +327,7 @@ target = | ||||
|  | ||||
| ; GitHub upload trigger configuration sample. | ||||
| [github] | ||||
| ; Trigger type name | ||||
| ; Trigger type name. | ||||
| ;type = github | ||||
| ; GitHub repository owner username. | ||||
| ;owner = | ||||
| @ -321,14 +344,14 @@ target = | ||||
|  | ||||
| ; Remote instance upload trigger configuration sample. | ||||
| [remote-service] | ||||
| ; Trigger type name | ||||
| ; Trigger type name. | ||||
| ;type = remote-service | ||||
| ; HTTP request timeout in seconds. | ||||
| ;timeout = 30 | ||||
|  | ||||
| ; rsync upload trigger configuration sample. | ||||
| [rsync] | ||||
| ; Trigger type name | ||||
| ; Trigger type name. | ||||
| ;type = rsync | ||||
| ; rsync command to run. | ||||
| command = rsync --archive --compress --partial --delete | ||||
| @ -338,7 +361,7 @@ command = rsync --archive --compress --partial --delete | ||||
|  | ||||
| ; S3 upload trigger configuration sample. | ||||
| [s3] | ||||
| ; Trigger type name | ||||
| ; Trigger type name. | ||||
| ;type = s3 | ||||
| ; AWS services access key. | ||||
| ;access_key = | ||||
|  | ||||
| @ -7,6 +7,10 @@ | ||||
|  | ||||
|         {% include "utils/style.jinja2" %} | ||||
|         {% include "user-style.jinja2" ignore missing %} | ||||
|  | ||||
|         {% if rss_url is not none %} | ||||
|             <link rel="alternate" href="{{ rss_url }}" type="application/rss+xml"> | ||||
|         {% endif %} | ||||
|     </head> | ||||
|  | ||||
|     <body> | ||||
|  | ||||
							
								
								
									
										27
									
								
								package/share/ahriman/templates/rss.jinja2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								package/share/ahriman/templates/rss.jinja2
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> | ||||
|     <channel> | ||||
|         <title>{{ repository }}: Recent package updates</title> | ||||
|         {% if homepage is not none %} | ||||
|             <link>{{ homepage }}</link> | ||||
|         {% endif %} | ||||
|         <description>Recently updated packages in the {{ repository }}.</description> | ||||
|         {% if rss_url is not none %} | ||||
|             <atom:link href="{{ rss_url }}" rel="self"/> | ||||
|         {% endif %} | ||||
|         <language>en-us</language> | ||||
|         <lastBuildDate>{{ last_update }}</lastBuildDate> | ||||
|  | ||||
|         {% for package in packages %} | ||||
|             <item> | ||||
|                 <title>{{ package.name }} {{ package.version }} {{ package.architecture }}</title> | ||||
|                 <link>{{ link_path }}/{{ package.filename }}</link> | ||||
|                 <description>{{ package.description }}</description> | ||||
|                 <pubDate>{{ package.build_date }}</pubDate> | ||||
|                 <guid isPermaLink="false">{{ package.tag }}</guid> | ||||
|                 <category>{{ repository }}</category> | ||||
|                 <category>{{ package.architecture }}</category> | ||||
|             </item> | ||||
|         {% endfor %} | ||||
|     </channel> | ||||
| </rss> | ||||
| @ -171,7 +171,7 @@ class PackageArchive: | ||||
|  | ||||
|         result: dict[Path, list[FilesystemPackage]] = {} | ||||
|         # sort items from children directories to root | ||||
|         for path, packages in reversed(sorted(source.items())): | ||||
|         for path, packages in sorted(source.items(), reverse=True): | ||||
|             # skip if this path belongs to the one of the base packages | ||||
|             if any(package.package_name in base_packages for package in packages): | ||||
|                 continue | ||||
|  | ||||
| @ -17,14 +17,16 @@ | ||||
| # 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 datetime | ||||
| import jinja2 | ||||
|  | ||||
| from collections.abc import Callable | ||||
| from pathlib import Path | ||||
| from typing import Any | ||||
|  | ||||
| from ahriman.core.configuration import Configuration | ||||
| from ahriman.core.sign.gpg import GPG | ||||
| from ahriman.core.utils import pretty_datetime, pretty_size | ||||
| from ahriman.core.utils import pretty_datetime, pretty_size, utcnow | ||||
| from ahriman.models.repository_id import RepositoryId | ||||
| from ahriman.models.result import Result | ||||
| from ahriman.models.sign_settings import SignSettings | ||||
| @ -37,6 +39,7 @@ class JinjaTemplate: | ||||
|     It uses jinja2 templates for report generation, the following variables are allowed: | ||||
|  | ||||
|         * homepage - link to homepage, string, optional | ||||
|         * last_update - report generation time, pretty printed datetime, required | ||||
|         * link_path - prefix fo packages to download, string, required | ||||
|         * has_package_signed - ``True`` in case if package sign enabled, ``False`` otherwise, required | ||||
|         * has_repo_signed - ``True`` in case if repository database sign enabled, ``False`` otherwise, required | ||||
| @ -46,21 +49,24 @@ class JinjaTemplate: | ||||
|             * build_date, pretty printed datetime, string | ||||
|             * depends, sorted list of strings | ||||
|             * description, string | ||||
|             * filename, string, | ||||
|             * filename, string | ||||
|             * groups, sorted list of strings | ||||
|             * installed_size, pretty printed datetime, string | ||||
|             * installed_size, pretty printed size, string | ||||
|             * licenses, sorted list of strings | ||||
|             * name, string | ||||
|             * tag, string | ||||
|             * url, string | ||||
|             * version, string | ||||
|         * pgp_key - default PGP key ID, string, optional | ||||
|         * repository - repository name, string, required | ||||
|         * rss_url - optional link to the RSS feed, string, optional | ||||
|  | ||||
|     Attributes: | ||||
|         default_pgp_key(str | None): default PGP key | ||||
|         homepage(str | None): homepage link if any (for footer) | ||||
|         link_path(str): prefix fo packages to download | ||||
|         name(str): repository name | ||||
|         rss_url(str | None): link to the RSS feed | ||||
|         sign_targets(set[SignSettings]): targets to sign enabled in configuration | ||||
|         templates(list[Path]): list of directories with templates | ||||
|     """ | ||||
| @ -80,8 +86,36 @@ class JinjaTemplate: | ||||
|         self.homepage = configuration.get(section, "homepage", fallback=None) | ||||
|         self.link_path = configuration.get(section, "link_path") | ||||
|         self.name = repository_id.name | ||||
|         self.rss_url = configuration.get(section, "rss_url", fallback=None) | ||||
|         self.sign_targets, self.default_pgp_key = GPG.sign_options(configuration) | ||||
|  | ||||
|     @staticmethod | ||||
|     def format_datetime(timestamp: datetime.datetime | float | int | None) -> str: | ||||
|         """ | ||||
|         convert datetime object to string | ||||
|  | ||||
|         Args: | ||||
|             timestamp(datetime.datetime | float | int | None): datetime to convert | ||||
|  | ||||
|         Returns: | ||||
|             str: datetime as string representation | ||||
|         """ | ||||
|         return pretty_datetime(timestamp) | ||||
|  | ||||
|     @staticmethod | ||||
|     def sort_content(content: list[dict[str, Any]]) -> list[dict[str, Any]]: | ||||
|         """ | ||||
|         sort content before rendering | ||||
|  | ||||
|         Args: | ||||
|             content(list[dict[str, str]]): content of the template | ||||
|  | ||||
|         Returns: | ||||
|             list[dict[str, str]]: sorted content according to comparator defined | ||||
|         """ | ||||
|         comparator: Callable[[dict[str, str]], str] = lambda item: item["filename"] | ||||
|         return sorted(content, key=comparator) | ||||
|  | ||||
|     def make_html(self, result: Result, template_name: Path | str) -> str: | ||||
|         """ | ||||
|         generate report for the specified packages | ||||
| @ -104,7 +138,7 @@ class JinjaTemplate: | ||||
|             { | ||||
|                 "architecture": properties.architecture or "", | ||||
|                 "archive_size": pretty_size(properties.archive_size), | ||||
|                 "build_date": pretty_datetime(properties.build_date), | ||||
|                 "build_date": self.format_datetime(properties.build_date), | ||||
|                 "depends": properties.depends, | ||||
|                 "description": properties.description or "", | ||||
|                 "filename": properties.filename, | ||||
| @ -112,17 +146,20 @@ class JinjaTemplate: | ||||
|                 "installed_size": pretty_size(properties.installed_size), | ||||
|                 "licenses": properties.licenses, | ||||
|                 "name": package, | ||||
|                 "tag": f"tag:{self.name}:{properties.architecture}:{package}:{base.version}:{properties.build_date}", | ||||
|                 "url": properties.url or "", | ||||
|                 "version": base.version | ||||
|                 "version": base.version, | ||||
|             } for base in result.success for package, properties in base.packages.items() | ||||
|         ] | ||||
|         comparator: Callable[[dict[str, str]], str] = lambda item: item["filename"] | ||||
|  | ||||
|         return template.render( | ||||
|             homepage=self.homepage, | ||||
|             last_update=self.format_datetime(utcnow()), | ||||
|             link_path=self.link_path, | ||||
|             has_package_signed=SignSettings.Packages in self.sign_targets, | ||||
|             has_repo_signed=SignSettings.Repository in self.sign_targets, | ||||
|             packages=sorted(content, key=comparator), | ||||
|             packages=self.sort_content(content), | ||||
|             pgp_key=self.default_pgp_key, | ||||
|             repository=self.name) | ||||
|             repository=self.name, | ||||
|             rss_url=self.rss_url, | ||||
|         ) | ||||
|  | ||||
| @ -66,7 +66,7 @@ class Report(LazyLogging): | ||||
|         self.configuration = configuration | ||||
|  | ||||
|     @staticmethod | ||||
|     def load(repository_id: RepositoryId, configuration: Configuration, target: str) -> Report: | ||||
|     def load(repository_id: RepositoryId, configuration: Configuration, target: str) -> Report:  # pylint: disable=too-many-return-statements | ||||
|         """ | ||||
|         load client from settings | ||||
|  | ||||
| @ -92,6 +92,9 @@ class Report(LazyLogging): | ||||
|             case ReportSettings.Telegram: | ||||
|                 from ahriman.core.report.telegram import Telegram | ||||
|                 return Telegram(repository_id, configuration, section) | ||||
|             case ReportSettings.RSS: | ||||
|                 from ahriman.core.report.rss import RSS | ||||
|                 return RSS(repository_id, configuration, section) | ||||
|             case ReportSettings.RemoteCall: | ||||
|                 from ahriman.core.report.remote_call import RemoteCall | ||||
|                 return RemoteCall(repository_id, configuration, section) | ||||
|  | ||||
| @ -116,6 +116,11 @@ class ReportTrigger(Trigger): | ||||
|                     "required": True, | ||||
|                     "empty": False, | ||||
|                 }, | ||||
|                 "rss_url": { | ||||
|                     "type": "string", | ||||
|                     "empty": False, | ||||
|                     "is_url": ["http", "https"], | ||||
|                 }, | ||||
|                 "sender": { | ||||
|                     "type": "string", | ||||
|                     "required": True, | ||||
| @ -187,6 +192,11 @@ class ReportTrigger(Trigger): | ||||
|                     "coerce": "absolute_path", | ||||
|                     "required": True, | ||||
|                 }, | ||||
|                 "rss_url": { | ||||
|                     "type": "string", | ||||
|                     "empty": False, | ||||
|                     "is_url": ["http", "https"], | ||||
|                 }, | ||||
|                 "template": { | ||||
|                     "type": "string", | ||||
|                     "excludes": ["template_path"], | ||||
| @ -243,6 +253,11 @@ class ReportTrigger(Trigger): | ||||
|                     "empty": False, | ||||
|                     "is_url": [], | ||||
|                 }, | ||||
|                 "rss_url": { | ||||
|                     "type": "string", | ||||
|                     "empty": False, | ||||
|                     "is_url": ["http", "https"], | ||||
|                 }, | ||||
|                 "template": { | ||||
|                     "type": "string", | ||||
|                     "excludes": ["template_path"], | ||||
| @ -304,7 +319,67 @@ class ReportTrigger(Trigger): | ||||
|                     "coerce": "integer", | ||||
|                 }, | ||||
|             }, | ||||
|         } | ||||
|         }, | ||||
|         "rss": { | ||||
|             "type": "dict", | ||||
|             "schema": { | ||||
|                 "type": { | ||||
|                     "type": "string", | ||||
|                     "allowed": ["rss"], | ||||
|                 }, | ||||
|                 "homepage": { | ||||
|                     "type": "string", | ||||
|                     "empty": False, | ||||
|                     "is_url": ["http", "https"], | ||||
|                 }, | ||||
|                 "link_path": { | ||||
|                     "type": "string", | ||||
|                     "required": True, | ||||
|                     "empty": False, | ||||
|                     "is_url": [], | ||||
|                 }, | ||||
|                 "max_entries": { | ||||
|                     "type": "integer", | ||||
|                     "coerce": "integer", | ||||
|                 }, | ||||
|                 "path": { | ||||
|                     "type": "path", | ||||
|                     "coerce": "absolute_path", | ||||
|                     "required": True, | ||||
|                 }, | ||||
|                 "rss_url": { | ||||
|                     "type": "string", | ||||
|                     "empty": False, | ||||
|                     "is_url": ["http", "https"], | ||||
|                 }, | ||||
|                 "template": { | ||||
|                     "type": "string", | ||||
|                     "excludes": ["template_path"], | ||||
|                     "dependencies": ["templates"], | ||||
|                     "required": True, | ||||
|                     "empty": False, | ||||
|                 }, | ||||
|                 "template_path": { | ||||
|                     "type": "path", | ||||
|                     "coerce": "absolute_path", | ||||
|                     "excludes": ["template"], | ||||
|                     "required": True, | ||||
|                     "path_exists": True, | ||||
|                     "path_type": "file", | ||||
|                 }, | ||||
|                 "templates": { | ||||
|                     "type": "list", | ||||
|                     "coerce": "list", | ||||
|                     "schema": { | ||||
|                         "type": "path", | ||||
|                         "coerce": "absolute_path", | ||||
|                         "path_exists": True, | ||||
|                         "path_type": "dir", | ||||
|                     }, | ||||
|                     "empty": False, | ||||
|                 }, | ||||
|             }, | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|     def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None: | ||||
|  | ||||
							
								
								
									
										130
									
								
								src/ahriman/core/report/rss.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								src/ahriman/core/report/rss.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,130 @@ | ||||
| # | ||||
| # Copyright (c) 2021-2024 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 datetime | ||||
|  | ||||
| from collections.abc import Callable | ||||
| from email.utils import format_datetime, parsedate_to_datetime | ||||
| from typing import Any | ||||
|  | ||||
| from ahriman.core import context | ||||
| from ahriman.core.configuration import Configuration | ||||
| from ahriman.core.report.jinja_template import JinjaTemplate | ||||
| from ahriman.core.report.report import Report | ||||
| from ahriman.core.status import Client | ||||
| from ahriman.models.event import EventType | ||||
| from ahriman.models.package import Package | ||||
| from ahriman.models.repository_id import RepositoryId | ||||
| from ahriman.models.result import Result | ||||
|  | ||||
|  | ||||
| class RSS(Report, JinjaTemplate): | ||||
|     """ | ||||
|     RSS report generator | ||||
|  | ||||
|     Attributes: | ||||
|         max_entries(int): the maximal amount of entries in RSS | ||||
|         report_path(Path): output path to RSS report | ||||
|         template(str): name of the template | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, repository_id: RepositoryId, configuration: Configuration, section: str) -> None: | ||||
|         """ | ||||
|         default constructor | ||||
|  | ||||
|         Args: | ||||
|             repository_id(RepositoryId): repository unique identifier | ||||
|             configuration(Configuration): configuration instance | ||||
|             section(str): settings section name | ||||
|         """ | ||||
|         Report.__init__(self, repository_id, configuration) | ||||
|         JinjaTemplate.__init__(self, repository_id, configuration, section) | ||||
|  | ||||
|         self.max_entries = configuration.getint(section, "max_entries", fallback=-1) | ||||
|         self.report_path = configuration.getpath(section, "path") | ||||
|         self.template = configuration.get(section, "template") | ||||
|  | ||||
|     @staticmethod | ||||
|     def format_datetime(timestamp: datetime.datetime | float | int | None) -> str: | ||||
|         """ | ||||
|         convert datetime object to string | ||||
|  | ||||
|         Args: | ||||
|             timestamp(datetime.datetime | float | int | None): datetime to convert | ||||
|  | ||||
|         Returns: | ||||
|             str: datetime as string representation | ||||
|         """ | ||||
|         if timestamp is None: | ||||
|             return "" | ||||
|         if isinstance(timestamp, (int, float)): | ||||
|             timestamp = datetime.datetime.fromtimestamp(timestamp, datetime.UTC) | ||||
|         return format_datetime(timestamp) | ||||
|  | ||||
|     @staticmethod | ||||
|     def sort_content(content: list[dict[str, Any]]) -> list[dict[str, Any]]: | ||||
|         """ | ||||
|         sort content before rendering | ||||
|  | ||||
|         Args: | ||||
|             content(list[dict[str, str]]): content of the template | ||||
|  | ||||
|         Returns: | ||||
|             list[dict[str, str]]: sorted content according to comparator defined | ||||
|         """ | ||||
|         comparator: Callable[[dict[str, str]], datetime.datetime] = \ | ||||
|             lambda item: parsedate_to_datetime(item["build_date"]) | ||||
|         return sorted(content, key=comparator, reverse=True) | ||||
|  | ||||
|     def content(self, packages: list[Package]) -> Result: | ||||
|         """ | ||||
|         extract result to be written to template | ||||
|  | ||||
|         Args: | ||||
|             packages(list[Package]): list of packages to generate report | ||||
|  | ||||
|         Returns: | ||||
|             Result: result descriptor | ||||
|         """ | ||||
|         ctx = context.get() | ||||
|         reporter = ctx.get(Client) | ||||
|         events = reporter.event_get(EventType.PackageUpdated, None, limit=self.max_entries) | ||||
|  | ||||
|         known_packages = {package.base: package for package in packages} | ||||
|  | ||||
|         result = Result() | ||||
|         for event in events: | ||||
|             package = known_packages.get(event.object_id) | ||||
|             if package is None: | ||||
|                 continue  # package not found | ||||
|             result.add_updated(package) | ||||
|  | ||||
|         return result | ||||
|  | ||||
|     def generate(self, packages: list[Package], result: Result) -> None: | ||||
|         """ | ||||
|         generate report for the specified packages | ||||
|  | ||||
|         Args: | ||||
|             packages(list[Package]): list of packages to generate report | ||||
|             result(Result): build result | ||||
|         """ | ||||
|         result = self.content(packages) | ||||
|         rss = self.make_html(result, self.template) | ||||
|         self.report_path.write_text(rss, encoding="utf8") | ||||
| @ -32,6 +32,7 @@ class ReportSettings(StrEnum): | ||||
|         Email(ReportSettings): (class attribute) email report generation | ||||
|         Console(ReportSettings): (class attribute) print result to console | ||||
|         Telegram(ReportSettings): (class attribute) markdown report to telegram channel | ||||
|         RSS(ReportSettings): (class attribute) RSS report generation | ||||
|         RemoteCall(ReportSettings): (class attribute) remote ahriman server call | ||||
|     """ | ||||
|  | ||||
| @ -40,10 +41,11 @@ class ReportSettings(StrEnum): | ||||
|     Email = "email" | ||||
|     Console = "console" | ||||
|     Telegram = "telegram" | ||||
|     RSS = "rss" | ||||
|     RemoteCall = "remote-call" | ||||
|  | ||||
|     @staticmethod | ||||
|     def from_option(value: str) -> ReportSettings: | ||||
|     def from_option(value: str) -> ReportSettings:  # pylint: disable=too-many-return-statements | ||||
|         """ | ||||
|         construct value from configuration | ||||
|  | ||||
| @ -62,6 +64,8 @@ class ReportSettings(StrEnum): | ||||
|                 return ReportSettings.Console | ||||
|             case "telegram": | ||||
|                 return ReportSettings.Telegram | ||||
|             case "rss": | ||||
|                 return ReportSettings.RSS | ||||
|             case "ahriman" | "remote-call": | ||||
|                 return ReportSettings.RemoteCall | ||||
|             case _: | ||||
|  | ||||
| @ -75,6 +75,7 @@ def test_schema(configuration: Configuration) -> None: | ||||
|     assert schema.pop("remote-push") | ||||
|     assert schema.pop("remote-service") | ||||
|     assert schema.pop("report") | ||||
|     assert schema.pop("rss") | ||||
|     assert schema.pop("rsync") | ||||
|     assert schema.pop("s3") | ||||
|     assert schema.pop("telegram") | ||||
|  | ||||
| @ -3,6 +3,7 @@ import pytest | ||||
| from ahriman.core.configuration import Configuration | ||||
| from ahriman.core.report.email import Email | ||||
| from ahriman.core.report.remote_call import RemoteCall | ||||
| from ahriman.core.report.rss import RSS | ||||
| from ahriman.core.report.telegram import Telegram | ||||
|  | ||||
|  | ||||
| @ -15,7 +16,7 @@ def email(configuration: Configuration) -> Email: | ||||
|         configuration(Configuration): configuration fixture | ||||
|  | ||||
|     Returns: | ||||
|         RemoteCall: email trigger test instance | ||||
|         Email: email trigger test instance | ||||
|     """ | ||||
|     _, repository_id = configuration.check_loaded() | ||||
|     return Email(repository_id, configuration, "email") | ||||
| @ -38,6 +39,21 @@ def remote_call(configuration: Configuration) -> RemoteCall: | ||||
|     return RemoteCall(repository_id, configuration, "remote-call") | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def rss(configuration: Configuration) -> RSS: | ||||
|     """ | ||||
|     fixture for rss trigger | ||||
|  | ||||
|     Args: | ||||
|         configuration(Configuration): configuration fixture | ||||
|  | ||||
|     Returns: | ||||
|         RSS: rss trigger test instance | ||||
|     """ | ||||
|     _, repository_id = configuration.check_loaded() | ||||
|     return RSS(repository_id, configuration, "rss") | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def telegram(configuration: Configuration) -> Telegram: | ||||
|     """ | ||||
| @ -47,7 +63,7 @@ def telegram(configuration: Configuration) -> Telegram: | ||||
|         configuration(Configuration): configuration fixture | ||||
|  | ||||
|     Returns: | ||||
|         RemoteCall: telegram trigger test instance | ||||
|         Telegram: telegram trigger test instance | ||||
|     """ | ||||
|     _, repository_id = configuration.check_loaded() | ||||
|     return Telegram(repository_id, configuration, "telegram") | ||||
|  | ||||
| @ -1,9 +1,24 @@ | ||||
| from ahriman.core.configuration import Configuration | ||||
| from ahriman.core.report.jinja_template import JinjaTemplate | ||||
| from ahriman.core.utils import utcnow | ||||
| from ahriman.models.package import Package | ||||
| from ahriman.models.result import Result | ||||
|  | ||||
|  | ||||
| def test_format_datetime() -> None: | ||||
|     """ | ||||
|     must format datetime | ||||
|     """ | ||||
|     assert JinjaTemplate.format_datetime(utcnow()) | ||||
|  | ||||
|  | ||||
| def sort_content() -> None: | ||||
|     """ | ||||
|     must sort content for the template | ||||
|     """ | ||||
|     assert JinjaTemplate.sort_content([{"filename": "2"}, {"filename": "1"}]) == [{"filename": "1"}, {"filename": "2"}] | ||||
|  | ||||
|  | ||||
| def test_generate(configuration: Configuration, package_ahriman: Package) -> None: | ||||
|     """ | ||||
|     must generate html report | ||||
|  | ||||
| @ -78,6 +78,17 @@ def test_report_remote_call(configuration: Configuration, result: Result, mocker | ||||
|     report_mock.assert_called_once_with([], result) | ||||
|  | ||||
|  | ||||
| def test_report_rss(configuration: Configuration, result: Result, mocker: MockerFixture) -> None: | ||||
|     """ | ||||
|     must instantiate rss trigger | ||||
|     """ | ||||
|     report_mock = mocker.patch("ahriman.core.report.rss.RSS.generate") | ||||
|     _, repository_id = configuration.check_loaded() | ||||
|  | ||||
|     Report.load(repository_id, configuration, "rss").run(result, []) | ||||
|     report_mock.assert_called_once_with([], result) | ||||
|  | ||||
|  | ||||
| def test_report_telegram(configuration: Configuration, result: Result, mocker: MockerFixture) -> None: | ||||
|     """ | ||||
|     must generate telegram report | ||||
|  | ||||
							
								
								
									
										79
									
								
								tests/ahriman/core/report/test_rss.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								tests/ahriman/core/report/test_rss.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,79 @@ | ||||
| import pytest | ||||
|  | ||||
| from email.utils import parsedate_to_datetime | ||||
| from pytest_mock import MockerFixture | ||||
| from unittest.mock import MagicMock | ||||
|  | ||||
| from ahriman.core.report.rss import RSS | ||||
| from ahriman.core.status import Client | ||||
| from ahriman.core.utils import utcnow | ||||
| from ahriman.models.event import Event, EventType | ||||
| from ahriman.models.package import Package | ||||
| from ahriman.models.result import Result | ||||
|  | ||||
|  | ||||
| def test_format_datetime() -> None: | ||||
|     """ | ||||
|     must format timestamp to rfc format | ||||
|     """ | ||||
|     timestamp = utcnow().replace(microsecond=0) | ||||
|     assert parsedate_to_datetime(RSS.format_datetime(timestamp.timestamp())) == timestamp | ||||
|  | ||||
|  | ||||
| def test_format_datetime_datetime() -> None: | ||||
|     """ | ||||
|     must format datetime to rfc format | ||||
|     """ | ||||
|     timestamp = utcnow().replace(microsecond=0) | ||||
|     assert parsedate_to_datetime(RSS.format_datetime(timestamp)) == timestamp | ||||
|  | ||||
|  | ||||
| def test_format_datetime_empty() -> None: | ||||
|     """ | ||||
|     must generate empty string from None timestamp | ||||
|     """ | ||||
|     assert RSS.format_datetime(None) == "" | ||||
|  | ||||
|  | ||||
| def test_sort_content() -> None: | ||||
|     """ | ||||
|     must sort content for the template | ||||
|     """ | ||||
|     assert RSS.sort_content([ | ||||
|         {"filename": "2", "build_date": "Thu, 29 Aug 2024 16:36:55 -0000"}, | ||||
|         {"filename": "1", "build_date": "Thu, 29 Aug 2024 16:36:54 -0000"}, | ||||
|         {"filename": "3", "build_date": "Thu, 29 Aug 2024 16:36:56 -0000"}, | ||||
|     ]) == [ | ||||
|         {"filename": "3", "build_date": "Thu, 29 Aug 2024 16:36:56 -0000"}, | ||||
|         {"filename": "2", "build_date": "Thu, 29 Aug 2024 16:36:55 -0000"}, | ||||
|         {"filename": "1", "build_date": "Thu, 29 Aug 2024 16:36:54 -0000"}, | ||||
|     ] | ||||
|  | ||||
|  | ||||
| def test_content(rss: RSS, package_ahriman: Package, mocker: MockerFixture) -> None: | ||||
|     """ | ||||
|     must generate RSS content correctly | ||||
|     """ | ||||
|     client_mock = MagicMock() | ||||
|     client_mock.event_get.return_value = [ | ||||
|         Event(EventType.PackageUpdated, package_ahriman.base), | ||||
|         Event(EventType.PackageUpdated, "random"), | ||||
|         Event(EventType.PackageUpdated, package_ahriman.base), | ||||
|     ] | ||||
|     context_mock = mocker.patch("ahriman.core._Context.get", return_value=client_mock) | ||||
|  | ||||
|     assert rss.content([package_ahriman]).success == [package_ahriman] | ||||
|     context_mock.assert_called_once_with(Client) | ||||
|     client_mock.event_get.assert_called_once_with(EventType.PackageUpdated, None, limit=rss.max_entries) | ||||
|  | ||||
|  | ||||
| def test_generate(rss: RSS, package_ahriman: Package, mocker: MockerFixture) -> None: | ||||
|     """ | ||||
|     must generate report | ||||
|     """ | ||||
|     content_mock = mocker.patch("ahriman.core.report.rss.RSS.content", return_value=Result()) | ||||
|     write_mock = mocker.patch("pathlib.Path.write_text") | ||||
|  | ||||
|     rss.generate([package_ahriman], Result()) | ||||
|     content_mock.assert_called_once_with([package_ahriman]) | ||||
|     write_mock.assert_called_once_with(pytest.helpers.anyvar(int), encoding="utf8") | ||||
| @ -489,6 +489,7 @@ def test_walk(resource_path_root: Path) -> None: | ||||
|         resource_path_root / "web" / "templates" / "email-index.jinja2", | ||||
|         resource_path_root / "web" / "templates" / "error.jinja2", | ||||
|         resource_path_root / "web" / "templates" / "repo-index.jinja2", | ||||
|         resource_path_root / "web" / "templates" / "rss.jinja2", | ||||
|         resource_path_root / "web" / "templates" / "shell", | ||||
|         resource_path_root / "web" / "templates" / "telegram-index.jinja2", | ||||
|     ]) | ||||
|  | ||||
| @ -24,6 +24,9 @@ def test_from_option_valid() -> None: | ||||
|     assert ReportSettings.from_option("telegram") == ReportSettings.Telegram | ||||
|     assert ReportSettings.from_option("TElegraM") == ReportSettings.Telegram | ||||
|  | ||||
|     assert ReportSettings.from_option("rss") == ReportSettings.RSS | ||||
|     assert ReportSettings.from_option("RSS") == ReportSettings.RSS | ||||
|  | ||||
|     assert ReportSettings.from_option("remote-call") == ReportSettings.RemoteCall | ||||
|     assert ReportSettings.from_option("reMOte-cALL") == ReportSettings.RemoteCall | ||||
|     assert ReportSettings.from_option("ahriman") == ReportSettings.RemoteCall | ||||
|  | ||||
| @ -81,6 +81,13 @@ templates = ../web/templates | ||||
| [remote-call] | ||||
| manual = yes | ||||
|  | ||||
| [rss] | ||||
| path = | ||||
| homepage = | ||||
| link_path = | ||||
| template = rss.jinja2 | ||||
| templates = ../web/templates | ||||
|  | ||||
| [telegram] | ||||
| api_key = apikey | ||||
| chat_id = @ahrimantestchat | ||||
|  | ||||
		Reference in New Issue
	
	Block a user