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-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new d7a562e  Update docs for WTForms -> Pydantic; fixes #338
d7a562e is described below

commit d7a562ead8cacc76fe8a32ae500b567261d0db89
Author: Andrew K. Musselman <[email protected]>
AuthorDate: Sat Nov 22 10:00:38 2025 -0800

    Update docs for WTForms -> Pydantic; fixes #338
---
 atr/docs/overview-of-the-code.html |   2 +-
 atr/docs/overview-of-the-code.md   |   2 +-
 atr/docs/user-interface.html       | 180 ++++++++++++++++++++++++++-------
 atr/docs/user-interface.md         | 198 +++++++++++++++++++++++++++++--------
 4 files changed, 304 insertions(+), 78 deletions(-)

diff --git a/atr/docs/overview-of-the-code.html 
b/atr/docs/overview-of-the-code.html
index 0f8734b..d785866 100644
--- a/atr/docs/overview-of-the-code.html
+++ b/atr/docs/overview-of-the-code.html
@@ -29,7 +29,7 @@
 <p>ATR provides a web interface for users to interact with the platform, and 
the implementation of that interface is split across several modules. The web 
interface uses server-side rendering almost entirely, where HTML is generated 
on the server and sent to the browser.</p>
 <p>The template system in ATR is <a 
href="https://jinja.palletsprojects.com/";>Jinja2</a>, always accessed through 
the ATR <a href="/ref/atr/template.py"><code>template</code></a> module. 
Template files in Jinja2 syntax are stored in <a 
href="/ref/atr/templates/"><code>templates/</code></a>, and route handlers 
render them using the asynchronous <a 
href="/ref/atr/template.py:render"><code>template.render</code></a> 
function.</p>
 <p>Template rendering can be slow if templates are loaded from disk on every 
request. To address this, we use <a 
href="/ref/atr/preload.py"><code>preload</code></a> to load all templates into 
memory before the server starts serving requests. The <a 
href="/ref/atr/preload.py:setup_template_preloading"><code>preload.setup_template_preloading</code></a>
 function registers a startup hook that finds and caches every template 
file.</p>
-<p>The ATR user interface includes many HTML forms. We use <a 
href="https://wtforms.readthedocs.io/";>WTForms</a> for form handling, accessed 
through the ATR <a href="/ref/atr/forms.py"><code>forms</code></a> module. The 
<a href="/ref/atr/forms.py:Typed"><code>forms.Typed</code></a> base class 
extends the standard <code>QuartForm</code> class in <a 
href="https://quart-wtf.readthedocs.io/";>Quart-WTF</a>. Each form field is 
created using helper functions such as <a href="/ref/atr/forms.py:s [...]
+<p>The ATR user interface includes many HTML forms. We use <a 
href="https://docs.pydantic.dev/latest/";>Pydantic</a> for form handling, 
accessed through the ATR <a href="/ref/atr/form.py"><code>form</code></a> 
module. The <a href="/ref/atr/form.py:Form"><code>form.Form</code></a> base 
class extends <code>pydantic.BaseModel</code> to provide form-specific 
functionality. Each form field is defined using Pydantic type annotations along 
with the <a href="/ref/atr/form.py:label"><code>form.lab [...]
 <p>In addition to templates, we sometimes need to generate HTML 
programmatically in Python. For this we use <a 
href="https://htpy.dev/";>htpy</a>, another third party library, for building 
HTML using Python syntax. The ATR <a 
href="/ref/atr/htm.py"><code>htm</code></a> module extends htpy with a <a 
href="/ref/atr/htm.py:Block"><code>Block</code></a> class that makes it easier 
to build complex HTML structures incrementally. Using htpy means that we get 
type checking for our HTML generation [...]
 <p>Refer to the <a href="user-interface">full user interface development 
guide</a> for more information about this topic.</p>
 <h2 id="scheduling-and-tasks">Scheduling and tasks</h2>
diff --git a/atr/docs/overview-of-the-code.md b/atr/docs/overview-of-the-code.md
index a63da07..21ebb58 100644
--- a/atr/docs/overview-of-the-code.md
+++ b/atr/docs/overview-of-the-code.md
@@ -50,7 +50,7 @@ The template system in ATR is 
[Jinja2](https://jinja.palletsprojects.com/), alwa
 
 Template rendering can be slow if templates are loaded from disk on every 
request. To address this, we use [`preload`](/ref/atr/preload.py) to load all 
templates into memory before the server starts serving requests. The 
[`preload.setup_template_preloading`](/ref/atr/preload.py:setup_template_preloading)
 function registers a startup hook that finds and caches every template file.
 
-The ATR user interface includes many HTML forms. We use 
[WTForms](https://wtforms.readthedocs.io/) for form handling, accessed through 
the ATR [`forms`](/ref/atr/forms.py) module. The 
[`forms.Typed`](/ref/atr/forms.py:Typed) base class extends the standard 
`QuartForm` class in [Quart-WTF](https://quart-wtf.readthedocs.io/). Each form 
field is created using helper functions such as 
[`forms.string`](/ref/atr/forms.py:string), 
[`forms.select`](/ref/atr/forms.py:select), and [`forms.submit`] [...]
+The ATR user interface includes many HTML forms. We use 
[Pydantic](https://docs.pydantic.dev/latest/) for form handling, accessed 
through the ATR [`form`](/ref/atr/form.py) module. The 
[`form.Form`](/ref/atr/form.py:Form) base class extends `pydantic.BaseModel` to 
provide form-specific functionality. Each form field is defined using Pydantic 
type annotations along with the [`form.label`](/ref/atr/form.py:label) function 
for metadata like labels and documentation. Validation happens autom [...]
 
 In addition to templates, we sometimes need to generate HTML programmatically 
in Python. For this we use [htpy](https://htpy.dev/), another third party 
library, for building HTML using Python syntax. The ATR 
[`htm`](/ref/atr/htm.py) module extends htpy with a 
[`Block`](/ref/atr/htm.py:Block) class that makes it easier to build complex 
HTML structures incrementally. Using htpy means that we get type checking for 
our HTML generation, and can compose HTML elements just like any other Python 
[...]
 
diff --git a/atr/docs/user-interface.html b/atr/docs/user-interface.html
index f5a0bf0..aa7e0b2 100644
--- a/atr/docs/user-interface.html
+++ b/atr/docs/user-interface.html
@@ -30,24 +30,109 @@
 <p>Templates are loaded into memory at server startup by <a 
href="/ref/atr/preload.py:setup_template_preloading"><code>preload.setup_template_preloading</code></a>.
 This means that changing a template requires restarting the server in 
development, which can be configured to happen automatically, but it also means 
that rendering is fast because we never do a disk read during request handling. 
The preloading scans <a href="/ref/atr/templates/"><code>templates/</code></a> 
recursively and ca [...]
 <p>Template rendering happens in a thread pool to avoid blocking the async 
event loop. The function <a 
href="/ref/atr/template.py:_render_in_thread"><code>_render_in_thread</code></a>
 uses <code>asyncio.to_thread</code> to execute Jinja2's synchronous 
<code>render</code> method.</p>
 <h2 id="forms">Forms</h2>
-<p>HTML forms in ATR are handled by <a 
href="https://wtforms.readthedocs.io/";>WTForms</a>, accessed through our <a 
href="/ref/atr/forms.py"><code>forms</code></a> module. Each form is a class 
that inherits from <a 
href="/ref/atr/forms.py:Typed"><code>forms.Typed</code></a>, which itself 
inherits from <code>QuartForm</code> in <a 
href="https://quart-wtf.readthedocs.io/";>Quart-WTF</a>. Form fields are class 
attributes created using helper functions from the <code>forms</code> 
module.</p>
-<p>Here is a typical form definition from <a 
href="/ref/atr/shared/keys.py:AddOpenPGPKeyForm"><code>shared/keys.py</code></a>:</p>
-<pre><code class="language-python">class AddOpenPGPKeyForm(forms.Typed):
-    public_key = forms.textarea(
+<p>HTML forms in ATR are handled by <a 
href="https://docs.pydantic.dev/latest/";>Pydantic</a> models accessed through 
our <a href="/ref/atr/form.py"><code>form</code></a> module. Each form is a 
class that inherits from <a 
href="/ref/atr/form.py:Form"><code>form.Form</code></a>, which itself inherits 
from <code>pydantic.BaseModel</code>. Form fields are defined as class 
attributes using Pydantic type annotations, with the <a 
href="/ref/atr/form.py:label"><code>form.label</code></a> functio [...]
+<p>Here is a typical form definition from <a 
href="/ref/atr/shared/keys.py"><code>shared/keys.py</code></a>:</p>
+<pre><code class="language-python">class AddOpenPGPKeyForm(form.Form):
+    public_key: str = form.label(
         "Public OpenPGP key",
-        placeholder="Paste your ASCII-armored public OpenPGP key here...",
-        description="Your public key should be in ASCII-armored format, 
starting with"
-        ' "-----BEGIN PGP PUBLIC KEY BLOCK-----"',
+        'Your public key should be in ASCII-armored format, starting with 
"-----BEGIN PGP PUBLIC KEY BLOCK-----"',
+        widget=form.Widget.TEXTAREA,
     )
-    selected_committees = forms.checkboxes(
+    selected_committees: form.StrList = form.label(
         "Associate key with committees",
-        description="Select the committees with which to associate your key.",
+        "Select the committees with which to associate your key.",
     )
-    submit = forms.submit("Add OpenPGP key")
+
+    @pydantic.model_validator(mode="after")
+    def validate_at_least_one_committee(self) -&gt; "AddOpenPGPKeyForm":
+        if not self.selected_committees:
+            raise ValueError("You must select at least one committee to 
associate with this key")
+        return self
+</code></pre>
+<h3 id="field-types-and-labels">Field Types and Labels</h3>
+<p>The <a href="/ref/atr/form.py:label"><code>form.label</code></a> function 
is used to add metadata to Pydantic fields. The first argument is the label 
text, the second (optional) argument is documentation text that appears below 
the field, and you can pass additional keyword arguments like 
<code>widget=form.Widget.TEXTAREA</code> to specify the HTML widget type.</p>
+<p>Fields use Pydantic type annotations to define their data type:</p>
+<ul>
+<li><code>str</code> - text input (default widget: 
<code>Widget.TEXT</code>)</li>
+<li><code>form.Email</code> - email input with validation</li>
+<li><code>form.URL</code> - URL input with validation</li>
+<li><code>form.Bool</code> - checkbox</li>
+<li><code>form.Int</code> - number input</li>
+<li><code>form.StrList</code> - multiple checkboxes that collect strings</li>
+<li><code>form.File</code> - single file upload</li>
+<li><code>form.FileList</code> - multiple file upload</li>
+<li><code>form.Enum[EnumType]</code> - dropdown select from enum values</li>
+<li><code>form.Set[EnumType]</code> - multiple checkboxes from enum values</li>
+</ul>
+<p>Empty values for fields are allowed by default in most cases, but URL is an 
exception.</p>
+<p>The <code>widget</code> parameter in <a 
href="/ref/atr/form.py:label"><code>form.label</code></a> lets you override the 
default widget for a field type. Available widgets include: 
<code>TEXTAREA</code>, <code>CHECKBOXES</code>, <code>SELECT</code>, 
<code>RADIO</code>, <code>HIDDEN</code>, and others from the 
<code>form.Widget</code> enum. Common reasons to override:</p>
+<ul>
+<li>HIDDEN: for values passed from the route, not entered by the user</li>
+<li>TEXTAREA: for multi-line text input</li>
+<li>RADIO: for mutually exclusive choices</li>
+<li>CUSTOM: for fully custom rendering</li>
+</ul>
+<p>From <a 
href="/ref/atr/shared/projects.py:AddProjectForm"><code>projects.AddProjectForm</code></a>:</p>
+<pre><code class="language-python">committee_name: str = form.label("Committee 
name", widget=form.Widget.HIDDEN)
+</code></pre>
+<p>From <a 
href="/ref/atr/shared/resolve.py:SubmitForm"><code>resolve.SubmitForm</code></a>:</p>
+<pre><code class="language-python">email_body: str = form.label("Email body", 
widget=form.Widget.TEXTAREA)
+</code></pre>
+<p>From <a 
href="/ref/atr/shared/resolve.py:SubmitForm"><code>resolve.SubmitForm</code></a>:</p>
+<pre><code class="language-python">vote_result: Literal["Passed", "Failed"] = 
form.label("Vote result", widget=form.Widget.RADIO)
+</code></pre>
+<p>From <a 
href="/ref/atr/shared/vote.py:CastVoteForm"><code>vote.CastVoteForm</code></a>:</p>
+<pre><code class="language-python">decision: Literal["+1", "0", "-1"] = 
form.label("Your vote", widget=form.Widget.CUSTOM)
+</code></pre>
+<h3 id="using-forms-in-routes">Using Forms in Routes</h3>
+<p>To use a form in a route, use the <a 
href="/ref/atr/blueprints/post.py:committer"><code>@post.committer()</code></a> 
decorator to get the session and auth the user, and the <a 
href="/ref/atr/blueprints/post.py:form"><code>@post.form()</code></a> decorator 
to parse and validate input data:</p>
+<pre><code class="language-python">@post.committer("/keys/add")
[email protected](shared.keys.AddOpenPGPKeyForm)
+async def add(session: web.Committer, add_openpgp_key_form: 
shared.keys.AddOpenPGPKeyForm) -&gt; web.WerkzeugResponse:
+    """Add a new public signing key to the user's account."""
+    try:
+        key_text = add_openpgp_key_form.public_key
+        selected_committee_names = add_openpgp_key_form.selected_committees
+
+        # Process the validated form data...
+        async with storage.write() as write:
+            # ...
+
+        await quart.flash("OpenPGP key added successfully.", "success")
+    except web.FlashError as e:
+        await quart.flash(str(e), "error")
+    except Exception as e:
+        log.exception("Error adding OpenPGP key:")
+        await quart.flash(f"An unexpected error occurred: {e!s}", "error")
+
+    return await session.redirect(get.keys.keys)
+</code></pre>
+<p>The <a href="/ref/atr/form.py:validate"><code>form.validate</code></a> 
function should only be called manually when the request comes from JavaScript, 
as in <a 
href="/ref/atr/post/preview.py:announce_preview"><code>announce_preview</code></a>.
 It takes the form class, the form data dictionary, and an optional context 
dictionary. If validation succeeds, it returns an instance of your form class 
with validated data. If validation fails, it raises a 
<code>pydantic.ValidationError</code>.</p>
+<p>The error handling uses <a 
href="/ref/atr/form.py:flash_error_data"><code>form.flash_error_data</code></a> 
to prepare error information for display, and <a 
href="/ref/atr/form.py:flash_error_summary"><code>form.flash_error_summary</code></a>
 to create a user-friendly summary of all validation errors.</p>
+<h3 id="rendering-forms">Rendering Forms</h3>
+<p>The <code>form</code> module provides the <a 
href="/ref/atr/form.py:render"><code>form.render</code></a> function (or <a 
href="/ref/atr/form.py:render_block"><code>form.render_block</code></a> for use 
with <a href="/ref/atr/htm.py:Block"><code>htm.Block</code></a>) that generates 
Bootstrap-styled HTML. This function creates a two-column layout with labels on 
the left and inputs on the right:</p>
+<pre><code class="language-python">form.render_block(
+    page,
+    model_cls=shared.keys.AddOpenPGPKeyForm,
+    action=util.as_url(post.keys.add),
+    submit_label="Add OpenPGP key",
+    cancel_url=util.as_url(keys),
+    defaults={
+        "selected_committees": committee_choices,
+    },
+)
 </code></pre>
-<p>The helper functions like <a 
href="/ref/atr/forms.py:textarea"><code>forms.textarea</code></a>, <a 
href="/ref/atr/forms.py:checkboxes"><code>forms.checkboxes</code></a>, and <a 
href="/ref/atr/forms.py:submit"><code>forms.submit</code></a> create WTForms 
field objects with appropriate validators. The first argument is always the 
label text. Optional fields take <code>optional=True</code>, and you can 
provide placeholders, descriptions, and other field-specific options. If you do 
not pa [...]
-<p>To use a form in a route, create it with <code>await 
FormClass.create_form()</code>. For POST requests, pass <code>data=await 
quart.request.form</code> to populate it with the submitted data. Then validate 
with <code>await form.validate_on_submit()</code>. If validation passes, you 
extract data from <code>form.field_name.data</code> and proceed. If validation 
fails, re-render the template with the form object, which will then display 
error messages.</p>
-<p>The <a href="/ref/atr/forms.py"><code>forms</code></a> module also provides 
rendering functions that generate Bootstrap-styled HTML. The function <a 
href="/ref/atr/forms.py:render_columns"><code>forms.render_columns</code></a> 
creates a two-column layout with labels on the left and inputs on the right. 
The function <a 
href="/ref/atr/forms.py:render_simple"><code>forms.render_simple</code></a> 
creates a simpler vertical layout. The function <a 
href="/ref/atr/forms.py:render_table"><cod [...]
+<p>The <code>defaults</code> parameter accepts a dictionary to populate 
initial field values. For checkbox/radio groups and select dropdowns, you can 
pass a list of <code>(value, label)</code> tuples to dynamically provide 
choices. The <code>render</code> function returns htpy elements which you can 
embed in templates or return directly from route handlers.</p>
+<p>Key rendering parameters:</p>
+<ul>
+<li><code>action</code> - form submission URL (defaults to current path)</li>
+<li><code>submit_label</code> - text for the submit button</li>
+<li><code>cancel_url</code> - if provided, adds a cancel link next to 
submit</li>
+<li><code>defaults</code> - dictionary of initial values or dynamic 
choices</li>
+<li><code>textarea_rows</code> - number of rows for textarea widgets (default: 
12)</li>
+<li><code>wider_widgets</code> - use wider input column (default: False)</li>
+<li><code>border</code> - add borders between fields (default: False)</li>
+</ul>
 <h2 id="programmatic-html">Programmatic HTML</h2>
 <p>Sometimes you need to generate HTML in Python rather than in a template. 
For this we use <a href="https://htpy.dev/";>htpy</a>, which provides a Python 
API for building HTML elements. You import <code>htpy</code> and then use it 
like this:</p>
 <pre><code class="language-python">import htpy
@@ -76,37 +161,58 @@ return div.collect()
 <p>The block class is useful when you are building HTML in a loop or when you 
have conditional elements. Instead of managing a list of elements manually, you 
can let the block class do it for you: append elements as you go, and at the 
end call <code>collect</code> to get the final result. This is cleaner than 
concatenating strings or maintaining lists yourself.</p>
 <p>The block class also adds a <code>data-src</code> attribute to elements, 
which records which function created the element. If you see an element in the 
browser inspector with <code>data-src="atr.get.keys:keys"</code>, you know that 
it came from the <code>keys</code> function in <code>get/keys.py</code>. The 
source is extracted automatically using <a 
href="/ref/atr/log.py:caller_name"><code>log.caller_name</code></a>.</p>
 <h2 id="how-a-route-renders-ui">How a route renders UI</h2>
-<p>A typical route that renders UI first authenticates the user, loads data 
from the database, creates and validates a form if necessary, and renders a 
template with the data and form. Here is a simplified example from <a 
href="/ref/atr/get/keys.py:add"><code>get/keys.py</code></a>:</p>
-<pre><code class="language-python">@route.committer("/keys/add", 
methods=["GET", "POST"])
-async def add(session: route.CommitterSession) -&gt; str:
+<p>A typical route that renders UI first authenticates the user, loads data 
from the database, builds HTML using htpy, and renders it using a template. GET 
and POST requests are handled by separate routes, with form validation 
automatically handled by the <a 
href="/ref/atr/blueprints/post.py:form"><code>@post.form()</code></a> 
decorator. Here is a simplified example from <a 
href="/ref/atr/get/keys.py:add"><code>get/keys.py</code></a>:</p>
+<pre><code class="language-python">@get.committer("/keys/add")
+async def add(session: web.Committer) -&gt; str:
+    """Add a new public signing key to the user's account."""
     async with storage.write() as write:
         participant_of_committees = await write.participant_of_committees()
 
-    committee_choices: forms.Choices = [
-        (c.name, c.display_name or c.name)
-        for c in participant_of_committees
+    committee_choices = [(c.name, c.display_name or c.name) for c in 
participant_of_committees]
+
+    page = htm.Block()
+    page.p[htm.a(".atr-back-link", href=util.as_url(keys))["← Back to Manage 
keys"]]
+    page.div(".my-4")[
+        htm.h1(".mb-4")["Add your OpenPGP key"],
+        htm.p["Add your public key to use for signing release artifacts."],
     ]
 
-    form = await AddOpenPGPKeyForm.create_form(
-        data=(await quart.request.form) if (quart.request.method == "POST") 
else None
+    form.render_block(
+        page,
+        model_cls=shared.keys.AddOpenPGPKeyForm,
+        action=util.as_url(post.keys.add),
+        submit_label="Add OpenPGP key",
+        cancel_url=util.as_url(keys),
+        defaults={
+            "selected_committees": committee_choices,
+        },
+    )
+    ...
+    return await template.blank(
+        "Add your OpenPGP key",
+        content=page.collect(),
+        description="Add your public signing key to your ATR account.",
     )
-    forms.choices(form.selected_committees, committee_choices)
+</code></pre>
+<p>The route is decorated with <a 
href="/ref/atr/blueprints/get.py:committer"><code>@get.committer()</code></a>, 
which handles authentication and provides a <code>session</code> object that is 
an instance of <a 
href="/ref/atr/web.py:Committer"><code>web.Committer</code></a> with a range of 
useful properties and methods.</p>
+<p>The function builds the UI using an <a 
href="/ref/atr/htm.py:Block"><code>htm.Block</code></a> object, which provides 
a convenient API for incrementally building HTML. The form is rendered directly 
into the block using <a 
href="/ref/atr/form.py:render_block"><code>form.render_block()</code></a>, 
which generates all the necessary HTML with Bootstrap styling.</p>
+<p>Finally, the route returns the rendered HTML using <a 
href="/ref/atr/template.py:blank"><code>template.blank()</code></a>, which 
renders a minimal template with just a title and content area.</p>
+<p>Form submission is handled by a separate POST route:</p>
+<pre><code class="language-python">@post.committer("/keys/add")
[email protected](shared.keys.AddOpenPGPKeyForm)
+async def add(session: web.Committer, add_openpgp_key_form: 
shared.keys.AddOpenPGPKeyForm) -&gt; web.WerkzeugResponse:
+    """Add a new public signing key to the user's account."""
+    try:
+        key_text = add_openpgp_key_form.public_key
+        selected_committee_names = add_openpgp_key_form.selected_committees
 
-    if await form.validate_on_submit():
-        # Process the form data
-        # ...
-        await quart.flash(f"OpenPGP key added successfully.", "success")
-        form = await AddOpenPGPKeyForm.create_form()
-        forms.choices(form.selected_committees, committee_choices)
+        # Process the validated form data...
 
-    return await template.render(
-        "keys-add.html",
-        asf_id=session.uid,
-        user_committees=participant_of_committees,
-        form=form,
-    )
+        await quart.flash("OpenPGP key added successfully.", "success")
+    except web.FlashError as e:
+        await quart.flash(str(e), "error")
+
+    return await session.redirect(get.keys.keys)
 </code></pre>
-<p>The route is decorated with <code>@route.committer</code>, which ensures 
that the route fails before the function is even entered if authentication 
fails. The function receives a <code>session</code> object, which is an 
instance of <a href="/ref/atr/web.py:Committer"><code>web.Committer</code></a> 
with a range of useful properties and methods. The function then loads data, 
creates a form, checks if the request is a POST, and either processes the form 
or displays it. After successful p [...]
-<p>The template receives the form object and renders it by passing it to one 
of the <code>forms.render_*</code> functions. We previously used Jinja2 macros 
for this, but are migrating to the new rendering functions in Python (e.g. in 
<a href="/ref/atr/get/distribution.py"><code>get/distribution.py</code></a> and 
<a href="/ref/atr/get/ignores.py"><code>get/ignores.py</code></a>). The 
template also receives other data like <code>asf_id</code> and 
<code>user_committees</code>, which it uses [...]
-<p>If you use the programmatic rendering functions from <a 
href="/ref/atr/forms.py"><code>forms</code></a>, you can skip the template 
entirely. These functions return htpy elements, which you can combine with 
other htpy elements and return directly from the route, which is often useful 
for admin routes, for example. You can also use <a 
href="/ref/atr/template.py:blank"><code>template.blank</code></a>, which 
renders a minimal template with just a title and content area. This is useful 
for [...]
+<p>The <a 
href="/ref/atr/blueprints/post.py:form"><code>@post.form()</code></a> decorator 
handles form validation automatically. If validation fails, it flashes error 
messages and redirects back to the GET route. If validation succeeds, the 
validated form instance is injected into the route handler as a parameter.</p>
 <p>Bootstrap CSS classes are applied automatically by the form rendering 
functions. The functions use classes like <code>form-control</code>, 
<code>form-select</code>, <code>btn-primary</code>, <code>is-invalid</code>, 
and <code>invalid-feedback</code>. We currently use Bootstrap 5. If you 
generate HTML manually with htpy, you can apply Bootstrap classes yourself by 
using the CSS selector syntax like <code>htpy.div(".container")</code> or the 
class attribute like <code>htpy.div(class_="c [...]
diff --git a/atr/docs/user-interface.md b/atr/docs/user-interface.md
index a5424d4..4d8d51a 100644
--- a/atr/docs/user-interface.md
+++ b/atr/docs/user-interface.md
@@ -46,30 +46,126 @@ Template rendering happens in a thread pool to avoid 
blocking the async event lo
 
 ## Forms
 
-HTML forms in ATR are handled by [WTForms](https://wtforms.readthedocs.io/), 
accessed through our [`forms`](/ref/atr/forms.py) module. Each form is a class 
that inherits from [`forms.Typed`](/ref/atr/forms.py:Typed), which itself 
inherits from `QuartForm` in [Quart-WTF](https://quart-wtf.readthedocs.io/). 
Form fields are class attributes created using helper functions from the 
`forms` module.
-
-Here is a typical form definition from 
[`shared/keys.py`](/ref/atr/shared/keys.py:AddOpenPGPKeyForm):
+HTML forms in ATR are handled by [Pydantic](https://docs.pydantic.dev/latest/) 
models accessed through our [`form`](/ref/atr/form.py) module. Each form is a 
class that inherits from [`form.Form`](/ref/atr/form.py:Form), which itself 
inherits from `pydantic.BaseModel`. Form fields are defined as class attributes 
using Pydantic type annotations, with the 
[`form.label`](/ref/atr/form.py:label) function providing field metadata like 
labels and documentation.
 
+Here is a typical form definition from 
[`shared/keys.py`](/ref/atr/shared/keys.py):
 ```python
-class AddOpenPGPKeyForm(forms.Typed):
-    public_key = forms.textarea(
+class AddOpenPGPKeyForm(form.Form):
+    public_key: str = form.label(
         "Public OpenPGP key",
-        placeholder="Paste your ASCII-armored public OpenPGP key here...",
-        description="Your public key should be in ASCII-armored format, 
starting with"
-        ' "-----BEGIN PGP PUBLIC KEY BLOCK-----"',
+        'Your public key should be in ASCII-armored format, starting with 
"-----BEGIN PGP PUBLIC KEY BLOCK-----"',
+        widget=form.Widget.TEXTAREA,
     )
-    selected_committees = forms.checkboxes(
+    selected_committees: form.StrList = form.label(
         "Associate key with committees",
-        description="Select the committees with which to associate your key.",
+        "Select the committees with which to associate your key.",
     )
-    submit = forms.submit("Add OpenPGP key")
+
+    @pydantic.model_validator(mode="after")
+    def validate_at_least_one_committee(self) -> "AddOpenPGPKeyForm":
+        if not self.selected_committees:
+            raise ValueError("You must select at least one committee to 
associate with this key")
+        return self
 ```
 
-The helper functions like [`forms.textarea`](/ref/atr/forms.py:textarea), 
[`forms.checkboxes`](/ref/atr/forms.py:checkboxes), and 
[`forms.submit`](/ref/atr/forms.py:submit) create WTForms field objects with 
appropriate validators. The first argument is always the label text. Optional 
fields take `optional=True`, and you can provide placeholders, descriptions, 
and other field-specific options. If you do not pass `optional=True`, the field 
is required by default. The [`forms.string`](/ref/ [...]
+### Field Types and Labels
+
+The [`form.label`](/ref/atr/form.py:label) function is used to add metadata to 
Pydantic fields. The first argument is the label text, the second (optional) 
argument is documentation text that appears below the field, and you can pass 
additional keyword arguments like `widget=form.Widget.TEXTAREA` to specify the 
HTML widget type.
+
+Fields use Pydantic type annotations to define their data type:
+- `str` - text input (default widget: `Widget.TEXT`)
+- `form.Email` - email input with validation
+- `form.URL` - URL input with validation
+- `form.Bool` - checkbox
+- `form.Int` - number input
+- `form.StrList` - multiple checkboxes that collect strings
+- `form.File` - single file upload
+- `form.FileList` - multiple file upload
+- `form.Enum[EnumType]` - dropdown select from enum values
+- `form.Set[EnumType]` - multiple checkboxes from enum values
+
+Empty values for fields are allowed by default in most cases, but URL is an 
exception.
+
+The `widget` parameter in [`form.label`](/ref/atr/form.py:label) lets you 
override the default widget for a field type. Available widgets include: 
`TEXTAREA`, `CHECKBOXES`, `SELECT`, `RADIO`, `HIDDEN`, and others from the 
`form.Widget` enum. Common reasons to override:
+
+* HIDDEN: for values passed from the route, not entered by the user
+* TEXTAREA: for multi-line text input
+* RADIO: for mutually exclusive choices
+* CUSTOM: for fully custom rendering
+
+From [`projects.AddProjectForm`](/ref/atr/shared/projects.py:AddProjectForm):
+```python
+committee_name: str = form.label("Committee name", widget=form.Widget.HIDDEN)
+```
+From [`resolve.SubmitForm`](/ref/atr/shared/resolve.py:SubmitForm):
+```python
+email_body: str = form.label("Email body", widget=form.Widget.TEXTAREA)
+```
+From [`resolve.SubmitForm`](/ref/atr/shared/resolve.py:SubmitForm):
+```python
+vote_result: Literal["Passed", "Failed"] = form.label("Vote result", 
widget=form.Widget.RADIO)
+```
+From [`vote.CastVoteForm`](/ref/atr/shared/vote.py:CastVoteForm):
+```python
+decision: Literal["+1", "0", "-1"] = form.label("Your vote", 
widget=form.Widget.CUSTOM)
+```
 
-To use a form in a route, create it with `await FormClass.create_form()`. For 
POST requests, pass `data=await quart.request.form` to populate it with the 
submitted data. Then validate with `await form.validate_on_submit()`. If 
validation passes, you extract data from `form.field_name.data` and proceed. If 
validation fails, re-render the template with the form object, which will then 
display error messages.
+### Using Forms in Routes
 
-The [`forms`](/ref/atr/forms.py) module also provides rendering functions that 
generate Bootstrap-styled HTML. The function 
[`forms.render_columns`](/ref/atr/forms.py:render_columns) creates a two-column 
layout with labels on the left and inputs on the right. The function 
[`forms.render_simple`](/ref/atr/forms.py:render_simple) creates a simpler 
vertical layout. The function 
[`forms.render_table`](/ref/atr/forms.py:render_table) puts the form inside a 
table. All three functions return ht [...]
+To use a form in a route, use the 
[`@post.committer()`](/ref/atr/blueprints/post.py:committer) decorator to get 
the session and auth the user, and the 
[`@post.form()`](/ref/atr/blueprints/post.py:form) decorator to parse and 
validate input data:
+```python
[email protected]("/keys/add")
[email protected](shared.keys.AddOpenPGPKeyForm)
+async def add(session: web.Committer, add_openpgp_key_form: 
shared.keys.AddOpenPGPKeyForm) -> web.WerkzeugResponse:
+    """Add a new public signing key to the user's account."""
+    try:
+        key_text = add_openpgp_key_form.public_key
+        selected_committee_names = add_openpgp_key_form.selected_committees
+
+        # Process the validated form data...
+        async with storage.write() as write:
+            # ...
+
+        await quart.flash("OpenPGP key added successfully.", "success")
+    except web.FlashError as e:
+        await quart.flash(str(e), "error")
+    except Exception as e:
+        log.exception("Error adding OpenPGP key:")
+        await quart.flash(f"An unexpected error occurred: {e!s}", "error")
+
+    return await session.redirect(get.keys.keys)
+```
+
+The [`form.validate`](/ref/atr/form.py:validate) function should only be 
called manually when the request comes from JavaScript, as in 
[`announce_preview`](/ref/atr/post/preview.py:announce_preview). It takes the 
form class, the form data dictionary, and an optional context dictionary. If 
validation succeeds, it returns an instance of your form class with validated 
data. If validation fails, it raises a `pydantic.ValidationError`.
+
+The error handling uses 
[`form.flash_error_data`](/ref/atr/form.py:flash_error_data) to prepare error 
information for display, and 
[`form.flash_error_summary`](/ref/atr/form.py:flash_error_summary) to create a 
user-friendly summary of all validation errors.
+
+### Rendering Forms
+
+The `form` module provides the [`form.render`](/ref/atr/form.py:render) 
function (or [`form.render_block`](/ref/atr/form.py:render_block) for use with 
[`htm.Block`](/ref/atr/htm.py:Block)) that generates Bootstrap-styled HTML. 
This function creates a two-column layout with labels on the left and inputs on 
the right:
+```python
+form.render_block(
+    page,
+    model_cls=shared.keys.AddOpenPGPKeyForm,
+    action=util.as_url(post.keys.add),
+    submit_label="Add OpenPGP key",
+    cancel_url=util.as_url(keys),
+    defaults={
+        "selected_committees": committee_choices,
+    },
+)
+```
+
+The `defaults` parameter accepts a dictionary to populate initial field 
values. For checkbox/radio groups and select dropdowns, you can pass a list of 
`(value, label)` tuples to dynamically provide choices. The `render` function 
returns htpy elements which you can embed in templates or return directly from 
route handlers.
+
+Key rendering parameters:
+- `action` - form submission URL (defaults to current path)
+- `submit_label` - text for the submit button
+- `cancel_url` - if provided, adds a cancel link next to submit
+- `defaults` - dictionary of initial values or dynamic choices
+- `textarea_rows` - number of rows for textarea widgets (default: 12)
+- `wider_widgets` - use wider input column (default: False)
+- `border` - add borders between fields (default: False)
 
 ## Programmatic HTML
 
@@ -115,43 +211,67 @@ The block class also adds a `data-src` attribute to 
elements, which records whic
 
 ## How a route renders UI
 
-A typical route that renders UI first authenticates the user, loads data from 
the database, creates and validates a form if necessary, and renders a template 
with the data and form. Here is a simplified example from 
[`get/keys.py`](/ref/atr/get/keys.py:add):
+A typical route that renders UI first authenticates the user, loads data from 
the database, builds HTML using htpy, and renders it using a template. GET and 
POST requests are handled by separate routes, with form validation 
automatically handled by the [`@post.form()`](/ref/atr/blueprints/post.py:form) 
decorator. Here is a simplified example from 
[`get/keys.py`](/ref/atr/get/keys.py:add):
 
 ```python
[email protected]("/keys/add", methods=["GET", "POST"])
-async def add(session: route.CommitterSession) -> str:
[email protected]("/keys/add")
+async def add(session: web.Committer) -> str:
+    """Add a new public signing key to the user's account."""
     async with storage.write() as write:
         participant_of_committees = await write.participant_of_committees()
 
-    committee_choices: forms.Choices = [
-        (c.name, c.display_name or c.name)
-        for c in participant_of_committees
+    committee_choices = [(c.name, c.display_name or c.name) for c in 
participant_of_committees]
+
+    page = htm.Block()
+    page.p[htm.a(".atr-back-link", href=util.as_url(keys))["← Back to Manage 
keys"]]
+    page.div(".my-4")[
+        htm.h1(".mb-4")["Add your OpenPGP key"],
+        htm.p["Add your public key to use for signing release artifacts."],
     ]
 
-    form = await AddOpenPGPKeyForm.create_form(
-        data=(await quart.request.form) if (quart.request.method == "POST") 
else None
+    form.render_block(
+        page,
+        model_cls=shared.keys.AddOpenPGPKeyForm,
+        action=util.as_url(post.keys.add),
+        submit_label="Add OpenPGP key",
+        cancel_url=util.as_url(keys),
+        defaults={
+            "selected_committees": committee_choices,
+        },
     )
-    forms.choices(form.selected_committees, committee_choices)
-
-    if await form.validate_on_submit():
-        # Process the form data
-        # ...
-        await quart.flash(f"OpenPGP key added successfully.", "success")
-        form = await AddOpenPGPKeyForm.create_form()
-        forms.choices(form.selected_committees, committee_choices)
-
-    return await template.render(
-        "keys-add.html",
-        asf_id=session.uid,
-        user_committees=participant_of_committees,
-        form=form,
+    ...
+    return await template.blank(
+        "Add your OpenPGP key",
+        content=page.collect(),
+        description="Add your public signing key to your ATR account.",
     )
 ```
 
-The route is decorated with `@route.committer`, which ensures that the route 
fails before the function is even entered if authentication fails. The function 
receives a `session` object, which is an instance of 
[`web.Committer`](/ref/atr/web.py:Committer) with a range of useful properties 
and methods. The function then loads data, creates a form, checks if the 
request is a POST, and either processes the form or displays it. After 
successful processing, it creates a fresh form to clear the [...]
+The route is decorated with 
[`@get.committer()`](/ref/atr/blueprints/get.py:committer), which handles 
authentication and provides a `session` object that is an instance of 
[`web.Committer`](/ref/atr/web.py:Committer) with a range of useful properties 
and methods.
+
+The function builds the UI using an [`htm.Block`](/ref/atr/htm.py:Block) 
object, which provides a convenient API for incrementally building HTML. The 
form is rendered directly into the block using 
[`form.render_block()`](/ref/atr/form.py:render_block), which generates all the 
necessary HTML with Bootstrap styling.
+
+Finally, the route returns the rendered HTML using 
[`template.blank()`](/ref/atr/template.py:blank), which renders a minimal 
template with just a title and content area.
+
+Form submission is handled by a separate POST route:
+```python
[email protected]("/keys/add")
[email protected](shared.keys.AddOpenPGPKeyForm)
+async def add(session: web.Committer, add_openpgp_key_form: 
shared.keys.AddOpenPGPKeyForm) -> web.WerkzeugResponse:
+    """Add a new public signing key to the user's account."""
+    try:
+        key_text = add_openpgp_key_form.public_key
+        selected_committee_names = add_openpgp_key_form.selected_committees
+
+        # Process the validated form data...
 
-The template receives the form object and renders it by passing it to one of 
the `forms.render_*` functions. We previously used Jinja2 macros for this, but 
are migrating to the new rendering functions in Python (e.g. in 
[`get/distribution.py`](/ref/atr/get/distribution.py) and 
[`get/ignores.py`](/ref/atr/get/ignores.py)). The template also receives other 
data like `asf_id` and `user_committees`, which it uses to display information 
or make decisions about what to show.
+        await quart.flash("OpenPGP key added successfully.", "success")
+    except web.FlashError as e:
+        await quart.flash(str(e), "error")
+
+    return await session.redirect(get.keys.keys)
+```
 
-If you use the programmatic rendering functions from 
[`forms`](/ref/atr/forms.py), you can skip the template entirely. These 
functions return htpy elements, which you can combine with other htpy elements 
and return directly from the route, which is often useful for admin routes, for 
example. You can also use [`template.blank`](/ref/atr/template.py:blank), which 
renders a minimal template with just a title and content area. This is useful 
for simple pages that do not need the full templat [...]
+The [`@post.form()`](/ref/atr/blueprints/post.py:form) decorator handles form 
validation automatically. If validation fails, it flashes error messages and 
redirects back to the GET route. If validation succeeds, the validated form 
instance is injected into the route handler as a parameter.
 
 Bootstrap CSS classes are applied automatically by the form rendering 
functions. The functions use classes like `form-control`, `form-select`, 
`btn-primary`, `is-invalid`, and `invalid-feedback`. We currently use Bootstrap 
5. If you generate HTML manually with htpy, you can apply Bootstrap classes 
yourself by using the CSS selector syntax like `htpy.div(".container")` or the 
class attribute like `htpy.div(class_="container")`.


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


Reply via email to