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

(tooling-trusted-releases) branch arm updated: Add catalog API endpoint and refactor existing logic to be shared between this and the UI.

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

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


The following commit(s) were added to refs/heads/arm by this push:
     new c0cdd018 Add catalog API endpoint and refactor existing logic to be shared between this and the UI.
c0cdd018 is described below

commit c0cdd018f0897552d66f32b17330f9761d3b921d
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Jun 4 17:53:03 2026 +0100

    Add catalog API endpoint and refactor existing logic to be shared between this and the UI.
---
 atr/api/__init__.py                 |  40 +++++++
 atr/get/catalog.py                  | 171 +++---------------------------
 atr/models/api.py                   |  51 +++++++++
 atr/{get => shared}/catalog.py      | 206 ++++++++++++++++--------------------
 atr/templates/_catalog_version.html |   6 +-
 atr/templates/catalog.html          |   2 +-
 tests/unit/test_catalog.py          |  55 ++++++++--
 7 files changed, 243 insertions(+), 288 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index bda6b984..d2f1fafb 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -46,6 +46,7 @@ import atr.models.unsafe as unsafe
 import atr.models.validation as validation
 import atr.paths as paths
 import atr.principal as principal
+import atr.shared.catalog as catalog
 import atr.storage as storage
 import atr.storage.outcome as outcome
 import atr.storage.types as types
@@ -181,6 +182,45 @@ async def checks_ongoing(
     ).model_dump(mode="json"), 200
 
 
+@api.typed
+@api.auth.public
+@quart_schema.validate_response(models.api.CatalogProjectResults, 200)
+async def catalog_project(
+    _catalog_project: Literal["catalog/project"],
+    project_key: safe.ProjectKey,
+) -> DictResponse:
+    """
+    URL: GET /catalog/project/<project_key>
+
+    The release catalogue for a project as JSON: every catalogued version with
+    its artifacts, grouped into cycles for projects that use them.
+    """
+    async with db.session() as data:
+        project = await data.project(key=str(project_key), status=sql.ProjectStatus.ACTIVE).demand(
+            exceptions.NotFound(f"Project '{project_key!s}' was not found")
+        )
+        artifacts = await data.artifact(project_key=project.key, _release=True).all()
+        project_cycles = await data.project_cycle(project_key=project.key).all()
+
+    atr_host = config.get().APP_HOST
+    assembled = catalog.assemble(
+        project.version_method,
+        artifacts,
+        project_cycles,
+        project.committee,
+        datetime.datetime.now(datetime.UTC),
+        atr_host=atr_host,
+    )
+    return models.api.CatalogProjectResults(
+        endpoint="/catalog/project",
+        project=project.key,
+        cle_url=catalog.cle_project_url(atr_host, project.key),
+        grouped=assembled.grouped,
+        versions=assembled.versions,
+        cycles=assembled.cycles,
+    ).model_dump(mode="json"), 200
+
+
 @api.typed
 @api.auth.public
 async def cle_project(
diff --git a/atr/get/catalog.py b/atr/get/catalog.py
index a470ed8a..d3e12776 100644
--- a/atr/get/catalog.py
+++ b/atr/get/catalog.py
@@ -15,11 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import collections
-import dataclasses
 import datetime
-from collections.abc import Sequence
-from typing import Final, Literal
+from typing import Literal
 
 import asfquart.base as base
 
@@ -28,12 +25,11 @@ import atr.blueprints.get as get
 import atr.db as db
 import atr.models.safe as safe
 import atr.models.sql as sql
+import atr.shared.catalog as catalog
 import atr.template as template
 import atr.util as util
 import atr.web as web
 
-_GROUPED_METHODS: Final[frozenset[sql.VersionMethod]] = frozenset({sql.VersionMethod.SEMVER, sql.VersionMethod.CALVER})
-
 
 @get.typed
 async def project(_session: web.Public, _catalog: Literal["catalog"], project_key: safe.ProjectKey) -> str:
@@ -48,163 +44,20 @@ async def project(_session: web.Public, _catalog: Literal["catalog"], project_ke
         artifacts = await data.artifact(project_key=project_obj.key, _release=True).all()
         project_cycles = await data.project_cycle(project_key=project_obj.key).all()
 
-    cycles_by_key = {cycle.cycle_key: cycle for cycle in project_cycles}
-    now = datetime.datetime.now(datetime.UTC)
-    versions = _catalog_versions(artifacts, cycles_by_key, project_obj.committee)
-    grouped = _grouped_layout(project_obj.version_method)
-    cycles = _catalog_cycles(versions, now) if grouped else []
+    assembled = catalog.assemble(
+        project_obj.version_method,
+        artifacts,
+        project_cycles,
+        project_obj.committee,
+        datetime.datetime.now(datetime.UTC),
+    )
 
     return await template.render(
         "catalog.html",
         project=project_obj,
-        versions=versions,
-        cycles=cycles,
-        grouped=grouped,
+        versions=assembled.versions,
+        cycles=assembled.cycles,
+        grouped=assembled.grouped,
         api=api,
         format_datetime=util.format_datetime,
     )
-
-
-@dataclasses.dataclass
-class _CatalogArtifact:
-    artifact_path: str
-    classification: str | None
-    signature_path: str | None
-    checksum_path: str | None
-    key_fingerprint: str | None
-    svn_revision: int | None
-    downloadable: bool
-    # Public download URLs: the artifact via the mirror network, its signature and
-    # checksum from downloads.apache.org. None when the version is not downloadable.
-    artifact_url: str | None
-    signature_url: str | None
-    checksum_url: str | None
-
-
-@dataclasses.dataclass
-class _CatalogVersion:
-    version: str
-    status: Literal["released", "archived"]
-    released: datetime.datetime | None
-    svn_revision: int | None
-    cycle: sql.ProjectCycle | None
-    artifacts: list[_CatalogArtifact]
-
-
-@dataclasses.dataclass
-class _CatalogCycle:
-    cycle: sql.ProjectCycle | None
-    lifecycle: str | None
-    versions: list[_CatalogVersion]
-
-
-def _grouped_layout(version_method: sql.VersionMethod) -> bool:
-    # Simple projects have only the default cycle, so they skip cycle grouping.
-    return version_method in _GROUPED_METHODS
-
-
-def _catalog_artifact(row: sql.Artifact, committee: sql.Committee | None, downloadable: bool) -> _CatalogArtifact:
-    suffix = safe.RelPath(row.download_path_suffix) if row.download_path_suffix else None
-    artifact_url: str | None = None
-    signature_url: str | None = None
-    checksum_url: str | None = None
-    if downloadable and (committee is not None):
-        artifact_url = util.public_download_url(
-            committee, suffix, util.DownloadFile.ARTIFACT, filename=row.artifact_path
-        )
-        if row.signature_path:
-            signature_url = util.public_download_url(
-                committee, suffix, util.DownloadFile.METADATA, filename=row.signature_path
-            )
-        if row.checksum_path:
-            checksum_url = util.public_download_url(
-                committee, suffix, util.DownloadFile.METADATA, filename=row.checksum_path
-            )
-    return _CatalogArtifact(
-        artifact_path=row.artifact_path,
-        classification=row.classification,
-        signature_path=row.signature_path,
-        checksum_path=row.checksum_path,
-        key_fingerprint=row.key_fingerprint,
-        svn_revision=row.svn_revision,
-        downloadable=downloadable,
-        artifact_url=artifact_url,
-        signature_url=signature_url,
-        checksum_url=checksum_url,
-    )
-
-
-def _catalog_versions(
-    artifacts: Sequence[sql.Artifact],
-    cycles_by_key: dict[str, sql.ProjectCycle],
-    committee: sql.Committee | None,
-) -> list[_CatalogVersion]:
-    by_version: dict[str, list[sql.Artifact]] = collections.defaultdict(list)
-    for artifact in artifacts:
-        by_version[artifact.version].append(artifact)
-
-    versions: list[_CatalogVersion] = []
-    for version, rows in by_version.items():
-        release = next((row.release for row in rows if row.release is not None), None)
-        status = _status(release)
-        # Archived versions aren't served from the download route, so show metadata only.
-        downloadable = status == "released"
-        cycle = cycles_by_key.get(release.cycle_key) if (release is not None) else None
-        svn_revisions = [row.svn_revision for row in rows if row.svn_revision is not None]
-        versions.append(
-            _CatalogVersion(
-                version=version,
-                status=status,
-                released=(release.released or release.created) if (release is not None) else None,
-                svn_revision=max(svn_revisions) if svn_revisions else None,
-                cycle=cycle,
-                artifacts=[_catalog_artifact(row, committee, downloadable) for row in rows],
-            )
-        )
-
-    versions.sort(key=_version_sort_key, reverse=True)
-    return versions
-
-
-def _catalog_cycles(versions: list[_CatalogVersion], now: datetime.datetime) -> list[_CatalogCycle]:
-    by_cycle: dict[str, list[_CatalogVersion]] = collections.defaultdict(list)
-    cycles: dict[str, sql.ProjectCycle] = {}
-    for version in versions:
-        if version.cycle is None:
-            continue
-        by_cycle[version.cycle.cycle_key].append(version)
-        cycles[version.cycle.cycle_key] = version.cycle
-
-    catalog = [
-        _CatalogCycle(cycle=cycles[key], lifecycle=_lifecycle_badge(cycles[key], now), versions=grouped)
-        for key, grouped in by_cycle.items()
-    ]
-    catalog.sort(key=_cycle_sort_key, reverse=True)
-    return catalog
-
-
-def _lifecycle_badge(cycle: sql.ProjectCycle, now: datetime.datetime) -> str:
-    if cycle.lts:
-        return "LTS"
-    if (cycle.eol is not None) and (cycle.eol <= now):
-        return "EOL"
-    return "Active"
-
-
-def _cycle_sort_key(cycle: _CatalogCycle) -> datetime.datetime:
-    # Most recently active cycle first.
-    latest = cycle.cycle.latest if (cycle.cycle is not None) else None
-    return latest or datetime.datetime.min.replace(tzinfo=datetime.UTC)
-
-
-def _status(release: sql.Release | None) -> Literal["released", "archived"]:
-    # Treat a missing release as archived, so it is never shown as downloadable.
-    if (release is not None) and (release.archived is None):
-        return "released"
-    return "archived"
-
-
-def _version_sort_key(version: _CatalogVersion) -> tuple[datetime.datetime, int]:
-    # Newest first: released date leads, svn revision breaks ties for older rows.
-    released = version.released or datetime.datetime.min.replace(tzinfo=datetime.UTC)
-    return (released, version.svn_revision or 0)
diff --git a/atr/models/api.py b/atr/models/api.py
index 83e17359..f058bb91 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -34,6 +34,57 @@ class ResultsTypeError(TypeError):
     pass
 
 
+class CatalogArtifact(schema.Strict):
+    artifact_path: str
+    classification: str | None
+    signature_path: str | None
+    checksum_path: str | None
+    key_fingerprint: str | None
+    svn_revision: int | None
+    downloadable: bool
+    # Public download URLs: the artifact via the mirror network, its signature and
+    # checksum from downloads.apache.org. None when the version is not downloadable.
+    artifact_url: str | None
+    signature_url: str | None
+    checksum_url: str | None
+
+
+class CatalogVersion(schema.Strict):
+    version: str
+    status: Literal["released", "archived"]
+    released: datetime.datetime | None
+    svn_revision: int | None
+    # The cycle's display label (e.g. "2.x"), or None for versions outside any cycle.
+    cycle: str | None
+    # The per-version CLE feed, or None when no released-phase record backs this version.
+    cle_url: str | None = None
+    artifacts: Sequence[CatalogArtifact]
+
+    @pydantic.field_validator("released", mode="before")
+    @classmethod
+    def released_from_iso(cls, v):
+        # Strict mode won't coerce on its own, so accept the ISO string a JSON
+        # round-trip leaves behind (e.g. validate_response re-reading our dump).
+        return datetime.datetime.fromisoformat(v) if isinstance(v, str) else v
+
+
+class CatalogCycle(schema.Strict):
+    cycle: str
+    lifecycle: str | None
+    versions: Sequence[CatalogVersion]
+
+
+class CatalogProjectResults(schema.Strict):
+    endpoint: Literal["/catalog/project"] = schema.alias("endpoint")
+    project: str
+    # The project-wide CLE feed covering every cycle and release.
+    cle_url: str | None = None
+    # True when the project groups versions into cycles; then read `cycles`, else `versions`.
+    grouped: bool
+    versions: Sequence[CatalogVersion]
+    cycles: Sequence[CatalogCycle]
+
+
 class ChecksListResults(schema.Strict):
     endpoint: Literal["/checks/list"] = schema.alias("endpoint")
     checks: Sequence[sql.CheckResult]
diff --git a/atr/get/catalog.py b/atr/shared/catalog.py
similarity index 57%
copy from atr/get/catalog.py
copy to atr/shared/catalog.py
index a470ed8a..fae37fce 100644
--- a/atr/get/catalog.py
+++ b/atr/shared/catalog.py
@@ -21,89 +21,52 @@ import datetime
 from collections.abc import Sequence
 from typing import Final, Literal
 
-import asfquart.base as base
-
-import atr.api as api
-import atr.blueprints.get as get
-import atr.db as db
+import atr.models as models
 import atr.models.safe as safe
 import atr.models.sql as sql
-import atr.template as template
 import atr.util as util
-import atr.web as web
 
 _GROUPED_METHODS: Final[frozenset[sql.VersionMethod]] = frozenset({sql.VersionMethod.SEMVER, sql.VersionMethod.CALVER})
 
 
-@get.typed
-async def project(_session: web.Public, _catalog: Literal["catalog"], project_key: safe.ProjectKey) -> str:
-    """
-    URL: /catalog/<project_key>
-    The release catalogue for a project: versions and their artifacts.
-    """
-    async with db.session() as data:
-        project_obj = await data.project(key=str(project_key), status=sql.ProjectStatus.ACTIVE).demand(
-            base.ASFQuartException(f"Project {project_key} not found", errorcode=404)
-        )
-        artifacts = await data.artifact(project_key=project_obj.key, _release=True).all()
-        project_cycles = await data.project_cycle(project_key=project_obj.key).all()
-
-    cycles_by_key = {cycle.cycle_key: cycle for cycle in project_cycles}
-    now = datetime.datetime.now(datetime.UTC)
-    versions = _catalog_versions(artifacts, cycles_by_key, project_obj.committee)
-    grouped = _grouped_layout(project_obj.version_method)
-    cycles = _catalog_cycles(versions, now) if grouped else []
-
-    return await template.render(
-        "catalog.html",
-        project=project_obj,
-        versions=versions,
-        cycles=cycles,
-        grouped=grouped,
-        api=api,
-        format_datetime=util.format_datetime,
-    )
-
-
 @dataclasses.dataclass
-class _CatalogArtifact:
-    artifact_path: str
-    classification: str | None
-    signature_path: str | None
-    checksum_path: str | None
-    key_fingerprint: str | None
-    svn_revision: int | None
-    downloadable: bool
-    # Public download URLs: the artifact via the mirror network, its signature and
-    # checksum from downloads.apache.org. None when the version is not downloadable.
-    artifact_url: str | None
-    signature_url: str | None
-    checksum_url: str | None
+class CatalogProject:
+    versions: list[models.api.CatalogVersion]
+    cycles: list[models.api.CatalogCycle]
+    grouped: bool
 
 
-@dataclasses.dataclass
-class _CatalogVersion:
-    version: str
-    status: Literal["released", "archived"]
-    released: datetime.datetime | None
-    svn_revision: int | None
-    cycle: sql.ProjectCycle | None
-    artifacts: list[_CatalogArtifact]
+def cle_project_url(atr_host: str, project_key: str) -> str:
+    return f"https://{atr_host}/api/cle/project/{project_key}"
 
 
-@dataclasses.dataclass
-class _CatalogCycle:
-    cycle: sql.ProjectCycle | None
-    lifecycle: str | None
-    versions: list[_CatalogVersion]
+def cle_release_url(atr_host: str, project_key: str, version: str) -> str:
+    return f"https://{atr_host}/api/cle/release/{project_key}/{version}"
 
 
-def _grouped_layout(version_method: sql.VersionMethod) -> bool:
-    # Simple projects have only the default cycle, so they skip cycle grouping.
-    return version_method in _GROUPED_METHODS
-
-
-def _catalog_artifact(row: sql.Artifact, committee: sql.Committee | None, downloadable: bool) -> _CatalogArtifact:
+def assemble(
+    version_method: sql.VersionMethod,
+    artifacts: Sequence[sql.Artifact],
+    project_cycles: Sequence[sql.ProjectCycle],
+    committee: sql.Committee | None,
+    now: datetime.datetime,
+    atr_host: str | None = None,
+) -> CatalogProject:
+    # `atr_host` is only supplied by the API, which needs absolute CLE links. The
+    # page leaves it None and renders its own relative links via as_url instead.
+    cycles_by_key = {cycle.cycle_key: cycle for cycle in project_cycles}
+    versions = _versions(artifacts, cycles_by_key, committee, atr_host)
+    grouped = _grouped_layout(version_method)
+    if grouped:
+        # Cycle labels are unique within a project, so they group and look up safely.
+        cycles_by_label = {cycle.cycle: cycle for cycle in project_cycles}
+        cycles = _cycles(versions, cycles_by_label, now)
+    else:
+        cycles = []
+    return CatalogProject(versions=versions, cycles=cycles, grouped=grouped)
+
+
+def _artifact(row: sql.Artifact, committee: sql.Committee | None, downloadable: bool) -> models.api.CatalogArtifact:
     suffix = safe.RelPath(row.download_path_suffix) if row.download_path_suffix else None
     artifact_url: str | None = None
     signature_url: str | None = None
@@ -120,7 +83,7 @@ def _catalog_artifact(row: sql.Artifact, committee: sql.Committee | None, downlo
             checksum_url = util.public_download_url(
                 committee, suffix, util.DownloadFile.METADATA, filename=row.checksum_path
             )
-    return _CatalogArtifact(
+    return models.api.CatalogArtifact(
         artifact_path=row.artifact_path,
         classification=row.classification,
         signature_path=row.signature_path,
@@ -134,55 +97,35 @@ def _catalog_artifact(row: sql.Artifact, committee: sql.Committee | None, downlo
     )
 
 
-def _catalog_versions(
-    artifacts: Sequence[sql.Artifact],
-    cycles_by_key: dict[str, sql.ProjectCycle],
-    committee: sql.Committee | None,
-) -> list[_CatalogVersion]:
-    by_version: dict[str, list[sql.Artifact]] = collections.defaultdict(list)
-    for artifact in artifacts:
-        by_version[artifact.version].append(artifact)
-
-    versions: list[_CatalogVersion] = []
-    for version, rows in by_version.items():
-        release = next((row.release for row in rows if row.release is not None), None)
-        status = _status(release)
-        # Archived versions aren't served from the download route, so show metadata only.
-        downloadable = status == "released"
-        cycle = cycles_by_key.get(release.cycle_key) if (release is not None) else None
-        svn_revisions = [row.svn_revision for row in rows if row.svn_revision is not None]
-        versions.append(
-            _CatalogVersion(
-                version=version,
-                status=status,
-                released=(release.released or release.created) if (release is not None) else None,
-                svn_revision=max(svn_revisions) if svn_revisions else None,
-                cycle=cycle,
-                artifacts=[_catalog_artifact(row, committee, downloadable) for row in rows],
-            )
-        )
-
-    versions.sort(key=_version_sort_key, reverse=True)
-    return versions
+def _grouped_layout(version_method: sql.VersionMethod) -> bool:
+    # Simple projects have only the default cycle, so they skip cycle grouping.
+    return version_method in _GROUPED_METHODS
 
 
-def _catalog_cycles(versions: list[_CatalogVersion], now: datetime.datetime) -> list[_CatalogCycle]:
-    by_cycle: dict[str, list[_CatalogVersion]] = collections.defaultdict(list)
-    cycles: dict[str, sql.ProjectCycle] = {}
+def _cycles(
+    versions: Sequence[models.api.CatalogVersion],
+    cycles_by_label: dict[str, sql.ProjectCycle],
+    now: datetime.datetime,
+) -> list[models.api.CatalogCycle]:
+    by_label: dict[str, list[models.api.CatalogVersion]] = collections.defaultdict(list)
     for version in versions:
         if version.cycle is None:
             continue
-        by_cycle[version.cycle.cycle_key].append(version)
-        cycles[version.cycle.cycle_key] = version.cycle
+        by_label[version.cycle].append(version)
 
     catalog = [
-        _CatalogCycle(cycle=cycles[key], lifecycle=_lifecycle_badge(cycles[key], now), versions=grouped)
-        for key, grouped in by_cycle.items()
+        models.api.CatalogCycle(cycle=label, lifecycle=_lifecycle_badge(cycles_by_label[label], now), versions=grouped)
+        for label, grouped in by_label.items()
     ]
-    catalog.sort(key=_cycle_sort_key, reverse=True)
+    catalog.sort(key=lambda c: _cycle_sort_key(cycles_by_label[c.cycle]), reverse=True)
     return catalog
 
 
+def _cycle_sort_key(cycle: sql.ProjectCycle) -> datetime.datetime:
+    # Most recently active cycle first.
+    return cycle.latest or datetime.datetime.min.replace(tzinfo=datetime.UTC)
+
+
 def _lifecycle_badge(cycle: sql.ProjectCycle, now: datetime.datetime) -> str:
     if cycle.lts:
         return "LTS"
@@ -191,12 +134,6 @@ def _lifecycle_badge(cycle: sql.ProjectCycle, now: datetime.datetime) -> str:
     return "Active"
 
 
-def _cycle_sort_key(cycle: _CatalogCycle) -> datetime.datetime:
-    # Most recently active cycle first.
-    latest = cycle.cycle.latest if (cycle.cycle is not None) else None
-    return latest or datetime.datetime.min.replace(tzinfo=datetime.UTC)
-
-
 def _status(release: sql.Release | None) -> Literal["released", "archived"]:
     # Treat a missing release as archived, so it is never shown as downloadable.
     if (release is not None) and (release.archived is None):
@@ -204,7 +141,46 @@ def _status(release: sql.Release | None) -> Literal["released", "archived"]:
     return "archived"
 
 
-def _version_sort_key(version: _CatalogVersion) -> tuple[datetime.datetime, int]:
+def _version_sort_key(version: models.api.CatalogVersion) -> tuple[datetime.datetime, int]:
     # Newest first: released date leads, svn revision breaks ties for older rows.
     released = version.released or datetime.datetime.min.replace(tzinfo=datetime.UTC)
     return (released, version.svn_revision or 0)
+
+
+def _versions(
+    artifacts: Sequence[sql.Artifact],
+    cycles_by_key: dict[str, sql.ProjectCycle],
+    committee: sql.Committee | None,
+    atr_host: str | None = None,
+) -> list[models.api.CatalogVersion]:
+    by_version: dict[str, list[sql.Artifact]] = collections.defaultdict(list)
+    for artifact in artifacts:
+        by_version[artifact.version].append(artifact)
+
+    versions: list[models.api.CatalogVersion] = []
+    for version, rows in by_version.items():
+        release = next((row.release for row in rows if row.release is not None), None)
+        status = _status(release)
+        # Archived versions aren't served from the download route, so show metadata only.
+        downloadable = status == "released"
+        cycle = cycles_by_key.get(release.cycle_key) if (release is not None) else None
+        svn_revisions = [row.svn_revision for row in rows if row.svn_revision is not None]
+        # CLE only resolves for a released-phase record, so historical svn-only
+        # versions (no release) get no link rather than one that would 404.
+        cle_eligible = (release is not None) and (release.phase == sql.ReleasePhase.RELEASE)
+        versions.append(
+            models.api.CatalogVersion(
+                version=version,
+                status=status,
+                released=(release.released or release.created) if (release is not None) else None,
+                svn_revision=max(svn_revisions) if svn_revisions else None,
+                cycle=cycle.cycle if (cycle is not None) else None,
+                cle_url=cle_release_url(atr_host, release.project_key, version)
+                if (atr_host is not None) and cle_eligible and (release is not None)
+                else None,
+                artifacts=[_artifact(row, committee, downloadable) for row in rows],
+            )
+        )
+
+    versions.sort(key=_version_sort_key, reverse=True)
+    return versions
diff --git a/atr/templates/_catalog_version.html b/atr/templates/_catalog_version.html
index e7ceb4a3..c3e479cd 100644
--- a/atr/templates/_catalog_version.html
+++ b/atr/templates/_catalog_version.html
@@ -5,8 +5,10 @@
       <strong class="fs-5">{{ version.version }}</strong>
       <span>
         <span class="badge {{ status_classes[version.status] }}">{{ version.status | capitalize }}</span>
-        <a class="ms-2 small"
-           href="{{ as_url(api.cle_release, project_key=project.key, version_key=version.version) }}">CLE</a>
+        {% if version.released %}
+          <a class="ms-2 small"
+             href="{{ as_url(api.cle_release, project_key=project.key, version_key=version.version) }}">CLE</a>
+        {% endif %}
       </span>
     </div>
     <p class="text-muted small mb-2">
diff --git a/atr/templates/catalog.html b/atr/templates/catalog.html
index 72de61e0..7390a8f5 100644
--- a/atr/templates/catalog.html
+++ b/atr/templates/catalog.html
@@ -22,7 +22,7 @@
     {% for cycle_entry in cycles %}
       <div class="mb-4">
         <h2 class="fs-4 border-bottom pb-1">
-          Version {{ cycle_entry.cycle.cycle }}
+          Version {{ cycle_entry.cycle }}
           {% if cycle_entry.lifecycle %}<span class="badge text-bg-primary align-middle">{{ cycle_entry.lifecycle }}</span>{% endif %}
         </h2>
         {% for version in cycle_entry.versions %}
diff --git a/tests/unit/test_catalog.py b/tests/unit/test_catalog.py
index 8b90c942..e89441f9 100644
--- a/tests/unit/test_catalog.py
+++ b/tests/unit/test_catalog.py
@@ -17,8 +17,9 @@
 
 import datetime
 
-import atr.get.catalog as catalog
+import atr.models.api as api
 import atr.models.sql as sql
+import atr.shared.catalog as catalog
 
 _NOW = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
 
@@ -68,12 +69,12 @@ def _artifact(
 
 class _CatalogVersionStub:
     def __init__(self, version: str, *, cycle: sql.ProjectCycle | None) -> None:
-        self.value = catalog._CatalogVersion(
+        self.value = api.CatalogVersion(
             version=version,
             status="released" if cycle is not None else "archived",
             released=None,
             svn_revision=None,
-            cycle=cycle,
+            cycle=cycle.cycle if cycle is not None else None,
             artifacts=[],
         )
 
@@ -108,7 +109,7 @@ def test_status_reflects_release_and_archive_state() -> None:
         _artifact(project, "4.1.7", "a-4.1.7.tar.gz", release=archived),
     ]
 
-    versions = {v.version: v for v in catalog._catalog_versions(artifacts, {}, project.committee_key)}
+    versions = {v.version: v for v in catalog._versions(artifacts, {}, project.committee_key)}
 
     assert versions["5.0.2"].status == "released"
     assert versions["4.1.7"].status == "archived"
@@ -123,7 +124,7 @@ def test_only_released_versions_are_downloadable() -> None:
         _artifact(project, "4.1.7", "a-4.1.7.tar.gz", release=archived, svn_revision=28114),
     ]
 
-    versions = {v.version: v for v in catalog._catalog_versions(artifacts, {}, project.committee_key)}
+    versions = {v.version: v for v in catalog._versions(artifacts, {}, project.committee_key)}
 
     assert versions["5.0.2"].artifacts[0].downloadable is True
     assert versions["4.1.7"].artifacts[0].downloadable is False
@@ -143,7 +144,7 @@ def test_versions_are_newest_first() -> None:
         _artifact(project, "5.0.2", "a-5.0.2.tar.gz", release=newer),
     ]
 
-    versions = catalog._catalog_versions(artifacts, {}, project.committee_key)
+    versions = catalog._versions(artifacts, {}, project.committee_key)
 
     assert [v.version for v in versions] == ["5.0.2", "5.0.1"]
 
@@ -155,22 +156,24 @@ def test_cycles_are_ordered_by_latest_activity() -> None:
         _CatalogVersionStub("4.1.7", cycle=four).value,
         _CatalogVersionStub("5.0.2", cycle=five).value,
     ]
+    cycles_by_label = {c.cycle: c for c in (five, four)}
 
-    cycles = catalog._catalog_cycles(versions, _NOW)
+    cycles = catalog._cycles(versions, cycles_by_label, _NOW)
 
-    assert [c.cycle.cycle for c in cycles if c.cycle is not None] == ["5.0", "4.1"]
+    assert [c.cycle for c in cycles] == ["5.0", "4.1"]
 
 
-def test_versions_without_a_cycle_are_excluded_from_the_grouped_layout() -> None:
+def test_versions_without_a_cycle_are_excluded_from_grouping() -> None:
     default = _cycle("default", latest=datetime.datetime(2025, 5, 1, tzinfo=datetime.UTC))
     versions = [
         _CatalogVersionStub("2.0.0", cycle=default).value,
         _CatalogVersionStub("1.0.0", cycle=None).value,
     ]
+    cycles_by_label = {default.cycle: default}
 
-    cycles = catalog._catalog_cycles(versions, _NOW)
+    cycles = catalog._cycles(versions, cycles_by_label, _NOW)
 
-    assert [c.cycle.cycle for c in cycles if c.cycle is not None] == ["default"]
+    assert [c.cycle for c in cycles] == ["default"]
 
 
 def test_lifecycle_badge_prefers_lts_then_eol_then_active() -> None:
@@ -178,3 +181,33 @@ def test_lifecycle_badge_prefers_lts_then_eol_then_active() -> None:
     assert catalog._lifecycle_badge(_cycle("5.0", lts=True, eol=past), _NOW) == "LTS"
     assert catalog._lifecycle_badge(_cycle("4.0", eol=past), _NOW) == "EOL"
     assert catalog._lifecycle_badge(_cycle("5.0"), _NOW) == "Active"
+
+
+def test_cle_url_links_versions_backed_by_a_release_when_a_host_is_supplied() -> None:
+    project = _project()
+    released = _release(project, "5.0.2", cycle_key="cassandra-default", released=_NOW)
+    artifacts = [_artifact(project, "5.0.2", "a-5.0.2.tar.gz", release=released)]
+
+    versions = {v.version: v for v in catalog._versions(artifacts, {}, project.committee_key, "atr.example.org")}
+
+    assert versions["5.0.2"].cle_url == "https://atr.example.org/api/cle/release/cassandra/5.0.2"
+
+
+def test_cle_url_is_omitted_for_versions_without_a_backing_release() -> None:
+    project = _project()
+    artifacts = [_artifact(project, "3.0.0", "a-3.0.0.tar.gz", svn_revision=100)]
+
+    versions = {v.version: v for v in catalog._versions(artifacts, {}, project.committee_key, "atr.example.org")}
+
+    assert versions["3.0.0"].cle_url is None
+
+
+def test_cle_url_is_omitted_when_no_host_is_supplied() -> None:
+    # The page opts out of absolute URLs, building its own relative CLE links instead.
+    project = _project()
+    released = _release(project, "5.0.2", cycle_key="cassandra-default", released=_NOW)
+    artifacts = [_artifact(project, "5.0.2", "a-5.0.2.tar.gz", release=released)]
+
+    versions = {v.version: v for v in catalog._versions(artifacts, {}, project.committee_key)}
+
+    assert versions["5.0.2"].cle_url is None


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