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 85e0065 Make JSON interfaces type safe
85e0065 is described below
commit 85e0065f96d6448eb1dfee0ae965b8b257024dfa
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Jul 11 16:12:27 2025 +0100
Make JSON interfaces type safe
---
pyproject.toml | 4 +-
src/atrclient/client.py | 133 +++++++++++++++++++++++++++++++++++++++---------
tests/cli_workflow.t | 9 ++++
uv.lock | 4 +-
4 files changed, 122 insertions(+), 28 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 5437fca..d8ad600 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,7 @@ build-backend = "hatchling.build"
[project]
name = "apache-trusted-releases"
-version = "0.20250711.1320"
+version = "0.20250711.1511"
description = "ATR CLI and Python API"
readme = "README.md"
requires-python = ">=3.13"
@@ -48,4 +48,4 @@ atr = "atrclient.client:main"
packages = ["src/atrclient"]
[tool.uv]
-exclude-newer = "2025-07-11T13:20:00Z"
+exclude-newer = "2025-07-11T15:11:00Z"
diff --git a/src/atrclient/client.py b/src/atrclient/client.py
index 2045766..f02b96a 100755
--- a/src/atrclient/client.py
+++ b/src/atrclient/client.py
@@ -33,7 +33,7 @@ import re
import signal
import sys
import time
-from typing import TYPE_CHECKING, Annotated, Any, Literal, NoReturn
+from typing import TYPE_CHECKING, Annotated, Any, Literal, NoReturn, TypeGuard
import aiohttp
import cyclopts
@@ -72,6 +72,8 @@ YAML_SCHEMA: strictyaml.Map = strictyaml.Map(
}
)
+JSON = dict[str, Any] | list[Any] | str | int | float | bool | None
+
@APP_CHECKS.command(
name="exceptions", help="Get check exceptions for a release revision."
@@ -87,6 +89,8 @@ def app_checks_exceptions(
host, verify_ssl = config_host_get()
url = f"https://{host}/api/checks/{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)
@@ -102,6 +106,8 @@ def app_checks_failures(
host, verify_ssl = config_host_get()
url = f"https://{host}/api/checks/{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)
@@ -118,9 +124,14 @@ def app_checks_status(
release_url = f"https://{host}/api/releases/{project}"
releases_result = asyncio.run(web_get_public(release_url, verify_ssl))
+ if not is_json_dict(releases_result):
+ show_error_and_exit(f"Unexpected API response: {releases_result}")
target_release = None
- for release in releases_result.get("data", []):
+ releases_result_data = releases_result.get("data", [])
+ if not is_json_list_of_dict(releases_result_data):
+ show_error_and_exit(f"Unexpected API response: {releases_result_data}")
+ for release in releases_result_data:
if release.get("version") == version:
target_release = release
break
@@ -137,6 +148,8 @@ def app_checks_status(
url = f"https://{host}/api/checks/{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)
@@ -152,6 +165,8 @@ def app_checks_warnings(
host, verify_ssl = config_host_get()
url = f"https://{host}/api/checks/{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)
@@ -257,7 +272,7 @@ def app_draft_delete(project: str, version: str, /) -> None:
payload: dict[str, str] = {"project_name": project, "version": version}
url = f"https://{host}/api/draft/delete"
result = asyncio.run(web_post(url, payload, jwt_value, verify_ssl))
- print(result)
+ print_json(result)
@APP.command(name="docs", help="Show comprehensive CLI documentation in
Markdown.")
@@ -354,6 +369,8 @@ def app_release_list(project: str, /) -> None:
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)
@@ -366,7 +383,7 @@ def app_release_start(project: str, version: str, /) ->
None:
payload: dict[str, str] = {"project_name": project, "version": version}
result = asyncio.run(web_post(url, payload, jwt_value, verify_ssl))
- print(result)
+ print_json(result)
@APP.command(name="revisions", help="List all revisions for a release.")
@@ -374,7 +391,12 @@ 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))
- for revision in result.get("revisions", []):
+ 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:
print(revision)
@@ -422,7 +444,7 @@ def app_upload(project: str, version: str, path: str,
filepath: str, /) -> None:
}
result = asyncio.run(web_post(url, payload, jwt_value, verify_ssl))
- print(result)
+ print_json(result)
@APP_VOTE.command(name="start", help="Start a vote.")
@@ -455,10 +477,10 @@ def app_vote_start(
"body": body_text or f"Release {project} {version} is ready for
voting.",
}
result = asyncio.run(web_post(url, payload, jwt_value, verify_ssl))
- print(result)
+ print_json(result)
-def checks_display(results: list[dict[str, Any]], verbose: bool = False) ->
None:
+def checks_display(results: list[dict[str, JSON]], verbose: bool = False) ->
None:
if not results:
print("No check results found for this revision.")
return
@@ -473,7 +495,7 @@ def checks_display(results: list[dict[str, Any]], verbose:
bool = False) -> None
def checks_display_details(
- by_status: dict[str, list[dict[str, Any]]], verbose: bool
+ by_status: dict[str, list[dict[str, JSON]]], verbose: bool
) -> None:
if not verbose:
return
@@ -486,7 +508,7 @@ def checks_display_details(
def checks_display_status(
status: Literal["failure", "exception", "warning"],
- results: list[dict[str, Any]],
+ results: list[dict[str, JSON]],
members: bool,
) -> None:
messages = {}
@@ -498,7 +520,12 @@ def checks_display_status(
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")
if not member_rel_path:
path = primary_rel_path
@@ -518,7 +545,7 @@ def checks_display_status(
def checks_display_summary(
- by_status: dict[str, list[dict[str, Any]]], verbose: bool, total: int
+ by_status: dict[str, list[dict[str, JSON]]], verbose: bool, total: int
) -> None:
print(f"Total checks: {total}")
for status, checks in by_status.items():
@@ -530,7 +557,7 @@ def checks_display_summary(
print(f" {status}: {len(checks)}")
-def checks_display_verbose_details(checks: list[dict[str, Any]]) -> None:
+def checks_display_verbose_details(checks: list[dict[str, JSON]]) -> None:
for check in checks[:10]:
checker = check["checker"]
primary_rel_path = check.get("primary_rel_path", "")
@@ -766,6 +793,32 @@ def initialised() -> bool:
return APP.version == VERSION
+def is_json(data: Any) -> TypeGuard[JSON]:
+ if isinstance(data, str | int | float | bool | None):
+ return True
+ if isinstance(data, dict):
+ if any(not isinstance(key, str) for key in data):
+ return False
+ return all(is_json(value) for value in data.values())
+ if isinstance(data, list):
+ return all(is_json(item) for item in data)
+ return False
+
+
+def is_json_dict(data: JSON) -> TypeGuard[dict[str, JSON]]:
+ # The keys are already validated due to it being a JSON object
+ return isinstance(data, dict)
+
+
+def is_json_list(data: JSON) -> TypeGuard[list[JSON]]:
+ # The items are already validated due to it being a JSON array
+ return isinstance(data, list)
+
+
+def is_json_list_of_dict(data: JSON) -> TypeGuard[list[dict[str, JSON]]]:
+ return is_json_list(data) and all(is_json_dict(item) for item in data)
+
+
def iso_to_human(ts: str) -> str:
dt = datetime.datetime.fromisoformat(ts.rstrip("Z"))
if dt.tzinfo is None:
@@ -782,11 +835,17 @@ def main() -> None:
APP(sys.argv[1:])
-def releases_display(result: dict[str, Any]) -> None:
+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"]
if not releases:
@@ -798,6 +857,12 @@ def releases_display(result: dict[str, Any]) -> None:
for release in releases:
version = release.get("version", "Unknown")
phase = release.get("phase", "Unknown")
+ # 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",
@@ -805,6 +870,9 @@ def releases_display(result: dict[str, Any]) -> None:
"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 "-"
print(f" {version:<24} {latest:<7} {phase_short:<11}
{created_formatted}")
@@ -857,13 +925,20 @@ async def web_fetch(
f"JWT fetch failed: {payload!r} {resp.status} {text!r}"
)
- data: dict[str, Any] = await resp.json()
+ data = await resp.json()
+ if not is_json(data):
+ show_error_and_exit(f"Unexpected API response: {data}")
+ if not is_json_dict(data):
+ show_error_and_exit(f"Unexpected API response: {data}")
if "jwt" in data:
- return data["jwt"]
+ jwt_value = data["jwt"]
+ if not isinstance(jwt_value, str):
+ show_error_and_exit(f"Unexpected API response: {data}")
+ return jwt_value
raise RuntimeError(f"Unexpected response: {data}")
-async def web_get(url: str, jwt_token: str, verify_ssl: bool = True) -> Any:
+async def web_get(url: str, jwt_token: str, verify_ssl: bool = True) -> JSON:
connector = None if verify_ssl else aiohttp.TCPConnector(ssl=False)
headers = {"Authorization": f"Bearer {jwt_token}"}
async with aiohttp.ClientSession(connector=connector, headers=headers) as
session:
@@ -878,10 +953,13 @@ async def web_get(url: str, jwt_token: str, verify_ssl:
bool = True) -> Any:
show_error_and_exit(f"Request failed: {resp.status}
{text}")
except json.JSONDecodeError:
show_error_and_exit(f"Request failed: {resp.status}
{text}")
- return await resp.json()
+ data = await resp.json()
+ if not is_json(data):
+ show_error_and_exit(f"Unexpected API response: {data}")
+ return data
-async def web_get_public(url: str, verify_ssl: bool = True) -> Any:
+async def web_get_public(url: str, verify_ssl: bool = True) -> JSON:
connector = None if verify_ssl else aiohttp.TCPConnector(ssl=False)
async with aiohttp.ClientSession(connector=connector) as session:
async with session.get(url) as resp:
@@ -895,12 +973,15 @@ async def web_get_public(url: str, verify_ssl: bool =
True) -> Any:
show_error_and_exit(f"Request failed: {resp.status}
{text}")
except json.JSONDecodeError:
show_error_and_exit(f"Request failed: {resp.status}
{text}")
- return await resp.json()
+ data = await resp.json()
+ if not is_json(data):
+ show_error_and_exit(f"Unexpected API response: {data}")
+ return data
async def web_post(
url: str, payload: dict[str, Any], jwt_token: str, verify_ssl: bool = True
-) -> Any:
+) -> JSON:
connector = None if verify_ssl else aiohttp.TCPConnector(ssl=False)
headers = {"Authorization": f"Bearer {jwt_token}"}
async with aiohttp.ClientSession(connector=connector, headers=headers) as
session:
@@ -912,7 +993,11 @@ async def web_post(
)
try:
- return await resp.json()
- except Exception:
- text = await resp.text()
- return text
+ data = await resp.json()
+ if not is_json(data):
+ show_error_and_exit(f"Unexpected API response: {data}")
+ return data
+ except Exception as e:
+ show_error_and_exit(
+ f"Python error getting API response:\n{resp.status}
{url}\n{e}"
+ )
diff --git a/tests/cli_workflow.t b/tests/cli_workflow.t
index 1fd0375..1aa379c 100644
--- a/tests/cli_workflow.t
+++ b/tests/cli_workflow.t
@@ -19,3 +19,12 @@ Set tokens.pat to "<!pat!>".
$ atr release start tooling-test-example 0.3+cli
<.skip.>created<.skip.>
+
+$ atr config path
+<?config_rel_path?>
+
+$ atr upload tooling-test-example 0.3+cli atr-client.conf <!config_rel_path!>
+<.skip.>created<.skip.>
+
+* atr draft delete tooling-test-example 0.3+cli
+<.etc.>
diff --git a/uv.lock b/uv.lock
index 0b6858e..c8b3aba 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,7 +2,7 @@ version = 1
requires-python = ">=3.13"
[options]
-exclude-newer = "2025-07-11T13:20:00Z"
+exclude-newer = "2025-07-11T15:11:00Z"
[[package]]
name = "aiohappyeyeballs"
@@ -74,7 +74,7 @@ wheels = [
[[package]]
name = "apache-trusted-releases"
-version = "0.20250711.1320"
+version = "0.20250711.1511"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]