|
#!/usr/bin/env python3
|
|
|
|
"""
|
|
impLODE: A Python script to consolidate a multi-file Python project
|
|
into a single, runnable file.
|
|
|
|
It intelligently resolves local imports, hoists external dependencies to the top,
|
|
and preserves the core logic, using AST for safe transformations.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import ast
|
|
import argparse
|
|
import logging
|
|
import py_compile # Added
|
|
from typing import Set, Dict, Optional, TextIO
|
|
|
|
# Set up the main logger
|
|
logger = logging.getLogger("impLODE")
|
|
|
|
|
|
class ImportTransformer(ast.NodeTransformer):
|
|
"""
|
|
An AST transformer that visits Import and ImportFrom nodes.
|
|
|
|
On Pass 1 (Dry Run):
|
|
- Identifies all local vs. external imports.
|
|
- Recursively calls the main resolver for local modules.
|
|
- Stores external and __future__ imports in the Imploder instance.
|
|
|
|
On Pass 2 (Write Run):
|
|
- Recursively calls the main resolver for local modules.
|
|
- Removes all import statements (since they were hoisted in Pass 1).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
imploder: "Imploder",
|
|
current_file_path: str,
|
|
f_out: Optional[TextIO],
|
|
is_dry_run: bool,
|
|
indent_level: int = 0,
|
|
):
|
|
self.imploder = imploder
|
|
self.current_file_path = current_file_path
|
|
self.current_dir = os.path.dirname(current_file_path)
|
|
self.f_out = f_out
|
|
self.is_dry_run = is_dry_run
|
|
self.indent = " " * indent_level
|
|
self.logger = logging.getLogger(self.__class__.__name__)
|
|
|
|
def _log_debug(self, msg: str):
|
|
"""Helper for indented debug logging."""
|
|
self.logger.debug(f"{self.indent} > {msg}")
|
|
|
|
def _find_local_module(self, module_name: str, level: int) -> Optional[str]:
|
|
"""
|
|
Tries to find the absolute path for a given module name and relative level.
|
|
Returns None if it's not a local module *and* cannot be found in site-packages.
|
|
"""
|
|
import importlib.util # Added
|
|
import importlib.machinery # Added
|
|
|
|
if not module_name:
|
|
# Handle cases like `from . import foo`
|
|
# The module_name is 'foo', handled by visit_ImportFrom's aliases.
|
|
# If it's just `from . import`, module_name is None.
|
|
# We'll resolve from the alias names.
|
|
|
|
# Determine base path for relative import
|
|
base_path = self.current_dir
|
|
if level > 0:
|
|
# Go up 'level' directories (minus one for current dir)
|
|
for _ in range(level - 1):
|
|
base_path = os.path.dirname(base_path)
|
|
return base_path
|
|
|
|
base_path = self.current_dir
|
|
if level > 0:
|
|
# Go up 'level' directories (minus one for current dir)
|
|
for _ in range(level - 1):
|
|
base_path = os.path.dirname(base_path)
|
|
else:
|
|
# Absolute import, start from the root
|
|
base_path = self.imploder.root_dir
|
|
|
|
module_parts = module_name.split(".")
|
|
module_path = os.path.join(base_path, *module_parts)
|
|
|
|
# Check for package `.../module/__init__.py`
|
|
package_init = os.path.join(module_path, "__init__.py")
|
|
if os.path.isfile(package_init):
|
|
self._log_debug(
|
|
f"Resolved '{module_name}' to local package: {os.path.relpath(package_init, self.imploder.root_dir)}"
|
|
)
|
|
return package_init
|
|
|
|
# Check for module `.../module.py`
|
|
module_py = module_path + ".py"
|
|
if os.path.isfile(module_py):
|
|
self._log_debug(
|
|
f"Resolved '{module_name}' to local module: {os.path.relpath(module_py, self.imploder.root_dir)}"
|
|
)
|
|
return module_py
|
|
|
|
# --- FALLBACK: Deep search from root ---
|
|
# This is less efficient but helps with non-standard project structures
|
|
# where the "root" for imports isn't the same as the Imploder's root.
|
|
if level == 0:
|
|
self._log_debug(
|
|
f"Module '{module_name}' not found at primary path. Starting deep fallback search from {self.imploder.root_dir}..."
|
|
)
|
|
|
|
target_path_py = os.path.join(*module_parts) + ".py"
|
|
target_path_init = os.path.join(*module_parts, "__init__.py")
|
|
|
|
for dirpath, dirnames, filenames in os.walk(self.imploder.root_dir, topdown=True):
|
|
# Prune common virtual envs and metadata dirs to speed up search
|
|
dirnames[:] = [
|
|
d
|
|
for d in dirnames
|
|
if not d.startswith(".")
|
|
and d not in ("venv", "env", ".venv", ".env", "__pycache__", "node_modules")
|
|
]
|
|
|
|
# Check for .../module/file.py
|
|
check_file_py = os.path.join(dirpath, target_path_py)
|
|
if os.path.isfile(check_file_py):
|
|
self._log_debug(
|
|
f"Fallback search found module: {os.path.relpath(check_file_py, self.imploder.root_dir)}"
|
|
)
|
|
return check_file_py
|
|
|
|
# Check for .../module/file/__init__.py
|
|
check_file_init = os.path.join(dirpath, target_path_init)
|
|
if os.path.isfile(check_file_init):
|
|
self._log_debug(
|
|
f"Fallback search found package: {os.path.relpath(check_file_init, self.imploder.root_dir)}"
|
|
)
|
|
return check_file_init
|
|
|
|
# --- Removed site-packages/external module inlining logic ---
|
|
# As requested, the script will now only resolve local modules.
|
|
|
|
# Not found locally
|
|
return None
|
|
|
|
def visit_Import(self, node: ast.Import) -> Optional[ast.AST]:
|
|
"""Handles `import foo` or `import foo.bar`."""
|
|
for alias in node.names:
|
|
module_path = self._find_local_module(alias.name, level=0)
|
|
|
|
if module_path:
|
|
# Local module
|
|
self._log_debug(f"Resolving local import: `import {alias.name}`")
|
|
self.imploder.resolve_file(
|
|
file_abs_path=module_path,
|
|
f_out=self.f_out,
|
|
is_dry_run=self.is_dry_run,
|
|
indent_level=self.imploder.current_indent_level,
|
|
)
|
|
else:
|
|
# External module
|
|
self._log_debug(f"Found external import: `import {alias.name}`")
|
|
if self.is_dry_run:
|
|
# On dry run, collect it for hoisting
|
|
key = f"import {alias.name}"
|
|
if key not in self.imploder.external_imports:
|
|
self.imploder.external_imports[key] = node
|
|
|
|
# On Pass 2 (write run), we remove this node by replacing it
|
|
# with a dummy function call to prevent IndentationErrors.
|
|
# On Pass 1 (dry run), we also replace it.
|
|
module_names = ", ".join([a.name for a in node.names])
|
|
new_call = ast.Call(
|
|
func=ast.Name(id="_implode_log_import", ctx=ast.Load()),
|
|
args=[
|
|
ast.Constant(value=module_names),
|
|
ast.Constant(value=0),
|
|
ast.Constant(value="import"),
|
|
],
|
|
keywords=[],
|
|
)
|
|
return ast.Expr(value=new_call)
|
|
|
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> Optional[ast.AST]:
|
|
"""Handles `from foo import bar` or `from .foo import bar`."""
|
|
|
|
# --- NEW: Create the replacement node first ---
|
|
module_name_str = node.module or ""
|
|
import_type = "from-import"
|
|
if module_name_str == "__future__":
|
|
import_type = "future-import"
|
|
|
|
new_call = ast.Call(
|
|
func=ast.Name(id="_implode_log_import", ctx=ast.Load()),
|
|
args=[
|
|
ast.Constant(value=module_name_str),
|
|
ast.Constant(value=node.level),
|
|
ast.Constant(value=import_type),
|
|
],
|
|
keywords=[],
|
|
)
|
|
replacement_node = ast.Expr(value=new_call)
|
|
|
|
# Handle `from __future__ import ...`
|
|
if node.module == "__future__":
|
|
self._log_debug("Found __future__ import. Hoisting to top.")
|
|
if self.is_dry_run:
|
|
key = ast.unparse(node)
|
|
self.imploder.future_imports[key] = node
|
|
return replacement_node # Return replacement
|
|
|
|
module_path = self._find_local_module(node.module or "", node.level)
|
|
|
|
if module_path and os.path.isdir(module_path):
|
|
# This is a package import like `from . import foo`
|
|
# `module_path` is the directory of the package (e.g., self.current_dir)
|
|
self._log_debug(f"Resolving package import: `from {node.module or '.'} import ...`")
|
|
for alias in node.names:
|
|
# --- FIX ---
|
|
# Resolve `foo` relative to the package directory (`module_path`)
|
|
|
|
# Check for module `.../package_dir/foo.py`
|
|
package_module_py = os.path.join(module_path, alias.name + ".py")
|
|
|
|
# Check for sub-package `.../package_dir/foo/__init__.py`
|
|
package_module_init = os.path.join(module_path, alias.name, "__init__.py")
|
|
|
|
if os.path.isfile(package_module_py):
|
|
self._log_debug(
|
|
f"Found sub-module: {os.path.relpath(package_module_py, self.imploder.root_dir)}"
|
|
)
|
|
self.imploder.resolve_file(
|
|
file_abs_path=package_module_py,
|
|
f_out=self.f_out,
|
|
is_dry_run=self.is_dry_run,
|
|
indent_level=self.imploder.current_indent_level,
|
|
)
|
|
elif os.path.isfile(package_module_init):
|
|
self._log_debug(
|
|
f"Found sub-package: {os.path.relpath(package_module_init, self.imploder.root_dir)}"
|
|
)
|
|
self.imploder.resolve_file(
|
|
file_abs_path=package_module_init,
|
|
f_out=self.f_out,
|
|
is_dry_run=self.is_dry_run,
|
|
indent_level=self.imploder.current_indent_level,
|
|
)
|
|
else:
|
|
# Could be another sub-package, etc.
|
|
# This logic can be expanded, but for now, log a warning
|
|
self.logger.warning(
|
|
f"{self.indent} > Could not resolve sub-module '{alias.name}' in package '{module_path}'"
|
|
)
|
|
return replacement_node # Return replacement
|
|
# --- END FIX ---
|
|
|
|
if module_path:
|
|
# Local module (e.g., `from .foo import bar` or `from myapp.foo import bar`)
|
|
self._log_debug(f"Resolving local from-import: `from {node.module or '.'} ...`")
|
|
self.imploder.resolve_file(
|
|
file_abs_path=module_path,
|
|
f_out=self.f_out,
|
|
is_dry_run=self.is_dry_run,
|
|
indent_level=self.imploder.current_indent_level,
|
|
)
|
|
else:
|
|
# External module
|
|
self._log_debug(f"Found external from-import: `from {node.module or '.'} ...`")
|
|
if self.is_dry_run:
|
|
# On dry run, collect it for hoisting
|
|
key = ast.unparse(node)
|
|
if key not in self.imploder.external_imports:
|
|
self.imploder.external_imports[key] = node
|
|
|
|
# On Pass 2 (write run), we remove this node entirely by replacing it
|
|
# On Pass 1 (dry run), we also replace it
|
|
return replacement_node
|
|
|
|
|
|
class Imploder:
|
|
"""
|
|
Core class for handling the implosion process.
|
|
Manages state, file processing, and the two-pass analysis.
|
|
"""
|
|
|
|
def __init__(self, root_dir: str, enable_import_logging: bool = False):
|
|
self.root_dir = os.path.realpath(root_dir)
|
|
self.processed_files: Set[str] = set()
|
|
self.external_imports: Dict[str, ast.AST] = {}
|
|
self.future_imports: Dict[str, ast.AST] = {}
|
|
self.current_indent_level = 0
|
|
self.enable_import_logging = enable_import_logging # Added
|
|
logger.info(f"Initialized Imploder with root: {self.root_dir}")
|
|
|
|
def implode(self, main_file_abs_path: str, output_file_path: str):
|
|
"""
|
|
Runs the full two-pass implosion process.
|
|
"""
|
|
if not os.path.isfile(main_file_abs_path):
|
|
logger.critical(f"Main file not found: {main_file_abs_path}")
|
|
sys.exit(1)
|
|
|
|
# --- Pass 1: Analyze Dependencies (Dry Run) ---
|
|
logger.info(
|
|
f"--- PASS 1: Analyzing dependencies from {os.path.relpath(main_file_abs_path, self.root_dir)} ---"
|
|
)
|
|
self.processed_files.clear()
|
|
self.external_imports.clear()
|
|
self.future_imports.clear()
|
|
|
|
try:
|
|
self.resolve_file(main_file_abs_path, f_out=None, is_dry_run=True, indent_level=0)
|
|
except Exception as e:
|
|
logger.critical(f"Error during analysis pass: {e}", exc_info=True)
|
|
sys.exit(1)
|
|
|
|
logger.info(
|
|
f"--- Analysis complete. Found {len(self.future_imports)} __future__ imports and {len(self.external_imports)} external modules. ---"
|
|
)
|
|
|
|
# --- Pass 2: Write Imploded File ---
|
|
logger.info(f"--- PASS 2: Writing imploded file to {output_file_path} ---")
|
|
self.processed_files.clear()
|
|
|
|
try:
|
|
with open(output_file_path, "w", encoding="utf-8") as f_out:
|
|
# Write headers
|
|
f_out.write(f"#!/usr/bin/env python3\n")
|
|
|
|
f_out.write(f"# -*- coding: utf-8 -*-\n")
|
|
f_out.write(f"import logging\n")
|
|
f_out.write(f"\n# --- IMPLODED FILE: Generated by impLODE --- #\n")
|
|
f_out.write(
|
|
f"# --- Original main file: {os.path.relpath(main_file_abs_path, self.root_dir)} --- #\n"
|
|
)
|
|
|
|
# Write __future__ imports
|
|
if self.future_imports:
|
|
f_out.write("\n# --- Hoisted __future__ Imports --- #\n")
|
|
for node in self.future_imports.values():
|
|
f_out.write(f"{ast.unparse(node)}\n")
|
|
f_out.write("# --- End __future__ Imports --- #\n")
|
|
|
|
# --- NEW: Write Helper Function ---
|
|
enable_logging_str = "True" if self.enable_import_logging else "False"
|
|
f_out.write("\n# --- impLODE Helper Function --- #\n")
|
|
f_out.write(f"_IMPLODE_LOGGING_ENABLED_ = {enable_logging_str}\n")
|
|
f_out.write("def _implode_log_import(module_name, level, import_type):\n")
|
|
f_out.write(
|
|
' """Dummy function to replace imports and prevent IndentationErrors."""\n'
|
|
)
|
|
f_out.write(" if _IMPLODE_LOGGING_ENABLED_:\n")
|
|
f_out.write(
|
|
" print(f\"[impLODE Logger]: Skipped {import_type}: module='{module_name}', level={level}\")\n"
|
|
)
|
|
f_out.write(" pass\n")
|
|
f_out.write("# --- End Helper Function --- #\n")
|
|
|
|
# Write external imports
|
|
if self.external_imports:
|
|
f_out.write("\n# --- Hoisted External Imports --- #\n")
|
|
for node in self.external_imports.values():
|
|
# As requested, wrap each external import in a try/except block
|
|
f_out.write("try:\n")
|
|
f_out.write(f" {ast.unparse(node)}\n")
|
|
f_out.write("except ImportError:\n")
|
|
f_out.write(" pass\n")
|
|
f_out.write("# --- End External Imports --- #\n")
|
|
|
|
# Write the actual code
|
|
self.resolve_file(main_file_abs_path, f_out=f_out, is_dry_run=False, indent_level=0)
|
|
|
|
except IOError as e:
|
|
logger.critical(
|
|
f"Could not write to output file {output_file_path}: {e}", exc_info=True
|
|
)
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
logger.critical(f"Error during write pass: {e}", exc_info=True)
|
|
sys.exit(1)
|
|
|
|
logger.info(f"--- Implosion complete! Output saved to {output_file_path} ---")
|
|
|
|
def resolve_file(
|
|
self, file_abs_path: str, f_out: Optional[TextIO], is_dry_run: bool, indent_level: int = 0
|
|
):
|
|
"""
|
|
Recursively resolves a single file.
|
|
- `is_dry_run=True`: Analyzes imports, populating `external_imports`.
|
|
- `is_dry_run=False`: Writes transformed code to `f_out`.
|
|
"""
|
|
self.current_indent_level = indent_level
|
|
indent = " " * indent_level
|
|
|
|
try:
|
|
file_real_path = os.path.realpath(file_abs_path)
|
|
# --- Reverted path logic as we only handle local files ---
|
|
rel_path = os.path.relpath(file_real_path, self.root_dir)
|
|
# --- End Revert ---
|
|
except ValueError:
|
|
# Happens if file is on a different drive than root_dir on Windows
|
|
logger.warning(
|
|
f"{indent}Cannot calculate relative path for {file_abs_path}. Using absolute."
|
|
)
|
|
rel_path = file_abs_path
|
|
|
|
# --- 1. Circular Dependency Check ---
|
|
if file_real_path in self.processed_files:
|
|
logger.debug(f"{indent}Skipping already processed file: {rel_path}")
|
|
return
|
|
|
|
logger.info(f"{indent}Processing: {rel_path}")
|
|
self.processed_files.add(file_real_path)
|
|
|
|
# --- 2. Read File ---
|
|
try:
|
|
with open(file_real_path, "r", encoding="utf-8") as f:
|
|
code = f.read()
|
|
except FileNotFoundError:
|
|
logger.error(f"{indent}File not found: {file_real_path}")
|
|
return
|
|
except UnicodeDecodeError:
|
|
logger.error(f"{indent}Could not decode file (not utf-8): {file_real_path}")
|
|
return
|
|
except Exception as e:
|
|
logger.error(f"{indent}Could not read file {file_real_path}: {e}")
|
|
return
|
|
|
|
# --- 2.5. Validate Syntax with py_compile ---
|
|
try:
|
|
# As requested, validate syntax using py_compile before AST parsing
|
|
py_compile.compile(file_real_path, doraise=True, quiet=1)
|
|
logger.debug(f"{indent}Syntax OK (py_compile): {rel_path}")
|
|
except py_compile.PyCompileError as e:
|
|
# This error includes file, line, and message
|
|
logger.error(
|
|
f"{indent}Syntax error (py_compile) in {e.file} on line {e.lineno}: {e.msg}"
|
|
)
|
|
return
|
|
except Exception as e:
|
|
# Catch other potential errors like permission issues
|
|
logger.error(f"{indent}Error during py_compile for {rel_path}: {e}")
|
|
return
|
|
|
|
# --- 3. Parse AST ---
|
|
try:
|
|
tree = ast.parse(code, filename=file_real_path)
|
|
except SyntaxError as e:
|
|
logger.error(f"{indent}Syntax error in {rel_path} on line {e.lineno}: {e.msg}")
|
|
return
|
|
except Exception as e:
|
|
logger.error(f"{indent}Could not parse AST for {rel_path}: {e}")
|
|
return
|
|
|
|
# --- 4. Transform AST ---
|
|
transformer = ImportTransformer(
|
|
imploder=self,
|
|
current_file_path=file_real_path,
|
|
f_out=f_out,
|
|
is_dry_run=is_dry_run,
|
|
indent_level=indent_level,
|
|
)
|
|
|
|
try:
|
|
new_tree = transformer.visit(tree)
|
|
except Exception as e:
|
|
logger.error(f"{indent}Error transforming AST for {rel_path}: {e}", exc_info=True)
|
|
return
|
|
|
|
# --- 5. Write Content (on Pass 2) ---
|
|
if not is_dry_run and f_out:
|
|
try:
|
|
ast.fix_missing_locations(new_tree)
|
|
f_out.write(f"\n\n# --- Content from {rel_path} --- #\n")
|
|
f_out.write(ast.unparse(new_tree))
|
|
f_out.write(f"\n# --- End of {rel_path} --- #\n")
|
|
logger.info(f"{indent}Successfully wrote content from: {rel_path}")
|
|
except Exception as e:
|
|
logger.error(
|
|
f"{indent}Could not unparse or write AST for {rel_path}: {e}", exc_info=True
|
|
)
|
|
|
|
self.current_indent_level = indent_level
|
|
|
|
|
|
def setup_logging(level: int):
|
|
"""Configures the root logger."""
|
|
handler = logging.StreamHandler()
|
|
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
handler.setFormatter(formatter)
|
|
|
|
# Configure the 'impLODE' logger
|
|
logger.setLevel(level)
|
|
logger.addHandler(handler)
|
|
|
|
# Configure the 'ImportTransformer' logger
|
|
transformer_logger = logging.getLogger("ImportTransformer")
|
|
transformer_logger.setLevel(level)
|
|
transformer_logger.addHandler(handler)
|
|
|
|
|
|
def main():
|
|
"""Main entry point for the script."""
|
|
parser = argparse.ArgumentParser(
|
|
description="impLODE: Consolidate a multi-file Python project into one file.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
|
|
parser.add_argument(
|
|
"main_file", type=str, help="The main entry point .py file of your project."
|
|
)
|
|
parser.add_argument(
|
|
"-o",
|
|
"--output",
|
|
type=str,
|
|
default="imploded.py",
|
|
help="Path for the combined output file. (default: imploded.py)",
|
|
)
|
|
parser.add_argument(
|
|
"-r",
|
|
"--root",
|
|
type=str,
|
|
default=".",
|
|
help="The root directory of the project for resolving absolute imports. (default: current directory)",
|
|
)
|
|
|
|
log_group = parser.add_mutually_exclusive_group()
|
|
log_group.add_argument(
|
|
"-v",
|
|
"--verbose",
|
|
action="store_const",
|
|
dest="log_level",
|
|
const=logging.DEBUG,
|
|
default=logging.INFO,
|
|
help="Enable verbose DEBUG logging.",
|
|
)
|
|
log_group.add_argument(
|
|
"-q",
|
|
"--quiet",
|
|
action="store_const",
|
|
dest="log_level",
|
|
const=logging.WARNING,
|
|
help="Suppress INFO logs, showing only WARNINGS and ERRORS.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--enable-import-logging",
|
|
action="store_true",
|
|
help="Enable runtime logging for removed import statements in the final imploded script.",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# --- Setup ---
|
|
setup_logging(args.log_level)
|
|
|
|
root_dir = os.path.abspath(args.root)
|
|
main_file_path = os.path.abspath(args.main_file)
|
|
output_file_path = os.path.abspath(args.output)
|
|
|
|
# --- Validation ---
|
|
if not os.path.isdir(root_dir):
|
|
logger.critical(f"Root directory not found: {root_dir}")
|
|
sys.exit(1)
|
|
|
|
if not os.path.isfile(main_file_path):
|
|
logger.critical(f"Main file not found: {main_file_path}")
|
|
sys.exit(1)
|
|
|
|
if not main_file_path.startswith(root_dir):
|
|
logger.warning(f"Main file {main_file_path} is outside the specified root {root_dir}.")
|
|
logger.warning("This may cause issues with absolute import resolution.")
|
|
|
|
if main_file_path == output_file_path:
|
|
logger.critical("Output file cannot be the same as the main file.")
|
|
sys.exit(1)
|
|
|
|
# --- Run ---
|
|
imploder = Imploder(
|
|
root_dir=root_dir, enable_import_logging=args.enable_import_logging # Added
|
|
)
|
|
imploder.implode(main_file_abs_path=main_file_path, output_file_path=output_file_path)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|