Files
Text-Texture-Generator-for-…/scripts/build_addon.py
2025-10-13 17:37:32 +02:00

437 lines
16 KiB
Python
Executable File

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