#!/usr/bin/env python3 """ Package validation script for Text Texture Generator addon. Validates ZIP packages to ensure they meet Blender addon requirements. """ import os import sys import json import zipfile import tempfile from pathlib import Path from typing import Dict, List, Tuple, Optional import argparse def get_project_root() -> Path: """Get the project root directory.""" return Path(__file__).parent.parent def load_config() -> Dict: """Load build configuration.""" config_path = get_project_root() / "build" / "config.json" with open(config_path, 'r') as f: return json.load(f) class ValidationResult: """Holds validation results.""" def __init__(self): self.errors = [] self.warnings = [] self.info = [] self.is_valid = True def add_error(self, message: str): """Add an error message.""" self.errors.append(message) self.is_valid = False def add_warning(self, message: str): """Add a warning message.""" self.warnings.append(message) def add_info(self, message: str): """Add an info message.""" self.info.append(message) def print_results(self): """Print validation results.""" if self.info: print("\nšŸ“‹ Information:") for msg in self.info: print(f" ā„¹ļø {msg}") if self.warnings: print("\nāš ļø Warnings:") for msg in self.warnings: print(f" āš ļø {msg}") if self.errors: print("\nāŒ Errors:") for msg in self.errors: print(f" āŒ {msg}") else: print("\nāœ… No errors found!") print(f"\nValidation Result: {'āœ… PASS' if self.is_valid else 'āŒ FAIL'}") def validate_zip_structure(zip_path: Path, config: Dict) -> ValidationResult: """Validate the ZIP file structure.""" result = ValidationResult() addon_id = config['project']['id'] try: with zipfile.ZipFile(zip_path, 'r') as zip_file: file_list = zip_file.namelist() # Check if all files are within the addon directory addon_root = f"{addon_id}/" files_in_root = [f for f in file_list if f.startswith(addon_root)] if len(files_in_root) != len(file_list): result.add_error(f"Some files are not in the addon root directory '{addon_root}'") result.add_info(f"ZIP contains {len(file_list)} files") # Required files required_files = [ f"{addon_id}/__init__.py", f"{addon_id}/blender_manifest.toml" ] for required_file in required_files: if required_file not in file_list: result.add_error(f"Missing required file: {required_file}") else: result.add_info(f"Found required file: {required_file}") # Check for excluded files exclude_patterns = config['build']['exclude_patterns'] for file_path in file_list: for pattern in exclude_patterns: if pattern in file_path: result.add_warning(f"Excluded pattern found in package: {file_path}") except zipfile.BadZipFile: result.add_error("Invalid ZIP file") except Exception as e: result.add_error(f"Error reading ZIP file: {e}") return result def validate_manifest(zip_path: Path, config: Dict, version_type: str) -> ValidationResult: """Validate the blender_manifest.toml file.""" result = ValidationResult() addon_id = config['project']['id'] manifest_path = f"{addon_id}/blender_manifest.toml" try: with zipfile.ZipFile(zip_path, 'r') as zip_file: if manifest_path not in zip_file.namelist(): result.add_error("blender_manifest.toml not found in package") return result # Read and parse manifest manifest_content = zip_file.read(manifest_path).decode('utf-8') result.add_info("Successfully read blender_manifest.toml") # Check for required fields required_fields = ['id', 'version', 'name', 'blender_version_min'] for field in required_fields: if f'{field} = ' not in manifest_content: result.add_error(f"Missing required field in manifest: {field}") # Validate specific values if f'id = "{addon_id}"' not in manifest_content: result.add_error(f"Incorrect addon ID in manifest (expected: {addon_id})") # Check version format import re version_pattern = r'version = "(\d+\.\d+\.\d+)"' version_match = re.search(version_pattern, manifest_content) if version_match: version = version_match.group(1) result.add_info(f"Package version: {version}") else: result.add_error("Invalid or missing version format in manifest") except Exception as e: result.add_error(f"Error validating manifest: {e}") return result def validate_constants(zip_path: Path, config: Dict, version_type: str) -> ValidationResult: """Validate the constants.py file for feature flags.""" result = ValidationResult() addon_id = config['project']['id'] constants_path = f"{addon_id}/utils/constants.py" try: with zipfile.ZipFile(zip_path, 'r') as zip_file: if constants_path not in zip_file.namelist(): result.add_error("utils/constants.py not found in package") return result # Read constants file constants_content = zip_file.read(constants_path).decode('utf-8') result.add_info("Successfully read utils/constants.py") # Check for feature flags if 'ENABLED_FEATURES' not in constants_content: result.add_error("ENABLED_FEATURES not found in constants.py") if 'VERSION_TYPE' not in constants_content: result.add_error("VERSION_TYPE not found in constants.py") else: # Check if version type matches expected if f'VERSION_TYPE = "{version_type}"' not in constants_content: result.add_warning(f"VERSION_TYPE might not match expected type: {version_type}") # Check for version limits functions if 'get_version_limits' not in constants_content: result.add_error("get_version_limits function not found in constants.py") except Exception as e: result.add_error(f"Error validating constants: {e}") return result def validate_version_differences(zip_path: Path, config: Dict, version_type: str) -> ValidationResult: """Validate version-specific content and file exclusions.""" result = ValidationResult() addon_id = config['project']['id'] # Load exclusions configuration exclusions_path = get_project_root() / "build" / "file_exclusions.json" try: with open(exclusions_path, 'r') as f: exclusions_config = json.load(f) except Exception as e: result.add_error(f"Could not load file exclusions config: {e}") return result version_config = exclusions_config['version_configs'].get(version_type, {}) excluded_files = version_config.get('exclude_files', []) excluded_patterns = version_config.get('exclude_patterns', []) try: with zipfile.ZipFile(zip_path, 'r') as zip_file: file_list = zip_file.namelist() # Check that excluded files are actually excluded in free version if version_type == "free": excluded_found = [] for excluded_file in excluded_files: full_path = f"{addon_id}/{excluded_file}" if full_path in file_list or any(f.startswith(full_path) for f in file_list): excluded_found.append(excluded_file) if excluded_found: result.add_error(f"Free version contains excluded files: {excluded_found}") else: result.add_info(f"Correctly excluded {len(excluded_files)} premium files from free version") # Check for excluded patterns pattern_violations = [] for file_path in file_list: for pattern in excluded_patterns: if pattern.strip('*').lower() in file_path.lower(): pattern_violations.append((file_path, pattern)) if pattern_violations: result.add_warning(f"Files matching excluded patterns found: {pattern_violations}") elif version_type == "full": # Full version should contain premium files missing_premium = [] for premium_file in excluded_files: full_path = f"{addon_id}/{premium_file}" if full_path not in file_list and not any(f.startswith(full_path) for f in file_list): missing_premium.append(premium_file) if missing_premium: result.add_error(f"Full version missing premium files: {missing_premium}") else: result.add_info(f"Full version correctly includes all {len(excluded_files)} premium features") # Validate UI helper functions are present in constants.py constants_path = f"{addon_id}/utils/constants.py" if constants_path in file_list: constants_content = zip_file.read(constants_path).decode('utf-8') ui_functions = [ 'get_version_badge_text', 'get_version_badge_icon', 'get_upgrade_hint_text', 'should_show_feature_lock', 'get_feature_availability_text', 'get_version_info_details' ] missing_ui_functions = [] for func in ui_functions: if func not in constants_content: missing_ui_functions.append(func) if missing_ui_functions: result.add_error(f"Missing UI helper functions in constants.py: {missing_ui_functions}") else: result.add_info("All UI helper functions present in constants.py") except Exception as e: result.add_error(f"Error validating version differences: {e}") return result def validate_feature_flags(zip_path: Path, config: Dict) -> ValidationResult: """Validate the feature flags system.""" result = ValidationResult() addon_id = config['project']['id'] feature_flags_path = f"{addon_id}/utils/feature_flags.py" try: with zipfile.ZipFile(zip_path, 'r') as zip_file: if feature_flags_path not in zip_file.namelist(): result.add_warning("utils/feature_flags.py not found in package") return result # Read feature flags file feature_content = zip_file.read(feature_flags_path).decode('utf-8') result.add_info("Successfully read utils/feature_flags.py") # Check for essential classes and functions required_items = [ 'FeatureManager', 'is_feature_enabled', 'require_feature', 'feature_required', 'FeatureNotAvailableError' ] for item in required_items: if item not in feature_content: result.add_warning(f"Missing feature flag component: {item}") else: result.add_info(f"Found feature flag component: {item}") except Exception as e: result.add_error(f"Error validating feature flags: {e}") return result def detect_version_type(zip_path: Path) -> Optional[str]: """Detect version type from ZIP filename or contents.""" filename = zip_path.name.lower() if '_free' in filename: return 'free' elif 'free' in filename: return 'free' else: return 'full' # Default assumption def validate_package(zip_path: Path, version_type: Optional[str] = None) -> bool: """Validate a complete addon package.""" if not zip_path.exists(): print(f"āŒ Package file not found: {zip_path}") return False if version_type is None: version_type = detect_version_type(zip_path) print(f"šŸ” Validating package: {zip_path.name}") print(f"šŸ“‹ Detected version type: {version_type}") print("=" * 60) config = load_config() overall_valid = True # Structure validation print("Validating ZIP structure...") structure_result = validate_zip_structure(zip_path, config) structure_result.print_results() overall_valid &= structure_result.is_valid print("\n" + "─" * 60) # Manifest validation print("Validating blender_manifest.toml...") manifest_result = validate_manifest(zip_path, config, version_type) manifest_result.print_results() overall_valid &= manifest_result.is_valid print("\n" + "─" * 60) # Constants validation print("Validating utils/constants.py...") constants_result = validate_constants(zip_path, config, version_type) constants_result.print_results() overall_valid &= constants_result.is_valid print("\n" + "─" * 60) # Feature flags validation print("Validating feature flags system...") features_result = validate_feature_flags(zip_path, config) features_result.print_results() overall_valid &= features_result.is_valid print("\n" + "─" * 60) # Version differences validation print("Validating version-specific content and exclusions...") version_diff_result = validate_version_differences(zip_path, config, version_type) version_diff_result.print_results() overall_valid &= version_diff_result.is_valid print("\n" + "=" * 60) print(f"Overall Validation: {'āœ… PASS' if overall_valid else 'āŒ FAIL'}") return overall_valid def main(): """Main entry point.""" parser = argparse.ArgumentParser(description="Validate Text Texture Generator addon package") parser.add_argument("package", type=Path, help="Path to ZIP package to validate") parser.add_argument("--type", choices=["free", "full"], help="Expected version type") args = parser.parse_args() try: is_valid = validate_package(args.package, args.type) sys.exit(0 if is_valid else 1) except Exception as e: print(f"āŒ Validation failed: {e}") sys.exit(1) if __name__ == "__main__": main()