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 9ec25ab Simplify some forms 9ec25ab is described below commit 9ec25ab6b5279ca5c96183441c51b11227ad1a80 Author: Sean B. Palmer <s...@miscoranda.com> AuthorDate: Thu Jul 31 15:25:55 2025 +0100 Simplify some forms --- atr/blueprints/admin/admin.py | 42 ++++++------------ atr/forms.py | 99 +++++++++++++++++++++++++++++++++++++++++-- atr/routes/announce.py | 71 +++++++++++-------------------- 3 files changed, 133 insertions(+), 79 deletions(-) diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py index 50b3337..841c3ea 100644 --- a/atr/blueprints/admin/admin.py +++ b/atr/blueprints/admin/admin.py @@ -34,7 +34,6 @@ import quart import sqlalchemy.orm as orm import sqlmodel import werkzeug.wrappers.response as response -import wtforms import atr.blueprints.admin as admin import atr.config as config @@ -56,49 +55,36 @@ import atr.validate as validate class BrowseAsUserForm(forms.Typed): """Form for browsing as another user.""" - uid = wtforms.StringField( - "ASF UID", - validators=[wtforms.validators.InputRequired()], - render_kw={"placeholder": "Enter the ASF UID to browse as"}, - ) - submit = wtforms.SubmitField("Browse as this user") + uid = forms.string("ASF UID", placeholder="Enter the ASF UID to browse as") + submit = forms.submit("Browse as this user") class DeleteCommitteeKeysForm(forms.Typed): - committee_name = wtforms.SelectField("Committee", validators=[wtforms.validators.InputRequired()]) - confirm_delete = wtforms.StringField( + committee_name = forms.select("Committee") + confirm_delete = forms.string( "Confirmation", - validators=[wtforms.validators.InputRequired(), wtforms.validators.Regexp("^DELETE KEYS$")], - render_kw={"placeholder": "DELETE KEYS"}, + validators=forms.constant("DELETE KEYS"), + placeholder="DELETE KEYS", ) - submit = wtforms.SubmitField("Delete all keys for selected committee") + submit = forms.submit("Delete all keys for selected committee") class DeleteReleaseForm(forms.Typed): """Form for deleting releases.""" - confirm_delete = wtforms.StringField( + confirm_delete = forms.string( "Confirmation", - validators=[ - wtforms.validators.InputRequired("Confirmation is required"), - wtforms.validators.Regexp("^DELETE$", message="Please type DELETE to confirm"), - ], - render_kw={"placeholder": "DELETE"}, + validators=forms.constant("DELETE"), + placeholder="DELETE", description="Please type DELETE exactly to confirm deletion.", ) - submit = wtforms.SubmitField("Delete selected releases permanently") + submit = forms.submit("Delete selected releases permanently") class LdapLookupForm(forms.Typed): - uid = wtforms.StringField( - "ASF UID (optional)", - render_kw={"placeholder": "Enter ASF UID, e.g. johnsmith, or * for all"}, - ) - email = wtforms.StringField( - "Email address (optional)", - render_kw={"placeholder": "Enter email address, e.g. u...@example.org"}, - ) - submit = wtforms.SubmitField("Lookup") + uid = forms.string("ASF UID (optional)", placeholder="Enter ASF UID, e.g. johnsmith, or * for all") + email = forms.string("Email address (optional)", placeholder="Enter email address, e.g. u...@example.org") + submit = forms.submit("Lookup") @admin.BLUEPRINT.route("/all-releases") diff --git a/atr/forms.py b/atr/forms.py index 180aa54..e46d835 100644 --- a/atr/forms.py +++ b/atr/forms.py @@ -17,12 +17,21 @@ from __future__ import annotations -from typing import Any, TypeVar +from typing import Any, Final, TypeVar import quart_wtf import quart_wtf.typing import wtforms +REQUIRED: Final = wtforms.validators.InputRequired() +REQUIRED_DATA: Final = wtforms.validators.DataRequired() +OPTIONAL: Final = wtforms.validators.Optional() + +# Match _Choice in the wtforms.fields.choices stub +# typeshed-fallback/stubs/WTForms/wtforms/fields/choices.pyi +type Choice = tuple[Any, str] | tuple[Any, str, dict[str, Any]] +type Choices = list[Choice] + class Typed(quart_wtf.QuartForm): """Quart form with type annotations.""" @@ -59,12 +68,94 @@ class Hidden(Typed): class Value(Typed): - value = wtforms.StringField(validators=[wtforms.validators.InputRequired()]) + value = wtforms.StringField(validators=[REQUIRED]) submit = wtforms.SubmitField() +def boolean( + label: str, optional: bool = False, validators: list[Any] | None = None, **kwargs: Any +) -> wtforms.BooleanField: + if validators is None: + validators = [] + if optional is False: + validators.append(REQUIRED_DATA) + else: + validators.append(OPTIONAL) + return wtforms.BooleanField(label, **kwargs) + + +def choices(field: wtforms.RadioField, choices: Choices, default: str | None = None) -> None: + field.choices = choices + # Form construction calls Field.process + # This sets data = self.default() or self.default + # Then self.object_data = data + # Then calls self.process_data(data) which sets self.data = data + # And SelectField.iter_choices reads self.data for the default + if default is not None: + field.data = default + + +def constant(value: str) -> list[wtforms.validators.InputRequired | wtforms.validators.Regexp]: + return [REQUIRED, wtforms.validators.Regexp(value, message=f"You must enter {value!r} in this field")] + + +def hidden(**kwargs: Any) -> wtforms.HiddenField: + return wtforms.HiddenField(**kwargs) + + +def optional(label: str, **kwargs: Any) -> wtforms.StringField: + return string(label, optional=True, **kwargs) + + +def radio(label: str, optional: bool = False, validators: list[Any] | None = None, **kwargs: Any) -> wtforms.RadioField: + # Choices and default must be set at runtime + if validators is None: + validators = [] + if optional is False: + validators.append(REQUIRED) + else: + validators.append(OPTIONAL) + return wtforms.RadioField(label, validators=validators, **kwargs) + + +def select(label: str, validators: list[Any] | None = None, **kwargs: Any) -> wtforms.SelectField: + if validators is None: + validators = [REQUIRED] + return wtforms.SelectField(label, validators=validators, **kwargs) + + # TODO: No shared class for Validators? -def string(label: str, validators: list[Any] | None = None, **kwargs: Any) -> wtforms.StringField: +def string( + label: str, + optional: bool = False, + validators: list[Any] | None = None, + placeholder: str | None = None, + **kwargs: Any, +) -> wtforms.StringField: if validators is None: - validators = [wtforms.validators.InputRequired()] + validators = [] + if optional is False: + validators.append(REQUIRED) + else: + validators.append(OPTIONAL) + if placeholder is not None: + if "render_kw" not in kwargs: + kwargs["render_kw"] = {} + kwargs["render_kw"]["placeholder"] = placeholder return wtforms.StringField(label, validators=validators, **kwargs) + + +def submit(label: str, **kwargs: Any) -> wtforms.SubmitField: + return wtforms.SubmitField(label, **kwargs) + + +def textarea( + label: str, optional: bool = False, validators: list[Any] | None = None, **kwargs: Any +) -> wtforms.TextAreaField: + if validators is None: + validators = [] + if optional is False: + validators.append(REQUIRED) + else: + validators.append(OPTIONAL) + return wtforms.TextAreaField(label, validators=validators, **kwargs) diff --git a/atr/routes/announce.py b/atr/routes/announce.py index 5fe28ef..8b97ffe 100644 --- a/atr/routes/announce.py +++ b/atr/routes/announce.py @@ -18,14 +18,13 @@ import asyncio import datetime import pathlib -from typing import Any, Protocol +from typing import Any import aiofiles.os import aioshutil import quart import sqlmodel import werkzeug.wrappers.response as response -import wtforms import atr.config as config import atr.construct as construct @@ -45,38 +44,28 @@ class AnnounceError(Exception): """Exception for announce errors.""" -class AnnounceFormProtocol(Protocol): - """Protocol for the dynamically generated AnnounceForm.""" +class AnnounceForm(forms.Typed): + """Form for announcing a release preview.""" - preview_name: wtforms.HiddenField - preview_revision: wtforms.HiddenField - mailing_list: wtforms.RadioField - confirm_announce: wtforms.BooleanField - download_path_suffix: wtforms.StringField - subject: wtforms.StringField - body: wtforms.TextAreaField - submit: wtforms.SubmitField - - @property - def errors(self) -> dict[str, Any]: ... - - async def validate_on_submit(self) -> bool: ... + preview_name = forms.hidden() + preview_revision = forms.hidden() + mailing_list = forms.radio("Send vote email to") + download_path_suffix = forms.optional("Download path suffix") + confirm_announce = forms.boolean("Confirm") + subject = forms.optional("Subject") + body = forms.textarea("Body", optional=True) + submit = forms.submit("Send announcement email") class DeleteForm(forms.Typed): """Form for deleting a release preview.""" - preview_name = wtforms.StringField( - "Preview name", validators=[wtforms.validators.InputRequired("Preview name is required")] - ) - confirm_delete = wtforms.StringField( + preview_name = forms.string("Preview name") + confirm_delete = forms.string( "Confirmation", - validators=[ - wtforms.validators.InputRequired("Confirmation is required"), - wtforms.validators.Regexp("^DELETE$", message="Please type DELETE to confirm"), - ], + validators=forms.constant("DELETE"), ) - submit = wtforms.SubmitField("Delete preview") + submit = forms.submit("Delete preview") @routes.committer("/announce/<project_name>/<version_name>") @@ -272,34 +261,22 @@ async def announce( async def _create_announce_form_instance( permitted_recipients: list[str], *, data: dict[str, Any] | None = None -) -> AnnounceFormProtocol: +) -> AnnounceForm: """Create and return an instance of the AnnounceForm.""" - class AnnounceForm(forms.Typed): - """Form for announcing a release preview.""" - - preview_name = wtforms.HiddenField() - preview_revision = wtforms.HiddenField() - mailing_list = wtforms.RadioField( - "Send vote email to", - choices=sorted([(recipient, recipient) for recipient in permitted_recipients]), - validators=[wtforms.validators.InputRequired("Mailing list selection is required")], - default="user-te...@tooling.apache.org", - ) - download_path_suffix = wtforms.StringField("Download path suffix", validators=[wtforms.validators.Optional()]) - confirm_announce = wtforms.BooleanField( - "Confirm", - validators=[wtforms.validators.DataRequired("You must confirm to proceed with announcement")], - ) - subject = wtforms.StringField("Subject", validators=[wtforms.validators.Optional()]) - body = wtforms.TextAreaField("Body", validators=[wtforms.validators.Optional()]) - submit = wtforms.SubmitField("Send announcement email") + mailing_list_choices: forms.Choices = sorted([(recipient, recipient) for recipient in permitted_recipients]) + mailing_list_default = "user-te...@tooling.apache.org" form_instance = await AnnounceForm.create_form(data=data) + forms.choices( + form_instance.mailing_list, + mailing_list_choices, + mailing_list_default, + ) return form_instance -def _download_path_suffix_validated(announce_form: AnnounceFormProtocol) -> str: +def _download_path_suffix_validated(announce_form: AnnounceForm) -> str: download_path_suffix = str(announce_form.download_path_suffix.data) if (".." in download_path_suffix) or ("//" in download_path_suffix): raise ValueError("Download path suffix must not contain .. or //") --------------------------------------------------------------------- To unsubscribe, e-mail: commits-unsubscr...@tooling.apache.org For additional commands, e-mail: commits-h...@tooling.apache.org