Source code for simvx.editor.refactor_extract
"""Extract classes from a single ``.py`` file into a sibling folder/package.
The "Extract classes to folder" refactoring takes a multi-class scene
file like ``src/things.py`` (containing classes ``Foo``, ``Bar``) and
splits it into:
* ``src/things/__init__.py``: re-exports each extracted class
* ``src/things/foo.py``: ``class Foo`` and its required imports
* ``src/things/bar.py``: ``class Bar`` and its required imports
Importers like ``from src.things import Foo`` keep working transparently
through Python's package resolution (the new ``__init__.py`` provides
the names).
Refusal cases (raise :class:`ExtractRefused`):
- The file has fewer than two top-level classes: extraction is a no-op.
- The file has top-level statements that are neither classes nor imports
(free functions, module-level state, etc.), these have nowhere clean
to land. The user must inline or relocate them before extraction.
- A folder of the target name already exists.
A module docstring is not one of those statements, though it is an expression
statement and was refused as one until 2026-09-07. See
:func:`is_module_docstring`.
"""
from __future__ import annotations
import ast
import logging
from dataclasses import dataclass
from pathlib import Path
from simvx.core.scene_io import ClassDefRef, find_class_definitions
from simvx.core.scene_io.source import SceneSyntaxError, SourceDocument, Statement, StatementKind
from simvx.core.scene_io.source import parse as parse_document
from .project_classes import ProjectClassIndex, _snake_case
log = logging.getLogger(__name__)
def _validate_no_unsupported_top_level(document: SourceDocument, file_path: Path) -> None:
"""Refuse if any top-level statement is neither a class nor an import.
Free functions, module-level state, and conditional/decorated
constructs all force the user to clean up first, the extracted
folder has no clean home for them.
"""
for index, stmt in enumerate(document.top_level_statements()):
kind = stmt.kind()
if kind is StatementKind.IMPORT:
continue
if is_module_docstring(stmt, index):
continue
if kind is StatementKind.COMPOUND:
keyword = block_keyword(stmt)
if keyword == "class":
continue
if keyword in ("def", "async def"):
raise ExtractRefused(
f"{file_path}:{stmt.line()}: top-level function "
f"{_function_name(stmt)!r} cannot be extracted; "
"move it into one of the classes or remove it before extracting"
)
raise ExtractRefused(
f"{file_path}:{stmt.line()}: top-level control-flow block cannot "
"be extracted; resolve manually before extracting"
)
raise ExtractRefused(
f"{file_path}:{stmt.line()}: unsupported top-level statement "
f"({kind}); only classes and imports may live in "
"extractable scene files"
)
[docs]
def class_block(cls: ClassDefRef) -> str:
"""One class as a standalone block: the comments written above it, then its source.
A comment above a class describes that class, so it moves with it into
whatever file the class lands in; the blank lines that spaced the class out
from what preceded it do not, because what precedes it is not coming along.
"""
lines = f"{cls.leading_trivia}{cls.text}".rstrip().split("\n")
while lines and not lines[0].strip():
lines.pop(0)
return "\n".join(lines) + "\n"
[docs]
def is_module_docstring(stmt: Statement, index: int) -> bool:
"""Is ``stmt`` the file's module docstring?
Python's own rule, so that these refactorings agree with the language: the
FIRST statement of the module, and a bare string literal. A string literal
anywhere else is a no-op expression rather than a docstring, and stays
subject to the ordinary side-effect refusal.
This exists because both refactorings classify statements by what they ARE,
which is the right question for a free function or module-level state and
the wrong one here: a docstring is an expression statement, so it tripped the
same branch as ``do_something()`` and made ``extract_to_folder`` refuse
nearly every hand-written file. The engine's own emitted scenes carry no
module docstring, which is why the test suite never reached it.
"""
if index != 0:
return False
try:
parsed = ast.parse(stmt.text().strip())
except SyntaxError: # pragma: no cover - the statement came from parsed source
return False
if len(parsed.body) != 1:
return False
node = parsed.body[0]
return isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)
[docs]
def block_keyword(stmt: Statement) -> str:
"""The keyword a block statement opens with, its decorators skipped.
``class``, ``def``, ``async def``, ``if``, ``for``, ``with``, ``try``,
``while``, ``match``: what the statement is, at the coarseness
:class:`~simvx.core.scene_io.source.StatementKind` deliberately does not
draw, since a block that holds a class and one that runs at import time are
the same kind of statement and not the same thing to a refactoring.
"""
for line in stmt.text().splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("@"):
continue
words = stripped.split()
if words[0] == "async" and len(words) > 1:
return f"async {words[1].split('(')[0].split(':')[0]}"
return words[0].split("(")[0].split(":")[0]
return ""
def _function_name(stmt: Statement) -> str:
"""The name a ``def`` statement binds, for a refusal to quote."""
text = stmt.text()
head = text[text.index("def ") + 4 :] if "def " in text else text
return head.split("(")[0].split(":")[0].strip() or "<?>"
def _emit_import_block(document: SourceDocument) -> str:
"""Concatenate every top-level ``import`` / ``from … import …`` line."""
parts = [imported.statement.text() for imported in document.imports()]
if not parts:
return ""
return "\n".join(parts) + "\n"