Compare commits

..

4 Commits

Author SHA1 Message Date
Adeeb Shihadeh
975313a86f add xvfb package (#77) 2026-04-30 17:55:32 -07:00
Adeeb Shihadeh
19a9ed13c0 add acados package (#76) 2026-04-30 16:18:27 -07:00
Adeeb Shihadeh
f9dd627e57 support pure wheels in shim packages (#75) 2026-04-25 13:35:28 -07:00
Adeeb Shihadeh
058ed3c076 add catch2 package (#74) 2026-04-25 13:11:59 -07:00
22 changed files with 822 additions and 265 deletions

6
.gitignore vendored
View File

@@ -30,3 +30,9 @@ git-lfs/git_lfs/bin/
# downloaded mdbook # downloaded mdbook
mdbook/mdbook/bin/ mdbook/mdbook/bin/
# vendored at build time
acados/acados/acados_template/
acados/casadi/
acados/casadi-venv/
acados/casadi-wheel/

View File

@@ -25,6 +25,17 @@ DATADIR = _cfg["tool"]["shim"]["datadir"]
VERSION = _cfg["project"]["version"] VERSION = _cfg["project"]["version"]
MODULE = _cfg["project"]["name"].replace("-", "_") MODULE = _cfg["project"]["name"].replace("-", "_")
# all top-level packages bundled in this wheel (e.g. acados + casadi).
# `packages` may be a flat list or a dict with find.include patterns.
_pkgs = _cfg.get("tool", {}).get("setuptools", {}).get("packages", [f"{MODULE}*"])
if isinstance(_pkgs, list):
_INCLUDE = _pkgs
elif isinstance(_pkgs, dict):
_INCLUDE = _pkgs.get("find", {}).get("include", [f"{MODULE}*"])
else:
_INCLUDE = [f"{MODULE}*"]
TOP_PACKAGES = sorted({p.rstrip("*").rstrip("/") for p in _INCLUDE if p.rstrip("*").rstrip("/")})
PLATFORM_MAP = { PLATFORM_MAP = {
("Linux", "x86_64"): "linux_x86_64", ("Linux", "x86_64"): "linux_x86_64",
("Linux", "aarch64"): "linux_aarch64", ("Linux", "aarch64"): "linux_aarch64",
@@ -43,59 +54,52 @@ class InstallPrebuilt(build_py):
if plat is None: if plat is None:
raise RuntimeError(f"unsupported platform: {key}") raise RuntimeError(f"unsupported platform: {key}")
whl_name = f"{MODULE}-{VERSION}-py3-none-{plat}.whl" whl_names = [
url = f"{REPO_URL}/releases/download/{TAG}/{whl_name}" f"{MODULE}-{VERSION}-py3-none-{plat}.whl",
f"{MODULE}-{VERSION}-py3-none-any.whl",
]
print(f"Downloading {url} ...") raw = None
for attempt in range(3): for whl_name in whl_names:
try: url = f"{REPO_URL}/releases/download/{TAG}/{whl_name}"
raw = urlopen(url, timeout=60).read() print(f"Downloading {url} ...")
for attempt in range(3):
try:
raw = urlopen(url, timeout=60).read()
break
except (URLError, OSError) as e:
if attempt == 2:
if whl_name == whl_names[-1]:
raise
break
wait = 2 ** attempt
print(f"Download failed ({e}), retrying in {wait}s ...")
time.sleep(wait)
if raw is not None:
break break
except (URLError, OSError) as e:
if attempt == 2:
raise
wait = 2 ** attempt
print(f"Download failed ({e}), retrying in {wait}s ...")
time.sleep(wait)
print(f"Extracting {DATADIR} ...") print("Extracting wheel ...")
with zipfile.ZipFile(BytesIO(raw)) as zf: with zipfile.ZipFile(BytesIO(raw)) as zf:
prefix = f"{MODULE}/{DATADIR}/" purelib_data = f"{MODULE}-{VERSION}.data/purelib/"
alt_prefix = f"{MODULE}-{VERSION}.data/purelib/{MODULE}/{DATADIR}/"
for info in zf.infolist(): for info in zf.infolist():
for p in (prefix, alt_prefix): if info.is_dir():
if info.filename.startswith(p):
rel = info.filename[len(p):]
if not rel:
continue
dest = os.path.join(data_dir, rel)
if info.is_dir():
os.makedirs(dest, exist_ok=True)
else:
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "wb") as f:
f.write(zf.read(info))
if info.external_attr >> 16 & 0o111:
os.chmod(dest, 0o755)
break
# Also extract compiled extension modules (.so)
ext_prefix = f"{MODULE}/"
ext_alt_prefix = f"{MODULE}-{VERSION}.data/purelib/{MODULE}/"
for info in zf.infolist():
if not info.filename.endswith('.so'):
continue continue
for p in (ext_prefix, ext_alt_prefix): name = info.filename
if info.filename.startswith(p): if name.startswith(purelib_data):
rel = info.filename[len(p):] rel = name[len(purelib_data):]
if rel and '/' not in rel: else:
dest = os.path.join(module_dir, rel) rel = name
os.makedirs(os.path.dirname(dest), exist_ok=True) if "/" not in rel:
with open(dest, "wb") as f: continue
f.write(zf.read(info)) top = rel.split("/", 1)[0]
if info.external_attr >> 16 & 0o111: if top not in TOP_PACKAGES:
os.chmod(dest, 0o755) continue
break dest = os.path.join(_HERE, rel)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "wb") as f:
f.write(zf.read(info))
if info.external_attr >> 16 & 0o111:
os.chmod(dest, 0o755)
super().run() super().run()

48
acados/acados/__init__.py Normal file
View File

@@ -0,0 +1,48 @@
import os
DIR = os.path.join(os.path.dirname(__file__), "install")
INCLUDE_DIR = os.path.join(DIR, "include")
LIB_DIR = os.path.join(DIR, "lib")
BIN_DIR = os.path.join(DIR, "bin")
TERA_PATH = os.path.join(BIN_DIR, "t_renderer")
TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "acados_template")
def smoketest():
import sys
lib_ext = ".dylib" if sys.platform == "darwin" else ".so"
for lib in ("libacados", "libblasfeo", "libhpipm", "libqpOASES_e"):
path = os.path.join(LIB_DIR, lib + lib_ext)
assert os.path.isfile(path), f"missing lib: {path}"
assert os.path.isfile(os.path.join(INCLUDE_DIR, "acados_c", "ocp_nlp_interface.h"))
assert os.path.isfile(os.path.join(INCLUDE_DIR, "blasfeo", "include", "blasfeo.h"))
assert os.path.isfile(os.path.join(INCLUDE_DIR, "hpipm", "include", "hpipm_common.h"))
assert os.path.isfile(TERA_PATH) and os.access(TERA_PATH, os.X_OK), f"t_renderer missing/not executable: {TERA_PATH}"
assert os.path.isfile(os.path.join(TEMPLATE_DIR, "__init__.py"))
assert os.path.isfile(os.path.join(TEMPLATE_DIR, "acados_layout.json"))
assert os.path.isdir(os.path.join(TEMPLATE_DIR, "c_templates_tera"))
# the vendored slim casadi shipped in this wheel is built against cpython 3.12
# and pulls in numpy; only exercise it when both are available (real consumers
# like openpilot install via the shim, which declares numpy as a dep).
try:
import numpy # noqa: F401
except ImportError:
return
if sys.version_info[:2] != (3, 12):
return
from casadi import SX, MX, DM, Function, CasadiMeta, vertcat, jacobian, sin, cos, n_nodes # noqa: F401
x = SX.sym("x")
y = SX.sym("y")
expr = vertcat(sin(x), cos(y))
J = jacobian(expr, vertcat(x, y))
assert J.shape == (2, 2)
assert n_nodes(J) > 0
assert isinstance(CasadiMeta.version(), str)

139
acados/build.sh Executable file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -e
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)"
cd "$DIR"
# v0.2.2
VERSION="8af9b0ad180940ef611884574a0b27a43504311d"
INSTALL_DIR="$DIR/acados/install"
TEMPLATE_DIR="$DIR/acados/acados_template"
CASADI_DIR="$DIR/casadi"
CASADI_VERSION="3.6.7"
NJOBS="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2)"
# pick BLAS target per host arch
ARCH="$(uname -m)"
if [[ "$OSTYPE" == "darwin"* ]]; then
# this BLASFEO version doesn't have an Apple Silicon target; Cortex-A57
# baseline ARMv8 SIMD compiles and runs fine on M1+.
BLAS_TARGET="ARMV8A_ARM_CORTEX_A57"
elif [[ "$ARCH" == "aarch64" ]]; then
# Cortex-A57 = TICI baseline; safe for any modern aarch64
BLAS_TARGET="ARMV8A_ARM_CORTEX_A57"
else
BLAS_TARGET="X64_AUTOMATIC"
fi
ACADOS_FLAGS=(
-DACADOS_WITH_QPOASES=ON
-UBLASFEO_TARGET
-DBLASFEO_TARGET="$BLAS_TARGET"
-DACADOS_INSTALL_DIR="$INSTALL_DIR"
# acados (and several of its submodules) still pin cmake_minimum_required <3.5;
# CMake 4 removed that compatibility, so re-enable it here.
-DCMAKE_POLICY_VERSION_MINIMUM=3.5
# qpOASES + blasfeo both call malloc()/posix_memalign() without including
# <stdlib.h>. C99/C2x and modern gcc/clang reject implicit declarations as
# errors. qpOASES also has a real Constraints*/Constraints** pointer bug
# that gcc 14+ now flags as an error too. Downgrade both so the upstream
# (pinned) sources keep compiling.
"-DCMAKE_C_FLAGS=-Wno-implicit-function-declaration -Wno-incompatible-pointer-types"
)
if [[ "$OSTYPE" == "darwin"* ]]; then
ACADOS_FLAGS+=(
-DCMAKE_OSX_ARCHITECTURES=arm64
-DCMAKE_MACOSX_RPATH=1
)
fi
# clone/update source
if [ ! -d "acados-src/.git" ]; then
rm -rf acados-src
git clone https://github.com/acados/acados.git acados-src
fi
git -C acados-src fetch --all --tags
git -C acados-src checkout --force "$VERSION"
git -C acados-src submodule update --init --recursive --depth=1
# build acados
mkdir -p build
cd build
cmake "${ACADOS_FLAGS[@]}" "$DIR/acados-src"
make -j"$NJOBS" install
cd "$DIR"
# we don't ship sample json templates
rm -f "$INSTALL_DIR"/lib/*.json
# python interface package (acados_template)
rm -rf "$TEMPLATE_DIR"
cp -r acados-src/interfaces/acados_template/acados_template "$TEMPLATE_DIR"
# strip future_fstrings (avoids needing the compatibility package on py>=3.6).
# Cython chokes on the unknown encoding in .pyx/.pxd too, not just .py.
find "$TEMPLATE_DIR" -type f \( -name '*.py' -o -name '*.pyx' -o -name '*.pxd' \) \
-exec sed -i.bak '/future.fstrings/d' {} +
find "$TEMPLATE_DIR" -name '*.bak' -delete
# acados_template's gnsf/check_reformulation.py uses an absolute `from
# acados_template.utils import ...` that only worked when acados_template was
# itself a top-level package on the path. We ship it as `acados.acados_template`,
# so rewrite to the relative form the rest of gnsf already uses.
if [ -f "$TEMPLATE_DIR/gnsf/check_reformulation.py" ]; then
sed -i.bak 's/^from acados_template\.utils /from ..utils /' "$TEMPLATE_DIR/gnsf/check_reformulation.py"
rm -f "$TEMPLATE_DIR/gnsf/check_reformulation.py.bak"
fi
# build tera renderer (needs cargo)
if ! command -v cargo >/dev/null 2>&1; then
echo "installing rust toolchain (needed for tera_renderer)..."
curl -LsSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
# shellcheck disable=SC1091
source "$HOME/.cargo/env"
fi
mkdir -p "$INSTALL_DIR/bin"
cd "$DIR/acados-src/interfaces/acados_template/tera_renderer/"
if [[ "$OSTYPE" == "darwin"* ]]; then
cargo build --release --target aarch64-apple-darwin
cp target/aarch64-apple-darwin/release/t_renderer "$INSTALL_DIR/bin/t_renderer"
else
cargo build --release
cp target/release/t_renderer "$INSTALL_DIR/bin/t_renderer"
fi
cd "$DIR"
# vendor a slim casadi: install the upstream wheel into a throwaway cp312 venv
# (uv resolves the right platform wheel automatically), then move the casadi/
# tree out and slim it.
echo "vendoring casadi $CASADI_VERSION ..."
rm -rf "$CASADI_DIR" casadi-venv
uv venv --python 3.12 --quiet casadi-venv
uv pip install --python casadi-venv/bin/python --no-deps --quiet "casadi==$CASADI_VERSION"
mv casadi-venv/lib/python3.12/site-packages/casadi "$CASADI_DIR"
rm -rf casadi-venv
# drop everything except the bits openpilot actually needs:
# - __init__.py, casadi.py, tools/ (Python wrapper)
# - _casadi.so (CPython extension; same name on linux+darwin)
# - libcasadi.{so,dylib}* (the C++ runtime that _casadi.so links to)
# openpilot only uses symbolic SX/MX/Function/jacobian etc., which live in
# libcasadi + _casadi. Solver plugins (conic_*, nlpsol_*, integrator_*, ...)
# and their third-party backends (ipopt, bonmin, hpipm, fatrop, ...) are
# loaded lazily via dlopen and never reached.
cd "$CASADI_DIR"
shopt -s extglob
# libc++.*.dylib only exists on darwin and is needed by _casadi.so via @rpath
rm -rf !(__init__.py|casadi.py|_casadi.so|tools|libcasadi.*|libc++.*)
shopt -u extglob
cd "$DIR"
rm -rf casadi-wheel
echo "Installed acados to $INSTALL_DIR"
du -sh "$INSTALL_DIR"
echo "Vendored casadi (slim) at $CASADI_DIR"
du -sh "$CASADI_DIR"

23
acados/pyproject.toml Normal file
View File

@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=64", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "acados"
version = "0.2.2"
description = "acados solvers + Python interface, with a slimmed casadi vendored alongside"
requires-python = ">=3.8"
dependencies = [
# the vendored casadi.py shim imports numpy at module load time
"numpy",
]
# `find` runs at setup() init time, before build.sh creates these dirs, so
# enumerate packages explicitly. acados.acados_template + casadi.tools are
# subpackages and ship via package-data.
[tool.setuptools]
packages = ["acados", "casadi"]
[tool.setuptools.package-data]
acados = ["install/**/*", "acados_template/**/*"]
casadi = ["**/*"]

68
acados/setup.py Normal file
View File

@@ -0,0 +1,68 @@
import os
import platform
import subprocess
from setuptools.command.build_py import build_py
# `casadi/` is generated by build.sh (vendored from the upstream wheel), but
# setuptools requires every declared package to have a directory + __init__.py
# *before* any command runs. Drop a stub if it isn't there yet — build.sh
# replaces it with the real slim casadi during build_py.
_HERE = os.path.dirname(os.path.abspath(__file__))
_casadi_init = os.path.join(_HERE, "casadi", "__init__.py")
if not os.path.exists(_casadi_init):
os.makedirs(os.path.dirname(_casadi_init), exist_ok=True)
open(_casadi_init, "w").close()
try:
from wheel.bdist_wheel import bdist_wheel
except ImportError:
bdist_wheel = None
class BuildAcados(build_py):
"""Run build.sh to compile acados before collecting package data."""
def run(self):
pkg_dir = os.path.dirname(os.path.abspath(__file__))
build_script = os.path.join(pkg_dir, "build.sh")
subprocess.check_call(["bash", build_script], cwd=pkg_dir)
super().run()
cmdclass = {"build_py": BuildAcados}
if bdist_wheel is not None:
class PlatformWheel(bdist_wheel):
"""Produce a platform-specific, Python-version-agnostic wheel."""
def finalize_options(self):
super().finalize_options()
self.root_is_pure = False
def get_tag(self):
system = platform.system()
machine = platform.machine()
if system == "Linux":
plat = f"linux_{machine}"
elif system == "Darwin":
plat = "macosx_11_0_arm64"
else:
plat = f"{system.lower()}_{machine}"
return "py3", "none", plat
cmdclass["bdist_wheel"] = PlatformWheel
def setup():
from setuptools import setup as _setup
_setup(cmdclass=cmdclass)
if __name__ == "__main__":
setup()

22
catch2/build.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -e
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)"
cd "$DIR"
VERSION="v2.13.10"
INSTALL_DIR="$DIR/catch2/install"
if [ ! -d "catch2-src/.git" ]; then
rm -rf catch2-src
git clone --depth 1 https://github.com/catchorg/Catch2.git catch2-src
fi
git -C catch2-src fetch --depth 1 origin "$VERSION"
git -C catch2-src checkout --force FETCH_HEAD
rm -rf "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR/include"
cp -r catch2-src/single_include/catch2 "$INSTALL_DIR/include/"
echo "Installed catch2 to $INSTALL_DIR"
du -sh "$INSTALL_DIR"

View File

@@ -0,0 +1,9 @@
import os
DIR = os.path.join(os.path.dirname(__file__), "install")
INCLUDE_DIR = os.path.join(DIR, "include")
LIB_DIR = DIR # header-only; no libraries
def smoketest():
assert os.path.isfile(os.path.join(INCLUDE_DIR, "catch2", "catch.hpp")), "catch2/catch.hpp not found"

16
catch2/pyproject.toml Normal file
View File

@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=64", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "catch2"
version = "2.13.10"
description = "Catch2 C++ test framework headers"
requires-python = ">=3.8"
[tool.setuptools.packages.find]
include = ["catch2*"]
exclude = ["catch2-src*"]
[tool.setuptools.package-data]
catch2 = ["install/**/*"]

28
catch2/setup.py Normal file
View File

@@ -0,0 +1,28 @@
import os
import subprocess
from setuptools.command.build_py import build_py
class BuildCatch2(build_py):
"""Run build.sh to download Catch2 headers before collecting package data."""
def run(self):
pkg_dir = os.path.dirname(os.path.abspath(__file__))
build_script = os.path.join(pkg_dir, "build.sh")
subprocess.check_call(["bash", build_script], cwd=pkg_dir)
super().run()
cmdclass = {"build_py": BuildCatch2}
def setup():
from setuptools import setup as _setup
_setup(cmdclass=cmdclass)
if __name__ == "__main__":
setup()

View File

@@ -1,7 +1,9 @@
[tool.uv.workspace] [tool.uv.workspace]
members = [ members = [
"acados",
"bzip2", "bzip2",
"capnproto", "capnproto",
"catch2",
"cppcheck", "cppcheck",
"eigen", "eigen",
"ffmpeg", "ffmpeg",
@@ -17,6 +19,7 @@ members = [
"ncurses", "ncurses",
"qt5", "qt5",
"raylib", "raylib",
"xvfb",
"zeromq", "zeromq",
"zstd", "zstd",
] ]

View File

@@ -1,8 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -e
export SOURCE_DATE_EPOCH=0
export ZERO_AR_DATE=1
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)"
cd "$DIR" cd "$DIR"
@@ -10,36 +7,30 @@ cd "$DIR"
INSTALL_DIR="$DIR/raylib/install" INSTALL_DIR="$DIR/raylib/install"
NJOBS="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2)" NJOBS="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2)"
if command -v ccache >/dev/null 2>&1; then CC="ccache ${CC:-cc}"
CC="${CC:-ccache cc}"
else # Detect platform: PLATFORM_COMMA for comma devices, PLATFORM_DESKTOP otherwise
CC="${CC:-cc}" RAYLIB_PLATFORM="${RAYLIB_PLATFORM:-PLATFORM_DESKTOP}"
if [ -f /TICI ]; then
RAYLIB_PLATFORM="PLATFORM_COMMA"
fi fi
export RAYLIB_PLATFORM
is_linux() { # Install build dependencies
[[ "$(uname)" == "Linux" ]] if [[ "$(uname)" == "Linux" ]]; then
} if [ "$RAYLIB_PLATFORM" = "PLATFORM_COMMA" ]; then
install_linux_deps() {
local platform="$1"
if ! is_linux; then
return
fi
if [ "$platform" = "PLATFORM_COMMA" ]; then
# comma device: needs DRM/EGL/GLES headers (usually already present on AGNOS) # comma device: needs DRM/EGL/GLES headers (usually already present on AGNOS)
# apt may fail on devices due to read-only rootfs or package conflicts; that's OK. # apt may fail on devices due to read-only rootfs or package conflicts that's OK
if command -v apt-get >/dev/null 2>&1; then if command -v apt-get &>/dev/null; then
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
apt-get update && apt-get install -y libdrm-dev libgbm-dev libgles2-mesa-dev libegl1-mesa-dev || true apt-get update && apt-get install -y libdrm-dev libgbm-dev libgles2-mesa-dev libegl1-mesa-dev || true
else else
sudo apt-get update && sudo apt-get install -y libdrm-dev libgbm-dev libgles2-mesa-dev libegl1-mesa-dev || true sudo apt-get update && sudo apt-get install -y libdrm-dev libgbm-dev libgles2-mesa-dev libegl1-mesa-dev || true
fi fi
fi fi
elif [ "$platform" = "PLATFORM_OFFSCREEN" ]; then elif [ "$RAYLIB_PLATFORM" = "PLATFORM_OFFSCREEN" ]; then
if command -v dnf >/dev/null 2>&1; then # offscreen (CI): needs EGL/GL dev packages (no X11)
dnf install -y mesa-libEGL-devel mesa-libGL-devel libglvnd-opengl libglvnd-core-devel 2>/dev/null || true if command -v apt-get &>/dev/null; then
elif command -v apt-get >/dev/null 2>&1; then
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
apt-get update && apt-get install -y libegl-dev libgl-dev apt-get update && apt-get install -y libegl-dev libgl-dev
else else
@@ -47,9 +38,10 @@ install_linux_deps() {
fi fi
fi fi
else else
if command -v dnf >/dev/null 2>&1; then # desktop: needs X11/GL dev packages
if command -v dnf &>/dev/null; then
dnf install -y libX11-devel libXcursor-devel libXrandr-devel libXinerama-devel libXi-devel mesa-libGL-devel dnf install -y libX11-devel libXcursor-devel libXrandr-devel libXinerama-devel libXi-devel mesa-libGL-devel
elif command -v apt-get >/dev/null 2>&1; then elif command -v apt-get &>/dev/null; then
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
apt-get update && apt-get install -y libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev libgl-dev apt-get update && apt-get install -y libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev libgl-dev
else else
@@ -57,72 +49,55 @@ install_linux_deps() {
fi fi
fi fi
fi fi
} fi
# RAYLIB_COMMIT matches the openpilot third_party/raylib pin this package replaces. # Clone and build raylib C library
RAYLIB_COMMIT="${RAYLIB_COMMIT:-3425bd9d1fb292ede4d80f97a1f4f258f614cffc}" RAYLIB_COMMIT="d9d7cc1353ec0f73c97e84ddf0973983d1ee25e2"
RAYLIB_OFFSCREEN_COMMIT="${RAYLIB_OFFSCREEN_COMMIT:-d9d7cc1353ec0f73c97e84ddf0973983d1ee25e2}"
RAYGUI_COMMIT="${RAYGUI_COMMIT:-76b36b597edb70ffaf96f046076adc20d67e7827}"
if [ ! -d "raylib-src/.git" ]; then if [ ! -d "raylib-src/.git" ]; then
rm -rf raylib-src rm -rf raylib-src
git clone --depth 1 -b master --no-tags https://github.com/commaai/raylib.git raylib-src git clone --depth 1 -b platform-offscreen --no-tags https://github.com/commaai/raylib.git raylib-src
fi fi
rm -rf "$INSTALL_DIR" cd raylib-src
mkdir -p "$INSTALL_DIR"/{lib,include} git fetch --depth 1 origin "$RAYLIB_COMMIT"
git reset --hard "$RAYLIB_COMMIT"
if [ -n "${RAYLIB_VARIANTS:-}" ]; then cd src
read -r -a VARIANTS <<< "$RAYLIB_VARIANTS" make clean
elif [[ -f /TICI || -f /AGNOS ]]; then make -j"$NJOBS" PLATFORM="$RAYLIB_PLATFORM" CC="${CC:-gcc}"
VARIANTS=(comma)
elif is_linux && [[ "$(uname -m)" == "x86_64" ]]; then
VARIANTS=(desktop offscreen)
elif is_linux && [[ "$(uname -m)" == "aarch64" ]]; then
VARIANTS=(desktop comma)
else
VARIANTS=(desktop)
fi
build_variant() {
local variant="$1"
local platform="PLATFORM_DESKTOP"
local commit="$RAYLIB_COMMIT"
if [ "$variant" = "comma" ]; then
platform="PLATFORM_COMMA"
elif [ "$variant" = "offscreen" ]; then
platform="PLATFORM_OFFSCREEN"
commit="$RAYLIB_OFFSCREEN_COMMIT"
fi
echo "Building raylib $variant ($platform)"
install_linux_deps "$platform"
cd "$DIR/raylib-src"
git fetch --depth 1 origin "$commit"
git reset --hard "$commit"
git clean -xdff .
cd src
make clean
make -j"$NJOBS" PLATFORM="$platform" CC="$CC"
mkdir -p "$INSTALL_DIR/lib/$variant"
cp libraylib.a "$INSTALL_DIR/lib/$variant/libraylib.a"
if [ "$variant" = "${VARIANTS[0]}" ]; then
cp raylib.h raymath.h rlgl.h "$INSTALL_DIR/include/"
cp libraylib.a "$INSTALL_DIR/lib/libraylib.a"
fi
}
for variant in "${VARIANTS[@]}"; do
build_variant "$variant"
done
cd "$DIR" cd "$DIR"
if is_linux && [[ "$(uname -m)" == "x86_64" && " ${VARIANTS[*]} " == *" offscreen "* ]]; then # Install lib + headers
rm -rf "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"/{lib,include}
cp raylib-src/src/libraylib.a "$INSTALL_DIR/lib/"
cp raylib-src/src/raylib.h raylib-src/src/raymath.h raylib-src/src/rlgl.h "$INSTALL_DIR/include/"
# On x86_64 Linux, also build the offscreen variant for CI headless rendering
if [[ "$(uname)" == "Linux" && "$(uname -m)" == "x86_64" && "$RAYLIB_PLATFORM" != "PLATFORM_OFFSCREEN" ]]; then
echo "Building offscreen variant..."
# Install EGL/GL dev packages needed for offscreen build + bundling
if command -v dnf &>/dev/null; then
dnf install -y mesa-libEGL-devel mesa-libGL-devel libglvnd-opengl libglvnd-core-devel 2>/dev/null || true
elif command -v apt-get &>/dev/null; then
if [ "$(id -u)" -eq 0 ]; then
apt-get update && apt-get install -y libegl-dev libgl-dev
else
sudo apt-get update && sudo apt-get install -y libegl-dev libgl-dev
fi
fi
cd raylib-src/src
make clean
make -j"$NJOBS" PLATFORM=PLATFORM_OFFSCREEN CC="${CC:-gcc}"
cp libraylib.a "$INSTALL_DIR/lib/libraylib_offscreen.a"
cd "$DIR"
# Bundle GLVND dispatchers so offscreen rendering works without extra system packages
MESA_DIR="$INSTALL_DIR/lib/mesa" MESA_DIR="$INSTALL_DIR/lib/mesa"
mkdir -p "$MESA_DIR" mkdir -p "$MESA_DIR"
ldconfig 2>/dev/null || true ldconfig 2>/dev/null || true
@@ -130,21 +105,17 @@ if is_linux && [[ "$(uname -m)" == "x86_64" && " ${VARIANTS[*]} " == *" offscree
src="$(ldconfig -p 2>/dev/null | grep "$lib" | grep -E 'x86.64|libc6,' | awk '{print $NF}' | head -1)" src="$(ldconfig -p 2>/dev/null | grep "$lib" | grep -E 'x86.64|libc6,' | awk '{print $NF}' | head -1)"
if [ -n "$src" ] && [ -f "$src" ]; then if [ -n "$src" ] && [ -f "$src" ]; then
cp -L "$src" "$MESA_DIR/" cp -L "$src" "$MESA_DIR/"
# Create unversioned symlink for the linker
base="${lib%%.so.*}" base="${lib%%.so.*}"
ln -sf "$lib" "$MESA_DIR/${base}.so" ln -sf "$lib" "$MESA_DIR/${base}.so"
fi fi
done done
fi fi
# Download raygui header
RAYGUI_COMMIT="76b36b597edb70ffaf96f046076adc20d67e7827"
curl -fsSLo "$INSTALL_DIR/include/raygui.h" \ curl -fsSLo "$INSTALL_DIR/include/raygui.h" \
"https://raw.githubusercontent.com/raysan5/raygui/$RAYGUI_COMMIT/src/raygui.h" "https://raw.githubusercontent.com/raysan5/raygui/$RAYGUI_COMMIT/src/raygui.h"
cat > "$INSTALL_DIR/build-info.txt" <<EOF
raylib_commit=$RAYLIB_COMMIT
raylib_offscreen_commit=$RAYLIB_OFFSCREEN_COMMIT
raygui_commit=$RAYGUI_COMMIT
variants=${VARIANTS[*]}
EOF
echo "Installed raylib to $INSTALL_DIR" echo "Installed raylib to $INSTALL_DIR"
du -sh "$INSTALL_DIR" du -sh "$INSTALL_DIR"

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "raylib" name = "raylib"
version = "5.5.0.10" version = "5.5.0.8"
description = "raylib + pyray Python bindings (commaai fork)" description = "raylib + pyray Python bindings (commaai fork)"
requires-python = ">=3.8" requires-python = ">=3.8"
dependencies = ["cffi>=1.17.1"] dependencies = ["cffi>=1.17.1"]

View File

@@ -1,111 +1,71 @@
import importlib
import os import os
import platform as _platform import platform as _platform
DIR = os.path.join(os.path.dirname(__file__), "install") DIR = os.path.join(os.path.dirname(__file__), "install")
LIB_ROOT = os.path.join(DIR, "lib") LIB_DIR = os.path.join(DIR, "lib")
INCLUDE_DIR = os.path.join(DIR, "include") INCLUDE_DIR = os.path.join(DIR, "include")
BUILD_INFO = os.path.join(DIR, "build-info.txt")
_PLATFORM_BY_VARIANT = {
"comma": "PLATFORM_COMMA",
"desktop": "PLATFORM_DESKTOP",
"offscreen": "PLATFORM_OFFSCREEN",
}
def _normalize_variant(value): def _detect_platform():
if value in ("PLATFORM_COMMA", "comma"): """Auto-detect the raylib platform. In CI on Linux x86_64, use offscreen EGL rendering."""
return "comma" explicit = os.environ.get("RAYLIB_PLATFORM", "")
if value in ("PLATFORM_OFFSCREEN", "offscreen"):
return "offscreen"
if value in ("PLATFORM_DESKTOP", "desktop", ""):
return "desktop"
return value
def _detect_variant():
explicit = os.environ.get("RAYLIB_VARIANT") or os.environ.get("RAYLIB_PLATFORM", "")
if explicit: if explicit:
return _normalize_variant(explicit) return explicit
if os.path.isfile("/TICI") or os.path.isfile("/AGNOS"):
return "comma"
if os.environ.get("CI") and _platform.system() == "Linux" and _platform.machine() == "x86_64": if os.environ.get("CI") and _platform.system() == "Linux" and _platform.machine() == "x86_64":
return "offscreen" return "PLATFORM_OFFSCREEN"
return "desktop" return ""
VARIANT = _detect_variant()
LIB_DIR = os.path.join(LIB_ROOT, VARIANT)
if not os.path.isfile(os.path.join(LIB_DIR, "libraylib.a")):
LIB_DIR = LIB_ROOT
def _module_name(variant):
return f"_raylib_cffi_{variant}" if variant else "_raylib_cffi"
def _import_cffi_module():
names = [_module_name(VARIANT), "_raylib_cffi"]
seen = set()
for name in names:
if name in seen:
continue
seen.add(name)
try:
return importlib.import_module(f".{name}", __name__)
except ImportError:
continue
return None
def smoketest(): def smoketest():
assert os.path.isfile(os.path.join(LIB_DIR, "libraylib.a")), "libraylib.a not found" assert os.path.isfile(os.path.join(LIB_DIR, "libraylib.a")), "libraylib.a not found"
assert os.path.isfile(os.path.join(INCLUDE_DIR, "raylib.h")), "raylib.h not found" assert os.path.isfile(os.path.join(INCLUDE_DIR, "raylib.h")), "raylib.h not found"
assert os.path.isfile(os.path.join(INCLUDE_DIR, "raymath.h")), "raymath.h not found"
assert os.path.isfile(os.path.join(INCLUDE_DIR, "rlgl.h")), "rlgl.h not found"
assert os.path.isfile(os.path.join(INCLUDE_DIR, "raygui.h")), "raygui.h not found"
assert os.path.isfile(BUILD_INFO), "build-info.txt not found"
try:
import _cffi_backend # noqa: F401
except ModuleNotFoundError:
return
assert _import_cffi_module() is not None, f"CFFI module for {VARIANT} not found"
import pyray # noqa: F401
# Build CFFI extension on first import if not already compiled,
# or rebuild if the target platform changed since last build.
def _ensure_cffi_built(): def _ensure_cffi_built():
import glob
import subprocess import subprocess
import sys import sys
pkg_dir = os.path.dirname(__file__)
platform_marker = os.path.join(pkg_dir, ".raylib_platform")
requested = _detect_platform()
if _import_cffi_module() is not None: # Export so build.py picks it up
return if requested:
os.environ["RAYLIB_PLATFORM"] = requested
# Mesa llvmpipe for software rendering in headless CI
if requested == "PLATFORM_OFFSCREEN":
os.environ.setdefault("LIBGL_ALWAYS_SOFTWARE", "1")
build_script = os.path.join(os.path.dirname(__file__), "build.py") cffi_files = glob.glob(os.path.join(pkg_dir, "_raylib_cffi*"))
if not os.path.isfile(build_script) or not os.path.isfile(os.path.join(LIB_DIR, "libraylib.a")):
return
env = os.environ.copy() # Rebuild if platform changed
env["RAYLIB_VARIANT"] = VARIANT if cffi_files and requested:
env["RAYLIB_PLATFORM"] = _PLATFORM_BY_VARIANT.get(VARIANT, "PLATFORM_DESKTOP") built_for = open(platform_marker).read().strip() if os.path.isfile(platform_marker) else ""
env["RAYLIB_CFFI_MODULE"] = f"raylib.{_module_name(VARIANT)}" if built_for != requested:
for f in cffi_files:
try: os.remove(f)
subprocess.check_call([sys.executable, build_script], cwd=os.path.dirname(os.path.dirname(__file__)), env=env) cffi_files = []
except subprocess.CalledProcessError:
pass
if not cffi_files:
build_script = os.path.join(pkg_dir, "build.py")
if os.path.isfile(build_script) and os.path.isfile(os.path.join(LIB_DIR, "libraylib.a")):
try:
subprocess.check_call([sys.executable, build_script], cwd=os.path.dirname(pkg_dir))
with open(platform_marker, "w") as f:
f.write(requested)
except subprocess.CalledProcessError:
pass
_ensure_cffi_built() _ensure_cffi_built()
_cffi_module = _import_cffi_module() # CFFI bindings (available when graphics libraries are present)
if _cffi_module is not None: try:
ffi = _cffi_module.ffi from ._raylib_cffi import ffi, lib as rl
rl = _cffi_module.lib from raylib._raylib_cffi.lib import * # noqa: F403
for _name in dir(rl):
if not _name.startswith("_"):
globals()[_name] = getattr(rl, _name)
from raylib.colors import * # noqa: F403 from raylib.colors import * # noqa: F403
from raylib.defines import * # noqa: F403 from raylib.defines import * # noqa: F403
from .version import __version__ from .version import __version__
except (ImportError, OSError):
pass

View File

@@ -11,11 +11,8 @@ from cffi import FFI
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
RAYLIB_INCLUDE_PATH = os.path.join(HERE, "install", "include") RAYLIB_INCLUDE_PATH = os.path.join(HERE, "install", "include")
RAYLIB_VARIANT = os.getenv("RAYLIB_VARIANT", "") RAYLIB_LIB_PATH = os.path.join(HERE, "install", "lib")
RAYLIB_LIB_ROOT = os.path.join(HERE, "install", "lib")
RAYLIB_LIB_PATH = os.path.join(RAYLIB_LIB_ROOT, RAYLIB_VARIANT) if RAYLIB_VARIANT else RAYLIB_LIB_ROOT
RAYLIB_PLATFORM = os.getenv("RAYLIB_PLATFORM", "") RAYLIB_PLATFORM = os.getenv("RAYLIB_PLATFORM", "")
RAYLIB_CFFI_MODULE = os.getenv("RAYLIB_CFFI_MODULE", "raylib._raylib_cffi")
ffibuilder = FFI() ffibuilder = FFI()
@@ -108,9 +105,13 @@ def build_ffi():
extra_link_args.remove('-lGL') extra_link_args.remove('-lGL')
extra_link_args += ['-lGLESv2', '-lEGL', '-lgbm', '-ldrm'] extra_link_args += ['-lGLESv2', '-lEGL', '-lgbm', '-ldrm']
elif RAYLIB_PLATFORM == "PLATFORM_OFFSCREEN": elif RAYLIB_PLATFORM == "PLATFORM_OFFSCREEN":
# Use offscreen variant if available, otherwise fall back to default
offscreen_lib = os.path.join(RAYLIB_LIB_PATH, 'libraylib_offscreen.a')
if os.path.isfile(offscreen_lib):
extra_link_args[extra_link_args.index('-lraylib')] = '-lraylib_offscreen'
extra_link_args.remove('-lGL') extra_link_args.remove('-lGL')
# Use bundled GLVND dispatchers if available, with RPATH for runtime # Use bundled GLVND dispatchers if available, with RPATH for runtime
mesa_dir = os.path.join(RAYLIB_LIB_ROOT, 'mesa') mesa_dir = os.path.join(RAYLIB_LIB_PATH, 'mesa')
if os.path.isdir(mesa_dir): if os.path.isdir(mesa_dir):
extra_link_args += [f'-L{mesa_dir}', f'-Wl,-rpath,$ORIGIN/install/lib/mesa'] extra_link_args += [f'-L{mesa_dir}', f'-Wl,-rpath,$ORIGIN/install/lib/mesa']
extra_link_args += ['-lOpenGL', '-lEGL'] extra_link_args += ['-lOpenGL', '-lEGL']
@@ -120,7 +121,7 @@ def build_ffi():
libraries = [] libraries = []
print("extra_link_args: " + str(extra_link_args)) print("extra_link_args: " + str(extra_link_args))
ffibuilder.set_source(RAYLIB_CFFI_MODULE, ffibuilder.set_source("raylib._raylib_cffi",
ffi_includes, ffi_includes,
py_limited_api=True, py_limited_api=True,
include_dirs=[RAYLIB_INCLUDE_PATH], include_dirs=[RAYLIB_INCLUDE_PATH],

View File

@@ -1 +1 @@
__version__ = "5.5.0.10" __version__ = "5.5.0.8"

View File

@@ -18,37 +18,15 @@ class BuildRaylib(build_py):
def run(self): def run(self):
pkg_dir = os.path.dirname(os.path.abspath(__file__)) pkg_dir = os.path.dirname(os.path.abspath(__file__))
raylib_pkg_dir = os.path.join(pkg_dir, "raylib")
build_script = os.path.join(pkg_dir, "build.sh") build_script = os.path.join(pkg_dir, "build.sh")
subprocess.check_call(["bash", build_script], cwd=pkg_dir) subprocess.check_call(["bash", build_script], cwd=pkg_dir)
# Build CFFI extensions so they are included in the wheel, and make sure stale # Build CFFI extension so it's included in the wheel
# local extensions from another raylib platform do not leak into the wheel. cffi_so = glob.glob(os.path.join(pkg_dir, "raylib", "_raylib_cffi*"))
for path in glob.glob(os.path.join(raylib_pkg_dir, "_raylib_cffi*")): if not cffi_so:
os.remove(path) build_cffi = os.path.join(pkg_dir, "raylib", "build.py")
if os.path.isfile(build_cffi):
build_cffi = os.path.join(raylib_pkg_dir, "build.py") subprocess.check_call([sys.executable, build_cffi], cwd=pkg_dir)
if os.path.isfile(build_cffi):
variants = [
os.path.basename(path)
for path in sorted(glob.glob(os.path.join(raylib_pkg_dir, "install", "lib", "*")))
if os.path.isfile(os.path.join(path, "libraylib.a"))
]
if not variants:
variants = [""]
platform_by_variant = {
"comma": "PLATFORM_COMMA",
"desktop": "PLATFORM_DESKTOP",
"offscreen": "PLATFORM_OFFSCREEN",
"": os.environ.get("RAYLIB_PLATFORM", ""),
}
for variant in variants:
env = os.environ.copy()
env["RAYLIB_VARIANT"] = variant
env["RAYLIB_PLATFORM"] = platform_by_variant.get(variant, "PLATFORM_DESKTOP")
env["RAYLIB_CFFI_MODULE"] = f"raylib._raylib_cffi_{variant}" if variant else "raylib._raylib_cffi"
subprocess.check_call([sys.executable, build_cffi], cwd=pkg_dir, env=env)
super().run() super().run()

View File

@@ -65,18 +65,37 @@ for toml in sorted(pathlib.Path(".").glob("*/pyproject.toml")):
for py_file in src_mod.glob("*.py"): for py_file in src_mod.glob("*.py"):
shutil.copy2(py_file, mod_dir / py_file.name) shutil.copy2(py_file, mod_dir / py_file.name)
# copy extra packages (e.g. pyray for raylib) # copy extra packages (e.g. pyray for raylib, slim casadi for acados)
pkgs_val = data.get("tool", {}).get("setuptools", {}).get("packages", {}) # binaries (.so/.dylib) are fetched from the wheel at install time, so the
include_patterns = pkgs_val.get("find", {}).get("include", []) if isinstance(pkgs_val, dict) else [] # shim repo only carries Python sources to keep it small
def _ignore_binaries(_dir, names):
return [n for n in names if n.endswith((".so", ".dylib", ".a")) or ".so." in n or n == "__pycache__"]
# `packages` is either a flat list (["acados", "casadi"]) or a dict with
# `find.include` patterns (["acados*", "casadi*"]) — handle both
pkgs_val = data.get("tool", {}).get("setuptools", {}).get("packages", [])
if isinstance(pkgs_val, list):
include_patterns = list(pkgs_val)
elif isinstance(pkgs_val, dict):
include_patterns = pkgs_val.get("find", {}).get("include", [])
else:
include_patterns = []
extra_packages = [] extra_packages = []
for pattern in include_patterns: for pattern in include_patterns:
p = pattern.rstrip("*") p = pattern.rstrip("*")
if p and p != module and p != f"{module}/": if p and p != module and p != f"{module}/":
src_extra = pathlib.Path(pkg) / p src_extra = pathlib.Path(pkg) / p
dst_extra = pkg_dir / p
if src_extra.is_dir(): if src_extra.is_dir():
dst_extra = pkg_dir / p shutil.copytree(src_extra, dst_extra, dirs_exist_ok=True, ignore=_ignore_binaries)
shutil.copytree(src_extra, dst_extra, dirs_exist_ok=True) else:
extra_packages.append(pattern) # source not in the workspace (typically built lazily by build.sh and
# not cached into the publish job). Drop a placeholder __init__.py so
# setuptools.packages.find picks it up; the shim's setup.py overwrites
# it from the wheel at install time.
dst_extra.mkdir(parents=True, exist_ok=True)
(dst_extra / "__init__.py").write_text("")
extra_packages.append(pattern)
shutil.copy2(shim_setup, pkg_dir / "setup.py") shutil.copy2(shim_setup, pkg_dir / "setup.py")
@@ -102,14 +121,28 @@ for toml in sorted(pathlib.Path(".").glob("*/pyproject.toml")):
for name, target in scripts.items(): for name, target in scripts.items():
lines.append(f'"{name}" = {json.dumps(target)}') lines.append(f'"{name}" = {json.dumps(target)}')
find_include = [f"{module}*"] + extra_packages # propagate the workspace's package-data so the shim wheel knows to ship
# everything the upstream wheel ships (e.g. acados_template/, casadi/*.so).
workspace_pkgdata = data.get("tool", {}).get("setuptools", {}).get("package-data", {})
# extra_packages may be plain names (["casadi"]) or globs (["casadi*"]).
# Normalise to plain package names for both `packages` and `package-data`.
extra_pkg_names = [p.rstrip("*").rstrip("/") for p in extra_packages]
lines += [ lines += [
"", "",
"[tool.setuptools.packages.find]", "[tool.setuptools]",
f"include = {json.dumps(find_include)}", f"packages = {json.dumps([module] + extra_pkg_names)}",
"", "",
"[tool.setuptools.package-data]", "[tool.setuptools.package-data]",
f'{module} = ["{datadir}/**/*", "*.so"]', ]
module_data = sorted(set(workspace_pkgdata.get(module, [f"{datadir}/**/*"]) + ["*.so"]))
lines.append(f"{module} = {json.dumps(module_data)}")
for name in extra_pkg_names:
extra_data = workspace_pkgdata.get(name, ["**/*"])
lines.append(f"{name} = {json.dumps(list(extra_data))}")
lines += [
"", "",
"[tool.shim]", "[tool.shim]",
f'repo_url = "{repo_url}"', f'repo_url = "{repo_url}"',

133
xvfb/build.sh Executable file
View File

@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -e
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)"
cd "$DIR"
INSTALL_DIR="$DIR/xvfb/install"
# macOS: Xvfb is Linux-only. Ship an empty install dir so the wheel still
# builds; smoketest() is a no-op on Darwin.
if [[ "$OSTYPE" == "darwin"* ]]; then
rm -rf "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"/{bin,lib,share/X11/xkb}
echo "xvfb: macOS not supported, shipping empty install dir"
exit 0
fi
# Linux: bundle the Xvfb binary, xkbcomp, xkb keymap data, and the closure
# of shared libraries it needs (minus libc/libpthread/etc which the host
# always provides). For CI/manylinux we pull from AlmaLinux 8 so the wheel
# works on any glibc >= 2.28 distro; on a Debian/Ubuntu dev host we accept
# whatever the system has (the local wheel just won't be as portable).
if command -v dnf >/dev/null 2>&1; then
dnf install -y -q xorg-x11-server-Xvfb xorg-x11-xkb-utils xkeyboard-config >/dev/null
elif command -v apt-get >/dev/null 2>&1; then
if [[ "$(id -u)" -eq 0 ]]; then SUDO=""
elif command -v sudo >/dev/null 2>&1; then SUDO=sudo
else echo "xvfb: need sudo or root to apt-get install" >&2; exit 1; fi
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends \
xvfb x11-xkb-utils xkb-data patchelf
else
echo "xvfb: need dnf or apt-get to fetch upstream Xvfb" >&2
exit 1
fi
if ! command -v patchelf >/dev/null 2>&1; then
echo "xvfb: patchelf is required but not found" >&2
exit 1
fi
rm -rf "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"/{bin,lib,share/X11/xkb}
# Xvfb may live in /usr/bin (RPM/Debian) — just locate it.
XVFB_SRC="$(command -v Xvfb || true)"
XKBCOMP_SRC="$(command -v xkbcomp || true)"
if [[ -z "$XVFB_SRC" || -z "$XKBCOMP_SRC" ]]; then
echo "xvfb: Xvfb or xkbcomp not found after install" >&2
exit 1
fi
cp "$XVFB_SRC" "$INSTALL_DIR/bin/Xvfb"
cp "$XKBCOMP_SRC" "$INSTALL_DIR/bin/xkbcomp"
chmod u+w "$INSTALL_DIR/bin/Xvfb" "$INSTALL_DIR/bin/xkbcomp"
cp -a /usr/share/X11/xkb/. "$INSTALL_DIR/share/X11/xkb/"
# Two binary patches to make Xvfb relocatable:
#
# 1. "/usr/bin" -> "" (null bytes)
# Xvfb's compile-time XkbBinDirectory points at /usr/bin and is used to
# build an absolute path to xkbcomp. Blanking it makes the spawned
# command just "xkbcomp", which popen() then resolves via PATH. Our
# Python wrapper prepends the bundled bin/ to PATH.
#
# 2. "-R%s" -> "-I%s" (in the xkbcomp argv format string)
# Xvfb passes its XkbBaseDirectory to xkbcomp via -R, expecting xkbcomp
# to chdir there *and* add "." to the include path. xkbcomp 1.4.x only
# chdirs — the include-path side was added later. Switching to -I makes
# 1.4.2 actually search the path we hand it via -xkbdir.
python3 - "$INSTALL_DIR/bin/Xvfb" <<'PY'
import sys
path = sys.argv[1]
with open(path, "r+b") as f:
data = bytearray(f.read())
def replace_unique(needle: bytes, replacement: bytes):
assert len(needle) == len(replacement)
idx = data.find(needle)
if idx < 0:
sys.exit(f"could not find {needle!r} in Xvfb binary")
if data.find(needle, idx + 1) >= 0:
sys.exit(f"multiple {needle!r} matches; refusing to patch")
data[idx:idx + len(needle)] = replacement
replace_unique(b"/usr/bin\x00", b"\x00" * 9)
replace_unique(b'"-R%s"\x00', b'"-I%s"\x00')
with open(path, "wb") as f:
f.write(data)
PY
# bundle the shared library closure. Recursively walk ldd output to catch
# libs-of-libs (e.g. libXfont2 -> libfontenc -> libbz2). Skip core glibc
# pieces; everything else gets copied alongside the binary.
declare -A SEEN
collect_libs() {
local target="$1"
while IFS= read -r line; do
local lib
lib=$(echo "$line" | awk '{print $3}')
[[ -z "$lib" || "$lib" == "not" ]] && continue
[[ ! -e "$lib" ]] && continue
local base
base=$(basename "$lib")
case "$base" in
libc.so.*|libpthread.so.*|libm.so.*|librt.so.*|libdl.so.*|libgcc_s.so.*|libresolv.so.*|libutil.so.*|ld-linux-*.so.*|linux-vdso.so.*|linux-gate.so.*)
continue ;;
esac
[[ -n "${SEEN[$base]:-}" ]] && continue
SEEN[$base]=1
cp -L "$lib" "$INSTALL_DIR/lib/$base"
chmod u+w "$INSTALL_DIR/lib/$base"
collect_libs "$INSTALL_DIR/lib/$base"
done < <(ldd "$target" 2>/dev/null || true)
}
collect_libs "$INSTALL_DIR/bin/Xvfb"
collect_libs "$INSTALL_DIR/bin/xkbcomp"
# point the binaries (and the bundled libs) at our private lib dir so they
# don't accidentally resolve against an incompatible host copy.
patchelf --set-rpath '$ORIGIN/../lib' "$INSTALL_DIR/bin/Xvfb"
patchelf --set-rpath '$ORIGIN/../lib' "$INSTALL_DIR/bin/xkbcomp"
for so in "$INSTALL_DIR"/lib/*.so*; do
patchelf --set-rpath '$ORIGIN' "$so" 2>/dev/null || true
done
strip --strip-unneeded "$INSTALL_DIR/bin/Xvfb" "$INSTALL_DIR/bin/xkbcomp" 2>/dev/null || true
find "$INSTALL_DIR/lib" -name '*.so*' -exec strip --strip-unneeded {} + 2>/dev/null || true
echo "Installed xvfb to $INSTALL_DIR"
du -sh "$INSTALL_DIR"

18
xvfb/pyproject.toml Normal file
View File

@@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools>=64", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "xvfb"
version = "1.20.11"
description = "Xvfb (X virtual framebuffer) headless X server"
requires-python = ">=3.8"
[project.scripts]
Xvfb = "xvfb:_run_xvfb"
[tool.setuptools.packages.find]
include = ["xvfb*"]
[tool.setuptools.package-data]
xvfb = ["install/**/*"]

58
xvfb/setup.py Normal file
View File

@@ -0,0 +1,58 @@
import os
import platform
import subprocess
from setuptools.command.build_py import build_py
try:
from wheel.bdist_wheel import bdist_wheel
except ImportError:
bdist_wheel = None
class BuildXvfb(build_py):
"""Run build.sh to fetch and bundle Xvfb before collecting package data."""
def run(self):
pkg_dir = os.path.dirname(os.path.abspath(__file__))
build_script = os.path.join(pkg_dir, "build.sh")
subprocess.check_call(["bash", build_script], cwd=pkg_dir)
super().run()
cmdclass = {"build_py": BuildXvfb}
if bdist_wheel is not None:
class PlatformWheel(bdist_wheel):
"""Produce a platform-specific, Python-version-agnostic wheel."""
def finalize_options(self):
super().finalize_options()
self.root_is_pure = False
def get_tag(self):
system = platform.system()
machine = platform.machine()
if system == "Linux":
plat = f"linux_{machine}"
elif system == "Darwin":
plat = "macosx_11_0_arm64"
else:
plat = f"{system.lower()}_{machine}"
return "py3", "none", plat
cmdclass["bdist_wheel"] = PlatformWheel
def setup():
from setuptools import setup as _setup
_setup(cmdclass=cmdclass)
if __name__ == "__main__":
setup()

39
xvfb/xvfb/__init__.py Normal file
View File

@@ -0,0 +1,39 @@
import os
import sys
DIR = os.path.join(os.path.dirname(__file__), "install")
BIN_DIR = os.path.join(DIR, "bin")
LIB_DIR = os.path.join(DIR, "lib")
XKB_DIR = os.path.join(DIR, "share", "X11", "xkb")
XVFB_BIN = os.path.join(BIN_DIR, "Xvfb")
XKBCOMP_BIN = os.path.join(BIN_DIR, "xkbcomp")
def _run_xvfb():
# The bundled Xvfb has its compile-time XkbBinDirectory blanked out so it
# invokes xkbcomp via PATH lookup; prepend our bin dir so the bundled
# xkbcomp wins. -xkbdir points the server at the bundled keymap data.
env = os.environ.copy()
env["PATH"] = BIN_DIR + os.pathsep + env.get("PATH", "")
args = sys.argv[1:]
if not any(a == "-xkbdir" for a in args):
args = ["-xkbdir", XKB_DIR] + args
os.execvpe(XVFB_BIN, [XVFB_BIN] + args, env)
def smoketest():
if sys.platform == "darwin":
return
assert os.path.isfile(XVFB_BIN), f"Xvfb not found at {XVFB_BIN}"
assert os.path.isfile(XKBCOMP_BIN), f"xkbcomp not found at {XKBCOMP_BIN}"
assert os.path.isdir(XKB_DIR), f"xkb data not found at {XKB_DIR}"
import subprocess
# Xvfb prints usage to stderr and exits non-zero on `-help`; the banner
# mentions Xvfb-specific flags like -screen and -fbdir. If those appear,
# the binary loaded its bundled libs and ran far enough to print help.
result = subprocess.run([XVFB_BIN, "-help"], capture_output=True, text=True)
output = result.stderr + result.stdout
assert "-screen scrn WxHxD" in output, \
f"Xvfb -help did not produce expected output: {output}"