mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
The version cron updates docker image tags and repository refs, and tests/external/update covers docker, addons and repository. Neither sees a version inside an Ansible task variable, and dependabot reads pyproject.toml rather than roles/. A pin set once therefore froze and nothing thawed it: backup-docker-to-local has not moved since it was written, and the automtu pin one commit back would have gone the same way. utils/update/pip mirrors the docker module: collect the pins, ask PyPI, rewrite in place. Only exact == pins are collected, because a range or a VCS reference states an intent an updater must not overrule. The external check warns rather than fails, like its docker sibling, so a slow PyPI cannot redden a run and a release the repository has deliberately not taken is not a regression. The cron job is the docker job with one script swapped, so a changed pin arrives as its own PR under update/pip-pins. Executed: the collector finds both pins in the repository; a synthetic file rewrites from 1.0.0 to 1.1.1 with the indent, quoting and neighbouring line untouched; automtu==1.0.0 would be reported while a bare name and a git+ reference are passed over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
192 lines
5.9 KiB
Python
192 lines
5.9 KiB
Python
"""Pinned pip requirements in ``roles/**/*.yml``.
|
|
|
|
A role that hands ``sys-pip-install`` a bare package name follows whatever pip
|
|
resolves; one that pins ``name==version`` freezes it, and nothing thaws it
|
|
again. The docker updater keeps image tags current and dependabot reads
|
|
``pyproject.toml``; neither sees a version inside an Ansible task variable, so
|
|
a pin set once ages silently.
|
|
|
|
Only exact ``==`` pins are collected. A range or a VCS reference states an
|
|
intent this cannot second-guess.
|
|
|
|
Suppress a package by placing ``# nocheck: pip-version`` on the line directly
|
|
above the ``package_name:`` key, or on the key itself:
|
|
|
|
# nocheck: pip-version
|
|
package_name: "automtu==1.1.1"
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import http.client
|
|
import json
|
|
import re
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
from utils.annotations.suppress import is_suppressed_at
|
|
from utils.cache.files import read_text
|
|
from utils.update.base import is_semver, version_key
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
PIN_PATTERN = re.compile(
|
|
r"^(?P<indent>\s*)package_name:\s*"
|
|
r"(?P<quote>[\"']?)(?P<package>[A-Za-z0-9][A-Za-z0-9._-]*)=="
|
|
r"(?P<version>[0-9][0-9A-Za-z.+-]*)(?P=quote)"
|
|
r"(?P<suffix>\s*(?:#.*)?)$"
|
|
)
|
|
|
|
MARKER = "pip-version"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PipPinEntry:
|
|
"""One ``package_name: "<name>==<version>"`` line.
|
|
|
|
Args:
|
|
role: the role directory the pin lives in.
|
|
package: the distribution name on PyPI.
|
|
version: the pinned version.
|
|
config_path: the task file holding the pin.
|
|
line_number: 1-indexed line of the pin.
|
|
"""
|
|
|
|
role: str
|
|
package: str
|
|
version: str
|
|
config_path: Path
|
|
line_number: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PipPinUpdate:
|
|
entry: PipPinEntry
|
|
latest: str
|
|
|
|
|
|
def collect_entries(repo_root: Path) -> list[PipPinEntry]:
|
|
"""Return every exact pip pin under ``roles/``.
|
|
|
|
Args:
|
|
repo_root: repository root to scan.
|
|
"""
|
|
entries: list[PipPinEntry] = []
|
|
for path in sorted((repo_root / "roles").rglob("*.yml")):
|
|
text = read_text(str(path))
|
|
if "package_name:" not in text:
|
|
continue
|
|
lines = text.splitlines()
|
|
for index, line in enumerate(lines):
|
|
match = PIN_PATTERN.match(line)
|
|
if not match:
|
|
continue
|
|
if is_suppressed_at(lines, index + 1, MARKER) or MARKER in line:
|
|
continue
|
|
entries.append(
|
|
PipPinEntry(
|
|
role=path.relative_to(repo_root / "roles").parts[0],
|
|
package=match.group("package"),
|
|
version=match.group("version"),
|
|
config_path=path,
|
|
line_number=index + 1,
|
|
)
|
|
)
|
|
return entries
|
|
|
|
|
|
def fetch_pypi_versions(package: str) -> list[str]:
|
|
"""Return every released version of ``package``, empty when PyPI is unreachable.
|
|
|
|
Args:
|
|
package: distribution name.
|
|
"""
|
|
url = f"https://pypi.org/pypi/{package}/json"
|
|
req = urllib.request.Request(
|
|
url, headers={"User-Agent": "infinito-nexus-version-updater"}
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 - fixed https PyPI origin, package name matched against PIN_PATTERN
|
|
body = json.loads(resp.read().decode())
|
|
except (
|
|
urllib.error.URLError,
|
|
OSError,
|
|
http.client.HTTPException,
|
|
json.JSONDecodeError,
|
|
):
|
|
return []
|
|
releases = body.get("releases") or {}
|
|
return [
|
|
version
|
|
for version, files in releases.items()
|
|
if is_semver(version) and any(not f.get("yanked") for f in files)
|
|
]
|
|
|
|
|
|
def latest_release(package: str) -> str | None:
|
|
"""Return the highest released version of ``package``, or None.
|
|
|
|
Args:
|
|
package: distribution name.
|
|
"""
|
|
versions = fetch_pypi_versions(package)
|
|
return max(versions, key=version_key) if versions else None
|
|
|
|
|
|
def find_outdated_updates(repo_root: Path) -> list[PipPinUpdate]:
|
|
"""Return one update per pin that PyPI has moved past.
|
|
|
|
Args:
|
|
repo_root: repository root to scan.
|
|
"""
|
|
updates: list[PipPinUpdate] = []
|
|
latest_by_package: dict[str, str | None] = {}
|
|
for entry in collect_entries(repo_root):
|
|
if not is_semver(entry.version):
|
|
continue
|
|
if entry.package not in latest_by_package:
|
|
latest_by_package[entry.package] = latest_release(entry.package)
|
|
latest = latest_by_package[entry.package]
|
|
if latest and version_key(latest) > version_key(entry.version):
|
|
updates.append(PipPinUpdate(entry=entry, latest=latest))
|
|
return updates
|
|
|
|
|
|
def apply_updates(updates: list[PipPinUpdate]) -> list[Path]:
|
|
"""Rewrite each pin to its latest version and return the files changed.
|
|
|
|
Args:
|
|
updates: the updates to apply.
|
|
"""
|
|
by_path: dict[Path, dict[int, str]] = {}
|
|
for update in updates:
|
|
by_path.setdefault(update.entry.config_path, {})[update.entry.line_number] = (
|
|
update.latest
|
|
)
|
|
|
|
changed: list[Path] = []
|
|
for path, line_versions in sorted(by_path.items()):
|
|
lines = read_text(str(path)).splitlines(keepends=True)
|
|
touched = False
|
|
for line_number, latest in line_versions.items():
|
|
original = lines[line_number - 1]
|
|
match = PIN_PATTERN.match(original.rstrip("\n"))
|
|
if not match:
|
|
continue
|
|
quote = match.group("quote")
|
|
replacement = (
|
|
f"{match.group('indent')}package_name: "
|
|
f"{quote}{match.group('package')}=={latest}{quote}"
|
|
f"{match.group('suffix')}\n"
|
|
)
|
|
if replacement != original:
|
|
lines[line_number - 1] = replacement
|
|
touched = True
|
|
if touched:
|
|
path.write_text("".join(lines), encoding="utf-8")
|
|
changed.append(path)
|
|
return changed
|