diff --git a/scripts/loopx b/scripts/loopx index b5090097c..faeb5b36f 100755 --- a/scripts/loopx +++ b/scripts/loopx @@ -63,17 +63,33 @@ if [[ "$loopx_command" == "quota" \ exec node --no-warnings --experimental-strip-types "$native_scheduler_entry" "$@" fi -python_bin="${LOOPX_PYTHON:-}" -if [[ -z "$python_bin" && -f "$repo_root/.loopx-python" ]]; then - IFS= read -r python_bin <"$repo_root/.loopx-python" -fi -if [[ -z "$python_bin" ]]; then - python_bin="python3" -fi -if ! command -v "$python_bin" >/dev/null 2>&1; then - echo "loopx runtime error: configured Python executable not found: $python_bin" >&2 - echo "Set LOOPX_PYTHON to Python 3.11+ and reinstall LoopX." >&2 - exit 2 +python_selector="$repo_root/scripts/loopx-python.sh" +if [[ -n "${LOOPX_PYTHON:-}" ]]; then + python_bin="$LOOPX_PYTHON" + if ! command -v "$python_bin" >/dev/null 2>&1; then + echo "loopx runtime error: configured Python executable not found: $python_bin" >&2 + echo "Set LOOPX_PYTHON to Python 3.11+ and reinstall LoopX." >&2 + exit 2 + fi +elif [[ -f "$python_selector" ]]; then + if ! python_bin="$(bash "$python_selector")"; then + echo "Set LOOPX_PYTHON to Python 3.11+ and reinstall LoopX." >&2 + exit 2 + fi +else + # Keep old release snapshots runnable if the companion selector is absent. + python_bin="${LOOPX_PYTHON:-}" + if [[ -z "$python_bin" && -f "$repo_root/.loopx-python" ]]; then + IFS= read -r python_bin <"$repo_root/.loopx-python" + fi + if [[ -z "$python_bin" ]]; then + python_bin="python3" + fi + if ! command -v "$python_bin" >/dev/null 2>&1; then + echo "loopx runtime error: configured Python executable not found: $python_bin" >&2 + echo "Set LOOPX_PYTHON to Python 3.11+ and reinstall LoopX." >&2 + exit 2 + fi fi if [[ "$loopx_command" == "canary" && "$loopx_subcommand" == "premerge" ]]; then diff --git a/tests/test_loopx_launcher_python_selection.py b/tests/test_loopx_launcher_python_selection.py new file mode 100644 index 000000000..4800747bd --- /dev/null +++ b/tests/test_loopx_launcher_python_selection.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _write_stub(path: Path, body: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"#!/usr/bin/env bash\n{body}\n", encoding="utf-8") + path.chmod(0o755) + + +def test_source_launcher_uses_project_venv_without_path_activation(tmp_path: Path) -> None: + release_root = tmp_path / "release" + scripts = release_root / "scripts" + scripts.mkdir(parents=True) + package = release_root / "loopx" + package.mkdir() + (package / "cli.py").write_text("print('launcher selected the project interpreter')\n", encoding="utf-8") + shutil.copy2(REPO_ROOT / "scripts/loopx", scripts / "loopx") + shutil.copy2(REPO_ROOT / "scripts/loopx-python.sh", scripts / "loopx-python.sh") + + selected = tmp_path / "selected-python.log" + _write_stub( + release_root / ".venv/bin/python", + "printf selected > " + f"{shlex.quote(str(selected))}\n" + f"exec {shlex.quote(sys.executable)} \"$@\"", + ) + + completed = subprocess.run( + ["bash", str(scripts / "loopx"), "version"], + cwd=release_root, + env={ + **{key: value for key, value in os.environ.items() if key != "LOOPX_PYTHON"}, + "PATH": "/usr/bin:/bin", + }, + text=True, + capture_output=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + assert selected.read_text(encoding="utf-8") == "selected" + assert "Python 3.11+ is required" not in completed.stderr + + +def test_source_launcher_preserves_explicit_missing_python_diagnostic( + tmp_path: Path, +) -> None: + release_root = tmp_path / "release" + scripts = release_root / "scripts" + scripts.mkdir(parents=True) + (release_root / "loopx").mkdir() + shutil.copy2(REPO_ROOT / "scripts/loopx", scripts / "loopx") + shutil.copy2(REPO_ROOT / "scripts/loopx-python.sh", scripts / "loopx-python.sh") + + missing = tmp_path / "missing-python" + completed = subprocess.run( + ["bash", str(scripts / "loopx"), "version"], + cwd=release_root, + env={ + **{key: value for key, value in os.environ.items() if key != "LOOPX_PYTHON"}, + "LOOPX_PYTHON": str(missing), + "PATH": "/usr/bin:/bin", + }, + text=True, + capture_output=True, + check=False, + ) + + assert completed.returncode == 2 + assert "configured Python executable not found" in completed.stderr + assert str(missing) in completed.stderr + + +def test_source_launcher_preserves_explicit_old_python_diagnostic( + tmp_path: Path, +) -> None: + release_root = tmp_path / "release" + scripts = release_root / "scripts" + scripts.mkdir(parents=True) + (release_root / "loopx").mkdir() + shutil.copy2(REPO_ROOT / "scripts/loopx", scripts / "loopx") + shutil.copy2(REPO_ROOT / "scripts/loopx-python.sh", scripts / "loopx-python.sh") + + legacy = tmp_path / "python-legacy" + _write_stub( + legacy, + "if [[ \"$1\" == \"-c\" ]]; then\n" + " printf '%s\\n' 'loopx runtime error: Python 3.11+ is required; selected Python is 3.9. Set LOOPX_PYTHON to Python 3.11+ and reinstall LoopX.' >&2\n" + "fi\n" + "exit 2", + ) + clean_env = { + key: value for key, value in os.environ.items() if key != "LOOPX_PYTHON" + } + + completed = subprocess.run( + ["bash", str(scripts / "loopx"), "version"], + cwd=release_root, + env={**clean_env, "LOOPX_PYTHON": str(legacy), "PATH": "/usr/bin:/bin"}, + text=True, + capture_output=True, + check=False, + ) + + assert completed.returncode == 2 + assert "selected Python is 3.9" in completed.stderr