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 8aec44b  Allow distribution deletion, and handle duplicate submissions 
gracefully
8aec44b is described below

commit 8aec44b27c7802bbfe2172dceaf0e89527e973a5
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Aug 7 17:49:06 2025 +0100

    Allow distribution deletion, and handle duplicate submissions gracefully
---
 atr/routes/distribution.py           | 88 +++++++++++++++++++++++++++++++++++-
 atr/storage/writers/distributions.py | 38 +++++++++++++++-
 2 files changed, 122 insertions(+), 4 deletions(-)

diff --git a/atr/routes/distribution.py b/atr/routes/distribution.py
index da19529..9cb8c73 100644
--- a/atr/routes/distribution.py
+++ b/atr/routes/distribution.py
@@ -20,6 +20,7 @@ from __future__ import annotations
 import dataclasses
 import datetime
 import json
+from typing import TYPE_CHECKING
 
 import aiohttp
 import htpy
@@ -36,6 +37,10 @@ import atr.routes as routes
 import atr.storage as storage
 import atr.storage.outcome as outcome
 import atr.template as template
+import atr.util as util
+
+if TYPE_CHECKING:
+    import werkzeug.wrappers.response as response
 
 
 class ArtifactHubAvailableVersion(schema.Lax):
@@ -74,6 +79,30 @@ class PyPIResponse(schema.Lax):
     urls: list[PyPIUrl] = pydantic.Field(default_factory=list)
 
 
+class DeleteForm(forms.Typed):
+    release_name = forms.hidden()
+    platform = forms.hidden()
+    owner_namespace = forms.hidden()
+    package = forms.hidden()
+    version = forms.hidden()
+    submit = forms.submit("Delete")
+
+
+class DeleteData(schema.Lax):
+    release_name: str
+    platform: sql.DistributionPlatform
+    owner_namespace: str
+    package: str
+    version: str
+
+    @pydantic.field_validator("platform", mode="before")
+    @classmethod
+    def coerce_platform(cls, v: object) -> object:
+        if isinstance(v, str):
+            return sql.DistributionPlatform[v]
+        return v
+
+
 class DistributeForm(forms.Typed):
     platform = forms.select("Platform", choices=sql.DistributionPlatform)
     owner_namespace = forms.optional(
@@ -133,6 +162,35 @@ class DistributeData(schema.Lax):
         return None if v is None or (isinstance(v, str) and v.strip() == "") 
else v
 
 
[email protected]("/distribution/delete/<project>/<version>", methods=["POST"])
+async def delete(session: routes.CommitterSession, project: str, version: str) 
-> response.Response:
+    form = await DeleteForm.create_form(data=await quart.request.form)
+    dd = DeleteData.model_validate(form.data)
+
+    # Validate the submitted data, and obtain the committee for its name
+    async with db.session() as data:
+        release = await 
data.release(name=dd.release_name).demand(RuntimeError(f"Release 
{dd.release_name} not found"))
+    committee = release.committee
+    if committee is None:
+        raise RuntimeError(f"Release {dd.release_name} has no committee")
+
+    # Delete the distribution
+    async with 
storage.write_as_committee_member(committee_name=committee.name) as wacm:
+        await wacm.distributions.delete_distribution(
+            release_name=dd.release_name,
+            platform=dd.platform,
+            owner_namespace=dd.owner_namespace,
+            package=dd.package,
+            version=dd.version,
+        )
+    return await session.redirect(
+        list_get,
+        project=project,
+        version=version,
+        success="Distribution deleted",
+    )
+
+
 @routes.committer("/distributions/list/<project>/<version>", methods=["GET"])
 async def list_get(session: routes.CommitterSession, project: str, version: 
str) -> str:
     async with db.session() as data:
@@ -142,7 +200,22 @@ async def list_get(session: routes.CommitterSession, 
project: str, version: str)
 
     block = htm.Block()
     block.h1["Distribution list"]
+    if not distributions:
+        block.p["No distributions found."]
+        return await template.blank(
+            "Distribution list",
+            content=block.collect(),
+        )
     for distribution in distributions:
+        delete_form = await DeleteForm.create_form(
+            data={
+                "release_name": distribution.release_name,
+                "platform": distribution.platform.name,
+                "owner_namespace": distribution.owner_namespace,
+                "package": distribution.package,
+                "version": distribution.version,
+            }
+        )
         block.h2[f"{distribution.platform.name} {distribution.package} 
{distribution.version}"]
         tbody = htpy.tbody[
             _tr("Release name", distribution.release_name),
@@ -155,6 +228,13 @@ async def list_get(session: routes.CommitterSession, 
project: str, version: str)
             _tr("API URL", distribution.api_url),
         ]
         block.table(".table.table-striped.table-bordered")[tbody]
+        form_action = util.as_url(delete, project=project, version=version)
+        delete_form_element = forms.render_simple(
+            delete_form,
+            action=form_action,
+            submit_classes="btn-danger",
+        )
+        block.append(htpy.div(".mb-3")[delete_form_element])
     title = f"Distribution list for {project} {version}"
     return await template.blank(title, content=block.collect())
 
@@ -251,7 +331,7 @@ async def _distribute_post_validated(fpv: 
FormProjectVersion, /) -> str:
     block.h1["Distribution recorded"]
     match api_oc:
         case outcome.Result(result):
-            block.p["The distribution was recorded successfully."]
+            pass
         case outcome.Error():
             alert = _alert("package and version", "check the package name and 
version")
             return await _distribute_page(fpv, extra_content=alert)
@@ -265,7 +345,7 @@ async def _distribute_post_validated(fpv: 
FormProjectVersion, /) -> str:
         return await _distribute_page(fpv, extra_content=alert)
 
     async with 
storage.write_as_committee_member(committee_name=committee.name) as w:
-        distribution = await w.distributions.add_distribution(
+        distribution, added = await w.distributions.add_distribution(
             release_name=release.name,
             platform=dd.platform,
             owner_namespace=dd.owner_namespace,
@@ -278,6 +358,10 @@ async def _distribute_post_validated(fpv: 
FormProjectVersion, /) -> str:
 
     ### Record
     block.h2["Record"]
+    if added:
+        block.p["The distribution was recorded successfully."]
+    else:
+        block.p["The distribution was already recorded."]
     block.table(".table.table-striped.table-bordered")[
         htpy.tbody[
             _tr("Release name", distribution.release_name),
diff --git a/atr/storage/writers/distributions.py 
b/atr/storage/writers/distributions.py
index de1a301..03b3319 100644
--- a/atr/storage/writers/distributions.py
+++ b/atr/storage/writers/distributions.py
@@ -18,8 +18,11 @@
 # Removing this will cause circular imports
 from __future__ import annotations
 
+import sqlite3
 from typing import TYPE_CHECKING
 
+import sqlalchemy.exc as exc
+
 import atr.db as db
 import atr.models.sql as sql
 import atr.storage as storage
@@ -97,7 +100,7 @@ class CommitteeMember(CommitteeParticipant):
         staging: bool,
         upload_date: datetime.datetime | None,
         api_url: str,
-    ) -> sql.Distribution:
+    ) -> tuple[sql.Distribution, bool]:
         distribution = sql.Distribution(
             platform=platform,
             release_name=release_name,
@@ -109,5 +112,36 @@ class CommitteeMember(CommitteeParticipant):
             api_url=api_url,
         )
         self.__data.add(distribution)
+        try:
+            await self.__data.commit()
+        except exc.IntegrityError as e:
+            # "The names and numeric values for existing result codes are 
fixed and unchanging."
+            # https://www.sqlite.org/rescode.html
+            # e.orig.sqlite_errorcode == 1555
+            # e.orig.sqlite_errorname == "SQLITE_CONSTRAINT_PRIMARYKEY"
+            match e.orig:
+                case 
sqlite3.IntegrityError(sqlite_errorcode=sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY):
+                    # log.debug(f"Distribution already exists: {distribution}")
+                    return distribution, False
+            raise e
+        return distribution, True
+
+    async def delete_distribution(
+        self,
+        release_name: str,
+        platform: sql.DistributionPlatform,
+        owner_namespace: str,
+        package: str,
+        version: str,
+    ) -> None:
+        distribution = await self.__data.distribution(
+            release_name=release_name,
+            platform=platform,
+            owner_namespace=owner_namespace,
+            package=package,
+            version=version,
+        ).demand(
+            RuntimeError(f"Distribution {release_name} {platform} 
{owner_namespace} {package} {version} not found")
+        )
+        await self.__data.delete(distribution)
         await self.__data.commit()
-        return distribution


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to