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.enums import JoyAxis, JoyButton, Key, MouseButton, key_to_name, name_to_keys
from .input.events import MODIFIER_NAMES, InputBinding, key_combo_to_binding

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 _key_binding_from_name(name: str) -> InputBinding | None:
    """A key binding from a written key name, an enum name, or a modifier combo.

    Combos come last, so a key whose own name contains ``+`` still resolves to
    itself. Returns None when the text names no key at all.
    """
    keys = name_to_keys(name)
    if keys:
        return InputBinding(key=keys[0])
    try:
        return InputBinding(key=Key[name.upper()])
    except KeyError:
        return key_combo_to_binding(name)


def _modifiers_from_entry(action: str, entry: dict[str, Any]) -> list[str]:
    """The modifier flags an inline-table binding sets, e.g. ``["shift"]``."""
    held: list[str] = []
    for name in MODIFIER_NAMES:
        value = entry.get(name)
        if value is None:
            continue
        if not isinstance(value, bool):
            raise ValidationError(f"[input] {action}: {name} must be true or false")
        if value:
            held.append(name)
    return held


def _binding_from_entry(action: str, entry: Any) -> InputBinding:
    """Convert one [input] entry (string or dict) to an InputBinding."""
    if isinstance(entry, str):
        binding = _key_binding_from_name(entry)
        if binding is not None:
            return binding
        upper = entry.upper()
        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"
        )

    modifiers = _modifiers_from_entry(action, entry)
    if modifiers and key is None:
        raise ValidationError(f"[input] {action}: {'/'.join(modifiers)} only applies to a key binding")

    if key is not None:
        binding = _key_binding_from_name(str(key))
        if binding is None:
            raise ValidationError(f"[input] {action}: unknown key {key!r}")
        for name in modifiers:
            setattr(binding, name, True)
        return binding

    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 _key_binding_name(key: Key) -> str: """The name that reads back as exactly *key*. The display names share one spelling between the left and right modifiers ("shift" is both), and reading a shared name back picks the left one. Where that would change the binding, write the enum's own name instead, which :func:`_binding_from_entry` resolves unambiguously. """ name = key_to_name(key) matches = name_to_keys(name) if matches and matches[0] == key: return name return key.name.lower() def _binding_to_toml(binding: InputBinding) -> dict[str, Any]: """Serialise an InputBinding to an inline-table-friendly dict. A required modifier is written as its own flag (``{key = "tab", shift = true}``) rather than folded into the key name, so the key stays one field a reader can edit. :func:`_binding_from_entry` also accepts the combo spelling, which is what a hand-written file is likely to use. """ if binding.key is not None: key_entry: dict[str, Any] = {"key": _key_binding_name(binding.key)} for name in MODIFIER_NAMES: if getattr(binding, name): key_entry[name] = True return key_entry 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, # 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, }, "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 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") 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[InputBinding]] = { 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_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 --- #: 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