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.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from pathlib import Path

from simvx.core.scene_io import (
    find_class_definitions,
    parse_source,
)

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") tree = parse_source(text) if tree.errors: raise ExtractRefused( f"{file_path} has parse errors; fix syntax before extracting" ) classes = find_class_definitions(tree) if len(classes) < 2: raise ExtractRefused( f"{file_path} has fewer than two top-level classes; nothing to extract" ) _validate_no_unsupported_top_level(tree, 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(tree) extracted_names = {c.name for c in classes} folder.mkdir() created: list[Path] = [] try: for cls in classes: class_text = cls.classdef_node.get_code().lstrip("\n").rstrip() + "\n" 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(tree, 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 stmt in tree.module.children: if stmt.type in ("classdef", "decorated"): # `decorated` wraps either a class or a function; check the # last child to distinguish. inner = ( stmt.children[-1] if stmt.type == "decorated" and stmt.children else stmt ) if inner.type == "funcdef": raise ExtractRefused( f"{file_path}: top-level function " f"{getattr(inner.children[1], 'value', '<?>')!r} cannot be extracted; " "move it into one of the classes or remove it before extracting" ) continue if stmt.type == "simple_stmt": inner = stmt.children[0] if stmt.children else None if inner is None: continue if inner.type in ("import_name", "import_from"): continue # Any other simple_stmt at top level is a problem (assignments, # expressions, calls, etc.). line = stmt.get_first_leaf().start_pos[0] raise ExtractRefused( f"{file_path}:{line}: unsupported top-level statement " f"({inner.type}); only classes and imports may live in " "extractable scene files" ) if stmt.type == "funcdef": line = stmt.get_first_leaf().start_pos[0] raise ExtractRefused( f"{file_path}:{line}: top-level function cannot be " "extracted; move it into a class or remove it" ) if stmt.type in ("if_stmt", "while_stmt", "for_stmt", "try_stmt", "with_stmt"): line = stmt.get_first_leaf().start_pos[0] raise ExtractRefused( f"{file_path}:{line}: top-level control-flow block cannot " "be extracted; resolve manually before extracting" ) def _emit_import_block(tree) -> str: """Concatenate every top-level ``import`` / ``from … import …`` line.""" parts: list[str] = [] for stmt in tree.module.children: if stmt.type != "simple_stmt": continue inner = stmt.children[0] if stmt.children else None if inner is None: continue if inner.type in ("import_name", "import_from"): parts.append(stmt.get_code().rstrip()) if not parts: return "" return "\n".join(parts) + "\n"