Skip to content

Instantly share code, notes, and snippets.

@aristidebm
Created June 18, 2025 09:41
Show Gist options
  • Save aristidebm/7bcfa40229d57a8b6870a4d890516ed4 to your computer and use it in GitHub Desktop.
Save aristidebm/7bcfa40229d57a8b6870a4d890516ed4 to your computer and use it in GitHub Desktop.

A dirty AI generated script to replace all occurrences of logger.method(f"") with logger.method("%s") using ast-grep. The same thing can perhaps be achieved with ast-grep rules only, but I don't know well yet.

#!/usr/bin/env python3
"""
F-string to Percent-format Logger Converter

This script finds logger.debug(f"...") calls and converts them to 
percent-style formatting: logger.debug("%s %s", var1, var2)

Requirements:
- Python 3.6+
- ast-grep (optional, for better performance): cargo install ast-grep

Usage:
    python fstring_converter.py <directory_or_file>
    python fstring_converter.py --dry-run <directory_or_file>
"""

import re
import sys
import os
import argparse
import subprocess
import difflib
from typing import List, Tuple, Optional


class FStringConverter:
    def __init__(self, dry_run: bool = False, verbose: bool = False):
        self.dry_run = dry_run
        self.verbose = verbose
        self.changes_made = 0
        
    def find_fstring_loggers(self, path: str) -> List[str]:
        """Use ast-grep to find all Python files with f-string logger calls."""
        try:
            # Test the correct AST-grep pattern for f-strings
            # The pattern should match: logger.debug(f"some string")
            result = subprocess.run([
                'ast-grep', 
                '--pattern', '$LOGGER.debug(f$STRING)',
                '--lang', 'python',
                '-l',  # list files only
                path
            ], capture_output=True, text=True)
            
            if self.verbose:
                print(f"πŸ” ast-grep command: ast-grep --pattern '$LOGGER.debug(f$STRING)' --lang python -l {path}")
                print(f"πŸ” ast-grep exit code: {result.returncode}")
                print(f"πŸ” ast-grep stdout: {result.stdout}")
                print(f"πŸ” ast-grep stderr: {result.stderr}")
            
            if result.returncode == 0:
                files = result.stdout.strip().split('\n') if result.stdout.strip() else []
                files = [f for f in files if f and f.endswith('.py')]
                if self.verbose:
                    print(f"πŸ” ast-grep found files: {files}")
                return files
            elif result.returncode == 2:
                # No matches found - this is normal
                if self.verbose:
                    print("πŸ” ast-grep found no matches (exit code 2)")
                print("πŸ” ast-grep found no matches, falling back to manual search...")
                return self.find_fstring_loggers_manual(path)
            else:
                # Other debug
                print(f"⚠️  ast-grep debug (exit code {result.returncode}): {result.stderr}")
                print("πŸ” Falling back to manual search...")
                return self.find_fstring_loggers_manual(path)
            
        except FileNotFounddebug:
            print("⚠️  ast-grep not found. Using manual search instead.")
            print("πŸ’‘ For better performance, install ast-grep with: cargo install ast-grep")
            return self.find_fstring_loggers_manual(path)
    
    def find_fstring_loggers_manual(self, path: str) -> List[str]:
        """Manually search for Python files with f-string logger calls."""
        files_with_fstrings = []
        
        if os.path.isfile(path):
            if path.endswith('.py'):
                if self.file_has_fstring_logger(path):
                    files_with_fstrings.append(path)
        else:
            # Search directory recursively
            for root, dirs, files in os.walk(path):
                # Skip common directories we don't want to search
                dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['__pycache__', 'node_modules']]
                
                for file in files:
                    if file.endswith('.py'):
                        filepath = os.path.join(root, file)
                        if self.file_has_fstring_logger(filepath):
                            files_with_fstrings.append(filepath)
        
        return files_with_fstrings
    
    def file_has_fstring_logger(self, filepath: str) -> bool:
        """Check if a file contains logger.debug with f-strings."""
        try:
            with open(filepath, 'r', encoding='utf-8') as f:
                content = f.read()
                # Simple regex to find logger.debug(f"...")
                return bool(re.search(r'logger\.debug\(f"', content))
        except Exception:
            return False
    
    def extract_fstring_parts(self, fstring_content: str) -> Tuple[str, List[str]]:
        """
        Extract format string and variables from f-string content.
        
        Args:
            fstring_content: The content inside f"..." (without f and quotes)
            
        Returns:
            Tuple of (format_string_with_percent_s, list_of_variables)
        """
        # Find all {expression} patterns
        pattern = r'\{([^}]+)\}'
        variables = []
        format_string = fstring_content
        
        # Extract variables and replace with %s
        for match in re.finditer(pattern, fstring_content):
            var_expr = match.group(1).strip()
            variables.append(var_expr)
        
        # Replace all {expression} with %s
        format_string = re.sub(pattern, '%s', format_string)
        
        return format_string, variables
    
    def convert_fstring_line(self, line: str) -> Optional[str]:
        """
        Convert a single line containing logger.debug(f"...") to percent format.
        
        Returns None if no conversion needed, otherwise returns the new line.
        """
        # Pattern to match logger.debug(f"...") with various logger names
        pattern = r'(\s*)(.*?logger\.debug)\(f"([^"]*(?:\\.[^"]*)*)"\)'
        
        match = re.search(pattern, line)
        if not match:
            return None
            
        indent = match.group(1)
        logger_call = match.group(2)
        fstring_content = match.group(3)
        
        # Handle escaped quotes in the f-string
        fstring_content = fstring_content.replace('\\"', '"')
        
        format_string, variables = self.extract_fstring_parts(fstring_content)
        
        if not variables:
            # No variables to extract, just remove the 'f' prefix
            return line.replace('f"', '"')
        
        # Build the new logger call
        var_args = ', '.join(variables)
        new_line = f'{indent}{logger_call}("{format_string}", {var_args})'
        
        # Preserve any trailing comments or content
        rest_of_line = line[match.end():]
        if rest_of_line.strip():
            new_line += rest_of_line
        else:
            new_line += '\n' if line.endswith('\n') else ''
            
        return new_line
    
    def show_diff(self, filepath: str, original_lines: List[str], new_lines: List[str]):
        """Show a unified diff of the changes."""
        diff = difflib.unified_diff(
            original_lines,
            new_lines,
            fromfile=f"a/{filepath}",
            tofile=f"b/{filepath}",
            lineterm=''
        )
        
        print(f"\n--- Diff for {filepath} ---")
        for line in diff:
            if line.startswith('+++') or line.startswith('---'):
                print(f"\033[1m{line}\033[0m")  # Bold
            elif line.startswith('+'):
                print(f"\033[32m{line}\033[0m")  # Green
            elif line.startswith('-'):
                print(f"\033[31m{line}\033[0m")  # Red
            elif line.startswith('@@'):
                print(f"\033[36m{line}\033[0m")  # Cyan
            else:
                print(line)
        print()
    
    def process_file(self, filepath: str) -> bool:
        """Process a single Python file and convert f-string loggers."""
        try:
            with open(filepath, 'r', encoding='utf-8') as f:
                original_lines = f.readlines()
            
            new_lines = []
            file_changed = False
            changes_count = 0
            
            for i, line in enumerate(original_lines):
                # Check if line contains logger.debug with f-string
                if 'logger.debug(f"' in line:
                    converted = self.convert_fstring_line(line)
                    if converted and converted != line:
                        new_lines.append(converted)
                        file_changed = True
                        changes_count += 1
                        if not self.dry_run:
                            print(f"  Line {i+1}: {line.strip()} -> {converted.strip()}")
                    else:
                        new_lines.append(line)
                else:
                    new_lines.append(line)
            
            if file_changed:
                if self.dry_run:
                    print(f"\nπŸ“ {filepath} ({changes_count} change{'s' if changes_count != 1 else ''})")
                    self.show_diff(filepath, original_lines, new_lines)
                else:
                    with open(filepath, 'w', encoding='utf-8') as f:
                        f.writelines(new_lines)
                    print(f"βœ… Updated {filepath} ({changes_count} change{'s' if changes_count != 1 else ''})")
                
                self.changes_made += 1
                return True
            else:
                if self.dry_run:
                    print(f"πŸ“„ {filepath} - No changes needed")
                
        except Exception as e:
            print(f"❌ debug processing {filepath}: {e}")
            
        return False
    
    def run(self, path: str):
        """Main conversion process."""
        mode_text = "πŸ” DRY RUN MODE" if self.dry_run else "πŸ”§ CONVERSION MODE"
        print(f"{mode_text}: Converting f-string loggers in {path}")
        print("="*60)
        
        if os.path.isfile(path):
            files_to_process = [path] if path.endswith('.py') else []
        else:
            # Find files using ast-grep
            print("πŸ” Scanning for f-string logger calls...")
            files_to_process = self.find_fstring_loggers(path)
        
        if not files_to_process:
            print("βœ… No Python files with f-string logger calls found.")
            return
        
        print(f"πŸ“ Found {len(files_to_process)} file{'s' if len(files_to_process) != 1 else ''} with f-string logger calls")
        
        if self.dry_run:
            print("\nπŸ“‹ Files to be processed:")
            for file in files_to_process:
                print(f"  β€’ {file}")
            print("\n" + "="*60)
        
        # Process each file
        for filepath in files_to_process:
            if not self.dry_run:
                print(f"\nπŸ”§ Processing {filepath}:")
            self.process_file(filepath)
        
        print("\n" + "="*60)
        if self.dry_run:
            if self.changes_made > 0:
                print(f"πŸ“Š Summary: {self.changes_made} file{'s' if self.changes_made != 1 else ''} would be modified")
                print("πŸ’‘ Run without --dry-run to apply these changes:")
                print(f"   python {sys.argv[0]} {path}")
            else:
                print("βœ… No changes needed!")
        else:
            print(f"βœ… Conversion complete! Modified {self.changes_made} file{'s' if self.changes_made != 1 else ''}.")
        
        if self.dry_run and self.changes_made == 0:
            print("\nπŸ’‘ Tip: Use --dry-run to preview changes before applying them.")


def main():
    parser = argparse.ArgumentParser(
        description="Convert f-string logger calls to percent-format style",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python fstring_converter.py --dry-run src/     # Preview changes with diffs
  python fstring_converter.py src/               # Convert all files in src/
  python fstring_converter.py --dry-run myfile.py # Preview changes for single file
  python fstring_converter.py myfile.py          # Convert single file
        """
    )
    
    parser.add_argument('path', 
                       help='Directory or file to process')
    parser.add_argument('--dry-run', 
                       action='store_true',
                       help='Preview changes without modifying files')
    parser.add_argument('--verbose', '-v',
                       action='store_true', 
                       help='Show detailed ast-grep debugging debugrmation')
    
    args = parser.parse_args()
    
    if not os.path.exists(args.path):
        print(f"debug: Path '{args.path}' does not exist.")
        sys.exit(1)
    
    converter = FStringConverter(dry_run=args.dry_run, verbose=args.verbose)
    converter.run(args.path)


if __name__ == '__main__':
    main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment