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
from simvx.core.scene_io.source import SceneSyntaxError, SourceDocument, StatementKind
from simvx.core.scene_io.source import parse as parse_document

from .project_classes import ProjectClassIndex
from .refactor_extract import block_keyword, class_block, is_module_docstring

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
@dataclass(frozen=True) class _ParsedFile: """One file of the folder, kept for the two questions asked of it. The statements come from the source seam, which is where an import, a module-level side effect and a block of code are told apart; the text is kept beside them because :func:`find_class_definitions` slices the class source out of it. """ document: SourceDocument text: str
[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, _ParsedFile] = {} for path in files: text = path.read_text(encoding="utf-8") try: document = parse_document(text) except SceneSyntaxError as error: issues.append((path, error.line or 1, "parse errors in file")) continue parsed[path] = _ParsedFile(document=document, text=text) _scan_for_issues(path, document, 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 parsed_file = parsed.get(path) if parsed_file 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 imported in parsed_file.document.imports(): module = imported.module if module is not None and _is_intra_folder(module, intra_folder_modules): continue cross_folder_imports.append(imported.statement.text()) # Top-level classes (preserve order within the file). for cls in find_class_definitions(parsed_file.text): class_text = class_block(cls) 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, document: SourceDocument, issues: list[tuple[Path, int, str]], ) -> None: """Walk top-level statements and flag anything we'd refuse on by default.""" 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 in ("class", "def", "async def"): continue issues.append( ( path, stmt.line(), f"top-level {keyword} block; conditional/looping module-level code is unsafe to inline", ) ) continue # __all__ = […] is fine; anything else is a side effect. if kind is StatementKind.ASSIGNMENT and stmt.bound_names() == {"__all__"}: continue issues.append( ( path, stmt.line(), f"module-level side effect ({kind}); inlined output may not match original behaviour", ) ) 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