"""Turn a node in the editor into a class of its own, and bind the scene to it.
A scene is Python, and a node's behaviour is its class, so giving a node
behaviour means giving it a class the scene file names. That is what this does,
end to end: write the class file, point the scene's own source at it, save, and
reload so the node in front of the user IS an instance of the new class -- with
whatever the template declared already built, the way it will be every time the
game runs.
Two shapes, according to which node was picked:
* **A child.** The class is written, the live node's class is rebound, and the
save carries the swap into the construction the file already has
(:mod:`~simvx.editor.scene_diff` repoints it where it stands rather than
deleting and re-emitting it, so the author's arguments, comments and any
attribute the statement binds all survive). The import goes in beside it.
* **The scene root.** There is no construction to repoint -- the root is the
class the file defines -- so that class is rebased instead: ``class
Arena(Node3D)`` becomes ``class Arena(ArenaLogic)`` where the new class
extends the base the file already had. What the file gives the root goes on
reaching the old base, because the new class stands between them and the
templates written here declare no ``__init__`` of their own. The rebase
itself moves one name and nothing else; the save that carries it is an
ordinary save of that scene, so a value the file already sets can end up set
twice -- once where the author set it, and again as a kwarg the save writes.
Anything the file cannot express is refused, and the refusal says what stopped
it (:class:`ConversionRefused`). Most of them are answered before a byte is
written: the file says something about its root class that this cannot rewrite
without guessing -- two bases, a base that is not a plain name, a base the
running scene does not agree with -- or the scene has never been saved, so there
is no source to bind to, or the node is deeper in the tree than a save writes,
which is the root and its own children, or the class file would be imported
under a name that already means something else in Python. The two that can only
be answered by trying -- a class file that will not import, and a save that
cannot point the scene at the class -- put the class file back as it was found
before they raise, along with the live node's class and the path entry the
import needed, so a refusal always leaves the process as the user left it.
"""
from __future__ import annotations
import ast
import importlib
import importlib.machinery
import importlib.util
import logging
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from simvx.core import Node, NodeNotFound
from simvx.core.scene_io import structural_type_name
from simvx.core.scene_io.detection import AmbiguousSceneError, primary_node_class_from_source
from simvx.core.scene_io.scene_file import ImportSet, SceneFile
from simvx.core.scene_io.scene_module import SceneModule
from simvx.core.scene_io.source_tree import parse_source
from .project import read_project_settings
from .scene_diff import DESTRUCTIVE, ReportEntry
from .templates import TEMPLATES, generate_script
if TYPE_CHECKING:
from .scene_file_ops import ScenePlan
from .state import State
log = logging.getLogger(__name__)
__all__ = [
"Conversion",
"ConversionRefused",
"EMPTY_TEMPLATE",
"class_files_dir",
"convert_node_to_class",
"default_class_file",
"templates_for",
]
#: The template that writes a class and nothing else. Named rather than spelled
#: ``None`` so a dialog can offer it in the same list as the others.
EMPTY_TEMPLATE = "Empty"
_PROJECT_FILE = "simvx.toml"
[docs]
class ConversionRefused(Exception):
"""The conversion did not happen, and this says what stopped it.
Nothing of it is on disk when this is raised: the questions the file's own
text can answer are asked before anything is written, and the two that can
only be answered by trying -- a class file that will not import, a save that
cannot point the scene at the class -- put that file back as they found it,
the scene never having been written at all. :attr:`entry` is the same
:class:`~simvx.editor.scene_diff.ReportEntry` a save would have used to say
so, categorised :data:`~simvx.editor.scene_diff.DESTRUCTIVE`, because the
only way past it is for the user to change something themselves.
"""
def __init__(self, message: str) -> None:
super().__init__(message)
#: The refusal as a report entry, for a caller that collects them.
self.entry = ReportEntry(message, DESTRUCTIVE)
[docs]
@dataclass(frozen=True)
class Conversion:
"""What one conversion did, once the scene has been saved and reloaded."""
#: The class that was written.
class_name: str
#: The file it was written to, which is the scene file itself when the user
#: asked for it there.
source_path: Path
#: The dotted module the scene file imports it from, or ``None`` when the
#: scene file defines it and needs no import.
module_path: str | None
#: The live node after the reload: an instance of the new class for a child
#: conversion, and the rebased scene root for a root one. Checked against the
#: class before this is returned, so a node that is neither is a
#: :data:`~simvx.editor.scene_diff.DESTRUCTIVE` entry of :attr:`report` and
#: never a silent one.
node: Node | None
#: Whether the scene class was rebased (a root conversion) rather than a
#: construction retyped.
rebased: bool
#: What the save that carried the conversion could not do beyond it. Empty
#: for the ordinary case; a caller showing these shows them after the fact,
#: since the conversion is already on disk by then.
report: tuple[ReportEntry, ...] = ()
# ---------------------------------------------------------------------------
# Where a class file goes, and which templates suit a node
# ---------------------------------------------------------------------------
[docs]
def class_files_dir(state: State) -> str:
"""The project's ``[editor] class_files_dir``, or ``src`` when it has none."""
settings = getattr(state, "settings", None)
editor = getattr(settings, "editor", None)
if isinstance(editor, dict) and isinstance(editor.get("class_files_dir"), str):
return str(editor["class_files_dir"]) or "src"
project = getattr(state, "project_path", None)
if project is not None:
settings, _error = read_project_settings(Path(project) / _PROJECT_FILE)
value = settings.editor.get("class_files_dir", "src")
if isinstance(value, str) and value:
return value
return "src"
[docs]
def default_class_file(state: State, class_name: str) -> Path | None:
"""Where a class of this name would go, following the project's own layout.
``<project>/<class_files_dir>/<snake_case>.py``, which is where the rest of
the editor puts user classes and where its class index looks for them.
``None`` for a session with no project open, which is a session with nowhere
to put one.
"""
project = getattr(state, "project_path", None)
if project is None or not class_name:
return None
from .make_custom_class_dialog import snake_case
return Path(project) / class_files_dir(state) / f"{snake_case(class_name)}.py"
[docs]
def templates_for(node: Node) -> list[str]:
"""The template names worth offering for ``node``, most specific first.
A template is written against a base class, and only one whose base the node
already descends from produces code that runs: the 2D character template
reads ``is_on_floor`` and moves a ``Vec2``, which a ``Node3D`` has no answer
for. :data:`EMPTY_TEMPLATE` leads the list, so the default stays a class
with nothing in it.
"""
ancestors = {cls.__name__ for cls in type(node).__mro__}
matches = [
name for name, template in TEMPLATES.items() if name != EMPTY_TEMPLATE and template.base_class in ancestors
]
matches.sort(key=lambda name: _inheritance_depth(node, TEMPLATES[name].base_class), reverse=True)
return [EMPTY_TEMPLATE, *matches]
def _inheritance_depth(node: Node, base_name: str) -> int:
"""How far down ``node``'s own ancestry ``base_name`` sits, for ordering."""
names = [cls.__name__ for cls in type(node).__mro__]
return len(names) - names.index(base_name) if base_name in names else 0
# ---------------------------------------------------------------------------
# The conversion
# ---------------------------------------------------------------------------
[docs]
def convert_node_to_class(
state: State,
node: Node,
class_name: str,
*,
destination: Path | None = None,
template: str | None = None,
) -> Conversion:
"""Give ``node`` a class of its own and bind the scene's source to it.
``destination`` is the file the class is written to, defaulting to
:func:`default_class_file`. A destination that already exists gains the
class at module scope with its imports merged, which is how a class lands
inside the scene file itself; anything else is written as a new module.
``template`` names an entry of :data:`~simvx.editor.templates.TEMPLATES`
whose body the class is written with, rendered against the node's own class
rather than the template's declared base, so a ``CharacterBody3D`` converted
with the plain 3D template still extends ``CharacterBody3D``. The default
(:data:`EMPTY_TEMPLATE`) writes a class with nothing in it.
The scene is saved through the plan/commit path and then reloaded, so what
comes back is what the file says -- a node built by running the new class's
``__init__``, declared children and all. The reload is also why this returns
a node rather than mutating the one it was given: the tree in front of the
user afterwards is a different one.
That the binding reached the file is checked rather than assumed, and twice:
the save is read before it is committed (:func:`_would_build_it`) and
refused, with the class file put back as it was found, when the scene would
go on constructing the node as it always did; and the node the reload
produced is compared against the class that was written
(:func:`_is_bound_to`), which is the end-to-end answer. A conversion that
did not bind never returns quietly.
Raises :class:`ConversionRefused` for anything the file could not express,
with the message naming what stopped it and the project as the user left it:
everything written between the class file and the scene's own save is put
back by whatever refuses, and the scene is not written until every question
has been answered.
"""
root = state.edited_scene.root if state.edited_scene else None
if root is None:
raise ConversionRefused("There is no scene open to convert a node in.")
if not class_name.isidentifier():
raise ConversionRefused(f"{class_name!r} is not a Python class name.")
scene_path = Path(state.current_scene_path) if state.current_scene_path else None
if scene_path is None or not scene_path.exists():
raise ConversionRefused(
"This scene has never been saved, so there is no source to bind the class to. Save it first."
)
is_root = node is root
if not is_root and node.parent is not root:
raise ConversionRefused(
f"A save writes the scene root's own children into `__init__`, and {node.name!r} is deeper in the "
"tree, so the class would be written and the scene would go on building the type it has. Convert a "
"top-level node, or the scene root."
)
base_name = structural_type_name(node)
if is_root:
# Asked before anything is written: the rebase is the part of a root
# conversion the file has to be able to express.
base_name = _rebase_target(scene_path, root)
pending = state.plan_save(scene_path)
if pending is not None and pending.destructive:
raise ConversionRefused(
f"Saving {scene_path.name} would change more than this scene: "
f"{pending.report[0]} Save the scene and answer that first."
)
target = destination or default_class_file(state, class_name)
if target is None:
raise ConversionRefused("This session has no project open, so there is nowhere to put a class file.")
target = Path(target)
if _defines_class(target, class_name):
raise ConversionRefused(f"{target.name} already defines a class called {class_name}.")
project = _project_key(state)
# Before a name is asked about, since a root pinned for the project the user
# has since closed would answer for files this one has never seen.
_forget_pins_outside(project)
module_path, module_root = _module_and_root(state, target, scene_file=scene_path)
if module_path is not None:
taken = _module_name_taken_by(module_path, target)
if taken is not None:
raise ConversionRefused(
f"{target.name} would be imported as `{module_path}`, and Python already has a module of that name "
f"({taken}). Importing this one under it would replace that module for everything else running, and "
f"`from {module_path} import {class_name}` in {scene_path.name} would mean one or the other "
"depending on how the game was started. Call the class something else, or put it in a file whose "
"name nothing else has taken."
)
base_module = _base_import(node, base_name, target)
if base_module is not None and not _importable(base_module):
raise ConversionRefused(
f"{node.name!r} is a {base_name}, and no file can import that class: it lives in `{base_module}`, which "
f"is not a module on the path -- a class the scene file itself defines answers to a name only the "
f"process that read that file knows. Write {class_name} into the scene file too, or give {base_name} a "
"file of its own first."
)
if is_root and _rebased_text(scene_path, class_name, base_name, module_path) is None:
raise ConversionRefused(_rebase_refusal(scene_path, class_name))
was = type(node)
before = target.read_text(encoding="utf-8") if target.exists() else None
source = _with_base_import(
generate_script(template or EMPTY_TEMPLATE, class_name, base_class=base_name), base_name, base_module
)
node_path = None if is_root else _path_within(root, node)
plan: ScenePlan | None = None
pinned: Path | None = None
imported: str | None = None
# Everything from here to the save is undone by whatever goes wrong: the
# class file is written, the live node retyped and the class file's root put
# on the path along the way, and a refusal that left any of them standing
# would be one that changed the project.
try:
_write_class_file(target, class_name, base_name, source, base_module)
if module_root is not None and _pin(module_root, project):
pinned = module_root
if module_path and module_path not in sys.modules:
imported = module_path
new_class = _import_class(target, class_name, module_path)
if not is_root:
node.__class__ = new_class
plan = state.plan_save(scene_path)
if not is_root and not _would_build_it(plan, class_name):
# The scene file would come back with the node built as it always
# was: a conversion that does not bind is no conversion.
for entry in plan.report if plan is not None else ():
log.error("%s: %s", scene_path.name, entry)
raise ConversionRefused(
f"`__init__` builds {node.name!r} with a line this save cannot point at {class_name} -- a factory, a "
f"name bound elsewhere, or a construction it would have to take other statements out to rewrite -- "
f"so {scene_path.name} would go on building it as {was.__name__}. Nothing was written; build "
f"that node with {class_name} by hand, or convert one the file constructs where it stands."
)
except BaseException:
node.__class__ = was
_unwrite(target, before)
if imported is not None:
# The file is going; the module it was read into goes with it.
# Left standing it is a module with no file, and the next
# conversion of that name -- the user answering the refusal by
# trying again -- is refused as a name something else has taken.
sys.modules.pop(imported, None)
if pinned is not None:
_unpin(pinned)
raise
report: list[ReportEntry] = []
if plan is not None:
state.commit_save(plan)
report = list(plan.report)
if is_root:
# Asked again of the file the save has just written, since that is the
# text being rebased; the question was settled before anything was
# written, so this is the answer to a file changed underfoot.
rebased = _rebased_text(scene_path, class_name, base_name, module_path)
if rebased is None:
report.append(ReportEntry(_rebase_refusal(scene_path, class_name), DESTRUCTIVE))
else:
scene_path.write_text(rebased, encoding="utf-8")
live = _reload(state, scene_path, node_path)
if not _is_bound_to(live, class_name):
report.append(
ReportEntry(
f"{scene_path.name} was written and loads back with {node.name!r} as "
f"{structural_type_name(live) if live is not None else 'nothing at all'}, not as {class_name}; the "
f"class file is on disk, and the scene has to be pointed at it by hand.",
DESTRUCTIVE,
)
)
return Conversion(
class_name=class_name,
source_path=target,
module_path=module_path,
node=live,
rebased=is_root,
report=tuple(report),
)
# ---------------------------------------------------------------------------
# Did it bind?
# ---------------------------------------------------------------------------
def _would_build_it(plan: ScenePlan | None, class_name: str) -> bool:
"""Would the save this plan holds leave the scene constructing ``class_name``?
Asked of the text the plan would write, before it writes it, because a
conversion that does not reach the construction is a conversion that did
nothing: the class file is on disk, the node in the editor answers to the
new class until the next reload, and the scene goes on building what it
always built. The save has every reason to decline -- a child built by a
factory, in a loop, from a name bound elsewhere -- and says so in its
report; what it cannot do is let the conversion call that success.
A plan of ``None`` writes nothing at all, which is the same answer.
"""
if plan is None:
return False
return _constructs(_planned_source(plan), class_name)
def _planned_source(plan: ScenePlan) -> str:
"""The text the plan would write, read while the file is still untouched."""
document = plan._document
return document.root.dump() if isinstance(document, SceneModule) else document.dump()
def _constructs(source: str, class_name: str) -> bool:
"""Does ``source`` call ``class_name`` anywhere, which is how a node is built?"""
try:
module = ast.parse(source)
except SyntaxError:
return False
return any(
isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == class_name
for node in ast.walk(module)
)
def _is_bound_to(node: Node | None, class_name: str) -> bool:
"""Is the node the reload produced one that ``class_name`` had a hand in building?
Its own class for a child conversion, and an ancestor of the scene's class
for a root one, which is rebased rather than retyped.
"""
return node is not None and any(cls.__name__ == class_name for cls in type(node).__mro__)
# ---------------------------------------------------------------------------
# Writing the class file
# ---------------------------------------------------------------------------
def _unwrite(target: Path, before: str | None) -> None:
"""Put the class file back as it was found, for a conversion that got no further.
``before`` is the text the file held, or ``None`` for one this conversion
created, which is then taken away again -- and the bytecode that importing
it left behind with it, so the directory is as the user left it. A file that
already holds what it held is not written at all: the refusal being unwound
may be the write itself failing, and rewriting a file that could not be
written raises again, over the refusal that says what happened.
Nothing here is allowed to become the exception the caller sees, since that
is the one that says what stopped the conversion; a failure to undo is
logged, because a project left changed by a refusal is worth knowing about.
"""
try:
if before is not None:
if not target.exists() or target.read_text(encoding="utf-8") != before:
target.write_text(before, encoding="utf-8")
return
target.unlink(missing_ok=True)
Path(importlib.util.cache_from_source(str(target))).unlink(missing_ok=True)
except OSError:
log.exception("Could not put %s back as it was found", target)
def _write_class_file(target: Path, class_name: str, base_name: str, source: str, base_module: str | None) -> None:
"""Put the rendered class on disk, in a file of its own or beside what is there.
An :class:`OSError` is the one thing here that says nothing about the code
and everything about the machine -- a read-only file, a directory that
cannot be made -- and it is turned into a refusal like any other so the
caller has one exception to answer and the user a sentence to read.
"""
try:
if target.exists():
_insert_into(target, class_name, base_name, source, base_module)
else:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(source, encoding="utf-8")
except OSError as exc:
raise ConversionRefused(f"{target.name} could not be written: {exc.strerror or exc}.") from exc
def _base_import(node: Node, base_name: str, destination: Path) -> str | None:
"""Where the class the new one extends is imported from, or ``None`` for none.
The templates are written against the engine's own classes and spell the
import ``from simvx.core import <base>``, which is right for every class
they were written for and wrong for a base of the user's own: a node
converted once already has a class that lives in a file of their project,
and a second conversion extends *that*. The class the node really has is
what says where it comes from -- an engine class collapses to the
``simvx.core`` umbrella the rest of the editor imports through, and any
other class names the module it was imported under, which is the module the
scene file names it by too.
``None`` when the file being written to defines that class itself, which is
a class already in scope there: importing a name from the module that
defines it is a file that will not load.
"""
if _defines_class(destination, base_name):
return None
for cls in type(node).__mro__:
if cls.__name__ == base_name:
module = cls.__module__ or "simvx.core"
return "simvx.core" if module.startswith("simvx.core") else module
return "simvx.core"
def _with_base_import(source: str, base_name: str, base_module: str | None) -> str:
"""The rendered class, importing its base from where that base really lives.
The template's own line stands for a base out of ``simvx.core``; anything
else is written in its place, and a base the destination defines itself
needs no line at all (:func:`_base_import`). The new line goes in before the
old one goes out, so it lands in the import block rather than above the
module's docstring, which is where a file with no imports left takes one.
"""
if base_module == "simvx.core":
return source
tree = parse_source(source)
imports = ImportSet(tree)
if base_module is not None:
imports.ensure(base_name, from_=base_module)
imports.remove(base_name, from_="simvx.core")
return tree.dump()
def _importable(module_path: str) -> bool:
"""Would a game started tomorrow find this module, or only this process?
:data:`sys.modules` cannot answer it: a scene file is executed under a
private name of the loader's, so a class defined in one answers to a module
that resolves in the process that read that file and in no other. What is on
the path is what a class file may import from, and that is what this asks.
"""
locations: list[str] | None = None
for part in module_path.split("."):
try:
spec = importlib.machinery.PathFinder.find_spec(part, locations if locations is not None else sys.path)
except (ImportError, ValueError):
return False
if spec is None:
return False
locations = list(spec.submodule_search_locations or [])
return True
def _defines_class(path: Path, class_name: str) -> bool:
"""Does ``path`` already define a top-level class of this name?"""
if not path.exists():
return False
try:
return parse_source(path.read_text(encoding="utf-8")).find_class(class_name) is not None
except (OSError, UnicodeDecodeError):
return False
def _insert_into(path: Path, class_name: str, base_name: str, source: str, base_module: str | None) -> None:
"""Add the rendered class to an existing file at module scope.
The class goes in above the file's own scene class, which is where a class
the scene uses has to be, and the imports the template needs are merged into
the ones already there rather than repeated.
Unless the file defines the base itself, which is what a second conversion
of a node whose class already lives there writes: a class statement is run
where it stands, so a class written above the one it extends is a module
that raises ``NameError`` the first time anything imports it -- in another
process, or in this one after a restart, which is exactly what the scene
file's import has to survive. It goes after its base then: before whatever
class follows the base, or at the end of the file when the base is the last
class in it, which is a placement ``insert_top_level_class`` has no anchor
for and this writes itself.
The base's own import is written last and by hand, because
``insert_top_level_class`` writes ``from simvx.core import <base>`` for
whatever base it is given -- right for the engine's classes and wrong for a
node already on a class of the user's own, whose file that line cannot
import at all (:func:`_base_import` says where it really lives, and a base
this very file defines needs no line).
A scene file is asked afterwards whether it still names the scene it named
before, and refused if it does not: a scene is the one node class in its
file that has an ``__init__`` or Properties of its own, so a template
carrying either becomes a second answer to that question and the file stops
loading as a scene at all. Nothing is written when that happens.
"""
original = path.read_text(encoding="utf-8")
was = _primary_class(original)
order = _class_order(original)
scene_file = SceneFile.load(path)
for module, names in _imports_of(source).items():
for name in names:
if not scene_file.imports.has_any_alias(name):
scene_file.imports.ensure(name, from_=module)
append = False
if base_name not in order:
scene_file.insert_top_level_class(class_name, base_name, body=_class_body(source, class_name))
else:
# Both this index and the primitive's anchor resolve BY NAME, first
# match, so a duplicated top-level name would land the class above its
# base again. Index from the base's LAST occurrence, and append outright
# whenever the chosen anchor's name is not unique in the file.
last_base = len(order) - 1 - order[::-1].index(base_name)
follows = order[last_base + 1 :]
if follows and order.count(follows[0]) == 1:
scene_file.insert_top_level_class(
class_name, base_name, body=_class_body(source, class_name), before=follows[0]
)
else:
append = True
if base_module != "simvx.core":
scene_file.imports.remove(base_name, from_="simvx.core")
if base_module is not None:
scene_file.imports.ensure(base_name, from_=base_module)
text = scene_file.dump()
if append:
text = text.rstrip("\n") + "\n\n\n" + _class_statement(source, class_name, base_name)
if was is not None and _primary_class(text) != was:
raise ConversionRefused(
f"{path.name} defines the scene `{was}`, and a class with Properties or an `__init__` of its own is a "
f"second scene class in that file, which the loader cannot tell from `{was}`; put {class_name} in a "
"file of its own, or convert with the Empty template."
)
path.write_text(text, encoding="utf-8")
def _primary_class(source: str) -> str | None:
"""The one scene class ``source`` names, or ``None`` when it names none or many."""
try:
return primary_node_class_from_source(source)
except AmbiguousSceneError:
return None
def _imports_of(source: str) -> dict[str, list[str]]:
"""``{module: [name, ...]}`` for the ``from x import y`` lines of a template."""
out: dict[str, list[str]] = {}
for statement in ast.parse(source).body:
if isinstance(statement, ast.ImportFrom) and statement.module:
out.setdefault(statement.module, []).extend(alias.name for alias in statement.names)
return out
def _class_body(source: str, class_name: str) -> str:
"""The body of the rendered class, indented for ``insert_top_level_class``.
That primitive writes ``class <name>(<base>):`` and one indent, so the first
line arrives without its own and every line after it keeps the one the
template gave it.
"""
lines = source.splitlines()
for index, line in enumerate(lines):
if line.startswith(f"class {class_name}("):
body = "\n".join(lines[index + 1 :]).rstrip()
return body.lstrip() if body.strip() else "pass"
return "pass"
def _class_statement(source: str, class_name: str, base_name: str) -> str:
"""The rendered class as it stands, for a file it is written to the end of."""
lines = source.splitlines()
for index, line in enumerate(lines):
if line.startswith(f"class {class_name}("):
return "\n".join(lines[index:]).rstrip() + "\n"
return f"class {class_name}({base_name}):\n pass\n"
def _class_order(source: str) -> list[str]:
"""The names of ``source``'s top-level classes, in the order it defines them."""
try:
module = ast.parse(source)
except SyntaxError:
return []
return [statement.name for statement in module.body if isinstance(statement, ast.ClassDef)]
# ---------------------------------------------------------------------------
# Importing what was written
# ---------------------------------------------------------------------------
def _module_and_root(state: State, target: Path, *, scene_file: Path) -> tuple[str | None, Path | None]:
"""The dotted module the scene file must import the class from, and where from.
``(None, None)`` when the class was written into the scene file itself,
which imports nothing: the class is already in scope there, and a file
importing its own class from its own module does not load.
Otherwise the path the rest of the editor uses for project classes -- dotted
and relative to ``[editor] class_files_dir`` where the file is under it,
relative to the project root where it is merely inside the project, and the
bare module name for a file outside both -- with the root it is relative to,
which the caller puts on :data:`sys.path` (:func:`_pin`), since the editor,
the play mode and the game all reach the class by importing that name. That
is the caller's to do and not this function's, and it waits until the name
has been shown to be free (:func:`_module_name_taken_by`): a root pinned
first would make the new file answer to the name itself and hide whatever it
was about to shadow.
"""
if target.resolve() == scene_file.resolve():
return None, None
project = getattr(state, "project_path", None)
roots: list[Path] = []
if project is not None:
roots.append(Path(project) / class_files_dir(state))
roots.append(Path(project))
roots.append(target.parent)
for root in roots:
try:
relative = target.resolve().relative_to(root.resolve())
except (OSError, ValueError):
continue
parts = list(relative.with_suffix("").parts)
if parts and parts[-1] == "__init__":
parts.pop()
return ".".join(parts), root
return target.stem, target.parent
@dataclass(frozen=True)
class _Pin:
"""One directory this module put on :data:`sys.path`, and who for."""
#: The project the pin was made for, or ``""`` for a session with none. A
#: pin describes one project's layout, so it ends when that project does.
project: str
#: Whether this module is what put the entry on the path. A root already
#: there -- a project run from its own directory, a path the user set -- is
#: not ours to take off again.
inserted: bool
#: Every directory this module has put on :data:`sys.path` for a class file, by
#: path entry. A module found under one of them is the open project's own class
#: file, which a conversion may write beside and import over; anything else
#: answering to the name is something it would be shadowing
#: (:func:`_module_name_taken_by`), and so is anything the standard library
#: answers to, pin or no pin.
_PINNED: dict[str, _Pin] = {}
def _project_key(state: State) -> str:
"""What a pin belongs to: the open project's root, or ``""`` for none."""
project = getattr(state, "project_path", None)
return str(Path(project).resolve()) if project is not None else ""
def _pin(directory: Path, project: str) -> bool:
"""Put ``directory`` on :data:`sys.path` for ``project``; did this do it?
``False`` for a directory already pinned for this project, so a caller
unwinding a refusal takes off only what its own conversion put on.
"""
entry = str(directory.resolve())
if entry in _PINNED:
return False
_PINNED[entry] = _Pin(project, inserted=entry not in sys.path)
if entry not in sys.path:
sys.path.insert(0, entry)
return True
def _unpin(directory: Path) -> None:
"""Take ``directory`` back off :data:`sys.path`, if this is what put it there."""
_unpin_entry(str(directory.resolve()))
def _unpin_entry(entry: str) -> None:
pin = _PINNED.pop(entry, None)
if pin is not None and pin.inserted:
while entry in sys.path:
sys.path.remove(entry)
def _forget_pins_outside(project: str) -> None:
"""End every pin made for another project, before this one asks about a name.
A pin is a claim about one project's layout -- these directories hold that
project's classes -- and opening another project ends it. Left standing, the
last project's ``src`` goes on answering imports in this one, and
:func:`_is_ours` goes on vouching for files in it, so a name that is really
taken reads as free.
"""
for entry, pin in list(_PINNED.items()):
if pin.project != project:
_unpin_entry(entry)
def _module_name_taken_by(module_path: str, target: Path) -> str | None:
"""What already answers to ``module_path``, when that is not a file of ours.
A class file is imported by the name the scene file will name it by, and
that name is not the project's to choose freely -- it is Python's whole
namespace. A class called ``Json`` goes to ``src/json.py`` and would be
imported as ``json``, which is the standard library's: importing it under
that name replaces the module everything else in the process is using, and
the scene's ``from json import Json`` afterwards means one module or the
other depending on how the game was started.
So the name is asked about before anything is written and before the root it
would be found under is pinned, which is what would otherwise make the new
file the answer. ``None`` when nothing answers to it, or when what does is
the file being written or another under a root this module has pinned --
a project's own class file, which is what a second conversion writes beside.
A name the standard library has is answered first and without consulting the
pins at all: a project that already holds ``src/json.py`` -- written by
hand, or by another of the editor's flows -- would otherwise have that file
vouched for as its own, and importing it as ``json`` is the very thing this
refuses. No layout makes a stdlib name free to take.
"""
top = module_path.split(".")[0]
if top in sys.stdlib_module_names:
live = sys.modules.get(top)
return getattr(live, "__file__", None) or f"the standard library's own `{top}`"
module = sys.modules.get(top)
if module is not None:
origin = getattr(module, "__file__", None)
places = [origin] if origin else list(getattr(module, "__path__", []) or [])
else:
try:
spec = importlib.util.find_spec(top)
except (ImportError, ValueError):
return None
if spec is None:
return None
places = (
[spec.origin]
if spec.origin not in (None, "built-in", "frozen")
else list(spec.submodule_search_locations or [])
)
if not places:
return "built into Python"
foreign = [place for place in places if not _is_ours(place, target)]
return foreign[0] if foreign else None
def _is_ours(origin: str, target: Path) -> bool:
"""Is this the class file being written, or anything under a root we pinned?
A directory as readily as a file: a name may answer to a package, and the
``actors`` a second conversion into ``actors/`` finds is the one the first
put there. Only asked of names the standard library does not have, which
:func:`_module_name_taken_by` settles before it gets here: a pinned root
says a file belongs to the project, never that the name it would take is
the project's.
"""
place = Path(origin).resolve()
if place == target.resolve():
return True
return any(place.is_relative_to(Path(root)) for root in _PINNED)
def _import_class(target: Path, class_name: str, module_path: str | None) -> type:
"""Import the class that was just written, under the name the file will use.
The module name matters as much as the class: a save writes the import from
``type(node).__module__``, so a class imported under a name only this
process knows leaves the scene file with an import that resolves nowhere
else. A class written into the scene file itself is imported privately,
since the file names it directly and no import of it is ever written.
Running the file is the first time anything has run it, so this is where a
class file that cannot be imported at all says so, whatever the reason: it
is a refusal like any other, and the caller puts the file back.
"""
importlib.invalidate_caches()
if module_path:
try:
module = sys.modules.get(module_path)
module = importlib.reload(module) if module is not None else importlib.import_module(module_path)
except Exception as exc:
raise ConversionRefused(f"{target.name} could not be imported as `{module_path}`: {exc}") from exc
else:
module = _import_privately(target)
new_class = getattr(module, class_name, None)
if not isinstance(new_class, type) or not issubclass(new_class, Node):
raise ConversionRefused(f"{target.name} does not define {class_name} as a node class.")
return new_class
def _import_privately(path: Path):
"""Execute ``path`` under a name of this module's own, for the class in it."""
name = f"_simvx_convert_{path.stem}_{id(path)}"
spec = importlib.util.spec_from_file_location(name, str(path))
if spec is None or spec.loader is None:
raise ConversionRefused(f"{path.name} could not be imported.")
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
try:
spec.loader.exec_module(module)
except Exception as exc:
sys.modules.pop(name, None)
raise ConversionRefused(f"{path.name} could not be imported: {exc}") from exc
return module
# ---------------------------------------------------------------------------
# Rebasing the scene's own class
# ---------------------------------------------------------------------------
def _rebase_target(scene_path: Path, root: Node) -> str:
"""The base the scene class is written with, which the new class takes over.
Converting the root does not retype a construction -- the root is the class
the file defines -- so the new class goes between that class and its base,
which only works while the file says plainly what that base is. Refuses,
naming why, when it does not: a class with two bases has no single place to
stand, a base written as anything but a plain name (``simvx.core.Node3D``,
a call, a subscript) is not a name to hand to the new class, and a base the
running scene does not agree with means the file and the editor are looking
at different scenes.
"""
classdef = _scene_classdef(scene_path)
if classdef is None:
raise ConversionRefused(f"{scene_path.name} does not define a scene class this can rebase.")
if len(classdef.bases) != 1 or classdef.keywords:
spelled = ", ".join(ast.unparse(base) for base in classdef.bases) or "nothing"
raise ConversionRefused(
f"`class {classdef.name}({spelled})` has no single base for the new class to take the place of, "
"so this save would have to guess which one to rebase; edit the class statement by hand."
)
base = classdef.bases[0]
if not isinstance(base, ast.Name):
raise ConversionRefused(
f"`class {classdef.name}({ast.unparse(base)})` names its base as an expression rather than a plain "
"name, which this cannot rewrite without changing what the file means; edit it by hand."
)
live_bases = {cls.__name__ for cls in type(root).__bases__}
if base.id != structural_type_name(root) and base.id not in live_bases:
raise ConversionRefused(
f"{scene_path.name} builds `{classdef.name}` from `{base.id}` and the scene now holds "
f"`{structural_type_name(root)}`; save the scene first so the file and the editor agree."
)
return base.id
def _scene_classdef(scene_path: Path) -> ast.ClassDef | None:
"""The scene file's own class statement, read as syntax."""
try:
module = ast.parse(scene_path.read_text(encoding="utf-8"))
except (OSError, SyntaxError, UnicodeDecodeError):
return None
name = SceneFile.load(scene_path).scene_class().name
for statement in module.body:
if isinstance(statement, ast.ClassDef) and statement.name == name:
return statement
return None
def _rebased_text(scene_path: Path, class_name: str, base_name: str, module_path: str | None) -> str | None:
"""The file with its own class rebased onto the new one, and the import added.
The one name in the class statement moves; everything else in the file --
the root's kwargs among them -- stays exactly as it is, and goes on reaching
the old base through the new class, which extends it.
Which spelling lands is the loader's answer, not this module's guess. A
scene is recognised by the engine class its own class statement names, so
``class Arena(ArenaLogic)`` alone can leave a file the loader no longer sees
a scene in; when it does, the engine base stays in the list beside the new
class (``class Arena(ArenaLogic, Node3D)``), which says the same thing about
the type and keeps the file loadable. The single-base form is tried first,
so the day a base a file cannot see becomes visible to the loader, that is
what gets written.
Nothing is written here, so the caller may ask before it has written
anything either: the answer turns on the class statement alone, which a save
of the scene does not touch, so the answer this gives before the save is the
answer after it. ``None`` when no spelling leaves a file the loader still
reads as this scene (:func:`_rebase_refusal`).
"""
scene_name = SceneFile.load(scene_path).scene_class().name
for spelling in (class_name, f"{class_name}, {base_name}"):
text = _rebased_source(scene_path, base_name, spelling, class_name, module_path)
if _primary_class(text) == scene_name:
return text
return None
def _rebase_refusal(scene_path: Path, class_name: str) -> str:
"""Why a rebase that cannot be spelled leaves the scene class where it is."""
scene_name = SceneFile.load(scene_path).scene_class().name
return (
f"Rebasing `{scene_name}` onto `{class_name}` leaves {scene_path.name} with no class the loader reads as a "
"scene, so nothing was changed."
)
def _rebased_source(scene_path: Path, base_name: str, spelling: str, class_name: str, module_path: str | None) -> str:
"""The file's text with the scene class's base list written as ``spelling``."""
scene_file = SceneFile.load(scene_path)
scene_class = scene_file.scene_class()
for leaf in scene_class.node.children:
if getattr(leaf, "type", None) == "name" and leaf.value == base_name:
leaf.value = spelling
break
if module_path:
scene_file.imports.ensure(class_name, from_=module_path)
text = scene_file.dump()
if base_name not in _names_used(text):
scene_file.imports.remove(base_name)
text = scene_file.dump()
return text
def _names_used(source: str) -> set[str]:
"""Every name the file mentions outside its own import statements."""
used: set[str] = set()
module = ast.parse(source)
for node in ast.walk(module):
if isinstance(node, ast.Import | ast.ImportFrom):
continue
if isinstance(node, ast.Name):
used.add(node.id)
elif isinstance(node, ast.Attribute):
used.add(node.attr)
return used
# ---------------------------------------------------------------------------
# Reloading
# ---------------------------------------------------------------------------
def _path_within(root: Node, node: Node) -> str | None:
"""``node``'s path relative to ``root``, for finding it again after a reload."""
parts: list[str] = []
current: Node | None = node
while current is not None and current is not root:
parts.append(current.name)
current = current.parent
if current is not root:
return None
return "/".join(reversed(parts))
def _reload(state: State, scene_path: Path, node_path: str | None) -> Node | None:
"""Read the scene back off disk and put the selection back on the node.
The point of the reload: the node the user converted is now built by running
the class they created, so it holds whatever that class declares -- the
collider a character template brings with it, the Properties it exposes --
exactly as it will when the game runs. The one the editor had before was the
old node with a new class label on it.
"""
state.open_scene(scene_path)
root = state.edited_scene.root if state.edited_scene else None
if root is None:
return None
live: Node | None = root
if node_path:
try:
live = root.node_at(node_path)
except NodeNotFound:
return None
state.selection.select(live)
return live