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.
* ``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.

View File

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

View File

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

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]] = {}
# 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

View File

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

View File

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

View File

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

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
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 _:

View File

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

View File

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

View File

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

View File

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

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" / "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",
])

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("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

View File

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