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