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 da17de3  Use consistent API types for commands using release endpoints
da17de3 is described below

commit da17de323882262e0ca503babadcb3304777450d
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Jul 15 17:30:00 2025 +0100

    Use consistent API types for commands using release endpoints
---
 pyproject.toml              |  4 +-
 src/atrclient/client.py     | 96 ++++++++++++++++++++++----------------------
 src/atrclient/models/api.py | 97 +++++++++++++++++++++++++++++++++++++++++----
 src/atrclient/models/sql.py | 10 +++++
 tests/cli_workflow.t        |  2 +-
 tests/test_all.py           | 55 +++++++++++++++++++------
 uv.lock                     |  4 +-
 7 files changed, 196 insertions(+), 72 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 26d3520..d545723 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,7 @@ build-backend = "hatchling.build"
 
 [project]
 name            = "apache-trusted-releases"
-version         = "0.20250715.1539"
+version         = "0.20250715.1629"
 description     = "ATR CLI and Python API"
 readme          = "README.md"
 requires-python = ">=3.13"
@@ -72,4 +72,4 @@ select = [
 ]
 
 [tool.uv]
-exclude-newer = "2025-07-15T15:39:00Z"
+exclude-newer = "2025-07-15T16:29:00Z"
diff --git a/src/atrclient/client.py b/src/atrclient/client.py
index 8cbdfc8..d543e8c 100755
--- a/src/atrclient/client.py
+++ b/src/atrclient/client.py
@@ -155,25 +155,26 @@ def app_checks_status(
     jwt_value = config_jwt_usable()
     host, verify_ssl = config_host_get()
 
-    release_url = f"https://{host}/api/releases/{project}/{version}";
-    target_release = asyncio.run(web_get_public(release_url, verify_ssl))
-    # TODO: Handle the not found case better
-    if not is_json_dict(target_release):
-        show_error_and_exit(f"Unexpected API response: {target_release}")
+    release_url = f"https://{host}/api/releases/version/{project}/{version}";
+    response = asyncio.run(web_get_public(release_url, verify_ssl))
+    try:
+        releases_version = models.api.validate_releases_version(response)
+    except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+        show_error_and_exit(f"Unexpected API response: {response}\n{e}")
 
-    phase = target_release.get("phase")
-    if phase != "release_candidate_draft":
+    release = releases_version.release
+    # TODO: Handle the not found case better
+    if release.phase != "release_candidate_draft":
         print("Checks are not applicable for this release phase.")
         print("Checks are only performed during the draft phase.")
         return
 
     if revision is None:
-        latest_revision_number = target_release.get("latest_revision_number")
-        if latest_revision_number is None:
+        if release.latest_revision_number is None:
             show_error_and_exit("No revision number found.")
-        if not isinstance(latest_revision_number, str):
-            show_error_and_exit(f"Unexpected API response: 
{latest_revision_number}")
-        revision = latest_revision_number
+        if not isinstance(release.latest_revision_number, str):
+            show_error_and_exit(f"Unexpected API response: 
{release.latest_revision_number}")
+        revision = release.latest_revision_number
 
     url = f"https://{host}/api/checks/list/{project}/{version}/{revision}";
     response = asyncio.run(web_get(url, jwt_value, verify_ssl))
@@ -261,8 +262,12 @@ def app_dev_delete(project: str, version: str, /) -> None:
     host, verify_ssl = config_host_get()
     args = models.api.ProjectVersion(project=project, version=version)
     url = f"https://{host}/api/releases/delete";
-    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:
+        release_delete = models.api.validate_releases_delete(response)
+    except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+        show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+    print(release_delete.deleted)
 
 
 @APP_DEV.command(name="env", help="Show the environment variables.")
@@ -438,24 +443,26 @@ def app_list(project: str, version: str, revision: str | 
None = None, /) -> None
 @APP_RELEASE.command(name="info", help="Show information about a release.")
 def app_release_info(project: str, version: str, /) -> None:
     host, verify_ssl = config_host_get()
-    url = f"https://{host}/api/releases/{project}/{version}";
-    result = asyncio.run(web_get_public(url, verify_ssl))
+    url = f"https://{host}/api/releases/version/{project}/{version}";
+    response = asyncio.run(web_get_public(url, verify_ssl))
     try:
-        release = models.sql.Release.model_validate(result)
-    except pydantic.ValidationError as e:
-        show_error_and_exit(f"Unexpected API response: {result}\n{e}")
-    print(release.model_dump_json(indent=None))
+        releases_version = models.api.validate_releases_version(response)
+    except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+        show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+    print(releases_version.release.model_dump_json(indent=None))
 
 
 @APP_RELEASE.command(name="list", help="List releases for a project.")
 def app_release_list(project: str, /) -> None:
     # TODO: Support showing all of a user's releases if no project is provided
     host, verify_ssl = config_host_get()
-    url = f"https://{host}/api/releases/{project}";
-    result = asyncio.run(web_get_public(url, verify_ssl))
-    if not is_json_dict(result):
-        show_error_and_exit(f"Unexpected API response: {result}")
-    releases_display(result)
+    url = f"https://{host}/api/releases/project/{project}";
+    response = asyncio.run(web_get_public(url, verify_ssl))
+    try:
+        releases_project = models.api.validate_releases_project(response)
+    except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+        show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+    releases_display(releases_project.data)
 
 
 @APP_RELEASE.command(name="start", help="Start a release.")
@@ -464,8 +471,12 @@ def app_release_start(project: str, version: str, /) -> 
None:
     host, verify_ssl = config_host_get()
     url = f"https://{host}/api/releases/create";
     args = models.api.ProjectVersion(project=project, version=version)
-    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:
+        releases_create = models.api.validate_releases_create(response)
+    except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+        show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+    print(releases_create.release.model_dump_json(indent=None))
 
 
 @APP.command(name="revisions", help="List all revisions for a release.")
@@ -926,42 +937,31 @@ def print_json(data: JSON) -> None:
     print(json.dumps(data, indent=None))
 
 
-def releases_display(result: dict[str, JSON]) -> None:
-    if ("data" not in result) or ("count" not in result):
-        show_error_and_exit("Invalid response format")
-
-    releases = result["data"]
-    if not is_json_list_of_dict(releases):
-        show_error_and_exit(f"Unexpected API response: {releases}")
-    count = result["count"]
-
+def releases_display(releases: Sequence[models.sql.Release]) -> None:
     if not releases:
         print("No releases found for this project.")
         return
 
-    print(f"Total releases: {count}")
+    print(f"Total releases: {len(releases)}")
     print(f"  {'Version':<24} {'Latest':<7} {'Phase':<11} {'Created'}")
     for release in releases:
-        version = release.get("version", "Unknown")
-        phase = release.get("phase", "Unknown")
+        version = release.version
+        phase = release.phase
         # if not isinstance(version, str):
         #     show_warning(f"Unexpected API response: {release}")
         #     continue
-        if not isinstance(phase, str):
-            show_warning(f"Unexpected API response: {release}")
-            continue
         phase_short = {
             "release_candidate_draft": "draft",
             "release_candidate": "candidate",
             "release_preview": "preview",
             "release": "finished",
         }.get(phase, "unknown")
-        created = release.get("created")
-        if not isinstance(created, str):
-            show_warning(f"Unexpected API response: {release}")
-            continue
-        created_formatted = iso_to_human(created) if created else "Unknown"
-        latest = release.get("latest_revision_number") or "-"
+        if release.created:
+            created_iso = release.created.isoformat()
+            created_formatted = iso_to_human(created_iso)
+        else:
+            created_formatted = "Unknown"
+        latest = release.latest_revision_number or "-"
         print(f"  {version:<24} {latest:<7} {phase_short:<11} 
{created_formatted}")
 
 
diff --git a/src/atrclient/models/api.py b/src/atrclient/models/api.py
index 9a0539f..4311afc 100644
--- a/src/atrclient/models/api.py
+++ b/src/atrclient/models/api.py
@@ -36,12 +36,6 @@ class Pagination:
     limit: int = 20
 
 
-# TODO: ReleasesPagination?
[email protected]
-class Releases(Pagination):
-    phase: str | None = None
-
-
 # TODO: TaskPagination?
 @dataclasses.dataclass
 class Task(Pagination):
@@ -145,6 +139,17 @@ class KeysSshAddResults(schema.Strict):
     fingerprint: str
 
 
+class KeysSshListQuery(Pagination):
+    offset: int = 0
+    limit: int = 20
+
+
+class KeysSshListResults(schema.Strict):
+    endpoint: Literal["/keys/ssh/list"] = schema.Field(alias="endpoint")
+    data: Sequence[sql.SSHKey]
+    count: int
+
+
 class ProjectResults(schema.Strict):
     endpoint: Literal["/project"] = schema.Field(alias="endpoint")
     project: sql.Project
@@ -178,6 +183,68 @@ class ProjectVersionResolution(schema.Strict):
     resolution: Literal["passed", "failed"]
 
 
[email protected]
+class ReleasesQuery:
+    offset: int = 0
+    limit: int = 20
+    phase: str | None = None
+
+
+class ReleasesResults(schema.Strict):
+    endpoint: Literal["/releases"] = schema.Field(alias="endpoint")
+    data: Sequence[sql.Release]
+    count: int
+
+
+class ReleasesCreateArgs(schema.Strict):
+    project: str
+    version: str
+
+
+class ReleasesCreateResults(schema.Strict):
+    endpoint: Literal["/releases/create"] = schema.Field(alias="endpoint")
+    release: sql.Release
+
+
+class ReleasesDeleteArgs(schema.Strict):
+    project: str
+    version: str
+
+
+class ReleasesDeleteResults(schema.Strict):
+    endpoint: Literal["/releases/delete"] = schema.Field(alias="endpoint")
+    deleted: str
+
+
[email protected]
+class ReleasesProjectQuery:
+    limit: int = 20
+    offset: int = 0
+    # project: str
+    # version: str
+
+
+class ReleasesProjectResults(schema.Strict):
+    endpoint: Literal["/releases/project"] = schema.Field(alias="endpoint")
+    data: Sequence[sql.Release]
+    count: int
+
+    @pydantic.field_validator("data", mode="before")
+    @classmethod
+    def coerce_release(cls, v: Sequence[dict[str, Any]]) -> 
Sequence[sql.Release]:
+        return [sql.Release.model_validate(item) if isinstance(item, dict) 
else item for item in v]
+
+
+class ReleasesVersionResults(schema.Strict):
+    endpoint: Literal["/releases/version"] = schema.Field(alias="endpoint")
+    release: sql.Release
+
+
+class ReleasesRevisionsResults(schema.Strict):
+    endpoint: Literal["/releases/revisions"] = schema.Field(alias="endpoint")
+    revisions: Sequence[sql.Revision]
+
+
 class Text(schema.Strict):
     text: str
 
@@ -192,6 +259,8 @@ class VoteStart(schema.Strict):
     body: str
 
 
+# This is for *Results classes only
+# We do NOT put *Args classes here
 Results = Annotated[
     AnnounceResults
     | ChecksListResults
@@ -205,10 +274,17 @@ Results = Annotated[
     | KeyResults
     | KeysResults
     | KeysSshAddResults
+    | KeysSshListResults
     | ListResults
     | ProjectResults
     | ProjectReleasesResults
-    | ProjectsResults,
+    | ProjectsResults
+    | ReleasesResults
+    | ReleasesCreateResults
+    | ReleasesDeleteResults
+    | ReleasesProjectResults
+    | ReleasesVersionResults
+    | ReleasesRevisionsResults,
     schema.Field(discriminator="endpoint"),
 ]
 
@@ -237,7 +313,14 @@ validate_jwt = validator(JwtResults)
 validate_key = validator(KeyResults)
 validate_keys = validator(KeysResults)
 validate_keys_ssh_add = validator(KeysSshAddResults)
+validate_keys_ssh_list = validator(KeysSshListResults)
 validate_list = validator(ListResults)
 validate_project = validator(ProjectResults)
 validate_project_releases = validator(ProjectReleasesResults)
 validate_projects = validator(ProjectsResults)
+validate_releases = validator(ReleasesResults)
+validate_releases_create = validator(ReleasesCreateResults)
+validate_releases_delete = validator(ReleasesDeleteResults)
+validate_releases_project = validator(ReleasesProjectResults)
+validate_releases_version = validator(ReleasesVersionResults)
+validate_releases_revisions = validator(ReleasesRevisionsResults)
diff --git a/src/atrclient/models/sql.py b/src/atrclient/models/sql.py
index dea5604..d2c91b2 100644
--- a/src/atrclient/models/sql.py
+++ b/src/atrclient/models/sql.py
@@ -597,6 +597,16 @@ class Release(sqlmodel.SQLModel, table=True):
             raise ValueError("Latest revision number is not a str or None")
         return number
 
+    @pydantic.field_validator("created", mode="before")
+    @classmethod
+    def parse_created(cls, v: str | datetime.datetime):
+        return datetime.datetime.fromisoformat(v.rstrip("Z")) if isinstance(v, 
str) else v
+
+    @pydantic.field_validator("phase", mode="before")
+    @classmethod
+    def parse_phase(cls, v: str | ReleasePhase):
+        return ReleasePhase(v) if isinstance(v, str) else v
+
     # NOTE: This does not work
     # But it we set it with Release.latest_revision_number_query = ..., it 
might work
     # Not clear that we'd want to do that, though
diff --git a/tests/cli_workflow.t b/tests/cli_workflow.t
index 77d37a6..5e621a1 100644
--- a/tests/cli_workflow.t
+++ b/tests/cli_workflow.t
@@ -29,7 +29,7 @@ $ atr upload tooling-test-example 0.3+cli atr-client.conf 
<!config_rel_path!>
 $ atr checks wait tooling-test-example 0.3+cli -i 25
 Checks completed.
 
-$ atr checks status tooling-test-example 0.3+cli
+$ atr checks status tooling-test-example 0.3+cli 00002
 Total checks: 1
   warning: 1
 
diff --git a/tests/test_all.py b/tests/test_all.py
index cab46f6..fe79150 100755
--- a/tests/test_all.py
+++ b/tests/test_all.py
@@ -51,17 +51,26 @@ def test_app_checks_status_non_draft_phase(
     client.app_set("atr.host", "example.invalid")
     client.app_set("tokens.jwt", "dummy_jwt_token")
 
-    releases_url = "https://example.invalid/api/releases/test-project/2.3.0";
+    releases_url = 
"https://example.invalid/api/releases/version/test-project/2.3.0";
 
     with aioresponses.aioresponses() as mock:
         mock.get(
             releases_url,
             status=200,
             payload={
-                "version": "2.3.0",
-                "phase": "release",
-                "created": "2024-07-04T00:00:00.000000Z",
-                "latest_revision_number": "00001",
+                "endpoint": "/releases/version",
+                "release": {
+                    "name": "test-project-2.3.0",
+                    "project_name": "test-project",
+                    "version": "2.3.0",
+                    "phase": "release",
+                    "created": "2024-07-04T00:00:00.000000Z",
+                    "latest_revision_number": "00001",
+                    "package_managers": [],
+                    "sboms": [],
+                    "votes": [],
+                    "vote_manual": False,
+                },
             },
         )
 
@@ -76,14 +85,23 @@ def test_app_checks_status_verbose(capsys: 
pytest.CaptureFixture[str], fixture_c
     client.app_set("atr.host", "example.invalid")
     client.app_set("tokens.jwt", "dummy_jwt_token")
 
-    release_url = "https://example.invalid/api/releases/test-project/2.3.1";
+    release_url = 
"https://example.invalid/api/releases/version/test-project/2.3.1";
     checks_url = 
"https://example.invalid/api/checks/list/test-project/2.3.1/00003";
 
     release_payload = {
-        "version": "2.3.1",
-        "phase": "release_candidate_draft",
-        "created": "2025-01-01T00:00:00.000000Z",
-        "latest_revision_number": "00003",
+        "endpoint": "/releases/version",
+        "release": {
+            "name": "test-project-2.3.1",
+            "project_name": "test-project",
+            "version": "2.3.1",
+            "phase": "release_candidate_draft",
+            "created": "2025-01-01T00:00:00.000000Z",
+            "latest_revision_number": "00003",
+            "package_managers": [],
+            "sboms": [],
+            "votes": [],
+            "vote_manual": False,
+        },
     }
 
     checks_payload = {
@@ -140,7 +158,7 @@ def test_app_checks_status_verbose(capsys: 
pytest.CaptureFixture[str], fixture_c
 def test_app_release_list_not_found(capsys: pytest.CaptureFixture[str], 
fixture_config_env: pathlib.Path) -> None:
     client.app_set("atr.host", "example.invalid")
 
-    releases_url = "https://example.invalid/api/releases/nonexistent-project";
+    releases_url = 
"https://example.invalid/api/releases/project/nonexistent-project";
 
     with aioresponses.aioresponses() as mock:
         mock.get(releases_url, status=404, body="Not Found")
@@ -152,21 +170,34 @@ def test_app_release_list_not_found(capsys: 
pytest.CaptureFixture[str], fixture_
 def test_app_release_list_success(capsys: pytest.CaptureFixture[str], 
fixture_config_env: pathlib.Path) -> None:
     client.app_set("atr.host", "example.invalid")
 
-    releases_url = "https://example.invalid/api/releases/test-project";
+    releases_url = "https://example.invalid/api/releases/project/test-project";
 
     payload = {
+        "endpoint": "/releases/project",
         "data": [
             {
+                "name": "test-project-2.3.1",
+                "project_name": "test-project",
                 "version": "2.3.1",
                 "phase": "release_candidate_draft",
                 "created": "2025-01-01T00:00:00.000000Z",
                 "latest_revision_number": "00003",
+                "package_managers": [],
+                "sboms": [],
+                "votes": [],
+                "vote_manual": False,
             },
             {
+                "name": "test-project-2.3.0",
+                "project_name": "test-project",
                 "version": "2.3.0",
                 "phase": "release",
                 "created": "2024-07-04T00:00:00.000000Z",
                 "latest_revision_number": "00001",
+                "package_managers": [],
+                "sboms": [],
+                "votes": [],
+                "vote_manual": False,
             },
         ],
         "count": 2,
diff --git a/uv.lock b/uv.lock
index 7fdb5c5..33b286c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,7 +2,7 @@ version = 1
 requires-python = ">=3.13"
 
 [options]
-exclude-newer = "2025-07-15T15:39:00Z"
+exclude-newer = "2025-07-15T16:29:00Z"
 
 [[package]]
 name = "aiohappyeyeballs"
@@ -83,7 +83,7 @@ wheels = [
 
 [[package]]
 name = "apache-trusted-releases"
-version = "0.20250715.1539"
+version = "0.20250715.1629"
 source = { editable = "." }
 dependencies = [
     { name = "aiohttp" },


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

Reply via email to