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-release.git
The following commit(s) were added to refs/heads/main by this push:
new 839dde1 Move the code to start a new release to the storage interface
839dde1 is described below
commit 839dde12262761404a6557e04ec386c17e10ab3f
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Sep 10 18:57:11 2025 +0100
Move the code to start a new release to the storage interface
---
atr/blueprints/api/api.py | 14 ++--
atr/routes/start.py | 74 +++------------------
atr/storage/__init__.py | 27 ++++++++
atr/storage/writers/__init__.py | 2 +
atr/storage/writers/release.py | 140 ++++++++++++++++++++++++++++++++++++++++
5 files changed, 184 insertions(+), 73 deletions(-)
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 68cb927..ea54451 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -38,8 +38,6 @@ import atr.jwtoken as jwtoken
import atr.models as models
import atr.models.sql as sql
import atr.revision as revision
-import atr.routes as routes
-import atr.routes.start as start
import atr.storage as storage
import atr.storage.outcome as outcome
import atr.storage.types as types
@@ -773,13 +771,11 @@ async def release_create(data:
models.api.ReleaseCreateArgs) -> DictResponse:
asf_uid = _jwt_asf_uid()
try:
- release, _project = await start.create_release_draft(
- project_name=data.project,
- version=data.version,
- asf_uid=asf_uid,
- )
- except routes.FlashError as exc:
- raise exceptions.BadRequest(str(exc))
+ async with storage.write(asf_uid) as write:
+ wacp = await write.as_project_committee_participant(data.project)
+ release, _project = await wacp.release.start(data.project,
data.version)
+ except storage.AccessError as e:
+ raise exceptions.BadRequest(str(e))
return models.api.ReleaseCreateResults(
endpoint="/release/create",
diff --git a/atr/routes/start.py b/atr/routes/start.py
index 70e452a..a794a14 100644
--- a/atr/routes/start.py
+++ b/atr/routes/start.py
@@ -15,7 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-import datetime
import asfquart.base as base
import quart
@@ -25,11 +24,10 @@ import atr.db as db
import atr.db.interaction as interaction
import atr.forms as forms
import atr.models.sql as sql
-import atr.revision as revision
import atr.routes as routes
import atr.routes.compose as compose
+import atr.storage as storage
import atr.template as template
-import atr.util as util
class StartReleaseForm(forms.Typed):
@@ -42,59 +40,6 @@ class StartReleaseForm(forms.Typed):
submit = forms.submit("Start new release")
-async def create_release_draft(project_name: str, version: str, asf_uid: str)
-> tuple[sql.Release, sql.Project]:
- """Creates the initial release draft record and revision directory."""
- # Get the project from the project name
- async with db.session() as data:
- async with data.begin():
- project = await data.project(name=project_name,
status=sql.ProjectStatus.ACTIVE, _committee=True).get()
- if not project:
- raise routes.FlashError(f"Project {project_name} not found")
-
- # TODO: Temporarily allow committers to start drafts
- if project.committee is None or (
- asf_uid not in project.committee.committee_members and asf_uid
not in project.committee.committers
- ):
- raise base.ASFQuartException(
- f"You must be a member or committer for the
{project.display_name}"
- " committee to start a release draft.",
- errorcode=403,
- )
-
- # TODO: Consider using Release.revision instead of ./latest
- async with db.session() as data:
- async with data.begin():
- # Check whether the release already exists
- if release := await data.release(project_name=project.name,
version=version).get():
- if release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
- raise routes.FlashError(f"A draft for {project_name}
{version} already exists.")
- else:
- raise routes.FlashError(
- f"A release ({release.phase.value}) for {project_name}
{version} already exists."
- )
-
- # Validate the version name
- # TODO: We should check that it's bigger than the current version
- if version_name_error := util.version_name_error(version):
- raise routes.FlashError(f'Invalid version name "{version}":
{version_name_error}')
-
- release = sql.Release(
- phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
- project_name=project.name,
- project=project,
- version=version,
- created=datetime.datetime.now(datetime.UTC),
- )
- data.add(release)
-
- await data.refresh(release)
-
- description = "Creation of empty release candidate draft through web
interface"
- async with revision.create_and_manage(project_name, version, asf_uid,
description=description) as _creating:
- pass
- return release, project
-
-
@routes.committer("/start/<project_name>", methods=["GET", "POST"])
async def selected(session: routes.CommitterSession, project_name: str) ->
response.Response | str:
"""Allow the user to start a new release draft, or handle its
submission."""
@@ -105,19 +50,20 @@ async def selected(session: routes.CommitterSession,
project_name: str) -> respo
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)
+ form = await StartReleaseForm.create_form(
+ data=await quart.request.form if (quart.request.method == "POST") else
None
+ )
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:
- # TODO: Move the helper somewhere else
- # We already have the project, so we only need to get [0]
- new_release = (
- await create_release_draft(
- project_name=str(form.project_name.data),
version=str(form.version_name.data), asf_uid=session.uid
- )
- )[0]
+ 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.uid) 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(
compose.selected,
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index db5b052..cd3ad0e 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -138,6 +138,7 @@ class WriteAsGeneralPublic(WriteAs):
self.announce = writers.announce.GeneralPublic(write, self, data)
self.checks = writers.checks.GeneralPublic(write, self, data)
self.keys = writers.keys.GeneralPublic(write, self, data)
+ self.release = writers.release.GeneralPublic(write, self, data)
self.ssh = writers.ssh.GeneralPublic(write, self, data)
self.tokens = writers.tokens.GeneralPublic(write, self, data)
self.vote = writers.vote.GeneralPublic(write, self, data)
@@ -150,6 +151,7 @@ class WriteAsFoundationCommitter(WriteAsGeneralPublic):
self.announce = writers.announce.FoundationCommitter(write, self, data)
self.checks = writers.checks.FoundationCommitter(write, self, data)
self.keys = writers.keys.FoundationCommitter(write, self, data)
+ self.release = writers.release.FoundationCommitter(write, self, data)
self.ssh = writers.ssh.FoundationCommitter(write, self, data)
self.tokens = writers.tokens.FoundationCommitter(write, self, data)
self.vote = writers.vote.FoundationCommitter(write, self, data)
@@ -168,6 +170,7 @@ class
WriteAsCommitteeParticipant(WriteAsFoundationCommitter):
self.announce = writers.announce.CommitteeParticipant(write, self,
data, committee_name)
self.checks = writers.checks.CommitteeParticipant(write, self, data,
committee_name)
self.keys = writers.keys.CommitteeParticipant(write, self, data,
committee_name)
+ self.release = writers.release.CommitteeParticipant(write, self, data,
committee_name)
self.ssh = writers.ssh.CommitteeParticipant(write, self, data,
committee_name)
self.tokens = writers.tokens.CommitteeParticipant(write, self, data,
committee_name)
self.vote = writers.vote.CommitteeParticipant(write, self, data,
committee_name)
@@ -191,6 +194,7 @@ class WriteAsCommitteeMember(WriteAsCommitteeParticipant):
self.checks = writers.checks.CommitteeMember(write, self, data,
committee_name)
self.distributions = writers.distributions.CommitteeMember(write,
self, data, committee_name)
self.keys = writers.keys.CommitteeMember(write, self, data,
committee_name)
+ self.release = writers.release.CommitteeMember(write, self, data,
committee_name)
self.ssh = writers.ssh.CommitteeMember(write, self, data,
committee_name)
self.tokens = writers.tokens.CommitteeMember(write, self, data,
committee_name)
self.vote = writers.vote.CommitteeMember(write, self, data,
committee_name)
@@ -213,6 +217,7 @@ class WriteAsFoundationAdmin(WriteAsCommitteeMember):
# self.announce = writers.announce.FoundationAdmin(write, self, data,
committee_name)
# self.checks = writers.checks.FoundationAdmin(write, self, data,
committee_name)
self.keys = writers.keys.FoundationAdmin(write, self, data,
committee_name)
+ # self.release = writers.release.FoundationAdmin(write, self, data,
committee_name)
# self.ssh = writers.ssh.FoundationAdmin(write, self, data,
committee_name)
# self.tokens = writers.tokens.FoundationAdmin(write, self, data,
committee_name)
# self.vote = writers.vote.FoundationAdmin(write, self, data,
committee_name)
@@ -319,6 +324,28 @@ class Write:
return outcome.Error(e)
return outcome.Result(wacm)
+ async def as_project_committee_participant(self, project_name: str) ->
WriteAsCommitteeParticipant:
+ write_as_outcome = await
self.as_project_committee_participant_outcome(project_name)
+ return write_as_outcome.result_or_raise()
+
+ async def as_project_committee_participant_outcome(
+ self, project_name: str
+ ) -> outcome.Outcome[WriteAsCommitteeParticipant]:
+ project = await self.__data.project(project_name,
_committee=True).demand(
+ AccessError(f"Project not found: {project_name}")
+ )
+ if project.committee is None:
+ return outcome.Error(AccessError("No committee found for project"))
+ if self.__authorisation.asf_uid is None:
+ return outcome.Error(AccessError("No ASF UID"))
+ if not self.__authorisation.is_participant_of(project.committee.name):
+ return outcome.Error(AccessError(f"Not a participant of
{project.committee.name}"))
+ try:
+ wacp = WriteAsCommitteeParticipant(self, self.__data,
project.committee.name)
+ except Exception as e:
+ return outcome.Error(e)
+ return outcome.Result(wacp)
+
@property
def member_of(self) -> frozenset[str]:
return self.__authorisation.member_of()
diff --git a/atr/storage/writers/__init__.py b/atr/storage/writers/__init__.py
index 3ec7225..0d8b5b1 100644
--- a/atr/storage/writers/__init__.py
+++ b/atr/storage/writers/__init__.py
@@ -19,6 +19,7 @@ import atr.storage.writers.announce as announce
import atr.storage.writers.checks as checks
import atr.storage.writers.distributions as distributions
import atr.storage.writers.keys as keys
+import atr.storage.writers.release as release
import atr.storage.writers.ssh as ssh
import atr.storage.writers.tokens as tokens
import atr.storage.writers.vote as vote
@@ -28,6 +29,7 @@ __all__ = [
"checks",
"distributions",
"keys",
+ "release",
"ssh",
"tokens",
"vote",
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
new file mode 100644
index 0000000..1145484
--- /dev/null
+++ b/atr/storage/writers/release.py
@@ -0,0 +1,140 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Removing this will cause circular imports
+from __future__ import annotations
+
+import datetime
+
+import atr.db as db
+import atr.models.sql as sql
+import atr.revision as revision
+import atr.storage as storage
+import atr.util as util
+
+
+class GeneralPublic:
+ def __init__(
+ self,
+ write: storage.Write,
+ write_as: storage.WriteAsGeneralPublic,
+ data: db.Session,
+ ) -> None:
+ self.__write = write
+ self.__write_as = write_as
+ self.__data = data
+ self.__asf_uid = write.authorisation.asf_uid
+
+
+class FoundationCommitter(GeneralPublic):
+ def __init__(self, write: storage.Write, write_as:
storage.WriteAsFoundationCommitter, data: db.Session) -> None:
+ super().__init__(write, write_as, data)
+ self.__write = write
+ self.__write_as = write_as
+ self.__data = data
+ asf_uid = write.authorisation.asf_uid
+ if asf_uid is None:
+ raise storage.AccessError("No ASF UID")
+ self.__asf_uid = asf_uid
+
+
+class CommitteeParticipant(FoundationCommitter):
+ def __init__(
+ self,
+ write: storage.Write,
+ write_as: storage.WriteAsCommitteeParticipant,
+ data: db.Session,
+ committee_name: str,
+ ) -> None:
+ super().__init__(write, write_as, data)
+ self.__write = write
+ self.__write_as = write_as
+ self.__data = data
+ asf_uid = write.authorisation.asf_uid
+ if asf_uid is None:
+ raise storage.AccessError("No ASF UID")
+ self.__asf_uid = asf_uid
+ self.__committee_name = committee_name
+
+ async def start(self, project_name: str, version: str) ->
tuple[sql.Release, sql.Project]:
+ """Creates the initial release draft record and revision directory."""
+ # Get the project from the project name
+ project = await self.__data.project(name=project_name,
status=sql.ProjectStatus.ACTIVE, _committee=True).get()
+ if not project:
+ raise storage.AccessError(f"Project {project_name} not found")
+
+ # TODO: Temporarily allow committers to start drafts
+ if project.committee is None or (
+ self.__asf_uid not in project.committee.committee_members
+ and self.__asf_uid not in project.committee.committers
+ ):
+ raise storage.AccessError(
+ f"You must be a member or committer for the
{project.display_name} committee to start a release draft."
+ )
+
+ # TODO: Consider using Release.revision instead of ./latest
+ # Check whether the release already exists
+ if release := await self.__data.release(project_name=project.name,
version=version).get():
+ if release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+ raise storage.AccessError(f"A draft for {project_name}
{version} already exists.")
+ else:
+ raise storage.AccessError(
+ f"A release ({release.phase.value}) for {project_name}
{version} already exists."
+ )
+
+ # Validate the version name
+ # TODO: We should check that it's bigger than the current version
+ # We have the packaging library as a dependency, but it is Python
specific
+ if version_name_error := util.version_name_error(version):
+ raise storage.AccessError(f'Invalid version name "{version}":
{version_name_error}')
+
+ release = sql.Release(
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ project_name=project.name,
+ project=project,
+ version=version,
+ created=datetime.datetime.now(datetime.UTC),
+ )
+ self.__data.add(release)
+ await self.__data.commit()
+ await self.__data.refresh(release)
+
+ description = "Creation of empty release candidate draft through web
interface"
+ async with revision.create_and_manage(
+ project_name, version, self.__asf_uid, description=description
+ ) as _creating:
+ pass
+ return release, project
+
+
+class CommitteeMember(CommitteeParticipant):
+ def __init__(
+ self,
+ write: storage.Write,
+ write_as: storage.WriteAsCommitteeMember,
+ data: db.Session,
+ committee_name: str,
+ ) -> None:
+ super().__init__(write, write_as, data, committee_name)
+ self.__write = write
+ self.__write_as = write_as
+ self.__data = data
+ asf_uid = write.authorisation.asf_uid
+ if asf_uid is None:
+ raise storage.AccessError("No ASF UID")
+ self.__asf_uid = asf_uid
+ self.__committee_name = committee_name
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]