From 47d38e44687c64ce1c3004ea39d1463dc10f0a50 Mon Sep 17 00:00:00 2001 From: onyx-and-iris Date: Tue, 10 Mar 2026 20:39:21 +0000 Subject: [PATCH] add spec_generator.py as well as generate-specs task --- Taskfile.yml | 9 +- tools/spec_generator.py | 280 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 tools/spec_generator.py diff --git a/Taskfile.yml b/Taskfile.yml index 8fff95f..d10ab3f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -15,8 +15,13 @@ tasks: - task: build - task: compress + generate-specs: + desc: Generate all spec files from templates + cmd: pdm run python tools/spec_generator.py + build: desc: Build the project + deps: [generate-specs] cmds: - for: matrix: @@ -35,6 +40,4 @@ tasks: desc: Clean the project cmds: - | - {{.SHELL}} -Command " - Remove-Item -Recurse -Force build/basic,build/banana,build/potato - Remove-Item -Recurse -Force dist/*" + {{.SHELL}} -Command "Remove-Item -Path build/*,dist/* -Recurse -Force -ErrorAction SilentlyContinue" diff --git a/tools/spec_generator.py b/tools/spec_generator.py new file mode 100644 index 0000000..c30eb55 --- /dev/null +++ b/tools/spec_generator.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +PyInstaller spec file generator for nvda-voicemeeter project. + +This script generates .spec files for different Voicemeeter kinds (basic, banana, potato). +Based on the existing spec file patterns in the spec/ directory. + +Usage: + python tools/spec_generator.py [kind] + +Arguments: + kind: The Voicemeeter kind to generate spec for (basic, banana, potato). + If not provided, generates all spec files. + +Examples: + python tools/spec_generator.py basic + python tools/spec_generator.py +""" + +import argparse +from pathlib import Path +from typing import List + + +class SpecGenerator: + """Generate PyInstaller spec files for nvda-voicemeeter project.""" + + # Supported Voicemeeter kinds + KINDS = ['basic', 'banana', 'potato'] + + def __init__(self, project_root: Path = None): + """Initialize the spec generator. + + Args: + project_root: Path to the project root. If None, uses current directory. + """ + if project_root is None: + # Assume we're running from tools/ directory + project_root = Path(__file__).parent.parent + + self.project_root = Path(project_root) + self.spec_dir = self.project_root / 'spec' + + # Ensure spec directory exists + self.spec_dir.mkdir(exist_ok=True) + + def generate_spec_template(self, kind: str) -> str: + """Generate a PyInstaller spec file template for the given kind. + + Args: + kind: The Voicemeeter kind (basic, banana, potato). + + Returns: + The generated spec file content as a string. + """ + if kind not in self.KINDS: + raise ValueError(f'Unsupported kind: {kind}. Supported kinds: {self.KINDS}') + + template = f"""# -*- mode: python ; coding: utf-8 -*- + + +block_cipher = None + +added_files = [ + ( '../controllerClient/x64', 'controllerClient/x64' ), + ( '../controllerClient/x86', 'controllerClient/x86' ), + ] + +a = Analysis( + ['{kind}.py'], + 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='{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='{kind}', +) +""" + return template + + def generate_entry_point(self, kind: str) -> str: + """Generate a Python entry point script for the given kind. + + Args: + kind: The Voicemeeter kind (basic, banana, potato). + + Returns: + The generated Python script content as a string. + """ + if kind not in self.KINDS: + raise ValueError(f'Unsupported kind: {kind}. Supported kinds: {self.KINDS}') + + entry_point = f"""import voicemeeterlib + +import nvda_voicemeeter + +KIND_ID = '{kind}' + +with voicemeeterlib.api(KIND_ID) as vm: + with nvda_voicemeeter.draw(KIND_ID, vm) as window: + window.run() +""" + return entry_point + + def write_spec_file(self, kind: str, overwrite: bool = False) -> Path: + """Write a spec file for the given kind. + + Args: + kind: The Voicemeeter kind (basic, banana, potato). + overwrite: Whether to overwrite existing files. + + Returns: + Path to the created spec file. + + Raises: + FileExistsError: If file exists and overwrite is False. + """ + spec_file = self.spec_dir / f'{kind}.spec' + entry_file = self.spec_dir / f'{kind}.py' + + if spec_file.exists() and not overwrite: + raise FileExistsError(f'Spec file already exists: {spec_file}') + + if entry_file.exists() and not overwrite: + raise FileExistsError(f'Entry point file already exists: {entry_file}') + + # Write spec file + spec_content = self.generate_spec_template(kind) + spec_file.write_text(spec_content, encoding='utf-8') + print(f'Generated spec file: {spec_file}') + + # Write entry point script + entry_content = self.generate_entry_point(kind) + entry_file.write_text(entry_content, encoding='utf-8') + print(f'Generated entry point: {entry_file}') + + return spec_file + + def generate_all_specs(self, overwrite: bool = False) -> List[Path]: + """Generate spec files for all supported kinds. + + Args: + overwrite: Whether to overwrite existing files. + + Returns: + List of paths to the created spec files. + """ + spec_files = [] + for kind in self.KINDS: + try: + spec_file = self.write_spec_file(kind, overwrite=overwrite) + spec_files.append(spec_file) + except FileExistsError as e: + print(f'Skipped {kind}: {e}') + continue + + return spec_files + + def list_existing_specs(self) -> List[str]: + """List existing spec files in the spec directory. + + Returns: + List of existing spec file kinds. + """ + existing = [] + for kind in self.KINDS: + spec_file = self.spec_dir / f'{kind}.spec' + if spec_file.exists(): + existing.append(kind) + + return existing + + +def main(): + """Main entry point for the spec generator.""" + parser = argparse.ArgumentParser( + description='Generate PyInstaller spec files for nvda-voicemeeter project', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python tools/spec_generator.py basic # Generate basic.spec + python tools/spec_generator.py # Generate all spec files + python tools/spec_generator.py --list # List existing spec files + python tools/spec_generator.py --force # Overwrite existing files + """, + ) + + parser.add_argument( + 'kind', + nargs='?', + choices=SpecGenerator.KINDS, + help=f'Voicemeeter kind to generate spec for ({", ".join(SpecGenerator.KINDS)})', + ) + + parser.add_argument('--force', '-f', action='store_true', help='Overwrite existing spec files') + + parser.add_argument('--list', '-l', action='store_true', help='List existing spec files') + + parser.add_argument( + '--project-root', type=Path, help='Path to project root (default: detect from script location)' + ) + + args = parser.parse_args() + + # Initialize generator + generator = SpecGenerator(project_root=args.project_root) + + # List existing files if requested + if args.list: + existing = generator.list_existing_specs() + if existing: + print('Existing spec files:') + for kind in existing: + spec_file = generator.spec_dir / f'{kind}.spec' + print(f' {kind}: {spec_file}') + else: + print('No existing spec files found.') + return + + try: + if args.kind: + # Generate specific kind + generator.write_spec_file(args.kind, overwrite=args.force) + print(f'Successfully generated spec for: {args.kind}') + else: + # Generate all kinds + spec_files = generator.generate_all_specs(overwrite=args.force) + if spec_files: + print(f'Successfully generated {len(spec_files)} spec files.') + else: + print('No spec files were generated (use --force to overwrite existing files).') + + except ValueError as e: + print(f'Error: {e}') + return 1 + except FileExistsError as e: + print(f'Error: {e}') + print('Use --force to overwrite existing files.') + return 1 + + return 0 + + +if __name__ == '__main__': + exit(main())