#!/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()