diff --git a/docs/configuration.rst b/docs/configuration.rst
index 4a416aa8..81cbc843 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -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.
diff --git a/package/share/ahriman/settings/ahriman.ini b/package/share/ahriman/settings/ahriman.ini
index 9c22d7fa..bd815a8c 100644
--- a/package/share/ahriman/settings/ahriman.ini
+++ b/package/share/ahriman/settings/ahriman.ini
@@ -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 =
diff --git a/package/share/ahriman/templates/repo-index.jinja2 b/package/share/ahriman/templates/repo-index.jinja2
index e6314f53..6f2add03 100644
--- a/package/share/ahriman/templates/repo-index.jinja2
+++ b/package/share/ahriman/templates/repo-index.jinja2
@@ -7,6 +7,10 @@
{% include "utils/style.jinja2" %}
{% include "user-style.jinja2" ignore missing %}
+
+ {% if rss_url is not none %}
+
+ {% endif %}
diff --git a/package/share/ahriman/templates/rss.jinja2 b/package/share/ahriman/templates/rss.jinja2
new file mode 100644
index 00000000..07f45a85
--- /dev/null
+++ b/package/share/ahriman/templates/rss.jinja2
@@ -0,0 +1,27 @@
+
+
+
+ {{ repository }}: Recent package updates
+ {% if homepage is not none %}
+ {{ homepage }}
+ {% endif %}
+ Recently updated packages in the {{ repository }}.
+ {% if rss_url is not none %}
+
+ {% endif %}
+ en-us
+ {{ last_update }}
+
+ {% for package in packages %}
+
+ {{ package.name }} {{ package.version }} {{ package.architecture }}
+ {{ link_path }}/{{ package.filename }}
+ {{ package.description }}
+ {{ package.build_date }}
+ {{ package.tag }}
+ {{ repository }}
+ {{ package.architecture }}
+
+ {% endfor %}
+
+
diff --git a/src/ahriman/core/build_tools/package_archive.py b/src/ahriman/core/build_tools/package_archive.py
index fe2f737c..006724d5 100644
--- a/src/ahriman/core/build_tools/package_archive.py
+++ b/src/ahriman/core/build_tools/package_archive.py
@@ -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
diff --git a/src/ahriman/core/report/jinja_template.py b/src/ahriman/core/report/jinja_template.py
index e00a366f..5facddae 100644
--- a/src/ahriman/core/report/jinja_template.py
+++ b/src/ahriman/core/report/jinja_template.py
@@ -17,14 +17,16 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
+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,
+ )
diff --git a/src/ahriman/core/report/report.py b/src/ahriman/core/report/report.py
index 8fc5ca2c..c835f2ee 100644
--- a/src/ahriman/core/report/report.py
+++ b/src/ahriman/core/report/report.py
@@ -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)
diff --git a/src/ahriman/core/report/report_trigger.py b/src/ahriman/core/report/report_trigger.py
index 2be51a16..f9204324 100644
--- a/src/ahriman/core/report/report_trigger.py
+++ b/src/ahriman/core/report/report_trigger.py
@@ -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:
diff --git a/src/ahriman/core/report/rss.py b/src/ahriman/core/report/rss.py
new file mode 100644
index 00000000..cc304914
--- /dev/null
+++ b/src/ahriman/core/report/rss.py
@@ -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 .
+#
+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")
diff --git a/src/ahriman/models/report_settings.py b/src/ahriman/models/report_settings.py
index 366c3149..01a4881a 100644
--- a/src/ahriman/models/report_settings.py
+++ b/src/ahriman/models/report_settings.py
@@ -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 _:
diff --git a/tests/ahriman/application/handlers/test_handler_validate.py b/tests/ahriman/application/handlers/test_handler_validate.py
index 2f4a4e2f..8525fdc4 100644
--- a/tests/ahriman/application/handlers/test_handler_validate.py
+++ b/tests/ahriman/application/handlers/test_handler_validate.py
@@ -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")
diff --git a/tests/ahriman/core/report/conftest.py b/tests/ahriman/core/report/conftest.py
index 8dcc79dd..b93054fa 100644
--- a/tests/ahriman/core/report/conftest.py
+++ b/tests/ahriman/core/report/conftest.py
@@ -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")
diff --git a/tests/ahriman/core/report/test_jinja_template.py b/tests/ahriman/core/report/test_jinja_template.py
index b9f845f5..2e5d3dcc 100644
--- a/tests/ahriman/core/report/test_jinja_template.py
+++ b/tests/ahriman/core/report/test_jinja_template.py
@@ -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
diff --git a/tests/ahriman/core/report/test_report.py b/tests/ahriman/core/report/test_report.py
index 7c92317d..cc1e649c 100644
--- a/tests/ahriman/core/report/test_report.py
+++ b/tests/ahriman/core/report/test_report.py
@@ -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
diff --git a/tests/ahriman/core/report/test_rss.py b/tests/ahriman/core/report/test_rss.py
new file mode 100644
index 00000000..ffd6550a
--- /dev/null
+++ b/tests/ahriman/core/report/test_rss.py
@@ -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")
diff --git a/tests/ahriman/core/test_utils.py b/tests/ahriman/core/test_utils.py
index ecf2b9b6..d825a81d 100644
--- a/tests/ahriman/core/test_utils.py
+++ b/tests/ahriman/core/test_utils.py
@@ -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",
])
diff --git a/tests/ahriman/models/test_report_settings.py b/tests/ahriman/models/test_report_settings.py
index 8974cc56..25a5db7e 100644
--- a/tests/ahriman/models/test_report_settings.py
+++ b/tests/ahriman/models/test_report_settings.py
@@ -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
diff --git a/tests/testresources/core/ahriman.ini b/tests/testresources/core/ahriman.ini
index fe88b912..d60f0737 100644
--- a/tests/testresources/core/ahriman.ini
+++ b/tests/testresources/core/ahriman.ini
@@ -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