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 51197b1 Use consistent types for API endpoints from revisions to
uploads
51197b1 is described below
commit 51197b13fc96b34a6b5816c7d5588d29334b2fb7
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Jul 15 18:54:50 2025 +0100
Use consistent types for API endpoints from revisions to uploads
---
atr/blueprints/api/api.py | 65 +++++++++++++++++----------------
atr/models/api.py | 92 +++++++++++++++++++++++++++++------------------
atr/models/sql.py | 23 ++++++++++++
3 files changed, 115 insertions(+), 65 deletions(-)
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index c4a1f9c..88a8ddb 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -20,7 +20,6 @@ import base64
import datetime
import hashlib
import pathlib
-from collections.abc import Mapping
from typing import Any
import aiofiles.os
@@ -552,8 +551,8 @@ async def releases_project_version_revisions(project: str,
version: str) -> Dict
@api.BLUEPRINT.route("/revisions/<project>/<version>")
-@quart_schema.validate_response(dict[str, list[sql.Revision]], 200)
-async def revisions_project_version(project: str, version: str) ->
tuple[dict[str, list[sql.Revision]], int]:
+@quart_schema.validate_response(models.api.RevisionsResults, 200)
+async def revisions_project_version(project: str, version: str) ->
DictResponse:
_simple_check(project, version)
async with db.session() as data:
release_name = sql.release_name(project, version)
@@ -562,21 +561,15 @@ async def revisions_project_version(project: str,
version: str) -> tuple[dict[st
if not isinstance(revisions, list):
revisions = list(revisions)
revisions.sort(key=lambda rev: rev.number)
- return {"revisions": revisions}, 200
-
-
-# @api.BLUEPRINT.route("/secret")
-# @jwtoken.require
-# @quart_schema.security_scheme([{"BearerAuth": []}])
-# @quart_schema.validate_response(dict[str, str], 200)
-# async def secret() -> tuple[Mapping, int]:
-# """Return a secret."""
-# return {"secret": "*******"}, 200
+ return models.api.RevisionsResults(
+ endpoint="/revisions",
+ revisions=revisions,
+ ).model_dump(), 200
@api.BLUEPRINT.route("/tasks")
-@quart_schema.validate_querystring(models.api.Task)
-async def tasks(query_args: models.api.Task) -> quart.Response:
+@quart_schema.validate_querystring(models.api.TasksQuery)
+async def tasks(query_args: models.api.TasksQuery) -> DictResponse:
_pagination_args_validate(query_args)
via = sql.validate_instrumented_attribute
async with db.session() as data:
@@ -591,16 +584,19 @@ async def tasks(query_args: models.api.Task) ->
quart.Response:
if query_args.status:
count_statement = count_statement.where(via(sql.Task.status) ==
query_args.status)
count = (await data.execute(count_statement)).scalar_one()
- result = {"data": [paged_task.model_dump(exclude={"result"}) for
paged_task in paged_tasks], "count": count}
- return quart.jsonify(result)
+ return models.api.TasksResults(
+ endpoint="/tasks",
+ data=paged_tasks,
+ count=count,
+ ).model_dump(), 200
@api.BLUEPRINT.route("/vote/resolve", methods=["POST"])
@jwtoken.require
@quart_schema.security_scheme([{"BearerAuth": []}])
-@quart_schema.validate_request(models.api.ProjectVersionResolution)
-@quart_schema.validate_response(dict[str, str], 200)
-async def vote_resolve(data: models.api.ProjectVersionResolution) ->
tuple[Mapping, int]:
+@quart_schema.validate_request(models.api.VoteResolveArgs)
+@quart_schema.validate_response(models.api.VoteResolveResults, 200)
+async def vote_resolve(data: models.api.VoteResolveArgs) -> DictResponse:
asf_uid = _jwt_asf_uid()
async with db.session() as db_data:
@@ -624,15 +620,18 @@ async def vote_resolve(data:
models.api.ProjectVersionResolution) -> tuple[Mappi
release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
success_message = "Vote marked as failed"
await db_data.commit()
- return {"success": success_message}, 200
+ return models.api.VoteResolveResults(
+ endpoint="/vote/resolve",
+ success=success_message,
+ ).model_dump(), 200
@api.BLUEPRINT.route("/vote/start", methods=["POST"])
@jwtoken.require
@quart_schema.security_scheme([{"BearerAuth": []}])
-@quart_schema.validate_request(models.api.VoteStart)
-@quart_schema.validate_response(sql.Task, 201)
-async def vote_start(data: models.api.VoteStart) -> tuple[Mapping, int]:
+@quart_schema.validate_request(models.api.VoteStartArgs)
+@quart_schema.validate_response(models.api.VoteStartResults, 201)
+async def vote_start(data: models.api.VoteStartArgs) -> DictResponse:
asf_uid = _jwt_asf_uid()
permitted_recipients = util.permitted_recipients(asf_uid)
@@ -672,15 +671,18 @@ async def vote_start(data: models.api.VoteStart) ->
tuple[Mapping, int]:
)
db_data.add(task)
await db_data.commit()
- return task.model_dump(exclude={"result"}), 201
+ return models.api.VoteStartResults(
+ endpoint="/vote/start",
+ task=task,
+ ).model_dump(), 201
@api.BLUEPRINT.route("/upload", methods=["POST"])
@jwtoken.require
@quart_schema.security_scheme([{"BearerAuth": []}])
-@quart_schema.validate_request(models.api.ProjectVersionRelpathContent)
-@quart_schema.validate_response(sql.Revision, 201)
-async def upload(data: models.api.ProjectVersionRelpathContent) ->
tuple[Mapping, int]:
+@quart_schema.validate_request(models.api.UploadArgs)
+@quart_schema.validate_response(models.api.UploadResults, 201)
+async def upload(data: models.api.UploadArgs) -> DictResponse:
asf_uid = _jwt_asf_uid()
async with db.session() as db_data:
@@ -690,7 +692,10 @@ async def upload(data:
models.api.ProjectVersionRelpathContent) -> tuple[Mapping
raise exceptions.Forbidden("You do not have permission to upload
to this project")
revision = await _upload_process_file(data, asf_uid)
- return revision.model_dump(), 201
+ return models.api.UploadResults(
+ endpoint="/upload",
+ revision=revision,
+ ).model_dump(), 201
def _committee_member_or_admin(committee: sql.Committee, asf_uid: str) -> None:
@@ -730,7 +735,7 @@ def _simple_check(*args: str | None) -> None:
raise exceptions.BadRequest("Argument cannot be the string 'None'")
-async def _upload_process_file(args: models.api.ProjectVersionRelpathContent,
asf_uid: str) -> sql.Revision:
+async def _upload_process_file(args: models.api.UploadArgs, asf_uid: str) ->
sql.Revision:
file_bytes = base64.b64decode(args.content, validate=True)
file_path = args.relpath.lstrip("/")
description = f"Upload via API: {file_path}"
diff --git a/atr/models/api.py b/atr/models/api.py
index 70c3036..02e63b6 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -30,18 +30,6 @@ class ResultsTypeError(TypeError):
pass
[email protected]
-class Pagination:
- offset: int = 0
- limit: int = 20
-
-
-# TODO: TaskPagination?
[email protected]
-class Task(Pagination):
- status: str | None = None
-
-
class AnnounceArgs(schema.Strict):
project: str
version: str
@@ -139,7 +127,7 @@ class KeysSshAddResults(schema.Strict):
fingerprint: str
-class KeysSshListQuery(Pagination):
+class KeysSshListQuery:
offset: int = 0
limit: int = 20
@@ -165,24 +153,6 @@ class ProjectsResults(schema.Strict):
projects: Sequence[sql.Project]
-class ProjectVersion(schema.Strict):
- project: str
- version: str
-
-
-class ProjectVersionRelpathContent(schema.Strict):
- project: str
- version: str
- relpath: str
- content: str
-
-
-class ProjectVersionResolution(schema.Strict):
- project: str
- version: str
- resolution: Literal["passed", "failed"]
-
-
@dataclasses.dataclass
class ReleasesQuery:
offset: int = 0
@@ -240,11 +210,36 @@ class ReleasesRevisionsResults(schema.Strict):
revisions: Sequence[sql.Revision]
-class Text(schema.Strict):
- text: str
+class RevisionsResults(schema.Strict):
+ endpoint: Literal["/revisions"] = schema.Field(alias="endpoint")
+ revisions: Sequence[sql.Revision]
-class VoteStart(schema.Strict):
[email protected]
+class TasksQuery:
+ limit: int = 20
+ offset: int = 0
+ status: str | None = None
+
+
+class TasksResults(schema.Strict):
+ endpoint: Literal["/tasks"] = schema.Field(alias="endpoint")
+ data: Sequence[sql.Task]
+ count: int
+
+
+class VoteResolveArgs(schema.Strict):
+ project: str
+ version: str
+ resolution: Literal["passed", "failed"]
+
+
+class VoteResolveResults(schema.Strict):
+ endpoint: Literal["/vote/resolve"] = schema.Field(alias="endpoint")
+ success: str
+
+
+class VoteStartArgs(schema.Strict):
project: str
version: str
revision: str
@@ -254,6 +249,23 @@ class VoteStart(schema.Strict):
body: str
+class VoteStartResults(schema.Strict):
+ endpoint: Literal["/vote/start"] = schema.Field(alias="endpoint")
+ task: sql.Task
+
+
+class UploadArgs(schema.Strict):
+ project: str
+ version: str
+ relpath: str
+ content: str
+
+
+class UploadResults(schema.Strict):
+ endpoint: Literal["/upload"] = schema.Field(alias="endpoint")
+ revision: sql.Revision
+
+
# This is for *Results classes only
# We do NOT put *Args classes here
Results = Annotated[
@@ -279,7 +291,12 @@ Results = Annotated[
| ReleasesDeleteResults
| ReleasesProjectResults
| ReleasesVersionResults
- | ReleasesRevisionsResults,
+ | ReleasesRevisionsResults
+ | RevisionsResults
+ | TasksResults
+ | VoteResolveResults
+ | VoteStartResults
+ | UploadResults,
schema.Field(discriminator="endpoint"),
]
@@ -319,3 +336,8 @@ validate_releases_delete = validator(ReleasesDeleteResults)
validate_releases_project = validator(ReleasesProjectResults)
validate_releases_version = validator(ReleasesVersionResults)
validate_releases_revisions = validator(ReleasesRevisionsResults)
+validate_revisions = validator(RevisionsResults)
+validate_tasks = validator(TasksResults)
+validate_vote_resolve = validator(VoteResolveResults)
+validate_vote_start = validator(VoteStartResults)
+validate_upload = validator(UploadResults)
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 802e349..90b6584 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -244,6 +244,22 @@ class Task(sqlmodel.SQLModel, table=True):
revision_number: str | None = sqlmodel.Field(default=None, index=True)
primary_rel_path: str | None = sqlmodel.Field(default=None, index=True)
+ def model_post_init(self, _context):
+ if isinstance(self.task_type, str):
+ self.task_type = TaskType(self.task_type)
+
+ if isinstance(self.status, str):
+ self.status = TaskStatus(self.status)
+
+ if isinstance(self.added, str):
+ self.added =
datetime.datetime.fromisoformat(self.added.rstrip("Z"))
+
+ if isinstance(self.started, str):
+ self.started =
datetime.datetime.fromisoformat(self.started.rstrip("Z"))
+
+ if isinstance(self.completed, str):
+ self.completed =
datetime.datetime.fromisoformat(self.completed.rstrip("Z"))
+
# Create an index on status and added for efficient task claiming
__table_args__ = (
sqlalchemy.Index("ix_task_status_added", "status", "added"),
@@ -755,6 +771,13 @@ class Revision(sqlmodel.SQLModel, table=True):
description: str | None = sqlmodel.Field(default=None)
+ def model_post_init(self, _context):
+ if isinstance(self.created, str):
+ self.created =
datetime.datetime.fromisoformat(self.created.rstrip("Z"))
+
+ if isinstance(self.phase, str):
+ self.phase = ReleasePhase(self.phase)
+
__table_args__ = (
sqlmodel.UniqueConstraint("release_name", "seq",
name="uq_revision_release_seq"),
sqlmodel.UniqueConstraint("release_name", "number",
name="uq_revision_release_number"),
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]