Files
computer-playbook/utils/update/galaxy.py
kevinveenbirkenbachandClaude Opus 5 f0ecf3e337 style(update): apply the formatting the gate produced
The nocheck markers were written as long single lines; ruff splits the
call across three lines and leaves the marker behind the closing paren.
Committing the formatter's own output keeps the tree and the history in
step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:51:25 +02:00

137 lines
4.5 KiB
Python

"""Pinned Ansible collection versions in ``requirements/``.
Both requirement files name the same collections at the same versions: the
Galaxy file is the normal source, the git file the fallback used when
galaxy.ansible.com is unreachable. A bump that touches only one of them
would make the fallback install something the pin does not describe, so an
update is applied to both or to neither.
Upstream releases are resolved from the git file's own ``source:`` URL via
``git ls-remote --tags``, not from the Galaxy API, because the collections
are tagged in the repository the fallback already clones.
An entry whose ``version:`` is not a semver (a branch name, say) states an
intent this cannot second-guess and is skipped.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from utils.cache.yaml import load_yaml_any
from utils.update.base import is_semver, version_key
from utils.update.repository import git_ls_remote_tags
GALAXY_REQUIREMENTS = Path("requirements/requirements.galaxy.yml")
GIT_REQUIREMENTS = Path("requirements/requirements.git.yml")
@dataclass(frozen=True)
class CollectionPin:
"""One collection pinned in both requirement files.
Args:
fqcn: fully qualified collection name, namespace and name.
version: the version both files pin today.
source: upstream git URL taken from the git requirements file.
"""
fqcn: str
version: str
source: str
@dataclass(frozen=True)
class CollectionPinUpdate:
pin: CollectionPin
latest: str
def _entries(path: Path) -> list[dict]:
declared = load_yaml_any(str(path), default_if_missing={}) or {}
return [e for e in (declared.get("collections") or []) if isinstance(e, dict)]
def collect_pins(repo_root: Path) -> list[CollectionPin]:
"""Collections pinned to the same semver in both requirement files.
Args:
repo_root: repository root holding the ``requirements`` directory.
"""
galaxy = {
str(e.get("name")): str(e.get("version"))
for e in _entries(repo_root / GALAXY_REQUIREMENTS)
if e.get("name") and e.get("version")
}
pins: list[CollectionPin] = []
for entry in _entries(repo_root / GIT_REQUIREMENTS):
fqcn = str(entry.get("name", ""))
version = str(entry.get("version", ""))
source = str(entry.get("source", ""))
if not (fqcn and source and is_semver(version)):
continue
if galaxy.get(fqcn) != version:
continue
pins.append(CollectionPin(fqcn=fqcn, version=version, source=source))
return pins
def _highest_release(source: str, current: str) -> str | None:
candidates = [
tag
for tag in git_ls_remote_tags(source)
if is_semver(tag) and tag.lstrip("v").count(".") == current.count(".")
]
if not candidates:
return None
newest = max(candidates, key=version_key)
if version_key(newest) <= version_key(current):
return None
return newest.lstrip("v")
def find_outdated_updates(repo_root: Path) -> list[CollectionPinUpdate]:
"""Pins whose upstream carries a higher release tag.
Args:
repo_root: repository root holding the ``requirements`` directory.
"""
updates: list[CollectionPinUpdate] = []
for pin in collect_pins(repo_root):
latest = _highest_release(pin.source, pin.version)
if latest:
updates.append(CollectionPinUpdate(pin=pin, latest=latest))
return updates
def _rewrite(path: Path, fqcn: str, old: str, new: str) -> bool:
text = path.read_text(
encoding="utf-8"
) # nocheck: cache-read -- rewritten below, a cached copy would go stale mid-run
pattern = re.compile(
rf"(name:\s*{re.escape(fqcn)}\b(?:\n(?!\s*-\s).*)*?\n\s*version:\s*){re.escape(old)}\b"
)
replaced, count = pattern.subn(rf"\g<1>{new}", text)
if not count:
return False
path.write_text(replaced, encoding="utf-8")
return True
def apply_updates(repo_root: Path, updates: list[CollectionPinUpdate]) -> list[Path]:
"""Write every update into both requirement files.
Args:
repo_root: repository root holding the ``requirements`` directory.
updates: the bumps to apply, as returned by :func:`find_outdated_updates`.
"""
changed: set[Path] = set()
for update in updates:
for relative in (GALAXY_REQUIREMENTS, GIT_REQUIREMENTS):
path = repo_root / relative
if _rewrite(path, update.pin.fqcn, update.pin.version, update.latest):
changed.add(path)
return sorted(changed)