Files
voculinux/scripts/check_build_deps.py
Sergey SilaevandGitHub b59797f1af chore(dev): widen uv range and harden Fedora/AppImage builds (#952)
* chore(deps): allow uv versions below 0.14

* fix(ci): align uv version and generated exports

* fix(dev): check Fedora build headers and AppImage GI bundle

Fail early with distro-specific native dependency hints before dev sync. Verify the AppImage's PyGObject installation and Python path after linuxdeploy, and report isolated GI import failures with actionable path diagnostics. Guard the CI uv pin and cover both preflights with tests.
2026-10-23 22:16:16 -07:00

213 lines
7.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fail fast when the native headers `just deps` needs are missing.
`uv sync` builds pyaudio, pycairo and PyGObject from source on Linux. Without
their development headers it fails deep inside a C compiler invocation, which
reads as a uv or dependency problem even though the fix is a distro package.
This check runs before the sync and names what is missing.
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VENV_PYTHON = ROOT / ".venv" / "bin" / "python"
# pkg-config modules the source builds query. Labels explain the module to a
# contributor who does not know which package owns it.
BUILD_MODULES = (("portaudio-2.0", "PortAudio headers (pyaudio)"),)
# PyGObject and its pycairo dependency are selected only on Linux by the
# sys_platform marker in pyproject.toml. They must not gate macOS tooling.
PYGOBJECT_BUILD_MODULES = (
("cairo", "cairo headers (pycairo)"),
("cairo-gobject", "cairo-gobject headers (PyGObject)"),
("girepository-2.0", "GObject Introspection headers (glib 2.80+)"),
("libffi", "libffi headers (PyGObject)"),
)
MIN_GIREPOSITORY_VERSION = "2.80"
APT_HINT = [
"Ubuntu 24.04+ / Debian 13+:",
" sudo apt install -y build-essential pkg-config libcairo2-dev portaudio19-dev \\",
" libgirepository-2.0-dev libffi-dev",
]
DEBIAN_12_NOTE = [
"",
"Debian 12 cannot build PyGObject 3.56 (glib is too old). Use ./install.sh --dev",
"there and run the suite with venv/bin/pytest.",
]
DNF_HINT = [
"Fedora 40+ / RHEL-based systems with glib 2.80+:",
" sudo dnf install -y gcc gcc-c++ make pkg-config cairo-devel \\",
" cairo-gobject-devel portaudio-devel glib2-devel \\",
" gobject-introspection-devel libffi-devel",
"If your system's glib is older than 2.80, use ./install.sh --dev instead.",
]
FEDORA_OLD_NOTE = [
"Fedora 39 and older cannot build PyGObject 3.56 (glib is too old).",
"Use ./install.sh --dev there and run the suite with venv/bin/pytest.",
]
PACMAN_HINT = [
"Arch / Manjaro:",
" sudo pacman -S --needed base-devel pkgconf cairo portaudio gobject-introspection",
]
ZYPPER_HINT = [
"openSUSE:",
" sudo zypper install -y gcc gcc-c++ make pkg-config cairo-devel \\",
" portaudio-devel gobject-introspection-devel libffi-devel",
]
GENERIC_HINT = [
"Install the development packages for PortAudio, cairo, GObject Introspection",
"(glib 2.80+) and libffi, plus a C compiler and pkg-config.",
]
MACOS_HINT = [
"macOS (Homebrew):",
" brew install portaudio pkg-config",
"Install the Xcode Command Line Tools if a C compiler is missing:",
" xcode-select --install",
]
def read_os_release(path: Path = Path("/etc/os-release")) -> dict[str, str]:
"""Parse os-release without executing it as shell."""
values: dict[str, str] = {}
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
return values
for line in lines:
key, separator, value = line.partition("=")
if not separator or not key or key.startswith("#"):
continue
values[key] = value.strip().strip('"').strip("'")
return values
def install_hint(release: dict[str, str]) -> list[str]:
"""Return distro-specific install commands for the missing build headers."""
distro = release.get("ID", "").lower()
like = release.get("ID_LIKE", "").lower()
if distro == "ubuntu" or "ubuntu" in like:
return APT_HINT
if distro == "debian" or "debian" in like:
return [*APT_HINT, *DEBIAN_12_NOTE]
if distro in {"fedora", "rhel", "centos", "rocky", "almalinux"} or any(
family in like for family in ("fedora", "rhel")
):
if distro == "fedora" and release.get("VERSION_ID", "").isdigit():
if int(release["VERSION_ID"]) < 40:
return FEDORA_OLD_NOTE
return DNF_HINT
if distro in {"arch", "manjaro", "endeavouros"} or "arch" in like:
return PACMAN_HINT
if distro == "opensuse" or distro.startswith("opensuse-") or "suse" in like:
return ZYPPER_HINT
return GENERIC_HINT
def venv_has_compiled_packages() -> bool:
"""True when `.venv` has the native packages selected for this platform."""
if not VENV_PYTHON.is_file():
return False
packages = ("pyaudio",)
if sys.platform == "linux":
packages += ("pycairo", "pygobject")
check = "import importlib.metadata as metadata; " + "; ".join(
f"metadata.version({package!r})" for package in packages
)
result = subprocess.run(
[str(VENV_PYTHON), "-c", check],
capture_output=True,
check=False,
)
return result.returncode == 0
def missing_build_dependencies() -> list[str]:
"""Return the compiler or pkg-config modules uv cannot find, if any."""
missing: list[str] = []
if shutil.which("pkg-config") is None:
missing.append("pkg-config")
if not any(shutil.which(compiler) for compiler in ("cc", "gcc", "clang")):
missing.append("a C compiler")
# Module probes need pkg-config; report the missing tool instead of a list
# of modules the user cannot check yet.
if missing:
return missing
modules = BUILD_MODULES
if sys.platform == "linux":
modules += PYGOBJECT_BUILD_MODULES
for module, description in modules:
# PyGObject 3.56 requires girepository-2.0 >= 2.80; finding an older
# .pc file is not enough to build it successfully.
args = ["pkg-config", "--exists", module]
if module == "girepository-2.0":
args = ["pkg-config", f"--atleast-version={MIN_GIREPOSITORY_VERSION}", module]
result = subprocess.run(
args,
capture_output=True,
check=False,
)
if result.returncode != 0:
missing.append(description)
return missing
def main() -> int:
"""Print an actionable dependency report when the build cannot succeed."""
if venv_has_compiled_packages():
return 0
missing = missing_build_dependencies()
if not missing:
return 0
print("error: missing native build dependencies for .venv/", file=sys.stderr)
print(file=sys.stderr)
print(
"`just deps` builds native Python packages from source, so uv needs their",
file=sys.stderr,
)
print("development headers before it can create the environment.", file=sys.stderr)
print(file=sys.stderr)
print("Missing:", file=sys.stderr)
for item in missing:
print(f" - {item}", file=sys.stderr)
print(file=sys.stderr)
print("Install them for your system:", file=sys.stderr)
hints = MACOS_HINT if sys.platform == "darwin" else install_hint(read_os_release())
for line in hints:
print(line, file=sys.stderr)
print(file=sys.stderr)
print(
"See the Manual setup section in CONTRIBUTING.md for the full package list.",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())