Source code for simvx.editor.refactor_inline

"""Inline a folder-as-package back into a single ``.py`` file.

The "Inline folder to file" refactoring is the inverse of
:func:`simvx.editor.refactor_extract.extract_to_folder`. It takes
``src/things/`` (a package with ``__init__.py`` re-exporting per-file
classes) and produces ``src/things.py`` containing every class.

Refusal cases (raise :class:`FolderInlineRefused`) when ``force=False``:
- Module-level side effects in any file (statements that are neither
  imports, classes, function defs, nor ``__all__`` assignments).
- Conditional imports (``if sys.version_info: import X``).
- Cross-folder circular imports.
- ``__init__.py`` carries executable code beyond re-exports.

When ``force=True``, the inline proceeds best-effort. Each suspect
construct is captured in :attr:`InlineResult.flagged` so the editor can
display a review report — the generated source itself is left clean
(no inserted markers, no comments).
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from pathlib import Path

from simvx.core.scene_io import find_class_definitions, parse_source

from .project_classes import ProjectClassIndex

log = logging.getLogger(__name__)


[docs] class FolderInlineRefused(ValueError): """Raised when a folder cannot be cleanly inlined and ``force=False``.""" def __init__(self, message: str, issues: list[tuple[Path, int, str]]) -> None: super().__init__(message) self.issues = issues
[docs] @dataclass class InlineResult: """Outcome of :func:`inline_to_file`.""" file: Path deleted_folder: Path flagged: list[tuple[Path, int, str]] = field(default_factory=list) """Per-issue audit trail when ``force=True`` proceeded over warnings."""
[docs] def inline_to_file( folder_path: Path, project_index: ProjectClassIndex | None = None, *, force: bool = False, ) -> InlineResult: """Concatenate every class in ``folder_path`` into a single ``.py`` file. The new file lands at ``folder_path.parent / (folder_path.name + ".py")``. The folder is deleted on success. Refusal conditions are detected up front; when ``force=False`` and any issue exists, :class:`FolderInlineRefused` is raised with the full audit trail in ``.issues``. When ``force=True``, the inline proceeds and the same audit trail lands in :attr:`InlineResult.flagged`. ``project_index.refresh()`` runs at the end so callers see the new layout. May be ``None`` for one-shot scripted use. """ if not folder_path.is_dir(): raise FolderInlineRefused( f"{folder_path} is not a directory", [] ) init_path = folder_path / "__init__.py" if not init_path.is_file(): raise FolderInlineRefused( f"{folder_path}/__init__.py missing; not a package", [] ) # Collect every .py file in the folder. Walk in stable (sorted) order # so the inlined source is deterministic. files = sorted([p for p in folder_path.glob("*.py") if p.is_file()]) if not files: raise FolderInlineRefused( f"{folder_path} has no Python files to inline", [] ) target_file = folder_path.parent / f"{folder_path.name}.py" if target_file.exists(): raise FolderInlineRefused( f"target file {target_file} already exists", [] ) issues: list[tuple[Path, int, str]] = [] parsed: dict[Path, object] = {} for path in files: text = path.read_text(encoding="utf-8") tree = parse_source(text) if tree.errors: issues.append((path, 1, "parse errors in file")) continue parsed[path] = tree _scan_for_issues(path, tree, issues) if issues and not force: raise FolderInlineRefused( f"{folder_path} has {len(issues)} issue(s); cannot inline cleanly. " "Pass force=True to proceed best-effort.", issues, ) # Build the inlined source. intra_folder_modules = {p.stem for p in files} cross_folder_imports: list[str] = [] classes_in_order: list[tuple[str, str]] = [] # (class_name, source_text) for path in files: if path == init_path: continue # __init__.py contents are re-export only, dropped tree = parsed.get(path) if tree is None: continue # Cross-folder imports survive verbatim; intra-folder imports # are dropped (the classes they reference will be inlined into # the same file). 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 == "import_from": module = _from_module_text(inner) if _is_intra_folder(module, intra_folder_modules): continue cross_folder_imports.append(stmt.get_code().rstrip()) elif inner.type == "import_name": cross_folder_imports.append(stmt.get_code().rstrip()) # Top-level classes (preserve order within the file). for cls in find_class_definitions(tree): class_text = cls.classdef_node.get_code().lstrip("\n").rstrip() + "\n" classes_in_order.append((cls.name, class_text)) # Deduplicate imports (preserve first occurrence's order). seen_imports: set[str] = set() unique_imports: list[str] = [] for line in cross_folder_imports: if line in seen_imports: continue seen_imports.add(line) unique_imports.append(line) parts: list[str] = [] if unique_imports: parts.append("\n".join(unique_imports)) if classes_in_order: parts.extend(text for _, text in classes_in_order) inlined_source = "\n\n\n".join(p.rstrip() for p in parts) + "\n" target_file.write_text(inlined_source, encoding="utf-8") # Delete the folder. for path in folder_path.iterdir(): if path.is_file(): path.unlink() folder_path.rmdir() if project_index is not None: project_index.refresh() return InlineResult( file=target_file, deleted_folder=folder_path, flagged=issues, )
def _scan_for_issues( path: Path, tree, issues: list[tuple[Path, int, str]], ) -> None: """Walk top-level statements and flag anything we'd refuse on by default.""" for stmt in tree.module.children: stype = stmt.type if stype in ("classdef", "decorated", "funcdef"): continue if stype == "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 # __all__ = […] is fine; anything else is a side effect. if ( inner.type == "expr_stmt" and inner.children and getattr(inner.children[0], "value", None) == "__all__" ): continue line = stmt.get_first_leaf().start_pos[0] issues.append( ( path, line, f"module-level side effect ({inner.type}); inlined output may not match original behaviour", ) ) continue if stype in ("if_stmt", "while_stmt", "for_stmt", "try_stmt", "with_stmt"): line = stmt.get_first_leaf().start_pos[0] issues.append( ( path, line, f"top-level {stype}; conditional/looping module-level code is unsafe to inline", ) ) def _from_module_text(import_from) -> str: """Reconstruct the dotted module name (with leading dots) from an ``import_from`` parso node.""" parts: list[str] = [] for c in import_from.children[1:]: if c.type == "keyword" and c.value == "import": break if c.type == "operator" and c.value == ".": parts.append(".") elif c.type == "name": parts.append(c.value) elif c.type == "dotted_name": parts.append("".join(sub.value for sub in c.children if hasattr(sub, "value"))) return "".join(parts) def _is_intra_folder(module: str, sibling_stems: set[str]) -> bool: """True iff ``from <module> import …`` references a sibling file inside the folder being inlined. Matches ``.foo`` (relative) and ``foo`` (bare absolute) when ``foo`` is in the sibling-stem set. Multi-dot ancestor imports (``..foo``) are NOT intra-folder — they reach into a parent package. """ if not module: return False stripped = module.lstrip(".") leading_dots = len(module) - len(stripped) if leading_dots > 1: return False if "." in stripped: return False # qualified path; not a bare sibling return stripped in sibling_stems