#!/usr/bin/env python3 """ Build script for Text Texture Generator addon. Creates ZIP packages for both full and free versions. """ import os import sys import json import shutil import zipfile import subprocess from pathlib import Path from typing import Dict, List, Optional import tempfile import argparse import fnmatch import ast import tokenize import io import re def minify_python_code(source_code: str) -> str: """ Minify Python source code by removing comments and docstrings. This function: 1. Removes all # comments (inline and standalone) 2. Preserves bl_info dictionary (Blender requirement) 3. Removes other function/class docstrings 4. Optimizes whitespace (max 1 blank line between definitions) 5. Maintains valid Python syntax Args: source_code: Python source code to minify Returns: Minified Python code as a string """ # Parse the code to find docstring line ranges to remove try: tree = ast.parse(source_code) except SyntaxError: return source_code # Collect line numbers of docstrings to remove docstring_lines_to_remove = set() def process_body(body, parent_is_module=False): """Process a body of statements to find docstrings.""" if not body: return first_stmt = body[0] if isinstance(first_stmt, ast.Expr): value = first_stmt.value if isinstance(value, (ast.Str, ast.Constant)): # Check if this is a docstring (string literal as first statement) if isinstance(value, ast.Constant) and not isinstance(value.value, str): return # Always remove docstrings (module, class, and function) # The bl_info DICT itself will be preserved by not being marked # Mark these lines for removal for lineno in range(first_stmt.lineno, first_stmt.end_lineno + 1): docstring_lines_to_remove.add(lineno) # Process module-level docstrings process_body(tree.body, parent_is_module=True) # Process function and class docstrings for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): process_body(node.body, parent_is_module=False) # First pass: Remove comments and docstring lines using tokenize # This safely handles # symbols inside strings result_lines = [] try: tokens = tokenize.generate_tokens(io.StringIO(source_code).readline) last_lineno = -1 last_col = 0 for tok in tokens: token_type = tok[0] token_string = tok[1] start_line, start_col = tok[2] end_line, end_col = tok[3] # Skip comments if token_type == tokenize.COMMENT: continue # Skip tokens that are part of docstrings we want to remove if start_line in docstring_lines_to_remove: continue # Handle newlines and track lines if start_line > last_lineno: last_col = 0 # Add spacing if needed if start_col > last_col: result_lines.append(" " * (start_col - last_col)) # Add the token result_lines.append(token_string) # Update position tracking last_col = end_col last_lineno = end_line except tokenize.TokenError: # If tokenization fails, return original return source_code # Join tokens back into source result = "".join(result_lines) # Second pass: Optimize whitespace (max 1 blank line between definitions) lines = result.split('\n') optimized_lines = [] blank_count = 0 for line in lines: if line.strip() == '': blank_count += 1 # Allow max 1 consecutive blank line if blank_count <= 1: optimized_lines.append(line) else: blank_count = 0 optimized_lines.append(line) # Remove trailing blank lines while optimized_lines and optimized_lines[-1].strip() == '': optimized_lines.pop() return '\n'.join(optimized_lines) 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) def load_exclusions_config() -> Dict: """Load file exclusions configuration.""" config_path = get_project_root() / "build" / "file_exclusions.json" with open(config_path, 'r') as f: return json.load(f) def clean_directory(path: Path): """Clean a directory by removing all contents.""" if path.exists(): shutil.rmtree(path) path.mkdir(parents=True, exist_ok=True) def should_exclude_file(file_path: Path, source_dir: Path, exclude_patterns: List[str], exclude_files: List[str]) -> bool: """Check if a file should be excluded based on patterns and specific files.""" file_str = str(file_path) name = file_path.name # Get relative path from source directory for exclusion checking try: rel_path = file_path.relative_to(source_dir) rel_path_str = str(rel_path) except ValueError: rel_path_str = str(file_path) # Check specific file exclusions for exclude_file in exclude_files: # Handle directory exclusions (ending with /) if exclude_file.endswith('/'): exclude_dir = exclude_file.rstrip('/') if rel_path_str.startswith(exclude_dir + '/') or rel_path_str == exclude_dir: return True # Handle exact file matches elif rel_path_str == exclude_file: return True # Check general patterns (for cache files, etc.) for pattern in exclude_patterns: if pattern in file_str or pattern in name: return True # Handle wildcards if pattern.startswith('*.'): extension = pattern[1:] if file_str.endswith(extension): return True # Handle glob patterns if fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(rel_path_str, pattern): return True return False def copy_source_files(source_dir: Path, dest_dir: Path, exclude_patterns: List[str], exclude_files: List[str] = None, minify: bool = True): """Copy source files to destination, excluding specified patterns and files.""" if exclude_files is None: exclude_files = [] print(f"Copying files from {source_dir} to {dest_dir}") print(f"Excluding files: {exclude_files}") print(f"Excluding patterns: {exclude_patterns}") copied_count = 0 excluded_count = 0 for item in source_dir.rglob('*'): if item.is_file(): if should_exclude_file(item, source_dir, exclude_patterns, exclude_files): rel_path = item.relative_to(source_dir) print(f" Excluded: {rel_path}") excluded_count += 1 else: # Calculate relative path rel_path = item.relative_to(source_dir) dest_file = dest_dir / rel_path # Create parent directories dest_file.parent.mkdir(parents=True, exist_ok=True) # Copy file with optional minification for Python files if minify and item.suffix == '.py': # Read, minify, and write Python files try: source_code = item.read_text(encoding='utf-8') minified_code = minify_python_code(source_code) dest_file.write_text(minified_code, encoding='utf-8') print(f" Copied (minified): {rel_path}") except Exception as e: # If minification fails, fall back to regular copy print(f" Warning: Could not minify {rel_path}, copying as-is: {e}") shutil.copy2(item, dest_file) print(f" Copied: {rel_path}") else: # Copy non-Python files or when minification is disabled shutil.copy2(item, dest_file) print(f" Copied: {rel_path}") copied_count += 1 print(f"Files copied: {copied_count}, excluded: {excluded_count}") def create_zip_package(source_dir: Path, output_file: Path, addon_name: str): """Create a ZIP package from source directory.""" print(f"Creating ZIP package: {output_file}") with zipfile.ZipFile(output_file, 'w', zipfile.ZIP_DEFLATED) as zip_file: for file_path in source_dir.rglob('*'): if file_path.is_file(): # Calculate archive path (include addon name as root folder) rel_path = file_path.relative_to(source_dir) archive_path = Path(addon_name) / rel_path zip_file.write(file_path, archive_path) print(f" Added to ZIP: {archive_path}") print(f"ZIP package created: {output_file}") def get_version_from_git() -> Optional[str]: """Get version from git tags.""" project_root = get_project_root() try: config = load_config() version_pattern = config['versions']['git_tag_pattern'] except Exception as e: print(f"Warning: Could not load version pattern: {e}") version_pattern = r"^v?([0-9]+\.[0-9]+\.[0-9]+)$" version_regex = re.compile(version_pattern) try: result = subprocess.run( ['git', 'tag', '-l', '--sort=-version:refname'], capture_output=True, text=True, cwd=project_root, check=True ) except subprocess.CalledProcessError as e: print(f"Warning: Could not get version from git: {e}") return None except FileNotFoundError: print("Warning: Git executable not found.") return None except Exception as e: print(f"Warning: Unexpected error while reading git tags: {e}") return None for tag in result.stdout.splitlines(): tag = tag.strip() if not tag: continue match = version_regex.match(tag) if match: # Prefer captured group when pattern defines one if match.lastindex: return match.group(1) return tag.lstrip('v') return None def build_addon(version_type: str, version: Optional[str] = None, output_dir: Optional[Path] = None, minify: bool = True): """Build addon for specified version type.""" # Use current working directory as project root (supports testing with temp dirs) project_root = Path.cwd() # Load configs from project root config_path = project_root / "build" / "config.json" with open(config_path, 'r') as f: config = json.load(f) exclusions_path = project_root / "build" / "file_exclusions.json" with open(exclusions_path, 'r') as f: exclusions_config = json.load(f) # Determine version if version is None: version = get_version_from_git() or "1.0.0" # Set up paths source_dir = project_root / config['build']['source_dir'] if output_dir is None: output_dir = project_root / config['build']['dist_dir'] temp_dir = project_root / config['build']['temp_dir'] # Clean and create directories clean_directory(temp_dir) output_dir.mkdir(parents=True, exist_ok=True) # Sync version files for the specific version type (simplified - no feature flags) print(f"Synchronizing version files for {version_type} version {version}") sync_result = subprocess.run([ 'python3', 'build/sync_version.py', '--version', version, '--type', version_type ], cwd=project_root) if sync_result.returncode != 0: raise RuntimeError(f"Version synchronization failed") # Get version-specific exclusions version_config = exclusions_config['version_configs'][version_type] exclude_files = version_config.get('exclude_files', []) exclude_patterns = config['build']['exclude_patterns'] + version_config.get('exclude_patterns', []) print(f"\nšŸ“‹ Build configuration for {version_type} version:") print(f"Max texture size: {version_config.get('max_texture_size', 'unlimited')}") print(f"Max concurrent operations: {version_config.get('max_concurrent_operations', 'unlimited')}") if exclude_files: print(f"Files to exclude: {exclude_files}") if version_config.get('exclude_patterns'): print(f"Additional patterns to exclude: {version_config.get('exclude_patterns')}") # Copy source files to temp directory with version-specific exclusions build_temp_dir = temp_dir / f"{config['project']['id']}_{version_type}" copy_source_files(source_dir, build_temp_dir, exclude_patterns, exclude_files, minify) # Create output filename version_suffix = f"_{version_type}" if version_type == "free" else "" output_filename = f"{config['project']['id']}_v{version}{version_suffix}.zip" output_file = output_dir / output_filename # Create ZIP package create_zip_package(build_temp_dir, output_file, config['project']['id']) # Clean up temp directory shutil.rmtree(temp_dir) print(f"\nāœ… Build complete!") print(f"Package: {output_file}") print(f"Version: {version} ({version_type})") return output_file def build_all_versions(version: Optional[str] = None, minify: bool = True): """Build both full and free versions.""" print("Building all versions...") built_packages = [] # Build full version print("\n" + "="*50) print("BUILDING FULL VERSION") print("="*50) full_package = build_addon("full", version, minify=minify) built_packages.append(("full", full_package)) # Build free version print("\n" + "="*50) print("BUILDING FREE VERSION") print("="*50) free_package = build_addon("free", version, minify=minify) built_packages.append(("free", free_package)) print("\n" + "="*50) print("BUILD SUMMARY") print("="*50) for version_type, package_path in built_packages: file_size = package_path.stat().st_size / 1024 # KB print(f"{version_type.upper():>4}: {package_path.name} ({file_size:.1f} KB)") return built_packages def main(): """Main entry point.""" parser = argparse.ArgumentParser(description="Build Text Texture Generator addon") parser.add_argument("--version", help="Version to build (defaults to git tag)") parser.add_argument("--type", choices=["free", "full", "all"], default="all", help="Version type to build") parser.add_argument("--output", type=Path, help="Output directory") parser.add_argument("--minify", dest="minify", action="store_true", default=True, help="Minify Python code (default: enabled)") parser.add_argument("--no-minify", dest="minify", action="store_false", help="Disable Python code minification") args = parser.parse_args() try: if args.type == "all": build_all_versions(args.version, args.minify) else: build_addon(args.type, args.version, args.output, args.minify) except Exception as e: print(f"āŒ Build failed: {e}") sys.exit(1) if __name__ == "__main__": main()