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