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-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new e650381  Add commands to display checks with a particular status
e650381 is described below

commit e6503813b657d167c21fe69f6c117c321b8f2ea3
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Jul 8 11:22:55 2025 +0100

    Add commands to display checks with a particular status
---
 client/atr | 371 +++++++++++++++++++++++++++++++++++++++++++++++--------------
 1 file changed, 287 insertions(+), 84 deletions(-)

diff --git a/client/atr b/client/atr
index 67ebec8..5323463 100755
--- a/client/atr
+++ b/client/atr
@@ -56,6 +56,7 @@ import logging
 import os
 import pathlib
 import re
+import signal
 import subprocess
 import sys
 import tempfile
@@ -100,17 +101,46 @@ APP.command(JWT)
 APP.command(RELEASE)
 
 
[email protected](name="status", help="Get check status for a release revision.")
-def app_checks_status(project: str, version: str, revision: str) -> None:
-    with config_lock() as config:
-        jwt_value = config_get(config, ["tokens", "jwt"])
[email protected](name="exceptions", help="Get check exceptions for a release 
revision.")
+def app_checks_exceptions(
+    project: str,
+    version: str,
+    revision: str,
+    *,
+    members: Annotated[bool, cyclopts.Parameter(alias="-m", name="--members")] 
= False,
+) -> None:
+    jwt_value = config_jwt_get()
+    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))
+    checks_display_status("exception", results, members=members)
 
-    if jwt_value is None:
-        LOGGER.error("No JWT stored in configuration.")
-        sys.exit(1)
 
-    host = config.get("atr", {}).get("host", "release-test.apache.org")
-    verify_ssl = not host.startswith("127.0.0.1")
[email protected](name="failures", help="Get check failures for a release 
revision.")
+def app_checks_failures(
+    project: str,
+    version: str,
+    revision: str,
+    *,
+    members: Annotated[bool, cyclopts.Parameter(alias="-m", name="--members")] 
= False,
+) -> None:
+    jwt_value = config_jwt_get()
+    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))
+    checks_display_status("failure", results, members=members)
+
+
[email protected](name="status", help="Get check status for a release revision.")
+def app_checks_status(
+    project: str,
+    version: str,
+    revision: str,
+    *,
+    verbose: Annotated[bool, cyclopts.Parameter(alias="-v", name="--verbose")] 
= False,
+) -> None:
+    jwt_value = config_jwt_get()
+    host, verify_ssl = config_host_get()
 
     release_url = f"https://{host}/api/releases/{project}";
     releases_result = asyncio.run(web_get_public(release_url, verify_ssl))
@@ -134,7 +164,22 @@ def app_checks_status(project: str, version: str, 
revision: str) -> None:
     url = f"https://{host}/api/checks/{project}/{version}/{revision}";
     results = asyncio.run(web_get(url, jwt_value, verify_ssl))
 
-    checks_display(results)
+    checks_display(results, verbose)
+
+
[email protected](name="warnings", help="Get check warnings for a release 
revision.")
+def app_checks_warnings(
+    project: str,
+    version: str,
+    revision: str,
+    *,
+    members: Annotated[bool, cyclopts.Parameter(alias="-m", name="--members")] 
= False,
+) -> None:
+    jwt_value = config_jwt_get()
+    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))
+    checks_display_status("warning", results, members=members)
 
 
 @CONFIG.command(name="file", help="Display the configuration file contents.")
@@ -190,12 +235,7 @@ def app_drop(path: str) -> None:
 
 @JWT.command(name="dump", help="Show decoded JWT payload from stored config.")
 def app_jwt_dump() -> None:
-    with config_lock() as config:
-        jwt_value = config_get(config, ["tokens", "jwt"])
-
-    if jwt_value is None:
-        LOGGER.error("No JWT stored in configuration.")
-        sys.exit(1)
+    jwt_value = config_jwt_get()
 
     header = jwt.get_unverified_header(jwt_value)
     if header != {"alg": "HS256", "typ": "JWT"}:
@@ -213,12 +253,7 @@ def app_jwt_dump() -> None:
 
 @JWT.command(name="info", help="Show JWT payload in human-readable form.")
 def app_jwt_info() -> None:
-    with config_lock() as config:
-        jwt_value = config_get(config, ["tokens", "jwt"])
-
-    if jwt_value is None:
-        LOGGER.error("No JWT stored in configuration.")
-        sys.exit(1)
+    jwt_value = config_jwt_get()
 
     try:
         payload = jwt.decode(jwt_value, options={"verify_signature": False})
@@ -244,7 +279,7 @@ def app_jwt_refresh(asf_uid: str | None = None) -> None:
         LOGGER.error("No Personal Access Token stored.")
         sys.exit(1)
 
-    host = config.get("atr", {}).get("host", "release-test.apache.org")
+    host, verify_ssl = config_host_get()
     url = f"https://{host}/api/jwt";
 
     if asf_uid is None:
@@ -254,7 +289,6 @@ def app_jwt_refresh(asf_uid: str | None = None) -> None:
         LOGGER.error("No ASF UID provided and asf.uid not configured.")
         sys.exit(1)
 
-    verify_ssl = not host.startswith("127.0.0.1")
     jwt_token = asyncio.run(web_fetch(url, asf_uid, pat_value, verify_ssl))
 
     with config_lock(write=True) as config:
@@ -270,31 +304,20 @@ def app_jwt_show() -> None:
 
 @RELEASE.command(name="list", help="List releases for PROJECT.")
 def app_release_list(project: str) -> None:
-    with config_lock() as config:
-        host = config.get("atr", {}).get("host", "release-test.apache.org")
-
+    host, verify_ssl = config_host_get()
     url = f"https://{host}/api/releases/{project}";
-    verify_ssl = not host.startswith("127.0.0.1")
-
     result = asyncio.run(web_get_public(url, verify_ssl))
     releases_display(result)
 
 
 @RELEASE.command(name="start", help="Start a release.")
 def app_release_start(project: str, version: str) -> None:
-    with config_lock() as config:
-        jwt_value = config_get(config, ["tokens", "jwt"])
-
-    if jwt_value is None:
-        LOGGER.error("No JWT stored in configuration.")
-        sys.exit(1)
-
-    host = config.get("atr", {}).get("host", "release-test.apache.org")
+    jwt_value = config_jwt_get()
+    host, verify_ssl = config_host_get()
     url = f"https://{host}/api/releases/create";
 
     payload: dict[str, str] = {"project_name": project, "version": version}
 
-    verify_ssl = not host.startswith("127.0.0.1")
     result = asyncio.run(web_post(url, payload, jwt_value, verify_ssl))
     print(result)
 
@@ -349,7 +372,7 @@ def app_test(q: Annotated[bool, 
cyclopts.Parameter(alias="-q")] = False, *pytest
             os.chdir(cwd)
 
 
-def checks_display(results: list[dict[str, Any]]) -> None:
+def checks_display(results: list[dict[str, Any]], verbose: bool = False) -> 
None:
     if not results:
         print("No check results found for this revision.")
         return
@@ -359,15 +382,70 @@ def checks_display(results: list[dict[str, Any]]) -> None:
         status = result["status"]
         by_status.setdefault(status, []).append(result)
 
-    print(f"Total checks: {len(results)}")
+    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, Any]]], 
verbose: bool) -> None:
+    if not verbose:
+        return
+    for status_key in by_status.keys():
+        if status_key.upper() not in ["FAILURE", "EXCEPTION", "WARNING"]:
+            continue
+        print(f"\n{status_key}:")
+        checks_display_verbose_details(by_status[status_key])
+
+
+def checks_display_status(
+    status: Literal["failure", "exception", "warning"], results: 
list[dict[str, Any]], members: bool
+) -> None:
+    messages = {}
+    for result in results:
+        result_status = result.get("status")
+        if result_status != status:
+            continue
+        member_rel_path = result.get("member_rel_path")
+        if member_rel_path and (not members):
+            continue
+        checker = result.get("checker") or ""
+        message = result.get("message")
+        primary_rel_path = result.get("primary_rel_path")
+        if not member_rel_path:
+            path = primary_rel_path
+        else:
+            path = f"{primary_rel_path} → {member_rel_path}"
+
+        if path not in messages:
+            messages[path] = []
+        msg = f" - {message} ({checker.removeprefix('atr.tasks.checks.')})"
+        messages[path].append(msg)
+
+    for path in sorted(messages):
+        print(path)
+        for msg in sorted(messages[path]):
+            print(msg)
+        print()
+
+
+def checks_display_summary(by_status: dict[str, list[dict[str, Any]]], 
verbose: bool, total: int) -> None:
+    print(f"Total checks: {total}")
     for status, checks in by_status.items():
-        print(f"  {status}: {len(checks)}")
+        if verbose and status.upper() in ["FAILURE", "EXCEPTION", "WARNING"]:
+            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)}")
 
-    for status in ["FAILURE", "EXCEPTION", "WARNING"]:
-        if status in by_status:
-            print(f"\n{status}:")
-            for check in by_status[status]:
-                print(f"  - {check['checker']}: {check['message']}")
+
+def checks_display_verbose_details(checks: list[dict[str, Any]]) -> 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"]
+        member_part = f" ({member_rel_path})" if member_rel_path else ""
+        print(f"  {checker} → {primary_rel_path}{member_part} : {message}")
 
 
 def config_drop(config: dict[str, Any], parts: list[str]) -> None:
@@ -378,6 +456,24 @@ def config_get(config: dict[str, Any], parts: list[str]) 
-> Any | None:
     return config_walk(config, parts, "get")[1]
 
 
+def config_host_get() -> tuple[str, bool]:
+    with config_lock() as config:
+        host = config.get("atr", {}).get("host", "release-test.apache.org")
+    verify_ssl = not ((host == "127.0.0.1") or host.startswith("127.0.0.1:"))
+    return host, verify_ssl
+
+
+def config_jwt_get() -> str:
+    with config_lock() as config:
+        jwt_value = config_get(config, ["tokens", "jwt"])
+
+    if jwt_value is None:
+        LOGGER.error("No JWT stored in configuration.")
+        sys.exit(1)
+
+    return jwt_value
+
+
 @contextlib.contextmanager
 def config_lock(write: bool = False) -> Generator[dict[str, Any]]:
     lock = filelock.FileLock(str(config_path()) + ".lock")
@@ -463,6 +559,7 @@ def iso_to_human(ts: str) -> str:
 
 
 def main() -> None:
+    signal.signal(signal.SIGPIPE, signal.SIG_DFL)
     logging.basicConfig(level=logging.INFO, format="%(message)s")
     APP()
 
@@ -496,20 +593,31 @@ def releases_display(result: dict[str, Any]) -> None:
         print(f"  {version:<24} {latest:<7} {phase_short:<11} 
{created_formatted}")
 
 
-def test_app_release_list_not_found(
+def test_app_checks_status_non_draft_phase(
     capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, 
tmp_path: pathlib.Path
 ) -> None:
     monkeypatch.setenv("ATR_CLIENT_CONFIG_PATH", str(tmp_path / "atr.yaml"))
     app_set("atr.host", "example.invalid")
+    app_set("tokens.jwt", "dummy_jwt_token")
 
     class Response:
-        status = 404
+        status = 200
 
         async def json(self):
-            return {}
+            return {
+                "data": [
+                    {
+                        "version": "2.3.0",
+                        "phase": "release",
+                        "created": "2024-07-04T00:00:00.000000Z",
+                        "latest_revision_number": "00001",
+                    }
+                ],
+                "count": 1,
+            }
 
         async def text(self):
-            return "Not Found"
+            return ""
 
         async def __aenter__(self):
             return self
@@ -529,40 +637,39 @@ def test_app_release_list_not_found(
 
     monkeypatch.setattr("aiohttp.ClientSession", lambda *a, **kw: Session())
 
-    with pytest.raises(SystemExit):
-        app_release_list("nonexistent-project")
+    app_checks_status("test-project", "2.3.0", "00001")
+    captured = capsys.readouterr()
+    assert "Checks are not applicable for this release phase." in captured.out
+    assert "Checks are only performed during the draft phase." in captured.out
 
 
-def test_app_release_list_success(
+def test_app_checks_status_verbose(
+    capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, 
tmp_path: pathlib.Path
+) -> None:
+    verbose_test_setup(monkeypatch, tmp_path)
+    verbose_test_mock_session(monkeypatch)
+
+    app_checks_status("test-project", "2.3.1", "00003", verbose=True)
+    captured = capsys.readouterr()
+    assert "(top-level" 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
+
+
+def test_app_release_list_not_found(
     capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, 
tmp_path: pathlib.Path
 ) -> None:
     monkeypatch.setenv("ATR_CLIENT_CONFIG_PATH", str(tmp_path / "atr.yaml"))
     app_set("atr.host", "example.invalid")
 
     class Response:
-        status = 200
+        status = 404
 
         async def json(self):
-            return {
-                "data": [
-                    {
-                        "version": "2.3.1",
-                        "phase": "release_candidate_draft",
-                        "created": "2025-01-01T00:00:00.000000Z",
-                        "latest_revision_number": "00003",
-                    },
-                    {
-                        "version": "2.3.0",
-                        "phase": "release",
-                        "created": "2024-07-04T00:00:00.000000Z",
-                        "latest_revision_number": "00001",
-                    },
-                ],
-                "count": 2,
-            }
+            return {}
 
         async def text(self):
-            return ""
+            return "Not Found"
 
         async def __aenter__(self):
             return self
@@ -582,19 +689,15 @@ def test_app_release_list_success(
 
     monkeypatch.setattr("aiohttp.ClientSession", lambda *a, **kw: Session())
 
-    app_release_list("test-project")
-    captured = capsys.readouterr()
-    assert "Total releases: 2" in captured.out
-    assert "2.3.1" in captured.out
-    assert "2.3.0" in captured.out
+    with pytest.raises(SystemExit):
+        app_release_list("nonexistent-project")
 
 
-def test_app_checks_status_non_draft_phase(
+def test_app_release_list_success(
     capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, 
tmp_path: pathlib.Path
 ) -> None:
     monkeypatch.setenv("ATR_CLIENT_CONFIG_PATH", str(tmp_path / "atr.yaml"))
     app_set("atr.host", "example.invalid")
-    app_set("tokens.jwt", "dummy_jwt_token")
 
     class Response:
         status = 200
@@ -602,14 +705,20 @@ def test_app_checks_status_non_draft_phase(
         async def json(self):
             return {
                 "data": [
+                    {
+                        "version": "2.3.1",
+                        "phase": "release_candidate_draft",
+                        "created": "2025-01-01T00:00:00.000000Z",
+                        "latest_revision_number": "00003",
+                    },
                     {
                         "version": "2.3.0",
                         "phase": "release",
                         "created": "2024-07-04T00:00:00.000000Z",
                         "latest_revision_number": "00001",
-                    }
+                    },
                 ],
-                "count": 1,
+                "count": 2,
             }
 
         async def text(self):
@@ -633,10 +742,11 @@ def test_app_checks_status_non_draft_phase(
 
     monkeypatch.setattr("aiohttp.ClientSession", lambda *a, **kw: Session())
 
-    app_checks_status("test-project", "2.3.0", "00001")
+    app_release_list("test-project")
     captured = capsys.readouterr()
-    assert "Checks are not applicable for this release phase." in captured.out
-    assert "Checks are only performed during the draft phase." in captured.out
+    assert "Total releases: 2" in captured.out
+    assert "2.3.1" in captured.out
+    assert "2.3.0" in captured.out
 
 
 def test_app_set_show(
@@ -726,6 +836,99 @@ def timestamp_format(ts: int | str | None) -> str | None:
         return str(ts)
 
 
+def verbose_test_checks_response():
+    class ChecksResponse:
+        status = 200
+
+        async def json(self):
+            return [
+                {
+                    "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",
+                },
+            ]
+
+        async def text(self):
+            return ""
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, exc_type, exc, tb):
+            return False
+
+    return ChecksResponse()
+
+
+def verbose_test_mock_session(monkeypatch: pytest.MonkeyPatch) -> None:
+    class MockSession:
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, exc_type, exc, tb):
+            return False
+
+        def get(self, url, **kwargs):
+            if "releases" in url:
+                return verbose_test_release_response()
+            else:
+                return verbose_test_checks_response()
+
+    monkeypatch.setattr("aiohttp.ClientSession", lambda *a, **kw: 
MockSession())
+
+
+def verbose_test_release_response():
+    class ReleaseResponse:
+        status = 200
+
+        async def json(self):
+            return {
+                "data": [
+                    {
+                        "version": "2.3.1",
+                        "phase": "release_candidate_draft",
+                        "created": "2025-01-01T00:00:00.000000Z",
+                        "latest_revision_number": "00003",
+                    }
+                ],
+                "count": 1,
+            }
+
+        async def text(self):
+            return ""
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, exc_type, exc, tb):
+            return False
+
+    return ReleaseResponse()
+
+
+def verbose_test_setup(monkeypatch: pytest.MonkeyPatch, tmp_path: 
pathlib.Path) -> None:
+    monkeypatch.setenv("ATR_CLIENT_CONFIG_PATH", str(tmp_path / "atr.yaml"))
+    app_set("atr.host", "example.invalid")
+    app_set("tokens.jwt", "dummy_jwt_token")
+
+
 async def web_fetch(url: str, asfuid: str, pat_token: str, verify_ssl: bool = 
True) -> str:
     # TODO: This is PAT request specific
     # Should give this a more specific name


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

Reply via email to