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

commit ba2f8df09b5caaf3f20d14c73de8b752a327f22d
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Nov 10 17:09:40 2025 +0000

    Make the forms to upload files more type safe
---
 atr/form.py                        |   4 +-
 atr/get/upload.py                  | 147 ++++++++++++++++++++++++++++++++++++-
 atr/post/draft.py                  |  45 ------------
 atr/post/upload.py                 |  78 +++++++++++++++++++-
 atr/shared/upload.py               | 119 +++++++++++-------------------
 atr/templates/upload-selected.html | 136 ----------------------------------
 playwright/test.py                 |   2 +-
 7 files changed, 267 insertions(+), 264 deletions(-)

diff --git a/atr/form.py b/atr/form.py
index 2c3a21c..528e139 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -345,9 +345,9 @@ def to_filestorage_list(v: Any) -> 
list[datastructures.FileStorage]:
     raise ValueError("Expected a list of uploaded files")
 
 
-def to_filename(v: Any) -> pathlib.Path:
+def to_filename(v: Any) -> pathlib.Path | None:
     if not v:
-        raise ValueError("Filename cannot be empty")
+        return None
 
     path = pathlib.Path(str(v))
 
diff --git a/atr/get/upload.py b/atr/get/upload.py
index 09fb5b3..850391e 100644
--- a/atr/get/upload.py
+++ b/atr/get/upload.py
@@ -15,12 +15,155 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from collections.abc import Sequence
 
 import atr.blueprints.get as get
+import atr.db as db
+import atr.form as form
+import atr.get.compose as compose
+import atr.get.keys as keys
+import atr.htm as htm
+import atr.models.sql as sql
 import atr.shared as shared
+import atr.template as template
+import atr.util as util
 import atr.web as web
 
 
 @get.committer("/upload/<project_name>/<version_name>")
-async def selected(session: web.Committer, project_name: str, version_name: 
str) -> web.WerkzeugResponse | str:
-    return await shared.upload.selected(session, project_name, version_name)
+async def selected(session: web.Committer, project_name: str, version_name: 
str) -> str:
+    await session.check_access(project_name)
+
+    async with db.session() as data:
+        release = await session.release(project_name, version_name, data=data)
+        user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
+
+    block = htm.Block()
+
+    shared.distribution.html_nav(
+        block,
+        util.as_url(compose.selected, project_name=release.project.name, 
version_name=release.version),
+        f"Compose {release.short_display_name}",
+        "COMPOSE",
+    )
+
+    block.h1[
+        "Upload to ",
+        htm.strong[release.project.short_display_name],
+        " ",
+        htm.em[release.version],
+    ]
+
+    block.p[
+        htm.a(".btn.btn-outline-primary.me-2", href="#file-upload")["Use the 
browser"],
+        htm.a(".btn.btn-outline-primary.me-2", href="#svn-upload")["Use SVN"],
+        htm.a(".btn.btn-outline-primary", href="#rsync-upload")["Use rsync"],
+    ]
+
+    block.h2(id="file-upload")["File upload"]
+    block.p["Use this form to add files to this candidate draft."]
+
+    await form.render_block(
+        block,
+        model_cls=shared.upload.AddFilesForm,
+        submit_label="Add files",
+        form_classes=".atr-canary.py-4.px-5",
+    )
+
+    block.h2(id="svn-upload")["SVN upload"]
+    block.p["Import files from a world readable Subversion repository URL into 
this draft."]
+    block.p[
+        "The import will be processed in the background using the ",
+        htm.code["svn export"],
+        " command. You can monitor progress on the ",
+        htm.em["Evaluate files"],
+        " page for this draft once the task is queued.",
+    ]
+
+    await form.render_block(
+        block,
+        model_cls=shared.upload.SvnImportForm,
+        submit_label="Queue SVN import task",
+        form_classes=".atr-canary.py-4.px-5",
+    )
+
+    block.h2(id="rsync-upload")["Rsync upload"]
+
+    key_count = len(user_ssh_keys)
+    if key_count == 0:
+        block.div(".alert.alert-warning")[
+            htm.p(".mb-0")[
+                "We have no SSH keys on file for you, ",
+                "so you cannot yet use this command. Please ",
+                htm.a(href=util.as_url(keys.ssh_add))["add your SSH key"],
+                ".",
+            ]
+        ]
+
+    block.p["Import files from a remote server using rsync with the following 
command:"]
+
+    server_domain = session.app_host.split(":", 1)[0]
+    rsync_command = (
+        f"rsync -av -e 'ssh -p 2222' ${{YOUR_FILES}}/ "
+        
f"{session.uid}@{server_domain}:/{release.project.name}/{release.version}/"
+    )
+    block.pre(".bg-light.p-3.mb-3")[rsync_command]
+
+    _render_ssh_keys_info(block, user_ssh_keys)
+
+    return await template.blank(
+        f"Upload files to {release.short_display_name}",
+        content=block.collect(),
+    )
+
+
+def _render_ssh_keys_info(block: htm.Block, user_ssh_keys: 
Sequence[sql.SSHKey]) -> None:
+    known_cves_url = 
"https://github.com/google/security-research/security/advisories/GHSA-p5pg-x43v-mvqj";
+    block.p[
+        "The ATR server should be compatible with long obsolete versions of 
rsync, ",
+        "as long as you use the command as shown, but as of May 2025 the only 
rsync version line without ",
+        htm.a(href=known_cves_url)["known CVEs"],
+        " is 3.4.*. Your package manager may have backports.",
+    ]
+    new_issue_url = 
"https://github.com/apache/tooling-trusted-releases/issues/new?template=BLANK_ISSUE";
+    block.p[
+        "If you find that you receive errors from ATR when using rsync, please 
",
+        htm.a(href=new_issue_url)["open an issue"],
+        " and we will try our best to make ATR compatible.",
+    ]
+
+    key_count = len(user_ssh_keys)
+    if key_count == 1:
+        key = user_ssh_keys[0]
+        key_parts = key.key.split(" ", 2)
+        key_comment = key_parts[2].strip() if len(key_parts) > 2 else "key"
+        block.p[
+            "We have the SSH key ",
+            htm.a(
+                href=util.as_url(keys.keys, 
_anchor=f"ssh-key-{key.fingerprint}"),
+                title=key.fingerprint,
+            )[htm.code[key_comment]],
+            " on file for you. You can also ",
+            htm.a(href=util.as_url(keys.ssh_add))["add another SSH key"],
+            ".",
+        ]
+    elif key_count > 1:
+        block.p["We have the following SSH keys on file for you:"]
+        key_items = []
+        for key in user_ssh_keys:
+            key_parts = key.key.split(" ", 2)
+            key_comment = key_parts[2].strip() if len(key_parts) > 2 else "key"
+            key_items.append(
+                htm.li[
+                    htm.a(
+                        href=util.as_url(keys.keys, 
_anchor=f"ssh-key-{key.fingerprint}"),
+                        title=key.fingerprint,
+                    )[htm.code[key_comment]]
+                ]
+            )
+        block.append(htm.ul[*key_items])
+        block.p[
+            "You can also ",
+            htm.a(href=util.as_url(keys.ssh_add))["add another SSH key"],
+            ".",
+        ]
diff --git a/atr/post/draft.py b/atr/post/draft.py
index 8fd7502..9a98e80 100644
--- a/atr/post/draft.py
+++ b/atr/post/draft.py
@@ -241,51 +241,6 @@ async def sbomgen(session: web.Committer, project_name: 
str, version_name: str,
     )
 
 
[email protected]("/draft/svnload/<project_name>/<version_name>")
-async def svnload(session: web.Committer, project_name: str, version_name: 
str) -> web.WerkzeugResponse | str:
-    """Import files from SVN into a draft."""
-
-    await session.check_access(project_name)
-
-    form = await shared.upload.SvnImportForm.create_form()
-    if not await form.validate_on_submit():
-        for _field, errors in form.errors.items():
-            for error in errors:
-                await quart.flash(f"{error}", "error")
-        return await session.redirect(
-            get.upload.selected,
-            project_name=project_name,
-            version_name=version_name,
-        )
-
-    try:
-        async with storage.write(session) as write:
-            wacp = await write.as_project_committee_participant(project_name)
-            await wacp.release.import_from_svn(
-                project_name,
-                version_name,
-                str(form.svn_url.data),
-                str(form.revision.data),
-                str(form.target_subdirectory.data) if 
form.target_subdirectory.data else None,
-            )
-
-    except Exception:
-        log.exception("Error queueing SVN import task:")
-        return await session.redirect(
-            get.upload.selected,
-            error="Error queueing SVN import task",
-            project_name=project_name,
-            version_name=version_name,
-        )
-
-    return await session.redirect(
-        get.compose.selected,
-        success="SVN import task queued successfully",
-        project_name=project_name,
-        version_name=version_name,
-    )
-
-
 @post.committer("/draft/vote/preview/<project_name>/<version_name>")
 async def vote_preview(
     session: web.Committer, project_name: str, version_name: str
diff --git a/atr/post/upload.py b/atr/post/upload.py
index 5eb0f37..f8404e3 100644
--- a/atr/post/upload.py
+++ b/atr/post/upload.py
@@ -16,11 +16,85 @@
 # under the License.
 
 
+import quart
+
 import atr.blueprints.post as post
+import atr.get as get
+import atr.log as log
 import atr.shared as shared
+import atr.storage as storage
 import atr.web as web
 
 
 @post.committer("/upload/<project_name>/<version_name>")
-async def selected(session: web.Committer, project_name: str, version_name: 
str) -> web.WerkzeugResponse | str:
-    return await shared.upload.selected(session, project_name, version_name)
[email protected](shared.upload.UploadForm)
+async def selected(
+    session: web.Committer, upload_form: shared.upload.UploadForm, 
project_name: str, version_name: str
+) -> web.WerkzeugResponse:
+    await session.check_access(project_name)
+
+    match upload_form:
+        case shared.upload.AddFilesForm() as add_form:
+            return await _add_files(session, add_form, project_name, 
version_name)
+
+        case shared.upload.SvnImportForm() as svn_form:
+            return await _svn_import(session, svn_form, project_name, 
version_name)
+
+
+async def _add_files(
+    session: web.Committer, add_form: shared.upload.AddFilesForm, 
project_name: str, version_name: str
+) -> web.WerkzeugResponse:
+    try:
+        file_name = add_form.file_name
+        file_data = add_form.file_data
+
+        async with storage.write(session) as write:
+            wacp = await write.as_project_committee_participant(project_name)
+            number_of_files = await wacp.release.upload_files(project_name, 
version_name, file_name, file_data)
+
+        plural = number_of_files != 1
+        return await session.redirect(
+            get.compose.selected,
+            success=f"{number_of_files} file{'s' if plural else ''} added 
successfully",
+            project_name=project_name,
+            version_name=version_name,
+        )
+    except Exception as e:
+        log.exception("Error adding file:")
+        await quart.flash(f"Error adding file: {e!s}", "error")
+        return await session.redirect(
+            get.upload.selected,
+            project_name=project_name,
+            version_name=version_name,
+        )
+
+
+async def _svn_import(
+    session: web.Committer, svn_form: shared.upload.SvnImportForm, 
project_name: str, version_name: str
+) -> web.WerkzeugResponse:
+    try:
+        target_subdirectory = str(svn_form.target_subdirectory) if 
svn_form.target_subdirectory else None
+        async with storage.write(session) as write:
+            wacp = await write.as_project_committee_participant(project_name)
+            await wacp.release.import_from_svn(
+                project_name,
+                version_name,
+                str(svn_form.svn_url),
+                svn_form.revision,
+                target_subdirectory,
+            )
+
+        return await session.redirect(
+            get.compose.selected,
+            success="SVN import task queued successfully",
+            project_name=project_name,
+            version_name=version_name,
+        )
+    except Exception:
+        log.exception("Error queueing SVN import task:")
+        return await session.redirect(
+            get.upload.selected,
+            error="Error queueing SVN import task",
+            project_name=project_name,
+            version_name=version_name,
+        )
diff --git a/atr/shared/upload.py b/atr/shared/upload.py
index 75bfaa9..cf4abb2 100644
--- a/atr/shared/upload.py
+++ b/atr/shared/upload.py
@@ -15,91 +15,58 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import pathlib
+from typing import Annotated, Literal
 
-import quart
-import wtforms
+import pydantic
 
-import atr.db as db
-import atr.forms as forms
-import atr.get as get
-import atr.log as log
-import atr.storage as storage
-import atr.template as template
-import atr.web as web
+import atr.form as form
 
+type ADD_FILES = Literal["add_files"]
+type SVN_IMPORT = Literal["svn_import"]
 
-class AddFilesForm(forms.Typed):
-    """Form for adding files to a release candidate."""
 
-    file_name = forms.optional(
+class AddFilesForm(form.Form):
+    variant: ADD_FILES = form.value(ADD_FILES)
+    file_data: form.FileList = form.label("Files", "Select the files to 
upload.")
+    file_name: form.Filename = form.label(
         "File name",
-        description="Optional: Enter a file name to use when saving the "
-        "file in the release candidate. Only available when uploading a "
-        "single file.",
+        "Optional: Enter a file name to use when saving the file in the 
release candidate. "
+        "Only available when uploading a single file.",
     )
-    file_data = forms.files("Files", description="Select the files to upload.")
-    submit = forms.submit("Add files")
 
-    def validate_file_name(self, field: wtforms.Field) -> bool:
-        if field.data and len(self.file_data.data) > 1:
-            raise wtforms.validators.ValidationError("File name can only be 
used when uploading a single file")
-        return True
-
-
-class SvnImportForm(forms.Typed):
-    """Form for importing files from SVN into a draft."""
-
-    svn_url = forms.url("SVN URL", description="The URL to the public SVN 
directory.")
-    revision = forms.string(
-        "Revision", default="HEAD", description="Specify an SVN revision 
number or leave as HEAD for the latest."
+    @pydantic.field_validator("file_name", mode="after")
+    @classmethod
+    def validate_file_name_with_files(cls, value: form.Filename, info: 
pydantic.ValidationInfo) -> form.Filename:
+        # We can only get file_data if it comes before this field
+        # TODO: Figure out how to use a model validator but associate an error 
with a field
+        # https://github.com/pydantic/pydantic/issues/8092
+        # https://github.com/pydantic/pydantic/issues/9686
+        # https://github.com/pydantic/pydantic-core/pull/1413
+        file_data = info.data.get("file_data") or []
+        if value and (len(file_data) != 1):
+            raise ValueError("Filename can only be used when uploading a 
single file")
+        return value
+
+
+class SvnImportForm(form.Form):
+    variant: SVN_IMPORT = form.value(SVN_IMPORT)
+    svn_url: form.URL = form.label(
+        "SVN URL",
+        "The HTTP or HTTPS URL to the public SVN directory.",
+        widget=form.Widget.URL,
     )
-    target_subdirectory = forms.string(
-        "Target subdirectory", description="Optional: Subdirectory to place 
imported files, defaulting to the root."
+    revision: str = form.label(
+        "Revision",
+        "Specify an SVN revision number or leave as HEAD for the latest.",
+        default="HEAD",
+    )
+    target_subdirectory: form.Filename = form.label(
+        "Target subdirectory",
+        "Optional: Subdirectory to place imported files, defaulting to the 
root.",
     )
-    submit = forms.submit("Queue SVN import task")
-
-
-async def selected(session: web.Committer, project_name: str, version_name: 
str) -> web.WerkzeugResponse | str:
-    """Show a page to allow the user to add files to a candidate draft."""
-    await session.check_access(project_name)
-
-    form = await AddFilesForm.create_form()
-    if await form.validate_on_submit():
-        try:
-            file_name = None
-            if isinstance(form.file_name.data, str) and form.file_name.data:
-                file_name = pathlib.Path(form.file_name.data)
-            file_data = form.file_data.data
-
-            async with storage.write(session) as write:
-                wacp = await 
write.as_project_committee_participant(project_name)
-                number_of_files = await 
wacp.release.upload_files(project_name, version_name, file_name, file_data)
-            return await session.redirect(
-                get.compose.selected,
-                success=f"{number_of_files} file{'' if number_of_files == 1 
else 's'} added successfully",
-                project_name=project_name,
-                version_name=version_name,
-            )
-        except Exception as e:
-            log.exception("Error adding file:")
-            await quart.flash(f"Error adding file: {e!s}", "error")
-
-    svn_form = await SvnImportForm.create_form()
 
-    async with db.session() as data:
-        release = await session.release(project_name, version_name, data=data)
-        user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
 
-    return await template.render(
-        "upload-selected.html",
-        asf_id=session.uid,
-        server_domain=session.app_host.split(":", 1)[0],
-        server_host=session.app_host,
-        release=release,
-        project_name=project_name,
-        version_name=version_name,
-        form=form,
-        svn_form=svn_form,
-        user_ssh_keys=user_ssh_keys,
-    )
+type UploadForm = Annotated[
+    AddFilesForm | SvnImportForm,
+    form.DISCRIMINATOR,
+]
diff --git a/atr/templates/upload-selected.html 
b/atr/templates/upload-selected.html
deleted file mode 100644
index ed9cc83..0000000
--- a/atr/templates/upload-selected.html
+++ /dev/null
@@ -1,136 +0,0 @@
-{% extends "layouts/base.html" %}
-
-{% block title %}
-  Upload files to {{ release.short_display_name }} ~ ATR
-{% endblock title %}
-
-{% block description %}
-  Add files to a release candidate.
-{% endblock description %}
-
-{% block content %}
-  <p class="d-flex justify-content-between align-items-center">
-    <a href="{{ as_url(get.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
-       class="atr-back-link">← Back to Compose {{ release.short_display_name 
}}</a>
-    <span>
-      <strong class="atr-phase-one atr-phase-symbol">①</strong>
-      <span class="atr-phase-one atr-phase-label">COMPOSE</span>
-      <span class="atr-phase-arrow">→</span>
-      <span class="atr-phase-symbol-other">②</span>
-      <span class="atr-phase-arrow">→</span>
-      <span class="atr-phase-symbol-other">③</span>
-    </span>
-  </p>
-
-  <h1>
-    Upload to <strong>{{ release.project.short_display_name }}</strong> <em>{{ 
release.version }}</em>
-  </h1>
-
-  <p>
-    <a href="#file-upload" class="btn btn-outline-primary me-2">Use the 
browser</a>
-    <a href="#svn-upload" class="btn btn-outline-primary me-2">Use SVN</a>
-    <a href="#rsync-upload" class="btn btn-outline-primary">Use rsync</a>
-  </p>
-
-  <h2 id="file-upload">File upload</h2>
-  <p>Use this form to add files to this candidate draft.</p>
-
-  {{ forms.errors_summary(form) }}
-  <form method="post"
-        enctype="multipart/form-data"
-        class="atr-canary py-4 px-5"
-        novalidate>
-    {{ form.hidden_tag() }}
-
-    <div class="mb-3 pb-3 row border-bottom">
-      {{ forms.label(form.file_data, col="sm3") }}
-      <div class="col-sm-8">
-        {{ forms.widget(form.file_data) }}
-        {{ forms.errors(form.file_data) }}
-        {{ forms.description(form.file_data) }}
-      </div>
-    </div>
-
-    <div class="mb-3 pb-3 row border-bottom">
-      {{ forms.label(form.file_name, col="sm3") }}
-      <div class="col-sm-8">
-        {{ forms.widget(form.file_name) }}
-        {{ forms.errors(form.file_name) }}
-        {{ forms.description(form.file_name) }}
-      </div>
-    </div>
-
-    <div class="row">
-      <div class="col-sm-9 offset-sm-3">{{ form.submit(class_="btn btn-primary 
mt-3") }}</div>
-    </div>
-  </form>
-
-  <h2 id="svn-upload">SVN upload</h2>
-  <p>Import files from a publicly readable Subversion repository URL into this 
draft.</p>
-  <p>
-    The import will be processed in the background using the <code>svn 
export</code> command.
-    You can monitor progress on the <em>Evaluate files</em> page for this 
draft once the task is queued.
-  </p>
-
-  {{ forms.errors_summary(svn_form) }}
-  <div class="row">
-    <div class="col-md-8 w-100">
-      <form action="{{ as_url(post.draft.svnload, project_name=project_name, 
version_name=version_name) }}"
-            method="post"
-            novalidate
-            class="atr-canary py-4 px-5">
-        {{ svn_form.hidden_tag() }}
-
-        <div class="mb-3 pb-3 row border-bottom">
-          {{ forms.label(svn_form.svn_url, col="sm3") }}
-          <div class="col-sm-9">
-            {{ forms.widget(svn_form.svn_url) }}
-            {{ forms.errors(svn_form.svn_url) }}
-            {{ forms.description(svn_form.svn_url) }}
-          </div>
-        </div>
-
-        <div class="mb-3 pb-3 row border-bottom">
-          {{ forms.label(svn_form.revision, col="sm3") }}
-          <div class="col-sm-9">
-            {{ forms.widget(svn_form.revision) }}
-            {{ forms.errors(svn_form.revision) }}
-            {{ forms.description(svn_form.revision) }}
-          </div>
-        </div>
-
-        <div class="mb-3 pb-3 row border-bottom">
-          {{ forms.label(svn_form.target_subdirectory, col="sm3") }}
-          <div class="col-sm-9">
-            {{ forms.widget(svn_form.target_subdirectory) }}
-            {{ forms.errors(svn_form.target_subdirectory) }}
-            {{ forms.description(svn_form.target_subdirectory) }}
-          </div>
-        </div>
-
-        <div class="row">
-          <div class="col-sm-9 offset-sm-3">{{ svn_form.submit(class_="btn 
btn-primary mt-3") }}</div>
-        </div>
-      </form>
-    </div>
-  </div>
-
-  <h2 id="rsync-upload">Rsync upload</h2>
-  {% set key_count = user_ssh_keys|length %}
-  {% if key_count == 0 %}
-    <div class="alert alert-warning">
-      <p class="mb-0">
-        We have no SSH keys on file for you, so you cannot yet use this 
command. Please <a href="{{ as_url(get.keys.ssh_add) }}">add your SSH key</a>.
-      </p>
-    </div>
-  {% endif %}
-  <p>Import files from a remote server using rsync with the following 
command:</p>
-  <!--
-  TODO: Add a button to copy the command to the clipboard
-  -->
-  <pre class="bg-light p-3 mb-3">
-rsync -av -e 'ssh -p 2222' ${YOUR_FILES}/ {{ asf_id }}@{{ server_domain }}:/{{ 
release.project.name }}/{{ release.version }}/
-</pre>
-  {% include "user-ssh-keys.html" %}
-
-{% endblock content %}
diff --git a/playwright/test.py b/playwright/test.py
index 1c2f01b..1443630 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -146,7 +146,7 @@ def lifecycle_03_add_file(page: sync_api.Page, credentials: 
Credentials, version
     file_input_locator.set_input_files("/run/tests/example.txt")
 
     logging.info("Locating and activating the add files button")
-    submit_button_locator = page.locator('input[type="submit"][value="Add 
files"]')
+    submit_button_locator = page.get_by_role("button", name="Add files")
     sync_api.expect(submit_button_locator).to_be_enabled()
     submit_button_locator.click()
 


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

Reply via email to