Files
fenrir/tools/validate_release.py

459 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Fenrir Release Validation Tool
Comprehensive validation suite for Fenrir releases, including syntax validation,
dependency checking, import testing, and basic functionality validation.
Usage:
python3 tools/validate_release.py # Full validation
python3 tools/validate_release.py --quick # Skip slow tests
python3 tools/validate_release.py --fix # Auto-fix issues where possible
"""
import ast
import os
import sys
import argparse
import subprocess
import tempfile
import importlib.util
from pathlib import Path
import time
class ReleaseValidator:
def __init__(self, verbose=True):
self.verbose = verbose
self.errors = []
self.warnings = []
self.fixes_applied = []
self.tests_run = 0
self.tests_passed = 0
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 run_test(self, name, test_func, *args, **kwargs):
"""Run a test and track results."""
self.tests_run += 1
try:
result = test_func(*args, **kwargs)
if result:
self.tests_passed += 1
self.log(f"{name}: PASSED", "SUCCESS")
else:
self.log(f"{name}: FAILED", "ERROR")
return result
except Exception as e:
self.log(f"{name}: ERROR - {e}", "ERROR")
self.errors.append(f"{name}: {e}")
return False
def validate_python_syntax(self, directory, fix_mode=False):
"""Validate Python syntax across all files."""
python_files = list(Path(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)]
syntax_errors = []
fixed_files = []
for filepath in python_files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
ast.parse(content, filename=str(filepath))
except SyntaxError as e:
syntax_errors.append((filepath, e))
if fix_mode:
# Try to fix common f-string issues
fixed_content = self.fix_fstring_issues(content)
if fixed_content != content:
try:
ast.parse(fixed_content, filename=str(filepath))
# Fix worked, write it back
with open(filepath, 'w', encoding='utf-8') as f:
f.write(fixed_content)
fixed_files.append(filepath)
syntax_errors.pop() # Remove from errors
except SyntaxError:
pass # Fix didn't work
except Exception as e:
syntax_errors.append((filepath, e))
if fixed_files:
self.fixes_applied.extend([f"Fixed f-string syntax in {f}" for f in fixed_files])
if syntax_errors:
for filepath, error in syntax_errors[:5]: # Show only first 5
if isinstance(error, SyntaxError):
self.errors.append(f"Syntax error in {filepath}:{error.lineno}: {error.msg}")
else:
self.errors.append(f"Error in {filepath}: {error}")
if len(syntax_errors) > 5:
self.errors.append(f"... and {len(syntax_errors) - 5} more syntax errors")
return len(syntax_errors) == 0
def fix_fstring_issues(self, content):
"""Fix common f-string syntax issues."""
lines = content.split('\n')
for i, line in enumerate(lines):
# Look for f-strings that span multiple lines incorrectly
if ('f"' in line and line.count('"') % 2 == 1 and
i + 1 < len(lines) and lines[i + 1].strip()):
next_line = lines[i + 1]
# Common patterns to fix
if (next_line.strip().endswith('}"') or
'str(e)}' in next_line or
next_line.strip().startswith(('fenrirVersion.', 'self.'))):
# Merge the lines properly
fixed_line = line.rstrip() + next_line.strip()
lines[i] = fixed_line
lines[i + 1] = ''
return '\n'.join(line for line in lines if line.strip() or not line)
def validate_dependencies(self):
"""Run the existing dependency checker."""
try:
result = subprocess.run([
sys.executable, "check-dependencies.py"
], capture_output=True, text=True, timeout=30)
if result.returncode == 0:
return True
else:
self.errors.append(f"Dependency check failed: {result.stderr}")
return False
except subprocess.TimeoutExpired:
self.errors.append("Dependency check timed out")
return False
except Exception as e:
self.errors.append(f"Could not run dependency check: {e}")
return False
def validate_core_imports(self):
"""Test importing core Fenrir modules."""
# Change to src directory for imports
original_path = sys.path.copy()
src_dir = Path.cwd() / "src"
if src_dir.exists():
sys.path.insert(0, str(src_dir))
core_modules = [
"fenrirscreenreader.core.fenrirManager",
"fenrirscreenreader.core.commandManager",
"fenrirscreenreader.core.eventManager",
"fenrirscreenreader.core.screenManager",
"fenrirscreenreader.core.inputManager",
"fenrirscreenreader.core.outputManager",
]
import_failures = []
for module_name in core_modules:
try:
importlib.import_module(module_name)
except ImportError as e:
import_failures.append(f"{module_name}: {e}")
except Exception as e:
import_failures.append(f"{module_name}: Unexpected error: {e}")
# Restore path
sys.path = original_path
if import_failures:
self.errors.extend(import_failures)
return False
return True
def validate_command_structure(self):
"""Validate command file structure and naming."""
commands_dir = Path("src/fenrirscreenreader/commands")
if not commands_dir.exists():
self.errors.append("Commands directory not found")
return False
issues = []
# Check command directories
expected_dirs = ["commands", "onHeartBeat", "onKeyInput", "onCursorChange",
"onScreenUpdate", "onScreenChanged", "vmenu-profiles"]
for expected_dir in expected_dirs:
if not (commands_dir / expected_dir).exists():
issues.append(f"Missing expected directory: {expected_dir}")
# Check for critical issues only (skip template files and base classes)
for py_file in commands_dir.rglob("*.py"):
if (py_file.name.startswith("__") or
"template" in py_file.name.lower() or
"base" in py_file.name.lower()):
continue
try:
with open(py_file, 'r', encoding='utf-8') as f:
content = f.read()
# Critical structure checks only
if "class command" not in content:
issues.append(f"{py_file}: Missing 'class command' definition")
# Skip method checks for files that inherit from base classes
if ("super().__init__" in content or
"importlib.util" in content or
"_base.py" in content):
continue # These inherit methods from base classes
# Only check direct implementations
# Special case: Application profile commands use load/unload instead of run
if "onSwitchApplicationProfile" in str(py_file):
if "def load" not in content and "def unload" not in content:
issues.append(f"{py_file}: Missing 'load' or 'unload' method")
else:
critical_methods = ["run"] # Focus on the most critical method
for method in critical_methods:
if (f"def {method}" not in content and
"super()" not in content): # Skip if uses inheritance
issues.append(f"{py_file}: Missing '{method}' method")
except Exception as e:
issues.append(f"{py_file}: Could not validate structure: {e}")
# Only report critical issues, not template/base class warnings
critical_issues = [issue for issue in issues if not any(skip in issue.lower()
for skip in ["template", "base", "missing 'initialize'", "missing 'shutdown'"])]
if critical_issues:
self.warnings.extend(critical_issues[:5]) # Limit warnings
if len(critical_issues) > 5:
self.warnings.append(f"... and {len(critical_issues) - 5} more critical command structure issues")
# Return success if no critical issues (warnings are acceptable)
return len(critical_issues) == 0
def validate_configuration_files(self):
"""Validate configuration file structure."""
config_dir = Path("config")
if not config_dir.exists():
self.errors.append("Config directory not found")
return False
required_configs = [
"settings/settings.conf",
"keyboard/desktop.conf",
"punctuation/default.conf"
]
missing_configs = []
for config_file in required_configs:
if not (config_dir / config_file).exists():
missing_configs.append(config_file)
if missing_configs:
self.errors.extend([f"Missing config file: {f}" for f in missing_configs])
return False
return True
def validate_installation_scripts(self):
"""Validate installation and setup scripts."""
required_scripts = ["setup.py", "install.sh", "uninstall.sh"]
missing_scripts = []
for script in required_scripts:
if not Path(script).exists():
missing_scripts.append(script)
if missing_scripts:
self.warnings.extend([f"Missing installation script: {s}" for s in missing_scripts])
# Check setup.py syntax if it exists
if Path("setup.py").exists():
try:
with open("setup.py", 'r') as f:
content = f.read()
ast.parse(content, filename="setup.py")
except SyntaxError as e:
self.errors.append(f"setup.py syntax error: {e}")
return False
return len(missing_scripts) == 0
def validate_repository_cleanliness(self):
"""Check for cache files and other artifacts that shouldn't be in git."""
# Check for Python cache files in git tracking
try:
result = subprocess.run([
"git", "ls-files", "--cached"
], capture_output=True, text=True, timeout=10)
if result.returncode == 0:
tracked_files = result.stdout.strip().split('\n')
cache_files = [f for f in tracked_files if '__pycache__' in f or f.endswith('.pyc')]
if cache_files:
self.errors.extend([f"Python cache file tracked in git: {f}" for f in cache_files[:5]])
if len(cache_files) > 5:
self.errors.append(f"... and {len(cache_files) - 5} more cache files in git")
return False
else:
return True
else:
self.warnings.append("Could not check git tracked files")
return True
except subprocess.TimeoutExpired:
self.warnings.append("Git check timed out")
return True
except Exception as e:
self.warnings.append(f"Could not check repository cleanliness: {e}")
return True
def generate_report(self):
"""Generate final validation report."""
self.log("FENRIR RELEASE VALIDATION REPORT", "HEADER")
# Test Summary
success_rate = (self.tests_passed / self.tests_run * 100) if self.tests_run > 0 else 0
self.log(f"Tests run: {self.tests_run}")
self.log(f"Tests passed: {self.tests_passed}")
self.log(f"Success rate: {success_rate:.1f}%")
# Fixes Applied
if self.fixes_applied:
self.log("\nAUTO-FIXES APPLIED:", "HEADER")
for fix in self.fixes_applied:
self.log(fix, "SUCCESS")
# Errors
if self.errors:
self.log(f"\nERRORS ({len(self.errors)}):", "HEADER")
for error in self.errors:
self.log(error, "ERROR")
# Warnings
if self.warnings:
self.log(f"\nWARNINGS ({len(self.warnings)}):", "HEADER")
for warning in self.warnings:
self.log(warning, "WARNING")
# Final Status
if not self.errors and success_rate >= 80:
self.log("\n🎉 RELEASE VALIDATION PASSED", "SUCCESS")
self.log("The codebase appears ready for release", "SUCCESS")
return True
elif not self.errors:
self.log("\n⚠️ RELEASE VALIDATION PASSED WITH WARNINGS", "WARNING")
self.log("Release is possible but issues should be addressed", "WARNING")
return True
else:
self.log("\n❌ RELEASE VALIDATION FAILED", "ERROR")
self.log("Critical issues must be fixed before release", "ERROR")
return False
def main():
parser = argparse.ArgumentParser(description='Comprehensive Fenrir release validation')
parser.add_argument('--quick', action='store_true',
help='Skip slow tests (dependency checking)')
parser.add_argument('--fix', action='store_true',
help='Attempt to fix issues automatically where possible')
parser.add_argument('--quiet', action='store_true',
help='Reduce output verbosity')
args = parser.parse_args()
# Ensure we're in the project root
if not Path("src/fenrirscreenreader").exists():
print("Error: Must be run from Fenrir project root directory")
sys.exit(1)
validator = ReleaseValidator(verbose=not args.quiet)
validator.log("FENRIR RELEASE VALIDATION STARTING", "HEADER")
start_time = time.time()
# Run validation tests
validator.run_test(
"Python syntax validation",
validator.validate_python_syntax,
"src/fenrirscreenreader",
args.fix
)
if not args.quick:
validator.run_test(
"Dependency validation",
validator.validate_dependencies
)
validator.run_test(
"Core module imports",
validator.validate_core_imports
)
validator.run_test(
"Command structure validation",
validator.validate_command_structure
)
validator.run_test(
"Configuration files validation",
validator.validate_configuration_files
)
validator.run_test(
"Installation scripts validation",
validator.validate_installation_scripts
)
validator.run_test(
"Repository cleanliness validation",
validator.validate_repository_cleanliness
)
# Generate final report
elapsed_time = time.time() - start_time
validator.log(f"\nValidation completed in {elapsed_time:.1f} seconds")
success = validator.generate_report()
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()