You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/04/16 14:08:20 UTC

(tooling-trusted-releases) branch sbp updated (97e89839 -> f820f929)

This is an automated email from the ASF dual-hosted git repository.

sbp pushed a change to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


    from 97e89839 Improve error messages on safe type validation failures
     add 5307d356 #1074 - HTTP timeouts for external calls
     add d9e47ba8 #1064: Add audit guidance
     add 2de8e6b5 #1041 - switch to committee admin instead of member for key regeneration
     add 9505b00f #1050 - audit guidance for token type discrimination
     add 65b527f3 #1056 - audit guidance for public openapi docs
     add cd70c0e1 #1117 - document visibility of vote tabulation
     new f820f929 Improve the source artifact check UI and add fallbacks

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 atr/admin/__init__.py                    |   7 +-
 atr/api/__init__.py                      |   3 +-
 atr/get/download.py                      |   3 +
 atr/jwtoken.py                           |   6 +-
 atr/sbom/utilities.py                    |   3 +-
 atr/server.py                            |   3 +
 atr/shared/distribution.py               |   5 +-
 atr/shared/web.py                        |  90 ++++++++++++++--------
 atr/tasks/checks/paths.py                |  40 +++++++++-
 atr/tasks/gha.py                         |   5 +-
 tests/unit/test_source_artifact_check.py | 124 +++++++++++++++++++++++++++++--
 11 files changed, 240 insertions(+), 49 deletions(-)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-trusted-releases) 01/01: Improve the source artifact check UI and add fallbacks

Posted by sb...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit f820f929285ad12e303d05ab60ec48ea2a876c86
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Apr 16 15:07:56 2026 +0100

    Improve the source artifact check UI and add fallbacks
---
 atr/shared/web.py                        |  90 ++++++++++++++--------
 atr/tasks/checks/paths.py                |  40 +++++++++-
 tests/unit/test_source_artifact_check.py | 124 +++++++++++++++++++++++++++++--
 3 files changed, 218 insertions(+), 36 deletions(-)

diff --git a/atr/shared/web.py b/atr/shared/web.py
index eb261947..809b227c 100644
--- a/atr/shared/web.py
+++ b/atr/shared/web.py
@@ -37,11 +37,17 @@ def render_checks_summary(
 
     body = htm.Block(htm.div, classes=".card-body")
 
+    release_errors_by_checker: dict[str, list[sql.CheckResult]] = {}
     for error in info.release_level_errors:
-        status_class = ".atr-bg-blocker" if (error.status == sql.CheckResultStatus.BLOCKER) else ".bg-danger"
-        body.div(f".alert{status_class}.text-white")[error.message]
-    for i, stat in enumerate(info.checker_stats):
-        body.append(_render_checker_stat(stat, i, project_key, version_key))
+        release_errors_by_checker.setdefault(error.checker, []).append(error)
+
+    stats_by_checker = {stat.checker: stat for stat in info.checker_stats}
+    all_checkers = sorted(set(release_errors_by_checker) | {s.checker for s in info.checker_stats})
+
+    for index, checker in enumerate(all_checkers):
+        stat = stats_by_checker.get(checker)
+        release_errors = release_errors_by_checker.get(checker, [])
+        body.append(_render_checker_entry(stat, release_errors, index, project_key, version_key))
 
     card.append(body.collect())
     return card.collect()
@@ -51,41 +57,65 @@ def _checker_display_name(checker: str) -> str:
     return checker.removeprefix("atr.tasks.checks.").replace("_", " ").replace(".", " ").title()
 
 
-def _render_checker_stat(
-    stat: types.CheckerStats, index: int, project_key: safe.ProjectKey, version_key: safe.VersionKey
+def _render_checker_entry(
+    stat: types.CheckerStats | None,
+    release_errors: list[sql.CheckResult],
+    index: int,
+    project_key: safe.ProjectKey,
+    version_key: safe.VersionKey,
 ) -> htm.Element:
     stripe_class = ".atr-stripe-odd" if ((index % 2) == 0) else ".atr-stripe-even"
     details = htm.Block(htm.details, classes=f".mb-0.p-2{stripe_class}")
 
+    checker = stat.checker if (stat is not None) else release_errors[0].checker
+    release_failure_count = sum(1 for e in release_errors if e.status != sql.CheckResultStatus.BLOCKER)
+    release_blocker_count = sum(1 for e in release_errors if e.status == sql.CheckResultStatus.BLOCKER)
+    warning_count = stat.warning_count if (stat is not None) else 0
+    failure_count = (stat.failure_count if (stat is not None) else 0) + release_failure_count
+    blocker_count = (stat.blocker_count if (stat is not None) else 0) + release_blocker_count
+
     summary_content: list[htm.Element | str] = []
-    if stat.warning_count > 0:
-        summary_content.append(htpy.span(".badge.bg-warning.text-dark.me-2")[str(stat.warning_count)])
-    if stat.failure_count > 0:
-        summary_content.append(htpy.span(".badge.bg-danger.me-2")[str(stat.failure_count)])
-    if stat.blocker_count > 0:
-        summary_content.append(htpy.span(".badge.atr-bg-blocker.me-2")[str(stat.blocker_count)])
-    summary_content.append(htpy.strong[_checker_display_name(stat.checker)])
+    if warning_count > 0:
+        summary_content.append(htpy.span(".badge.bg-warning.text-dark.me-2")[str(warning_count)])
+    if failure_count > 0:
+        summary_content.append(htpy.span(".badge.bg-danger.me-2")[str(failure_count)])
+    if blocker_count > 0:
+        summary_content.append(htpy.span(".badge.atr-bg-blocker.me-2")[str(blocker_count)])
+    summary_content.append(htpy.strong[_checker_display_name(checker)])
 
     details.summary[*summary_content]
 
     files_div = htm.Block(htm.div, classes=".mt-2.atr-checks-files")
-    all_files = set(stat.failure_files.keys()) | set(stat.warning_files.keys()) | set(stat.blocker_files.keys())
-    for file_path in sorted(all_files):
-        report_url = f"/report/{project_key!s}/{version_key!s}/{file_path}"
-        error_count = stat.failure_files.get(file_path, 0)
-        blocker_count = stat.blocker_files.get(file_path, 0)
-        warning_count = stat.warning_files.get(file_path, 0)
-
-        file_content: list[htm.Element | str] = []
-        if error_count > 0:
-            file_content.append(htpy.span(".badge.bg-danger.me-2")[util.plural(error_count, "error")])
-        if blocker_count > 0:
-            file_content.append(htpy.span(".badge.atr-bg-blocker.me-2")[util.plural(blocker_count, "blocker")])
-        if warning_count > 0:
-            file_content.append(htpy.span(".badge.bg-warning.text-dark.me-2")[util.plural(warning_count, "warning")])
-        file_content.append(htpy.a(href=report_url)[htpy.strong[htpy.code[file_path]]])
-
-        files_div.div[*file_content]
+
+    for error in release_errors:
+        is_blocker = error.status == sql.CheckResultStatus.BLOCKER
+        badge_class = ".atr-bg-blocker" if is_blocker else ".bg-danger"
+        label = util.plural(1, "blocker") if is_blocker else util.plural(1, "error")
+        files_div.div[
+            htpy.span(f".badge{badge_class}.me-2")[label],
+            error.message,
+        ]
+
+    if stat is not None:
+        all_files = set(stat.failure_files.keys()) | set(stat.warning_files.keys()) | set(stat.blocker_files.keys())
+        for file_path in sorted(all_files):
+            report_url = f"/report/{project_key!s}/{version_key!s}/{file_path}"
+            file_error_count = stat.failure_files.get(file_path, 0)
+            file_blocker_count = stat.blocker_files.get(file_path, 0)
+            file_warning_count = stat.warning_files.get(file_path, 0)
+
+            file_content: list[htm.Element | str] = []
+            if file_error_count > 0:
+                file_content.append(htpy.span(".badge.bg-danger.me-2")[util.plural(file_error_count, "error")])
+            if file_blocker_count > 0:
+                file_content.append(htpy.span(".badge.atr-bg-blocker.me-2")[util.plural(file_blocker_count, "blocker")])
+            if file_warning_count > 0:
+                file_content.append(
+                    htpy.span(".badge.bg-warning.text-dark.me-2")[util.plural(file_warning_count, "warning")]
+                )
+            file_content.append(htpy.a(href=report_url)[htpy.strong[htpy.code[file_path]]])
+
+            files_div.div[*file_content]
 
     details.append(files_div.collect())
     return details.collect()
diff --git a/atr/tasks/checks/paths.py b/atr/tasks/checks/paths.py
index 991d3669..20ff7b20 100644
--- a/atr/tasks/checks/paths.py
+++ b/atr/tasks/checks/paths.py
@@ -23,6 +23,8 @@ from typing import Final
 import aiofiles.os
 
 import atr.analysis as analysis
+import atr.attestable as attestable
+import atr.classify as classify
 import atr.db as db
 import atr.log as log
 import atr.models.results as results
@@ -110,7 +112,7 @@ async def check(args: checks.FunctionArguments) -> results.Results | None:
             is_podling,
         )
 
-    await _check_source_artifact_present(args, recorder_errors)
+    await _check_source_artifact_present(args, recorder_errors, relative_paths, base_path)
 
     return None
 
@@ -296,12 +298,48 @@ async def _check_path_process_single(  # noqa: C901
 async def _check_source_artifact_present(
     args: checks.FunctionArguments,
     recorder_errors: checks.Recorder,
+    relative_paths: list[safe.RelPath],
+    base_path: safe.StatePath,
 ) -> None:
     release_key_str = str(sql.release_key(args.project_key, args.version_key))
     revision_seq = int(str(args.revision_number))
     async with db.session() as data:
         classifications = await data.release_file_classifications_at(release_key_str, revision_seq)
 
+    missing_paths = [p for p in relative_paths if str(p) not in classifications]
+    if missing_paths:
+        attestable_data = await attestable.load(args.project_key, args.version_key, args.revision_number)
+        if attestable_data is not None:
+            policy = attestable_data.policy or {}
+            source_matcher, binary_matcher = classify.matchers_from_policy(
+                policy.get("source_artifact_paths", []),
+                policy.get("binary_artifact_paths", []),
+                base_path,
+            )
+            for path in missing_paths:
+                path_str = str(path)
+                cls = attestable.path_classification(attestable_data, path_str)
+                if cls is not None:
+                    classifications[path_str] = cls
+                else:
+                    classifications[path_str] = classify.classify(
+                        path, base_path, source_matcher=source_matcher, binary_matcher=binary_matcher
+                    ).value
+        else:
+            async with db.session() as data:
+                project = await data.project(key=str(args.project_key), _release_policy=True).demand(
+                    RuntimeError(f"Project {args.project_key} not found")
+                )
+            source_matcher, binary_matcher = classify.matchers_from_policy(
+                project.policy_source_artifact_paths,
+                project.policy_binary_artifact_paths,
+                base_path,
+            )
+            for path in missing_paths:
+                classifications[str(path)] = classify.classify(
+                    path, base_path, source_matcher=source_matcher, binary_matcher=binary_matcher
+                ).value
+
     source_artifacts = sorted(
         path for path, cls in classifications.items() if (cls == "source") and analysis.is_artifact(path)
     )
diff --git a/tests/unit/test_source_artifact_check.py b/tests/unit/test_source_artifact_check.py
index 14887350..1060ef0c 100644
--- a/tests/unit/test_source_artifact_check.py
+++ b/tests/unit/test_source_artifact_check.py
@@ -39,7 +39,8 @@ async def test_binary_only_artifacts_records_failure(monkeypatch: pytest.MonkeyP
         "apache-test-1.0-bin.tar.gz.sha512": "metadata",
     }
     monkeypatch.setattr("atr.db.session", lambda: _mock_db_session(classifications))
-    await paths._check_source_artifact_present(args, recorder)
+    relative_paths = [safe.RelPath(p) for p in classifications]
+    await paths._check_source_artifact_present(args, recorder, relative_paths, safe.StatePath(tmp_path))
     failures = [(s, m) for s, m, _ in recorder.messages if s == "failure"]
     assert len(failures) == 1
     assert "source release artifact" in failures[0][1]
@@ -80,6 +81,106 @@ async def test_failure_without_path_goes_to_release_level_errors():
     assert info.release_level_errors[0] is result
 
 
+@pytest.mark.asyncio
+async def test_fallback_for_partial_db_classifications(monkeypatch: pytest.MonkeyPatch, tmp_path):
+    recorder = RecorderStub(safe.StatePath(tmp_path), "atr.tasks.checks.paths.check_errors", "3")
+    args = _make_function_args(recorder)
+    classifications = {
+        "apache-test-1.0-source.tar.gz.sha512": "metadata",
+    }
+    monkeypatch.setattr("atr.db.session", lambda: _mock_db_session(classifications))
+    monkeypatch.setattr("atr.attestable.load", mock.AsyncMock(return_value=None))
+    relative_paths = [
+        safe.RelPath("apache-test-1.0-source.tar.gz"),
+        safe.RelPath("apache-test-1.0-source.tar.gz.sha512"),
+    ]
+    await paths._check_source_artifact_present(args, recorder, relative_paths, safe.StatePath(tmp_path))
+    assert not any(s == "failure" for s, _, _ in recorder.messages)
+
+
+@pytest.mark.asyncio
+async def test_fallback_to_attestable_when_db_empty(monkeypatch: pytest.MonkeyPatch, tmp_path):
+    recorder = RecorderStub(safe.StatePath(tmp_path), "atr.tasks.checks.paths.check_errors", "3")
+    args = _make_function_args(recorder)
+    monkeypatch.setattr("atr.db.session", lambda: _mock_db_session({}))
+    attestable_data = mock.MagicMock()
+    monkeypatch.setattr("atr.attestable.load", mock.AsyncMock(return_value=attestable_data))
+    monkeypatch.setattr(
+        "atr.attestable.path_classification",
+        lambda _att, path: "source" if path.endswith(".tar.gz") else "metadata",
+    )
+    relative_paths = [
+        safe.RelPath("apache-test-1.0-source.tar.gz"),
+        safe.RelPath("apache-test-1.0-source.tar.gz.sha512"),
+    ]
+    await paths._check_source_artifact_present(args, recorder, relative_paths, safe.StatePath(tmp_path))
+    assert not any(s == "failure" for s, _, _ in recorder.messages)
+
+
+@pytest.mark.asyncio
+async def test_fallback_to_classify_binary_only_records_failure(monkeypatch: pytest.MonkeyPatch, tmp_path):
+    recorder = RecorderStub(safe.StatePath(tmp_path), "atr.tasks.checks.paths.check_errors", "3")
+    args = _make_function_args(recorder)
+    monkeypatch.setattr("atr.db.session", lambda: _mock_db_session({}))
+    monkeypatch.setattr("atr.attestable.load", mock.AsyncMock(return_value=None))
+    relative_paths = [
+        safe.RelPath("apache-test-1.0-bin.tar.gz"),
+        safe.RelPath("apache-test-1.0-bin.tar.gz.sha512"),
+    ]
+    await paths._check_source_artifact_present(args, recorder, relative_paths, safe.StatePath(tmp_path))
+    failures = [(s, m) for s, m, _ in recorder.messages if s == "failure"]
+    assert len(failures) == 1
+    assert "source release artifact" in failures[0][1]
+
+
+@pytest.mark.asyncio
+async def test_fallback_to_classify_uses_attestable_policy_matchers(monkeypatch: pytest.MonkeyPatch, tmp_path):
+    recorder = RecorderStub(safe.StatePath(tmp_path), "atr.tasks.checks.paths.check_errors", "3")
+    args = _make_function_args(recorder)
+    monkeypatch.setattr("atr.db.session", lambda: _mock_db_session({}))
+    attestable_data = mock.MagicMock()
+    attestable_data.policy = {"source_artifact_paths": ["*.tar.gz"]}
+    monkeypatch.setattr("atr.attestable.load", mock.AsyncMock(return_value=attestable_data))
+    monkeypatch.setattr("atr.attestable.path_classification", lambda _att, _path: None)
+    relative_paths = [
+        safe.RelPath("my-project-1.0.tar.gz"),
+        safe.RelPath("my-project-1.0.tar.gz.sha512"),
+    ]
+    await paths._check_source_artifact_present(args, recorder, relative_paths, safe.StatePath(tmp_path))
+    assert not any(s == "failure" for s, _, _ in recorder.messages)
+
+
+@pytest.mark.asyncio
+async def test_fallback_to_classify_uses_project_policy_matchers(monkeypatch: pytest.MonkeyPatch, tmp_path):
+    recorder = RecorderStub(safe.StatePath(tmp_path), "atr.tasks.checks.paths.check_errors", "3")
+    args = _make_function_args(recorder)
+    monkeypatch.setattr(
+        "atr.db.session",
+        lambda: _mock_db_session({}, source_artifact_paths=["*.tar.gz"]),
+    )
+    monkeypatch.setattr("atr.attestable.load", mock.AsyncMock(return_value=None))
+    relative_paths = [
+        safe.RelPath("my-project-1.0.tar.gz"),
+        safe.RelPath("my-project-1.0.tar.gz.sha512"),
+    ]
+    await paths._check_source_artifact_present(args, recorder, relative_paths, safe.StatePath(tmp_path))
+    assert not any(s == "failure" for s, _, _ in recorder.messages)
+
+
+@pytest.mark.asyncio
+async def test_fallback_to_classify_when_no_attestable(monkeypatch: pytest.MonkeyPatch, tmp_path):
+    recorder = RecorderStub(safe.StatePath(tmp_path), "atr.tasks.checks.paths.check_errors", "3")
+    args = _make_function_args(recorder)
+    monkeypatch.setattr("atr.db.session", lambda: _mock_db_session({}))
+    monkeypatch.setattr("atr.attestable.load", mock.AsyncMock(return_value=None))
+    relative_paths = [
+        safe.RelPath("apache-test-1.0-source.tar.gz"),
+        safe.RelPath("apache-test-1.0-source.tar.gz.sha512"),
+    ]
+    await paths._check_source_artifact_present(args, recorder, relative_paths, safe.StatePath(tmp_path))
+    assert not any(s == "failure" for s, _, _ in recorder.messages)
+
+
 @pytest.mark.asyncio
 async def test_no_artifacts_records_failure(monkeypatch: pytest.MonkeyPatch, tmp_path):
     recorder = RecorderStub(safe.StatePath(tmp_path), "atr.tasks.checks.paths.check_errors", "3")
@@ -89,7 +190,8 @@ async def test_no_artifacts_records_failure(monkeypatch: pytest.MonkeyPatch, tmp
         "LICENSE": "docs",
     }
     monkeypatch.setattr("atr.db.session", lambda: _mock_db_session(classifications))
-    await paths._check_source_artifact_present(args, recorder)
+    relative_paths = [safe.RelPath(p) for p in classifications]
+    await paths._check_source_artifact_present(args, recorder, relative_paths, safe.StatePath(tmp_path))
     failures = [(s, m) for s, m, _ in recorder.messages if s == "failure"]
     assert len(failures) == 1
     assert "source release artifact" in failures[0][1]
@@ -121,7 +223,8 @@ async def test_source_artifact_present_no_failure(monkeypatch: pytest.MonkeyPatc
         "apache-test-1.0-source.tar.gz.sha512": "metadata",
     }
     monkeypatch.setattr("atr.db.session", lambda: _mock_db_session(classifications))
-    await paths._check_source_artifact_present(args, recorder)
+    relative_paths = [safe.RelPath(p) for p in classifications]
+    await paths._check_source_artifact_present(args, recorder, relative_paths, safe.StatePath(tmp_path))
     assert not any(s == "failure" for s, _, _ in recorder.messages)
 
 
@@ -133,7 +236,8 @@ async def test_source_classified_non_artifact_still_records_failure(monkeypatch:
         "README.md": "source",
     }
     monkeypatch.setattr("atr.db.session", lambda: _mock_db_session(classifications))
-    await paths._check_source_artifact_present(args, recorder)
+    relative_paths = [safe.RelPath(p) for p in classifications]
+    await paths._check_source_artifact_present(args, recorder, relative_paths, safe.StatePath(tmp_path))
     failures = [(s, m) for s, m, _ in recorder.messages if s == "failure"]
     assert len(failures) == 1
     assert "source release artifact" in failures[0][1]
@@ -183,9 +287,19 @@ def _make_reader() -> releases.GeneralPublic:
     return releases.GeneralPublic(read, read_as, data)
 
 
-def _mock_db_session(classifications: dict[str, str]) -> mock.AsyncMock:
+def _mock_db_session(
+    classifications: dict[str, str],
+    source_artifact_paths: list[str] | None = None,
+    binary_artifact_paths: list[str] | None = None,
+) -> mock.AsyncMock:
     mock_data = mock.AsyncMock()
     mock_data.release_file_classifications_at = mock.AsyncMock(return_value=classifications)
+    mock_project = mock.MagicMock()
+    mock_project.policy_source_artifact_paths = source_artifact_paths or []
+    mock_project.policy_binary_artifact_paths = binary_artifact_paths or []
+    mock_query = mock.MagicMock()
+    mock_query.demand = mock.AsyncMock(return_value=mock_project)
+    mock_data.project = mock.MagicMock(return_value=mock_query)
     mock_session_ctx = mock.AsyncMock()
     mock_session_ctx.__aenter__ = mock.AsyncMock(return_value=mock_data)
     mock_session_ctx.__aexit__ = mock.AsyncMock(return_value=False)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org