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__)


[docs] class ExtractRefused(ValueError): """Raised when a file cannot be cleanly extracted to a folder."""
[docs] @dataclass(frozen=True) class ExtractResult: """Outcome of :func:`extract_to_folder`.""" folder: Path created_files: tuple[Path, ...] original_path: Path
[docs] def extract_to_folder( file_path: Path, project_index: ProjectClassIndex | None = None, ) -> ExtractResult: """Extract every top-level class from ``file_path`` into a sibling folder. The folder is created at ``file_path.parent / file_path.stem``. The original ``file_path`` is deleted on success. Each extracted class file inherits the original file's import block verbatim (lint may flag unused imports per file, the user can prune those manually). Inter-class dependencies (one extracted class subclasses another) are wired via a relative import inside the new folder. ``project_index`` is refreshed at the end so downstream callers see the new layout. May be ``None`` for one-shot scripted use. Raises :class:`ExtractRefused` on failure with a message pointing at the offending construct. """ if not file_path.is_file(): raise ExtractRefused(f"{file_path} is not a regular file") if file_path.suffix != ".py": raise ExtractRefused(f"{file_path} is not a .py file") text = file_path.read_text(encoding="utf-8") try: document = parse_document(text) except SceneSyntaxError as error: raise ExtractRefused(f"{file_path} has parse errors; fix syntax before extracting: {error}") from error classes = find_class_definitions(text) if len(classes) < 2: raise ExtractRefused(f"{file_path} has fewer than two top-level classes; nothing to extract") _validate_no_unsupported_top_level(document, file_path) folder = file_path.parent / file_path.stem if folder.exists(): raise ExtractRefused(f"target folder {folder} already exists") # Build the verbatim import block from the original file. Each # extracted file gets the same block, plus intra-folder imports for # any extracted class it depends on. import_block = _emit_import_block(document) extracted_names = {c.name for c in classes} folder.mkdir() created: list[Path] = [] try: for cls in classes: class_text = class_block(cls) stem = _snake_case(cls.name) # Intra-folder imports: each base class that's also extracted # lives in a sibling file; pull it in via relative import. sibling_imports: list[str] = [] for base in cls.bases: if base in extracted_names and base != cls.name: sibling_imports.append(f"from .{_snake_case(base)} import {base}") preamble_parts: list[str] = [] if import_block: preamble_parts.append(import_block.rstrip()) if sibling_imports: preamble_parts.append("\n".join(sibling_imports)) preamble = "\n\n".join(preamble_parts) body = (preamble + "\n\n\n" if preamble else "") + class_text class_file = folder / f"{stem}.py" class_file.write_text(body, encoding="utf-8") created.append(class_file) # __init__.py re-exports every extracted class so absolute # imports of the original module path keep working. init_path = folder / "__init__.py" init_lines = [f"from .{_snake_case(c.name)} import {c.name}" for c in classes] init_lines.append("") init_lines.append("__all__ = [" + ", ".join(repr(c.name) for c in classes) + "]\n") init_path.write_text("\n".join(init_lines), encoding="utf-8") created.append(init_path) file_path.unlink() except Exception: # Best-effort rollback: drop anything we just created. for p in created: try: p.unlink() except OSError: pass try: folder.rmdir() except OSError: pass raise if project_index is not None: project_index.refresh() return ExtractResult( folder=folder, created_files=tuple(created), original_path=file_path, )
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"