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]
