Source code for simvx.editor.export_controller

"""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")), )