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-releases-client.git
The following commit(s) were added to refs/heads/main by this push:
new f170e00 Use consistent API types for starting and resolving votes,
and more
f170e00 is described below
commit f170e00c83e759d2354b00cbbea4f888484d167a
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Jul 15 18:56:02 2025 +0100
Use consistent API types for starting and resolving votes, and more
---
pyproject.toml | 4 +-
src/atrclient/client.py | 53 ++++++++++++++------------
src/atrclient/models/api.py | 92 ++++++++++++++++++++++++++++-----------------
src/atrclient/models/sql.py | 23 ++++++++++++
tests/cli_workflow.t | 16 +++++---
uv.lock | 4 +-
6 files changed, 125 insertions(+), 67 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 58ab4fb..5bda5ad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,7 @@ build-backend = "hatchling.build"
[project]
name = "apache-trusted-releases"
-version = "0.20250715.1733"
+version = "0.20250715.1754"
description = "ATR CLI and Python API"
readme = "README.md"
requires-python = ">=3.13"
@@ -72,4 +72,4 @@ select = [
]
[tool.uv]
-exclude-newer = "2025-07-15T17:33:00Z"
+exclude-newer = "2025-07-15T17:54:00Z"
diff --git a/src/atrclient/client.py b/src/atrclient/client.py
index d543e8c..c0eb39d 100755
--- a/src/atrclient/client.py
+++ b/src/atrclient/client.py
@@ -260,7 +260,7 @@ def app_dev_delete(project: str, version: str, /) -> None:
# Only ATR admins may do this
jwt_value = config_jwt_usable()
host, verify_ssl = config_host_get()
- args = models.api.ProjectVersion(project=project, version=version)
+ args = models.api.ReleasesDeleteArgs(project=project, version=version)
url = f"https://{host}/api/releases/delete"
response = asyncio.run(web_post(url, args, jwt_value, verify_ssl))
try:
@@ -347,7 +347,7 @@ def app_dev_user() -> None:
def app_draft_delete(project: str, version: str, /) -> None:
jwt_value = config_jwt_usable()
host, verify_ssl = config_host_get()
- args = models.api.ProjectVersion(project=project, version=version)
+ args = models.api.DraftDeleteArgs(project=project, version=version)
url = f"https://{host}/api/draft/delete"
response = asyncio.run(web_post(url, args, jwt_value, verify_ssl))
try:
@@ -470,7 +470,7 @@ def app_release_start(project: str, version: str, /) ->
None:
jwt_value = config_jwt_usable()
host, verify_ssl = config_host_get()
url = f"https://{host}/api/releases/create"
- args = models.api.ProjectVersion(project=project, version=version)
+ args = models.api.ReleasesCreateArgs(project=project, version=version)
response = asyncio.run(web_post(url, args, jwt_value, verify_ssl))
try:
releases_create = models.api.validate_releases_create(response)
@@ -483,13 +483,12 @@ def app_release_start(project: str, version: str, /) ->
None:
def app_revisions(project: str, version: str, /) -> None:
host, verify_ssl = config_host_get()
url = f"https://{host}/api/revisions/{project}/{version}"
- result = asyncio.run(web_get_public(url, verify_ssl))
- if not is_json_dict(result):
- show_error_and_exit(f"Unexpected API response: {result}")
- result_revisions = result.get("revisions", [])
- if not is_json_list_of_dict(result_revisions):
- show_error_and_exit(f"Unexpected API response: {result_revisions}")
- for revision in result_revisions:
+ response = asyncio.run(web_get_public(url, verify_ssl))
+ try:
+ revisions = models.api.validate_revisions(response)
+ except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+ show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+ for revision in revisions.revisions:
print(revision)
@@ -529,15 +528,19 @@ def app_upload(project: str, version: str, path: str,
filepath: str, /) -> None:
with open(filepath, "rb") as f:
content = f.read()
- args = models.api.ProjectVersionRelpathContent(
+ args = models.api.UploadArgs(
project=project,
version=version,
relpath=path,
content=base64.b64encode(content).decode("utf-8"),
)
- result = asyncio.run(web_post(url, args, jwt_value, verify_ssl))
- print_json(result)
+ response = asyncio.run(web_post(url, args, jwt_value, verify_ssl))
+ try:
+ upload = models.api.validate_upload(response)
+ except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+ show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+ print(upload.revision.model_dump_json(indent=None))
@APP_VOTE.command(name="resolve", help="Resolve a vote.")
@@ -549,13 +552,17 @@ def app_vote_resolve(
jwt_value = config_jwt_usable()
host, verify_ssl = config_host_get()
url = f"https://{host}/api/vote/resolve"
- args = models.api.ProjectVersionResolution(
+ args = models.api.VoteResolveArgs(
project=project,
version=version,
resolution=resolution,
)
- result = asyncio.run(web_post(url, args, jwt_value, verify_ssl))
- print_json(result)
+ response = asyncio.run(web_post(url, args, jwt_value, verify_ssl))
+ try:
+ vote_resolve = models.api.validate_vote_resolve(response)
+ except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+ show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+ print(vote_resolve.success)
@APP_VOTE.command(name="start", help="Start a vote.")
@@ -576,7 +583,7 @@ def app_vote_start(
if body:
with open(body, encoding="utf-8") as f:
body_text = f.read()
- args = models.api.VoteStart(
+ args = models.api.VoteStartArgs(
project=project,
version=version,
revision=revision,
@@ -585,8 +592,12 @@ def app_vote_start(
subject=subject or f"[VOTE] Release {project} {version}",
body=body_text or f"Release {project} {version} is ready for voting.",
)
- result = asyncio.run(web_post(url, args, jwt_value, verify_ssl))
- print_json(result)
+ response = asyncio.run(web_post(url, args, jwt_value, verify_ssl))
+ try:
+ vote_start = models.api.validate_vote_start(response)
+ except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+ show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+ print(vote_start.task.model_dump_json(indent=None))
def checks_display(results: Sequence[models.sql.CheckResult], verbose: bool =
False) -> None:
@@ -933,10 +944,6 @@ def main() -> None:
APP(sys.argv[1:])
-def print_json(data: JSON) -> None:
- print(json.dumps(data, indent=None))
-
-
def releases_display(releases: Sequence[models.sql.Release]) -> None:
if not releases:
print("No releases found for this project.")
diff --git a/src/atrclient/models/api.py b/src/atrclient/models/api.py
index 70c3036..02e63b6 100644
--- a/src/atrclient/models/api.py
+++ b/src/atrclient/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/src/atrclient/models/sql.py b/src/atrclient/models/sql.py
index 802e349..90b6584 100644
--- a/src/atrclient/models/sql.py
+++ b/src/atrclient/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"),
diff --git a/tests/cli_workflow.t b/tests/cli_workflow.t
index 5e621a1..b244736 100644
--- a/tests/cli_workflow.t
+++ b/tests/cli_workflow.t
@@ -14,12 +14,18 @@ $ atr set tokens.pat <!pat!>
Set tokens.pat to "<!pat!>".
<# Delete any existing draft, ignoring errors. #>
-* atr draft delete tooling-test-example 0.3+cli
+* atr dev delete tooling-test-example 0.3+cli
<.etc.>
$ atr release start tooling-test-example 0.3+cli
<.skip.>created<.skip.>
+$ atr dev delete tooling-test-example 0.3+cli
+tooling-test-example-0.3+cli
+
+$ atr release start tooling-test-example 0.3+cli
+<.skip.>created<.skip.>
+
$ atr config path
<?config_rel_path?>
@@ -34,16 +40,16 @@ Total checks: 1
warning: 1
$ atr vote start tooling-test-example 0.3+cli 00002 -m "<!user!>@apache.org"
-<.skip.>"email_to": "<!user!>@apache.org"<.skip.>
+<.skip.>"email_to":"<!user!>@apache.org"<.skip.>
$ atr vote resolve tooling-test-example 0.3+cli failed
-{"success": "Vote marked as failed"}
+Vote marked as failed
$ atr vote start tooling-test-example 0.3+cli 00002 -m "<!user!>@apache.org"
-<.skip.>"email_to": "<!user!>@apache.org"<.skip.>
+<.skip.>"email_to":"<!user!>@apache.org"<.skip.>
$ atr vote resolve tooling-test-example 0.3+cli passed
-{"success": "Vote marked as passed"}
+Vote marked as passed
$ atr announce tooling-test-example 0.3+cli 00003 -m "<!user!>@apache.org" -s
"[ANNOUNCE] Release tooling-test-example 0.3+cli" -b "Release
tooling-test-example 0.3+cli has been announced."
Announcement sent
diff --git a/uv.lock b/uv.lock
index bc066f3..f578f60 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,7 +2,7 @@ version = 1
requires-python = ">=3.13"
[options]
-exclude-newer = "2025-07-15T17:33:00Z"
+exclude-newer = "2025-07-15T17:54:00Z"
[[package]]
name = "aiohappyeyeballs"
@@ -83,7 +83,7 @@ wheels = [
[[package]]
name = "apache-trusted-releases"
-version = "0.20250715.1733"
+version = "0.20250715.1754"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]