"""
Export/build system -- package SimVX games as distributable Python packages.
Scans a project directory, collects assets matching glob patterns,
generates a valid Python package structure (pyproject.toml, entry point,
assets), and optionally builds a wheel via ``uv build``. Supports
multiple export modes: wheel packaging and standalone folder export.
Public API:
ExportMode -- enum selecting the export format
ExportPreset -- dataclass describing what/how to export
AssetManifest -- scans, hashes, and tracks project assets
ProjectExporter -- orchestrates the full export pipeline
bundle_project() -- single-call convenience wrapper
Example:
from simvx.core.export import ExportMode, ExportPreset, ProjectExporter
preset = ExportPreset(
name="MyGame-Linux",
platform="linux",
entry_point="main.py",
include_patterns=["assets/**/*", "scenes/**/*.json"],
version="1.0.0",
export_mode=ExportMode.FOLDER,
)
exporter = ProjectExporter()
exporter.export("./my_game", preset, "./dist")
"""
from __future__ import annotations
from __future__ import annotations
import hashlib
import json
import logging
import shutil
import stat
import subprocess
import textwrap
from dataclasses import dataclass, field
from enum import IntEnum
from fnmatch import fnmatch
from pathlib import Path
log = logging.getLogger(__name__)
__all__ = [
"ExportMode",
"ExportPreset",
"AssetEntry",
"AssetManifest",
"ProjectExporter",
"ExportError",
"bundle_project",
]
# Valid target platforms
PLATFORMS = frozenset({"windows", "linux", "macos", "web"})
# Extensions that should never be shipped in a release build
_DEFAULT_EXCLUDES = [
"**/__pycache__/**",
"**/*.pyc",
"**/.git/**",
"**/.gitignore",
"**/.env",
"**/credentials.*",
"**/*.secret",
"**/node_modules/**",
"**/.mypy_cache/**",
"**/.ruff_cache/**",
"**/.pytest_cache/**",
]
[docs]
class ExportError(Exception):
"""Raised when export validation or execution fails."""
[docs]
class ExportMode(IntEnum):
"""How the project should be packaged."""
WHEEL = 0 # Python package + optional wheel (setuptools)
FOLDER = 1 # Standalone folder with run scripts
# ============================================================================
# ExportPreset
# ============================================================================
[docs]
@dataclass
class ExportPreset:
"""Describes how a SimVX project should be exported.
Attributes:
name: Human-readable preset name (also used as the package slug).
platform: Target platform -- ``"windows"``, ``"linux"``, ``"macos"``, or ``"web"``.
entry_point: Path to the main script relative to the project root.
include_patterns: Glob patterns selecting files to ship (relative to project root).
exclude_patterns: Glob patterns for files to skip (merged with built-in excludes).
icon_path: Optional icon file (relative to project root).
version: Semantic version string for the exported package.
description: One-line description embedded in pyproject.toml.
custom_features: Arbitrary platform-specific flags forwarded to build hooks.
build_wheel: Whether ``export()`` should also invoke ``uv build`` (wheel mode only).
export_mode: Export format -- WHEEL (Python package) or FOLDER (standalone folder).
create_zip: Zip the output folder (folder mode only).
scene_to_code: Generate .py alongside .json scene files.
"""
name: str = "MyGame"
platform: str = "linux"
entry_point: str = "main.py"
include_patterns: list[str] = field(default_factory=lambda: ["**/*.py", "assets/**/*", "scenes/**/*"])
exclude_patterns: list[str] = field(default_factory=list)
icon_path: str | None = None
version: str = "0.1.0"
description: str = "A SimVX game."
custom_features: dict[str, object] = field(default_factory=dict)
build_wheel: bool = False
export_mode: ExportMode = ExportMode.FOLDER
create_zip: bool = False
scene_to_code: bool = False
@property
def package_name(self) -> str:
"""Normalised package name derived from *name* (PEP 503)."""
return self.name.lower().replace(" ", "-").replace("_", "-")
# ============================================================================
# AssetEntry / AssetManifest
# ============================================================================
[docs]
@dataclass(frozen=True)
class AssetEntry:
"""A single file to include in the export.
Attributes:
relative: Path relative to project root (always forward-slash separated).
size: File size in bytes.
sha256: Hex-encoded SHA-256 digest.
"""
relative: str
size: int
sha256: str
[docs]
class AssetManifest:
"""Scans a project directory and builds a list of :class:`AssetEntry` items.
The manifest can be serialised to / loaded from JSON for incremental rebuilds
and cache invalidation.
"""
def __init__(self) -> None:
self.entries: list[AssetEntry] = []
# ------------------------------------------------------------------ #
# Collect
# ------------------------------------------------------------------ #
[docs]
def collect(self, project_dir: str | Path, preset: ExportPreset) -> list[AssetEntry]:
"""Walk *project_dir*, select files matching *preset* patterns, hash them.
Returns the collected :class:`AssetEntry` list (also stored in ``self.entries``).
"""
project_dir = Path(project_dir).resolve()
if not project_dir.is_dir():
raise ExportError(f"Project directory does not exist: {project_dir}")
all_excludes = _DEFAULT_EXCLUDES + preset.exclude_patterns
seen: set[str] = set()
entries: list[AssetEntry] = []
for pattern in preset.include_patterns:
for path in sorted(project_dir.glob(pattern)):
if not path.is_file():
continue
rel = path.relative_to(project_dir).as_posix()
if rel in seen:
continue
if _matches_any(rel, all_excludes):
continue
seen.add(rel)
digest = _sha256(path)
entries.append(AssetEntry(relative=rel, size=path.stat().st_size, sha256=digest))
# Always include the entry-point script itself
ep = Path(preset.entry_point)
ep_rel = ep.as_posix()
if ep_rel not in seen:
ep_abs = project_dir / ep
if ep_abs.is_file():
entries.append(AssetEntry(relative=ep_rel, size=ep_abs.stat().st_size, sha256=_sha256(ep_abs)))
self.entries = sorted(entries, key=lambda e: e.relative)
return self.entries
# ------------------------------------------------------------------ #
# Serialisation
# ------------------------------------------------------------------ #
[docs]
def write_manifest(self, path: str | Path) -> None:
"""Write the current entries to a JSON manifest file."""
path = Path(path)
data = {
"version": 1,
"files": [{"path": e.relative, "size": e.size, "sha256": e.sha256} for e in self.entries],
}
path.write_text(json.dumps(data, indent=2))
[docs]
@classmethod
def load_manifest(cls, path: str | Path) -> AssetManifest:
"""Load a previously-written manifest from *path*."""
path = Path(path)
raw = json.loads(path.read_text())
manifest = cls()
manifest.entries = [
AssetEntry(relative=f["path"], size=f["size"], sha256=f["sha256"]) for f in raw.get("files", [])
]
return manifest
# ------------------------------------------------------------------ #
# Utilities
# ------------------------------------------------------------------ #
@property
def total_size(self) -> int:
"""Sum of all entry sizes in bytes."""
return sum(e.size for e in self.entries)
[docs]
def diff(self, other: AssetManifest) -> tuple[list[AssetEntry], list[AssetEntry], list[AssetEntry]]:
"""Compare two manifests, returning (added, removed, changed) lists."""
old_map = {e.relative: e for e in other.entries}
new_map = {e.relative: e for e in self.entries}
added = [e for r, e in new_map.items() if r not in old_map]
removed = [e for r, e in old_map.items() if r not in new_map]
changed = [e for r, e in new_map.items() if r in old_map and old_map[r].sha256 != e.sha256]
return added, removed, changed
# ============================================================================
# ProjectExporter
# ============================================================================
[docs]
class ProjectExporter:
"""Orchestrates the full export pipeline for a SimVX project."""
[docs]
def export(self, project_dir: str | Path, preset: ExportPreset, output_dir: str | Path) -> Path:
"""Export a project according to *preset* into *output_dir*.
Dispatches to ``_export_wheel()`` or ``_export_folder()`` based on
``preset.export_mode``.
Returns:
The :class:`Path` to the generated package/folder root inside *output_dir*.
"""
project_dir = Path(project_dir).resolve()
output_dir = Path(output_dir).resolve()
self.validate_preset(preset, project_dir)
if preset.export_mode == ExportMode.FOLDER:
return self._export_folder(project_dir, preset, output_dir)
return self._export_wheel(project_dir, preset, output_dir)
# ------------------------------------------------------------------ #
# Wheel export (existing behaviour)
# ------------------------------------------------------------------ #
def _export_wheel(self, project_dir: Path, preset: ExportPreset, output_dir: Path) -> Path:
"""Package as a Python package with optional wheel build."""
manifest = AssetManifest()
manifest.collect(project_dir, preset)
if not manifest.entries:
raise ExportError("No files matched the include patterns -- nothing to export.")
pkg_dir = output_dir / preset.package_name
src_dir = pkg_dir / "src" / preset.package_name.replace("-", "_")
src_dir.mkdir(parents=True, exist_ok=True)
self._copy_assets(project_dir, manifest, src_dir)
self._write_init(src_dir)
self._write_main(src_dir, preset)
self._write_pyproject(pkg_dir, preset)
manifest.write_manifest(pkg_dir / "asset_manifest.json")
log.info("Exported %d files (%.1f KB) to %s", len(manifest.entries), manifest.total_size / 1024, pkg_dir)
if preset.build_wheel:
self._build_wheel(pkg_dir)
return pkg_dir
# ------------------------------------------------------------------ #
# Folder export
# ------------------------------------------------------------------ #
def _export_folder(self, project_dir: Path, preset: ExportPreset, output_dir: Path) -> Path:
"""Export as a standalone folder with run scripts."""
manifest = AssetManifest()
manifest.collect(project_dir, preset)
if not manifest.entries:
raise ExportError("No files matched the include patterns -- nothing to export.")
game_dir = output_dir / preset.package_name
game_dir.mkdir(parents=True, exist_ok=True)
# Copy assets preserving relative paths (flat -- no src/ nesting)
self._copy_assets(project_dir, manifest, game_dir)
# Run scripts
self._write_run_scripts(game_dir, preset)
# requirements.txt
(game_dir / "requirements.txt").write_text("simvx-core>=0.1.0\nsimvx-graphics>=0.1.0\n")
# Asset manifest
manifest.write_manifest(game_dir / "asset_manifest.json")
# Scene-to-code conversion
if preset.scene_to_code:
self._convert_scenes_to_code(game_dir)
log.info("Folder export: %d files (%.1f KB) to %s", len(manifest.entries), manifest.total_size / 1024, game_dir)
# Optional zip
if preset.create_zip:
zip_path = shutil.make_archive(str(game_dir), "zip", output_dir, preset.package_name)
log.info("Created zip: %s", zip_path)
return game_dir
# ------------------------------------------------------------------ #
# Validation
# ------------------------------------------------------------------ #
[docs]
def validate_preset(self, preset: ExportPreset, project_dir: str | Path | None = None) -> list[str]:
"""Check a preset for problems. Returns a list of warning strings; raises on fatal errors."""
errors: list[str] = []
if preset.platform not in PLATFORMS:
raise ExportError(f"Unknown platform {preset.platform!r}. Valid: {', '.join(sorted(PLATFORMS))}")
if not preset.name or not preset.name.strip():
raise ExportError("Preset name must not be empty.")
if not preset.entry_point:
raise ExportError("entry_point must be set.")
if project_dir is not None:
project_dir = Path(project_dir)
ep = project_dir / preset.entry_point
if not ep.is_file():
raise ExportError(f"Entry point does not exist: {ep}")
if preset.icon_path:
icon = project_dir / preset.icon_path
if not icon.is_file():
errors.append(f"Icon file not found: {icon}")
# Version sanity check
parts = preset.version.split(".")
if len(parts) < 2 or not all(p.isdigit() for p in parts):
errors.append(f"Version {preset.version!r} does not look like a valid semver string.")
return errors
[docs]
def estimate_size(self, project_dir: str | Path, preset: ExportPreset) -> int:
"""Calculate approximate export size in bytes without copying anything."""
manifest = AssetManifest()
manifest.collect(project_dir, preset)
return manifest.total_size
# ------------------------------------------------------------------ #
# Internal helpers
# ------------------------------------------------------------------ #
@staticmethod
def _copy_assets(project_dir: Path, manifest: AssetManifest, dest: Path) -> None:
"""Copy every entry from *manifest* into *dest*, preserving relative paths."""
for entry in manifest.entries:
src = project_dir / entry.relative
dst = dest / entry.relative
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
@staticmethod
def _write_init(src_dir: Path) -> None:
"""Generate a minimal ``__init__.py``."""
init = src_dir / "__init__.py"
if not init.exists():
init.write_text('"""Auto-generated package for a SimVX game export."""\n')
@staticmethod
def _write_main(src_dir: Path, preset: ExportPreset) -> None:
"""Generate ``__main__.py`` that launches the game via ``runpy.run_path``."""
content = textwrap.dedent(f"""\
\"\"\"Entry point -- run with ``python -m {preset.package_name.replace('-', '_')}``\"\"\"
import runpy
import sys
from pathlib import Path
_pkg = Path(__file__).resolve().parent
sys.path.insert(0, str(_pkg))
def main():
runpy.run_path(str(_pkg / "{preset.entry_point}"), run_name="__main__")
if __name__ == "__main__":
main()
""")
(src_dir / "__main__.py").write_text(content)
@staticmethod
def _write_pyproject(pkg_dir: Path, preset: ExportPreset) -> None:
"""Generate a minimal ``pyproject.toml`` for the exported package."""
pkg_slug = preset.package_name.replace("-", "_")
# Determine platform-specific extras
platform_deps: list[str] = []
if preset.platform == "web":
platform_deps.append('"pyodide" # web target')
base_deps = ["simvx-core>=0.1.0", "simvx-graphics>=0.1.0"]
deps_lines = ",\n ".join(f'"{d}"' for d in base_deps + platform_deps)
content = textwrap.dedent(f"""\
[project]
name = "{preset.package_name}"
version = "{preset.version}"
description = "{preset.description}"
requires-python = ">=3.13"
dependencies = [
{deps_lines},
]
[project.scripts]
{preset.package_name} = "{pkg_slug}.__main__:main"
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
include = ["{pkg_slug}*"]
[tool.setuptools.package-data]
"{pkg_slug}" = ["assets/**/*", "scenes/**/*", "**/*.json"]
""")
(pkg_dir / "pyproject.toml").write_text(content)
@staticmethod
def _write_run_scripts(game_dir: Path, preset: ExportPreset) -> None:
"""Generate ``run.sh`` and ``run.bat`` launch scripts."""
sh_path = game_dir / "run.sh"
sh_path.write_text(
f'#!/bin/sh\ncd "$(dirname "$0")" && python {preset.entry_point} "$@"\n'
)
sh_path.chmod(sh_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
(game_dir / "run.bat").write_text(
f'@echo off\ncd /d "%~dp0" && python {preset.entry_point} %*\n'
)
@staticmethod
def _convert_scenes_to_code(game_dir: Path) -> None:
"""Convert all .json scene files in *game_dir* to .py alongside them."""
from .scene_codegen import scene_to_code
for json_file in game_dir.rglob("*.json"):
# Skip asset_manifest.json and project.simvx-adjacent files
if json_file.name == "asset_manifest.json":
continue
try:
code = scene_to_code(json_file)
json_file.with_suffix(".py").write_text(code)
except Exception:
log.warning("scene_to_code: skipped %s (not a valid scene)", json_file)
@staticmethod
def _build_wheel(pkg_dir: Path) -> Path:
"""Run ``uv build`` inside *pkg_dir* and return the dist directory."""
dist_dir = pkg_dir / "dist"
try:
subprocess.run(
["uv", "build", "--wheel"],
cwd=pkg_dir,
check=True,
capture_output=True,
text=True,
)
except FileNotFoundError:
raise ExportError("'uv' is not installed or not on PATH. Cannot build wheel.") from None
except subprocess.CalledProcessError as exc:
raise ExportError(f"uv build failed:\n{exc.stderr}") from exc
log.info("Built wheel in %s", dist_dir)
return dist_dir
# ============================================================================
# Convenience wrapper
# ============================================================================
[docs]
def bundle_project(
project_dir: str | Path,
output_dir: str | Path,
*,
name: str | None = None,
platform: str = "linux",
entry_point: str | None = None,
include_patterns: list[str] | None = None,
exclude_patterns: list[str] | None = None,
version: str = "0.1.0",
description: str = "A SimVX game.",
build_wheel: bool = False,
export_mode: ExportMode = ExportMode.FOLDER,
create_zip: bool = False,
scene_to_code: bool = False,
**custom_features: object,
) -> Path:
"""One-call export: build an :class:`ExportPreset` from keyword args and run the export.
If ``project.simvx`` exists in *project_dir*, its settings are used as
fallbacks for *name* and *entry_point*.
Returns the path to the generated package/folder directory.
"""
project_dir = Path(project_dir).resolve()
preset_name = name
preset_entry = entry_point or "main.py"
# Read project.simvx fallbacks
proj_file = project_dir / "project.simvx"
if proj_file.is_file():
try:
from .project import load_project
settings = load_project(proj_file)
if not preset_name and settings.name and settings.name != "Untitled":
preset_name = settings.name
if not entry_point and settings.main_scene:
preset_entry = settings.main_scene
except Exception:
pass
preset_name = preset_name or project_dir.name
preset = ExportPreset(
name=preset_name,
platform=platform,
entry_point=preset_entry,
include_patterns=include_patterns or ["**/*.py", "assets/**/*", "scenes/**/*"],
exclude_patterns=exclude_patterns or [],
version=version,
description=description,
custom_features=dict(custom_features),
build_wheel=build_wheel,
export_mode=export_mode,
create_zip=create_zip,
scene_to_code=scene_to_code,
)
return ProjectExporter().export(project_dir, preset, output_dir)
# ============================================================================
# Internal utilities
# ============================================================================
def _sha256(path: Path) -> str:
"""Return the hex SHA-256 digest of a file."""
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def _matches_any(rel_path: str, patterns: list[str]) -> bool:
"""Return True if *rel_path* matches any of the glob-style *patterns*."""
parts = rel_path.split("/")
for pat in patterns:
# Exact match against the full relative path
if fnmatch(rel_path, pat):
return True
# Also check if any path component matches a **/-prefixed pattern
if pat.startswith("**/"):
suffix = pat[3:]
for i, _ in enumerate(parts):
if fnmatch("/".join(parts[i:]), suffix):
return True
# Also match the final component alone
if fnmatch(parts[-1], suffix):
return True
return False