Source code for simvx.core.run

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