A few improvements to validation.

This commit is contained in:
Storm Dragon
2025-07-24 14:05:51 -04:00
parent 8c233e0385
commit 94a1acbaca
4 changed files with 404 additions and 0 deletions

365
tools/validate_pep8.py Executable file
View File

@ -0,0 +1,365 @@
#!/usr/bin/env python3
"""
Fenrir PEP8 Validation and Auto-Fix Tool
Validates Python code style using flake8 and applies safe automatic fixes.
Designed to work with Fenrir's existing codebase while respecting timing-critical code.
Usage:
python3 tools/validate_pep8.py # Check all Python files
python3 tools/validate_pep8.py --fix-safe # Auto-fix safe issues
python3 tools/validate_pep8.py --check-only # Exit with error if issues found
python3 tools/validate_pep8.py --staged # Check only staged files
"""
import os
import sys
import argparse
import subprocess
import tempfile
from pathlib import Path
class PEP8Validator:
def __init__(self, verbose=True):
self.verbose = verbose
self.errors = []
self.warnings = []
self.fixes_applied = []
def log(self, message, level="INFO"):
"""Log a message with appropriate formatting."""
if not self.verbose and level == "INFO":
return
colors = {
"INFO": "\033[0;36m", # Cyan
"SUCCESS": "\033[0;32m", # Green
"WARNING": "\033[1;33m", # Yellow
"ERROR": "\033[0;31m", # Red
"HEADER": "\033[1;34m", # Bold Blue
}
reset = "\033[0m"
color = colors.get(level, "")
if level == "HEADER":
print(f"\n{color}{'='*60}")
print(f"{message}")
print(f"{'='*60}{reset}")
else:
symbol = {
"SUCCESS": "",
"ERROR": "",
"WARNING": "",
"INFO": ""
}.get(level, "")
print(f"{color}{symbol} {message}{reset}")
def check_flake8_available(self):
"""Check if flake8 is available."""
try:
result = subprocess.run(["flake8", "--version"],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
version = result.stdout.strip().split('\n')[0]
self.log(f"Using flake8: {version}")
return True
else:
return False
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
def get_python_files(self, directory=None, staged_only=False):
"""Get list of Python files to check."""
if staged_only:
try:
result = subprocess.run([
"git", "diff", "--cached", "--name-only", "--diff-filter=ACM"
], capture_output=True, text=True, timeout=10)
if result.returncode == 0:
files = [f for f in result.stdout.strip().split('\n')
if f.endswith('.py') and Path(f).exists()]
return [Path(f) for f in files if f]
else:
self.warnings.append("Could not get staged files, checking all files")
staged_only = False
except subprocess.TimeoutExpired:
self.warnings.append("Git command timed out, checking all files")
staged_only = False
if not staged_only:
directory = Path(directory or "src/fenrirscreenreader")
if not directory.exists():
self.errors.append(f"Directory {directory} does not exist")
return []
python_files = list(directory.rglob("*.py"))
# Filter out cache and build directories
python_files = [f for f in python_files if not any(
part.startswith(('__pycache__', '.git', 'build', 'dist'))
for part in f.parts)]
return python_files
return []
def run_flake8(self, files, select=None, ignore=None):
"""Run flake8 on the given files."""
if not files:
return True, ""
cmd = ["flake8"]
if select:
cmd.extend(["--select", select])
if ignore:
cmd.extend(["--ignore", ignore])
# Add files
cmd.extend([str(f) for f in files])
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return result.returncode == 0, result.stdout
except subprocess.TimeoutExpired:
self.errors.append("flake8 command timed out")
return False, ""
except Exception as e:
self.errors.append(f"Failed to run flake8: {e}")
return False, ""
def categorize_issues(self, flake8_output):
"""Categorize flake8 issues by severity and safety for auto-fixing."""
lines = flake8_output.strip().split('\n')
issues = {'critical': [], 'safe_fixable': [], 'manual': []}
for line in lines:
if not line.strip():
continue
# Parse flake8 output: filename:line:col: code message
parts = line.split(':', 3)
if len(parts) < 4:
continue
filename = parts[0]
line_num = parts[1]
col = parts[2]
code_msg = parts[3].strip()
code = code_msg.split()[0] if code_msg else ""
# Categorize by error code
if code.startswith('E9') or code.startswith('F'):
# Critical syntax/import errors
issues['critical'].append(line)
elif code in ['E111', 'E114', 'E117', 'E121', 'E122', 'E123', 'E124',
'E125', 'E126', 'E127', 'E128', 'E129', 'E131', 'E133',
'W291', 'W292', 'W293']:
# Safe indentation and whitespace fixes
# But skip timing-critical files
if not any(critical in filename.lower() for critical in
['evdevdriver', 'vcsadriver', 'screenmanager', 'inputmanager']):
issues['safe_fixable'].append(line)
else:
issues['manual'].append(line)
else:
# Everything else needs manual review
issues['manual'].append(line)
return issues
def apply_safe_fixes(self, files):
"""Apply safe automatic fixes using autopep8."""
try:
# Check if autopep8 is available
result = subprocess.run(["autopep8", "--version"],
capture_output=True, text=True, timeout=5)
if result.returncode != 0:
self.warnings.append("autopep8 not available for auto-fixing")
return False
except (subprocess.TimeoutExpired, FileNotFoundError):
self.warnings.append("autopep8 not available for auto-fixing")
return False
fixed_count = 0
for file_path in files:
# Skip timing-critical files
if any(critical in str(file_path).lower() for critical in
['evdevdriver', 'vcsadriver', 'screenmanager', 'inputmanager']):
self.log(f"Skipping timing-critical file: {file_path}", "WARNING")
continue
try:
# Apply safe fixes only
cmd = [
"autopep8",
"--in-place",
"--select", "E111,E114,E117,E121,E122,E123,E124,E125,E126,E127,E128,E129,E131,E133,W291,W292,W293",
str(file_path)
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
self.fixes_applied.append(f"Applied safe PEP8 fixes to {file_path}")
fixed_count += 1
else:
self.warnings.append(f"Could not auto-fix {file_path}: {result.stderr}")
except subprocess.TimeoutExpired:
self.warnings.append(f"Auto-fix timed out for {file_path}")
except Exception as e:
self.warnings.append(f"Error auto-fixing {file_path}: {e}")
return fixed_count > 0
def validate_files(self, files, fix_safe=False):
"""Validate Python files for PEP8 compliance."""
if not files:
self.log("No Python files to validate")
return True
self.log(f"Validating {len(files)} Python files")
# Run comprehensive flake8 check
success, output = self.run_flake8(files)
if not output.strip():
self.log("All files pass PEP8 validation", "SUCCESS")
return True
# Categorize issues
issues = self.categorize_issues(output)
# Report critical issues
if issues['critical']:
self.log(f"Critical issues found ({len(issues['critical'])}):", "ERROR")
for issue in issues['critical'][:10]: # Limit output
self.log(f" {issue}", "ERROR")
if len(issues['critical']) > 10:
self.log(f" ... and {len(issues['critical']) - 10} more", "ERROR")
# Handle safe fixable issues
if issues['safe_fixable']:
if fix_safe:
self.log(f"Auto-fixing {len(issues['safe_fixable'])} safe issues...", "INFO")
# Get unique files from safe_fixable issues
fix_files = set()
for issue in issues['safe_fixable']:
filename = issue.split(':')[0]
fix_files.add(Path(filename))
if self.apply_safe_fixes(fix_files):
self.log("Safe auto-fixes applied", "SUCCESS")
# Re-run flake8 to see remaining issues
success, output = self.run_flake8(files)
if output.strip():
remaining_issues = self.categorize_issues(output)
issues = remaining_issues
else:
issues = {'critical': [], 'safe_fixable': [], 'manual': []}
else:
self.log(f"Safe fixable issues found ({len(issues['safe_fixable'])}):", "WARNING")
for issue in issues['safe_fixable'][:5]:
self.log(f" {issue}", "WARNING")
if len(issues['safe_fixable']) > 5:
self.log(f" ... and {len(issues['safe_fixable']) - 5} more", "WARNING")
self.log("Run with --fix-safe to auto-fix these", "INFO")
# Report manual issues
if issues['manual']:
self.log(f"Manual review needed ({len(issues['manual'])}):", "WARNING")
for issue in issues['manual'][:5]:
self.log(f" {issue}", "WARNING")
if len(issues['manual']) > 5:
self.log(f" ... and {len(issues['manual']) - 5} more", "WARNING")
# Return success if only manual issues remain (non-critical)
return len(issues['critical']) == 0
def generate_report(self):
"""Generate final validation report."""
total_issues = len(self.errors) + len(self.warnings)
if self.fixes_applied:
self.log(f"\nAUTO-FIXES APPLIED ({len(self.fixes_applied)}):", "HEADER")
for fix in self.fixes_applied:
self.log(fix, "SUCCESS")
if self.errors:
self.log(f"\nERRORS ({len(self.errors)}):", "HEADER")
for error in self.errors:
self.log(error, "ERROR")
if self.warnings:
self.log(f"\nWARNINGS ({len(self.warnings)}):", "HEADER")
for warning in self.warnings:
self.log(warning, "WARNING")
if len(self.errors) == 0:
self.log("\n✅ PEP8 VALIDATION PASSED", "SUCCESS")
if self.warnings:
self.log("Non-critical style issues found - consider manual review", "INFO")
return True
else:
self.log("\n❌ PEP8 VALIDATION FAILED", "ERROR")
self.log("Critical issues must be fixed", "ERROR")
return False
def main():
parser = argparse.ArgumentParser(description='Validate and fix PEP8 compliance in Fenrir')
parser.add_argument('--fix-safe', action='store_true',
help='Apply safe automatic fixes (avoids timing-critical files)')
parser.add_argument('--check-only', action='store_true',
help='Exit with non-zero code if issues found')
parser.add_argument('--staged', action='store_true',
help='Check only staged files')
parser.add_argument('--quiet', action='store_true',
help='Reduce output verbosity')
parser.add_argument('--directory', default='src/fenrirscreenreader',
help='Directory to scan (default: src/fenrirscreenreader)')
args = parser.parse_args()
validator = PEP8Validator(verbose=not args.quiet)
validator.log("FENRIR PEP8 VALIDATION", "HEADER")
# Check if flake8 is available
if not validator.check_flake8_available():
validator.log("flake8 is required but not available", "ERROR")
validator.log("Install with: pip install flake8", "INFO")
if args.fix_safe:
validator.log("For auto-fixing, also install: pip install autopep8", "INFO")
sys.exit(1)
# Get files to validate
files = validator.get_python_files(
directory=args.directory if not args.staged else None,
staged_only=args.staged
)
if not files:
validator.log("No Python files found to validate")
sys.exit(0)
# Validate files
success = validator.validate_files(files, fix_safe=args.fix_safe)
# Generate report
validation_passed = validator.generate_report()
if args.check_only and not validation_passed:
sys.exit(1)
elif validation_passed:
sys.exit(0)
else:
sys.exit(1)
if __name__ == '__main__':
main()