implement rss generation

This commit is contained in:
Evgenii Alekseev 2024-08-29 04:42:17 +03:00
parent fc508e19b8
commit d2a7e3cd53
18 changed files with 481 additions and 28 deletions

View File

@ -251,6 +251,7 @@ Section name must be either ``email`` (plus optional architecture name, e.g. ``e
* ``password`` - SMTP password to authenticate, string, optional. * ``password`` - SMTP password to authenticate, string, optional.
* ``port`` - SMTP port for sending emails, integer, required. * ``port`` - SMTP port for sending emails, integer, required.
* ``receivers`` - SMTP receiver addresses, space separated list of strings, 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. * ``sender`` - SMTP sender address, string, required.
* ``ssl`` - SSL mode for SMTP connection, one of ``ssl``, ``starttls``, ``disabled``, optional, default ``disabled``. * ``ssl`` - SSL mode for SMTP connection, one of ``ssl``, ``starttls``, ``disabled``, optional, default ``disabled``.
* ``template`` - Jinja2 template name, string, required. * ``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. * ``type`` - type of the report, string, optional, must be set to ``html`` if exists.
* ``homepage`` - link to homepage, string, optional. * ``homepage`` - link to homepage, string, optional.
* ``link_path`` - prefix for HTML links, string, required. * ``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. * ``template`` - Jinja2 template name, string, required.
* ``templates`` - path to templates directories, space separated list of paths, 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``. * ``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``. * ``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 ``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. * ``chat_id`` - telegram chat id, either string with ``@`` or integer value, required.
* ``homepage`` - link to homepage, string, optional. * ``homepage`` - link to homepage, string, optional.
* ``link_path`` - prefix for HTML links, string, required. * ``link_path`` - prefix for HTML links, string, required.
* ``rss_url`` - link to RSS feed, string, optional.
* ``template`` - Jinja2 template name, string, required. * ``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``. * ``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. * ``templates`` - path to templates directories, space separated list of paths, required.

View File

@ -17,7 +17,7 @@ mirror = https://geo.mirror.pkgbuild.com/$repo/os/$arch
repositories = core extra multilib repositories = core extra multilib
; Pacman's root directory. In the most cases it must point to the system root. ; Pacman's root directory. In the most cases it must point to the system root.
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 sync_files_database = yes
; Use local packages cache. If this option is enabled, the service will be able to synchronize databases (available ; 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. ; as additional option for some subcommands). If set to no, databases must be synchronized manually.
@ -52,17 +52,17 @@ allow_read_only = yes
[build] [build]
; List of additional flags passed to archbuild command. ; List of additional flags passed to archbuild command.
;archbuild_flags = ;archbuild_flags =
; Path to build command ; Path to build command.
;build_command = ;build_command =
; List of packages to be ignored during automatic updates. ; List of packages to be ignored during automatic updates.
;ignore_packages = ;ignore_packages =
; Include debug packages ; Include debug packages.
;include_debug_packages = yes ;include_debug_packages = yes
; List of additional flags passed to makechrootpkg command. ; List of additional flags passed to makechrootpkg command.
;makechrootpkg_flags = ;makechrootpkg_flags =
; List of additional flags passed to makepkg command. ; List of additional flags passed to makepkg command.
makepkg_flags = --nocolor --ignorearch 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).*$ scan_paths = ^usr/lib(?!/cmake).*$
; List of enabled triggers in the order of calls. ; 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 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 reporting trigger configuration sample.
[console] [console]
; Trigger type name ; Trigger type name.
;type = console ;type = console
; Use utf8 symbols in output. ; Use utf8 symbols in output.
use_utf = yes use_utf = yes
; Email reporting trigger configuration sample. ; Email reporting trigger configuration sample.
[email] [email]
; Trigger type name ; Trigger type name.
;type = email ;type = email
; Optional URL to the repository homepage. ; Optional URL to the repository homepage.
;homepage= ;homepage=
@ -235,6 +235,8 @@ use_utf = yes
;port = ;port =
; List of emails to receive the reports. ; List of emails to receive the reports.
;receivers = ;receivers =
; Optional link to the RSS feed.
;rss_url =
; Sender email. ; Sender email.
;sender = ;sender =
; SMTP server SSL mode, one of ssl, starttls, disabled. ; SMTP server SSL mode, one of ssl, starttls, disabled.
@ -250,7 +252,7 @@ templates = /usr/share/ahriman/templates
; HTML reporting trigger configuration sample. ; HTML reporting trigger configuration sample.
[html] [html]
; Trigger type name ; Trigger type name.
;type = html ;type = html
; Optional URL to the repository homepage. ; Optional URL to the repository homepage.
;homepage= ;homepage=
@ -258,6 +260,8 @@ templates = /usr/share/ahriman/templates
;link_path = ;link_path =
; Output path for the HTML report. ; Output path for the HTML report.
;path = ;path =
; Optional link to the RSS feed.
;rss_url =
; Template name to be used. ; Template name to be used.
template = repo-index.jinja2 template = repo-index.jinja2
; List of directories with templates. ; List of directories with templates.
@ -265,7 +269,7 @@ templates = /usr/share/ahriman/templates
; Remote service callback trigger configuration sample. ; Remote service callback trigger configuration sample.
[remote-call] [remote-call]
; Trigger type name ; Trigger type name.
;type = remote-call ;type = remote-call
; Call for AUR packages update. ; Call for AUR packages update.
;aur = no ;aur = no
@ -276,9 +280,26 @@ templates = /usr/share/ahriman/templates
; Wait until remote process will be terminated in seconds. ; Wait until remote process will be terminated in seconds.
;wait_timeout = -1 ;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 reporting trigger configuration sample.
[telegram] [telegram]
; Trigger type name ; Trigger type name.
;type = telegram ;type = telegram
; Telegram bot API key. ; Telegram bot API key.
;api_key = ;api_key =
@ -288,6 +309,8 @@ templates = /usr/share/ahriman/templates
;homepage= ;homepage=
; Prefix for packages links. Link to a package will be formed as link_path / filename. ; Prefix for packages links. Link to a package will be formed as link_path / filename.
;link_path = ;link_path =
; Optional link to the RSS feed.
;rss_url =
; Template name to be used. ; Template name to be used.
template = telegram-index.jinja2 template = telegram-index.jinja2
; Telegram specific template mode, one of MarkdownV2, HTML or Markdown. ; Telegram specific template mode, one of MarkdownV2, HTML or Markdown.
@ -304,7 +327,7 @@ target =
; GitHub upload trigger configuration sample. ; GitHub upload trigger configuration sample.
[github] [github]
; Trigger type name ; Trigger type name.
;type = github ;type = github
; GitHub repository owner username. ; GitHub repository owner username.
;owner = ;owner =
@ -321,14 +344,14 @@ target =
; Remote instance upload trigger configuration sample. ; Remote instance upload trigger configuration sample.
[remote-service] [remote-service]
; Trigger type name ; Trigger type name.
;type = remote-service ;type = remote-service
; HTTP request timeout in seconds. ; HTTP request timeout in seconds.
;timeout = 30 ;timeout = 30
; rsync upload trigger configuration sample. ; rsync upload trigger configuration sample.
[rsync] [rsync]
; Trigger type name ; Trigger type name.
;type = rsync ;type = rsync
; rsync command to run. ; rsync command to run.
command = rsync --archive --compress --partial --delete command = rsync --archive --compress --partial --delete
@ -338,7 +361,7 @@ command = rsync --archive --compress --partial --delete
; S3 upload trigger configuration sample. ; S3 upload trigger configuration sample.
[s3] [s3]
; Trigger type name ; Trigger type name.
;type = s3 ;type = s3
; AWS services access key. ; AWS services access key.
;access_key = ;access_key =

View File

@ -7,6 +7,10 @@
{% include "utils/style.jinja2" %} {% include "utils/style.jinja2" %}
{% include "user-style.jinja2" ignore missing %} {% include "user-style.jinja2" ignore missing %}
{% if rss_url is not none %}
<link rel="alternate" href="{{ rss_url }}" type="application/rss+xml">
{% endif %}
</head> </head>
<body> <body>

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

View File

@ -171,7 +171,7 @@ class PackageArchive:
result: dict[Path, list[FilesystemPackage]] = {} result: dict[Path, list[FilesystemPackage]] = {}
# sort items from children directories to root # 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 # skip if this path belongs to the one of the base packages
if any(package.package_name in base_packages for package in packages): if any(package.package_name in base_packages for package in packages):
continue continue

View File

@ -17,14 +17,16 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import datetime
import jinja2 import jinja2
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.sign.gpg import GPG 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.repository_id import RepositoryId
from ahriman.models.result import Result from ahriman.models.result import Result
from ahriman.models.sign_settings import SignSettings 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: It uses jinja2 templates for report generation, the following variables are allowed:
* homepage - link to homepage, string, optional * homepage - link to homepage, string, optional
* last_update - report generation time, pretty printed datetime, required
* link_path - prefix fo packages to download, string, required * link_path - prefix fo packages to download, string, required
* has_package_signed - ``True`` in case if package sign enabled, ``False`` otherwise, 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 * 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 * build_date, pretty printed datetime, string
* depends, sorted list of strings * depends, sorted list of strings
* description, string * description, string
* filename, string, * filename, string
* groups, sorted list of strings * groups, sorted list of strings
* installed_size, pretty printed datetime, string * installed_size, pretty printed size, string
* licenses, sorted list of strings * licenses, sorted list of strings
* name, string * name, string
* tag, string
* url, string * url, string
* version, string * version, string
* pgp_key - default PGP key ID, string, optional * pgp_key - default PGP key ID, string, optional
* repository - repository name, string, required * repository - repository name, string, required
* rss_url - optional link to the RSS feed, string, optional
Attributes: Attributes:
default_pgp_key(str | None): default PGP key default_pgp_key(str | None): default PGP key
homepage(str | None): homepage link if any (for footer) homepage(str | None): homepage link if any (for footer)
link_path(str): prefix fo packages to download link_path(str): prefix fo packages to download
name(str): repository name name(str): repository name
rss_url(str | None): link to the RSS feed
sign_targets(set[SignSettings]): targets to sign enabled in configuration sign_targets(set[SignSettings]): targets to sign enabled in configuration
templates(list[Path]): list of directories with templates templates(list[Path]): list of directories with templates
""" """
@ -80,8 +86,36 @@ class JinjaTemplate:
self.homepage = configuration.get(section, "homepage", fallback=None) self.homepage = configuration.get(section, "homepage", fallback=None)
self.link_path = configuration.get(section, "link_path") self.link_path = configuration.get(section, "link_path")
self.name = repository_id.name 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) 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: def make_html(self, result: Result, template_name: Path | str) -> str:
""" """
generate report for the specified packages generate report for the specified packages
@ -104,7 +138,7 @@ class JinjaTemplate:
{ {
"architecture": properties.architecture or "", "architecture": properties.architecture or "",
"archive_size": pretty_size(properties.archive_size), "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, "depends": properties.depends,
"description": properties.description or "", "description": properties.description or "",
"filename": properties.filename, "filename": properties.filename,
@ -112,17 +146,20 @@ class JinjaTemplate:
"installed_size": pretty_size(properties.installed_size), "installed_size": pretty_size(properties.installed_size),
"licenses": properties.licenses, "licenses": properties.licenses,
"name": package, "name": package,
"tag": f"tag:{self.name}:{properties.architecture}:{package}:{base.version}:{properties.build_date}",
"url": properties.url or "", "url": properties.url or "",
"version": base.version "version": base.version,
} for base in result.success for package, properties in base.packages.items() } 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( return template.render(
homepage=self.homepage, homepage=self.homepage,
last_update=self.format_datetime(utcnow()),
link_path=self.link_path, link_path=self.link_path,
has_package_signed=SignSettings.Packages in self.sign_targets, has_package_signed=SignSettings.Packages in self.sign_targets,
has_repo_signed=SignSettings.Repository 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, pgp_key=self.default_pgp_key,
repository=self.name) repository=self.name,
rss_url=self.rss_url,
)

View File

@ -66,7 +66,7 @@ class Report(LazyLogging):
self.configuration = configuration self.configuration = configuration
@staticmethod @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 load client from settings
@ -92,6 +92,9 @@ class Report(LazyLogging):
case ReportSettings.Telegram: case ReportSettings.Telegram:
from ahriman.core.report.telegram import Telegram from ahriman.core.report.telegram import Telegram
return Telegram(repository_id, configuration, section) 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: case ReportSettings.RemoteCall:
from ahriman.core.report.remote_call import RemoteCall from ahriman.core.report.remote_call import RemoteCall
return RemoteCall(repository_id, configuration, section) return RemoteCall(repository_id, configuration, section)

View File

@ -116,6 +116,11 @@ class ReportTrigger(Trigger):
"required": True, "required": True,
"empty": False, "empty": False,
}, },
"rss_url": {
"type": "string",
"empty": False,
"is_url": ["http", "https"],
},
"sender": { "sender": {
"type": "string", "type": "string",
"required": True, "required": True,
@ -187,6 +192,11 @@ class ReportTrigger(Trigger):
"coerce": "absolute_path", "coerce": "absolute_path",
"required": True, "required": True,
}, },
"rss_url": {
"type": "string",
"empty": False,
"is_url": ["http", "https"],
},
"template": { "template": {
"type": "string", "type": "string",
"excludes": ["template_path"], "excludes": ["template_path"],
@ -243,6 +253,11 @@ class ReportTrigger(Trigger):
"empty": False, "empty": False,
"is_url": [], "is_url": [],
}, },
"rss_url": {
"type": "string",
"empty": False,
"is_url": ["http", "https"],
},
"template": { "template": {
"type": "string", "type": "string",
"excludes": ["template_path"], "excludes": ["template_path"],
@ -304,7 +319,67 @@ class ReportTrigger(Trigger):
"coerce": "integer", "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: def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:

View 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")

View File

@ -32,6 +32,7 @@ class ReportSettings(StrEnum):
Email(ReportSettings): (class attribute) email report generation Email(ReportSettings): (class attribute) email report generation
Console(ReportSettings): (class attribute) print result to console Console(ReportSettings): (class attribute) print result to console
Telegram(ReportSettings): (class attribute) markdown report to telegram channel Telegram(ReportSettings): (class attribute) markdown report to telegram channel
RSS(ReportSettings): (class attribute) RSS report generation
RemoteCall(ReportSettings): (class attribute) remote ahriman server call RemoteCall(ReportSettings): (class attribute) remote ahriman server call
""" """
@ -40,10 +41,11 @@ class ReportSettings(StrEnum):
Email = "email" Email = "email"
Console = "console" Console = "console"
Telegram = "telegram" Telegram = "telegram"
RSS = "rss"
RemoteCall = "remote-call" RemoteCall = "remote-call"
@staticmethod @staticmethod
def from_option(value: str) -> ReportSettings: def from_option(value: str) -> ReportSettings: # pylint: disable=too-many-return-statements
""" """
construct value from configuration construct value from configuration
@ -62,6 +64,8 @@ class ReportSettings(StrEnum):
return ReportSettings.Console return ReportSettings.Console
case "telegram": case "telegram":
return ReportSettings.Telegram return ReportSettings.Telegram
case "rss":
return ReportSettings.RSS
case "ahriman" | "remote-call": case "ahriman" | "remote-call":
return ReportSettings.RemoteCall return ReportSettings.RemoteCall
case _: case _:

View File

@ -75,6 +75,7 @@ def test_schema(configuration: Configuration) -> None:
assert schema.pop("remote-push") assert schema.pop("remote-push")
assert schema.pop("remote-service") assert schema.pop("remote-service")
assert schema.pop("report") assert schema.pop("report")
assert schema.pop("rss")
assert schema.pop("rsync") assert schema.pop("rsync")
assert schema.pop("s3") assert schema.pop("s3")
assert schema.pop("telegram") assert schema.pop("telegram")

View File

@ -3,6 +3,7 @@ import pytest
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.report.email import Email from ahriman.core.report.email import Email
from ahriman.core.report.remote_call import RemoteCall from ahriman.core.report.remote_call import RemoteCall
from ahriman.core.report.rss import RSS
from ahriman.core.report.telegram import Telegram from ahriman.core.report.telegram import Telegram
@ -15,7 +16,7 @@ def email(configuration: Configuration) -> Email:
configuration(Configuration): configuration fixture configuration(Configuration): configuration fixture
Returns: Returns:
RemoteCall: email trigger test instance Email: email trigger test instance
""" """
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
return Email(repository_id, configuration, "email") return Email(repository_id, configuration, "email")
@ -38,6 +39,21 @@ def remote_call(configuration: Configuration) -> RemoteCall:
return RemoteCall(repository_id, configuration, "remote-call") 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 @pytest.fixture
def telegram(configuration: Configuration) -> Telegram: def telegram(configuration: Configuration) -> Telegram:
""" """
@ -47,7 +63,7 @@ def telegram(configuration: Configuration) -> Telegram:
configuration(Configuration): configuration fixture configuration(Configuration): configuration fixture
Returns: Returns:
RemoteCall: telegram trigger test instance Telegram: telegram trigger test instance
""" """
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
return Telegram(repository_id, configuration, "telegram") return Telegram(repository_id, configuration, "telegram")

View File

@ -1,9 +1,24 @@
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.report.jinja_template import JinjaTemplate from ahriman.core.report.jinja_template import JinjaTemplate
from ahriman.core.utils import utcnow
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.result import Result 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: def test_generate(configuration: Configuration, package_ahriman: Package) -> None:
""" """
must generate html report must generate html report

View File

@ -78,6 +78,17 @@ def test_report_remote_call(configuration: Configuration, result: Result, mocker
report_mock.assert_called_once_with([], result) 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: def test_report_telegram(configuration: Configuration, result: Result, mocker: MockerFixture) -> None:
""" """
must generate telegram report must generate telegram report

View 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")

View File

@ -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" / "email-index.jinja2",
resource_path_root / "web" / "templates" / "error.jinja2", resource_path_root / "web" / "templates" / "error.jinja2",
resource_path_root / "web" / "templates" / "repo-index.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" / "shell",
resource_path_root / "web" / "templates" / "telegram-index.jinja2", resource_path_root / "web" / "templates" / "telegram-index.jinja2",
]) ])

View File

@ -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("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("reMOte-cALL") == ReportSettings.RemoteCall assert ReportSettings.from_option("reMOte-cALL") == ReportSettings.RemoteCall
assert ReportSettings.from_option("ahriman") == ReportSettings.RemoteCall assert ReportSettings.from_option("ahriman") == ReportSettings.RemoteCall

View File

@ -81,6 +81,13 @@ templates = ../web/templates
[remote-call] [remote-call]
manual = yes manual = yes
[rss]
path =
homepage =
link_path =
template = rss.jinja2
templates = ../web/templates
[telegram] [telegram]
api_key = apikey api_key = apikey
chat_id = @ahrimantestchat chat_id = @ahrimantestchat