Skip to content

Commit df98a4e

Browse files
committed
feat(cli): aligner le preflight headless des tools engine sur la GUI
1 parent 4966202 commit df98a4e

2 files changed

Lines changed: 298 additions & 10 deletions

File tree

OnlyMod/EngineOnlyMod/app.py

Lines changed: 226 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import json
3939
import logging
4040
import subprocess
41+
import platform
4142
from pathlib import Path
4243
from typing import Optional, Dict, Any, List
4344
from datetime import datetime
@@ -629,21 +630,35 @@ def run_compilation(
629630
"duration_ms": 0,
630631
}
631632

632-
# Keep GUI behavior for tool checks when an interactive UI is available.
633-
# In headless mode, some tool-install flows rely on Qt dialogs.
634-
if not self.headless:
633+
# Ensure required tools are available before real compilation.
634+
# In headless mode we use a synchronous preflight suited for CI/CD.
635+
if not (dry_run or self.dry_run):
635636
try:
636-
if not engine.ensure_tools_installed(self.gui):
637+
if self.headless:
638+
tools_ok, tools_error = self._ensure_engine_tools_headless(engine)
639+
else:
640+
tools_ok = bool(engine.ensure_tools_installed(self.gui))
641+
tools_error = (
642+
"Engine required tools are missing or installation failed"
643+
)
644+
if not tools_ok:
637645
return {
638646
"success": False,
639-
"error": "Engine required tools are missing or installation failed",
647+
"error": tools_error,
640648
"return_code": -1,
641649
"stdout": "",
642650
"stderr": "",
643651
"duration_ms": 0,
644652
}
645-
except Exception:
646-
pass
653+
except Exception as exc:
654+
return {
655+
"success": False,
656+
"error": f"Engine required tools preflight failed: {exc}",
657+
"return_code": -1,
658+
"stdout": "",
659+
"stderr": "",
660+
"duration_ms": 0,
661+
}
647662

648663
# Construire la commande
649664
cmd = self.build_command(engine_id, file_path, engine=engine)
@@ -730,6 +745,207 @@ def run_compilation(
730745
"duration_ms": duration_ms,
731746
}
732747

748+
def _ensure_engine_tools_headless(self, engine) -> tuple[bool, str]:
749+
"""Ensure engine required tools in headless mode with blocking checks/install."""
750+
try:
751+
tools = getattr(engine, "required_tools", {"python": [], "system": []})
752+
if not isinstance(tools, dict):
753+
tools = {"python": [], "system": []}
754+
python_tools = [str(t).strip() for t in tools.get("python", []) if str(t).strip()]
755+
system_tools = [str(t).strip() for t in tools.get("system", []) if str(t).strip()]
756+
757+
missing_system = self._missing_system_tools(system_tools)
758+
if missing_system:
759+
auto_install = (
760+
str(os.environ.get("ARK_AUTO_INSTALL_SYSTEM_TOOLS", "0")).strip().lower()
761+
in {"1", "true", "yes", "on"}
762+
)
763+
if auto_install:
764+
try:
765+
from Core.sys_deps import install_system_packages
766+
767+
if not install_system_packages(missing_system, gui=None):
768+
return (
769+
False,
770+
"Missing system tools and automatic installation failed: "
771+
+ ", ".join(missing_system),
772+
)
773+
except Exception as exc:
774+
return (
775+
False,
776+
f"Missing system tools ({', '.join(missing_system)}) and install failed: {exc}",
777+
)
778+
else:
779+
return (
780+
False,
781+
"Missing system tools: "
782+
+ ", ".join(missing_system)
783+
+ ". Set ARK_AUTO_INSTALL_SYSTEM_TOOLS=1 to allow auto-install.",
784+
)
785+
786+
if not python_tools:
787+
return True, ""
788+
789+
manager = getattr(self.gui, "venv_manager", None)
790+
use_system = bool(getattr(self.gui, "use_system_python", False))
791+
venv_path = None
792+
if manager is not None and not use_system:
793+
try:
794+
venv_path = manager.resolve_project_venv()
795+
except Exception:
796+
venv_path = None
797+
if not venv_path:
798+
return False, "No resolved project venv for Python tool installation"
799+
800+
missing_python = self._missing_python_tools(
801+
python_tools,
802+
manager=manager,
803+
use_system=use_system,
804+
venv_path=venv_path,
805+
)
806+
if not missing_python:
807+
return True, ""
808+
809+
ok_install, install_error = self._install_python_tools_headless(
810+
missing_python,
811+
manager=manager,
812+
use_system=use_system,
813+
venv_path=venv_path,
814+
)
815+
if not ok_install:
816+
return False, install_error
817+
818+
still_missing = self._missing_python_tools(
819+
python_tools,
820+
manager=manager,
821+
use_system=use_system,
822+
venv_path=venv_path,
823+
)
824+
if still_missing:
825+
return (
826+
False,
827+
"Python tools still missing after installation: "
828+
+ ", ".join(still_missing),
829+
)
830+
return True, ""
831+
except Exception as exc:
832+
return False, f"Headless tools preflight failed: {exc}"
833+
834+
def _missing_system_tools(self, tools: list[str]) -> list[str]:
835+
"""Return missing system tools by checking PATH availability."""
836+
if not tools:
837+
return []
838+
try:
839+
from Core.sys_deps import check_system_packages
840+
841+
return [tool for tool in tools if not check_system_packages([tool])]
842+
except Exception:
843+
return tools
844+
845+
def _missing_python_tools(
846+
self,
847+
tools: list[str],
848+
*,
849+
manager,
850+
use_system: bool,
851+
venv_path: str | None,
852+
) -> list[str]:
853+
"""Return missing python tools according to current interpreter mode."""
854+
missing: list[str] = []
855+
for tool in tools:
856+
installed = False
857+
try:
858+
if manager is not None:
859+
if use_system:
860+
installed = bool(manager.is_tool_installed_system(tool))
861+
elif venv_path:
862+
installed = bool(manager.is_tool_installed(venv_path, tool))
863+
except Exception:
864+
installed = False
865+
if not installed:
866+
missing.append(tool)
867+
return missing
868+
869+
def _tool_install_candidates(self, tool: str) -> list[str]:
870+
"""Build pip install candidates from a generic tool identifier."""
871+
t = str(tool or "").strip()
872+
if not t:
873+
return []
874+
low = t.lower()
875+
variants = [low]
876+
if "_" in low:
877+
variants.append(low.replace("_", "-"))
878+
variants.append(low.replace("_", ""))
879+
if "-" in low:
880+
variants.append(low.replace("-", "_"))
881+
variants.append(low.replace("-", ""))
882+
883+
unique: list[str] = []
884+
seen: set[str] = set()
885+
for item in variants:
886+
key = item.lower()
887+
if not item or key in seen:
888+
continue
889+
seen.add(key)
890+
unique.append(item)
891+
return unique
892+
893+
def _install_python_tools_headless(
894+
self,
895+
tools: list[str],
896+
*,
897+
manager,
898+
use_system: bool,
899+
venv_path: str | None,
900+
) -> tuple[bool, str]:
901+
"""Install python tools synchronously for CI/CD headless runs."""
902+
if not tools:
903+
return True, ""
904+
905+
python_bin = sys.executable
906+
if manager is not None and not use_system:
907+
if not venv_path:
908+
return False, "No venv path available for Python tool installation"
909+
try:
910+
python_bin = manager.python_path(venv_path)
911+
except Exception:
912+
python_bin = sys.executable
913+
914+
extra_args: list[str] = []
915+
if use_system and platform.system() == "Linux":
916+
extra_args.append("--break-system-packages")
917+
918+
for tool in tools:
919+
installed = False
920+
last_error = ""
921+
for candidate in self._tool_install_candidates(tool):
922+
cmd = [python_bin, "-m", "pip", "install"] + extra_args + [candidate]
923+
try:
924+
proc = subprocess.run(
925+
cmd,
926+
stdout=subprocess.PIPE,
927+
stderr=subprocess.PIPE,
928+
text=True,
929+
timeout=1800,
930+
check=False,
931+
)
932+
except Exception as exc:
933+
last_error = str(exc)
934+
continue
935+
936+
if proc.returncode != 0:
937+
err = (proc.stderr or proc.stdout or "").strip()
938+
last_error = err or f"pip install failed for {candidate}"
939+
continue
940+
941+
installed = True
942+
break
943+
944+
if not installed:
945+
return False, f"Unable to install Python tool '{tool}': {last_error}"
946+
947+
return True, ""
948+
733949
def execute(self) -> Dict[str, Any]:
734950
"""
735951
Exécute l'application avec les paramètres configurés.
@@ -873,13 +1089,13 @@ def main():
8731089
python -m Core.engines_loader.engines_only_mod --list-engines
8741090
8751091
# Check engine compatibility
876-
python -m Core.engines_loader.engines_only_mod --check-compat nuitka
1092+
python -m Core.engines_loader.engines_only_mod --check-compat <engine_id>
8771093
8781094
# Compile a file with a specific engine (dry-run)
879-
python -m Core.engines_loader.engines_only_mod --engine nuitka --dry-run script.py
1095+
python -m Core.engines_loader.engines_only_mod --engine <engine_id> --dry-run script.py
8801096
8811097
# Compile a file with a specific engine
882-
python -m Core.engines_loader.engines_only_mod --engine pyinstaller --file script.py
1098+
python -m Core.engines_loader.engines_only_mod --engine <engine_id> --file script.py
8831099
""",
8841100
)
8851101

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 Ague Samuel Amen
3+
4+
from __future__ import annotations
5+
6+
from pathlib import Path
7+
8+
from OnlyMod.EngineOnlyMod.app import EnginesStandaloneApp
9+
10+
11+
class _DummyEngine:
12+
def __init__(self, required_tools: dict[str, list[str]] | None = None):
13+
self.required_tools = required_tools or {"python": [], "system": []}
14+
15+
16+
def test_headless_tools_preflight_fails_on_missing_system_tools(monkeypatch) -> None:
17+
app = EnginesStandaloneApp(headless=True, workspace_dir=str(Path.cwd()))
18+
engine = _DummyEngine(required_tools={"python": [], "system": ["patchelf"]})
19+
20+
monkeypatch.setattr(app, "_missing_system_tools", lambda tools: ["patchelf"])
21+
22+
ok, error = app._ensure_engine_tools_headless(engine)
23+
24+
assert ok is False
25+
assert "Missing system tools" in error
26+
assert "ARK_AUTO_INSTALL_SYSTEM_TOOLS=1" in error
27+
28+
29+
def test_headless_tools_preflight_installs_missing_python_tools(monkeypatch) -> None:
30+
app = EnginesStandaloneApp(headless=True, workspace_dir=str(Path.cwd()))
31+
engine = _DummyEngine(required_tools={"python": ["pyinstaller"], "system": []})
32+
33+
state = {"calls": 0}
34+
35+
def _missing_python(*_args, **_kwargs):
36+
state["calls"] += 1
37+
return ["pyinstaller"] if state["calls"] == 1 else []
38+
39+
install_called = {"ok": False}
40+
41+
def _install_python(*_args, **_kwargs):
42+
install_called["ok"] = True
43+
return True, ""
44+
45+
monkeypatch.setattr(app, "_missing_system_tools", lambda tools: [])
46+
monkeypatch.setattr(app, "_missing_python_tools", _missing_python)
47+
monkeypatch.setattr(app, "_install_python_tools_headless", _install_python)
48+
49+
ok, error = app._ensure_engine_tools_headless(engine)
50+
51+
assert ok is True
52+
assert error == ""
53+
assert install_called["ok"] is True
54+
assert state["calls"] >= 2
55+
56+
57+
def test_run_compilation_headless_returns_preflight_error(monkeypatch) -> None:
58+
app = EnginesStandaloneApp(headless=True, workspace_dir=str(Path.cwd()))
59+
60+
monkeypatch.setattr(
61+
app,
62+
"check_engine_compatibility",
63+
lambda engine_id: {"compatible": True, "message": None, "missing_requirements": []},
64+
)
65+
monkeypatch.setattr(app, "_create_engine_for_run", lambda engine_id: _DummyEngine())
66+
monkeypatch.setattr(app, "_ensure_engine_tools_headless", lambda engine: (False, "preflight failed"))
67+
68+
result = app.run_compilation("pyinstaller", str(Path.cwd() / "main.py"), dry_run=False)
69+
70+
assert result["success"] is False
71+
assert result["return_code"] == -1
72+
assert "preflight failed" in result["error"]

0 commit comments

Comments
 (0)