sbp commented on code in PR #309:
URL: 
https://github.com/apache/tooling-trusted-releases/pull/309#discussion_r2537033761


##########
atr/config.py:
##########
@@ -86,16 +86,7 @@ class AppConfig:
     # FIXME: retrieve the list of admin users from LDAP or oath session / 
isRoot
     ADMIN_USERS_ADDITIONAL = decouple.config("ADMIN_USERS_ADDITIONAL", 
default="", cast=str)
     ADMIN_USERS = frozenset(
-        {
-            "cwells",
-            "dfoulks",
-            "fluxo",
-            "gmcdonald",
-            "humbedooh",
-            "sbp",
-            "tn",
-            "wave",
-        }
+        {"cwells", "dfoulks", "fluxo", "gmcdonald", "humbedooh", "sbp", "tn", 
"wave"}

Review Comment:
   Please revert unassociated changes to make the PR easier to review.



##########
atr/get/distribution.py:
##########
@@ -66,55 +68,116 @@ async def list_get(session: web.Committer, project: str, 
version: str) -> str:
     ## Distributions
     block.h2["Distributions"]
     for dist in distributions:
-        delete_form = await shared.distribution.DeleteForm.create_form(
-            data={
-                "release_name": dist.release_name,
-                "platform": dist.platform.name,
-                "owner_namespace": dist.owner_namespace,
-                "package": dist.package,
-                "version": dist.version,
-            }
-        )
-
         ### Platform package version
         block.h3(
             # Cannot use "#id" here, because the ID contains "."
             # If an ID contains ".", htm parses that as a class
             id=f"distribution-{dist.identifier}"
         )[dist.title]
         tbody = htm.tbody[
-            shared.distribution.html_tr("Release name", dist.release_name),
-            shared.distribution.html_tr("Platform", dist.platform.value.name),
-            shared.distribution.html_tr("Owner or Namespace", 
dist.owner_namespace or "-"),
-            shared.distribution.html_tr("Package", dist.package),
-            shared.distribution.html_tr("Version", dist.version),
-            shared.distribution.html_tr("Staging", "Yes" if dist.staging else 
"No"),
-            shared.distribution.html_tr("Upload date", str(dist.upload_date)),
-            shared.distribution.html_tr_a("API URL", dist.api_url),
-            shared.distribution.html_tr_a("Web URL", dist.web_url),
+            shared.html_tr("Release name", dist.release_name),
+            shared.html_tr("Platform", dist.platform.value.name),
+            shared.html_tr("Owner or Namespace", dist.owner_namespace or "-"),
+            shared.html_tr("Package", dist.package),
+            shared.html_tr("Version", dist.version),
+            shared.html_tr("Staging", "Yes" if dist.staging else "No"),
+            shared.html_tr("Upload date", str(dist.upload_date)),
+            shared.html_tr_a("API URL", dist.api_url),
+            shared.html_tr_a("Web URL", dist.web_url),
         ]
         block.table(".table.table-striped.table-bordered")[tbody]
-        form_action = util.as_url(post.distribution.delete, project=project, 
version=version)
-        delete_form_element = forms.render_simple(
-            delete_form,
-            action=form_action,
-            submit_classes="btn-danger",
-        )
-        block.append(htm.div(".mb-3")[delete_form_element])
+
+        # Create inline delete form with confirmation dialog (following 
projects.py pattern)

Review Comment:
   This is currently the correct way to do this, but please add a TODO to the 
effect that we should add a `confirm: bool` keyword argument to `form.render`. 
This will allow us to use `form.render` and not have to special case forms with 
confirmations.



##########
atr/get/distribution.py:
##########
@@ -66,55 +68,116 @@ async def list_get(session: web.Committer, project: str, 
version: str) -> str:
     ## Distributions
     block.h2["Distributions"]
     for dist in distributions:
-        delete_form = await shared.distribution.DeleteForm.create_form(
-            data={
-                "release_name": dist.release_name,
-                "platform": dist.platform.name,
-                "owner_namespace": dist.owner_namespace,
-                "package": dist.package,
-                "version": dist.version,
-            }
-        )
-
         ### Platform package version
         block.h3(
             # Cannot use "#id" here, because the ID contains "."
             # If an ID contains ".", htm parses that as a class
             id=f"distribution-{dist.identifier}"
         )[dist.title]
         tbody = htm.tbody[
-            shared.distribution.html_tr("Release name", dist.release_name),
-            shared.distribution.html_tr("Platform", dist.platform.value.name),
-            shared.distribution.html_tr("Owner or Namespace", 
dist.owner_namespace or "-"),
-            shared.distribution.html_tr("Package", dist.package),
-            shared.distribution.html_tr("Version", dist.version),
-            shared.distribution.html_tr("Staging", "Yes" if dist.staging else 
"No"),
-            shared.distribution.html_tr("Upload date", str(dist.upload_date)),
-            shared.distribution.html_tr_a("API URL", dist.api_url),
-            shared.distribution.html_tr_a("Web URL", dist.web_url),
+            shared.html_tr("Release name", dist.release_name),
+            shared.html_tr("Platform", dist.platform.value.name),
+            shared.html_tr("Owner or Namespace", dist.owner_namespace or "-"),
+            shared.html_tr("Package", dist.package),
+            shared.html_tr("Version", dist.version),
+            shared.html_tr("Staging", "Yes" if dist.staging else "No"),
+            shared.html_tr("Upload date", str(dist.upload_date)),
+            shared.html_tr_a("API URL", dist.api_url),
+            shared.html_tr_a("Web URL", dist.web_url),
         ]
         block.table(".table.table-striped.table-bordered")[tbody]
-        form_action = util.as_url(post.distribution.delete, project=project, 
version=version)
-        delete_form_element = forms.render_simple(
-            delete_form,
-            action=form_action,
-            submit_classes="btn-danger",
-        )
-        block.append(htm.div(".mb-3")[delete_form_element])
+
+        # Create inline delete form with confirmation dialog (following 
projects.py pattern)
+        delete_form = htm.form(
+            ".d-inline-block.m-0",
+            method="post",
+            action=util.as_url(post.distribution.delete, project=project, 
version=version),
+            onsubmit=(
+                f"return confirm('Are you sure you want to delete the 
distribution "
+                f"{dist.platform.name} {dist.package} {dist.version}? This 
cannot be undone.');"
+            ),
+        )[
+            form.csrf_input(),
+            htpy.input(type="hidden", name="release_name", 
value=dist.release_name),
+            htpy.input(type="hidden", name="platform", 
value=dist.platform.name),
+            htpy.input(type="hidden", name="owner_namespace", 
value=dist.owner_namespace or ""),
+            htpy.input(type="hidden", name="package", value=dist.package),
+            htpy.input(type="hidden", name="version", value=dist.version),
+            htpy.button(".btn.btn-danger.btn-sm", type="submit", 
title=f"Delete {dist.title}")[
+                htpy.i(".bi.bi-trash"), " Delete"
+            ],
+        ]
+        block.append(htm.div(".mb-3")[delete_form])
 
     title = f"Distribution list for {project} {version}"
     return await template.blank(title, content=block.collect())
 
 
+# The delete_get function can now be removed since we're using inline 
confirmation

Review Comment:
   Let's just remove this.



##########
atr/post/distribution.py:
##########
@@ -49,6 +47,7 @@ async def delete(session: web.Committer, project: str, 
version: str) -> web.Werk
             package=dd.package,
             version=dd.version,
         )
+

Review Comment:
   Unassociated whitespace change, please remove.



##########
atr/shared/distribution.py:
##########
@@ -155,45 +163,13 @@ def html_tr_a(label: str, value: str | None) -> 
htm.Element:
     return htm.tr[htm.th[label], htm.td[htm.a(href=value)[value] if value else 
"-"]]
 
 
-# This function is used for COMPOSE (stage) and FINISH (record)
-# It's also used whenever there is an error
-async def record_form_page(
-    fpv: FormProjectVersion, *, extra_content: htm.Element | None = None, 
staging: bool = False
+async def record_form_process_page_new(

Review Comment:
   We only have functions in `shared` modules when they're used in both `get/` 
_and_ `post/` modules. Since this function is only used in 
`post/distribution.py`, it must be moved there.



##########
atr/shared/distribution.py:
##########
@@ -155,45 +163,13 @@ def html_tr_a(label: str, value: str | None) -> 
htm.Element:
     return htm.tr[htm.th[label], htm.td[htm.a(href=value)[value] if value else 
"-"]]
 
 
-# This function is used for COMPOSE (stage) and FINISH (record)
-# It's also used whenever there is an error
-async def record_form_page(
-    fpv: FormProjectVersion, *, extra_content: htm.Element | None = None, 
staging: bool = False
+async def record_form_process_page_new(
+    form_data: DistributeForm, project: str, version: str, /, staging: bool = 
False
 ) -> str:
-    await release_validated(fpv.project, fpv.version, staging=staging)
-
-    # Render the explanation and form
-    block = htm.Block()
-    html_nav_phase(block, fpv.project, fpv.version, staging)
-
-    # Record a manual distribution
-    title_and_heading = f"Record a {'staging' if staging else 'manual'} 
distribution"
-    block.h1[title_and_heading]
-    if extra_content:
-        block.append(extra_content)
-    block.p[
-        "Record a distribution of ",
-        htm.strong[f"{fpv.project}-{fpv.version}"],
-        " using the form below.",
-    ]
-    block.p[
-        "You can also ",
-        htm.a(href=util.as_url(get.distribution.list_get, project=fpv.project, 
version=fpv.version))[
-            "view the distribution list"
-        ],
-        ".",
-    ]
-    block.append(forms.render_columns(fpv.form, action=quart.request.path, 
descriptions=True))
-
-    # Render the page
-    return await template.blank(title_and_heading, content=block.collect())
-
-
-async def record_form_process_page(fpv: FormProjectVersion, /, staging: bool = 
False) -> str:
-    dd = distribution.Data.model_validate(fpv.form.data)
+    dd = distribution.Data.model_validate(form_data.model_dump())

Review Comment:
   This form is already being validated by the route functions in 
`post/distribution.py` that call this function. It should not be revalidated.



##########
atr/shared/distribution.py:
##########
@@ -270,6 +246,56 @@ async def _alert(message: str) -> str:
     return await template.blank("Distribution submitted", 
content=block.collect())
 
 
+async def record_form_page_new(
+    form_data: DistributeForm,
+    project: str,
+    version: str,
+    *,
+    extra_content: htm.Element | None = None,
+    staging: bool = False,
+) -> str:
+    await release_validated(project, version, staging=staging)
+
+    # Render the explanation and form
+    block = htm.Block()
+    html_nav_phase(block, project, version, staging)
+
+    # Record a manual distribution
+    title_and_heading = f"Record a {'staging' if staging else 'manual'} 
distribution"
+    block.h1[title_and_heading]
+    if extra_content:
+        block.append(extra_content)
+    block.p[
+        "Record a distribution of ",
+        htm.strong[f"{project}-{version}"],
+        " using the form below.",
+    ]
+    block.p[
+        "You can also ",
+        htm.a(href=util.as_url(get.distribution.list_get, project=project, 
version=version))[
+            "view the distribution list"
+        ],
+        ".",
+    ]
+
+    # Use form.render instead of forms.render_columns

Review Comment:
   This comment tells us what's changed, which we don't need to know; we have 
revision history for that. Comments should be used to describe things that 
aren't obvious about the code but that we need to know.



##########
atr/get/distribution.py:
##########
@@ -66,55 +68,116 @@ async def list_get(session: web.Committer, project: str, 
version: str) -> str:
     ## Distributions
     block.h2["Distributions"]
     for dist in distributions:
-        delete_form = await shared.distribution.DeleteForm.create_form(
-            data={
-                "release_name": dist.release_name,
-                "platform": dist.platform.name,
-                "owner_namespace": dist.owner_namespace,
-                "package": dist.package,
-                "version": dist.version,
-            }
-        )
-
         ### Platform package version
         block.h3(
             # Cannot use "#id" here, because the ID contains "."
             # If an ID contains ".", htm parses that as a class
             id=f"distribution-{dist.identifier}"
         )[dist.title]
         tbody = htm.tbody[
-            shared.distribution.html_tr("Release name", dist.release_name),
-            shared.distribution.html_tr("Platform", dist.platform.value.name),
-            shared.distribution.html_tr("Owner or Namespace", 
dist.owner_namespace or "-"),
-            shared.distribution.html_tr("Package", dist.package),
-            shared.distribution.html_tr("Version", dist.version),
-            shared.distribution.html_tr("Staging", "Yes" if dist.staging else 
"No"),
-            shared.distribution.html_tr("Upload date", str(dist.upload_date)),
-            shared.distribution.html_tr_a("API URL", dist.api_url),
-            shared.distribution.html_tr_a("Web URL", dist.web_url),
+            shared.html_tr("Release name", dist.release_name),
+            shared.html_tr("Platform", dist.platform.value.name),
+            shared.html_tr("Owner or Namespace", dist.owner_namespace or "-"),
+            shared.html_tr("Package", dist.package),
+            shared.html_tr("Version", dist.version),
+            shared.html_tr("Staging", "Yes" if dist.staging else "No"),
+            shared.html_tr("Upload date", str(dist.upload_date)),
+            shared.html_tr_a("API URL", dist.api_url),
+            shared.html_tr_a("Web URL", dist.web_url),
         ]
         block.table(".table.table-striped.table-bordered")[tbody]
-        form_action = util.as_url(post.distribution.delete, project=project, 
version=version)
-        delete_form_element = forms.render_simple(
-            delete_form,
-            action=form_action,
-            submit_classes="btn-danger",
-        )
-        block.append(htm.div(".mb-3")[delete_form_element])
+
+        # Create inline delete form with confirmation dialog (following 
projects.py pattern)
+        delete_form = htm.form(
+            ".d-inline-block.m-0",
+            method="post",
+            action=util.as_url(post.distribution.delete, project=project, 
version=version),
+            onsubmit=(
+                f"return confirm('Are you sure you want to delete the 
distribution "
+                f"{dist.platform.name} {dist.package} {dist.version}? This 
cannot be undone.');"
+            ),
+        )[
+            form.csrf_input(),
+            htpy.input(type="hidden", name="release_name", 
value=dist.release_name),
+            htpy.input(type="hidden", name="platform", 
value=dist.platform.name),
+            htpy.input(type="hidden", name="owner_namespace", 
value=dist.owner_namespace or ""),
+            htpy.input(type="hidden", name="package", value=dist.package),
+            htpy.input(type="hidden", name="version", value=dist.version),
+            htpy.button(".btn.btn-danger.btn-sm", type="submit", 
title=f"Delete {dist.title}")[
+                htpy.i(".bi.bi-trash"), " Delete"
+            ],
+        ]
+        block.append(htm.div(".mb-3")[delete_form])
 
     title = f"Distribution list for {project} {version}"
     return await template.blank(title, content=block.collect())
 
 
+# The delete_get function can now be removed since we're using inline 
confirmation
+# But if you want to keep it for direct URL access, you can redirect to the 
list
[email protected]("/distribution/delete/<project>/<version>")
+async def delete_get(session: web.Committer, project: str, version: str) -> 
web.WerkzeugResponse:
+    # Redirect to the list page instead of showing a separate confirmation page
+    return await session.redirect(list_get, project=project, version=version)
+
+
 @get.committer("/distribution/record/<project>/<version>")
 async def record(session: web.Committer, project: str, version: str) -> str:
-    form = await 
shared.distribution.DistributeForm.create_form(data={"package": project, 
"version": version})
-    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
-    return await shared.distribution.record_form_page(fpv)
+    await shared.release_validated(project, version, staging=False)
+
+    block = htm.Block()
+    shared.html_nav_phase(block, project, version, staging=False)
+
+    block.h1["Record a manual distribution"]
+    block.p[
+        "Record a distribution of ",
+        htm.strong[f"{project}-{version}"],
+        " using the form below.",
+    ]
+    block.p[
+        "You can also ",
+        htm.a(href=util.as_url(list_get, project=project, 
version=version))["view the distribution list"],
+        ".",
+    ]
+
+    # Render the distribution form
+    form_html = form.render(
+        model_cls=shared.DistributeForm,
+        submit_label="Record distribution",
+        action=util.as_url(post.distribution.record_post, project=project, 
version=version),
+        defaults={"package": project, "version": version},
+    )
+    block.append(form_html)
+
+    return await template.blank("Record Manual Distribution", 
content=block.collect())
 
 
 @get.committer("/distribution/stage/<project>/<version>")
 async def stage(session: web.Committer, project: str, version: str) -> str:
-    form = await 
shared.distribution.DistributeForm.create_form(data={"package": project, 
"version": version})
-    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
-    return await shared.distribution.record_form_page(fpv, staging=True)
+    await shared.release_validated(project, version, staging=True)
+
+    block = htm.Block()
+    shared.html_nav_phase(block, project, version, staging=True)
+
+    block.h1["Record a staging distribution"]
+    block.p[
+        "Record a distribution of ",
+        htm.strong[f"{project}-{version}"],
+        " using the form below.",
+    ]
+    block.p[
+        "You can also ",
+        htm.a(href=util.as_url(list_get, project=project, 
version=version))["view the distribution list"],
+        ".",
+    ]
+
+    # Render the distribution form
+    form_html = form.render(
+        model_cls=shared.DistributeForm,
+        submit_label="Record distribution",
+        action=util.as_url(post.distribution.stage_post, project=project, 
version=version),
+        defaults={"package": project, "version": version},
+    )
+    block.append(form_html)
+
+    return await template.blank("Record Staging Distribution", 
content=block.collect())

Review Comment:
   We use sentence case for titles, [as mentioned in our code 
conventions](https://release-test.apache.org/docs/code-conventions).



##########
atr/shared/distribution.py:
##########
@@ -36,55 +35,64 @@
 type Phase = Literal["COMPOSE", "VOTE", "FINISH"]
 
 
-class DeleteForm(forms.Typed):
-    release_name = forms.hidden()
-    platform = forms.hidden()
-    owner_namespace = forms.hidden()
-    package = forms.hidden()
-    version = forms.hidden()
-    submit = forms.submit("Delete")
+class DeleteForm(form.Form):
+    release_name: str = form.label("Release name", widget=form.Widget.HIDDEN)
+    platform: str = form.label("Platform", widget=form.Widget.HIDDEN)
+    owner_namespace: str = form.label("Owner namespace", 
widget=form.Widget.HIDDEN)
+    package: str = form.label("Package", widget=form.Widget.HIDDEN)
+    version: str = form.label("Version", widget=form.Widget.HIDDEN)
 
 
-class DistributeForm(forms.Typed):
-    platform = forms.select("Platform", choices=sql.DistributionPlatform)
-    owner_namespace = forms.optional(
-        "Owner or Namespace",
-        placeholder="E.g. com.example or scope or library",
-        description="Who owns or names the package (Maven groupId, npm @scope, 
"
-        "Docker namespace, GitHub owner, ArtifactHub repo). Leave blank if not 
used.",
+class DistributeForm(form.Form):
+    platform: sql.DistributionPlatform = form.label(description="Platform")
+    owner_namespace: str | None = form.label(

Review Comment:
   Using `str` allows `""` as a submission, so we typically don't need `| 
None`. There is one case where I found it would have been more elegant and type 
safe, but arguably typing a value as `None` from the actual HTML is not a 
realistic account of what is happening because unlike with checkboxes the field 
is not actually omitted. Anyway, let's use `str` for now.



##########
atr/shared/distribution.py:
##########
@@ -36,55 +35,64 @@
 type Phase = Literal["COMPOSE", "VOTE", "FINISH"]
 
 
-class DeleteForm(forms.Typed):
-    release_name = forms.hidden()
-    platform = forms.hidden()
-    owner_namespace = forms.hidden()
-    package = forms.hidden()
-    version = forms.hidden()
-    submit = forms.submit("Delete")
+class DeleteForm(form.Form):
+    release_name: str = form.label("Release name", widget=form.Widget.HIDDEN)
+    platform: str = form.label("Platform", widget=form.Widget.HIDDEN)
+    owner_namespace: str = form.label("Owner namespace", 
widget=form.Widget.HIDDEN)
+    package: str = form.label("Package", widget=form.Widget.HIDDEN)
+    version: str = form.label("Version", widget=form.Widget.HIDDEN)
 
 
-class DistributeForm(forms.Typed):
-    platform = forms.select("Platform", choices=sql.DistributionPlatform)
-    owner_namespace = forms.optional(
-        "Owner or Namespace",
-        placeholder="E.g. com.example or scope or library",
-        description="Who owns or names the package (Maven groupId, npm @scope, 
"
-        "Docker namespace, GitHub owner, ArtifactHub repo). Leave blank if not 
used.",
+class DistributeForm(form.Form):
+    platform: sql.DistributionPlatform = form.label(description="Platform")
+    owner_namespace: str | None = form.label(
+        description="Owner or Namespace",
+        documentation=(
+            "Who owns or names the package (Maven groupId, npm @scope, Docker 
namespace, "
+            "GitHub owner, ArtifactHub repo). Leave blank if not used."
+        ),
+        widget=form.Widget.TEXT,
+    )
+    package: str = form.label("Package", widget=form.Widget.TEXT)
+    version: str = form.label("Version", widget=form.Widget.TEXT)
+    details: bool = form.label(
+        description="Include details",

Review Comment:
   We typically don't use `description=` and `documentation=` keywords for the 
arguments to `form.label`.



##########
atr/post/distribution.py:
##########
@@ -58,33 +57,14 @@ async def delete(session: web.Committer, project: str, 
version: str) -> web.Werk
 
 
 @post.committer("/distribution/record/<project>/<version>")
-async def record_post(session: web.Committer, project: str, version: str) -> 
str:
-    form = await shared.distribution.DistributeForm.create_form(data=await 
quart.request.form)
-    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
-    if await form.validate():
-        return await shared.distribution.record_form_process_page(fpv)
-    match len(form.errors):
-        case 0:
-            # Should not happen
-            await quart.flash("Ambiguous submission errors", 
category="warning")
-        case 1:
-            await quart.flash("There was 1 submission error", category="error")
-        case _ as n:
-            await quart.flash(f"There were {n} submission errors", 
category="error")
-    return await shared.distribution.record_form_page(fpv)
[email protected](shared.DistributeForm)
+async def record_post(session: web.Committer, form: shared.DistributeForm, 
project: str, version: str) -> str:
+    # Pydantic validation happens automatically in @post.form

Review Comment:
   We'd have to add this comment to every single POST handler to be consistent. 
Let's just remove this comment instead.



##########
atr/get/distribution.py:
##########
@@ -15,13 +15,15 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import htpy
+
 import atr.blueprints.get as get
 import atr.db as db
-import atr.forms as forms
+import atr.form as form
 import atr.htm as htm
 import atr.models.sql as sql
 import atr.post as post
-import atr.shared as shared
+import atr.shared.distribution as shared

Review Comment:
   We don't use this import style. We always import a name `x` as `x`, [as 
described in our code 
conventions](https://release-test.apache.org/docs/code-conventions). This is 
also an unassociated change which should, ideally, be left out of a PR to make 
it easier to review.



##########
atr/get/distribution.py:
##########
@@ -66,55 +68,116 @@ async def list_get(session: web.Committer, project: str, 
version: str) -> str:
     ## Distributions
     block.h2["Distributions"]
     for dist in distributions:
-        delete_form = await shared.distribution.DeleteForm.create_form(
-            data={
-                "release_name": dist.release_name,
-                "platform": dist.platform.name,
-                "owner_namespace": dist.owner_namespace,
-                "package": dist.package,
-                "version": dist.version,
-            }
-        )
-
         ### Platform package version
         block.h3(
             # Cannot use "#id" here, because the ID contains "."
             # If an ID contains ".", htm parses that as a class
             id=f"distribution-{dist.identifier}"
         )[dist.title]
         tbody = htm.tbody[
-            shared.distribution.html_tr("Release name", dist.release_name),
-            shared.distribution.html_tr("Platform", dist.platform.value.name),
-            shared.distribution.html_tr("Owner or Namespace", 
dist.owner_namespace or "-"),
-            shared.distribution.html_tr("Package", dist.package),
-            shared.distribution.html_tr("Version", dist.version),
-            shared.distribution.html_tr("Staging", "Yes" if dist.staging else 
"No"),
-            shared.distribution.html_tr("Upload date", str(dist.upload_date)),
-            shared.distribution.html_tr_a("API URL", dist.api_url),
-            shared.distribution.html_tr_a("Web URL", dist.web_url),
+            shared.html_tr("Release name", dist.release_name),
+            shared.html_tr("Platform", dist.platform.value.name),
+            shared.html_tr("Owner or Namespace", dist.owner_namespace or "-"),
+            shared.html_tr("Package", dist.package),
+            shared.html_tr("Version", dist.version),
+            shared.html_tr("Staging", "Yes" if dist.staging else "No"),
+            shared.html_tr("Upload date", str(dist.upload_date)),
+            shared.html_tr_a("API URL", dist.api_url),
+            shared.html_tr_a("Web URL", dist.web_url),
         ]
         block.table(".table.table-striped.table-bordered")[tbody]
-        form_action = util.as_url(post.distribution.delete, project=project, 
version=version)
-        delete_form_element = forms.render_simple(
-            delete_form,
-            action=form_action,
-            submit_classes="btn-danger",
-        )
-        block.append(htm.div(".mb-3")[delete_form_element])
+
+        # Create inline delete form with confirmation dialog (following 
projects.py pattern)
+        delete_form = htm.form(
+            ".d-inline-block.m-0",
+            method="post",
+            action=util.as_url(post.distribution.delete, project=project, 
version=version),
+            onsubmit=(
+                f"return confirm('Are you sure you want to delete the 
distribution "
+                f"{dist.platform.name} {dist.package} {dist.version}? This 
cannot be undone.');"
+            ),
+        )[
+            form.csrf_input(),
+            htpy.input(type="hidden", name="release_name", 
value=dist.release_name),
+            htpy.input(type="hidden", name="platform", 
value=dist.platform.name),
+            htpy.input(type="hidden", name="owner_namespace", 
value=dist.owner_namespace or ""),
+            htpy.input(type="hidden", name="package", value=dist.package),
+            htpy.input(type="hidden", name="version", value=dist.version),
+            htpy.button(".btn.btn-danger.btn-sm", type="submit", 
title=f"Delete {dist.title}")[
+                htpy.i(".bi.bi-trash"), " Delete"
+            ],
+        ]
+        block.append(htm.div(".mb-3")[delete_form])
 
     title = f"Distribution list for {project} {version}"
     return await template.blank(title, content=block.collect())
 
 
+# The delete_get function can now be removed since we're using inline 
confirmation
+# But if you want to keep it for direct URL access, you can redirect to the 
list
[email protected]("/distribution/delete/<project>/<version>")
+async def delete_get(session: web.Committer, project: str, version: str) -> 
web.WerkzeugResponse:
+    # Redirect to the list page instead of showing a separate confirmation page
+    return await session.redirect(list_get, project=project, version=version)
+
+
 @get.committer("/distribution/record/<project>/<version>")
 async def record(session: web.Committer, project: str, version: str) -> str:
-    form = await 
shared.distribution.DistributeForm.create_form(data={"package": project, 
"version": version})
-    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
-    return await shared.distribution.record_form_page(fpv)
+    await shared.release_validated(project, version, staging=False)
+
+    block = htm.Block()

Review Comment:
   All of this code before `form.render` is duplicated in the `stage` function 
below, except for the call to `staging` in `html_nav_phase`. We can lift it 
into a helper function with `staging` parameterised.



##########
atr/storage/writers/distributions.py:
##########
@@ -161,8 +164,12 @@ async def record_from_data(
         match api_oc:
             case outcome.Result(result):
                 pass
-            case outcome.Error():
-                raise storage.AccessError("Failed to get API response from 
distribution platform")
+            case outcome.Error(error):
+                # Log the actual error for debugging
+                import atr.log as log

Review Comment:
   This should be top level unless it causes an import cycle.



##########
atr/post/distribution.py:
##########
@@ -58,33 +57,14 @@ async def delete(session: web.Committer, project: str, 
version: str) -> web.Werk
 
 
 @post.committer("/distribution/record/<project>/<version>")
-async def record_post(session: web.Committer, project: str, version: str) -> 
str:
-    form = await shared.distribution.DistributeForm.create_form(data=await 
quart.request.form)
-    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
-    if await form.validate():
-        return await shared.distribution.record_form_process_page(fpv)
-    match len(form.errors):
-        case 0:
-            # Should not happen
-            await quart.flash("Ambiguous submission errors", 
category="warning")
-        case 1:
-            await quart.flash("There was 1 submission error", category="error")
-        case _ as n:
-            await quart.flash(f"There were {n} submission errors", 
category="error")
-    return await shared.distribution.record_form_page(fpv)
[email protected](shared.DistributeForm)
+async def record_post(session: web.Committer, form: shared.DistributeForm, 
project: str, version: str) -> str:
+    # Pydantic validation happens automatically in @post.form
+    return await shared.record_form_process_page_new(form, project, version, 
staging=False)
 
 
 @post.committer("/distribution/stage/<project>/<version>")
-async def stage_post(session: web.Committer, project: str, version: str) -> 
str:
-    form = await shared.distribution.DistributeForm.create_form(data=await 
quart.request.form)
-    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
-    if await form.validate():
-        return await shared.distribution.record_form_process_page(fpv, 
staging=True)
-    match len(form.errors):
-        case 0:
-            await quart.flash("Ambiguous submission errors", 
category="warning")
-        case 1:
-            await quart.flash("There was 1 submission error", category="error")
-        case _ as n:
-            await quart.flash(f"There were {n} submission errors", 
category="error")
-    return await shared.distribution.record_form_page(fpv, staging=True)
[email protected](shared.DistributeForm)
+async def stage_post(session: web.Committer, form: shared.DistributeForm, 
project: str, version: str) -> str:

Review Comment:
   We don't call forms `form` because that conflicts with the `atr.form` module 
name when imported in our standard style. For `ExampleForm` we use the name 
`example_form`, so for `DistributeForm` that would be `distribute_form`.



##########
atr/post/distribution.py:
##########
@@ -58,33 +57,14 @@ async def delete(session: web.Committer, project: str, 
version: str) -> web.Werk
 
 
 @post.committer("/distribution/record/<project>/<version>")
-async def record_post(session: web.Committer, project: str, version: str) -> 
str:
-    form = await shared.distribution.DistributeForm.create_form(data=await 
quart.request.form)
-    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
-    if await form.validate():
-        return await shared.distribution.record_form_process_page(fpv)
-    match len(form.errors):
-        case 0:
-            # Should not happen
-            await quart.flash("Ambiguous submission errors", 
category="warning")
-        case 1:
-            await quart.flash("There was 1 submission error", category="error")
-        case _ as n:
-            await quart.flash(f"There were {n} submission errors", 
category="error")
-    return await shared.distribution.record_form_page(fpv)
[email protected](shared.DistributeForm)
+async def record_post(session: web.Committer, form: shared.DistributeForm, 
project: str, version: str) -> str:

Review Comment:
   The `form` variable here should be called `distribute_form`, and the 
function should be called `record_selected`; see the `stage_post` review for 
details.



##########
atr/shared/distribution.py:
##########
@@ -155,45 +163,13 @@ def html_tr_a(label: str, value: str | None) -> 
htm.Element:
     return htm.tr[htm.th[label], htm.td[htm.a(href=value)[value] if value else 
"-"]]
 
 
-# This function is used for COMPOSE (stage) and FINISH (record)
-# It's also used whenever there is an error
-async def record_form_page(
-    fpv: FormProjectVersion, *, extra_content: htm.Element | None = None, 
staging: bool = False
+async def record_form_process_page_new(
+    form_data: DistributeForm, project: str, version: str, /, staging: bool = 
False

Review Comment:
   We reserve the name `form_data` for form data that we get from Quart or 
ASFQuart. `DistributeForm` should always have the name `distribute_form`.



##########
atr/post/distribution.py:
##########
@@ -58,33 +57,14 @@ async def delete(session: web.Committer, project: str, 
version: str) -> web.Werk
 
 
 @post.committer("/distribution/record/<project>/<version>")
-async def record_post(session: web.Committer, project: str, version: str) -> 
str:
-    form = await shared.distribution.DistributeForm.create_form(data=await 
quart.request.form)
-    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
-    if await form.validate():
-        return await shared.distribution.record_form_process_page(fpv)
-    match len(form.errors):
-        case 0:
-            # Should not happen
-            await quart.flash("Ambiguous submission errors", 
category="warning")
-        case 1:
-            await quart.flash("There was 1 submission error", category="error")
-        case _ as n:
-            await quart.flash(f"There were {n} submission errors", 
category="error")
-    return await shared.distribution.record_form_page(fpv)
[email protected](shared.DistributeForm)
+async def record_post(session: web.Committer, form: shared.DistributeForm, 
project: str, version: str) -> str:
+    # Pydantic validation happens automatically in @post.form
+    return await shared.record_form_process_page_new(form, project, version, 
staging=False)
 
 
 @post.committer("/distribution/stage/<project>/<version>")
-async def stage_post(session: web.Committer, project: str, version: str) -> 
str:
-    form = await shared.distribution.DistributeForm.create_form(data=await 
quart.request.form)
-    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
-    if await form.validate():
-        return await shared.distribution.record_form_process_page(fpv, 
staging=True)
-    match len(form.errors):
-        case 0:
-            await quart.flash("Ambiguous submission errors", 
category="warning")
-        case 1:
-            await quart.flash("There was 1 submission error", category="error")
-        case _ as n:
-            await quart.flash(f"There were {n} submission errors", 
category="error")
-    return await shared.distribution.record_form_page(fpv, staging=True)
[email protected](shared.DistributeForm)
+async def stage_post(session: web.Committer, form: shared.DistributeForm, 
project: str, version: str) -> str:

Review Comment:
   We try to avoid using a module's own name in any of its functions, unless 
there is no obvious alternative. In this case, the function should be called 
`stage_selected`, because we use `selected` when `project` and `version` 
parameters are present in the URL.



##########
atr/storage/writers/distributions.py:
##########
@@ -23,6 +23,7 @@
 
 import aiohttp
 import sqlalchemy.exc as exc
+from sqlalchemy import text

Review Comment:
   We don't use this import style, [according to our code 
conventions](https://release-test.apache.org/docs/code-conventions).



##########
atr/shared/distribution.py:
##########
@@ -202,7 +178,7 @@ async def _alert(message: str) -> str:
         div = htm.Block(htm.div(".alert.alert-danger"))
         div.p[message]
         collected = div.collect()
-        return await record_form_page(fpv, extra_content=collected, 
staging=staging)
+        return await record_form_page_new(form_data, project, version, 
extra_content=collected, staging=staging)

Review Comment:
   Instead of calling a whole function separate from the GET route to render 
the page again, we should set a flash message and redirect. Then this function 
could disappear; it probably only exists due to code which has since been 
refactored out anyway as it's only called once.



##########
atr/form.py:
##########
@@ -561,14 +561,26 @@ def _get_choices(field_info: pydantic.fields.FieldInfo) 
-> list[tuple[str, str]]
             inner_type = args[0]
             if isinstance(inner_type, type) and issubclass(inner_type, 
enum.Enum):
                 # This is an enum type wrapped in Annotated, from Enum[T] or 
Set[T]
-                return [(member.value, member.value) for member in inner_type]
+                return [
+                    (
+                        member.name,
+                        member.value.name if hasattr(member.value, "name") 
else str(member.value),
+                    )
+                    for member in inner_type
+                ]
 
     if origin is set:
         args = get_args(annotation)
         if args:
             enum_class = args[0]
             if isinstance(enum_class, type) and issubclass(enum_class, 
enum.Enum):
-                return [(member.value, member.value) for member in enum_class]
+                return [

Review Comment:
   This won't be necessary after using a wrapper enum; see the "Check for 
single enum types" comment below for details.



##########
atr/form.py:
##########
@@ -577,7 +589,13 @@ def _get_choices(field_info: pydantic.fields.FieldInfo) -> 
list[tuple[str, str]]
 
     # Check for plain enum types, e.g. when Pydantic unwraps form.Enum[T]
     if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
-        return [(member.value, member.value) for member in annotation]
+        return [

Review Comment:
   This won't be necessary after using a wrapper enum; see the "Check for 
single enum types" comment below for details.



##########
atr/get/distribution.py:
##########
@@ -66,55 +68,116 @@ async def list_get(session: web.Committer, project: str, 
version: str) -> str:
     ## Distributions
     block.h2["Distributions"]
     for dist in distributions:
-        delete_form = await shared.distribution.DeleteForm.create_form(
-            data={
-                "release_name": dist.release_name,
-                "platform": dist.platform.name,
-                "owner_namespace": dist.owner_namespace,
-                "package": dist.package,
-                "version": dist.version,
-            }
-        )
-
         ### Platform package version
         block.h3(
             # Cannot use "#id" here, because the ID contains "."
             # If an ID contains ".", htm parses that as a class
             id=f"distribution-{dist.identifier}"
         )[dist.title]
         tbody = htm.tbody[
-            shared.distribution.html_tr("Release name", dist.release_name),
-            shared.distribution.html_tr("Platform", dist.platform.value.name),
-            shared.distribution.html_tr("Owner or Namespace", 
dist.owner_namespace or "-"),
-            shared.distribution.html_tr("Package", dist.package),
-            shared.distribution.html_tr("Version", dist.version),
-            shared.distribution.html_tr("Staging", "Yes" if dist.staging else 
"No"),
-            shared.distribution.html_tr("Upload date", str(dist.upload_date)),
-            shared.distribution.html_tr_a("API URL", dist.api_url),
-            shared.distribution.html_tr_a("Web URL", dist.web_url),
+            shared.html_tr("Release name", dist.release_name),
+            shared.html_tr("Platform", dist.platform.value.name),
+            shared.html_tr("Owner or Namespace", dist.owner_namespace or "-"),
+            shared.html_tr("Package", dist.package),
+            shared.html_tr("Version", dist.version),
+            shared.html_tr("Staging", "Yes" if dist.staging else "No"),
+            shared.html_tr("Upload date", str(dist.upload_date)),
+            shared.html_tr_a("API URL", dist.api_url),
+            shared.html_tr_a("Web URL", dist.web_url),
         ]
         block.table(".table.table-striped.table-bordered")[tbody]
-        form_action = util.as_url(post.distribution.delete, project=project, 
version=version)
-        delete_form_element = forms.render_simple(
-            delete_form,
-            action=form_action,
-            submit_classes="btn-danger",
-        )
-        block.append(htm.div(".mb-3")[delete_form_element])
+
+        # Create inline delete form with confirmation dialog (following 
projects.py pattern)
+        delete_form = htm.form(
+            ".d-inline-block.m-0",
+            method="post",
+            action=util.as_url(post.distribution.delete, project=project, 
version=version),
+            onsubmit=(
+                f"return confirm('Are you sure you want to delete the 
distribution "
+                f"{dist.platform.name} {dist.package} {dist.version}? This 
cannot be undone.');"
+            ),
+        )[
+            form.csrf_input(),
+            htpy.input(type="hidden", name="release_name", 
value=dist.release_name),
+            htpy.input(type="hidden", name="platform", 
value=dist.platform.name),
+            htpy.input(type="hidden", name="owner_namespace", 
value=dist.owner_namespace or ""),
+            htpy.input(type="hidden", name="package", value=dist.package),
+            htpy.input(type="hidden", name="version", value=dist.version),
+            htpy.button(".btn.btn-danger.btn-sm", type="submit", 
title=f"Delete {dist.title}")[
+                htpy.i(".bi.bi-trash"), " Delete"
+            ],
+        ]
+        block.append(htm.div(".mb-3")[delete_form])
 
     title = f"Distribution list for {project} {version}"
     return await template.blank(title, content=block.collect())
 
 
+# The delete_get function can now be removed since we're using inline 
confirmation
+# But if you want to keep it for direct URL access, you can redirect to the 
list
[email protected]("/distribution/delete/<project>/<version>")
+async def delete_get(session: web.Committer, project: str, version: str) -> 
web.WerkzeugResponse:
+    # Redirect to the list page instead of showing a separate confirmation page
+    return await session.redirect(list_get, project=project, version=version)
+
+
 @get.committer("/distribution/record/<project>/<version>")
 async def record(session: web.Committer, project: str, version: str) -> str:
-    form = await 
shared.distribution.DistributeForm.create_form(data={"package": project, 
"version": version})
-    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
-    return await shared.distribution.record_form_page(fpv)
+    await shared.release_validated(project, version, staging=False)
+
+    block = htm.Block()
+    shared.html_nav_phase(block, project, version, staging=False)
+
+    block.h1["Record a manual distribution"]
+    block.p[
+        "Record a distribution of ",
+        htm.strong[f"{project}-{version}"],
+        " using the form below.",
+    ]
+    block.p[
+        "You can also ",
+        htm.a(href=util.as_url(list_get, project=project, 
version=version))["view the distribution list"],
+        ".",
+    ]
+
+    # Render the distribution form
+    form_html = form.render(
+        model_cls=shared.DistributeForm,
+        submit_label="Record distribution",
+        action=util.as_url(post.distribution.record_post, project=project, 
version=version),
+        defaults={"package": project, "version": version},
+    )
+    block.append(form_html)
+
+    return await template.blank("Record Manual Distribution", 
content=block.collect())

Review Comment:
   We use sentence case for titles, [as mentioned in our code 
conventions](https://release-test.apache.org/docs/code-conventions).



##########
atr/shared/distribution.py:
##########
@@ -36,55 +35,64 @@
 type Phase = Literal["COMPOSE", "VOTE", "FINISH"]
 
 
-class DeleteForm(forms.Typed):
-    release_name = forms.hidden()
-    platform = forms.hidden()
-    owner_namespace = forms.hidden()
-    package = forms.hidden()
-    version = forms.hidden()
-    submit = forms.submit("Delete")
+class DeleteForm(form.Form):
+    release_name: str = form.label("Release name", widget=form.Widget.HIDDEN)
+    platform: str = form.label("Platform", widget=form.Widget.HIDDEN)
+    owner_namespace: str = form.label("Owner namespace", 
widget=form.Widget.HIDDEN)
+    package: str = form.label("Package", widget=form.Widget.HIDDEN)
+    version: str = form.label("Version", widget=form.Widget.HIDDEN)
 
 
-class DistributeForm(forms.Typed):
-    platform = forms.select("Platform", choices=sql.DistributionPlatform)
-    owner_namespace = forms.optional(
-        "Owner or Namespace",
-        placeholder="E.g. com.example or scope or library",
-        description="Who owns or names the package (Maven groupId, npm @scope, 
"
-        "Docker namespace, GitHub owner, ArtifactHub repo). Leave blank if not 
used.",
+class DistributeForm(form.Form):
+    platform: sql.DistributionPlatform = form.label(description="Platform")
+    owner_namespace: str | None = form.label(
+        description="Owner or Namespace",
+        documentation=(
+            "Who owns or names the package (Maven groupId, npm @scope, Docker 
namespace, "
+            "GitHub owner, ArtifactHub repo). Leave blank if not used."
+        ),
+        widget=form.Widget.TEXT,
+    )
+    package: str = form.label("Package", widget=form.Widget.TEXT)
+    version: str = form.label("Version", widget=form.Widget.TEXT)
+    details: bool = form.label(
+        description="Include details",
+        documentation="Include the details of the distribution in the 
response",
+        widget=form.Widget.CHECKBOX,

Review Comment:
   This can be dropped, like with `TEXT`.



##########
atr/form.py:
##########
@@ -640,6 +658,10 @@ def _get_widget_type(field_info: 
pydantic.fields.FieldInfo) -> Widget:  # noqa:
     if annotation in (int, float):
         return Widget.NUMBER
 
+    # Check for single enum types

Review Comment:
   We already have `form.Enum` for this which annotates a single enum type, but 
`sql.DistributionPlatform` isn't compatible because it's structured. Rather 
than adding special code for it, we should probably take the same approach used 
in `shared/ignore.py` and use a wrapper enum. Then we could use 
`form.Enum[NameOfTheWrapperEnum]` as the type of the form field.



##########
atr/form.py:
##########
@@ -561,14 +561,26 @@ def _get_choices(field_info: pydantic.fields.FieldInfo) 
-> list[tuple[str, str]]
             inner_type = args[0]
             if isinstance(inner_type, type) and issubclass(inner_type, 
enum.Enum):
                 # This is an enum type wrapped in Annotated, from Enum[T] or 
Set[T]
-                return [(member.value, member.value) for member in inner_type]
+                return [

Review Comment:
   This won't be necessary after using a wrapper enum; see the "Check for 
single enum types" comment below for details.



##########
atr/shared/distribution.py:
##########
@@ -36,55 +35,64 @@
 type Phase = Literal["COMPOSE", "VOTE", "FINISH"]
 
 
-class DeleteForm(forms.Typed):
-    release_name = forms.hidden()
-    platform = forms.hidden()
-    owner_namespace = forms.hidden()
-    package = forms.hidden()
-    version = forms.hidden()
-    submit = forms.submit("Delete")
+class DeleteForm(form.Form):
+    release_name: str = form.label("Release name", widget=form.Widget.HIDDEN)
+    platform: str = form.label("Platform", widget=form.Widget.HIDDEN)
+    owner_namespace: str = form.label("Owner namespace", 
widget=form.Widget.HIDDEN)
+    package: str = form.label("Package", widget=form.Widget.HIDDEN)
+    version: str = form.label("Version", widget=form.Widget.HIDDEN)
 
 
-class DistributeForm(forms.Typed):
-    platform = forms.select("Platform", choices=sql.DistributionPlatform)
-    owner_namespace = forms.optional(
-        "Owner or Namespace",
-        placeholder="E.g. com.example or scope or library",
-        description="Who owns or names the package (Maven groupId, npm @scope, 
"
-        "Docker namespace, GitHub owner, ArtifactHub repo). Leave blank if not 
used.",
+class DistributeForm(form.Form):
+    platform: sql.DistributionPlatform = form.label(description="Platform")
+    owner_namespace: str | None = form.label(
+        description="Owner or Namespace",
+        documentation=(
+            "Who owns or names the package (Maven groupId, npm @scope, Docker 
namespace, "
+            "GitHub owner, ArtifactHub repo). Leave blank if not used."
+        ),
+        widget=form.Widget.TEXT,

Review Comment:
   We don't need to use `Widget.TEXT`. We only need to set the widget in 
general when we want to use something other than the default choice, which is 
this case is already `TEXT`. Same for other fields.



##########
atr/form.py:
##########
@@ -640,6 +658,10 @@ def _get_widget_type(field_info: 
pydantic.fields.FieldInfo) -> Widget:  # noqa:
     if annotation in (int, float):
         return Widget.NUMBER
 
+    # Check for single enum types
+    if hasattr(annotation, "__members__"):

Review Comment:
   We don't need this because we already have `form.Enum` as noted above, but 
if we were to do this then we would check for it being a subclass of 
`enum.Enum` instead.



##########
atr/shared/distribution.py:
##########
@@ -36,55 +35,64 @@
 type Phase = Literal["COMPOSE", "VOTE", "FINISH"]
 
 
-class DeleteForm(forms.Typed):
-    release_name = forms.hidden()
-    platform = forms.hidden()
-    owner_namespace = forms.hidden()
-    package = forms.hidden()
-    version = forms.hidden()
-    submit = forms.submit("Delete")
+class DeleteForm(form.Form):
+    release_name: str = form.label("Release name", widget=form.Widget.HIDDEN)
+    platform: str = form.label("Platform", widget=form.Widget.HIDDEN)
+    owner_namespace: str = form.label("Owner namespace", 
widget=form.Widget.HIDDEN)
+    package: str = form.label("Package", widget=form.Widget.HIDDEN)
+    version: str = form.label("Version", widget=form.Widget.HIDDEN)
 
 
-class DistributeForm(forms.Typed):
-    platform = forms.select("Platform", choices=sql.DistributionPlatform)
-    owner_namespace = forms.optional(
-        "Owner or Namespace",
-        placeholder="E.g. com.example or scope or library",
-        description="Who owns or names the package (Maven groupId, npm @scope, 
"
-        "Docker namespace, GitHub owner, ArtifactHub repo). Leave blank if not 
used.",
+class DistributeForm(form.Form):
+    platform: sql.DistributionPlatform = form.label(description="Platform")
+    owner_namespace: str | None = form.label(
+        description="Owner or Namespace",
+        documentation=(
+            "Who owns or names the package (Maven groupId, npm @scope, Docker 
namespace, "
+            "GitHub owner, ArtifactHub repo). Leave blank if not used."
+        ),
+        widget=form.Widget.TEXT,
+    )
+    package: str = form.label("Package", widget=form.Widget.TEXT)
+    version: str = form.label("Version", widget=form.Widget.TEXT)
+    details: bool = form.label(
+        description="Include details",
+        documentation="Include the details of the distribution in the 
response",
+        widget=form.Widget.CHECKBOX,
     )
-    package = forms.string("Package", placeholder="E.g. artifactId or 
package-name")
-    version = forms.string("Version", placeholder="E.g. 1.2.3, without a 
leading v")
-    details = forms.checkbox("Include details", description="Include the 
details of the distribution in the response")
-    submit = forms.submit("Record distribution")
-
-    async def validate(self, extra_validators: dict | None = None) -> bool:
-        if not await super().validate(extra_validators):
-            return False
-        if not self.platform.data:
-            return False
-        default_owner_namespace = 
self.platform.data.value.default_owner_namespace
-        requires_owner_namespace = 
self.platform.data.value.requires_owner_namespace
-        owner_namespace = self.owner_namespace.data
-        # TODO: We should disable the owner_namespace field if it's not 
required
-        # But that would be a lot of complexity
-        # And this validation, which we need to keep, is complex enough
-        if default_owner_namespace and (not owner_namespace):
-            self.owner_namespace.data = default_owner_namespace
-        if requires_owner_namespace and (not owner_namespace):
-            msg = f'Platform "{self.platform.data.name}" requires an owner or 
namespace.'
-            return forms.error(self.owner_namespace, msg)
-        if (not requires_owner_namespace) and (not default_owner_namespace) 
and owner_namespace:
-            msg = f'Platform "{self.platform.data.name}" does not require an 
owner or namespace.'
-            return forms.error(self.owner_namespace, msg)
-        return True
-
-
[email protected]
-class FormProjectVersion:
-    form: DistributeForm
-    project: str
-    version: str
+
+    @pydantic.field_validator("platform", mode="before")
+    @classmethod
+    def validate_platform(cls, value: Any) -> sql.DistributionPlatform:
+        if isinstance(value, sql.DistributionPlatform):
+            return value
+        if isinstance(value, str):

Review Comment:
   We'll be changing this anyway when we use the wrapper enum in`form.Enum`, 
but presumably this value is either one or the other. We shouldn't need to 
check for both.



##########
atr/shared/distribution.py:
##########
@@ -36,55 +35,64 @@
 type Phase = Literal["COMPOSE", "VOTE", "FINISH"]
 
 
-class DeleteForm(forms.Typed):
-    release_name = forms.hidden()
-    platform = forms.hidden()
-    owner_namespace = forms.hidden()
-    package = forms.hidden()
-    version = forms.hidden()
-    submit = forms.submit("Delete")
+class DeleteForm(form.Form):
+    release_name: str = form.label("Release name", widget=form.Widget.HIDDEN)
+    platform: str = form.label("Platform", widget=form.Widget.HIDDEN)
+    owner_namespace: str = form.label("Owner namespace", 
widget=form.Widget.HIDDEN)
+    package: str = form.label("Package", widget=form.Widget.HIDDEN)
+    version: str = form.label("Version", widget=form.Widget.HIDDEN)
 
 
-class DistributeForm(forms.Typed):
-    platform = forms.select("Platform", choices=sql.DistributionPlatform)
-    owner_namespace = forms.optional(
-        "Owner or Namespace",
-        placeholder="E.g. com.example or scope or library",
-        description="Who owns or names the package (Maven groupId, npm @scope, 
"
-        "Docker namespace, GitHub owner, ArtifactHub repo). Leave blank if not 
used.",
+class DistributeForm(form.Form):
+    platform: sql.DistributionPlatform = form.label(description="Platform")
+    owner_namespace: str | None = form.label(
+        description="Owner or Namespace",
+        documentation=(
+            "Who owns or names the package (Maven groupId, npm @scope, Docker 
namespace, "
+            "GitHub owner, ArtifactHub repo). Leave blank if not used."
+        ),
+        widget=form.Widget.TEXT,
+    )
+    package: str = form.label("Package", widget=form.Widget.TEXT)
+    version: str = form.label("Version", widget=form.Widget.TEXT)
+    details: bool = form.label(
+        description="Include details",
+        documentation="Include the details of the distribution in the 
response",
+        widget=form.Widget.CHECKBOX,
     )
-    package = forms.string("Package", placeholder="E.g. artifactId or 
package-name")
-    version = forms.string("Version", placeholder="E.g. 1.2.3, without a 
leading v")
-    details = forms.checkbox("Include details", description="Include the 
details of the distribution in the response")
-    submit = forms.submit("Record distribution")
-
-    async def validate(self, extra_validators: dict | None = None) -> bool:
-        if not await super().validate(extra_validators):
-            return False
-        if not self.platform.data:
-            return False
-        default_owner_namespace = 
self.platform.data.value.default_owner_namespace
-        requires_owner_namespace = 
self.platform.data.value.requires_owner_namespace
-        owner_namespace = self.owner_namespace.data
-        # TODO: We should disable the owner_namespace field if it's not 
required
-        # But that would be a lot of complexity
-        # And this validation, which we need to keep, is complex enough
-        if default_owner_namespace and (not owner_namespace):
-            self.owner_namespace.data = default_owner_namespace
-        if requires_owner_namespace and (not owner_namespace):
-            msg = f'Platform "{self.platform.data.name}" requires an owner or 
namespace.'
-            return forms.error(self.owner_namespace, msg)
-        if (not requires_owner_namespace) and (not default_owner_namespace) 
and owner_namespace:
-            msg = f'Platform "{self.platform.data.name}" does not require an 
owner or namespace.'
-            return forms.error(self.owner_namespace, msg)
-        return True
-
-
[email protected]
-class FormProjectVersion:
-    form: DistributeForm
-    project: str
-    version: str
+
+    @pydantic.field_validator("platform", mode="before")
+    @classmethod
+    def validate_platform(cls, value: Any) -> sql.DistributionPlatform:
+        if isinstance(value, sql.DistributionPlatform):
+            return value
+        if isinstance(value, str):
+            try:
+                return sql.DistributionPlatform[value]
+            except KeyError:
+                raise ValueError(f"Invalid platform: {value}")
+        raise ValueError(f"Platform must be a string or DistributionPlatform, 
got {type(value)}")
+
+    @pydantic.model_validator(mode="after")
+    def validate_owner_namespace(self):
+        if not self.platform:
+            raise ValueError("Platform is required")
+
+        default_owner_namespace = self.platform.value.default_owner_namespace
+        requires_owner_namespace = self.platform.value.requires_owner_namespace
+
+        # Set default if needed and not provided
+        if default_owner_namespace and not self.owner_namespace:
+            self.owner_namespace = default_owner_namespace
+
+        # Validate requirements
+        if requires_owner_namespace and not self.owner_namespace:
+            raise ValueError(f'Platform "{self.platform.name}" requires an 
owner or namespace.')
+
+        if not requires_owner_namespace and not default_owner_namespace and 
self.owner_namespace:

Review Comment:
   Please ensure that all subexpressions are parenthesized, as pointed out in 
our [code conventions](https://release-test.apache.org/docs/code-conventions). 
Same goes for prior lines in this method, and potentially elsewhere.



##########
atr/post/distribution.py:
##########
@@ -17,28 +17,26 @@
 
 from __future__ import annotations
 
-import quart
-
 import atr.blueprints.post as post
 import atr.db as db
 import atr.get as get
 import atr.models.distribution as distribution
-import atr.shared as shared
+import atr.shared.distribution as shared
 import atr.storage as storage
 import atr.web as web
 
 
 @post.committer("/distribution/delete/<project>/<version>")
-async def delete(session: web.Committer, project: str, version: str) -> 
web.WerkzeugResponse:
-    form = await shared.distribution.DeleteForm.create_form(data=await 
quart.request.form)
-    dd = distribution.DeleteData.model_validate(form.data)
[email protected](shared.DeleteForm)
+async def delete(session: web.Committer, form: shared.DeleteForm, project: 
str, version: str) -> web.WerkzeugResponse:
+    dd = distribution.DeleteData.model_validate(form.model_dump())

Review Comment:
   This form is already being validated by the route function decorator. It 
should not be revalidated.



##########
atr/post/distribution.py:
##########
@@ -58,33 +57,14 @@ async def delete(session: web.Committer, project: str, 
version: str) -> web.Werk
 
 
 @post.committer("/distribution/record/<project>/<version>")
-async def record_post(session: web.Committer, project: str, version: str) -> 
str:
-    form = await shared.distribution.DistributeForm.create_form(data=await 
quart.request.form)
-    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
-    if await form.validate():
-        return await shared.distribution.record_form_process_page(fpv)
-    match len(form.errors):
-        case 0:
-            # Should not happen
-            await quart.flash("Ambiguous submission errors", 
category="warning")
-        case 1:
-            await quart.flash("There was 1 submission error", category="error")
-        case _ as n:
-            await quart.flash(f"There were {n} submission errors", 
category="error")
-    return await shared.distribution.record_form_page(fpv)
[email protected](shared.DistributeForm)
+async def record_post(session: web.Committer, form: shared.DistributeForm, 
project: str, version: str) -> str:
+    # Pydantic validation happens automatically in @post.form
+    return await shared.record_form_process_page_new(form, project, version, 
staging=False)
 
 
 @post.committer("/distribution/stage/<project>/<version>")
-async def stage_post(session: web.Committer, project: str, version: str) -> 
str:
-    form = await shared.distribution.DistributeForm.create_form(data=await 
quart.request.form)
-    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
-    if await form.validate():
-        return await shared.distribution.record_form_process_page(fpv, 
staging=True)
-    match len(form.errors):
-        case 0:
-            await quart.flash("Ambiguous submission errors", 
category="warning")
-        case 1:
-            await quart.flash("There was 1 submission error", category="error")
-        case _ as n:
-            await quart.flash(f"There were {n} submission errors", 
category="error")
-    return await shared.distribution.record_form_page(fpv, staging=True)
[email protected](shared.DistributeForm)
+async def stage_post(session: web.Committer, form: shared.DistributeForm, 
project: str, version: str) -> str:
+    # Pydantic validation happens automatically in @post.form

Review Comment:
   Let's remove this comment.



##########
atr/post/distribution.py:
##########
@@ -17,28 +17,26 @@
 
 from __future__ import annotations
 
-import quart
-
 import atr.blueprints.post as post
 import atr.db as db
 import atr.get as get
 import atr.models.distribution as distribution
-import atr.shared as shared
+import atr.shared.distribution as shared

Review Comment:
   As mentioned on `get/distribution.py`, we don't use this import style and 
this is an unassociated change.



##########
atr/storage/writers/distributions.py:
##########
@@ -120,6 +121,8 @@ async def record(
         self.__data.add(distribution)
         try:
             await self.__data.commit()
+            # Force WAL checkpoint to ensure data is immediately visible to 
other sessions

Review Comment:
   [Checkpointing does not make data visible to other 
sessions](https://sqlite.org/wal.html#concurrency):
   
   > A checkpoint can run concurrently with readers, however the checkpoint 
must stop when it reaches a page in the WAL that is past the end mark of any 
current reader.
   
   The operation that [makes data available to _subsequent_ 
transactions](https://sqlite.org/isolation.html) is `commit`, which we are 
already doing. Once data is in the WAL, [it is available to 
readers](https://sqlite.org/wal.html#concurrency):
   
   > When a reader needs a page of content, it first checks the WAL to see if 
that page appears there, and if so it pulls in the last copy of the page that 
occurs in the WAL prior to the reader's end mark.
   
   It is [not possible in sqlite to make new data available to _existing_ 
transactions](https://sqlite.org/lang_transaction.html#read_transactions_versus_write_transactions)
 unless [a mode of an obsolete feature is 
enabled](https://sqlite.org/isolation.html):
   
   > If two database connections share the same cache and the reader has 
enabled the [read_uncommitted 
pragma](https://sqlite.org/pragma.html#pragma_read_uncommitted), then the 
reader will be able to see changes made by the writer before the writer 
transaction commits. The combined use of [shared cache 
mode](https://sqlite.org/sharedcache.html) and the [read_uncommitted 
pragma](https://sqlite.org/pragma.html#pragma_read_uncommitted) is the only way 
that one database connection can see uncommitted changes on a different 
database connection.
   
   In addition to being marked as obsolete, the documentation says that 
"[shared-cache mode is discouraged](https://sqlite.org/sharedcache.html)"; 
`read_uncommitted` only applies when shared cache mode is enabled. We try 
instead to ensure that transactions are open for as short a duration as 
possible, which has other benefits such as not blocking checkpointing. 
Checkpointing has no effect even on database connections with a shared cache 
and `read_uncommitted` set because the uncommitted data is read from the cache, 
not from the WAL.



##########
atr/shared/distribution.py:
##########
@@ -36,55 +35,64 @@
 type Phase = Literal["COMPOSE", "VOTE", "FINISH"]
 
 
-class DeleteForm(forms.Typed):
-    release_name = forms.hidden()
-    platform = forms.hidden()
-    owner_namespace = forms.hidden()
-    package = forms.hidden()
-    version = forms.hidden()
-    submit = forms.submit("Delete")
+class DeleteForm(form.Form):
+    release_name: str = form.label("Release name", widget=form.Widget.HIDDEN)
+    platform: str = form.label("Platform", widget=form.Widget.HIDDEN)
+    owner_namespace: str = form.label("Owner namespace", 
widget=form.Widget.HIDDEN)
+    package: str = form.label("Package", widget=form.Widget.HIDDEN)
+    version: str = form.label("Version", widget=form.Widget.HIDDEN)
 
 
-class DistributeForm(forms.Typed):
-    platform = forms.select("Platform", choices=sql.DistributionPlatform)
-    owner_namespace = forms.optional(
-        "Owner or Namespace",
-        placeholder="E.g. com.example or scope or library",
-        description="Who owns or names the package (Maven groupId, npm @scope, 
"
-        "Docker namespace, GitHub owner, ArtifactHub repo). Leave blank if not 
used.",
+class DistributeForm(form.Form):
+    platform: sql.DistributionPlatform = form.label(description="Platform")
+    owner_namespace: str | None = form.label(
+        description="Owner or Namespace",
+        documentation=(
+            "Who owns or names the package (Maven groupId, npm @scope, Docker 
namespace, "
+            "GitHub owner, ArtifactHub repo). Leave blank if not used."
+        ),
+        widget=form.Widget.TEXT,
+    )
+    package: str = form.label("Package", widget=form.Widget.TEXT)
+    version: str = form.label("Version", widget=form.Widget.TEXT)
+    details: bool = form.label(

Review Comment:
   Using `bool` makes this field required. The correct type is `Bool`. We need 
to make it clearer which `form` types are mandatory and which are optional. 
They're almost all optional but I didn't want to add `Opt` to them all, so I 
think it would be better to have a prefix (or suffix) for the few mandatory 
ones only.



##########
atr/post/distribution.py:
##########
@@ -17,28 +17,26 @@
 
 from __future__ import annotations
 
-import quart
-
 import atr.blueprints.post as post
 import atr.db as db
 import atr.get as get
 import atr.models.distribution as distribution
-import atr.shared as shared
+import atr.shared.distribution as shared
 import atr.storage as storage
 import atr.web as web
 
 
 @post.committer("/distribution/delete/<project>/<version>")
-async def delete(session: web.Committer, project: str, version: str) -> 
web.WerkzeugResponse:
-    form = await shared.distribution.DeleteForm.create_form(data=await 
quart.request.form)
-    dd = distribution.DeleteData.model_validate(form.data)
[email protected](shared.DeleteForm)
+async def delete(session: web.Committer, form: shared.DeleteForm, project: 
str, version: str) -> web.WerkzeugResponse:

Review Comment:
   The form should be called `delete_form`, otherwise this overrides the 
`atr.form` module name.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to