Source code for simvx.core.project

"""Project configuration — simvx.toml files.

Schema-driven TOML file describing a SimVX game's display, physics, input,
audio, rendering, export, and editor settings. Uses stdlib tomllib for
reading and a small hand-rolled writer for round-tripping.

Public API:
    from simvx.core.project import ProjectSettings, load_project, save_project

    settings = load_project("simvx.toml")
    settings.name = "My Game"
    save_project(settings, "simvx.toml")
"""

import logging
import tomllib
from pathlib import Path
from typing import Any

from .input.enums import JoyAxis, JoyButton, Key, MouseButton, key_to_name, name_to_keys
from .input.events import InputBinding

log = logging.getLogger(__name__)

__all__ = [
    "ProjectSettings",
    "ValidationError",
    "load_project",
    "save_project",
    "find_project",
    "input_bindings_from_toml",
    "input_bindings_to_toml",
]

TOML_FILENAME = "simvx.toml"

# --- Input binding serialisation ---------------------------------------------

def _binding_from_entry(action: str, entry: Any) -> InputBinding:
    """Convert one [input] entry (string or dict) to an InputBinding."""
    if isinstance(entry, str):
        keys = name_to_keys(entry)
        if keys:
            return InputBinding(key=keys[0])
        upper = entry.upper()
        try:
            return InputBinding(key=Key[upper])
        except KeyError:
            pass
        try:
            return InputBinding(mouse_button=MouseButton[upper])
        except KeyError:
            pass
        try:
            return InputBinding(joy_button=JoyButton[upper])
        except KeyError:
            pass
        raise ValidationError(f"[input] {action}: unknown binding name {entry!r}")

    if not isinstance(entry, dict):
        raise ValidationError(
            f"[input] {action}: binding must be a string or inline table, got {type(entry).__name__}"
        )

    key = entry.get("key")
    mouse = entry.get("mouse")
    joy_button = entry.get("joy_button")
    joy_axis = entry.get("joy_axis")
    provided = [n for n, v in (("key", key), ("mouse", mouse), ("joy_button", joy_button), ("joy_axis", joy_axis)) if v is not None]
    if len(provided) != 1:
        raise ValidationError(
            f"[input] {action}: each binding must specify exactly one of key/mouse/joy_button/joy_axis"
        )

    if key is not None:
        ks = name_to_keys(str(key))
        if ks:
            return InputBinding(key=ks[0])
        try:
            return InputBinding(key=Key[str(key).upper()])
        except KeyError as exc:
            raise ValidationError(f"[input] {action}: unknown key {key!r}") from exc

    if mouse is not None:
        try:
            return InputBinding(mouse_button=MouseButton[str(mouse).upper()])
        except KeyError as exc:
            raise ValidationError(f"[input] {action}: unknown mouse button {mouse!r}") from exc

    if joy_button is not None:
        try:
            return InputBinding(joy_button=JoyButton[str(joy_button).upper()])
        except KeyError as exc:
            raise ValidationError(f"[input] {action}: unknown joy_button {joy_button!r}") from exc

    try:
        axis = JoyAxis[str(joy_axis).upper()]
    except KeyError as exc:
        raise ValidationError(f"[input] {action}: unknown joy_axis {joy_axis!r}") from exc

    positive = bool(entry.get("positive", True))
    deadzone_raw = entry.get("deadzone", 0.2)
    if not isinstance(deadzone_raw, int | float):
        raise ValidationError(f"[input] {action}: deadzone must be a number")
    deadzone = float(deadzone_raw)
    if not 0.0 <= deadzone <= 1.0:
        raise ValidationError(f"[input] {action}: deadzone must be between 0.0 and 1.0")
    return InputBinding(joy_axis=axis, joy_axis_positive=positive, deadzone=deadzone)

[docs] def input_bindings_from_toml(action: str, entries: list[Any]) -> list[InputBinding]: """Convert a TOML [input] value (legacy string list or structured list) to bindings.""" if not isinstance(entries, list): raise ValidationError(f"[input] {action}: must be a list of bindings") return [_binding_from_entry(action, e) for e in entries]
def _binding_to_toml(binding: InputBinding) -> dict[str, Any]: """Serialise an InputBinding to an inline-table-friendly dict.""" if binding.key is not None: return {"key": key_to_name(binding.key)} if binding.mouse_button is not None: return {"mouse": binding.mouse_button.name.lower()} if binding.joy_button is not None: return {"joy_button": binding.joy_button.name.lower()} if binding.joy_axis is not None: entry: dict[str, Any] = {"joy_axis": binding.joy_axis.name.lower()} if not binding.joy_axis_positive: entry["positive"] = False if binding.deadzone != 0.2: entry["deadzone"] = float(binding.deadzone) return entry raise ValueError("InputBinding has no active field to serialise")
[docs] def input_bindings_to_toml(bindings: list[InputBinding]) -> list[dict[str, Any]]: """Serialise a list of InputBindings to a list of inline-table dicts.""" return [_binding_to_toml(b) for b in bindings]
# --- Schema defaults --- _DISPLAY_DEFAULTS: dict[str, Any] = { "width": 1280, "height": 720, "vsync": True, "fullscreen": False, "stretch_mode": "viewport", "stretch_aspect": "keep", } _PHYSICS_DEFAULTS: dict[str, Any] = { "fps": 60, "gravity": 9.8, } _AUDIO_DEFAULTS: dict[str, Any] = { "master_volume": 1.0, } _RENDERING_DEFAULTS: dict[str, Any] = { "backend": "vulkan", "msaa": 0, } _EXPORT_WEB_DEFAULTS: dict[str, Any] = { "width": 800, "height": 600, "responsive": False, # pyodide_version is intentionally omitted — the canonical default lives in # simvx.web.export.DEFAULT_PYODIDE_VERSION. Project TOMLs that want to pin # a specific version set it explicitly; otherwise the exporter supplies it. "extra_packages": [], "root_class": "", "title": "SimVX", "output_path": "dist/web/game.html", } _EXPORT_DESKTOP_DEFAULTS: dict[str, Any] = { "icon": "", "mode": "folder", "os_label": "linux", "build_wheel": False, "create_zip": False, "package_name": "", "version": "0.1.0", "output_dir": "dist/desktop", } _EXPORT_ANDROID_DEFAULTS: dict[str, Any] = { "package": "", "min_sdk": 26, "mode": "debug", "output_dir": "dist/android", } _EXPORT_EXE_DEFAULTS: dict[str, Any] = { "onefile": True, "console": False, "name": "", "output_dir": "dist/exe", } _EDITOR_DEFAULTS: dict[str, Any] = { "plugins": [], "class_files_dir": "src", } # --- Schema validation --- _SCHEMA: dict[str, dict[str, type | tuple[type, ...]]] = { "display": {"width": int, "height": int, "vsync": bool, "fullscreen": bool, "stretch_mode": str, "stretch_aspect": str}, "physics": {"fps": int, "gravity": (int, float)}, "audio": {"master_volume": (int, float)}, "rendering": {"backend": str, "msaa": int}, "export.web": { "width": int, "height": int, "responsive": bool, "pyodide_version": str, "extra_packages": list, "root_class": str, "title": str, "output_path": str, }, "export.desktop": { "icon": str, "mode": str, "os_label": str, "build_wheel": bool, "create_zip": bool, "package_name": str, "version": str, "output_dir": str, }, "export.android": {"package": str, "min_sdk": int, "mode": str, "output_dir": str}, "export.exe": {"onefile": bool, "console": bool, "name": str, "output_dir": str}, "editor": {"plugins": list, "class_files_dir": str}, } _VALID_STRETCH_MODES = {"viewport", "canvas_items", "disabled"} _VALID_STRETCH_ASPECTS = {"keep", "expand", "ignore"} _VALID_BACKENDS = {"vulkan", "sdl3"} _VALID_DESKTOP_MODES = {"wheel", "folder"} _VALID_ANDROID_MODES = {"debug", "release"} # Autoload names the engine reserves for itself. Mirrors the constant in # scene_tree.py; project loading rejects these names up-front so the error # surfaces on simvx.toml load rather than at first add_autoload() call. _RESERVED_AUTOLOAD_NAMES = {"events"}
[docs] class ValidationError(ValueError): """Raised when simvx.toml contains invalid values."""
def _validate_type(section: str, key: str, value: Any, expected: type | tuple[type, ...]) -> None: """Validate a single field's type.""" if not isinstance(value, expected): exp = expected.__name__ if isinstance(expected, type) else " or ".join(t.__name__ for t in expected) raise ValidationError(f"[{section}] {key}: expected {exp}, got {type(value).__name__}") def _validate_section(section_name: str, data: dict[str, Any]) -> None: """Validate all fields in a section against the schema.""" schema = _SCHEMA.get(section_name) if not schema: return for key, value in data.items(): if key in schema: _validate_type(section_name, key, value, schema[key]) def _validate_constraints(settings: ProjectSettings) -> None: """Validate cross-field and enum-like constraints.""" if settings.display.get("width", 1280) <= 0: raise ValidationError("[display] width: must be positive") if settings.display.get("height", 720) <= 0: raise ValidationError("[display] height: must be positive") sm = settings.display.get("stretch_mode", "viewport") if sm not in _VALID_STRETCH_MODES: raise ValidationError(f"[display] stretch_mode: must be one of {_VALID_STRETCH_MODES}, got {sm!r}") sa = settings.display.get("stretch_aspect", "keep") if sa not in _VALID_STRETCH_ASPECTS: raise ValidationError(f"[display] stretch_aspect: must be one of {_VALID_STRETCH_ASPECTS}, got {sa!r}") backend = settings.rendering.get("backend", "vulkan") if backend not in _VALID_BACKENDS: raise ValidationError(f"[rendering] backend: must be one of {_VALID_BACKENDS}, got {backend!r}") fps = settings.physics.get("fps", 60) if fps <= 0: raise ValidationError("[physics] fps: must be positive") mv = settings.audio.get("master_volume", 1.0) if not 0.0 <= mv <= 1.0: raise ValidationError("[audio] master_volume: must be between 0.0 and 1.0") # export.web if settings.export_web.get("width", 1) <= 0: raise ValidationError("[export.web] width: must be positive") if settings.export_web.get("height", 1) <= 0: raise ValidationError("[export.web] height: must be positive") # export.desktop dm = settings.export_desktop.get("mode", "folder") if dm not in _VALID_DESKTOP_MODES: raise ValidationError(f"[export.desktop] mode: must be one of {_VALID_DESKTOP_MODES}, got {dm!r}") # export.android if settings.export_android.get("min_sdk", 26) < 21: raise ValidationError("[export.android] min_sdk: must be >= 21") am = settings.export_android.get("mode", "debug") if am not in _VALID_ANDROID_MODES: raise ValidationError(f"[export.android] mode: must be one of {_VALID_ANDROID_MODES}, got {am!r}") # Validate input action values are lists of InputBindings for action, bindings in settings.input.items(): if not isinstance(bindings, list) or not all(isinstance(b, InputBinding) for b in bindings): raise ValidationError(f"[input] {action}: must be a list of InputBinding objects") # Validate editor plugins is a list of strings plugins = settings.editor.get("plugins", []) if not isinstance(plugins, list) or not all(isinstance(p, str) for p in plugins): raise ValidationError("[editor] plugins: must be a list of strings") cfd = settings.editor.get("class_files_dir", "src") if not isinstance(cfd, str) or not cfd: raise ValidationError("[editor] class_files_dir: must be a non-empty string") # Reject reserved autoload names. The engine ships its own autoloads # (currently: "events" -> EventBus, exposed as tree.events). User code # would shadow them and break access via tree.events, so refuse the load. for reserved in sorted(_RESERVED_AUTOLOAD_NAMES.intersection(settings.autoloads)): raise ValidationError( f"[autoloads] {reserved!r}: name is reserved for the engine " f"(use tree.{reserved} to access the built-in instance); " "pick a different autoload name." ) # --- Settings class ---
[docs] class ProjectSettings: """TOML-based project configuration. Sections are stored as plain dicts for simplicity and easy serialization. Top-level fields (name, main) are direct attributes. """ __slots__ = ( "name", "main", "display", "input", "physics", "audio", "rendering", "export_web", "export_desktop", "export_android", "export_exe", "editor", "autoloads", "project_path", "engine_version", ) def __init__(self, data: dict[str, Any] | None = None): data = data or {} self.name: str = data.get("name", "Untitled") self.main: str = data.get("main", "") self.display: dict[str, Any] = {**_DISPLAY_DEFAULTS, **data.get("display", {})} raw_input = data.get("input", {}) or {} self.input: dict[str, list[InputBinding]] = { action: input_bindings_from_toml(action, entries) for action, entries in raw_input.items() } self.physics: dict[str, Any] = {**_PHYSICS_DEFAULTS, **data.get("physics", {})} self.audio: dict[str, Any] = {**_AUDIO_DEFAULTS, **data.get("audio", {})} self.rendering: dict[str, Any] = {**_RENDERING_DEFAULTS, **data.get("rendering", {})} export = data.get("export", {}) self.export_web: dict[str, Any] = {**_EXPORT_WEB_DEFAULTS, **export.get("web", {})} self.export_desktop: dict[str, Any] = {**_EXPORT_DESKTOP_DEFAULTS, **export.get("desktop", {})} self.export_android: dict[str, Any] = {**_EXPORT_ANDROID_DEFAULTS, **export.get("android", {})} self.export_exe: dict[str, Any] = {**_EXPORT_EXE_DEFAULTS, **export.get("exe", {})} self.editor: dict[str, Any] = {**_EDITOR_DEFAULTS, **data.get("editor", {})} # Engine section engine = data.get("engine", {}) self.engine_version: str = engine.get("version", "") self.autoloads: dict[str, str] = dict(data.get("autoloads", {})) self.project_path: str = ""
[docs] def to_dict(self) -> dict[str, Any]: """Serialize to a nested dict matching TOML structure.""" d: dict[str, Any] = {"name": self.name, "main": self.main} d["display"] = dict(self.display) if self.input: d["input"] = {action: input_bindings_to_toml(bindings) for action, bindings in self.input.items()} d["physics"] = dict(self.physics) d["audio"] = dict(self.audio) d["rendering"] = dict(self.rendering) export: dict[str, Any] = {} if self.export_web != _EXPORT_WEB_DEFAULTS: export["web"] = dict(self.export_web) if self.export_desktop != _EXPORT_DESKTOP_DEFAULTS: export["desktop"] = dict(self.export_desktop) if self.export_android != _EXPORT_ANDROID_DEFAULTS: export["android"] = dict(self.export_android) if self.export_exe != _EXPORT_EXE_DEFAULTS: export["exe"] = dict(self.export_exe) if export: d["export"] = export if self.editor != _EDITOR_DEFAULTS: d["editor"] = dict(self.editor) if self.engine_version: d["engine"] = {"version": self.engine_version} if self.autoloads: d["autoloads"] = dict(self.autoloads) return d
[docs] def validate(self) -> None: """Validate all settings. Raises ValidationError on invalid values.""" _validate_section("display", self.display) _validate_section("physics", self.physics) _validate_section("audio", self.audio) _validate_section("rendering", self.rendering) _validate_section("export.web", self.export_web) _validate_section("export.desktop", self.export_desktop) _validate_section("export.android", self.export_android) _validate_section("export.exe", self.export_exe) _validate_section("editor", self.editor) _validate_constraints(self)
[docs] @property def project_dir(self) -> str: """Directory containing the project file (``"."`` if unset).""" if self.project_path: return str(Path(self.project_path).parent) return "."
[docs] def resolve_path(self, relative: str) -> str: """Resolve a project-relative path to an absolute path.""" return str((Path(self.project_dir) / relative).resolve())
[docs] def apply_input_actions(self) -> None: """Register all input actions with InputMap, replacing any existing state.""" from .input.map import InputMap InputMap.clear() for action_name, bindings in self.input.items(): InputMap.add_action(action_name, list(bindings))
# --- TOML writer --- def _toml_inline_table(d: dict[str, Any]) -> str: """Format a dict as a TOML inline table literal.""" parts = [f"{k} = {_toml_value(v)}" for k, v in d.items()] return "{" + ", ".join(parts) + "}" def _toml_value(v: Any) -> str: """Format a Python value as a TOML literal.""" if isinstance(v, bool): return "true" if v else "false" if isinstance(v, int): return str(v) if isinstance(v, float): return f"{v:g}" if isinstance(v, str): # Escape backslashes and quotes escaped = v.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' if isinstance(v, dict): return _toml_inline_table(v) if isinstance(v, list): items = ", ".join(_toml_value(item) for item in v) return f"[{items}]" raise TypeError(f"Unsupported TOML type: {type(v).__name__}") def _write_toml(data: dict[str, Any]) -> str: """Generate valid TOML from a nested dict. Handles: str, int, float, bool, list[str], and one level of nested tables. """ lines: list[str] = [] # Top-level scalar keys first for key, value in data.items(): if not isinstance(value, dict): lines.append(f"{key} = {_toml_value(value)}") # Then sections (dicts) for key, value in data.items(): if isinstance(value, dict): # Check if this is a table with sub-tables (like export.web) has_subtables = any(isinstance(v, dict) for v in value.values()) if has_subtables: # Write scalar keys under [key] first scalars = {k: v for k, v in value.items() if not isinstance(v, dict)} if scalars: lines.append("") lines.append(f"[{key}]") for sk, sv in scalars.items(): lines.append(f"{sk} = {_toml_value(sv)}") # Then sub-tables as [key.subkey] for sk, sv in value.items(): if isinstance(sv, dict): lines.append("") lines.append(f"[{key}.{sk}]") for ssk, ssv in sv.items(): lines.append(f"{ssk} = {_toml_value(ssv)}") else: lines.append("") lines.append(f"[{key}]") for sk, sv in value.items(): lines.append(f"{sk} = {_toml_value(sv)}") return "\n".join(lines) + "\n" # --- Public API ---
[docs] def load_project(path: str | Path) -> ProjectSettings: """Load project settings from a simvx.toml file. Raises: FileNotFoundError: If the file does not exist. tomllib.TOMLDecodeError: If the file is not valid TOML. ValidationError: If values fail schema validation. """ path = Path(path) if not path.exists(): raise FileNotFoundError(f"Project file not found: {path}") data = tomllib.loads(path.read_text(encoding="utf-8")) settings = ProjectSettings(data) settings.validate() settings.project_path = str(path.resolve()) log.debug("project: loaded %s (%s)", settings.name, path) return settings
[docs] def save_project(settings: ProjectSettings, path: str | Path | None = None) -> None: """Save project settings to a simvx.toml file. Args: settings: ProjectSettings to save. path: Output file path. If None, uses settings.project_path. """ if path is None: path = settings.project_path if not path: raise ValueError("No path specified and settings.project_path is empty") path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(_write_toml(settings.to_dict()), encoding="utf-8") settings.project_path = str(path.resolve()) log.debug("project: saved %s to %s", settings.name, path)
[docs] def find_project(start_dir: str | Path | None = None) -> Path | None: """Search up the directory tree for simvx.toml. Args: start_dir: Directory to start searching from. Defaults to cwd. Returns: Path to simvx.toml if found, None otherwise. """ current = Path(start_dir or ".").resolve() while True: candidate = current / TOML_FILENAME if candidate.is_file(): return candidate parent = current.parent if parent == current: return None current = parent