"""Export controller -- dispatches export requests to backend targets.
Defines ``ExportTarget``, ``ExportRequest``, ``ExportProgress``, and
``run_export`` which routes a request to the correct backend:
- Desktop wheel/folder -> :class:`simvx.core.export.ProjectExporter`
- Web HTML -> :func:`simvx.web.export.export_web`
- Standalone exe -> PyInstaller subprocess (host OS only)
- Standalone Nuitka -> placeholder, always fails
- Android APK -> ``android/build.sh`` subprocess
Also provides :func:`save_request_to_toml` and
:func:`load_request_from_toml` for round-tripping through ``simvx.toml``.
"""
import logging
import re
import shutil
import subprocess
import sys
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from simvx.core.export import ExportError, ExportMode, ExportPreset, ProjectExporter
from simvx.core.project import ProjectSettings
from simvx.web.export import DEFAULT_PYODIDE_VERSION
log = logging.getLogger(__name__)
__all__ = [
"ExportTarget",
"ExportRequest",
"ExportProgress",
"run_export",
"save_request_to_toml",
"load_request_from_toml",
"detect_host_os",
]
[docs]
class ExportTarget(str, Enum):
DESKTOP_WHEEL = "desktop_wheel"
DESKTOP_FOLDER = "desktop_folder"
WEB_HTML = "web_html"
STANDALONE_EXE = "standalone_exe"
STANDALONE_NUITKA = "standalone_nuitka"
ANDROID_APK = "android_apk"
_PACKAGE_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$")
_ANDROID_PACKAGE_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
[docs]
def detect_host_os() -> str:
"""Return 'linux', 'windows', or 'macos' based on ``sys.platform``."""
p = sys.platform
if p.startswith("win"):
return "windows"
if p == "darwin":
return "macos"
return "linux"
[docs]
@dataclass
class ExportRequest:
"""Target-agnostic export request captured from the dialog form."""
target: ExportTarget
project_dir: Path
output_dir: Path
package_name: str
version: str = "0.1.0"
entry_point: str = "main.py"
# Desktop (wheel/folder)
os_label: str = "linux"
build_wheel: bool = False
create_zip: bool = False
icon_path: str | None = None
# Web
web_width: int = 800
web_height: int = 600
web_responsive: bool = False
pyodide_version: str = DEFAULT_PYODIDE_VERSION
extra_packages: list[str] = field(default_factory=list)
root_class: str | None = None
title: str = "SimVX"
# Standalone exe (PyInstaller)
exe_onefile: bool = True
exe_console: bool = False
exe_name: str = ""
exe_host_os: str = field(default_factory=detect_host_os)
# Android
android_package: str = ""
android_min_sdk: int = 26
android_mode: str = "debug"
[docs]
def validate(self) -> list[str]:
"""Return a list of human-readable error messages. Empty list means OK."""
errs: list[str] = []
if not self.package_name or not self.package_name.strip():
errs.append("Package name is required.")
elif not _PACKAGE_NAME_RE.match(self.package_name):
errs.append(f"Invalid package name: {self.package_name!r}.")
if not self.entry_point:
errs.append("Entry point is required.")
else:
ep = self.project_dir / self.entry_point
if not ep.is_file():
errs.append(f"Entry point does not exist: {ep}")
if not self.version or not re.match(r"^\d+(\.\d+)+", self.version):
errs.append(f"Invalid version: {self.version!r}.")
if self.target == ExportTarget.WEB_HTML:
if self.web_width <= 0 or self.web_height <= 0:
errs.append("Web width/height must be positive.")
if self.target == ExportTarget.ANDROID_APK:
if not self.android_package:
errs.append("Android package is required (e.g. com.example.mygame).")
elif not _ANDROID_PACKAGE_RE.match(self.android_package):
errs.append(f"Invalid Android package: {self.android_package!r}. Must be reverse-DNS (e.g. com.example.mygame).")
if self.android_min_sdk < 21:
errs.append("Android min_sdk must be >= 21.")
return errs
[docs]
@dataclass
class ExportProgress:
fraction: float # 0..1 (-1 = indeterminate)
status: str
finished: bool = False
success: bool = False
result_path: Path | None = None
error: str | None = None
def _emit(progress: Callable[[ExportProgress], None], fraction: float, status: str) -> None:
progress(ExportProgress(fraction=fraction, status=status))
def _fail(progress: Callable[[ExportProgress], None], message: str) -> ExportProgress:
result = ExportProgress(fraction=1.0, status=message, finished=True, success=False, error=message)
progress(result)
return result
def _succeed(progress: Callable[[ExportProgress], None], path: Path, message: str) -> ExportProgress:
result = ExportProgress(
fraction=1.0, status=message, finished=True, success=True, result_path=path
)
progress(result)
return result
[docs]
def run_export(req: ExportRequest, progress: Callable[[ExportProgress], None]) -> ExportProgress:
"""Dispatch an export request. Calls ``progress`` with checkpoints.
Returns the final :class:`ExportProgress` (success or failure).
"""
_emit(progress, 0.0, "Validating...")
errors = req.validate()
if errors:
return _fail(progress, "; ".join(errors))
_emit(progress, 0.25, "Collecting...")
try:
if req.target in (ExportTarget.DESKTOP_WHEEL, ExportTarget.DESKTOP_FOLDER):
return _run_desktop(req, progress)
if req.target == ExportTarget.WEB_HTML:
return _run_web(req, progress)
if req.target == ExportTarget.STANDALONE_EXE:
return _run_exe(req, progress)
if req.target == ExportTarget.STANDALONE_NUITKA:
return _fail(progress, "Nuitka target not yet implemented.")
if req.target == ExportTarget.ANDROID_APK:
return _run_android(req, progress)
return _fail(progress, f"Unknown target: {req.target}")
except ExportError as exc:
return _fail(progress, str(exc))
except Exception as exc:
log.exception("Export failed")
return _fail(progress, f"Unexpected error: {exc}")
def _run_desktop(req: ExportRequest, progress: Callable[[ExportProgress], None]) -> ExportProgress:
mode = ExportMode.WHEEL if req.target == ExportTarget.DESKTOP_WHEEL else ExportMode.FOLDER
preset = ExportPreset(
name=req.package_name,
platform=req.os_label,
entry_point=req.entry_point,
version=req.version,
icon_path=req.icon_path,
build_wheel=req.build_wheel if mode == ExportMode.WHEEL else False,
export_mode=mode,
create_zip=req.create_zip if mode == ExportMode.FOLDER else False,
)
_emit(progress, 0.5, "Building...")
exporter = ProjectExporter()
result = exporter.export(req.project_dir, preset, req.output_dir)
return _succeed(progress, result, f"Exported to {result}")
def _run_web(req: ExportRequest, progress: Callable[[ExportProgress], None]) -> ExportProgress:
from simvx.web.export import export_web
output = req.output_dir / "game.html"
output.parent.mkdir(parents=True, exist_ok=True)
_emit(progress, 0.5, "Generating HTML...")
result = export_web(
game_path=req.project_dir / req.entry_point,
output=output,
width=req.web_width,
height=req.web_height,
title=req.title,
root_class=req.root_class or None,
responsive=req.web_responsive,
pyodide_version=req.pyodide_version,
extra_packages=list(req.extra_packages) if req.extra_packages else None,
)
return _succeed(progress, Path(result), f"Exported to {result}")
def _run_exe(req: ExportRequest, progress: Callable[[ExportProgress], None]) -> ExportProgress:
if shutil.which("pyinstaller") is None:
return _fail(progress, "PyInstaller is not installed or not on PATH.")
name = req.exe_name or req.package_name
args = ["pyinstaller", "--distpath", str(req.output_dir), "--name", name]
if req.exe_onefile:
args.append("--onefile")
if not req.exe_console:
args.append("--noconsole")
if req.icon_path:
args.extend(["--icon", req.icon_path])
args.append(str(req.project_dir / req.entry_point))
_emit(progress, 0.5, "Running PyInstaller...")
req.output_dir.mkdir(parents=True, exist_ok=True)
try:
subprocess.run(args, cwd=req.project_dir, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as exc:
return _fail(progress, f"PyInstaller failed: {exc.stderr or exc.stdout}")
host = req.exe_host_os
if host == "windows":
result = req.output_dir / f"{name}.exe"
elif host == "macos":
app = req.output_dir / f"{name}.app"
result = app if app.exists() else req.output_dir / name
else:
result = req.output_dir / name
return _succeed(progress, result, f"Built {result}")
def _run_android(req: ExportRequest, progress: Callable[[ExportProgress], None]) -> ExportProgress:
# Look for build.sh relative to the project, then walk up to repo root.
candidates = [
req.project_dir / "android" / "build.sh",
Path(__file__).resolve().parents[5] / "android" / "build.sh",
]
script = next((c for c in candidates if c.is_file()), None)
if script is None:
return _fail(progress, "android/build.sh not found.")
if shutil.which("buildozer") is None:
return _fail(progress, "buildozer is not installed or not on PATH.")
_emit(progress, 0.5, "Running buildozer...")
try:
subprocess.run(
["bash", str(script), req.android_mode],
cwd=req.project_dir, check=True, capture_output=True, text=True,
)
except subprocess.CalledProcessError as exc:
return _fail(progress, f"Android build failed: {exc.stderr or exc.stdout}")
return _succeed(progress, req.output_dir, f"APK built in {script.parent / 'bin'}")
# ---------------------------------------------------------------------------
# TOML round-trip
# ---------------------------------------------------------------------------
[docs]
def save_request_to_toml(req: ExportRequest, settings: ProjectSettings) -> None:
"""Write target-specific fields of *req* into *settings* sub-tables."""
t = req.target
if t in (ExportTarget.DESKTOP_WHEEL, ExportTarget.DESKTOP_FOLDER):
settings.export_desktop.update({
"mode": "wheel" if t == ExportTarget.DESKTOP_WHEEL else "folder",
"os_label": req.os_label,
"build_wheel": req.build_wheel,
"create_zip": req.create_zip,
"package_name": req.package_name,
"version": req.version,
"icon": req.icon_path or "",
"output_dir": str(req.output_dir),
})
elif t == ExportTarget.WEB_HTML:
settings.export_web.update({
"width": req.web_width,
"height": req.web_height,
"responsive": req.web_responsive,
"pyodide_version": req.pyodide_version,
"extra_packages": list(req.extra_packages),
"root_class": req.root_class or "",
"title": req.title,
"output_path": str(req.output_dir / "game.html"),
})
elif t == ExportTarget.STANDALONE_EXE:
settings.export_exe.update({
"onefile": req.exe_onefile,
"console": req.exe_console,
"name": req.exe_name,
"output_dir": str(req.output_dir),
})
elif t == ExportTarget.ANDROID_APK:
settings.export_android.update({
"package": req.android_package,
"min_sdk": req.android_min_sdk,
"mode": req.android_mode,
"output_dir": str(req.output_dir),
})
[docs]
def load_request_from_toml(
target: ExportTarget, settings: ProjectSettings, project_dir: Path
) -> ExportRequest:
"""Construct an :class:`ExportRequest` for *target* seeded from *settings*."""
d = settings.export_desktop
w = settings.export_web
a = settings.export_android
e = settings.export_exe
# Shared defaults
package_name = d.get("package_name") or settings.name or project_dir.name
version = d.get("version") or "0.1.0"
entry_point = settings.main or "main.py"
# Output directory pick based on target
if target == ExportTarget.WEB_HTML:
out_path = Path(w.get("output_path", "dist/web/game.html"))
output_dir = out_path.parent if out_path.suffix else out_path
elif target == ExportTarget.STANDALONE_EXE:
output_dir = Path(e.get("output_dir", "dist/exe"))
elif target == ExportTarget.ANDROID_APK:
output_dir = Path(a.get("output_dir", "dist/android"))
else:
output_dir = Path(d.get("output_dir", "dist/desktop"))
if not output_dir.is_absolute():
output_dir = project_dir / output_dir
return ExportRequest(
target=target,
project_dir=project_dir,
output_dir=output_dir,
package_name=package_name,
version=version,
entry_point=entry_point,
os_label=d.get("os_label", "linux"),
build_wheel=bool(d.get("build_wheel", False)),
create_zip=bool(d.get("create_zip", False)),
icon_path=d.get("icon") or None,
web_width=int(w.get("width", 800)),
web_height=int(w.get("height", 600)),
web_responsive=bool(w.get("responsive", False)),
pyodide_version=str(w.get("pyodide_version", DEFAULT_PYODIDE_VERSION)),
extra_packages=list(w.get("extra_packages", [])),
root_class=(w.get("root_class") or None),
title=str(w.get("title", "SimVX")),
exe_onefile=bool(e.get("onefile", True)),
exe_console=bool(e.get("console", False)),
exe_name=str(e.get("name", "")),
exe_host_os=detect_host_os(),
android_package=str(a.get("package", "")),
android_min_sdk=int(a.get("min_sdk", 26)),
android_mode=str(a.get("mode", "debug")),
)