#!/usr/bin/env python3 import argparse import importlib.util import os import shutil import stat import tempfile from pathlib import Path from typing import List DEFAULT_EXCLUDES = { ".git", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".idea", ".vscode", "dist", "build", } DEFAULT_EXT_EXCLUDES = { ".pyc", ".pyo", ".pyd", ".log", } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Exporte un projet Pygame vers un package compatible Batocera" ) parser.add_argument("--project-dir", default=".", help="Répertoire du projet à exporter (défaut: .)") parser.add_argument("--entrypoint", default="main.py", help="Script Python principal à lancer (défaut: main.py)") parser.add_argument("--name", default="duploGame", help="Nom du jeu/package (défaut: duploGame)") parser.add_argument("--output-dir", default="dist", help="Dossier de sortie (défaut: dist)") parser.add_argument( "--mode", choices=["folder", "single-file"], default="folder", help="Format d'export: folder (style retrotrivia) ou single-file (défaut: folder)", ) parser.add_argument( "--vendor-module", action="append", dest="vendor_modules", default=None, help="Module Python à embarquer (répétable). Par défaut: duplo_controller + bleak + dbus_fast", ) parser.add_argument( "--allow-missing-vendor", action="store_true", help="N'échoue pas si un module vendor demandé est introuvable", ) return parser.parse_args() def should_skip(path: Path, project_root: Path) -> bool: rel = path.relative_to(project_root) parts = set(rel.parts) if parts & DEFAULT_EXCLUDES: return True if path.suffix.lower() in DEFAULT_EXT_EXCLUDES: return True return False def copy_project(src_root: Path, dst_root: Path) -> None: for root, dirs, files in os.walk(src_root): root_path = Path(root) dirs[:] = [d for d in dirs if not should_skip(root_path / d, src_root)] for filename in files: src_file = root_path / filename if should_skip(src_file, src_root): continue rel = src_file.relative_to(src_root) dst_file = dst_root / rel dst_file.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src_file, dst_file) def _copy_path(src: Path, dst: Path) -> None: if src.is_dir(): if dst.exists(): shutil.rmtree(dst) shutil.copytree(src, dst) else: dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) def vendor_modules(package_root: Path, module_names: List[str], allow_missing: bool) -> List[str]: vendor_root = package_root / "vendor" vendor_root.mkdir(parents=True, exist_ok=True) vendored = [] for module_name in module_names: spec = importlib.util.find_spec(module_name) if spec is None: message = f"Module introuvable pour vendor: {module_name}" if allow_missing: print(f"[WARN] {message}") continue raise SystemExit(message) if spec.submodule_search_locations: src_path = Path(list(spec.submodule_search_locations)[0]).resolve() dst_path = vendor_root / src_path.name _copy_path(src_path, dst_path) elif spec.origin: src_file = Path(spec.origin).resolve() dst_file = vendor_root / src_file.name _copy_path(src_file, dst_file) else: message = f"Impossible de résoudre la source du module: {module_name}" if allow_missing: print(f"[WARN] {message}") continue raise SystemExit(message) vendored.append(module_name) return vendored def build_folder_launcher(entrypoint: str) -> str: return f'''#!/usr/bin/env python # Auto-generated Batocera .pygame launcher (folder mode) import os import runpy import sys from pathlib import Path def main(): game_dir = Path(__file__).resolve().parent vendor_dir = game_dir / "vendor" if vendor_dir.exists(): sys.path.insert(0, str(vendor_dir)) sys.path.insert(0, str(game_dir)) os.chdir(game_dir) os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1") entry = game_dir / {entrypoint!r} if not entry.exists(): raise SystemExit(f"Entrypoint introuvable: {{entry}}") runpy.run_path(str(entry), run_name="__main__") if __name__ == "__main__": main() ''' def build_single_file_launcher(game_name: str, entrypoint: str) -> str: return f'''#!/usr/bin/env python # Auto-generated Batocera .pygame launcher (single-file mode) import os import runpy import shutil import sys import tempfile from pathlib import Path def main(): script_path = Path(__file__).resolve() payload_dir = script_path.with_suffix("") if not payload_dir.exists(): raise SystemExit(f"Dossier payload introuvable: {{payload_dir}}") root = Path(tempfile.gettempdir()) / {('batocera_pygame_' + game_name)!r} if root.exists(): shutil.rmtree(root, ignore_errors=True) shutil.copytree(payload_dir, root) vendor_dir = root / "vendor" if vendor_dir.exists(): sys.path.insert(0, str(vendor_dir)) sys.path.insert(0, str(root)) os.chdir(root) os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1") entry = root / {entrypoint!r} if not entry.exists(): raise SystemExit(f"Entrypoint introuvable: {{entry}}") runpy.run_path(str(entry), run_name="__main__") if __name__ == "__main__": main() ''' def make_executable(path: Path) -> None: mode = path.stat().st_mode path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) def main() -> None: args = parse_args() project_dir = Path(args.project_dir).resolve() entrypoint = args.entrypoint game_name = args.name output_dir = Path(args.output_dir).resolve() if not project_dir.exists(): raise SystemExit(f"Projet introuvable: {project_dir}") if not (project_dir / entrypoint).exists(): raise SystemExit(f"Entrypoint introuvable: {entrypoint}") vendor_modules_list = args.vendor_modules or ["duplo_controller", "bleak", "dbus_fast"] output_path = output_dir / f"{game_name}.pygame" with tempfile.TemporaryDirectory(prefix="batocera_pygame_export_") as tmp: temp_root = Path(tmp) package_root = temp_root / game_name package_root.mkdir(parents=True, exist_ok=True) copy_project(project_dir, package_root) vendored = vendor_modules( package_root, module_names=vendor_modules_list, allow_missing=args.allow_missing_vendor, ) output_dir.mkdir(parents=True, exist_ok=True) if args.mode == "folder": game_output_dir = output_dir / game_name if game_output_dir.exists(): shutil.rmtree(game_output_dir) shutil.copytree(package_root, game_output_dir) launcher_path = game_output_dir / f"{game_name}.pygame" launcher_path.write_text(build_folder_launcher(entrypoint=entrypoint), encoding="utf-8") make_executable(launcher_path) output_path = launcher_path else: output_path.write_text(build_single_file_launcher(game_name=game_name, entrypoint=entrypoint), encoding="utf-8") make_executable(output_path) payload_dir = output_dir / game_name if payload_dir.exists(): shutil.rmtree(payload_dir) shutil.copytree(package_root, payload_dir) print(f"Export termine: {output_path}") if vendored: print(f"Modules vendor inclus: {', '.join(vendored)}") if __name__ == "__main__": main()