mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 23:35:08 +00:00
* fix(install): detect desktops by their metapackages, not name prefixes
The Lite check matched any installed package starting with gnome/kde/
xfce/lxde, so standalone parts (gnome-keyring, xfce4-terminal,
lxde-icon-theme) rejected a Lite system. Match whole names of desktop
metapackages and session managers instead.
Also catch desktops the prefixes missed: Raspberry Pi OS Trixie replaced
raspberrypi-ui-mods with rpd-wayland-core / rpd-x-core, Debian tasksel
desktops (task-*-desktop), and multi-arch names (plasma-workspace:arm64).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ
* docs(common): list espn_payload in the common README
#749 added src/common/espn_payload.py without a summary row or section,
so test_common_readme_lists_every_module fails on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ
(cherry picked from commit 8333f23e5c)
* fix(install): only a running desktop stops the install
A desktop costs the panel CPU only while it runs, so a running display
manager (checked with systemctl is-active, including the generic
display-manager alias, instead of a grep -q pipe under pipefail) still
stops the installer. Desktop packages or session files on a Pi that boots
to the console now print a warning and the install continues.
Adds installer OS-check tests for running, installed-only and Lite
systems, including the libblockdev and gnome-keyring false positives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ
---------
Co-authored-by: Claude <noreply@anthropic.com>
393 lines
18 KiB
Python
393 lines
18 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",
|
|
active=(), packages=()) -> Path:
|
|
"""python3 reports ``python_version`` (None: not installed); systemctl
|
|
reports ``network`` and ``active`` as the only active units; dpkg-query
|
|
lists ``packages`` as installed (none by default, so 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')
|
|
units = "|".join(f'*"is-active --quiet {unit}"' for unit in (network, *active))
|
|
_stub(bin_dir, "systemctl", f'case "$*" in {units}) exit 0 ;; esac\nexit 3\n')
|
|
_stub(bin_dir, "dpkg", "exit 0\n")
|
|
if packages:
|
|
listing = "".join(f"ii {name}\\n" for name in packages)
|
|
_stub(bin_dir, "dpkg-query", f'printf "{listing}"\n')
|
|
else:
|
|
_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(marker_root: str = "/nonexistent") -> str:
|
|
"""first_time_install.sh from the OS check up to the next section, with
|
|
the desktop-marker directories moved under ``marker_root`` (by default
|
|
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, marker_root + marker)
|
|
return section
|
|
|
|
|
|
def run_os_check(tmp_path: Path, release: str, marker_root: str = "/nonexistent",
|
|
**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(marker_root) + '\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
|
|
|
|
# A running desktop stops the install; one that is only installed warns.
|
|
|
|
@pytest.mark.parametrize("unit", ["display-manager", "lightdm", "gdm", "sddm"])
|
|
def test_running_desktop_stops(self, tmp_path, unit):
|
|
result = run_os_check(tmp_path, "trixie", python_version="3.13", active=(unit,))
|
|
assert result.returncode == 1, result.stdout + result.stderr
|
|
assert "A desktop is running" in result.stdout
|
|
assert "multi-user.target" in result.stdout
|
|
assert "SECTION-DONE" not in result.stdout
|
|
|
|
@pytest.mark.parametrize("package", [
|
|
"raspberrypi-ui-mods", "rpd-wayland-core", "rpd-x-core", "xfce4",
|
|
"lxde-core", "gnome-shell", "kde-plasma-desktop", "plasma-workspace:arm64",
|
|
"task-desktop", "task-mate-desktop",
|
|
])
|
|
def test_installed_desktop_that_is_not_running_warns(self, tmp_path, package):
|
|
result = run_os_check(tmp_path, "trixie", python_version="3.13",
|
|
packages=("bash", package))
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert "Desktop packages are installed, but no desktop is running" in result.stdout
|
|
assert "✓ OS requirements met" in result.stdout
|
|
|
|
def test_desktop_session_files_warn(self, tmp_path):
|
|
(tmp_path / "markers" / "usr" / "share" / "xsessions").mkdir(parents=True)
|
|
result = run_os_check(tmp_path, "trixie", python_version="3.13",
|
|
marker_root=str(tmp_path / "markers"))
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert "Desktop packages are installed, but no desktop is running" in result.stdout
|
|
|
|
@pytest.mark.parametrize("packages", [
|
|
# libblockdev contains "kde" mid-word; the old check stopped on it.
|
|
("libblockdev-crypto3", "libblockdev3:arm64"),
|
|
("gnome-keyring", "xfce4-terminal", "xfconf", "lxde-icon-theme",
|
|
"kde-cli-tools", "gnome-session-common", "task-ssh-server", "rpd-plym-splash"),
|
|
])
|
|
def test_lite_with_desktop_named_parts_is_lite(self, tmp_path, packages):
|
|
result = run_os_check(tmp_path, "trixie", python_version="3.13", packages=packages)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert "✓ Lite version confirmed" in result.stdout
|
|
assert "WARNING: Desktop" not 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"
|