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 e4aebed Move writes from the interaction module to the storage
interface
e4aebed is described below
commit e4aebedecb5b7e38a96f5e64c21b688db1cea1cd
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun Oct 12 13:28:10 2025 +0100
Move writes from the interaction module to the storage interface
---
atr/blueprints/api/api.py | 6 ++-
atr/db/interaction.py | 89 -------------------------------
atr/routes/vote.py | 4 +-
atr/routes/voting.py | 6 ++-
atr/storage/__init__.py | 14 +++++
atr/storage/writers/__init__.py | 2 +
atr/storage/writers/cache.py | 113 ++++++++++++++++++++++++++++++++++++++++
atr/storage/writers/release.py | 74 ++++++++++++++++++++++++++
atr/storage/writers/vote.py | 6 +--
playwright/test.py | 2 +-
10 files changed, 219 insertions(+), 97 deletions(-)
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index ba6415e..e672c70 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -1230,9 +1230,13 @@ async def vote_tabulate(data:
models.api.VoteTabulateArgs) -> DictResponse:
if latest_vote_task is None:
raise exceptions.NotFound("No vote task found")
task_mid = interaction.task_mid_get(latest_vote_task)
- archive_url = await interaction.task_archive_url_cached(task_mid)
+
+ async with storage.write() as write:
+ wagp = write.as_general_public()
+ archive_url = await wagp.cache.get_message_archive_url(task_mid)
if archive_url is None:
raise exceptions.NotFound("No archive URL found")
+
thread_id = archive_url.split("/")[-1]
committee = await tabulate.vote_committee(thread_id, release)
details = await tabulate.vote_details(committee, thread_id, release)
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 90dd784..3641583 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -16,7 +16,6 @@
# under the License.
import contextlib
-import datetime
import enum
from collections.abc import AsyncGenerator, Sequence
from typing import Any, Final
@@ -170,66 +169,6 @@ async def previews(project: sql.Project) ->
list[sql.Release]:
return await releases_by_phase(project, sql.ReleasePhase.RELEASE_PREVIEW)
-async def promote_release(
- data: db.Session,
- release_name: str,
- selected_revision_number: str,
- vote_manual: bool = False,
-) -> str | None:
- """Promote a release candidate draft to a new phase."""
- # TODO: Use session.release here
- release_for_pre_checks = await data.release(name=release_name,
_project=True).demand(
- InteractionError("Release candidate draft not found")
- )
- project_name = release_for_pre_checks.project.name
- version_name = release_for_pre_checks.version
-
- # Check for ongoing tasks
- ongoing_tasks = await tasks_ongoing(project_name, version_name,
selected_revision_number)
- if ongoing_tasks > 0:
- return "All checks must be completed before starting a vote"
-
- # Verify that it's in the correct phase
- # The atomic update below will also check this
- if release_for_pre_checks.phase !=
sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
- return "This release is not in the candidate draft phase"
-
- # Check that the revision number is the latest
- if release_for_pre_checks.latest_revision_number !=
selected_revision_number:
- return "The selected revision number does not match the latest
revision number"
-
- # Check that there is at least one file in the draft
- # This is why we require _project=True above
- file_count = await util.number_of_release_files(release_for_pre_checks)
- if file_count == 0:
- return "This candidate draft is empty, containing no files"
-
- # Promote it to RELEASE_CANDIDATE
- # NOTE: We previously allowed skipping phases, but removed that
functionality
- # We don't need a lock here because we use an atomic update
- via = sql.validate_instrumented_attribute
- stmt = (
- sqlmodel.update(sql.Release)
- .where(
- via(sql.Release.name) == release_name,
- via(sql.Release.phase) == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
- sql.latest_revision_number_query() == selected_revision_number,
- )
- .values(
- phase=sql.ReleasePhase.RELEASE_CANDIDATE,
- vote_started=datetime.datetime.now(datetime.UTC),
- vote_manual=vote_manual,
- )
- )
-
- result = await data.execute(stmt)
- if result.rowcount != 1:
- await data.rollback()
- return "A newer revision appeared, please refresh and try again."
- await data.commit()
- return None
-
-
async def release_latest_vote_task(release: sql.Release) -> sql.Task | None:
"""Find the most recent VOTE_INITIATE task for this release."""
disallowed_statuses = [sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE]
@@ -283,34 +222,6 @@ async def releases_in_progress(project: sql.Project) ->
list[sql.Release]:
return drafts + cands + prevs
-async def task_archive_url_cached(task_mid: str | None) -> str | None:
- if task_mid in _THREAD_URLS_FOR_DEVELOPMENT:
- return _THREAD_URLS_FOR_DEVELOPMENT[task_mid]
-
- if task_mid is None:
- return None
- if "@" not in task_mid:
- return None
-
- async with db.session() as data:
- url = await data.ns_text_get(
- "mid-url-cache",
- task_mid,
- )
- if url is not None:
- return url
-
- url = await util.task_archive_url(task_mid)
- if url is not None:
- await data.ns_text_set(
- "mid-url-cache",
- task_mid,
- url,
- )
-
- return url
-
-
def task_mid_get(latest_vote_task: sql.Task) -> str | None:
if util.is_dev_environment():
import atr.db.interaction as interaction
diff --git a/atr/routes/vote.py b/atr/routes/vote.py
index ace6331..634ecda 100644
--- a/atr/routes/vote.py
+++ b/atr/routes/vote.py
@@ -74,7 +74,9 @@ async def selected(session: route.CommitterSession,
project_name: str, version_n
# Move task_mid_get here?
task_mid = interaction.task_mid_get(latest_vote_task)
- archive_url = await interaction.task_archive_url_cached(task_mid)
+ async with storage.write() as write:
+ wagp = write.as_general_public()
+ archive_url = await wagp.cache.get_message_archive_url(task_mid)
# Special form for the [ Resolve vote ] button, to make it POST
hidden_form = await forms.Hidden.create_form()
diff --git a/atr/routes/voting.py b/atr/routes/voting.py
index ab020b9..b5c94c1 100644
--- a/atr/routes/voting.py
+++ b/atr/routes/voting.py
@@ -117,8 +117,10 @@ async def start_vote_manual(
session: route.CommitterSession,
data: db.Session,
) -> response.Response | str:
- # This verifies the state and sets the phase to RELEASE_CANDIDATE
- error = await interaction.promote_release(data, release.name,
selected_revision_number, vote_manual=True)
+ async with storage.write(session.uid) as write:
+ wacp = await
write.as_project_committee_participant(release.project_name)
+ # This verifies the state and sets the phase to RELEASE_CANDIDATE
+ error = await wacp.release.promote_to_candidate(release.name,
selected_revision_number, vote_manual=True)
if error:
return await session.redirect(root.index, error=error)
return await session.redirect(
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index 86b1dc4..a8cd78c 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -136,6 +136,7 @@ class Read:
class WriteAsGeneralPublic(WriteAs):
def __init__(self, write: Write, data: db.Session):
self.announce = writers.announce.GeneralPublic(write, self, data)
+ self.cache = writers.cache.GeneralPublic(write, self, data)
self.checks = writers.checks.GeneralPublic(write, self, data)
self.keys = writers.keys.GeneralPublic(write, self, data)
self.policy = writers.policy.GeneralPublic(write, self, data)
@@ -152,6 +153,7 @@ class WriteAsFoundationCommitter(WriteAsGeneralPublic):
# TODO: We need a definitive list of ASF UIDs
self.__asf_uid = write.authorisation.asf_uid
self.announce = writers.announce.FoundationCommitter(write, self, data)
+ self.cache = writers.cache.FoundationCommitter(write, self, data)
self.checks = writers.checks.FoundationCommitter(write, self, data)
self.keys = writers.keys.FoundationCommitter(write, self, data)
self.policy = writers.policy.FoundationCommitter(write, self, data)
@@ -174,6 +176,7 @@ class
WriteAsCommitteeParticipant(WriteAsFoundationCommitter):
self.__asf_uid = write.authorisation.asf_uid
self.__committee_name = committee_name
self.announce = writers.announce.CommitteeParticipant(write, self,
data, committee_name)
+ self.cache = writers.cache.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.policy = writers.policy.CommitteeParticipant(write, self, data,
committee_name)
@@ -200,6 +203,7 @@ class WriteAsCommitteeMember(WriteAsCommitteeParticipant):
self.__asf_uid = write.authorisation.asf_uid
self.__committee_name = committee_name
self.announce = writers.announce.CommitteeMember(write, self, data,
committee_name)
+ self.cache = writers.cache.CommitteeMember(write, self, data,
committee_name)
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)
@@ -308,6 +312,16 @@ class Write:
return outcome.Error(e)
return outcome.Result(wafa)
+ def as_general_public(self) -> WriteAsGeneralPublic:
+ return self.as_general_public_outcome().result_or_raise()
+
+ def as_general_public_outcome(self) ->
outcome.Outcome[WriteAsGeneralPublic]:
+ try:
+ wagp = WriteAsGeneralPublic(self, self.__data)
+ except Exception as e:
+ return outcome.Error(e)
+ return outcome.Result(wagp)
+
# async def as_key_owner(self) -> types.Outcome[WriteAsKeyOwner]:
# ...
diff --git a/atr/storage/writers/__init__.py b/atr/storage/writers/__init__.py
index 907ed19..829c1be 100644
--- a/atr/storage/writers/__init__.py
+++ b/atr/storage/writers/__init__.py
@@ -16,6 +16,7 @@
# under the License.
import atr.storage.writers.announce as announce
+import atr.storage.writers.cache as cache
import atr.storage.writers.checks as checks
import atr.storage.writers.distributions as distributions
import atr.storage.writers.keys as keys
@@ -29,6 +30,7 @@ import atr.storage.writers.vote as vote
__all__ = [
"announce",
+ "cache",
"checks",
"distributions",
"keys",
diff --git a/atr/storage/writers/cache.py b/atr/storage/writers/cache.py
new file mode 100644
index 0000000..d77811c
--- /dev/null
+++ b/atr/storage/writers/cache.py
@@ -0,0 +1,113 @@
+# 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.
+
+from __future__ import annotations
+
+import atr.db as db
+import atr.storage as storage
+import atr.util as util
+
+# TODO: This probably shouldn't be a cache.py module
+# We should name these modules by functionality, not by mechanism
+# But it's not clear where get_message_archive_url should go
+# Maybe tasks.py? messages.py?
+
+
+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
+
+ async def get_message_archive_url(self, task_mid: str | None) -> str |
None:
+ if task_mid is None:
+ return None
+ if "@" not in task_mid:
+ return None
+
+ url = await self.__data.ns_text_get(
+ "mid-url-cache",
+ task_mid,
+ )
+ if url is not None:
+ return url
+
+ url = await util.task_archive_url(task_mid)
+ if url is not None:
+ await self.__data.ns_text_set(
+ "mid-url-cache",
+ task_mid,
+ url,
+ )
+
+ return url
+
+
+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
+
+
+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
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 408b97a..96d6669 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -28,6 +28,8 @@ from typing import TYPE_CHECKING, Final
import aiofiles.os
import aioshutil
+import sqlalchemy
+import sqlmodel
import atr.analysis as analysis
import atr.db as db
@@ -262,6 +264,66 @@ class CommitteeParticipant(FoundationCommitter):
creation_error = str(creating.failed) if (creating.failed is not None)
else None
return creation_error, moved_files_names, skipped_files_names
+ async def promote_to_candidate(
+ self,
+ release_name: str,
+ selected_revision_number: str,
+ vote_manual: bool = False,
+ ) -> str | None:
+ """Promote a release candidate draft to a new phase."""
+ release_for_pre_checks = await self.__data.release(name=release_name,
_project=True).demand(
+ storage.AccessError("Release candidate draft not found")
+ )
+ project_name = release_for_pre_checks.project.name
+ version_name = release_for_pre_checks.version
+
+ # Check for ongoing tasks
+ ongoing_tasks = await self.__tasks_ongoing(project_name, version_name,
selected_revision_number)
+ if ongoing_tasks > 0:
+ return "All checks must be completed before starting a vote"
+
+ # Verify that it's in the correct phase
+ if release_for_pre_checks.phase !=
sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+ return "This release is not in the candidate draft phase"
+
+ # Check that the revision number is the latest
+ if release_for_pre_checks.latest_revision_number !=
selected_revision_number:
+ return "The selected revision number does not match the latest
revision number"
+
+ # Check that there is at least one file in the draft
+ file_count = await util.number_of_release_files(release_for_pre_checks)
+ if file_count == 0:
+ return "This candidate draft is empty, containing no files"
+
+ # Promote it to RELEASE_CANDIDATE
+ via = sql.validate_instrumented_attribute
+ stmt = (
+ sqlmodel.update(sql.Release)
+ .where(
+ via(sql.Release.name) == release_name,
+ via(sql.Release.phase) ==
sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ sql.latest_revision_number_query() == selected_revision_number,
+ )
+ .values(
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ vote_started=datetime.datetime.now(datetime.UTC),
+ vote_manual=vote_manual,
+ )
+ )
+
+ result = await self.__data.execute(stmt)
+ if result.rowcount != 1:
+ await self.__data.rollback()
+ return "A newer revision appeared, please refresh and try again."
+ await self.__data.commit()
+ self.__write_as.append_to_audit_log(
+ asf_uid=self.__asf_uid,
+ release_name=release_name,
+ selected_revision_number=selected_revision_number,
+ vote_manual=vote_manual,
+ )
+ return None
+
async def remove_rc_tags(self, project_name: str, version_name: str) ->
tuple[str | None, int, list[str]]:
description = "Remove RC tags from paths via web interface"
error_messages: list[str] = []
@@ -598,6 +660,18 @@ class CommitteeParticipant(FoundationCommitter):
if f == source_file_rel:
moved_files_names.append(f.name)
+ async def __tasks_ongoing(self, project_name: str, version_name: str,
revision_number: str | None = None) -> int:
+ tasks = sqlmodel.select(sqlalchemy.func.count()).select_from(sql.Task)
+ query = tasks.where(
+ sql.Task.project_name == project_name,
+ sql.Task.version_name == version_name,
+ sql.Task.revision_number
+ == (sql.RELEASE_LATEST_REVISION_NUMBER if (revision_number is
None) else revision_number),
+
sql.validate_instrumented_attribute(sql.Task.status).in_([sql.TaskStatus.QUEUED,
sql.TaskStatus.ACTIVE]),
+ )
+ result = await self.__data.execute(query)
+ return result.scalar_one()
+
class CommitteeMember(CommitteeParticipant):
def __init__(
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 6d1c373..d856b34 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -156,8 +156,8 @@ class CommitteeParticipant(FoundationCommitter):
if promote is True:
# This verifies the state and sets the phase to RELEASE_CANDIDATE
- error = await interaction.promote_release(
- self.__data, release.name, selected_revision_number,
vote_manual=False
+ error = await self.__write_as.release.promote_to_candidate(
+ release.name, selected_revision_number, vote_manual=False
)
if error:
raise storage.AccessError(error)
@@ -302,7 +302,7 @@ class CommitteeMember(CommitteeParticipant):
# Then we automatically start the Incubator PMC vote
# TODO: Note on the resolve vote page that resolving the Project
PPMC vote starts the Incubator PMC vote
task_mid = interaction.task_mid_get(latest_vote_task)
- archive_url = await interaction.task_archive_url_cached(task_mid)
+ archive_url = await
self.__write_as.cache.get_message_archive_url(task_mid)
if archive_url is None:
raise ValueError("No archive URL found for podling vote")
thread_id = archive_url.split("/")[-1]
diff --git a/playwright/test.py b/playwright/test.py
index 3023914..80e8b54 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -963,7 +963,7 @@ def test_ssh_01_add_key(page: sync_api.Page, credentials:
Credentials) -> None:
go_to_path(page, "/committees")
logging.info("Navigating to Your Public Keys page")
- page.locator('a[href="/keys"]:has-text("Manage keys")').click()
+ page.locator('a[href="/keys"]:has-text("Public keys")').click()
wait_for_path(page, "/keys")
logging.info("Navigated to Your Public Keys page")
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]