"""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)
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")
# --- 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())
# --- 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