486 lines
18 KiB
Python
Executable File
486 lines
18 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 _format_version_tuple(version_tuple: tuple) -> str:
|
|
"""Format version tuple for insertion into bl_info."""
|
|
return "(" + ", ".join(str(part) for part in version_tuple) + ")"
|
|
|
|
|
|
def _edition_version_tuple(version: str, version_type: str) -> tuple:
|
|
"""Return edition-specific version tuple."""
|
|
try:
|
|
major_str, minor_str, patch_str = version.split(".", 2)
|
|
major, minor, patch = int(major_str), int(minor_str), int(patch_str)
|
|
except ValueError:
|
|
# Fallback if version string unexpected
|
|
return (1, 0, 0) if version_type == "free" else (1, 0, 1)
|
|
|
|
if version_type == "full":
|
|
return (major, minor, patch + 1)
|
|
return (major, minor, patch)
|
|
|
|
|
|
def _apply_edition_overrides(rel_path: Path, source_code: str, version_type: str, version: str) -> str:
|
|
"""Inject edition-specific metadata into copied source files."""
|
|
if rel_path.as_posix() == "__init__.py":
|
|
edition_name = "Text Texture Generator (Pro)" if version_type == "full" else "Text Texture Generator (Free)"
|
|
target_version_tuple = _edition_version_tuple(version, version_type)
|
|
|
|
source_code = re.sub(
|
|
r'"name":\s*"Text Texture Generator\s*\([^"]*\)"',
|
|
f'"name": "{edition_name}"',
|
|
source_code,
|
|
count=1
|
|
)
|
|
|
|
source_code = re.sub(
|
|
r'"version":\s*\([^\)]+\)',
|
|
f'"version": {_format_version_tuple(target_version_tuple)}',
|
|
source_code,
|
|
count=1
|
|
)
|
|
return source_code
|
|
|
|
|
|
def copy_source_files(source_dir: Path, dest_dir: Path, exclude_patterns: List[str], exclude_files: List[str] = None, minify: bool = True, version_type: str = "free", version: str = "1.0.0"):
|
|
"""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')
|
|
source_code = _apply_edition_overrides(rel_path, source_code, version_type, version)
|
|
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}")
|
|
source_code = item.read_text(encoding='utf-8')
|
|
source_code = _apply_edition_overrides(rel_path, source_code, version_type, version)
|
|
dest_file.write_text(source_code, encoding='utf-8')
|
|
print(f" Copied: {rel_path}")
|
|
else:
|
|
# Copy non-Python files or when minification is disabled
|
|
if item.suffix == '.py':
|
|
source_code = item.read_text(encoding='utf-8')
|
|
source_code = _apply_edition_overrides(rel_path, source_code, version_type, version)
|
|
dest_file.write_text(source_code, encoding='utf-8')
|
|
else:
|
|
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, version_type=version_type, version=version)
|
|
|
|
# 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()
|