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]