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 597f41b Make the form to start a release more type safe
597f41b is described below
commit 597f41b633ea00934f99ab46819f6fd9c84c4ce5
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Nov 10 20:01:01 2025 +0000
Make the form to start a release more type safe
---
atr/get/start.py | 90 +++++++++++++++++++++++++++++++++++++--
atr/post/start.py | 30 +++++++++++--
atr/shared/start.py | 69 ++++++------------------------
atr/templates/start-selected.html | 79 ----------------------------------
playwright/test.py | 2 +-
5 files changed, 127 insertions(+), 143 deletions(-)
diff --git a/atr/get/start.py b/atr/get/start.py
index 69b7f68..8238eb6 100644
--- a/atr/get/start.py
+++ b/atr/get/start.py
@@ -15,13 +15,97 @@
# specific language governing permissions and limitations
# under the License.
+import asfquart.base as base
import atr.blueprints.get as get
+import atr.db as db
+import atr.db.interaction as interaction
+import atr.form as form
+import atr.get.root as root
+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("/start/<project_name>")
-async def selected(session: web.Committer, project_name: str) ->
web.WerkzeugResponse | str:
- """Allow the user to start a new release draft, or handle its
submission."""
- return await shared.start.selected(session, project_name)
+async def selected(session: web.Committer, project_name: str) -> str:
+ await session.check_access(project_name)
+
+ async with db.session() as data:
+ project = await data.project(name=project_name,
status=sql.ProjectStatus.ACTIVE).demand(
+ base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
+ )
+
+ releases = await interaction.all_releases(project)
+ content = await _render_page(project, releases)
+ return await template.blank(
+ title=f"Start a new release for {project.display_name}",
+ content=content,
+ )
+
+
+async def _render_page(project: sql.Project, releases: list[sql.Release]) ->
htm.Element:
+ page = htm.Block()
+
+ page.h1[f"Start a new release for {project.display_name}"]
+ page.p[
+ "Starting a new release creates a ",
+ htm.strong["release candidate draft"],
+ ". You can then add files to this draft before promoting it for
voting.",
+ ]
+ form.render_block(
+ page,
+ model_cls=shared.start.StartReleaseForm,
+ form_classes=".atr-canary.py-4.px-5.border.rounded",
+ submit_classes="btn-primary btn-lg",
+ submit_label="Start new release",
+ cancel_url=util.as_url(root.index),
+ defaults={"project_name": project.name},
+ )
+ if releases:
+ page.h2(".mt-5")["Existing releases"]
+ with page.block(htm.div, classes=".row") as row:
+ with row.block(htm.div, classes=".col-12") as col:
+ with col.block(htm.ul, classes=".list-unstyled.row.g-3") as ul:
+ _existing_releases(ul, releases)
+
+ return page.collect()
+
+
+def _existing_releases(ul: htm.Block, releases: list[sql.Release],
max_revisions: int = 18) -> None:
+ for i, release in enumerate(releases):
+ if i >= max_revisions:
+ break
+
+ phase_symbol = _get_phase_symbol(release.phase)
+ ul.li(".col-6.col-sm-4.col-md-3.col-lg-2")[
+ htm.div(".text-nowrap")[
+ htm.span(class_="atr-phase-symbol fs-6")[phase_symbol],
+ " ",
+ release.version,
+ ]
+ ]
+
+ if len(releases) > max_revisions:
+ ul.li(".col-6.col-sm-4.col-md-3.col-lg-2")[
+ htm.div(".text-center")[
+ htm.strong["..."],
+ " ",
+ htm.span(".text-muted.ms-1")[f"{len(releases) - max_revisions}
more"],
+ ]
+ ]
+
+
+def _get_phase_symbol(phase: sql.ReleasePhase) -> str:
+ match phase:
+ case sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+ return "①"
+ case sql.ReleasePhase.RELEASE_CANDIDATE:
+ return "②"
+ case sql.ReleasePhase.RELEASE_PREVIEW:
+ return "③"
+ case sql.ReleasePhase.RELEASE:
+ return "Ⓡ"
diff --git a/atr/post/start.py b/atr/post/start.py
index bb3aeb8..ea3e52c 100644
--- a/atr/post/start.py
+++ b/atr/post/start.py
@@ -15,13 +15,37 @@
# specific language governing permissions and limitations
# under the License.
+import asfquart.base as base
+import quart
import atr.blueprints.post as post
+import atr.get as get
import atr.shared as shared
+import atr.storage as storage
import atr.web as web
@post.committer("/start/<project_name>")
-async def selected(session: web.Committer, project_name: str) ->
web.WerkzeugResponse | str:
- """Allow the user to start a new release draft, or handle its
submission."""
- return await shared.start.selected(session, project_name)
[email protected](shared.start.StartReleaseForm)
+async def selected(
+ session: web.Committer, start_release_form: shared.start.StartReleaseForm,
project_name: str
+) -> web.WerkzeugResponse:
+ await session.check_access(project_name)
+
+ try:
+ async with storage.write(session) as write:
+ wacp = await write.as_project_committee_participant(project_name)
+ new_release, _project = await wacp.release.start(
+ project_name,
+ start_release_form.version_name,
+ )
+
+ return await session.redirect(
+ get.compose.selected,
+ project_name=project_name,
+ version_name=new_release.version,
+ success="Release candidate draft created successfully",
+ )
+ except (web.FlashError, base.ASFQuartException) as e:
+ await quart.flash(str(e), "error")
+ return await session.redirect(get.start.selected,
project_name=project_name)
diff --git a/atr/shared/start.py b/atr/shared/start.py
index 8566444..061a0d6 100644
--- a/atr/shared/start.py
+++ b/atr/shared/start.py
@@ -15,66 +15,21 @@
# specific language governing permissions and limitations
# under the License.
+import pydantic
-import asfquart.base as base
-import quart
+import atr.form as form
+import atr.util as util
-import atr.db as db
-import atr.db.interaction as interaction
-import atr.forms as forms
-import atr.get as get
-import atr.models.sql as sql
-import atr.storage as storage
-import atr.template as template
-import atr.web as web
-
-class StartReleaseForm(forms.Typed):
- project_name = forms.hidden()
- version_name = forms.string(
+class StartReleaseForm(form.Form):
+ version_name: str = form.label(
"Version",
- placeholder="Examples: 1.2.3 or 2.5-M1",
- description="Enter the version string for this new release.",
- )
- submit = forms.submit("Start new release")
-
-
-async def selected(session: web.Committer, project_name: str) ->
web.WerkzeugResponse | str:
- """Allow the user to start a new release draft, or handle its
submission."""
- await session.check_access(project_name)
-
- async with db.session() as data:
- project = await data.project(name=project_name,
status=sql.ProjectStatus.ACTIVE).demand(
- base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
- )
-
- form = await StartReleaseForm.create_form(
- data=await quart.request.form if (quart.request.method == "POST") else
None
+ "Enter the version string for this new release. Examples: 1.2.3 or
2.5-M1",
)
- if (quart.request.method == "GET") or (not form.project_name.data):
- form.project_name.data = project_name
-
- if (quart.request.method == "POST") and (await form.validate_on_submit()):
- try:
- project_name = str(form.project_name.data)
- version = str(form.version_name.data)
- # We already have the project, so we only need to get the new
release
- async with storage.write(session) as write:
- wacp = await
write.as_project_committee_participant(project_name)
- new_release, _project = await wacp.release.start(project_name,
version)
- # Redirect to the new draft's overview page on success
- return await session.redirect(
- get.compose.selected,
- project_name=project.name,
- version_name=new_release.version,
- success="Release candidate draft created successfully",
- )
- except (web.FlashError, base.ASFQuartException) as e:
- # Flash the error and let the code fall through to render the
template below
- await quart.flash(str(e), "error")
-
- # Get all releases for the project
- releases = await interaction.all_releases(project)
- # Render the template for GET requests or POST requests with validation
errors
- return await template.render("start-selected.html", project=project,
form=form, releases=releases)
+ @pydantic.field_validator("version_name", mode="after")
+ @classmethod
+ def validate_version_name(cls, value: str) -> str:
+ if error := util.version_name_error(value):
+ raise ValueError(error)
+ return value
diff --git a/atr/templates/start-selected.html
b/atr/templates/start-selected.html
deleted file mode 100644
index 9b0d0af..0000000
--- a/atr/templates/start-selected.html
+++ /dev/null
@@ -1,79 +0,0 @@
-{% extends "layouts/base.html" %}
-
-{% block title %}
- Start a new release for {{ project.display_name }} ~ ATR
-{% endblock title %}
-
-{% block content %}
- <h1>Start a new release</h1>
- <p>
- Starting a new release creates a <strong>release candidate draft</strong>.
You can then add files to this draft before promoting it for voting.
- </p>
-
- {{ forms.errors_summary(form) }}
-
- <form method="post"
- action="{{ as_url(post.start.selected, project_name=project.name) }}"
- enctype="multipart/form-data"
- class="atr-canary py-4 px-5 border rounded"
- novalidate>
- {{ form.hidden_tag() }}
-
- <div class="mb-4">
- <p class="fs-5">
- <label class="form-label fs-5">Project:</label>
- <span class="fw-semibold">{{ project.display_name }}</span>
- </p>
- </div>
-
- <div class="mb-3">
- {{ forms.label(form.version_name) }}
- {{ forms.widget(form.version_name, classes="form-control
form-control-lg", id=form.version_name.id) }}
- {{ forms.errors(form.version_name) }}
- {{ forms.description(form.version_name) }}
- </div>
-
- <div class="mt-4">
- {{ form.submit(class_="btn btn-primary btn-lg") }}
- <a href="{{ as_url(get.root.index) }}" class="btn btn-link
ms-2">Cancel</a>
- </div>
- </form>
-
- {% if releases %}
- {% set max_revisions = 18 %}
- <div class="mt-5">
- <h2>Existing releases</h2>
- <div class="row">
- <div class="col-12">
- <ul class="list-unstyled row g-3">
- {% for release in releases[:max_revisions] %}
- <li class="col-6 col-sm-4 col-md-3 col-lg-2">
- <div class="text-nowrap">
- {% if release.phase.value == "release_candidate_draft" %}
- <span class="atr-phase-one atr-phase-symbol fs-6">{{ "①"
}}</span>
- {% elif release.phase.value == "release_candidate" %}
- <span class="atr-phase-two atr-phase-symbol fs-6">{{ "②"
}}</span>
- {% elif release.phase.value == "release_preview" %}
- <span class="atr-phase-three atr-phase-symbol fs-6">{{ "③"
}}</span>
- {% elif release.phase.value == "release" %}
- <span class="atr-phase-symbol fs-6">{{ "Ⓡ" }}</span>
- {% endif %}
- {{ release.version }}
- </div>
- </li>
- {% endfor %}
- {% if releases|length > max_revisions %}
- <li class="col-6 col-sm-4 col-md-3 col-lg-2">
- <div class="text-center">
- <strong>...</strong>
- <span class="text-muted ms-1">{{ releases|length -
max_revisions }} more</span>
- </div>
- </li>
- {% endif %}
- </ul>
- </div>
- </div>
- </div>
- {% endif %}
-
-{% endblock content %}
diff --git a/playwright/test.py b/playwright/test.py
index 1443630..26c6734 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -114,7 +114,7 @@ def lifecycle_01_add_draft(page: sync_api.Page,
credentials: Credentials, versio
version_name_locator.fill(version_name)
logging.info("Submitting the start new release form")
- submit_button_locator = page.locator('input[type="submit"][value="Start
new release"]')
+ submit_button_locator = page.get_by_role("button", name="Start new
release")
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]