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