mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
#1347 passed the supported Python range to the remote install.sh as INFINITO_PYTHON_REQUIRES. Every INFINITO_* name is a key of the generated .env, so test_every_referenced_infinito_key_is_registered fails on main for this one, which no registry entry can carry: its value is derived from pyproject.toml's requires-python, the single source, and a default in default.env would be a second copy of the range. The task environment and the script now call it DEV_PYTHON_REQUIRES; behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
239 lines
8.4 KiB
Python
239 lines
8.4 KiB
Python
import os
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import tomllib
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
import jinja2
|
|
from ansible.plugins.filter.core import regex_search
|
|
|
|
from utils.cache.files import PROJECT_ROOT, read_text
|
|
from utils.cache.yaml import load_yaml_str
|
|
|
|
SCRIPT = PROJECT_ROOT / "roles/dev-python/files/shell/install.sh"
|
|
ROLE_TASKS = PROJECT_ROOT / "roles/dev-python/tasks/00_core.yml"
|
|
|
|
|
|
def _call(
|
|
snippet: str, *args: str, requires: str | None = None
|
|
) -> subprocess.CompletedProcess:
|
|
"""Source install.sh without running its entrypoint, then run a snippet.
|
|
|
|
Args:
|
|
snippet: bash code evaluated after sourcing; sees ``args`` as ``"$@"``.
|
|
args: positional arguments handed to the snippet.
|
|
requires: value for ``DEV_PYTHON_REQUIRES``; ``None`` leaves it
|
|
unset so the script reads the repository's ``pyproject.toml``.
|
|
|
|
Returns:
|
|
The completed bash process.
|
|
"""
|
|
env = {
|
|
key: value
|
|
for key, value in os.environ.items()
|
|
if key != "DEV_PYTHON_REQUIRES"
|
|
}
|
|
if requires is not None:
|
|
env["DEV_PYTHON_REQUIRES"] = requires
|
|
return subprocess.run(
|
|
["bash", "-c", f'source "$1"; shift; {snippet}', "bash", str(SCRIPT), *args],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
env=env,
|
|
)
|
|
|
|
|
|
def _declared_minors() -> tuple[int, int]:
|
|
spec = tomllib.loads(read_text(str(PROJECT_ROOT / "pyproject.toml")))["project"][
|
|
"requires-python"
|
|
]
|
|
match = re.fullmatch(r">=3\.(\d+),<3\.(\d+)", spec)
|
|
return int(match[1]), int(match[2]) - 1
|
|
|
|
|
|
def _bound(name: str) -> int:
|
|
return int(_call(f'printf "%s" "${name}"').stdout)
|
|
|
|
|
|
def _fake_python(directory: Path, minor: int, *, pip: bool) -> str:
|
|
"""Write an executable that answers the interpreter probes of install.sh.
|
|
|
|
Args:
|
|
directory: directory the fake is written to.
|
|
minor: Python 3 minor version the fake reports.
|
|
pip: whether ``-m pip --version`` succeeds.
|
|
|
|
Returns:
|
|
Absolute path of the fake interpreter.
|
|
"""
|
|
path = directory / f"python3.{minor}"
|
|
path.write_text(
|
|
"#!/bin/sh\n"
|
|
f'if [ "$1" = "-m" ]; then exit {0 if pip else 1}; fi\n'
|
|
"cat >/dev/null\n"
|
|
f"echo 3.{minor}\n"
|
|
)
|
|
path.chmod(0o755)
|
|
return str(path)
|
|
|
|
|
|
class _InterpreterCase(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(tmp.cleanup)
|
|
self.dir = Path(tmp.name)
|
|
self.floor = _bound("PYTHON_MIN_MINOR")
|
|
self.cap = _bound("PYTHON_MAX_MINOR")
|
|
|
|
def fake(self, minor: int, *, pip: bool = True) -> str:
|
|
return _fake_python(self.dir, minor, pip=pip)
|
|
|
|
|
|
class TestPickBestPython(_InterpreterCase):
|
|
def pick(self, *candidates: str) -> subprocess.CompletedProcess:
|
|
return _call('pick_best_python "$@"', *candidates)
|
|
|
|
def test_skips_a_minor_above_the_cap(self) -> None:
|
|
capped = self.fake(self.cap)
|
|
result = self.pick(capped, self.fake(self.cap + 1))
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stdout.strip(), capped)
|
|
|
|
def test_ignores_pip_on_a_minor_above_the_cap(self) -> None:
|
|
capped = self.fake(self.cap, pip=False)
|
|
result = self.pick(
|
|
self.fake(self.cap - 1, pip=False), capped, self.fake(self.cap + 1)
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stdout.strip(), capped)
|
|
|
|
def test_picks_the_highest_minor_within_bounds(self) -> None:
|
|
highest = self.fake(self.cap)
|
|
result = self.pick(self.fake(self.cap - 2), self.fake(self.cap - 1), highest)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stdout.strip(), highest)
|
|
|
|
def test_prefers_a_lower_minor_that_has_pip(self) -> None:
|
|
with_pip = self.fake(self.cap - 1)
|
|
result = self.pick(with_pip, self.fake(self.cap, pip=False))
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stdout.strip(), with_pip)
|
|
|
|
def test_fails_when_only_minors_above_the_cap_exist(self) -> None:
|
|
result = self.pick(self.fake(self.cap + 1))
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertEqual(result.stdout, "")
|
|
|
|
def test_fails_when_only_minors_below_the_floor_exist(self) -> None:
|
|
result = self.pick(self.fake(self.floor - 1))
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertEqual(result.stdout, "")
|
|
|
|
|
|
class TestResolvePythonBinOrFail(_InterpreterCase):
|
|
def test_names_the_supported_range_when_only_a_newer_minor_exists(self) -> None:
|
|
result = _call(
|
|
'candidates=("$@"); '
|
|
'pick_python_bin() { pick_best_python "${candidates[@]}"; }; '
|
|
"resolve_python_bin_or_fail",
|
|
self.fake(self.cap + 1),
|
|
)
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn(f"3.{self.floor} to 3.{self.cap}", result.stderr)
|
|
|
|
|
|
class TestFindHighestPythonPackage(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.cap = _bound("PYTHON_MAX_MINOR")
|
|
|
|
def rpm(self, *minors: int) -> subprocess.CompletedProcess:
|
|
listing = "Available packages\n" + "".join(
|
|
f"python3.{m}.x86_64 3.{m}.0-1.fc45 fedora\n"
|
|
f"python3.{m}-devel.x86_64 3.{m}.0-1.fc45 fedora\n"
|
|
for m in minors
|
|
)
|
|
return _call(
|
|
'listing="$1"; '
|
|
'run_privileged() { "$@"; }; '
|
|
'dnf() { printf "%s" "${listing}"; }; '
|
|
"find_highest_rpm_python_pkg dnf",
|
|
listing,
|
|
)
|
|
|
|
def apt(self, *minors: int) -> subprocess.CompletedProcess:
|
|
names = ["python3", "python3-pip"] + [f"python3.{m}" for m in minors]
|
|
return _call(
|
|
'names=("$@"); '
|
|
'apt-cache() { printf "%s\\n" "${names[@]}"; }; '
|
|
"find_highest_apt_python_pkg",
|
|
*names,
|
|
)
|
|
|
|
def test_rpm_skips_a_package_above_the_cap(self) -> None:
|
|
result = self.rpm(self.cap - 1, self.cap, self.cap + 1)
|
|
self.assertEqual(result.stdout.strip(), f"python3.{self.cap}")
|
|
|
|
def test_rpm_yields_nothing_when_only_packages_above_the_cap_exist(self) -> None:
|
|
result = self.rpm(self.cap + 1)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stdout, "")
|
|
|
|
def test_apt_skips_a_package_above_the_cap(self) -> None:
|
|
result = self.apt(self.cap - 1, self.cap, self.cap + 1)
|
|
self.assertEqual(result.stdout.strip(), f"python3.{self.cap}")
|
|
|
|
def test_apt_keeps_the_highest_package_within_bounds(self) -> None:
|
|
result = self.apt(self.cap - 2, self.cap - 1)
|
|
self.assertEqual(result.stdout.strip(), f"python3.{self.cap - 1}")
|
|
|
|
|
|
class TestBoundsFromRequiresPython(unittest.TestCase):
|
|
def test_the_bounds_come_from_pyproject_requires_python(self) -> None:
|
|
self.assertEqual(
|
|
(_bound("PYTHON_MIN_MINOR"), _bound("PYTHON_MAX_MINOR")),
|
|
_declared_minors(),
|
|
)
|
|
|
|
def test_the_role_hands_requires_python_to_the_remote_script(self) -> None:
|
|
result = _call(
|
|
'printf "%s %s" "$PYTHON_MIN_MINOR" "$PYTHON_MAX_MINOR"',
|
|
requires=">=3.12,<3.14",
|
|
)
|
|
self.assertEqual(result.stdout, "12 13")
|
|
|
|
def test_the_role_renders_requires_python_from_pyproject(self) -> None:
|
|
task = next(
|
|
task
|
|
for task in load_yaml_str(read_text(str(ROLE_TASKS)))
|
|
if "ansible.builtin.script" in task
|
|
)
|
|
expression = task["environment"]["DEV_PYTHON_REQUIRES"]
|
|
|
|
def lookup(name: str, term: str) -> str:
|
|
return {
|
|
"path_absolute": lambda: str(PROJECT_ROOT / term),
|
|
"file": lambda: read_text(term),
|
|
}[name]()
|
|
|
|
environment = jinja2.Environment(
|
|
autoescape=jinja2.select_autoescape(default_for_string=False)
|
|
)
|
|
environment.filters["regex_search"] = regex_search
|
|
environment.globals["lookup"] = lookup
|
|
spec = tomllib.loads(read_text(str(PROJECT_ROOT / "pyproject.toml")))[
|
|
"project"
|
|
]["requires-python"]
|
|
self.assertEqual(environment.from_string(expression).render(), spec)
|
|
|
|
def test_an_unsupported_requires_python_form_fails_loudly(self) -> None:
|
|
result = _call("true", requires=">=3.11")
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("must read >=3.X,<3.Y", result.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|