[Added][Tools] Style fixer
This commit is contained in:
Executable
+219
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import shutil # For file backup
|
||||
import logging
|
||||
|
||||
# Setup logging
|
||||
logger = logging.getLogger("StyleFixer")
|
||||
logger.setLevel(logging.INFO) # Default level
|
||||
# Create console handler and set level to debug
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(logging.DEBUG) # Let handler decide what to show based on logger's effective level
|
||||
# Create formatter
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
# Add formatter to ch
|
||||
ch.setFormatter(formatter)
|
||||
# Add ch to logger
|
||||
logger.addHandler(ch)
|
||||
|
||||
|
||||
def expand_tabs(line, tab_size=4):
|
||||
"""Expands tabs to spaces, respecting tab stops."""
|
||||
out_line = []
|
||||
current_col = 0
|
||||
for char in line:
|
||||
if char == '\t':
|
||||
spaces_to_add = tab_size - (current_col % tab_size)
|
||||
out_line.append(' ' * spaces_to_add)
|
||||
current_col += spaces_to_add
|
||||
else:
|
||||
out_line.append(char)
|
||||
current_col += 1
|
||||
return "".join(out_line)
|
||||
|
||||
|
||||
def fix_indentation(line, tab_size=4):
|
||||
"""Rounds leading whitespace indentation to the nearest multiple of tab_size."""
|
||||
stripped_line = line.lstrip(' ')
|
||||
if not stripped_line or stripped_line == line: # No leading spaces or empty line
|
||||
return line
|
||||
|
||||
leading_spaces_count = len(line) - len(stripped_line)
|
||||
|
||||
remainder = leading_spaces_count % tab_size
|
||||
if remainder == 0:
|
||||
new_indent_count = leading_spaces_count
|
||||
elif remainder <= tab_size / 2: # Prioritize rounding down or to current if exact multiple
|
||||
new_indent_count = leading_spaces_count - remainder
|
||||
else: # Round up
|
||||
new_indent_count = leading_spaces_count + (tab_size - remainder)
|
||||
|
||||
new_indent_count = max(0, new_indent_count) # Ensure non-negative
|
||||
|
||||
if new_indent_count != leading_spaces_count:
|
||||
logger.debug(f"Adjusting indent: from {leading_spaces_count} to {new_indent_count} for line: {line.rstrip()!r}")
|
||||
|
||||
return ' ' * new_indent_count + stripped_line
|
||||
|
||||
|
||||
def process_line(line_num, line_content_with_eol, expand_tabs_enabled=True):
|
||||
original_line_for_debug = line_content_with_eol # Keep for debugging if needed
|
||||
line = line_content_with_eol
|
||||
|
||||
# 1. Expand tabs (if enabled)
|
||||
if expand_tabs_enabled:
|
||||
line = expand_tabs(line)
|
||||
if line != original_line_for_debug and not line.isspace(): # Log only if actual content changed
|
||||
logger.debug(f"L{line_num}: Tabs expanded.")
|
||||
|
||||
# 2. W291: Remove trailing whitespace (done before other checks that might rely on EOL)
|
||||
# Also handles W293 (blank line contains whitespace) implicitly if the line becomes empty.
|
||||
processed_line = line.rstrip()
|
||||
if len(processed_line) < len(line.rstrip('\r\n')): # Compare lengths without EOL
|
||||
logger.debug(f"L{line_num}: Trailing whitespace removed.")
|
||||
|
||||
# If the line became empty after rstrip, it's a truly blank line
|
||||
if not processed_line and line.strip() == "":
|
||||
# Return an empty string for a blank line, newline char will be added later if original had it
|
||||
if line.endswith(('\n', '\r\n', '\r')):
|
||||
return '\n'
|
||||
return ""
|
||||
|
||||
# 3. Fix indentation (E111) - only if line is not blank
|
||||
if processed_line:
|
||||
indented_line = fix_indentation(processed_line)
|
||||
if indented_line != processed_line: # Log if indentation changed
|
||||
# Already logged inside fix_indentation with more detail
|
||||
pass
|
||||
processed_line = indented_line
|
||||
|
||||
# 4. E261: At least two spaces before inline comment
|
||||
in_string_char = None
|
||||
temp_part = ""
|
||||
comment_starts_at = -1
|
||||
|
||||
for idx, char_val in enumerate(processed_line):
|
||||
if in_string_char:
|
||||
temp_part += char_val
|
||||
if char_val == in_string_char:
|
||||
if len(temp_part) >= 2 and temp_part[-2] == '\\':
|
||||
pass
|
||||
else:
|
||||
in_string_char = None
|
||||
elif char_val in ("'", '"'):
|
||||
temp_part += char_val
|
||||
in_string_char = char_val
|
||||
elif char_val == '#':
|
||||
comment_starts_at = idx
|
||||
break # Found the first non-string comment char
|
||||
else:
|
||||
temp_part += char_val
|
||||
|
||||
if comment_starts_at != -1:
|
||||
code_part = processed_line[:comment_starts_at]
|
||||
comment_part = processed_line[comment_starts_at:] # Includes '#'
|
||||
|
||||
rstripped_code_part = code_part.rstrip()
|
||||
# Only add spaces if there's actual code before the comment
|
||||
if rstripped_code_part:
|
||||
if not code_part.endswith(' '): # Needs fixing (less than 2 spaces)
|
||||
new_line_with_comment_spacing = rstripped_code_part + ' ' + comment_part
|
||||
if new_line_with_comment_spacing != processed_line:
|
||||
logger.debug(f"L{line_num}: Adjusted inline comment spacing.")
|
||||
processed_line = new_line_with_comment_spacing
|
||||
# If rstripped_code_part is empty, it's a comment at the start of the line (after indent), leave it.
|
||||
|
||||
# Add back the newline character that rstrip might have removed, if the original line had one
|
||||
# or if it's not an empty line that was purely whitespace
|
||||
if line_content_with_eol.endswith('\n'):
|
||||
return processed_line + '\n'
|
||||
elif line_content_with_eol.endswith('\r\n'):
|
||||
return processed_line + '\r\n'
|
||||
elif line_content_with_eol.endswith('\r'):
|
||||
return processed_line + '\r'
|
||||
else: # Original line did not end with EOL
|
||||
return processed_line
|
||||
|
||||
|
||||
def process_file(filepath, expand_tabs_enabled=True, dry_run=False, no_backup=False):
|
||||
logger.info(f"Processing {filepath}...")
|
||||
try:
|
||||
# Read with universal newlines mode, then splitlines to preserve EOLs correctly for rejoining
|
||||
with open(filepath, 'r', encoding='utf-8', newline='') as f:
|
||||
# original_content = f.read() # Reading all at once
|
||||
# original_lines = original_content.splitlines(keepends=True) # This is better
|
||||
original_lines = f.readlines() # Simpler, usually works fine
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading file {filepath}: {e}")
|
||||
return False
|
||||
|
||||
fixed_lines = []
|
||||
changes_made = False
|
||||
for i, line_content_with_eol in enumerate(original_lines):
|
||||
fixed_line_content = process_line(i + 1, line_content_with_eol, expand_tabs_enabled)
|
||||
fixed_lines.append(fixed_line_content)
|
||||
if fixed_line_content != line_content_with_eol:
|
||||
changes_made = True
|
||||
logger.debug(f"L{i+1}: Original: {line_content_with_eol.rstrip()!r}")
|
||||
logger.debug(f"L{i+1}: Fixed : {fixed_line_content.rstrip()!r}")
|
||||
|
||||
if changes_made:
|
||||
if dry_run:
|
||||
logger.info(f"Would fix style issues in {filepath} (dry run)")
|
||||
else:
|
||||
if not no_backup:
|
||||
backup_dir = os.path.dirname(filepath)
|
||||
backup_filename = "." + os.path.basename(filepath) + "~"
|
||||
backup_path = os.path.join(backup_dir, backup_filename)
|
||||
try:
|
||||
shutil.copy2(filepath, backup_path) # copy2 preserves metadata
|
||||
logger.info(f"Backup of original file created at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup for {filepath}: {e}. File not modified.")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Write back lines using the original EOLs if possible, or common EOL
|
||||
with open(filepath, 'w', encoding='utf-8', newline='') as f:
|
||||
f.writelines(fixed_lines)
|
||||
logger.info(f"Fixed style issues in {filepath}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing to file {filepath}: {e}")
|
||||
return False
|
||||
else:
|
||||
logger.info(f"No style issues to fix in {filepath}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fix common Python code style issues (Flake8).")
|
||||
parser.add_argument("files", metavar="FILE", type=str, nargs='+',
|
||||
help="Python files to process")
|
||||
parser.add_argument("--no-expand-tabs", action="store_false", dest="expand_tabs",
|
||||
help="Disable expansion of tabs to spaces.")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Show what would be changed without modifying files.")
|
||||
parser.add_argument("--no-backup", action="store_true",
|
||||
help="Do not create a backup of the original file.")
|
||||
parser.add_argument("-v", "--verbose", action="store_true",
|
||||
help="Enable verbose (debug) logging.")
|
||||
parser.set_defaults(expand_tabs=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
for filepath in args.files:
|
||||
if not os.path.isfile(filepath):
|
||||
logger.warning(f"File not found: {filepath}. Skipping.")
|
||||
continue
|
||||
process_file(filepath, args.expand_tabs, args.dry_run, args.no_backup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user