#!/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()