mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
fix(install): keep the lint bootstrap off third-party imports
The lint installer imports this module, and it runs on the runner's bare interpreter before a single dependency exists. Sharing the version check with it therefore handed the bootstrap a PyYAML import, which killed it: 'ModuleNotFoundError: No module named yaml' raised through utils/install/lint/__main__.py:35. Routing the read through utils.cache.yaml would not have helped, that imports yaml too. The requirements files are this repository's own and hold one shape, so the pins are read with two regexes and the standard library. A line that does not match the shape leaves the entry unpinned, and unpinned already counts as unsatisfied, so the parser errs towards installing rather than towards skipping. Quoted versions are unwrapped: yaml accepts version: "13.4.0" and a formatter may introduce it, and carrying the quotes into the comparison would silently defeat the check instead of failing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0aa314bb9b
commit
67f5398985
2 files changed
+78
-13
No files matched your search
@@ -3,7 +3,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from utils.install.collections import unsatisfied
|
||||
from utils.install.collections import declared_pins, unsatisfied
|
||||
|
||||
REQUIREMENTS = """---
|
||||
collections:
|
||||
@@ -29,6 +29,41 @@ def _install(collections_dir: Path, fqcn: str, version: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
class TestDeclaredPins(unittest.TestCase):
|
||||
def test_a_quoted_version_is_read_without_its_quotes(self):
|
||||
body = (
|
||||
"---\ncollections:\n"
|
||||
' - name: double.quoted\n version: "13.4.0"\n'
|
||||
" - name: single.quoted\n version: '1.0.0'\n"
|
||||
" - name: commented\n version: 2.2.2 # pinned\n"
|
||||
)
|
||||
with TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
requirements = _write_requirements(root, body)
|
||||
|
||||
self.assertEqual(
|
||||
declared_pins(requirements),
|
||||
[
|
||||
("double.quoted", "13.4.0"),
|
||||
("single.quoted", "1.0.0"),
|
||||
("commented", "2.2.2"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_an_entry_without_a_version_stays_unpinned(self):
|
||||
body = "---\ncollections:\n - name: bare.entry\n"
|
||||
with TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
|
||||
self.assertEqual(
|
||||
declared_pins(_write_requirements(root, body)), [("bare.entry", None)]
|
||||
)
|
||||
|
||||
def test_a_missing_file_declares_nothing(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
self.assertEqual(declared_pins(Path(tmp) / "absent.yml"), [])
|
||||
|
||||
|
||||
class TestUnsatisfied(unittest.TestCase):
|
||||
def test_every_pin_installed_at_its_version_is_satisfied(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
|
||||
@@ -1,12 +1,48 @@
|
||||
"""Compare the pinned Galaxy requirements against what is on disk."""
|
||||
"""Compare the pinned Galaxy requirements against what is on disk.
|
||||
|
||||
Imported by the lint bootstrap, which runs on a bare interpreter before any
|
||||
dependency is installed, so this module stays on the standard library. The
|
||||
requirements files it reads are this repository's own and hold one shape:
|
||||
a ``collections:`` list of ``- name:`` entries with an optional ``version:``.
|
||||
A line that does not match that shape leaves the entry unpinned, which the
|
||||
caller treats as unsatisfied and installs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
_NAME = re.compile(r"^\s*-\s+name:\s*(?P<name>[A-Za-z0-9_.]+)\s*(?:#.*)?$")
|
||||
_VERSION = re.compile(
|
||||
r"^\s*version:\s*(?P<quote>[\"']?)(?P<version>[^\s#\"']+)(?P=quote)\s*(?:#.*)?$"
|
||||
)
|
||||
|
||||
|
||||
def declared_pins(requirements_file: Path) -> list[tuple[str, str | None]]:
|
||||
"""The ``(fqcn, version)`` pairs a requirements file declares, in order.
|
||||
|
||||
Args:
|
||||
requirements_file: the requirements.galaxy.yml to read.
|
||||
"""
|
||||
pins: list[tuple[str, str | None]] = []
|
||||
try:
|
||||
lines = requirements_file.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return pins
|
||||
|
||||
for line in lines:
|
||||
name_match = _NAME.match(line)
|
||||
if name_match:
|
||||
pins.append((name_match.group("name"), None))
|
||||
continue
|
||||
version_match = _VERSION.match(line)
|
||||
if version_match and pins:
|
||||
fqcn, _ = pins[-1]
|
||||
pins[-1] = (fqcn, version_match.group("version"))
|
||||
return pins
|
||||
|
||||
|
||||
def _installed_version(collections_dir: Path, namespace: str, name: str) -> str | None:
|
||||
@@ -38,19 +74,13 @@ def unsatisfied(requirements_file: Path, collections_dir: Path) -> list[str]:
|
||||
requirements_file: the requirements.galaxy.yml to read.
|
||||
collections_dir: the ``-p`` target ansible-galaxy installs into.
|
||||
"""
|
||||
declared = yaml.safe_load(requirements_file.read_text(encoding="utf-8")) or {}
|
||||
missing: list[str] = []
|
||||
for entry in declared.get("collections") or []:
|
||||
if not isinstance(entry, dict):
|
||||
missing.append(str(entry))
|
||||
continue
|
||||
fqcn = str(entry.get("name", ""))
|
||||
pinned = entry.get("version")
|
||||
if not fqcn or "." not in fqcn or not pinned:
|
||||
missing.append(fqcn or repr(entry))
|
||||
for fqcn, pinned in declared_pins(requirements_file):
|
||||
if "." not in fqcn or not pinned:
|
||||
missing.append(fqcn)
|
||||
continue
|
||||
namespace, _, name = fqcn.partition(".")
|
||||
if _installed_version(collections_dir, namespace, name) != str(pinned):
|
||||
if _installed_version(collections_dir, namespace, name) != pinned:
|
||||
missing.append(f"{fqcn}:{pinned}")
|
||||
return missing
|
||||
|
||||
|
||||
Reference in new issue
Block a user