You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
261 lines
7.8 KiB
261 lines
7.8 KiB
#!/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()
|
|
|