diff --git a/Dockerfile b/Dockerfile index a2213d53d8..48f113db3f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,7 @@ RUN set -euo pipefail; \ COPY default.env ${INFINITO_SRC_DIR}/default.env COPY scripts/install ${INFINITO_SRC_DIR}/scripts/install COPY roles/dev-python/files/shell ${INFINITO_SRC_DIR}/roles/dev-python/files/shell +COPY pyproject.toml ${INFINITO_SRC_DIR}/pyproject.toml COPY roles/update/files/shell/centos_mirror.sh ${INFINITO_SRC_DIR}/roles/update/files/shell/centos_mirror.sh COPY requirements ${INFINITO_SRC_DIR}/requirements COPY utils/__init__.py ${INFINITO_SRC_DIR}/utils/__init__.py diff --git a/pyproject.toml b/pyproject.toml index 16e198c3b2..aa3f5ccb9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "infinito-nexus" version = "16.2.3" description = "Infinito.Nexus" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.11,<3.15" license = { file = "LICENSE.md" } dependencies = [ diff --git a/roles/dev-python/files/shell/install.sh b/roles/dev-python/files/shell/install.sh index 3b524042ed..e6796f1ee8 100755 --- a/roles/dev-python/files/shell/install.sh +++ b/roles/dev-python/files/shell/install.sh @@ -1,13 +1,36 @@ #!/usr/bin/env bash set -euo pipefail -PYTHON_MIN_MINOR=11 -PYTHON_MAX_MINOR=14 - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" DOCKER_APT_SANITIZER="${REPO_ROOT}/roles/sys-svc-container/files/shell/apt_repo.sh" +python_requires_spec() { + if [[ -v INFINITO_PYTHON_REQUIRES ]]; then + printf '%s\n' "${INFINITO_PYTHON_REQUIRES}" + return 0 + fi + local pyproject="${REPO_ROOT}/pyproject.toml" + if [[ ! -f "${pyproject}" ]]; then + echo "[ERROR] Neither INFINITO_PYTHON_REQUIRES nor ${pyproject} names the supported Python range." >&2 + return 1 + fi + sed -n -E 's/^requires-python[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' "${pyproject}" +} + +load_python_bounds() { + local spec pattern='^>=3\.([0-9]+),<3\.([0-9]+)$' + spec="$(python_requires_spec)" + if [[ ! "${spec}" =~ ${pattern} ]]; then + echo "[ERROR] requires-python '${spec}' must read >=3.X,<3.Y." >&2 + return 1 + fi + PYTHON_MIN_MINOR="${BASH_REMATCH[1]}" + PYTHON_MAX_MINOR="$((BASH_REMATCH[2] - 1))" +} + +load_python_bounds + log() { printf '>>> [dev-python] %s\n' "$*" } diff --git a/roles/dev-python/tasks/00_core.yml b/roles/dev-python/tasks/00_core.yml index d7abeb27f6..7310e0e4c4 100644 --- a/roles/dev-python/tasks/00_core.yml +++ b/roles/dev-python/tasks/00_core.yml @@ -4,10 +4,14 @@ name: sys-pip when: run_once_sys_pip is not defined -- name: 📦 Install and prefer Python 3.11+ as default +- name: 📦 Install and prefer the highest supported Python as default ansible.builtin.script: "{{ role_path }}/files/shell/install.sh" environment: SKIP_DOCKER_APT_SANITIZER: "1" + INFINITO_PYTHON_REQUIRES: >- + {{ lookup('file', lookup('path_absolute', 'pyproject.toml')) + | regex_search('(?m)^requires-python\s*=\s*"([^"]+)"', '\1') + | first }} become: true - include_tasks: utils/once/flag.yml diff --git a/tests/unit/python/roles/dev-python/files/test_install.py b/tests/unit/python/roles/dev-python/files/test_install.py index e681cd73a5..3559dee048 100644 --- a/tests/unit/python/roles/dev-python/files/test_install.py +++ b/tests/unit/python/roles/dev-python/files/test_install.py @@ -1,31 +1,55 @@ +import os +import re import subprocess import tempfile +import tomllib import unittest from pathlib import Path -from utils.cache.files import PROJECT_ROOT +from utils.cache.files import PROJECT_ROOT, read_text 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) -> subprocess.CompletedProcess: +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 ``INFINITO_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 != "INFINITO_PYTHON_REQUIRES" + } + if requires is not None: + env["INFINITO_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) @@ -162,5 +186,26 @@ class TestFindHighestPythonPackage(unittest.TestCase): 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") + self.assertIn("INFINITO_PYTHON_REQUIRES", read_text(str(ROLE_TASKS))) + + 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()