deployment

This commit is contained in:
2025-09-11 14:07:55 +03:00
parent c1ac5297f2
commit 9c2c879483
54 changed files with 1859 additions and 26 deletions

263
build/build_addon.py Executable file
View File

@@ -0,0 +1,263 @@
#!/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
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):
"""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
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."""
try:
result = subprocess.run(
['python3', 'build/sync_version.py', '--version'],
capture_output=True, text=True, cwd=get_project_root()
)
if result.returncode == 0:
# Extract version from output
lines = result.stdout.strip().split('\n')
for line in lines:
if 'Synchronizing version:' in line:
return line.split(':')[1].strip().split()[0]
except Exception as e:
print(f"Warning: Could not get version from git: {e}")
return None
def build_addon(version_type: str, version: Optional[str] = None, output_dir: Optional[Path] = None):
"""Build addon for specified version type."""
config = load_config()
exclusions_config = load_exclusions_config()
project_root = get_project_root()
# 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)
# 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):
"""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)
built_packages.append(("full", full_package))
# Build free version
print("\n" + "="*50)
print("BUILDING FREE VERSION")
print("="*50)
free_package = build_addon("free", version)
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")
args = parser.parse_args()
try:
if args.type == "all":
build_all_versions(args.version)
else:
build_addon(args.type, args.version, args.output)
except Exception as e:
print(f"❌ Build failed: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

36
build/config.json Normal file
View File

@@ -0,0 +1,36 @@
{
"project": {
"name": "Text Texture Generator",
"id": "text_texture_generator",
"author": "Marc Mintel <marc@mintel.me>",
"description": "Generate image textures from text with accurate dimensions and instant live preview",
"category": "Material",
"location": "3D View > Sidebar (N) > Tool Tab | Shader Editor > Sidebar > Tool Tab",
"blender_version_min": "4.0.0",
"license": ["GPL-3.0-or-later"],
"support": "COMMUNITY",
"tags": ["Material", "Shader", "Texture", "Text", "Image"]
},
"build": {
"source_dir": "src",
"dist_dir": "dist",
"temp_dir": "build/temp",
"exclude_patterns": [
"__pycache__",
"*.pyc",
"*.pyo",
".DS_Store",
"Thumbs.db"
]
},
"versions": {
"template_files": ["src/blender_manifest.toml", "src/utils/constants.py"],
"git_tag_pattern": "^v?([0-9]+\\.[0-9]+\\.[0-9]+)$"
},
"file_exclusions": {
"config_file": "build/file_exclusions.json"
},
"dependencies": {
"pillow": ">=10.0.0"
}
}

View File

@@ -0,0 +1,67 @@
{
"features": {
"basic": {
"name": "Basic Text Generation",
"description": "Core text texture generation functionality",
"enabled_by_default": true,
"components": [
"core.text_processor",
"core.generation_engine",
"ui.panels.basic"
]
},
"preview": {
"name": "Live Preview",
"description": "Real-time preview of text textures",
"enabled_by_default": true,
"components": ["ui.preview", "core.text_fitting"]
},
"advanced_styling": {
"name": "Advanced Styling Options",
"description": "Extended styling and customization options",
"enabled_by_default": false,
"components": ["ui.panels.advanced", "core.advanced_styling"]
},
"normal_maps": {
"name": "Normal Map Generation",
"description": "Generate normal maps from text",
"enabled_by_default": false,
"components": ["core.normal_maps", "ui.panels.normal_maps"]
},
"presets": {
"name": "Preset Management",
"description": "Save and load text style presets",
"enabled_by_default": false,
"components": [
"presets.storage_system",
"operators.preset_ops",
"ui.panels.presets"
]
},
"batch_processing": {
"name": "Batch Processing",
"description": "Process multiple text textures at once",
"enabled_by_default": false,
"components": ["operators.batch_ops", "ui.panels.batch"]
}
},
"version_configs": {
"free": {
"enabled_features": ["basic", "preview"],
"max_texture_size": 1024,
"max_concurrent_operations": 1
},
"full": {
"enabled_features": [
"basic",
"preview",
"advanced_styling",
"normal_maps",
"presets",
"batch_processing"
],
"max_texture_size": 4096,
"max_concurrent_operations": 4
}
}
}

View File

@@ -0,0 +1,30 @@
{
"comment": "File exclusion configuration for building different addon versions",
"description": "Maps features to specific files/directories that should be excluded from free builds",
"version_configs": {
"free": {
"description": "Free version - core text texture generation only",
"exclude_files": [
"core/normal_maps.py",
"operators/preset_ops.py",
"operators/utility_ops.py",
"presets/"
],
"exclude_patterns": ["*batch*", "*advanced*"],
"max_texture_size": 1024,
"max_concurrent_operations": 1
},
"full": {
"description": "Full version - all features included",
"exclude_files": [],
"exclude_patterns": [],
"max_texture_size": 4096,
"max_concurrent_operations": 4
}
},
"feature_to_files_mapping": {
"normal_maps": ["core/normal_maps.py"],
"presets": ["presets/", "operators/preset_ops.py"],
"batch_processing": ["operators/utility_ops.py"]
}
}

296
build/generate_changelog.py Executable file
View File

@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
Changelog generation script for Text Texture Generator.
Generates changelog from git commits between tags.
"""
import os
import sys
import json
import subprocess
import argparse
from pathlib import Path
from typing import List, Dict, Optional, Tuple
from datetime import datetime
import re
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 run_git_command(command: List[str]) -> Optional[str]:
"""Run a git command and return output."""
try:
result = subprocess.run(
['git'] + command,
capture_output=True,
text=True,
check=True,
cwd=get_project_root()
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"Git command failed: {' '.join(['git'] + command)}")
print(f"Error: {e.stderr}")
return None
def get_git_tags() -> List[str]:
"""Get all git tags sorted by version."""
output = run_git_command(['tag', '-l', '--sort=-version:refname'])
if output:
return [tag for tag in output.split('\n') if tag.strip()]
return []
def get_commits_between(from_ref: Optional[str], to_ref: str) -> List[Dict]:
"""Get commits between two references."""
if from_ref:
commit_range = f"{from_ref}..{to_ref}"
else:
commit_range = to_ref
# Get commit info in a structured format
format_str = "%H|%an|%ae|%ad|%s"
output = run_git_command([
'log',
'--pretty=format:' + format_str,
'--date=iso',
commit_range
])
if not output:
return []
commits = []
for line in output.split('\n'):
if '|' in line:
parts = line.split('|', 4)
if len(parts) == 5:
commits.append({
'hash': parts[0],
'author': parts[1],
'email': parts[2],
'date': parts[3],
'message': parts[4]
})
return commits
def categorize_commit(commit_message: str) -> Tuple[str, str]:
"""Categorize a commit based on its message."""
message_lower = commit_message.lower()
# Define categories and their patterns
categories = {
'Features': [
r'^feat(\(.+\))?:', r'^add:', r'^implement:',
r'\bfeature\b', r'\badd\b.*\bfeature\b', r'\bnew\b.*\bfeature\b'
],
'Bug Fixes': [
r'^fix(\(.+\))?:', r'^bug:', r'^hotfix:',
r'\bfix\b', r'\bbug\b.*\bfix\b', r'\bresolve\b.*\bissue\b'
],
'Improvements': [
r'^improve(\(.+\))?:', r'^enhance(\(.+\))?:', r'^refactor(\(.+\))?:',
r'\bimprove\b', r'\benhance\b', r'\boptimize\b', r'\brefactor\b'
],
'Documentation': [
r'^docs?(\(.+\))?:', r'^documentation:',
r'\bdocs?\b', r'\bdocumentation\b', r'\breadme\b'
],
'Build System': [
r'^build(\(.+\))?:', r'^ci(\(.+\))?:', r'^chore(\(.+\))?:',
r'\bbuild\b', r'\bci\b', r'\bchore\b', r'\bscript\b'
],
'Tests': [
r'^test(\(.+\))?:', r'\btest\b', r'\btesting\b'
]
}
# Check each category
for category, patterns in categories.items():
for pattern in patterns:
if re.search(pattern, message_lower):
# Clean up the message
cleaned_message = commit_message
# Remove conventional commit prefixes
cleaned_message = re.sub(r'^(feat|fix|docs?|style|refactor|test|chore|build|ci|perf|improve|enhance|add|implement|bug|hotfix)(\(.+\))?:\s*', '', cleaned_message, flags=re.IGNORECASE)
return category, cleaned_message
# Default category
return 'Other Changes', commit_message
def generate_changelog_content(version: str, commits: List[Dict], previous_version: Optional[str] = None) -> str:
"""Generate changelog content for a version."""
lines = []
# Header
lines.append(f"## [{version}] - {datetime.now().strftime('%Y-%m-%d')}")
lines.append("")
if not commits:
lines.append("No changes recorded.")
lines.append("")
return "\n".join(lines)
# Categorize commits
categorized = {}
for commit in commits:
category, message = categorize_commit(commit['message'])
if category not in categorized:
categorized[category] = []
categorized[category].append({
'message': message,
'hash': commit['hash'][:8],
'author': commit['author']
})
# Order categories by importance
category_order = [
'Features',
'Bug Fixes',
'Improvements',
'Documentation',
'Build System',
'Tests',
'Other Changes'
]
# Generate sections
for category in category_order:
if category in categorized:
lines.append(f"### {category}")
lines.append("")
for item in categorized[category]:
# Format: - Message (hash)
lines.append(f"- {item['message']} ({item['hash']})")
lines.append("")
# Add comparison link if we have a previous version
if previous_version:
repo_url = "https://github.com/marcmintel/text-texture-generator" # Adjust as needed
lines.append(f"**Full Changelog**: {repo_url}/compare/{previous_version}...{version}")
lines.append("")
return "\n".join(lines)
def generate_full_changelog() -> str:
"""Generate complete changelog from all tags."""
lines = []
# Header
lines.append("# Changelog")
lines.append("")
lines.append("All notable changes to this project will be documented in this file.")
lines.append("")
lines.append("The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),")
lines.append("and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).")
lines.append("")
# Get tags
tags = get_git_tags()
if not tags:
lines.append("## [Unreleased]")
lines.append("")
# Get all commits
commits = get_commits_between(None, "HEAD")
version_content = generate_changelog_content("Unreleased", commits)
lines.append(version_content)
else:
# Process each version
for i, tag in enumerate(tags):
# Clean version (remove 'v' prefix if present)
clean_version = tag.lstrip('v')
# Get previous tag
previous_tag = tags[i + 1] if i + 1 < len(tags) else None
# Get commits for this version
commits = get_commits_between(previous_tag, tag)
# Generate content
version_content = generate_changelog_content(clean_version, commits, previous_tag)
lines.append(version_content)
return "\n".join(lines)
def generate_changelog_for_version(version: str, output_file: Optional[Path] = None) -> str:
"""Generate changelog for a specific version."""
tags = get_git_tags()
# Find the version tag
version_tag = None
previous_tag = None
for i, tag in enumerate(tags):
if tag == version or tag == f"v{version}":
version_tag = tag
previous_tag = tags[i + 1] if i + 1 < len(tags) else None
break
if not version_tag:
print(f"Warning: Version tag '{version}' not found. Using current HEAD.")
version_tag = "HEAD"
previous_tag = tags[0] if tags else None
# Get commits
commits = get_commits_between(previous_tag, version_tag)
# Generate content
content = generate_changelog_content(version, commits, previous_tag)
# Write to file if specified
if output_file:
with open(output_file, 'w') as f:
f.write(content)
print(f"Changelog written to: {output_file}")
return content
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Generate changelog from git commits")
parser.add_argument("--version", help="Generate changelog for specific version")
parser.add_argument("--output", type=Path, help="Output file path")
parser.add_argument("--full", action="store_true", help="Generate full changelog")
args = parser.parse_args()
try:
if args.full:
content = generate_full_changelog()
output_file = args.output or get_project_root() / "CHANGELOG.md"
elif args.version:
content = generate_changelog_for_version(args.version, args.output)
output_file = args.output
else:
# Default: generate for latest version
tags = get_git_tags()
if tags:
latest_version = tags[0].lstrip('v')
content = generate_changelog_for_version(latest_version, args.output)
output_file = args.output
else:
print("No git tags found. Use --full to generate unreleased changelog.")
sys.exit(1)
# Write to file or print
if output_file and not args.version:
with open(output_file, 'w') as f:
f.write(content)
print(f"Changelog written to: {output_file}")
elif not args.version:
print(content)
except Exception as e:
print(f"❌ Changelog generation failed: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

184
build/sync_version.py Executable file
View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""
Version synchronization script for Text Texture Generator.
Updates version information from git tags across all template files.
"""
import os
import sys
import json
import re
import subprocess
from pathlib import Path
from typing import Dict, Tuple, Optional
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 get_latest_git_tag() -> Optional[str]:
"""Get the latest git tag that matches the version pattern."""
try:
# Get all tags sorted by version
result = subprocess.run(['git', 'tag', '-l', '--sort=-version:refname'],
capture_output=True, text=True, check=True)
tags = result.stdout.strip().split('\n')
config = load_config()
pattern = config['versions']['git_tag_pattern']
version_regex = re.compile(pattern)
for tag in tags:
if version_regex.match(tag):
# Extract version number, removing 'v' prefix if present
match = version_regex.match(tag)
return match.group(1) if match else None
return None
except subprocess.CalledProcessError:
print("Warning: Could not get git tags. Using fallback version.")
return None
def get_version_info(version_string: str) -> Tuple[str, int, int, int]:
"""Parse version string into components."""
parts = version_string.split('.')
if len(parts) != 3:
raise ValueError(f"Invalid version format: {version_string}")
major, minor, patch = map(int, parts)
return version_string, major, minor, patch
def create_template_variables(config: Dict, version: str, version_type: str = "full") -> Dict[str, str]:
"""Create template variables for file generation."""
version_str, major, minor, patch = get_version_info(version)
# Load exclusions configuration for version limits
exclusions_path = get_project_root() / "build" / "file_exclusions.json"
with open(exclusions_path, 'r') as f:
exclusions_config = json.load(f)
# Get version-specific configuration
version_config = exclusions_config['version_configs'][version_type]
# Parse Blender version
blender_version = config['project']['blender_version_min']
blender_parts = blender_version.split('.')
blender_major, blender_minor = int(blender_parts[0]), int(blender_parts[1])
blender_patch = int(blender_parts[2]) if len(blender_parts) > 2 else 0
# Format dependencies for TOML
deps = []
for dep, version_spec in config['dependencies'].items():
deps.append(f'{dep} = "{version_spec}"')
dependencies_str = '\n'.join(deps)
variables = {
'PROJECT_ID': config['project']['id'],
'PROJECT_NAME': config['project']['name'],
'PROJECT_AUTHOR': config['project']['author'],
'PROJECT_DESCRIPTION': config['project']['description'],
'PROJECT_CATEGORY': config['project']['category'],
'PROJECT_LOCATION': config['project']['location'],
'PROJECT_SUPPORT': config['project']['support'],
'PROJECT_TAGS': json.dumps(config['project']['tags']),
'PROJECT_LICENSE': json.dumps(config['project']['license']),
'VERSION': version_str,
'VERSION_MAJOR': str(major),
'VERSION_MINOR': str(minor),
'VERSION_PATCH': str(patch),
'VERSION_TYPE': version_type,
'BLENDER_VERSION_MIN': blender_version,
'BLENDER_VERSION_MAJOR': str(blender_major),
'BLENDER_VERSION_MINOR': str(blender_minor),
'BLENDER_VERSION_PATCH': str(blender_patch),
'DEPENDENCIES': dependencies_str,
'MAX_TEXTURE_SIZE': str(version_config['max_texture_size']),
'MAX_CONCURRENT_OPERATIONS': str(version_config['max_concurrent_operations'])
}
return variables
def process_template(template_path: Path, output_path: Path, variables: Dict[str, str]):
"""Process a template file and write the output."""
print(f"Processing template: {template_path} -> {output_path}")
# Read template
with open(template_path, 'r') as f:
content = f.read()
# Replace variables
for key, value in variables.items():
content = content.replace(f'{{{{{key}}}}}', value)
# Ensure output directory exists
output_path.parent.mkdir(parents=True, exist_ok=True)
# Write output
with open(output_path, 'w') as f:
f.write(content)
print(f"Generated: {output_path}")
def sync_version(version: Optional[str] = None, version_type: str = "full"):
"""Synchronize version across all template files."""
config = load_config()
# Get version from git tag if not provided
if version is None:
version = get_latest_git_tag()
if version is None:
print("Warning: No valid git tag found. Using version from current blender_manifest.toml")
# Fallback to current version
manifest_path = get_project_root() / "src" / "blender_manifest.toml"
if manifest_path.exists():
with open(manifest_path, 'r') as f:
for line in f:
if line.startswith('version = '):
version = line.split('=')[1].strip().strip('"')
break
if version is None:
version = "1.0.0" # Ultimate fallback
print(f"Synchronizing version: {version} (type: {version_type})")
# Create template variables
variables = create_template_variables(config, version, version_type)
# Process each template file
templates_dir = get_project_root() / "build" / "templates"
for template_file in templates_dir.glob("*.template"):
# Determine output path
relative_name = template_file.stem # Remove .template extension
if relative_name == "blender_manifest.toml":
output_path = get_project_root() / "src" / relative_name
elif relative_name == "constants.py":
output_path = get_project_root() / "src" / "utils" / relative_name
else:
# For other templates, maintain directory structure
output_path = get_project_root() / "src" / relative_name
process_template(template_file, output_path, variables)
print(f"Version synchronization complete: {version}")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Synchronize version information")
parser.add_argument("--version", help="Version to set (defaults to latest git tag)")
parser.add_argument("--type", choices=["free", "full"], default="full",
help="Version type (free or full)")
args = parser.parse_args()
try:
sync_version(args.version, args.type)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)

View File

@@ -0,0 +1,19 @@
schema_version = "1.0.0"
id = "{{PROJECT_ID}}"
version = "{{VERSION}}"
name = "{{PROJECT_NAME}}"
tagline = "{{PROJECT_DESCRIPTION}}"
maintainer = "{{PROJECT_AUTHOR}}"
type = "add-on"
support = "{{PROJECT_SUPPORT}}"
tags = {{PROJECT_TAGS}}
blender_version_min = "{{BLENDER_VERSION_MIN}}"
license = {{PROJECT_LICENSE}}
# Dependencies
[dependencies]
{{DEPENDENCIES}}

View File

@@ -0,0 +1,32 @@
"""
Constants and metadata for the Text Texture Generator addon.
"""
bl_info = {
"name": "{{PROJECT_NAME}}",
"author": "{{PROJECT_AUTHOR}}",
"version": ({{VERSION_MAJOR}}, {{VERSION_MINOR}}, {{VERSION_PATCH}}),
"blender": ({{BLENDER_VERSION_MAJOR}}, {{BLENDER_VERSION_MINOR}}, {{BLENDER_VERSION_PATCH}}),
"location": "{{PROJECT_LOCATION}}",
"description": "{{PROJECT_DESCRIPTION}}",
"category": "{{PROJECT_CATEGORY}}",
}
# Version information
VERSION_TYPE = "{{VERSION_TYPE}}" # "free" or "full"
# Version-specific limits (applied at build time through file exclusions)
def get_version_limits():
"""Get version-specific limits."""
return {
"max_texture_size": {{MAX_TEXTURE_SIZE}},
"max_concurrent_operations": {{MAX_CONCURRENT_OPERATIONS}}
}
def is_free_version():
"""Check if this is the free version."""
return VERSION_TYPE == "free"
def is_full_version():
"""Check if this is the full version."""
return VERSION_TYPE == "full"

302
build/validate_package.py Executable file
View File

@@ -0,0 +1,302 @@
#!/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_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)
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()