Deduplicate campaign template rendering
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
@@ -18,6 +17,7 @@ from govoplan_campaign.backend.path_security import (
|
||||
assert_logical_relative_path,
|
||||
is_managed_source,
|
||||
)
|
||||
from govoplan_campaign.backend.template_rendering import render_template
|
||||
|
||||
|
||||
class AttachmentScope(StrEnum):
|
||||
@@ -145,43 +145,8 @@ def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
|
||||
return (campaign_path.parent / path).resolve()
|
||||
|
||||
|
||||
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
||||
|
||||
|
||||
def _normalize_template_key(raw: str) -> str:
|
||||
key = raw.strip()
|
||||
if key.startswith("fields."):
|
||||
key = key.removeprefix("fields.")
|
||||
elif key.startswith("local."):
|
||||
key = "local::" + key.removeprefix("local.")
|
||||
elif key.startswith("global."):
|
||||
key = "global::" + key.removeprefix("global.")
|
||||
|
||||
if key.startswith("local::") or key.startswith("global::"):
|
||||
return key
|
||||
if key.startswith("local:"):
|
||||
return "local::" + key.removeprefix("local:")
|
||||
if key.startswith("global:"):
|
||||
return "global::" + key.removeprefix("global:")
|
||||
return key
|
||||
|
||||
|
||||
def _render_template(template: str, values: dict[str, Any]) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = _normalize_template_key(match.group(1))
|
||||
if key in values:
|
||||
value = values[key]
|
||||
return "" if value is None else str(value)
|
||||
return match.group(0)
|
||||
|
||||
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
|
||||
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
|
||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
||||
|
||||
|
||||
def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str:
|
||||
rendered = _render_template(config.base_dir, values).strip()
|
||||
rendered = render_template(config.base_dir, values, keep_missing=True).strip()
|
||||
return rendered or "."
|
||||
|
||||
|
||||
@@ -265,7 +230,7 @@ def _attachment_zip_archive(
|
||||
def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None:
|
||||
if archive is None:
|
||||
return None
|
||||
rendered = _render_template(archive.name or "attachments.zip", values).strip() or "attachments.zip"
|
||||
rendered = render_template(archive.name or "attachments.zip", values, keep_missing=True).strip() or "attachments.zip"
|
||||
return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip"
|
||||
|
||||
|
||||
@@ -444,7 +409,7 @@ def _resolve_one_config(
|
||||
match_index: AttachmentMatchIndex | None = None,
|
||||
) -> ResolvedAttachment:
|
||||
rendered_base_dir = _rendered_base_dir(config, values)
|
||||
rendered_file_filter = _render_template(config.file_filter, values)
|
||||
rendered_file_filter = render_template(config.file_filter, values, keep_missing=True)
|
||||
directory, selected_base_path = _resolve_attachment_directory(
|
||||
campaign_file=campaign_file,
|
||||
campaign_config=campaign_config,
|
||||
|
||||
@@ -36,6 +36,10 @@ from govoplan_campaign.backend.campaign.models import (
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
||||
from govoplan_campaign.backend.template_rendering import (
|
||||
find_unresolved_placeholders as _find_unresolved_placeholders,
|
||||
render_template as _render_template,
|
||||
)
|
||||
|
||||
from .models import (
|
||||
CampaignBuildReport,
|
||||
@@ -47,28 +51,6 @@ from .models import (
|
||||
MessageValidationStatus,
|
||||
)
|
||||
|
||||
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
||||
|
||||
|
||||
def _normalize_template_key(raw: str) -> str:
|
||||
key = raw.strip()
|
||||
if key.startswith("fields."):
|
||||
key = key.removeprefix("fields.")
|
||||
elif key.startswith("local."):
|
||||
key = "local::" + key.removeprefix("local.")
|
||||
elif key.startswith("global."):
|
||||
key = "global::" + key.removeprefix("global.")
|
||||
|
||||
if key.startswith("local::") or key.startswith("global::"):
|
||||
return key
|
||||
if key.startswith("local:"):
|
||||
return "local::" + key.removeprefix("local:")
|
||||
if key.startswith("global:"):
|
||||
return "global::" + key.removeprefix("global:")
|
||||
return key
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BuiltMessage:
|
||||
draft: MessageDraft
|
||||
@@ -123,29 +105,6 @@ def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str =
|
||||
return path.read_text(encoding=encoding)
|
||||
|
||||
|
||||
def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = _normalize_template_key(match.group(1))
|
||||
if key in values:
|
||||
value = values[key]
|
||||
return "" if value is None else str(value)
|
||||
return match.group(0) if keep_missing else ""
|
||||
|
||||
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
|
||||
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
|
||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
||||
|
||||
|
||||
def _find_unresolved_placeholders(text: str | None) -> set[str]:
|
||||
if not text:
|
||||
return set()
|
||||
return {
|
||||
_normalize_template_key(match.group(1))
|
||||
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
|
||||
for match in pattern.finditer(text)
|
||||
}
|
||||
|
||||
|
||||
def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None:
|
||||
if recipient is None:
|
||||
return None
|
||||
|
||||
55
src/govoplan_campaign/backend/template_rendering.py
Normal file
55
src/govoplan_campaign/backend/template_rendering.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
||||
|
||||
|
||||
def normalize_template_key(raw: str) -> str:
|
||||
key = raw.strip()
|
||||
if key.startswith("fields."):
|
||||
key = key.removeprefix("fields.")
|
||||
elif key.startswith("local."):
|
||||
key = "local::" + key.removeprefix("local.")
|
||||
elif key.startswith("global."):
|
||||
key = "global::" + key.removeprefix("global.")
|
||||
|
||||
if key.startswith("local::") or key.startswith("global::"):
|
||||
return key
|
||||
if key.startswith("local:"):
|
||||
return "local::" + key.removeprefix("local:")
|
||||
if key.startswith("global:"):
|
||||
return "global::" + key.removeprefix("global:")
|
||||
return key
|
||||
|
||||
|
||||
def render_template(
|
||||
template: str,
|
||||
values: Mapping[str, Any],
|
||||
*,
|
||||
keep_missing: bool = True,
|
||||
) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = normalize_template_key(match.group(1))
|
||||
if key in values:
|
||||
value = values[key]
|
||||
return "" if value is None else str(value)
|
||||
return match.group(0) if keep_missing else ""
|
||||
|
||||
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
|
||||
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
|
||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
||||
|
||||
|
||||
def find_unresolved_placeholders(text: str | None) -> set[str]:
|
||||
if not text:
|
||||
return set()
|
||||
return {
|
||||
normalize_template_key(match.group(1))
|
||||
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
|
||||
for match in pattern.finditer(text)
|
||||
}
|
||||
101
tests/test_template_rendering.py
Normal file
101
tests/test_template_rendering.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||
from govoplan_campaign.backend.attachments.resolver import resolve_entry_attachments
|
||||
from govoplan_campaign.backend.template_rendering import (
|
||||
find_unresolved_placeholders,
|
||||
normalize_template_key,
|
||||
render_template,
|
||||
)
|
||||
|
||||
|
||||
class CampaignTemplateRenderingTests(unittest.TestCase):
|
||||
def test_template_key_aliases_have_shared_normalization(self) -> None:
|
||||
cases = {
|
||||
" name ": "name",
|
||||
"fields.name": "name",
|
||||
"local.name": "local::name",
|
||||
"local:name": "local::name",
|
||||
"local::name": "local::name",
|
||||
"global.name": "global::name",
|
||||
"global:name": "global::name",
|
||||
"global::name": "global::name",
|
||||
}
|
||||
|
||||
for raw, expected in cases.items():
|
||||
with self.subTest(raw=raw):
|
||||
self.assertEqual(normalize_template_key(raw), expected)
|
||||
|
||||
def test_rendering_preserves_both_syntaxes_none_and_missing_values(self) -> None:
|
||||
values = {
|
||||
"name": "Ada",
|
||||
"local::reference": 42,
|
||||
"global::empty": None,
|
||||
}
|
||||
template = "${fields.name}|{{ local.reference }}|${global:empty}|${missing}|{{ global.absent }}"
|
||||
|
||||
self.assertEqual(
|
||||
render_template(template, values),
|
||||
"Ada|42||${missing}|{{ global.absent }}",
|
||||
)
|
||||
self.assertEqual(render_template(template, values, keep_missing=False), "Ada|42|||")
|
||||
|
||||
def test_rendering_unescapes_literal_dollar_placeholders(self) -> None:
|
||||
self.assertEqual(
|
||||
render_template(r"\${literal\}|${known}", {"known": "resolved"}, keep_missing=False),
|
||||
"${literal}|resolved",
|
||||
)
|
||||
|
||||
def test_unresolved_placeholders_are_normalized_and_deduplicated(self) -> None:
|
||||
unresolved = find_unresolved_placeholders(
|
||||
r"${fields.missing} {{ local:name }} ${global.other} {{local::name}} \${escaped\}"
|
||||
)
|
||||
|
||||
self.assertEqual(unresolved, {"missing", "local::name", "global::other"})
|
||||
self.assertEqual(find_unresolved_placeholders(None), set())
|
||||
|
||||
def test_attachment_resolution_keeps_missing_placeholders(self) -> None:
|
||||
config = CampaignConfig.model_validate(
|
||||
{
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "template-parity", "name": "Template parity", "mode": "test"},
|
||||
"template": {"subject": "Subject", "text": "Body"},
|
||||
"attachments": {
|
||||
"base_path": ".",
|
||||
"global": [
|
||||
{
|
||||
"base_dir": ".",
|
||||
"file_filter": "${missing}.pdf",
|
||||
"required": False,
|
||||
"missing_behavior": "continue",
|
||||
}
|
||||
],
|
||||
},
|
||||
"entries": {
|
||||
"inline": [
|
||||
{
|
||||
"id": "recipient-1",
|
||||
"to": [{"email": "recipient@example.test", "type": "to"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
resolution = resolve_entry_attachments(
|
||||
config=config,
|
||||
campaign_file=Path(temp_dir) / "campaign.json",
|
||||
entry=config.entries.inline[0], # type: ignore[index]
|
||||
entry_index=1,
|
||||
)
|
||||
|
||||
self.assertEqual(resolution.attachments[0].file_filter, "${missing}.pdf")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user