"""simvx.run(): unified launch function for SimVX projects.
Handles all launch modes:
import simvx
simvx.run() # Read simvx.toml, run its main file
simvx.run(MyNode) # Run specific class
simvx.run(MyNode, width=800) # With explicit settings
simvx.run(file="main.py") # Run a file, as ``python main.py`` would
A file with an ``if __name__ == "__main__":`` block is run as a script, exactly as
``python main.py`` runs it, so its own code picks the root node and the App
settings, as the web export does. A file without one is a bare scene: it is
imported and its root Node class is chosen for it (see
:func:`_resolve_class_from_module`). Naming a class always imports the file and
runs that class, main block or not.
"""
import ast
import importlib
import importlib.util
import inspect
import logging
import runpy
import sys
from pathlib import Path
from typing import Any
from .project import ProjectSettings, find_project, load_project
log = logging.getLogger(__name__)
__all__ = ["run"]
def _find_node_classes(module) -> list[tuple[str, type]]:
"""Find all Node subclass definitions in a module."""
from .node import Node
results = []
for name, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, Node) and obj is not Node and obj.__module__ == module.__name__:
results.append((name, obj))
return results
#: How the Python API names a root class; the CLI passes its own flag instead.
_API_CLASS_HINT = "simvx.run(file=..., class_name=...)"
def _resolve_class_from_module(
module, filename: str = "", class_name: str | None = None, *, hint: str = _API_CLASS_HINT
) -> type:
"""Resolve which Node subclass to run from a module.
Priority:
1. class_name if specified (a private, underscore-prefixed class included)
2. 'Main' class
3. Class matching filename (case-insensitive)
4. Sole public Node subclass: a name starting with ``_`` is a helper, never a root
5. Error, naming ``hint`` as the way to choose
"""
every = _find_node_classes(module)
if not every:
raise RuntimeError(f"No Node subclasses found in {filename or module.__name__}")
# 1. Explicit class name
if class_name:
class_map = dict(every)
if class_name in class_map:
return class_map[class_name]
raise RuntimeError(
f"Class {class_name!r} not found in {filename or module.__name__}. Available: {list(class_map)}"
)
classes = [(name, cls) for name, cls in every if not name.startswith("_")]
if not classes:
raise RuntimeError(
f"{filename or module.__name__} defines only private Node subclasses {[n for n, _ in every]}; "
f"name one with {hint}"
)
class_map = dict(classes)
# 2. 'Main' class
if "Main" in class_map:
return class_map["Main"]
# 3. Filename match (case-insensitive)
if filename:
stem = Path(filename).stem.lower()
for name, cls in classes:
if name.lower() == stem:
return cls
# 4. Sole class
if len(classes) == 1:
return classes[0][1]
# 5. Error
raise RuntimeError(
f"Multiple Node subclasses in {filename or module.__name__}, cannot determine which to run. "
f"Available: {list(class_map)}. Define a 'Main' class, or name one with {hint}."
)
def _has_main_block(path: Path) -> bool:
"""True when the file has a top-level ``if __name__ == "__main__":`` block."""
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in tree.body:
if not isinstance(node, ast.If) or not isinstance(node.test, ast.Compare):
continue
test = node.test
if len(test.ops) != 1 or not isinstance(test.ops[0], ast.Eq):
continue
sides = [test.left, *test.comparators]
names = [s for s in sides if isinstance(s, ast.Name) and s.id == "__name__"]
mains = [s for s in sides if isinstance(s, ast.Constant) and s.value == "__main__"]
if names and mains:
return True
return False
def _run_as_main(path: Path) -> None:
"""Run ``path`` as ``python path`` would: as ``__main__``, its directory first on the path.
The script sees ``sys.argv == [path]``, as it would under ``python path``, so one
that parses its own arguments is not handed the ``simvx run`` flags.
"""
directory = str(path.parent)
if directory not in sys.path:
sys.path.insert(0, directory)
saved_argv = sys.argv
sys.argv = [str(path)]
try:
runpy.run_path(str(path), run_name="__main__")
finally:
sys.argv = saved_argv
def _import_file(file_path: str) -> Any:
"""Import a game's entry file as a module, with its directory importable.
``python main.py`` puts the script's directory first on :data:`sys.path`, so
a game imports the modules beside it by plain name, at module level or later
from inside a method. Running the file here gives it the same path, for the
rest of the process, which belongs to that game.
"""
path = Path(file_path).resolve()
if not path.exists():
raise FileNotFoundError(f"File not found: {path}")
directory = str(path.parent)
if directory not in sys.path:
sys.path.insert(0, directory)
module_name = f"_simvx_run_{path.stem}"
spec = importlib.util.spec_from_file_location(module_name, str(path))
if spec is None or spec.loader is None:
raise ImportError(f"Cannot import {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
[docs]
def run(
node_class=None,
*,
width: int | None = None,
height: int | None = None,
title: str | None = None,
file: str | None = None,
class_name: str | None = None,
**overrides,
) -> None:
"""Run a SimVX application.
Args:
node_class: Node subclass to instantiate and run. If None, resolves from
simvx.toml or ``file`` parameter.
width: Window width override.
height: Window height override.
title: Window title override.
file: Python file to run. With an ``if __name__ == "__main__":`` block it
runs as a script, as ``python file`` does, and its own code sets the
App up; without one its root Node class is found and run.
class_name: Specific class name to run from the file/module; the file is
then imported and this class run, whether or not it has a main block.
**overrides: Additional App kwargs (vsync, physics_fps, etc.).
"""
_launch(node_class, width=width, height=height, title=title, file=file, class_name=class_name, **overrides)
def _launch(
node_class=None,
*,
width: int | None = None,
height: int | None = None,
title: str | None = None,
file: str | None = None,
class_name: str | None = None,
hint: str = _API_CLASS_HINT,
settings_hint: str = "width, height, title and App keyword arguments",
**overrides,
) -> None:
""":func:`run`, with the spelling its errors use for a class choice and for App settings."""
# Load project settings if available
settings: ProjectSettings | None = None
toml_path = find_project()
if toml_path:
settings = load_project(toml_path)
log.debug("run: loaded project %s from %s", settings.name, toml_path)
# Resolve the node class
if node_class is None:
source = file or (settings.resolve_path(settings.main) if settings and settings.main else None)
if source:
path = Path(source).resolve()
if not path.exists():
raise FileNotFoundError(f"File not found: {path}")
if class_name is None and _has_main_block(path):
if width is not None or height is not None or title is not None or overrides:
raise RuntimeError(
f"{path} sets up its own App in its __main__ block, so {settings_hint} do not reach "
f"it; set them in the file, or name a root class with {hint} to run it here instead"
)
if settings:
settings.apply_input()
_run_as_main(path)
return
module = _import_file(str(path))
node_class = _resolve_class_from_module(module, filename=str(path), class_name=class_name, hint=hint)
else:
raise RuntimeError(
"No node_class specified and no simvx.toml found (or no 'main' set). "
"Use simvx.run(MyNode) or create a simvx.toml with main = 'path/to/main.py'."
)
# Build App kwargs from settings + overrides
app_kwargs: dict[str, Any] = {}
if settings:
app_kwargs["width"] = settings.display.get("width", 1280)
app_kwargs["height"] = settings.display.get("height", 720)
app_kwargs["title"] = settings.name
app_kwargs["vsync"] = settings.display.get("vsync", True)
app_kwargs["physics_fps"] = settings.physics.get("fps", 60)
# Apply input actions
settings.apply_input()
# Explicit args override settings
if width is not None:
app_kwargs["width"] = width
if height is not None:
app_kwargs["height"] = height
if title is not None:
app_kwargs["title"] = title
app_kwargs.update(overrides)
# Lazy import graphics
from simvx.graphics import App
app = App(**app_kwargs)
app.run(node_class())