459 lines
17 KiB
Python
Executable File
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() |