From 737dc75cba3d0e8824310773b763fbf90ac9741f Mon Sep 17 00:00:00 2001 From: onyx-and-iris Date: Tue, 10 Mar 2026 01:31:25 +0000 Subject: [PATCH] add dynamic_builder script add Taskfile for running dynamic builds --- Taskfile.dynamic.yml | 40 +++++ tools/dynamic_builder.py | 318 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 Taskfile.dynamic.yml create mode 100644 tools/dynamic_builder.py diff --git a/Taskfile.dynamic.yml b/Taskfile.dynamic.yml new file mode 100644 index 0000000..8796c86 --- /dev/null +++ b/Taskfile.dynamic.yml @@ -0,0 +1,40 @@ +version: '3' + +# Dynamic build system - no spec files needed! +# Usage: task build THEMES="azure forest" or task build-all + +vars: + THEMES: '{{.THEMES | default "all"}}' + SHELL: pwsh + +tasks: + build: + desc: Build specified themes dynamically (no spec files needed) + cmds: + - poetry run python tools/dynamic_builder.py {{.THEMES}} + + build-all: + desc: Build all themes + cmds: + - poetry run python tools/dynamic_builder.py all + + build-azure: + desc: Build only azure theme + cmds: + - poetry run python tools/dynamic_builder.py azure + + build-forest: + desc: Build only forest theme + cmds: + - poetry run python tools/dynamic_builder.py forest + + build-sunvalley: + desc: Build only sunvalley theme + cmds: + - poetry run python tools/dynamic_builder.py sunvalley + + clean: + desc: Clean all build artifacts + cmds: + - | + {{.SHELL}} -Command "Remove-Item -Path build/*,dist/* -Recurse -Force -ErrorAction SilentlyContinue" diff --git a/tools/dynamic_builder.py b/tools/dynamic_builder.py new file mode 100644 index 0000000..8a07c49 --- /dev/null +++ b/tools/dynamic_builder.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +""" +Dynamic build system for voicemeeter-compact. +Generates spec files on-the-fly and builds executables without storing intermediate files. +""" + +import argparse +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Dict + +# Build configuration +THEMES = { + 'azure': ['azure-light', 'azure-dark'], + 'forest': ['forest-light', 'forest-dark'], + 'sunvalley': ['sunvalley'], +} + +KINDS = ['basic', 'banana', 'potato'] + +# Templates (same as spec_generator.py) +PYTHON_TEMPLATE = """import voicemeeterlib + +import vmcompact + + +def main(): + KIND_ID = '{kind}' + + with voicemeeterlib.api(KIND_ID) as vmr:{theme_arg} + app = vmcompact.connect(KIND_ID, vmr{theme_param}) + app.mainloop() + + +if __name__ == '__main__': + main() +""" + +SPEC_TEMPLATE = """# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + +added_files = [ + ( '{img_path}', 'img' ),{theme_files} + ( '{config_path}', 'configs' ), + ] + +a = Analysis( + ['{script_path}'], + pathex=[], + binaries=[], + datas=added_files, + hiddenimports=[], + hookspath=[], + hooksconfig={{}}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='{theme_variant}-{kind}', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='{theme_variant}-{kind}', +) +""" + + +class DynamicBuilder: + def __init__(self, base_dir: Path, dist_dir: Path): + self.base_dir = base_dir + self.dist_dir = dist_dir + self.temp_dir = None + + def __enter__(self): + self.temp_dir = Path(tempfile.mkdtemp(prefix='vmcompact_build_')) + print(f'Using temp directory: {self.temp_dir}') + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.temp_dir and self.temp_dir.exists(): + shutil.rmtree(self.temp_dir) + print(f'Cleaned up temp directory: {self.temp_dir}') + + def create_python_file(self, theme_variant: str, kind: str) -> Path: + """Create a temporary Python launcher file.""" + if theme_variant == 'sunvalley': + theme_arg = '' + theme_param = '' + else: + theme_arg = f"\n theme = '{theme_variant}'" + theme_param = ', theme=theme' + + content = PYTHON_TEMPLATE.format( + kind=kind, theme_arg=theme_arg, theme_param=theme_param + ) + + py_file = self.temp_dir / f'{theme_variant}-{kind}.py' + with open(py_file, 'w') as f: + f.write(content) + + return py_file + + def create_spec_file(self, theme_variant: str, kind: str, py_file: Path) -> Path: + """Create a temporary PyInstaller spec file.""" + if theme_variant == 'sunvalley': + theme_files = '' + else: + theme_base = theme_variant.split('-')[0] + theme_path = (self.base_dir / 'theme' / theme_base).as_posix() + theme_files = f"\n ( '{theme_path}', 'theme' )," + + content = SPEC_TEMPLATE.format( + script_path=py_file.as_posix(), + img_path=(self.base_dir / 'vmcompact' / 'img').as_posix(), + config_path=(self.base_dir / 'configs').as_posix(), + theme_files=theme_files, + kind=kind, + theme_variant=theme_variant, + ) + + spec_file = self.temp_dir / f'{theme_variant}-{kind}.spec' + with open(spec_file, 'w') as f: + f.write(content) + + return spec_file + + def build_variant(self, theme_variant: str, kind: str) -> bool: + """Build a single theme/kind variant.""" + print(f'Building {theme_variant}-{kind}...') + + # Create temporary files + py_file = self.create_python_file(theme_variant, kind) + spec_file = self.create_spec_file(theme_variant, kind, py_file) + + # Build with PyInstaller + dist_path = self.dist_dir / f'{theme_variant}-{kind}' + cmd = [ + 'poetry', + 'run', + 'pyinstaller', + '--noconfirm', + '--distpath', + str(dist_path.parent), + str(spec_file), + ] + + try: + result = subprocess.run( + cmd, cwd=self.base_dir, capture_output=True, text=True + ) + if result.returncode == 0: + print(f'✓ Built {theme_variant}-{kind}') + return True + else: + print(f'✗ Failed to build {theme_variant}-{kind}') + print(f'Error: {result.stderr}') + return False + except Exception as e: + print(f'✗ Exception building {theme_variant}-{kind}: {e}') + return False + + def build_theme(self, theme_family: str) -> Dict[str, bool]: + """Build all variants for a theme family.""" + results = {} + + if theme_family not in THEMES: + print(f'Unknown theme: {theme_family}') + return results + + variants = THEMES[theme_family] + + for variant in variants: + for kind in KINDS: + success = self.build_variant(variant, kind) + results[f'{variant}-{kind}'] = success + + return results + + +def run_rewriter(theme_family: str, base_dir: Path) -> bool: + """Run the theme rewriter if needed.""" + if theme_family in ['azure', 'forest']: + print(f'Running rewriter for {theme_family} theme...') + cmd = [ + 'poetry', + 'run', + 'python', + 'tools/rewriter.py', + '--rewrite', + '--theme', + theme_family, + ] + try: + result = subprocess.run(cmd, cwd=base_dir) + return result.returncode == 0 + except Exception as e: + print(f'Rewriter failed: {e}') + return False + return True + + +def restore_rewriter(base_dir: Path) -> bool: + """Restore files after rewriter.""" + print('Restoring rewriter changes...') + cmd = ['poetry', 'run', 'python', 'tools/rewriter.py', '--restore'] + try: + result = subprocess.run(cmd, cwd=base_dir) + return result.returncode == 0 + except Exception as e: + print(f'Restore failed: {e}') + return False + + +def main(): + parser = argparse.ArgumentParser( + description='Dynamic build system for voicemeeter-compact' + ) + parser.add_argument( + 'themes', + nargs='*', + choices=list(THEMES.keys()) + ['all'], + help='Themes to build (default: all)', + ) + parser.add_argument( + '--dist-dir', + type=Path, + default=Path('dist'), + help='Distribution output directory', + ) + + args = parser.parse_args() + + if not args.themes or 'all' in args.themes: + themes_to_build = list(THEMES.keys()) + else: + themes_to_build = args.themes + + base_dir = Path.cwd() + args.dist_dir.mkdir(exist_ok=True) + + print(f'Building themes: {", ".join(themes_to_build)}') + + all_results = {} + + with DynamicBuilder(base_dir, args.dist_dir) as builder: + for theme_family in themes_to_build: + # Run rewriter if needed + if not run_rewriter(theme_family, base_dir): + print(f'Skipping {theme_family} due to rewriter failure') + continue + + try: + # Build theme + results = builder.build_theme(theme_family) + all_results.update(results) + + finally: + # Always restore rewriter changes + if theme_family in ['azure', 'forest']: + restore_rewriter(base_dir) + + # Report results + print('\n' + '=' * 50) + print('BUILD SUMMARY') + print('=' * 50) + + success_count = 0 + total_count = 0 + + for build_name, success in all_results.items(): + status = '✓' if success else '✗' + print(f'{status} {build_name}') + if success: + success_count += 1 + total_count += 1 + + print(f'\nSuccess: {success_count}/{total_count}') + + if success_count == total_count: + print('All builds completed successfully!') + sys.exit(0) + else: + print('Some builds failed!') + sys.exit(1) + + +if __name__ == '__main__': + main()