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 02c8210  Use consistent new API types
02c8210 is described below

commit 02c8210de343ad98ac65a6c40bc5ba8be28d3583
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Jul 15 16:14:10 2025 +0100

    Use consistent new API types
---
 pyproject.toml              |   4 +-
 src/atrclient/client.py     | 101 +++++++++++++++++++++++---------------------
 src/atrclient/models/api.py |  88 ++++++++++++++++++++++++++++++++------
 tests/cli_workflow.t        |   2 +-
 tests/test_all.py           |  63 ++++++++++++++++-----------
 uv.lock                     |   4 +-
 6 files changed, 172 insertions(+), 90 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 499783f..78e2ad5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,7 @@ build-backend = "hatchling.build"
 
 [project]
 name            = "apache-trusted-releases"
-version         = "0.20250715.1422"
+version         = "0.20250715.1512"
 description     = "ATR CLI and Python API"
 readme          = "README.md"
 requires-python = ">=3.13"
@@ -72,4 +72,4 @@ select = [
 ]
 
 [tool.uv]
-exclude-newer = "2025-07-15T14:22:00Z"
+exclude-newer = "2025-07-15T15:12:00Z"
diff --git a/src/atrclient/client.py b/src/atrclient/client.py
index 37a1b0e..693c399 100755
--- a/src/atrclient/client.py
+++ b/src/atrclient/client.py
@@ -47,7 +47,7 @@ import strictyaml
 import atrclient.models as models
 
 if TYPE_CHECKING:
-    from collections.abc import Generator
+    from collections.abc import Generator, Sequence
 
 APP: cyclopts.App = cyclopts.App()
 APP_CHECKS: cyclopts.App = cyclopts.App(name="checks", help="Check result 
operations.")
@@ -88,7 +88,7 @@ def app_announce(
 ) -> None:
     jwt_value = config_jwt_usable()
     host, verify_ssl = config_host_get()
-    announce = models.api.Announce(
+    announce = models.api.AnnounceArgs(
         project=project,
         version=version,
         revision=revision,
@@ -98,8 +98,12 @@ def app_announce(
         path_suffix=path_suffix or "",
     )
     url = f"https://{host}/api/announce";
-    result = asyncio.run(web_post(url, announce, jwt_value, verify_ssl))
-    print_json(result)
+    response = asyncio.run(web_post(url, announce, jwt_value, verify_ssl))
+    try:
+        announce = models.api.validate_announce(response)
+    except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+        show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+    print(announce.success)
 
 
 @APP_CHECKS.command(name="exceptions", help="Get check exceptions for a 
release revision.")
@@ -113,10 +117,12 @@ def app_checks_exceptions(
     jwt_value = config_jwt_usable()
     host, verify_ssl = config_host_get()
     url = f"https://{host}/api/checks/list/{project}/{version}/{revision}";
-    results = asyncio.run(web_get(url, jwt_value, verify_ssl))
-    if not is_json_list_of_dict(results):
-        show_error_and_exit(f"Unexpected API response: {results}")
-    checks_display_status("exception", results, members=members)
+    response = asyncio.run(web_get(url, jwt_value, verify_ssl))
+    try:
+        checks_list = models.api.validate_checks_list(response)
+    except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+        show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+    checks_display_status("exception", checks_list.checks, members=members)
 
 
 @APP_CHECKS.command(name="failures", help="Get check failures for a release 
revision.")
@@ -130,10 +136,12 @@ def app_checks_failures(
     jwt_value = config_jwt_usable()
     host, verify_ssl = config_host_get()
     url = f"https://{host}/api/checks/list/{project}/{version}/{revision}";
-    results = asyncio.run(web_get(url, jwt_value, verify_ssl))
-    if not is_json_list_of_dict(results):
-        show_error_and_exit(f"Unexpected API response: {results}")
-    checks_display_status("failure", results, members=members)
+    response = asyncio.run(web_get(url, jwt_value, verify_ssl))
+    try:
+        checks_list = models.api.validate_checks_list(response)
+    except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+        show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+    checks_display_status("failure", checks_list.checks, members=members)
 
 
 @APP_CHECKS.command(name="status", help="Get check status for a release 
revision.")
@@ -168,11 +176,12 @@ def app_checks_status(
         revision = latest_revision_number
 
     url = f"https://{host}/api/checks/list/{project}/{version}/{revision}";
-    results = asyncio.run(web_get(url, jwt_value, verify_ssl))
-
-    if not is_json_list_of_dict(results):
-        show_error_and_exit(f"Unexpected API response: {results}")
-    checks_display(results, verbose)
+    response = asyncio.run(web_get(url, jwt_value, verify_ssl))
+    try:
+        checks_list = models.api.validate_checks_list(response)
+    except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+        show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+    checks_display(checks_list.checks, verbose)
 
 
 @APP_CHECKS.command(name="wait", help="Wait for checks to be completed.")
@@ -196,12 +205,12 @@ def app_checks_wait(
         url = f"https://{host}/api/checks/ongoing/{project}/{version}";
         if revision is not None:
             url += f"/{revision}"
-        result = asyncio.run(web_get(url, jwt_value, verify_ssl))
+        response = asyncio.run(web_get(url, jwt_value, verify_ssl))
         try:
-            result_count = models.api.validate_count(result)
+            checks_ongoing = models.api.validate_checks_ongoing(response)
         except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
-            show_error_and_exit(f"Unexpected API response: {result}\n{e}")
-        if result_count.count == 0:
+            show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+        if checks_ongoing.ongoing == 0:
             break
         time.sleep(interval_seconds)
         timeout -= interval_seconds
@@ -221,10 +230,12 @@ def app_checks_warnings(
     jwt_value = config_jwt_usable()
     host, verify_ssl = config_host_get()
     url = f"https://{host}/api/checks/list/{project}/{version}/{revision}";
-    results = asyncio.run(web_get(url, jwt_value, verify_ssl))
-    if not is_json_list_of_dict(results):
-        show_error_and_exit(f"Unexpected API response: {results}")
-    checks_display_status("warning", results, members=members)
+    response = asyncio.run(web_get(url, jwt_value, verify_ssl))
+    try:
+        checks_list = models.api.validate_checks_list(response)
+    except (pydantic.ValidationError, models.api.ResultsTypeError) as e:
+        show_error_and_exit(f"Unexpected API response: {response}\n{e}")
+    checks_display_status("warning", checks_list.checks, members=members)
 
 
 @APP_CONFIG.command(name="file", help="Display the configuration file 
contents.")
@@ -558,21 +569,21 @@ def app_vote_start(
     print_json(result)
 
 
-def checks_display(results: list[dict[str, JSON]], verbose: bool = False) -> 
None:
+def checks_display(results: Sequence[models.sql.CheckResult], verbose: bool = 
False) -> None:
     if not results:
         print("No check results found for this revision.")
         return
 
-    by_status = {}
+    by_status: dict[str, list[models.sql.CheckResult]] = {}
     for result in results:
-        status = result["status"]
+        status = result.status
         by_status.setdefault(status, []).append(result)
 
     checks_display_summary(by_status, verbose, len(results))
     checks_display_details(by_status, verbose)
 
 
-def checks_display_details(by_status: dict[str, list[dict[str, JSON]]], 
verbose: bool) -> None:
+def checks_display_details(by_status: dict[str, list[models.sql.CheckResult]], 
verbose: bool) -> None:
     if not verbose:
         return
     for status_key in by_status.keys():
@@ -584,25 +595,19 @@ def checks_display_details(by_status: dict[str, 
list[dict[str, JSON]]], verbose:
 
 def checks_display_status(
     status: Literal["failure", "exception", "warning"],
-    results: list[dict[str, JSON]],
+    results: Sequence[models.sql.CheckResult],
     members: bool,
 ) -> None:
     messages = {}
     for result in results:
-        result_status = result.get("status")
-        if result_status != status:
+        if result.status != status:
             continue
-        member_rel_path = result.get("member_rel_path")
+        member_rel_path = result.member_rel_path
         if member_rel_path and (not members):
             continue
-        checker = result.get("checker") or ""
-        if not isinstance(checker, str):
-            show_warning(f"Unexpected API response: {result}")
-            continue
-        message = result.get("message")
-        if not isinstance(message, str):
-            show_warning(f"Unexpected API response: {result}")
-        primary_rel_path = result.get("primary_rel_path")
+        checker = result.checker or ""
+        message = result.message
+        primary_rel_path = result.primary_rel_path
         if not member_rel_path:
             path = primary_rel_path
         else:
@@ -620,23 +625,23 @@ def checks_display_status(
         print()
 
 
-def checks_display_summary(by_status: dict[str, list[dict[str, JSON]]], 
verbose: bool, total: int) -> None:
+def checks_display_summary(by_status: dict[str, list[models.sql.CheckResult]], 
verbose: bool, total: int) -> None:
     print(f"Total checks: {total}")
     for status, checks in by_status.items():
         if verbose and status.upper() in ["FAILURE", "EXCEPTION", "WARNING"]:
-            top = sum(r["member_rel_path"] is None for r in checks)
+            top = sum(r.member_rel_path is None for r in checks)
             inner = len(checks) - top
             print(f"  {status}: {len(checks)} (top-level {top}, inner 
{inner})")
         else:
             print(f"  {status}: {len(checks)}")
 
 
-def checks_display_verbose_details(checks: list[dict[str, JSON]]) -> None:
+def checks_display_verbose_details(checks: Sequence[models.sql.CheckResult]) 
-> None:
     for check in checks[:10]:
-        checker = check["checker"]
-        primary_rel_path = check.get("primary_rel_path", "")
-        member_rel_path = check.get("member_rel_path", "")
-        message = check["message"]
+        checker = check.checker or ""
+        primary_rel_path = check.primary_rel_path or ""
+        member_rel_path = check.member_rel_path or ""
+        message = check.message
         member_part = f" ({member_rel_path})" if member_rel_path else ""
         print(f"  {checker} → {primary_rel_path}{member_part} : {message}")
 
diff --git a/src/atrclient/models/api.py b/src/atrclient/models/api.py
index 76fb7c8..e67bcf5 100644
--- a/src/atrclient/models/api.py
+++ b/src/atrclient/models/api.py
@@ -16,11 +16,14 @@
 # under the License.
 
 import dataclasses
-from typing import Annotated, Any, Literal
+from collections.abc import Callable, Sequence
+from typing import Annotated, Any, Literal, TypeVar
 
 import pydantic
 
-from . import schema
+from . import schema, sql
+
+T = TypeVar("T")
 
 
 class ResultsTypeError(TypeError):
@@ -45,7 +48,7 @@ class Task(Pagination):
     status: str | None = None
 
 
-class Announce(schema.Strict):
+class AnnounceArgs(schema.Strict):
     project: str
     version: str
     revision: str
@@ -55,14 +58,49 @@ class Announce(schema.Strict):
     path_suffix: str
 
 
+class AnnounceResults(schema.Strict):
+    endpoint: Literal["/announce"] = schema.Field(alias="endpoint")
+    success: str
+
+
+class ChecksListResults(schema.Strict):
+    endpoint: Literal["/checks/list"] = schema.Field(alias="endpoint")
+    checks: Sequence[sql.CheckResult]
+
+
+class ChecksOngoingResults(schema.Strict):
+    endpoint: Literal["/checks/ongoing"] = schema.Field(alias="endpoint")
+    ongoing: int
+
+
+class CommitteesResults(schema.Strict):
+    endpoint: Literal["/committees"] = schema.Field(alias="endpoint")
+    committee: sql.Committee
+
+
+class CommitteesKeysResults(schema.Strict):
+    endpoint: Literal["/committees/keys"] = schema.Field(alias="endpoint")
+    keys: Sequence[sql.PublicSigningKey]
+
+
+class CommitteesListResults(schema.Strict):
+    endpoint: Literal["/committees/list"] = schema.Field(alias="endpoint")
+    committees: Sequence[sql.Committee]
+
+
+class CommitteesProjectsResults(schema.Strict):
+    endpoint: Literal["/committees/projects"] = schema.Field(alias="endpoint")
+    projects: Sequence[sql.Project]
+
+
 class AsfuidPat(schema.Strict):
     asfuid: str
     pat: str
 
 
-class Count(schema.Strict):
-    kind: Literal["count"] = schema.Field(alias="kind")
-    count: int
+class Fingerprint(schema.Strict):
+    endpoint: Literal["/keys/ssh/add"] = schema.Field(alias="endpoint")
+    fingerprint: str
 
 
 class ProjectVersion(schema.Strict):
@@ -83,6 +121,10 @@ class ProjectVersionResolution(schema.Strict):
     resolution: Literal["passed", "failed"]
 
 
+class Text(schema.Strict):
+    text: str
+
+
 class VoteStart(schema.Strict):
     project: str
     version: str
@@ -94,15 +136,35 @@ class VoteStart(schema.Strict):
 
 
 Results = Annotated[
-    Count,
-    schema.Field(discriminator="kind"),
+    AnnounceResults
+    | ChecksListResults
+    | ChecksOngoingResults
+    | CommitteesResults
+    | CommitteesKeysResults
+    | CommitteesListResults
+    | CommitteesProjectsResults
+    | Fingerprint,
+    schema.Field(discriminator="endpoint"),
 ]
 
 ResultsAdapter = pydantic.TypeAdapter(Results)
 
 
-def validate_count(value: Any) -> Count:
-    count = ResultsAdapter.validate_python(value)
-    if not isinstance(count, Count):
-        raise ResultsTypeError(f"Invalid API response: {value}")
-    return count
+def validator[T](t: type[T]) -> Callable[[Any], T]:
+    def validate(value: Any) -> T:
+        obj = ResultsAdapter.validate_python(value)
+        if not isinstance(obj, t):
+            raise ResultsTypeError(f"Invalid API response: {value}")
+        return obj
+
+    return validate
+
+
+validate_announce = validator(AnnounceResults)
+validate_checks_list = validator(ChecksListResults)
+validate_checks_ongoing = validator(ChecksOngoingResults)
+validate_committees = validator(CommitteesResults)
+validate_committees_keys = validator(CommitteesKeysResults)
+validate_committees_list = validator(CommitteesListResults)
+validate_committees_projects = validator(CommitteesProjectsResults)
+validate_fingerprint = validator(Fingerprint)
diff --git a/tests/cli_workflow.t b/tests/cli_workflow.t
index 4b8d2ee..77d37a6 100644
--- a/tests/cli_workflow.t
+++ b/tests/cli_workflow.t
@@ -46,7 +46,7 @@ $ atr vote resolve tooling-test-example 0.3+cli passed
 {"success": "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."
-{"success": "Announcement sent"}
+Announcement sent
 
 <# Tidy up. #>
 * atr dev delete tooling-test-example 0.3+cli
diff --git a/tests/test_all.py b/tests/test_all.py
index b7c76fc..e418886 100755
--- a/tests/test_all.py
+++ b/tests/test_all.py
@@ -86,29 +86,44 @@ def test_app_checks_status_verbose(capsys: 
pytest.CaptureFixture[str], fixture_c
         "latest_revision_number": "00003",
     }
 
-    checks_payload = [
-        {
-            "status": "FAILURE",
-            "checker": "test_checker1",
-            "primary_rel_path": "file1.txt",
-            "member_rel_path": None,
-            "message": "Test failure 1",
-        },
-        {
-            "status": "FAILURE",
-            "checker": "test_checker2",
-            "primary_rel_path": "file2.txt",
-            "member_rel_path": "inner.txt",
-            "message": "Test failure 2",
-        },
-        {
-            "status": "SUCCESS",
-            "checker": "test_checker3",
-            "primary_rel_path": "file3.txt",
-            "member_rel_path": None,
-            "message": "Test success",
-        },
-    ]
+    checks_payload = {
+        "endpoint": "/checks/list",
+        "checks": [
+            {
+                "release_name": "test-project-2.3.1",
+                "revision_number": "00003",
+                "created": "2025-01-01T00:00:00Z",
+                "status": "failure",
+                "checker": "test_checker1",
+                "primary_rel_path": "file1.txt",
+                "member_rel_path": None,
+                "message": "Test failure 1",
+                "data": None,
+            },
+            {
+                "release_name": "test-project-2.3.1",
+                "revision_number": "00003",
+                "created": "2025-01-01T00:00:00Z",
+                "status": "failure",
+                "checker": "test_checker2",
+                "primary_rel_path": "file2.txt",
+                "member_rel_path": "inner.txt",
+                "message": "Test failure 2",
+                "data": None,
+            },
+            {
+                "release_name": "test-project-2.3.1",
+                "revision_number": "00003",
+                "created": "2025-01-01T00:00:00Z",
+                "status": "success",
+                "checker": "test_checker3",
+                "primary_rel_path": "file3.txt",
+                "member_rel_path": None,
+                "message": "Test success",
+                "data": None,
+            },
+        ],
+    }
 
     with aioresponses.aioresponses() as mock:
         mock.get(release_url, status=200, payload=release_payload)
@@ -118,7 +133,7 @@ def test_app_checks_status_verbose(capsys: 
pytest.CaptureFixture[str], fixture_c
 
         captured = capsys.readouterr()
         assert "(top-level" in captured.out
-        assert "FAILURE: 2 (top-level 1, inner 1)" in captured.out
+        assert "failure: 2 (top-level 1, inner 1)" in captured.out
         assert "test_checker1 → file1.txt : Test failure 1" in captured.out
 
 
diff --git a/uv.lock b/uv.lock
index f5209d8..28e0beb 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,7 +2,7 @@ version = 1
 requires-python = ">=3.13"
 
 [options]
-exclude-newer = "2025-07-15T14:22:00Z"
+exclude-newer = "2025-07-15T15:12:00Z"
 
 [[package]]
 name = "aiohappyeyeballs"
@@ -83,7 +83,7 @@ wheels = [
 
 [[package]]
 name = "apache-trusted-releases"
-version = "0.20250715.1422"
+version = "0.20250715.1512"
 source = { editable = "." }
 dependencies = [
     { name = "aiohttp" },


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

Reply via email to