From 641bead0d86626178b787387fee9c084fa5af6a3 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 21 Jul 2026 13:14:32 +0200 Subject: [PATCH] Deduplicate campaign template rendering --- .../backend/attachments/resolver.py | 43 +------- .../backend/messages/builder.py | 49 +-------- .../backend/template_rendering.py | 55 ++++++++++ tests/test_template_rendering.py | 101 ++++++++++++++++++ 4 files changed, 164 insertions(+), 84 deletions(-) create mode 100644 src/govoplan_campaign/backend/template_rendering.py create mode 100644 tests/test_template_rendering.py diff --git a/src/govoplan_campaign/backend/attachments/resolver.py b/src/govoplan_campaign/backend/attachments/resolver.py index fa0655e..11cb5fa 100644 --- a/src/govoplan_campaign/backend/attachments/resolver.py +++ b/src/govoplan_campaign/backend/attachments/resolver.py @@ -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"(? 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, diff --git a/src/govoplan_campaign/backend/messages/builder.py b/src/govoplan_campaign/backend/messages/builder.py index 1caa164..fa43a31 100644 --- a/src/govoplan_campaign/backend/messages/builder.py +++ b/src/govoplan_campaign/backend/messages/builder.py @@ -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"(? 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 diff --git a/src/govoplan_campaign/backend/template_rendering.py b/src/govoplan_campaign/backend/template_rendering.py new file mode 100644 index 0000000..87bd08d --- /dev/null +++ b/src/govoplan_campaign/backend/template_rendering.py @@ -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"(? 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) + } diff --git a/tests/test_template_rendering.py b/tests/test_template_rendering.py new file mode 100644 index 0000000..64b3ce9 --- /dev/null +++ b/tests/test_template_rendering.py @@ -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()