You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/07/23 13:50:56 UTC
(tooling-trusted-releases) branch sbp updated: Validate submitted template variable names
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/sbp by this push:
new 7bf93275 Validate submitted template variable names
7bf93275 is described below
commit 7bf932751315991845c72fe023e4bdd75ba10ec4
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Jul 23 14:50:09 2026 +0100
Validate submitted template variable names
---
atr/construct.py | 24 +++++++++----
atr/shared/projects.py | 39 +++++++++++++++++++++
tests/unit/test_template_variables.py | 64 +++++++++++++++++++++++++++++++++++
3 files changed, 121 insertions(+), 6 deletions(-)
diff --git a/atr/construct.py b/atr/construct.py
index 937ed556..2307e2f3 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -111,6 +111,13 @@ class _VoteValues(TypedDict):
YOUR_FULL_NAME: str
+ANNOUNCE_SUBJECT_VARIABLE_NAMES: Final[frozenset[str]] = frozenset(_AnnounceSubjectValues.__required_keys__)
+ANNOUNCE_VARIABLE_NAMES: Final[frozenset[str]] = frozenset(_AnnounceValues.__required_keys__)
+CHECKLIST_VARIABLE_NAMES: Final[frozenset[str]] = frozenset(_ChecklistValues.__required_keys__)
+FINISH_VOTE_VARIABLE_NAMES: Final[frozenset[str]] = frozenset(_FinishVoteValues.__required_keys__)
+VOTE_SUBJECT_VARIABLE_NAMES: Final[frozenset[str]] = frozenset(_VoteSubjectValues.__required_keys__)
+VOTE_VARIABLE_NAMES: Final[frozenset[str]] = frozenset(_VoteValues.__required_keys__)
+
TEMPLATE_DESCRIPTIONS: Final[dict[str, str]] = {
"ATR_TALLY": "Vote tally block - URL, ballots, counts",
"BUG_DATABASE": "Bug database URL",
@@ -253,11 +260,11 @@ async def announce_release_subject_default(project_key: safe.ProjectKey) -> str:
def announce_subject_template_variables() -> list[tuple[str, str]]:
- return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(_AnnounceSubjectValues.__required_keys__)]
+ return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(ANNOUNCE_SUBJECT_VARIABLE_NAMES)]
def announce_template_variables() -> list[tuple[str, str]]:
- return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(_AnnounceValues.__required_keys__)]
+ return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(ANNOUNCE_VARIABLE_NAMES)]
def checklist_body(
@@ -291,7 +298,7 @@ def checklist_body(
def checklist_template_variables() -> list[tuple[str, str]]:
- return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(_ChecklistValues.__required_keys__)]
+ return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(CHECKLIST_VARIABLE_NAMES)]
def finish_vote_body(body: str, values: _FinishVoteValues) -> str:
@@ -299,7 +306,7 @@ def finish_vote_body(body: str, values: _FinishVoteValues) -> str:
def finish_vote_template_variables() -> list[tuple[str, str]]:
- return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(_FinishVoteValues.__required_keys__)]
+ return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(FINISH_VOTE_VARIABLE_NAMES)]
def release_notification(
@@ -455,12 +462,17 @@ def template_hash(template: str) -> str:
return hashlib.sha256(template.encode()).hexdigest()
+def unknown_template_variables(text: str, names: frozenset[str]) -> list[str]:
+ found = re.findall(r"\{\{([A-Za-z0-9_]+)\}\}", text)
+ return sorted({name for name in found if name not in names})
+
+
def vote_subject_template_variables() -> list[tuple[str, str]]:
- return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(_VoteSubjectValues.__required_keys__)]
+ return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(VOTE_SUBJECT_VARIABLE_NAMES)]
def vote_template_variables() -> list[tuple[str, str]]:
- return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(_VoteValues.__required_keys__)]
+ return [(name, TEMPLATE_DESCRIPTIONS[name]) for name in sorted(VOTE_VARIABLE_NAMES)]
def _podling_disclaimer(project: sql.Project, committee: sql.Committee) -> str:
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index 1fb39c75..22f1bbee 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -23,6 +23,7 @@ from typing import Annotated, Literal
import pydantic
import atr.calver as calver
+import atr.construct as construct
import atr.form as form
import atr.models.safe as safe
import atr.models.sql as sql
@@ -109,6 +110,14 @@ def _validate_display_name(val: str) -> str:
return display_name
+def _validate_template_variables(value: str, names: frozenset[str]) -> str:
+ unknown = construct.unknown_template_variables(value, names)
+ if unknown:
+ noun = util.plural(len(unknown), "variable", include_count=False)
+ raise ValueError(f"Unknown template {noun}: {', '.join(unknown)}")
+ return value
+
+
class AddProjectForm(form.Form):
committee_key: safe.CommitteeKey = form.label("Committee key", widget=form.Widget.HIDDEN)
committee_key_display: str = form.label(description="Committee key", widget=form.Widget.STATIC, default="")
@@ -214,6 +223,26 @@ class VotePolicyForm(form.Form):
widget=form.Widget.CUSTOM,
)
+ @pydantic.field_validator("finish_vote_template")
+ @classmethod
+ def validate_finish_vote_template(cls, val: str) -> str:
+ return _validate_template_variables(val, construct.FINISH_VOTE_VARIABLE_NAMES)
+
+ @pydantic.field_validator("release_checklist")
+ @classmethod
+ def validate_release_checklist(cls, val: str) -> str:
+ return _validate_template_variables(val, construct.CHECKLIST_VARIABLE_NAMES)
+
+ @pydantic.field_validator("start_vote_subject")
+ @classmethod
+ def validate_start_vote_subject(cls, val: str) -> str:
+ return _validate_template_variables(val, construct.VOTE_SUBJECT_VARIABLE_NAMES)
+
+ @pydantic.field_validator("start_vote_template")
+ @classmethod
+ def validate_start_vote_template(cls, val: str) -> str:
+ return _validate_template_variables(val, construct.VOTE_VARIABLE_NAMES)
+
@pydantic.model_validator(mode="after")
def validate_vote_fields(self) -> VotePolicyForm:
validation.validate_policy_min_hours(self.min_hours)
@@ -414,6 +443,16 @@ class FinishPolicyForm(form.Form):
validation.validate_download_path_suffix(val)
return val
+ @pydantic.field_validator("announce_release_subject")
+ @classmethod
+ def validate_announce_release_subject(cls, val: str) -> str:
+ return _validate_template_variables(val, construct.ANNOUNCE_SUBJECT_VARIABLE_NAMES)
+
+ @pydantic.field_validator("announce_release_template")
+ @classmethod
+ def validate_announce_release_template(cls, val: str) -> str:
+ return _validate_template_variables(val, construct.ANNOUNCE_VARIABLE_NAMES)
+
class TrustedPublishingPolicyForm(form.Form):
variant: TRUSTED_PUBLISHING = form.value(TRUSTED_PUBLISHING)
diff --git a/tests/unit/test_template_variables.py b/tests/unit/test_template_variables.py
new file mode 100644
index 00000000..fca07093
--- /dev/null
+++ b/tests/unit/test_template_variables.py
@@ -0,0 +1,64 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pydantic
+import pytest
+
+import atr.construct as construct
+import atr.shared.projects as projects
+
+_FINISH_BASE = {
+ "csrf_token": "x",
+ "variant": "finish",
+ "project_key": "test",
+ "announce_release_subject": "[ANNOUNCE] {{PROJECT_NAME}} {{VERSION}} released",
+ "announce_release_template": "{{DOWNLOAD_URL}}\n{{PODLING_DISCLAIMER}}",
+}
+
+_VOTE_BASE = {
+ "csrf_token": "x",
+ "variant": "vote",
+ "project_key": "test",
+ "email_cc": [],
+ "email_bcc": [],
+ "vote_mode": "email",
+ "min_hours": 72,
+ "release_checklist": "Check {{PROJECT_NAME}} {{VERSION}}",
+ "vote_comment_template": "",
+ "start_vote_subject": "[VOTE] Release {{PROJECT_NAME}} {{VERSION}}",
+ "start_vote_template": "The vote is open for {{DURATION}} hours.",
+ "finish_vote_template": "The vote {{OUTCOME}}.\n{{ATR_TALLY}}",
+}
+
+
+def test_finish_policy_form_validates_template_variables() -> None:
+ projects.FinishPolicyForm.model_validate(_FINISH_BASE)
+ with pytest.raises(pydantic.ValidationError, match="Unknown template variable: DISCLAIMER"):
+ projects.FinishPolicyForm.model_validate(_FINISH_BASE | {"announce_release_template": "{{DISCLAIMER}}"})
+
+
+def test_unknown_template_variables() -> None:
+ names = construct.ANNOUNCE_SUBJECT_VARIABLE_NAMES
+ assert construct.unknown_template_variables("{{PROJECT_NAME}} {{VERSION}}", names) == []
+ unknown = construct.unknown_template_variables("{{ZEBRA}} {{APPLE}} {{version}}", names)
+ assert unknown == ["APPLE", "ZEBRA", "version"]
+
+
+def test_vote_policy_form_validates_template_variables() -> None:
+ projects.VotePolicyForm.model_validate(_VOTE_BASE)
+ with pytest.raises(pydantic.ValidationError, match="Unknown template variables: BAD, WORSE"):
+ projects.VotePolicyForm.model_validate(_VOTE_BASE | {"start_vote_template": "{{BAD}} {{WORSE}}"})
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org