mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
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>
344 lines
16 KiB
Python
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"
|