_which() probes host dirs only in Flatpak (/run/host/usr/bin) and AppImage (/usr/bin); apply_host_libs prepares env for host subprocesses (prepend host libs in Flatpak, strip $APPDIR pollution + PYTHONHOME in AppImage); user override resolved via _which() for bare names.
277 lines
8.7 KiB
Python
277 lines
8.7 KiB
Python
"""Centralised resolution of external interpreter paths (Python, Lua).
|
|
|
|
The user can override the path through the global dict via the keys
|
|
``python_bin`` and ``lua_bin`` (typically populated from a YAML config).
|
|
When unset, the system PATH is searched for known candidates.
|
|
|
|
Resolution is cached in memory: each interpreter is resolved at most
|
|
once per testium process. Subsequent calls return the cached value.
|
|
|
|
Public API
|
|
----------
|
|
``python_bin()`` : resolved python3 path (or "" if missing)
|
|
``lua_bin()`` : resolved lua >= 5.1 path (or "" if missing)
|
|
``ensure(*names)`` : resolve every name and raise a clear error if
|
|
any is missing — meant for early validation at
|
|
test load time
|
|
``reset()`` : clear the cache (mostly useful for tests)
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
|
|
import api.testium as tm
|
|
from interpreter.utils.paths import sys_app_path_lin, sys_app_path_win
|
|
from runtime.tum_except import ETUMRuntimeError
|
|
|
|
|
|
# ---------- Discovery primitives ---------------------------------------------
|
|
|
|
_PYTHON_CANDIDATES = ["python3", "python"]
|
|
_LUA_CANDIDATES = ["lua", "lua5.5", "lua5.4", "lua5.3", "lua5.2", "lua5.1"]
|
|
|
|
# When running inside a Flatpak, --filesystem=host-os mounts the host at
|
|
# /run/host (read-only). Binaries and libraries from the host are not on the
|
|
# sandbox PATH/LD_LIBRARY_PATH, so we probe and inject them explicitly.
|
|
_FLATPAK_HOST_DIRS = [
|
|
"/run/host/usr/local/bin",
|
|
"/run/host/usr/bin",
|
|
"/run/host/bin",
|
|
]
|
|
_FLATPAK_HOST_LIB_DIRS = [
|
|
"/run/host/usr/lib",
|
|
"/run/host/usr/lib64",
|
|
"/run/host/usr/local/lib",
|
|
]
|
|
|
|
# Inside an AppImage, AppRun prepends $APPDIR/usr/bin to PATH and exports a
|
|
# bundle-local PYTHONHOME / PYTHONPATH / LD_LIBRARY_PATH. We want py_func and
|
|
# lua_func to run under the *host* interpreter (not the bundled one), so we
|
|
# probe standard host bin dirs directly and scrub APPDIR-prefixed entries from
|
|
# the env passed to host subprocesses.
|
|
_APPIMAGE_HOST_DIRS = [
|
|
"/usr/local/bin",
|
|
"/usr/bin",
|
|
"/bin",
|
|
]
|
|
|
|
|
|
def _in_flatpak():
|
|
return os.path.isfile("/.flatpak-info")
|
|
|
|
|
|
def _in_appimage():
|
|
return "APPIMAGE" in os.environ
|
|
|
|
|
|
def apply_host_lua_paths(env):
|
|
"""Prepend host Lua module dirs to LUA_PATH / LUA_CPATH (Flatpak only).
|
|
|
|
Must be called after user-defined lua_env overrides are applied, so host
|
|
paths are always first regardless of user config. User-defined paths remain
|
|
in the variable but after the host ones.
|
|
"""
|
|
if not _in_flatpak():
|
|
return
|
|
_LUA_VERSIONS = ["5.5", "5.4", "5.3", "5.2", "5.1"]
|
|
_HOST = "/run/host/usr"
|
|
cpath_dirs, lpath_dirs = [], []
|
|
for v in _LUA_VERSIONS:
|
|
for base in [f"{_HOST}/lib/lua/{v}",
|
|
f"{_HOST}/lib64/lua/{v}",
|
|
f"{_HOST}/lib/x86_64-linux-gnu/lua/{v}"]:
|
|
cpath_dirs.append(f"{base}/?.so")
|
|
lpath_dirs.append(f"{_HOST}/share/lua/{v}/?.lua")
|
|
lpath_dirs.append(f"{_HOST}/share/lua/{v}/?/init.lua")
|
|
sep = ";"
|
|
host_cpath = sep.join(cpath_dirs)
|
|
host_lpath = sep.join(lpath_dirs)
|
|
# ;; keeps Lua's compiled-in defaults at the end as last resort
|
|
env["LUA_CPATH"] = host_cpath + sep + env.get("LUA_CPATH", ";;")
|
|
env["LUA_PATH"] = host_lpath + sep + env.get("LUA_PATH", ";;")
|
|
|
|
|
|
def apply_host_libs(env):
|
|
"""Prepare *env* for launching a host binary from inside our bundle.
|
|
|
|
- Flatpak: prepend host library dirs to LD_LIBRARY_PATH so the dynamic
|
|
linker can find host .so files mounted under /run/host.
|
|
- AppImage: strip $APPDIR-prefixed entries from LD_LIBRARY_PATH and
|
|
PYTHONPATH and drop PYTHONHOME, so the host interpreter doesn't try
|
|
to load the bundled (incompatible) Python lib/site-packages.
|
|
- Otherwise: no-op.
|
|
"""
|
|
if _in_flatpak():
|
|
dirs = ":".join(d for d in _FLATPAK_HOST_LIB_DIRS if os.path.isdir(d))
|
|
if dirs:
|
|
existing = env.get("LD_LIBRARY_PATH", "")
|
|
env["LD_LIBRARY_PATH"] = dirs + (":" + existing if existing else "")
|
|
return
|
|
if _in_appimage():
|
|
appdir = os.environ.get("APPDIR", "")
|
|
if appdir:
|
|
for var, sep in (("LD_LIBRARY_PATH", ":"),
|
|
("PYTHONPATH", os.pathsep),
|
|
("PATH", os.pathsep)):
|
|
cur = env.get(var, "")
|
|
if not cur:
|
|
continue
|
|
cleaned = sep.join(
|
|
p for p in cur.split(sep)
|
|
if p and not p.startswith(appdir)
|
|
)
|
|
if cleaned:
|
|
env[var] = cleaned
|
|
else:
|
|
env.pop(var, None)
|
|
env.pop("PYTHONHOME", None)
|
|
|
|
|
|
def _which(name):
|
|
if tm.OS() == "Windows":
|
|
return sys_app_path_win(name)
|
|
if _in_flatpak():
|
|
for d in _FLATPAK_HOST_DIRS:
|
|
p = os.path.join(d, name)
|
|
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
return p
|
|
return ""
|
|
if _in_appimage():
|
|
for d in _APPIMAGE_HOST_DIRS:
|
|
p = os.path.join(d, name)
|
|
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
return p
|
|
return ""
|
|
return sys_app_path_lin(name)
|
|
|
|
|
|
def _probe_env():
|
|
"""Subprocess env for probing host binaries (adds host libs in Flatpak)."""
|
|
env = os.environ.copy()
|
|
apply_host_libs(env)
|
|
return env
|
|
|
|
|
|
def _python_version(path):
|
|
cmd = [path, "-c", "import sys; print(sys.version_info[:3])"]
|
|
try:
|
|
r = subprocess.run(
|
|
cmd, capture_output=True, text=True,
|
|
encoding=tm.sys_encoding(), timeout=10, env=_probe_env(),
|
|
)
|
|
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
|
return None
|
|
try:
|
|
return eval(r.stdout)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _is_python3(path):
|
|
v = _python_version(path)
|
|
return v is not None and v[0] == 3
|
|
|
|
|
|
def _lua_version(path):
|
|
try:
|
|
r = subprocess.run(
|
|
[path, "-v"], capture_output=True, text=True, timeout=10,
|
|
env=_probe_env(),
|
|
)
|
|
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
|
return None
|
|
# On Windows the version banner goes to stderr.
|
|
line = r.stdout or r.stderr
|
|
try:
|
|
major, minor, _patch = line.split(" ")[1].split(".")
|
|
return (int(major), int(minor))
|
|
except (IndexError, ValueError):
|
|
return None
|
|
|
|
|
|
def _is_lua51(path):
|
|
v = _lua_version(path)
|
|
return v is not None and v >= (5, 1)
|
|
|
|
|
|
# ---------- Resolver ---------------------------------------------------------
|
|
|
|
# (display name, globdict override key, candidate list, validator)
|
|
_SPECS = {
|
|
"python": ("Python 3", "python_bin", _PYTHON_CANDIDATES, _is_python3),
|
|
"lua": ("Lua 5.1+", "lua_bin", _LUA_CANDIDATES, _is_lua51),
|
|
}
|
|
|
|
_resolved = {}
|
|
|
|
|
|
def _resolve(name):
|
|
if name in _resolved:
|
|
return _resolved[name]
|
|
|
|
display, gd_key, candidates, validator = _SPECS[name]
|
|
override = tm.gd(gd_key, "") or ""
|
|
|
|
path = ""
|
|
if override:
|
|
# Absolute path: accept as-is (user knows exactly what they want).
|
|
# Bare name: resolve via _which() so the override stays host-only in
|
|
# Flatpak/AppImage instead of silently picking the bundled interpreter.
|
|
if os.path.isabs(override):
|
|
resolved = override if (os.path.isfile(override)
|
|
and os.access(override, os.X_OK)) else ""
|
|
else:
|
|
resolved = _which(override)
|
|
if resolved and validator(resolved):
|
|
path = resolved
|
|
else:
|
|
tm.print_warn(
|
|
f"Configured {display} interpreter '{override}' is not usable; "
|
|
f"falling back to discovery."
|
|
)
|
|
|
|
if not path:
|
|
for c in candidates:
|
|
p = _which(c)
|
|
if not p:
|
|
continue
|
|
if validator(p):
|
|
path = p
|
|
break
|
|
|
|
_resolved[name] = path
|
|
return path
|
|
|
|
|
|
def python_bin():
|
|
return _resolve("python")
|
|
|
|
|
|
def lua_bin():
|
|
return _resolve("lua")
|
|
|
|
|
|
def ensure(*names):
|
|
"""Resolve each of the given names; raise if any is missing.
|
|
|
|
Meant to be called at test load with the set of interpreters the
|
|
test tree actually needs, so the user gets a clear error before
|
|
execution starts instead of deep inside an engine spawn.
|
|
"""
|
|
missing = []
|
|
for n in names:
|
|
if not _resolve(n):
|
|
display, gd_key, candidates, _ = _SPECS[n]
|
|
missing.append(
|
|
f" - {display}: tried {candidates} on PATH, none usable. "
|
|
f"Set '{gd_key}' in the YAML config to override."
|
|
)
|
|
if missing:
|
|
raise ETUMRuntimeError(
|
|
"Required external interpreter(s) not found:\n" + "\n".join(missing)
|
|
)
|
|
|
|
|
|
def reset():
|
|
_resolved.clear()
|