Files
LEDMatrix/test/test_install_os_support.py
ChuckandClaude Opus 5.5 05deb1ee7d feat(install): support Raspberry Pi OS Bookworm (Python 3.11) alongside Trixie (3.13) (#689)
The installer and scripts/check_system_compatibility.sh share one set of OS rules (scripts/install/lib_os.sh): Bookworm (Debian 12, Python 3.11) and Trixie (Debian 13, Python 3.13) are supported, python3 older than 3.11 stops the install before anything changes, and dhcpcd gets a warning with directions. setcap targets /usr/bin/python3, the apt fallback honours the requirement floors, and the desktop check no longer misreads under pipefail. CI runs the unit and plugin-safety suites on 3.11 and 3.13 (tooling jobs on 3.13); mypy targets 3.11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:35:13 -04:00

344 lines
16 KiB
Python

"""The installer accepts Raspberry Pi OS Bookworm and Trixie, and nothing else.
Bookworm (Debian 12) ships Python 3.11 and Trixie (Debian 13) Python 3.13.
The rules live in scripts/install/lib_os.sh, which first_time_install.sh and
scripts/check_system_compatibility.sh both source. These tests feed the real
scripts a fake /etc/os-release (LM_OS_RELEASE_FILE) and stub python3,
systemctl and dpkg, so they need a Linux bash; the installer itself cannot
run end to end off a Pi.
"""
import importlib.util
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
LIB = ROOT / "scripts" / "install" / "lib_os.sh"
FIRST_TIME = ROOT / "first_time_install.sh"
COMPAT = ROOT / "scripts" / "check_system_compatibility.sh"
needs_bash = pytest.mark.skipif(
sys.platform == "win32" or shutil.which("bash") is None,
reason="runs the installer's shell code; needs a Linux bash",
)
OS_RELEASES = {
# Raspberry Pi OS 64-bit reports ID=debian, 32-bit ID=raspbian.
"trixie": 'PRETTY_NAME="Debian GNU/Linux 13 (trixie)"\nNAME="Debian GNU/Linux"\n'
'VERSION_ID="13"\nVERSION="13 (trixie)"\nVERSION_CODENAME=trixie\nID=debian\n',
"bookworm": 'PRETTY_NAME="Raspbian GNU/Linux 12 (bookworm)"\nNAME="Raspbian GNU/Linux"\n'
'VERSION_ID="12"\nVERSION="12 (bookworm)"\nVERSION_CODENAME=bookworm\nID=raspbian\n'
'ID_LIKE=debian\n',
"bookworm64": 'PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"\nVERSION_ID="12"\n'
"VERSION_CODENAME=bookworm\nID=debian\n",
"bullseye": 'PRETTY_NAME="Raspbian GNU/Linux 11 (bullseye)"\nVERSION_ID="11"\n'
"VERSION_CODENAME=bullseye\nID=raspbian\n",
"ubuntu": 'PRETTY_NAME="Ubuntu 24.04 LTS"\nVERSION_ID="24.04"\nVERSION_CODENAME=noble\n'
"ID=ubuntu\nID_LIKE=debian\n",
"no-version-id": "PRETTY_NAME='Debian GNU/Linux trixie'\nVERSION_CODENAME=trixie\nID=debian\n",
}
def _stub(bin_dir: Path, name: str, body: str) -> None:
path = bin_dir / name
path.write_text("#!/bin/sh\n" + body, encoding="utf-8", newline="\n")
path.chmod(0o755)
def _stubs(tmp_path: Path, python_version="3.11", network="NetworkManager") -> Path:
"""python3 reports ``python_version`` (None: not installed); systemctl
reports ``network`` as the only active unit; dpkg lists no desktop."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir(exist_ok=True)
if python_version is None:
# Shadows any real python3 further down PATH.
_stub(bin_dir, "python3", "exit 127\n")
else:
_stub(bin_dir, "python3", f'case "$*" in *"%d.%d.%d"*) echo "{python_version}.1" ;; '
f'*) echo "{python_version}" ;; esac\n')
_stub(bin_dir, "systemctl",
f'case "$*" in *"is-active --quiet {network}") exit 0 ;; esac\nexit 3\n')
_stub(bin_dir, "dpkg", "exit 0\n")
_stub(bin_dir, "dpkg-query", "exit 1\n")
_stub(bin_dir, "ping", "exit 0\n")
return bin_dir
def _env(tmp_path: Path, release: str, bin_dir: Path) -> dict:
os_release = tmp_path / "os-release"
os_release.write_text(OS_RELEASES[release], encoding="utf-8", newline="\n")
return {
"PATH": f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin",
"LM_OS_RELEASE_FILE": str(os_release),
"HOME": str(tmp_path),
}
def lib(snippet: str, env: dict) -> subprocess.CompletedProcess:
return subprocess.run(["bash", "-c", f"set -Eeuo pipefail\n. '{LIB}'\n{snippet}"],
capture_output=True, text=True, env=env)
# --- lib_os.sh -----------------------------------------------------------------
@needs_bash
class TestLibrary:
def test_library_is_syntactically_valid(self):
result = subprocess.run(["bash", "-n", str(LIB)], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
@pytest.mark.parametrize("release,expected", [
("trixie", "trixie"),
("bookworm", "bookworm"),
("bookworm64", "bookworm"),
("no-version-id", "trixie"),
])
def test_supported_releases_are_recognised(self, tmp_path, release, expected):
result = lib("lm_os_release", _env(tmp_path, release, _stubs(tmp_path)))
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == expected
@pytest.mark.parametrize("release", ["bullseye", "ubuntu"])
def test_other_systems_are_refused(self, tmp_path, release):
result = lib("lm_os_release", _env(tmp_path, release, _stubs(tmp_path)))
assert result.returncode != 0
assert result.stdout.strip() == ""
def test_missing_os_release_is_refused_not_fatal_to_the_caller(self, tmp_path):
env = _env(tmp_path, "trixie", _stubs(tmp_path))
env["LM_OS_RELEASE_FILE"] = str(tmp_path / "absent")
result = lib('if lm_os_release; then echo yes; else echo no; fi', env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "no"
def test_quoted_fields_are_unquoted(self, tmp_path):
env = _env(tmp_path, "no-version-id", _stubs(tmp_path))
result = lib("lm_os_field PRETTY_NAME; lm_os_field VERSION_ID", env)
assert result.stdout == "Debian GNU/Linux trixie\n"
@pytest.mark.parametrize("release,python", [("bookworm", "3.11"), ("trixie", "3.13")])
def test_each_release_names_the_python_it_ships(self, tmp_path, release, python):
result = lib(f"lm_release_python {release}", _env(tmp_path, "trixie", _stubs(tmp_path)))
assert result.stdout.strip() == python
@pytest.mark.parametrize("version,verdict", [
("3.11", "ok"), ("3.12", "ok"), ("3.13", "ok"),
("3.10", "too-old"), ("3.9", "too-old"), ("2.7", "too-old"),
("3.14", "too-new"), ("4.0", "too-new"),
("", "unknown"), ("garbage", "unknown"),
])
def test_python_versions(self, tmp_path, version, verdict):
result = lib(f'lm_python_check "{version}"', _env(tmp_path, "trixie", _stubs(tmp_path)))
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == verdict
@pytest.mark.parametrize("active,expected", [
("NetworkManager", "networkmanager"),
("dhcpcd", "dhcpcd"),
("nothing", "unknown"),
])
def test_network_stack(self, tmp_path, active, expected):
env = _env(tmp_path, "bookworm", _stubs(tmp_path, network=active))
assert lib("lm_network_stack", env).stdout.strip() == expected
# --- first_time_install.sh's OS check ------------------------------------------
def _os_check_section() -> str:
"""first_time_install.sh from the OS check up to the next section, with
the desktop-marker directories pointed somewhere that cannot exist."""
text = FIRST_TIME.read_text(encoding="utf-8").replace("\r\n", "\n")
start = text.index("# Check OS version")
end = text.index("# The user who ran the installer")
section = text[start:end]
for marker in ("/usr/share/raspberrypi-ui-mods", "/usr/share/xsessions"):
assert marker in section
section = section.replace(marker, "/nonexistent" + marker)
return section
def run_os_check(tmp_path: Path, release: str, **stub_args) -> subprocess.CompletedProcess:
"""Run the OS check as the installer would, from a copy of the project
layout so ``$(dirname "$0")/scripts/install/lib_os.sh`` resolves."""
project = tmp_path / "project"
(project / "scripts" / "install").mkdir(parents=True)
shutil.copy(LIB, project / "scripts" / "install" / "lib_os.sh")
script = project / "first_time_install.sh"
script.write_text("set -Eeuo pipefail\n"
"trap 'echo ERR-TRAP line $LINENO >&2; exit 99' ERR\n"
+ _os_check_section() + '\necho "SECTION-DONE"\n',
encoding="utf-8", newline="\n")
env = _env(tmp_path, release, _stubs(tmp_path, **stub_args))
return subprocess.run(["bash", str(script)], capture_output=True, text=True, env=env)
@needs_bash
class TestInstallerOsCheck:
@pytest.mark.parametrize("release,python,label", [
("bookworm", "3.11", "Debian 12 (Bookworm)"),
("bookworm64", "3.11", "Debian 12 (Bookworm)"),
("trixie", "3.13", "Debian 13 (Trixie)"),
])
def test_supported_release_passes(self, tmp_path, release, python, label):
result = run_os_check(tmp_path, release, python_version=python)
assert result.returncode == 0, result.stdout + result.stderr
assert f"✓ {label} detected" in result.stdout
assert f"✓ Python {python} detected" in result.stdout
assert "✓ OS requirements met" in result.stdout
assert "SECTION-DONE" in result.stdout
@pytest.mark.parametrize("release,reason", [
("bullseye", "This version of Raspberry Pi OS is not supported"),
("ubuntu", "This script requires Raspberry Pi OS"),
])
def test_unsupported_system_stops_with_directions(self, tmp_path, release, reason):
result = run_os_check(tmp_path, release)
assert result.returncode == 1, result.stdout + result.stderr
assert reason in result.stdout
assert "Installation cannot continue." in result.stdout
assert "Trixie (Debian 13) or Bookworm (Debian 12)" in result.stdout
assert "SECTION-DONE" not in result.stdout
def test_python_older_than_the_rgbmatrix_floor_stops(self, tmp_path):
result = run_os_check(tmp_path, "bookworm", python_version="3.10")
assert result.returncode == 1, result.stdout + result.stderr
assert "needs Python 3.11 or newer" in result.stdout
assert "ships Python 3.11" in result.stdout
def test_untested_newer_python_warns_and_continues(self, tmp_path):
result = run_os_check(tmp_path, "trixie", python_version="3.14")
assert result.returncode == 0, result.stdout + result.stderr
assert "has not been tested with" in result.stdout
def test_missing_python_is_left_to_step_1(self, tmp_path):
result = run_os_check(tmp_path, "trixie", python_version=None)
assert result.returncode == 0, result.stdout + result.stderr
assert "Step 1 installs it" in result.stdout
def test_dhcpcd_is_explained_but_not_fatal(self, tmp_path):
result = run_os_check(tmp_path, "bookworm", network="dhcpcd")
assert result.returncode == 0, result.stdout + result.stderr
assert "dhcpcd, not NetworkManager" in result.stdout
assert "Network Config" in result.stdout
def test_networkmanager_is_confirmed(self, tmp_path):
result = run_os_check(tmp_path, "trixie", python_version="3.13")
assert "✓ NetworkManager is managing the network" in result.stdout
# --- check_system_compatibility.sh ---------------------------------------------
def run_compat(tmp_path: Path, release: str, **stub_args) -> subprocess.CompletedProcess:
env = _env(tmp_path, release, _stubs(tmp_path, **stub_args))
return subprocess.run(["bash", str(COMPAT)], capture_output=True, text=True, env=env)
@needs_bash
class TestCompatibilityCheck:
@pytest.mark.parametrize("release,python,label", [
("bookworm", "3.11", "Debian 12 (Bookworm)"),
("trixie", "3.13", "Debian 13 (Trixie)"),
])
def test_supported_release_is_reported_supported(self, tmp_path, release, python, label):
out = run_compat(tmp_path, release, python_version=python).stdout
assert f"Detected {label} - supported" in out
assert "Python version is supported (3.11-3.13)" in out
assert "not supported" not in out
assert "ships Python" not in out
@pytest.mark.parametrize("release", ["bullseye", "ubuntu"])
def test_unsupported_release_is_an_error(self, tmp_path, release):
result = run_compat(tmp_path, release)
assert result.returncode == 1
assert "is not supported - the installer requires Raspberry Pi OS Lite" in result.stdout
def test_python_below_the_floor_is_an_error(self, tmp_path):
result = run_compat(tmp_path, "bookworm", python_version="3.10")
assert result.returncode == 1
assert "Python 3.10 is too old - Python 3.11+ is required" in result.stdout
def test_dhcpcd_is_a_warning(self, tmp_path):
out = run_compat(tmp_path, "bookworm", network="dhcpcd").stdout
assert "dhcpcd manages the network" in out
assert "NetworkManager manages the network" not in out
def test_python_that_is_not_the_releases_own_is_flagged(self, tmp_path):
out = run_compat(tmp_path, "trixie", python_version="3.11").stdout
assert "Debian 13 (Trixie) ships Python 3.13, but python3 runs 3.11" in out
# --- things that must hold for both Python versions ----------------------------
def test_installer_scripts_do_not_hard_code_a_python_minor_version():
"""The services run /usr/bin/python3, which is 3.11 on Bookworm and 3.13
on Trixie; naming either one in an installer or a unit breaks the other."""
paths = [FIRST_TIME, *sorted((ROOT / "scripts" / "install").glob("*.sh")),
*sorted((ROOT / "systemd").glob("*.service"))]
offenders = []
for path in paths:
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if line.lstrip().startswith("#"):
continue
if re.search(r"python3\.1[0-9]", line):
offenders.append(f"{path.relative_to(ROOT)}:{n}: {line.strip()}")
assert not offenders, "\n".join(offenders)
def _load_apt_installer():
spec = importlib.util.spec_from_file_location(
"install_dependencies_apt", ROOT / "scripts" / "install_dependencies_apt.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class TestAptFallbackRespectsThePins:
"""Step 7's apt-first fallback must not accept the releases' older apt
copies (Bookworm: Flask 2.2.2, Pillow 9.4; Trixie: Flask 3.1.1)."""
def test_floors_come_from_the_web_requirements(self):
mod = _load_apt_installer()
for name in ("flask", "werkzeug", "pillow", "requests", "psutil", "pytz", "freetype-py"):
assert mod.MIN_VERSIONS.get(name), f"no floor read for {name}"
assert mod.MIN_VERSIONS["freetype-py"] == (2, 5, 1)
@pytest.mark.parametrize("package,apt_version", [
("flask", (2, 2, 2)), # Bookworm
("flask", (3, 1, 1)), # Trixie
("PIL", (9, 4, 0)), # Bookworm python3-pil
("werkzeug", (2, 2, 2)),
("freetype-py", (2, 3, 0)),
])
def test_an_apt_copy_below_the_pin_does_not_count(self, monkeypatch, package, apt_version):
mod = _load_apt_installer()
monkeypatch.setattr(mod, "_installed_version_tuple", lambda dist: apt_version)
monkeypatch.setitem(sys.modules, mod.IMPORT_NAME_MAP.get(package, package), object())
assert mod.check_package_installed(package) is False
def test_a_version_at_the_pin_counts(self, monkeypatch):
mod = _load_apt_installer()
floor = mod.MIN_VERSIONS["flask"]
monkeypatch.setattr(mod, "_installed_version_tuple", lambda dist: floor)
monkeypatch.setitem(sys.modules, "flask", object())
assert mod.check_package_installed("flask") is True
def test_the_pillow_version_is_looked_up_under_its_distribution_name(self, monkeypatch):
mod = _load_apt_installer()
seen = []
monkeypatch.setattr(mod, "_installed_version_tuple", lambda dist: seen.append(dist) or (99,))
monkeypatch.setitem(sys.modules, "PIL", object())
assert mod.check_package_installed("PIL") is True
assert seen == ["Pillow"]
def test_pip_is_asked_for_pillow_not_pil(self, monkeypatch):
mod = _load_apt_installer()
calls = []
monkeypatch.setattr(mod, "_run", lambda cmd: calls.append(cmd) or (True, ""))
assert mod.install_via_pip("PIL") == (True, "")
assert calls[0][-1] == "Pillow"