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 copy
import datetime
import difflib
import logging
import re
import tomllib
from pathlib import Path
from typing import Any

from .input.actions import Axis1D, Axis2D, Source
from .input.events import InputBinding
from .input.spelling import source_from_data, source_to_data

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 ---------------------------------------------


[docs] def input_bindings_from_toml(action: str, entries: list[Any]) -> list[Source]: """An ``[input]`` entry's source list, read by the spelling the rebind file shares. See :mod:`simvx.core.input.spelling` for the forms: a key's name or a combo, a control's ``type:name``, a table with ``held`` and ``exact``, ``{low, high}``, ``{up, down, left, right}``, and ``{name = ...}``. """ if not isinstance(entries, list): raise ValidationError(f"[input] {action}: must be a list of bindings") sources: list[Source] = [] for entry in entries: try: sources.append(source_from_data(entry)) except (TypeError, ValueError) as exc: raise ValidationError(f"[input] {action}: {exc}") from exc return sources
[docs] def input_bindings_to_toml(sources: list[Source]) -> list[Any]: """An action's sources as the plain data an ``[input]`` entry holds.""" return [source_to_data(source) for source in sources]
# --- 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, # Font selection. Declared here so a misspelt key is reported rather than # ignored: a typo in prefer_system_fonts silently changed every typeface # in the game. "prefer_system_fonts": bool, "font": str, "mono_font": str, "fallback_fonts": list, "locales": list, # Where shaders compiled at run time are cached, relative to the project # directory. Overridden by $SIMVX_SHADER_CACHE_DIR. "shader_cache_dir": str, }, "export.web": { "width": int, "height": int, "responsive": bool, # The typeface exported pages are set in. Separate from [rendering] font # because a page draws its text from a glyph atlas rather than from a # face it rasterises, and that atlas is baked at export time, so which # face goes into it is an export setting. "font": str, "pyodide_version": str, "extra_packages": list, "root_class": str, "title": str, "max_bundle_mb": (int, float), "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"} # Tables that used to declare singletons, in the order they were spelled. A # project file carrying either is refused: neither was ever applied by anything # at runtime, and an unmodelled table is carried through untouched, so leaving # one to be ignored looks identical to singletons that silently never load -- # the failure this file's own comments argue against. # # There is no replacement table and there will not be one. What an entry in it # could do is import a module, guess a class and call it with no arguments; # what the engine already offers is ``tree.add_singleton(name, node)`` with a # node that is built and connected, which is strictly more capable. The # language-level global identifier that makes a declarative table worth having # in other engines is not something Python can give: access is # ``tree.singletons["Name"]`` either way. _RETIRED_SINGLETON_TABLES = ("singletons", "autoloads") _RETIRED_SINGLETON_ADVICE = ( "declaring singletons in simvx.toml is not supported and never took effect. " 'Build the node and register it instead: tree.add_singleton("Name", Name()) ' "in your main scene's on_ready(). Then delete the table." ) # Top-level keys ProjectSettings models field by field. Everything else in the # document is carried through untouched so that saving a file never discards a # section this class happens not to know about. _MODELLED_TOP_LEVEL = frozenset( {"name", "main", "display", "input", "physics", "audio", "rendering", "export", "editor", "engine"} ) _MODELLED_EXPORT = frozenset({"web", "desktop", "android", "exe"}) _MODELLED_ENGINE = frozenset({"version"}) # Older project files, and the files the editor's project templates used to # generate, spell the project name and entry point inside a [project] table. # The canonical spelling is top level; these are read on load and rewritten # canonically on the next save. _LEGACY_PROJECT_SECTION = "project" _LEGACY_NAME_KEYS = ("name", "project_name") _LEGACY_MAIN_KEYS = ("main", "default_scene")
[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. A key the schema does not know is an error, not something to skip. Ignoring it means a misspelt setting reads as "not set": the game runs with the default and nothing anywhere says why the setting had no effect. """ schema = _SCHEMA.get(section_name) if not schema: return for key, value in data.items(): if key not in schema: raise ValidationError(f"[{section_name}] {key}: unknown setting{_did_you_mean(key, schema)}") _validate_type(section_name, key, value, schema[key]) def _did_you_mean(key: str, schema: dict[str, type | tuple[type, ...]]) -> str: """Suggestion clause naming the closest known key, or the known keys.""" close = difflib.get_close_matches(key, schema, n=1) if close: return f" (did you mean {close[0]!r}?)" return f" (known settings: {', '.join(sorted(schema))})" 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") if settings.export_web.get("max_bundle_mb", 0.0) < 0: raise ValidationError("[export.web] max_bundle_mb: must not be negative (0 lifts the check)") # 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}") for field in ("name", "main"): value = getattr(settings, field) if not isinstance(value, str): raise ValidationError(f"{field}: expected str, got {type(value).__name__}") if not isinstance(settings.engine_version, str): raise ValidationError(f"[engine] version: expected str, got {type(settings.engine_version).__name__}") # Validate input entry values are lists of sources and entries' names for action, bindings in settings.input.items(): if not isinstance(bindings, list) or not all( isinstance(b, InputBinding | Axis1D | Axis2D | str) for b in bindings ): raise ValidationError(f"[input] {action}: must be a list of bindings, composites and entries' names") # 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") for table in _RETIRED_SINGLETON_TABLES: if table in settings.extras: raise ValidationError(f"[{table}]: {_RETIRED_SINGLETON_ADVICE}") # --- Settings class --- def _unmodelled(data: dict[str, Any]) -> dict[str, Any]: """Everything in *data* that ProjectSettings does not model, deep-copied. A project file belongs to the project, not to this class: a game is free to keep its own tables in it, and a newer engine may write sections an older one has never heard of. Saving must not silently delete them, so they are parked here and folded back in by :meth:`ProjectSettings.to_dict`. """ extras = {k: copy.deepcopy(v) for k, v in data.items() if k not in _MODELLED_TOP_LEVEL} extras.pop(_LEGACY_PROJECT_SECTION, None) for section, modelled in (("export", _MODELLED_EXPORT), ("engine", _MODELLED_ENGINE)): raw = data.get(section) or {} if isinstance(raw, dict): leftover = {k: copy.deepcopy(v) for k, v in raw.items() if k not in modelled} if leftover: extras[section] = leftover legacy = data.get(_LEGACY_PROJECT_SECTION) if isinstance(legacy, dict): consumed = set(_LEGACY_NAME_KEYS) | set(_LEGACY_MAIN_KEYS) leftover = {k: copy.deepcopy(v) for k, v in legacy.items() if k not in consumed} if leftover: extras[_LEGACY_PROJECT_SECTION] = leftover return extras def _fold_extras(out: dict[str, Any], extras: dict[str, Any]) -> None: """Merge unmodelled tables back into a serialised document. Modelled values always win: an extras entry only fills a slot the settings themselves left empty, so editing a section never resurrects what the edit removed. """ for key, value in extras.items(): target = out.get(key) if isinstance(target, dict) and isinstance(value, dict): for sub_key, sub_value in value.items(): target.setdefault(sub_key, copy.deepcopy(sub_value)) else: out.setdefault(key, copy.deepcopy(value)) def _table(data: dict[str, Any], key: str, label: str = "") -> dict[str, Any]: """The table stored at *key*, or an empty one when it is absent. A hand-edited file can spell a whole section wrong (``display = 5``, ``input = "wasd"``). Reporting that as a project-file error keeps it in the same class as every other bad value, so a caller that already handles :class:`ValidationError` surfaces it instead of dying on a TypeError from somewhere deeper. """ value = data.get(key) if value is None: return {} if not isinstance(value, dict): raise ValidationError(f"[{label or key}]: expected a table, got {type(value).__name__}") return value def _first_present(section: dict[str, Any], keys: tuple[str, ...]) -> Any: """First value in *section* under any of *keys*, or None.""" for key in keys: if key in section: return section[key] return None
[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. Any table the class does not model is kept in ``extras`` and written back out unchanged. """ __slots__ = ( "name", "main", "display", "input", "physics", "audio", "rendering", "export_web", "export_desktop", "export_android", "export_exe", "editor", "extras", "project_path", "engine_version", ) def __init__(self, data: dict[str, Any] | None = None): data = data or {} legacy = _table(data, _LEGACY_PROJECT_SECTION) legacy_name = _first_present(legacy, _LEGACY_NAME_KEYS) legacy_main = _first_present(legacy, _LEGACY_MAIN_KEYS) self.name: str = data.get("name", legacy_name if legacy_name is not None else "Untitled") self.main: str = data.get("main", legacy_main if legacy_main is not None else "") self.display: dict[str, Any] = {**_DISPLAY_DEFAULTS, **_table(data, "display")} raw_input = _table(data, "input") self.input: dict[str, list[Source]] = { action: input_bindings_from_toml(action, entries) for action, entries in raw_input.items() } self.physics: dict[str, Any] = {**_PHYSICS_DEFAULTS, **_table(data, "physics")} self.audio: dict[str, Any] = {**_AUDIO_DEFAULTS, **_table(data, "audio")} self.rendering: dict[str, Any] = {**_RENDERING_DEFAULTS, **_table(data, "rendering")} export = _table(data, "export") self.export_web: dict[str, Any] = {**_EXPORT_WEB_DEFAULTS, **_table(export, "web", "export.web")} self.export_desktop: dict[str, Any] = { **_EXPORT_DESKTOP_DEFAULTS, **_table(export, "desktop", "export.desktop"), } self.export_android: dict[str, Any] = { **_EXPORT_ANDROID_DEFAULTS, **_table(export, "android", "export.android"), } self.export_exe: dict[str, Any] = {**_EXPORT_EXE_DEFAULTS, **_table(export, "exe", "export.exe")} self.editor: dict[str, Any] = {**_EDITOR_DEFAULTS, **_table(data, "editor")} # Engine section self.engine_version: str = _table(data, "engine").get("version", "") self.extras: dict[str, Any] = _unmodelled(data) 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} _fold_extras(d, self.extras) 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(self) -> None: """Make the project's ``[input]`` entries the project layer of every tree's map. The layer sits over the engine's own entries and under every tree's contexts and the player's rebinds, and it is read live, so a change applied here reaches trees that already exist: that is what makes the editor's input-map dialog reach the game it is hosting. The entries may name each other in any order, and a set that cannot resolve is refused, naming the problem. """ from .input.map import set_project_input set_project_input({name: list(bindings) for name, bindings in self.input.items()})
# --- TOML writer --- #: Keys TOML lets a document spell without quotes. Anything else (a space, a #: dot, punctuation, an accented letter) has to be written as a quoted key. _BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$") #: Characters a TOML basic string must escape rather than emit literally. _STRING_ESCAPES = {"\\": "\\\\", '"': '\\"', "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r"} def _toml_string(s: str) -> str: """Format a string as a TOML basic string. Control characters are escaped, not emitted: a stray newline or tab in a value would otherwise end the line mid-string and leave a file that no longer parses, which is a worse outcome than any value being wrong. """ out: list[str] = [] for ch in s: escape = _STRING_ESCAPES.get(ch) if escape is not None: out.append(escape) elif ch < " " or ch == "\x7f": out.append(f"\\u{ord(ch):04X}") else: out.append(ch) return '"' + "".join(out) + '"' def _toml_key(key: Any) -> str: """Format a table or field name, quoting it unless it is a bare key. Project files carry names the engine never chose: a game's own table, a setting called ``"max hp"``. Writing those unquoted produces a file that cannot be read back. """ text = str(key) return text if _BARE_KEY.match(text) else _toml_string(text) def _toml_inline_table(d: dict[str, Any]) -> str: """Format a dict as a TOML inline table literal.""" parts = [f"{_toml_key(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): # repr keeps the value a TOML float: "%g" turns 20.0 into "20", which # reloads as an int and quietly changes the type of the setting. return repr(v) if isinstance(v, datetime.datetime | datetime.date | datetime.time): return v.isoformat() if isinstance(v, str): return _toml_string(v) 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"{_toml_key(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"[{_toml_key(key)}]") for sk, sv in scalars.items(): lines.append(f"{_toml_key(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"[{_toml_key(key)}.{_toml_key(sk)}]") for ssk, ssv in sv.items(): lines.append(f"{_toml_key(ssk)} = {_toml_value(ssv)}") else: lines.append("") lines.append(f"[{_toml_key(key)}]") for sk, sv in value.items(): lines.append(f"{_toml_key(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