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()