Deduplicate campaign template rendering
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -18,6 +17,7 @@ from govoplan_campaign.backend.path_security import (
|
|||||||
assert_logical_relative_path,
|
assert_logical_relative_path,
|
||||||
is_managed_source,
|
is_managed_source,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.template_rendering import render_template
|
||||||
|
|
||||||
|
|
||||||
class AttachmentScope(StrEnum):
|
class AttachmentScope(StrEnum):
|
||||||
@@ -145,43 +145,8 @@ def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
|
|||||||
return (campaign_path.parent / path).resolve()
|
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:
|
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 "."
|
return rendered or "."
|
||||||
|
|
||||||
|
|
||||||
@@ -265,7 +230,7 @@ def _attachment_zip_archive(
|
|||||||
def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None:
|
def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None:
|
||||||
if archive is None:
|
if archive is None:
|
||||||
return 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"
|
return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip"
|
||||||
|
|
||||||
|
|
||||||
@@ -444,7 +409,7 @@ def _resolve_one_config(
|
|||||||
match_index: AttachmentMatchIndex | None = None,
|
match_index: AttachmentMatchIndex | None = None,
|
||||||
) -> ResolvedAttachment:
|
) -> ResolvedAttachment:
|
||||||
rendered_base_dir = _rendered_base_dir(config, values)
|
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(
|
directory, selected_base_path = _resolve_attachment_directory(
|
||||||
campaign_file=campaign_file,
|
campaign_file=campaign_file,
|
||||||
campaign_config=campaign_config,
|
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.campaign.template_values import build_template_values
|
||||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
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 (
|
from .models import (
|
||||||
CampaignBuildReport,
|
CampaignBuildReport,
|
||||||
@@ -47,28 +51,6 @@ from .models import (
|
|||||||
MessageValidationStatus,
|
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)
|
@dataclass(slots=True)
|
||||||
class BuiltMessage:
|
class BuiltMessage:
|
||||||
draft: MessageDraft
|
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)
|
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:
|
def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None:
|
||||||
if recipient is None:
|
if recipient is None:
|
||||||
return 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