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]