text fitting pro feature

This commit is contained in:
2025-10-10 14:41:18 +02:00
parent 0edf324335
commit 596f640912
23 changed files with 909 additions and 86 deletions

View File

@@ -15,6 +15,128 @@ from typing import Dict, List, Optional
import tempfile
import argparse
import fnmatch
import ast
import tokenize
import io
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."""
@@ -78,7 +200,7 @@ def should_exclude_file(file_path: Path, source_dir: Path, exclude_patterns: Lis
return False
def copy_source_files(source_dir: Path, dest_dir: Path, exclude_patterns: List[str], exclude_files: List[str] = None):
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 = []
@@ -104,9 +226,23 @@ def copy_source_files(source_dir: Path, dest_dir: Path, exclude_patterns: List[s
# Create parent directories
dest_file.parent.mkdir(parents=True, exist_ok=True)
# Copy file
shutil.copy2(item, dest_file)
print(f" Copied: {rel_path}")
# 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}")
@@ -145,11 +281,19 @@ def get_version_from_git() -> Optional[str]:
return None
def build_addon(version_type: str, version: Optional[str] = None, output_dir: Optional[Path] = 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."""
config = load_config()
exclusions_config = load_exclusions_config()
project_root = get_project_root()
# 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:
@@ -191,7 +335,7 @@ def build_addon(version_type: str, version: Optional[str] = None, output_dir: Op
# 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)
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 ""
@@ -210,7 +354,7 @@ def build_addon(version_type: str, version: Optional[str] = None, output_dir: Op
return output_file
def build_all_versions(version: Optional[str] = None):
def build_all_versions(version: Optional[str] = None, minify: bool = True):
"""Build both full and free versions."""
print("Building all versions...")
@@ -220,14 +364,14 @@ def build_all_versions(version: Optional[str] = None):
print("\n" + "="*50)
print("BUILDING FULL VERSION")
print("="*50)
full_package = build_addon("full", version)
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)
free_package = build_addon("free", version, minify=minify)
built_packages.append(("free", free_package))
print("\n" + "="*50)
@@ -246,14 +390,18 @@ def main():
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)
build_all_versions(args.version, args.minify)
else:
build_addon(args.type, args.version, args.output)
build_addon(args.type, args.version, args.output, args.minify)
except Exception as e:
print(f"❌ Build failed: {e}")