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"