This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/main by this push:
new cb99fcb Make the forms to upload files more type safe
cb99fcb is described below
commit cb99fcbc24137eb658a1d7b85b4626380646dd16
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 ----------------------------------
6 files changed, 266 insertions(+), 263 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 %}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]