Improve template processing

This commit is contained in:
Evgenii Alekseev 2023-09-08 19:08:48 +03:00
parent a56fe28003
commit a02baec662
15 changed files with 200 additions and 33 deletions

View File

@ -54,14 +54,17 @@ use_utf = yes
[email] [email]
no_empty_report = yes no_empty_report = yes
template_path = /usr/share/ahriman/templates/email-index.jinja2 template = email-index.jinja2
templates = /usr/share/ahriman/templates
ssl = disabled ssl = disabled
[html] [html]
template_path = /usr/share/ahriman/templates/repo-index.jinja2 template = repo-index.jinja2
templates = /usr/share/ahriman/templates
[telegram] [telegram]
template_path = /usr/share/ahriman/templates/telegram-index.jinja2 template = telegram-index.jinja2
templates = /usr/share/ahriman/templates
[upload] [upload]
target = target =

View File

@ -82,6 +82,7 @@ class Configuration(configparser.RawConfigParser):
converters={ converters={
"list": shlex.split, "list": shlex.split,
"path": self._convert_path, "path": self._convert_path,
"pathlist": lambda value: [self._convert_path(element) for element in shlex.split(value)],
} }
) )
@ -241,6 +242,8 @@ class Configuration(configparser.RawConfigParser):
def getpath(self, *args: Any, **kwargs: Any) -> Path: ... # type: ignore[empty-body] def getpath(self, *args: Any, **kwargs: Any) -> Path: ... # type: ignore[empty-body]
def getpathlist(self, *args: Any, **kwargs: Any) -> list[Path]: ... # type: ignore[empty-body]
def gettype(self, section: str, repository_id: RepositoryId, *, fallback: str | None = None) -> tuple[str, str]: def gettype(self, section: str, repository_id: RepositoryId, *, fallback: str | None = None) -> tuple[str, str]:
""" """
get type variable with fallback to old logic. Despite the fact that it has same semantics as other get* methods, get type variable with fallback to old logic. Despite the fact that it has same semantics as other get* methods,

View File

@ -264,10 +264,14 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"path_exists": True, "path_exists": True,
}, },
"templates": { "templates": {
"type": "path", "type": "list",
"coerce": "absolute_path", "coerce": "list",
"required": True, "schema": {
"path_exists": True, "type": "path",
"coerce": "absolute_path",
"path_exists": True,
},
"empty": False,
}, },
"timeout": { "timeout": {
"type": "integer", "type": "integer",

View File

@ -37,7 +37,6 @@ class Email(Report, JinjaTemplate):
email report generator email report generator
Attributes: Attributes:
full_template_path(Path): path to template for full package list
host(str): SMTP host to connect host(str): SMTP host to connect
no_empty_report(bool): skip empty report generation no_empty_report(bool): skip empty report generation
password(str | None): password to authenticate via SMTP password(str | None): password to authenticate via SMTP
@ -45,7 +44,8 @@ class Email(Report, JinjaTemplate):
receivers(list[str]): list of receivers emails receivers(list[str]): list of receivers emails
sender(str): sender email address sender(str): sender email address
ssl(SmtpSSLSettings): SSL mode for SMTP connection ssl(SmtpSSLSettings): SSL mode for SMTP connection
template_path(Path): path to template for built packages template(Path | str): path or name to template for built packages
template_full(Path | str | None): path or name to template for full package list
user(str | None): username to authenticate via SMTP user(str | None): username to authenticate via SMTP
""" """
@ -61,8 +61,10 @@ class Email(Report, JinjaTemplate):
Report.__init__(self, repository_id, configuration) Report.__init__(self, repository_id, configuration)
JinjaTemplate.__init__(self, repository_id, configuration, section) JinjaTemplate.__init__(self, repository_id, configuration, section)
self.full_template_path = configuration.getpath(section, "full_template_path", fallback=None) self.template = configuration.get(section, "template", fallback=None) or \
self.template_path = configuration.getpath(section, "template_path") configuration.getpath(section, "template_path")
self.template_full = configuration.get(section, "template_full", fallback=None) or \
configuration.getpath(section, "full_template_path", fallback=None)
# base smtp settings # base smtp settings
self.host = configuration.get(section, "host") self.host = configuration.get(section, "host")
@ -114,9 +116,10 @@ class Email(Report, JinjaTemplate):
""" """
if self.no_empty_report and not result.success: if self.no_empty_report and not result.success:
return return
text = self.make_html(result, self.template_path)
if self.full_template_path is not None: text = self.make_html(result, self.template)
attachments = {"index.html": self.make_html(Result(success=packages), self.full_template_path)} attachments = {}
else: if self.template_full is not None:
attachments = {} attachments = {"index.html": self.make_html(Result(success=packages), self.template_full)}
self._send(text, attachments) self._send(text, attachments)

View File

@ -31,7 +31,7 @@ class HTML(Report, JinjaTemplate):
Attributes: Attributes:
report_path(Path): output path to html report report_path(Path): output path to html report
template_path(Path): path to template for full package list template(Path | str): name or path to template for full package list
""" """
def __init__(self, repository_id: RepositoryId, configuration: Configuration, section: str) -> None: def __init__(self, repository_id: RepositoryId, configuration: Configuration, section: str) -> None:
@ -47,7 +47,8 @@ class HTML(Report, JinjaTemplate):
JinjaTemplate.__init__(self, repository_id, configuration, section) JinjaTemplate.__init__(self, repository_id, configuration, section)
self.report_path = configuration.getpath(section, "path") self.report_path = configuration.getpath(section, "path")
self.template_path = configuration.getpath(section, "template_path") self.template = configuration.get(section, "template", fallback=None) or \
configuration.getpath(section, "template_path")
def generate(self, packages: list[Package], result: Result) -> None: def generate(self, packages: list[Package], result: Result) -> None:
""" """
@ -57,5 +58,5 @@ class HTML(Report, JinjaTemplate):
packages(list[Package]): list of packages to generate report packages(list[Package]): list of packages to generate report
result(Result): build result result(Result): build result
""" """
html = self.make_html(Result(success=packages), self.template_path) html = self.make_html(Result(success=packages), self.template)
self.report_path.write_text(html, encoding="utf8") self.report_path.write_text(html, encoding="utf8")

View File

@ -57,11 +57,12 @@ class JinjaTemplate:
* repository - repository name, string, required * repository - repository name, string, required
Attributes: Attributes:
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
default_pgp_key(str | None): default PGP key
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
""" """
def __init__(self, repository_id: RepositoryId, configuration: Configuration, section: str) -> None: def __init__(self, repository_id: RepositoryId, configuration: Configuration, section: str) -> None:
@ -73,6 +74,8 @@ class JinjaTemplate:
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
section(str): settings section name section(str): settings section name
""" """
self.templates = configuration.getpathlist(section, "templates", fallback=[])
self.link_path = configuration.get(section, "link_path") self.link_path = configuration.get(section, "link_path")
# base template vars # base template vars
@ -81,18 +84,23 @@ class JinjaTemplate:
self.sign_targets, self.default_pgp_key = GPG.sign_options(configuration) self.sign_targets, self.default_pgp_key = GPG.sign_options(configuration)
def make_html(self, result: Result, template_path: Path) -> str: def make_html(self, result: Result, template_name: Path | str) -> str:
""" """
generate report for the specified packages generate report for the specified packages
Args: Args:
result(Result): build result result(Result): build result
template_path(Path): path to jinja template template_name(Path | str): name of the template or path to it (legacy configuration)
""" """
templates = self.templates[:]
if isinstance(template_name, Path):
templates.append(template_name.parent)
template_name = template_name.name
# idea comes from https://stackoverflow.com/a/38642558 # idea comes from https://stackoverflow.com/a/38642558
loader = jinja2.FileSystemLoader(searchpath=template_path.parent) loader = jinja2.FileSystemLoader(searchpath=templates)
environment = jinja2.Environment(loader=loader, autoescape=True) environment = jinja2.Environment(loader=loader, autoescape=True)
template = environment.get_template(template_path.name) template = environment.get_template(template_name)
content = [ content = [
{ {

View File

@ -67,6 +67,8 @@ class ReportTrigger(Trigger):
"full_template_path": { "full_template_path": {
"type": "path", "type": "path",
"coerce": "absolute_path", "coerce": "absolute_path",
"excludes": ["template_full"],
"required": True,
"path_exists": True, "path_exists": True,
}, },
"homepage": { "homepage": {
@ -111,12 +113,36 @@ class ReportTrigger(Trigger):
"type": "string", "type": "string",
"allowed": ["ssl", "starttls", "disabled"], "allowed": ["ssl", "starttls", "disabled"],
}, },
"template": {
"type": "string",
"excludes": ["template_path"],
"dependencies": ["templates"],
"required": True,
"empty": False,
},
"template_full": {
"type": "string",
"excludes": ["template_path"],
"dependencies": ["templates"],
"required": True,
"empty": False,
},
"template_path": { "template_path": {
"type": "path", "type": "path",
"coerce": "absolute_path", "coerce": "absolute_path",
"excludes": ["template"],
"required": True, "required": True,
"path_exists": True, "path_exists": True,
}, },
"templates": {
"type": "list",
"coerce": "list",
"schema": {
"type": "path",
"coerce": "absolute_path",
"path_exists": True,
},
},
"user": { "user": {
"type": "string", "type": "string",
}, },
@ -143,12 +169,29 @@ class ReportTrigger(Trigger):
"coerce": "absolute_path", "coerce": "absolute_path",
"required": True, "required": True,
}, },
"template": {
"type": "string",
"excludes": ["template_path"],
"dependencies": ["templates"],
"required": True,
"empty": False,
},
"template_path": { "template_path": {
"type": "path", "type": "path",
"coerce": "absolute_path", "coerce": "absolute_path",
"excludes": ["template"],
"required": True, "required": True,
"path_exists": True, "path_exists": True,
}, },
"templates": {
"type": "list",
"coerce": "list",
"schema": {
"type": "path",
"coerce": "absolute_path",
"path_exists": True,
},
},
}, },
}, },
"telegram": { "telegram": {
@ -175,9 +218,17 @@ class ReportTrigger(Trigger):
"required": True, "required": True,
"is_url": [], "is_url": [],
}, },
"template": {
"type": "string",
"excludes": ["template_path"],
"dependencies": ["templates"],
"required": True,
"empty": False,
},
"template_path": { "template_path": {
"type": "path", "type": "path",
"coerce": "absolute_path", "coerce": "absolute_path",
"excludes": ["template"],
"required": True, "required": True,
"path_exists": True, "path_exists": True,
}, },
@ -185,6 +236,15 @@ class ReportTrigger(Trigger):
"type": "string", "type": "string",
"allowed": ["MarkdownV2", "HTML", "Markdown"], "allowed": ["MarkdownV2", "HTML", "Markdown"],
}, },
"templates": {
"type": "list",
"coerce": "list",
"schema": {
"type": "path",
"coerce": "absolute_path",
"path_exists": True,
},
},
"timeout": { "timeout": {
"type": "integer", "type": "integer",
"coerce": "integer", "coerce": "integer",

View File

@ -35,7 +35,7 @@ class Telegram(Report, JinjaTemplate, SyncHttpClient):
TELEGRAM_MAX_CONTENT_LENGTH(int): (class attribute) max content length of the message TELEGRAM_MAX_CONTENT_LENGTH(int): (class attribute) max content length of the message
api_key(str): bot api key api_key(str): bot api key
chat_id(str): chat id to post message, either string with @ or integer chat_id(str): chat id to post message, either string with @ or integer
template_path(Path): path to template for built packages template(Path | str): name or path to template for built packages
template_type(str): template message type to be used in parse mode, one of MarkdownV2, HTML, Markdown template_type(str): template message type to be used in parse mode, one of MarkdownV2, HTML, Markdown
""" """
@ -57,7 +57,8 @@ class Telegram(Report, JinjaTemplate, SyncHttpClient):
self.api_key = configuration.get(section, "api_key") self.api_key = configuration.get(section, "api_key")
self.chat_id = configuration.get(section, "chat_id") self.chat_id = configuration.get(section, "chat_id")
self.template_path = configuration.getpath(section, "template_path") self.template = configuration.get(section, "template", fallback=None) or \
configuration.getpath(section, "template_path")
self.template_type = configuration.get(section, "template_type", fallback="HTML") self.template_type = configuration.get(section, "template_type", fallback="HTML")
def _send(self, text: str) -> None: def _send(self, text: str) -> None:
@ -83,7 +84,7 @@ class Telegram(Report, JinjaTemplate, SyncHttpClient):
""" """
if not result.success: if not result.success:
return return
text = self.make_html(result, self.template_path) text = self.make_html(result, self.template)
# telegram content is limited by 4096 symbols, so we are going to split the message by new lines # telegram content is limited by 4096 symbols, so we are going to split the message by new lines
# to fit into this restriction # to fit into this restriction
while len(text) > self.TELEGRAM_MAX_CONTENT_LENGTH: while len(text) > self.TELEGRAM_MAX_CONTENT_LENGTH:

View File

@ -147,7 +147,8 @@ def setup_service(repository_id: RepositoryId, configuration: Configuration, spa
setup_cors(application) setup_cors(application)
application.logger.info("setup templates") application.logger.info("setup templates")
aiohttp_jinja2.setup(application, loader=jinja2.FileSystemLoader(configuration.getpath("web", "templates"))) templates = configuration.getpathlist("web", "templates")
aiohttp_jinja2.setup(application, loader=jinja2.FileSystemLoader(searchpath=templates))
application.logger.info("setup configuration") application.logger.info("setup configuration")
application["configuration"] = configuration application["configuration"] = configuration

View File

@ -229,6 +229,19 @@ def test_getpath_without_fallback(configuration: Configuration) -> None:
assert configuration.getpath("build", "option") assert configuration.getpath("build", "option")
def test_getpathlist(configuration: Configuration) -> None:
"""
must extract path list
"""
path = Path("/a/b/c")
configuration.set_option("build", "path", f"""{path} {path.relative_to("/")}""")
result = configuration.getpathlist("build", "path")
assert all(element.is_absolute() for element in result)
assert path in result
assert all(element.is_relative_to("/") for element in result)
def test_gettype(configuration: Configuration) -> None: def test_gettype(configuration: Configuration) -> None:
""" """
must extract type from variable must extract type from variable

View File

@ -8,6 +8,35 @@ from ahriman.models.package import Package
from ahriman.models.result import Result from ahriman.models.result import Result
def test_template(configuration: Configuration) -> None:
"""
must correctly parse template name and path
"""
template = configuration.get("email", "template")
root, repository_id = configuration.check_loaded()
assert Email(repository_id, configuration, "email").template == template
configuration.remove_option("email", "template")
configuration.set_option("email", "template_path", template)
assert Email(repository_id, configuration, "email").template == root.parent / template
def test_template_full(configuration: Configuration) -> None:
"""
must correctly parse template name and path
"""
template = "template"
root, repository_id = configuration.check_loaded()
configuration.set_option("email", "template_full", template)
assert Email(repository_id, configuration, "email").template_full == template
configuration.remove_option("email", "template_full")
configuration.set_option("email", "full_template_path", template)
assert Email(repository_id, configuration, "email").template_full == root.parent / template
def test_send(email: Email, mocker: MockerFixture) -> None: def test_send(email: Email, mocker: MockerFixture) -> None:
""" """
must send an email with attachment must send an email with attachment
@ -115,7 +144,7 @@ def test_generate_with_built_and_full_path(email: Email, package_ahriman: Packag
""" """
send_mock = mocker.patch("ahriman.core.report.email.Email._send") send_mock = mocker.patch("ahriman.core.report.email.Email._send")
email.full_template_path = email.template_path email.template_full = email.template
email.generate([package_ahriman], result) email.generate([package_ahriman], result)
send_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int)) send_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int))

View File

@ -8,6 +8,20 @@ from ahriman.models.package import Package
from ahriman.models.result import Result from ahriman.models.result import Result
def test_template(configuration: Configuration) -> None:
"""
must correctly parse template name and path
"""
template = configuration.get("html", "template")
root, repository_id = configuration.check_loaded()
assert HTML(repository_id, configuration, "html").template == template
configuration.remove_option("html", "template")
configuration.set_option("html", "template_path", template)
assert HTML(repository_id, configuration, "html").template == root.parent / template
def test_generate(configuration: Configuration, package_ahriman: Package, mocker: MockerFixture) -> None: def test_generate(configuration: Configuration, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must generate report must generate report

View File

@ -8,7 +8,17 @@ def test_generate(configuration: Configuration, package_ahriman: Package) -> Non
""" """
must generate html report must generate html report
""" """
path = configuration.getpath("html", "template_path") name = configuration.getpath("html", "template")
_, repository_id = configuration.check_loaded()
report = JinjaTemplate(repository_id, configuration, "html")
assert report.make_html(Result(success=[package_ahriman]), name)
def test_generate_from_path(configuration: Configuration, package_ahriman: Package) -> None:
"""
must generate html report from path
"""
path = configuration.getpath("html", "templates") / configuration.get("html", "template")
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
report = JinjaTemplate(repository_id, configuration, "html") report = JinjaTemplate(repository_id, configuration, "html")
assert report.make_html(Result(success=[package_ahriman]), path) assert report.make_html(Result(success=[package_ahriman]), path)

View File

@ -10,6 +10,20 @@ from ahriman.models.package import Package
from ahriman.models.result import Result from ahriman.models.result import Result
def test_template(configuration: Configuration) -> None:
"""
must correctly parse template name and path
"""
template = configuration.get("telegram", "template")
root, repository_id = configuration.check_loaded()
assert Telegram(repository_id, configuration, "telegram").template == template
configuration.remove_option("telegram", "template")
configuration.set_option("telegram", "template_path", template)
assert Telegram(repository_id, configuration, "telegram").template == root.parent / template
def test_send(telegram: Telegram, mocker: MockerFixture) -> None: def test_send(telegram: Telegram, mocker: MockerFixture) -> None:
""" """
must send a message must send a message

View File

@ -63,7 +63,8 @@ no_empty_report = no
port = 587 port = 587
receivers = mail@example.com receivers = mail@example.com
sender = mail@example.com sender = mail@example.com
template_path = ../web/templates/repo-index.jinja2 template = repo-index.jinja2
templates = ../web/templates
[console] [console]
use_utf = yes use_utf = yes
@ -72,7 +73,8 @@ use_utf = yes
path = path =
homepage = homepage =
link_path = link_path =
template_path = ../web/templates/repo-index.jinja2 template = repo-index.jinja2
templates = ../web/templates
[remote-call] [remote-call]
manual = yes manual = yes
@ -82,7 +84,8 @@ api_key = apikey
chat_id = @ahrimantestchat chat_id = @ahrimantestchat
homepage = homepage =
link_path = link_path =
template_path = ../web/templates/telegram-index.jinja2 template = telegram-index.jinja2
templates = ../web/templates
[upload] [upload]
target = target =