This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new 462adfe  Use a Pydantic model for release policy form data
462adfe is described below

commit 462adfeb5fb64aaf3181af2270ebdc61e93d8741
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Sep 11 15:44:38 2025 +0100

    Use a Pydantic model for release policy form data
---
 atr/forms.py                    |  17 +++
 atr/routes/projects.py          | 317 +++++++++++++++++++++++++---------------
 atr/templates/project-view.html |  18 +--
 3 files changed, 223 insertions(+), 129 deletions(-)

diff --git a/atr/forms.py b/atr/forms.py
index 02e9f9c..439eded 100644
--- a/atr/forms.py
+++ b/atr/forms.py
@@ -179,6 +179,23 @@ def error(field: wtforms.Field, message: str) -> 
Literal[False]:
     return False
 
 
+def clear_errors(field: wtforms.Field) -> None:
+    if not isinstance(field.errors, list):
+        try:
+            field.errors = list(field.errors)
+        except Exception:
+            field.errors = []
+    field.errors[:] = []
+    entries = getattr(field, "entries", None)
+    if isinstance(entries, list):
+        for entry in entries:
+            entry_errors = getattr(entry, "errors", None)
+            if isinstance(entry_errors, list):
+                entry_errors[:] = []
+            else:
+                setattr(entry, "errors", [])
+
+
 def file(label: str, optional: bool = False, validators: list[Any] | None = 
None, **kwargs: Any) -> wtforms.FileField:
     if validators is None:
         validators = []
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index 553fe2d..f62b1cf 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -17,20 +17,22 @@
 
 """project.py"""
 
+from __future__ import annotations
+
 import datetime
 import http.client
 import re
-from typing import Any, Final
+from typing import TYPE_CHECKING, Any, Final
 
 import asfquart.base as base
+import pydantic
 import quart
-import werkzeug.wrappers.response as response
-import wtforms
 
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.forms as forms
 import atr.log as log
+import atr.models.schema as schema
 import atr.models.sql as sql
 import atr.routes as routes
 import atr.storage as storage
@@ -38,6 +40,9 @@ import atr.template as template
 import atr.user as user
 import atr.util as util
 
+if TYPE_CHECKING:
+    import werkzeug.wrappers.response as response
+
 _FORBIDDEN_CATEGORIES: Final[set[str]] = {
     "retired",
 }
@@ -95,9 +100,12 @@ class ReleasePolicyForm(forms.Typed):
     )
 
     # Vote section
-    manual_vote = forms.boolean(
-        "Manual voting process",
-        description="If this is set then the vote will be completely manual 
and following policy is ignored.",
+    github_vote_workflow_path = forms.textarea(
+        "GitHub vote workflow paths",
+        optional=True,
+        rows=5,
+        description="The full paths to the GitHub workflows to use for the 
release,"
+        " including the .github/workflows/ prefix.",
     )
     mailto_addresses = forms.string(
         "Email",
@@ -107,6 +115,10 @@ class ReleasePolicyForm(forms.Typed):
         "emails are sent. You can set this value to your own mailing list, but 
ATR will "
         f"currently only let you send to {util.USER_TESTS_ADDRESS}.",
     )
+    manual_vote = forms.boolean(
+        "Manual voting process",
+        description="If this is set then the vote will be completely manual 
and following policy is ignored.",
+    )
     default_min_hours_value_at_render = forms.hidden()
     min_hours = forms.integer(
         "Minimum voting period",
@@ -131,13 +143,6 @@ class ReleasePolicyForm(forms.Typed):
         rows=10,
         description="Email template for messages to start a vote on a 
release.",
     )
-    github_vote_workflow_path = forms.textarea(
-        "GitHub vote workflow paths",
-        optional=True,
-        rows=5,
-        description="The full paths to the GitHub workflows to use for the 
release,"
-        " including the .github/workflows/ prefix.",
-    )
 
     # Finish section
     default_announce_release_template_hash = forms.hidden()
@@ -165,72 +170,144 @@ class ReleasePolicyForm(forms.Typed):
         await super().validate(extra_validators=extra_validators)
 
         if self.manual_vote.data:
-            optional_fields = (
+            for field_name in (
                 "mailto_addresses",
                 "min_hours",
                 "pause_for_rm",
                 "release_checklist",
                 "start_vote_template",
-            )
-            for field_name in optional_fields:
+            ):
                 field = getattr(self, field_name, None)
-                if field is None:
-                    continue
-                _form_clear(field)
-                if hasattr(field, "entries"):
-                    for entry in field.entries:
-                        _form_clear(entry)
+                if field is not None:
+                    forms.clear_errors(field)
                 self.errors.pop(field_name, None)
 
         if self.manual_vote.data and self.strict_checking.data:
             msg = "Manual voting process and strict checking cannot be enabled 
simultaneously."
-            _form_append(self.manual_vote, msg)
-            _form_append(self.strict_checking, msg)
-            # _form_setdefault_append(self, "manual_vote", [], msg)
-            # _form_setdefault_append(self, "strict_checking", [], msg)
+            forms.error(self.manual_vote, msg)
+            forms.error(self.strict_checking, msg)
 
-        grn = self.github_repository_name.data
-        compose = self.github_compose_workflow_path.data
-        vote = self.github_vote_workflow_path.data
-        finish = self.github_finish_workflow_path.data
+        github_repository_name = (self.github_repository_name.data or 
"").strip()
+        compose_raw = self.github_compose_workflow_path.data or ""
+        vote_raw = self.github_vote_workflow_path.data or ""
+        finish_raw = self.github_finish_workflow_path.data or ""
+        compose = [p.strip() for p in compose_raw.split("\n") if p.strip()]
+        vote = [p.strip() for p in vote_raw.split("\n") if p.strip()]
+        finish = [p.strip() for p in finish_raw.split("\n") if p.strip()]
 
         any_path = bool(compose or vote or finish)
-        if any_path and (not grn):
-            _form_append(
-                self.github_repository_name, "GitHub repository name is 
required when any workflow path is set."
+        if any_path and (not github_repository_name):
+            forms.error(
+                self.github_repository_name,
+                "GitHub repository name is required when any workflow path is 
set.",
             )
 
-        if grn:
-            if "/" in grn:
-                _form_append(self.github_repository_name, "GitHub repository 
name must not contain a slash.")
-            if compose:
-                for p in _parse_artifact_paths(compose):
-                    if not p.startswith(".github/workflows/"):
-                        _form_append(
-                            self.github_compose_workflow_path,
-                            "GitHub workflow paths must start with 
'.github/workflows/'.",
-                        )
-                        break
-            if vote:
-                for p in _parse_artifact_paths(vote):
-                    if not p.startswith(".github/workflows/"):
-                        _form_append(
-                            self.github_vote_workflow_path,
-                            "GitHub workflow paths must start with 
'.github/workflows/'.",
-                        )
-                        break
-            if finish:
-                for p in _parse_artifact_paths(finish):
-                    if not p.startswith(".github/workflows/"):
-                        _form_append(
-                            self.github_finish_workflow_path,
-                            "GitHub workflow paths must start with 
'.github/workflows/'.",
-                        )
-                        break
+        if github_repository_name and ("/" in github_repository_name):
+            forms.error(self.github_repository_name, "GitHub repository name 
must not contain a slash.")
+
+        if compose:
+            for p in compose:
+                if not p.startswith(".github/workflows/"):
+                    forms.error(
+                        self.github_compose_workflow_path,
+                        "GitHub workflow paths must start with 
'.github/workflows/'.",
+                    )
+                    break
+        if vote:
+            for p in vote:
+                if not p.startswith(".github/workflows/"):
+                    forms.error(
+                        self.github_vote_workflow_path,
+                        "GitHub workflow paths must start with 
'.github/workflows/'.",
+                    )
+                    break
+        if finish:
+            for p in finish:
+                if not p.startswith(".github/workflows/"):
+                    forms.error(
+                        self.github_finish_workflow_path,
+                        "GitHub workflow paths must start with 
'.github/workflows/'.",
+                    )
+                    break
 
         return not self.errors
 
 
+# TODO: Maybe it's easier to use quart_schema for all our forms
+# We can use source=DataSource.FORM
+# But do all form input types have a pydantic counterpart?
+class ReleasePolicyData(schema.Lax):
+    """Pydantic model for release policy form data."""
+
+    project_name: str
+
+    # Compose section
+    source_artifact_paths: list[str] = pydantic.Field(default_factory=list)
+    binary_artifact_paths: list[str] = pydantic.Field(default_factory=list)
+    github_repository_name: str = ""
+    github_compose_workflow_path: list[str] = 
pydantic.Field(default_factory=list)
+    strict_checking: bool = False
+
+    # Vote section
+    mailto_addresses: list[str] = pydantic.Field(default_factory=list)
+    manual_vote: bool = False
+    default_min_hours_value_at_render: str = ""
+    min_hours: int = 72
+    pause_for_rm: bool = False
+    release_checklist: str = ""
+    default_start_vote_template_hash: str = ""
+    start_vote_template: str = ""
+    github_vote_workflow_path: list[str] = pydantic.Field(default_factory=list)
+
+    # Finish section
+    default_announce_release_template_hash: str = ""
+    announce_release_template: str = ""
+    github_finish_workflow_path: list[str] = 
pydantic.Field(default_factory=list)
+    preserve_download_files: bool = False
+
+    @pydantic.field_validator(
+        "source_artifact_paths",
+        "binary_artifact_paths",
+        "github_compose_workflow_path",
+        "github_vote_workflow_path",
+        "github_finish_workflow_path",
+        mode="before",
+    )
+    @classmethod
+    def parse_artifact_paths(cls, v: Any) -> list[str]:
+        if (v is None) or (v == ""):
+            return []
+        if isinstance(v, str):
+            return [path.strip() for path in v.split("\n") if path.strip()]
+        if isinstance(v, list):
+            return v
+        return []
+
+    @pydantic.field_validator("mailto_addresses", mode="before")
+    @classmethod
+    def parse_mailto_addresses(cls, v: Any) -> list[str]:
+        if (v is None) or (v == ""):
+            return []
+        if isinstance(v, str):
+            return [v.strip()] if v.strip() else []
+        if isinstance(v, list):
+            return v
+        return []
+
+    @pydantic.field_validator(
+        "github_repository_name",
+        "release_checklist",
+        "start_vote_template",
+        "announce_release_template",
+        mode="before",
+    )
+    @classmethod
+    def unwrap_values(cls, v: Any) -> Any:
+        if v is None:
+            return ""
+        return v
+
+
 @routes.committer("/project/add/<committee_name>", methods=["GET", "POST"])
 async def add_project(session: routes.CommitterSession, committee_name: str) 
-> response.Response | str:
     await session.check_access_committee(committee_name)
@@ -361,16 +438,16 @@ async def view(session: routes.CommitterSession, name: 
str) -> response.Response
     )
 
 
-def _form_append(obj: wtforms.Field, msg: str) -> None:
-    if not isinstance(obj.errors, list):
-        obj.errors = list(obj.errors)
-    obj.errors.append(msg)
+# def _form_append(obj: wtforms.Field, msg: str) -> None:
+#     if not isinstance(obj.errors, list):
+#         obj.errors = list(obj.errors)
+#     obj.errors.append(msg)
 
 
-def _form_clear(obj: wtforms.Field) -> None:
-    if not isinstance(obj.errors, list):
-        obj.errors = list(obj.errors)
-    obj.errors[:] = []
+# def _form_clear(obj: wtforms.Field) -> None:
+#     if not isinstance(obj.errors, list):
+#         obj.errors = list(obj.errors)
+#     obj.errors[:] = []
 
 
 # def _form_setdefault_append(obj: util.QuartFormTyped, key: str, default: 
list[str], msg: str) -> None:
@@ -482,66 +559,66 @@ async def _metadata_edit(
     return False, metadata_form
 
 
-def _parse_artifact_paths(artifact_paths: str) -> list[str]:
-    if not artifact_paths:
-        return []
-    return [path.strip() for path in artifact_paths.split("\n") if 
path.strip()]
+# def _parse_artifact_paths(artifact_paths: str) -> list[str]:
+#     if not artifact_paths:
+#         return []
+#     return [path.strip() for path in artifact_paths.split("\n") if 
path.strip()]
 
 
 async def _policy_edit(
     data: db.Session, project: sql.Project, form_data: dict[str, str]
 ) -> tuple[bool, ReleasePolicyForm]:
     policy_form = await ReleasePolicyForm.create_form(data=form_data)
-    if await policy_form.validate_on_submit():
-        release_policy = project.release_policy
-        if release_policy is None:
-            release_policy = sql.ReleasePolicy(project=project)
-            project.release_policy = release_policy
-            data.add(release_policy)
-
-        # Compose section
-        release_policy.source_artifact_paths = _parse_artifact_paths(
-            util.unwrap(policy_form.source_artifact_paths.data)
-        )
-        release_policy.binary_artifact_paths = _parse_artifact_paths(
-            util.unwrap(policy_form.binary_artifact_paths.data)
-        )
-        release_policy.github_repository_name = 
util.unwrap(policy_form.github_repository_name.data)
-        # TODO: Change to paths, plural
-        release_policy.github_compose_workflow_path = _parse_artifact_paths(
-            util.unwrap(policy_form.github_compose_workflow_path.data)
-        )
-        release_policy.strict_checking = 
util.unwrap(policy_form.strict_checking.data)
+    validated = await policy_form.validate_on_submit()
+    if not validated:
+        log.info(f"policy_form.errors: {policy_form.errors}")
+        return False, policy_form
+
+    # Use ReleasePolicyData to parse and validate the processed form data
+    try:
+        policy_data = ReleasePolicyData.model_validate(policy_form.data)
+    except Exception as e:
+        # If pydantic validation fails, log it and fall back to form validation
+        log.error(f"ReleasePolicyData validation failed: {e}")
+        log.info(f"policy_form.errors: {policy_form.errors}")
+        return False, policy_form
 
-        # Vote section
-        release_policy.manual_vote = policy_form.manual_vote.data or False
-        if not release_policy.manual_vote:
-            release_policy.github_vote_workflow_path = _parse_artifact_paths(
-                util.unwrap(policy_form.github_vote_workflow_path.data)
-            )
-            release_policy.mailto_addresses = 
[util.unwrap(policy_form.mailto_addresses.data)]
-            _set_default_min_hours(policy_form, project, release_policy)
-            release_policy.pause_for_rm = 
util.unwrap(policy_form.pause_for_rm.data)
-            release_policy.release_checklist = 
util.unwrap(policy_form.release_checklist.data)
-            _set_default_start_vote_template(policy_form, project, 
release_policy)
-        elif project.committee and project.committee.is_podling:
-            # The caller ensures that project.committee is not None
-            await quart.flash("Manual voting is not allowed for podlings.", 
"error")
-            return False, policy_form
-
-        # Finish section
-        release_policy.github_finish_workflow_path = _parse_artifact_paths(
-            util.unwrap(policy_form.github_finish_workflow_path.data)
-        )
-        _set_default_announce_release_template(policy_form, project, 
release_policy)
-        release_policy.preserve_download_files = 
util.unwrap(policy_form.preserve_download_files.data)
+    release_policy = project.release_policy
+    if release_policy is None:
+        release_policy = sql.ReleasePolicy(project=project)
+        project.release_policy = release_policy
+        data.add(release_policy)
 
-        await data.commit()
-        await quart.flash("Release policy updated successfully.", "success")
-        return True, policy_form
-    else:
-        log.info(f"policy_form.errors: {policy_form.errors}")
-    return False, policy_form
+    # Compose section
+    release_policy.source_artifact_paths = policy_data.source_artifact_paths
+    release_policy.binary_artifact_paths = policy_data.binary_artifact_paths
+    release_policy.github_repository_name = policy_data.github_repository_name
+    # TODO: Change to paths, plural
+    release_policy.github_compose_workflow_path = 
policy_data.github_compose_workflow_path
+    release_policy.strict_checking = policy_data.strict_checking
+
+    # Vote section
+    release_policy.manual_vote = policy_data.manual_vote
+    if not release_policy.manual_vote:
+        release_policy.github_vote_workflow_path = 
policy_data.github_vote_workflow_path
+        release_policy.mailto_addresses = policy_data.mailto_addresses
+        _set_default_min_hours(policy_form, project, release_policy)
+        release_policy.pause_for_rm = policy_data.pause_for_rm
+        release_policy.release_checklist = policy_data.release_checklist
+        _set_default_start_vote_template(policy_form, project, release_policy)
+    elif project.committee and project.committee.is_podling:
+        # The caller ensures that project.committee is not None
+        await quart.flash("Manual voting is not allowed for podlings.", 
"error")
+        return False, policy_form
+
+    # Finish section
+    release_policy.github_finish_workflow_path = 
policy_data.github_finish_workflow_path
+    _set_default_announce_release_template(policy_form, project, 
release_policy)
+    release_policy.preserve_download_files = 
policy_data.preserve_download_files
+
+    await data.commit()
+    await quart.flash("Release policy updated successfully.", "success")
+    return True, policy_form
 
 
 async def _policy_form_create(project: sql.Project) -> ReleasePolicyForm:
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index 65eb066..a2c1aca 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -144,6 +144,15 @@
               <h3 class="col-md-3 col-form-label text-md-end fs-4">Vote 
options</h3>
             </div>
 
+            <div class="mb-3 pb-3 row border-bottom">
+              {{ forms.label(policy_form.github_vote_workflow_path, col="md3") 
}}
+              <div class="col-sm-8">
+                {{ forms.widget(policy_form.github_vote_workflow_path, 
classes="form-control font-monospace") }}
+                {{ forms.errors(policy_form.github_vote_workflow_path) }}
+                {{ forms.description(policy_form.github_vote_workflow_path) }}
+              </div>
+            </div>
+
             {% if not project.committee.is_podling %}
               <div class="mb-3 pb-3 row border-bottom">
                 {{ forms.label(policy_form.manual_vote, col="md3-high") }}
@@ -157,15 +166,6 @@
               </div>
             {% endif %}
 
-            <div class="mb-3 pb-3 row border-bottom">
-              {{ forms.label(policy_form.github_vote_workflow_path, col="md3") 
}}
-              <div class="col-sm-8">
-                {{ forms.widget(policy_form.github_vote_workflow_path, 
classes="form-control font-monospace") }}
-                {{ forms.errors(policy_form.github_vote_workflow_path) }}
-                {{ forms.description(policy_form.github_vote_workflow_path) }}
-              </div>
-            </div>
-
             <div id="vote-options-extra">
 
               <div class="mb-3 pb-3 row border-bottom">


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to