Source code for build

#!/usr/bin/env python3
"""
Improved PyInstaller build script for Mycelian
Balances size optimization with proper NiceGUI and web engine support
"""

import os
import platform
import shutil
import subprocess
import sys
import time
from pathlib import Path


[docs] class BuildProgress: """Simple progress tracking for the build process""" def __init__(self, total_steps=5): self.current_step = 0 self.total_steps = total_steps self.start_time = time.time()
[docs] def next_step(self, message): """Move to next step and display progress""" self.current_step += 1 elapsed = time.time() - self.start_time print(f"\n[{self.current_step}/{self.total_steps}] {message}")
[docs] def update(self, message): """Update current step message""" print(f" → {message}")
[docs] def success(self, message="✓ Complete"): """Mark current step as successful""" print(f" {message}")
[docs] def error(self, message): """Display error message""" print(f" ✗ ERROR: {message}")
[docs] def summary(self): """Display build summary""" elapsed = time.time() - self.start_time print(f"\n{'='*50}") print(f"BUILD COMPLETE - {elapsed:.1f}s") print(f"{'='*50}")
# Global progress tracker progress = BuildProgress()
[docs] def get_project_root(): """Get the project root directory""" return Path(__file__).parent
[docs] def detect_os(): """Detect the current operating system""" system = platform.system().lower() if system == "windows": return "windows" elif system == "linux": return "linux" elif system == "darwin": return "macos" else: raise ValueError( f"Unsupported operating system: {system}. Only Windows, Linux, and macOS are supported." )
[docs] def get_os_specific_icon_path(os_name): """Get the appropriate icon path for the current OS""" from pathlib import Path project_root = get_project_root() icon_paths = { "windows": "assets/default_assets/icons/Mycelian.ico", "linux": None, # No specific icon for Linux, will use default "macos": None, # No specific icon for macOS, will use default } icon_path = icon_paths.get(os_name) if icon_path and Path(project_root / icon_path).exists(): return icon_path else: return None
# ============================================================================ # EASY EDIT SECTION - Modify these values as needed # ============================================================================ # Version and Build Date - Update these for new releases VERSION = "1.6.0" BUILD_DATE = "March 21st 2026" # Version file path (set to None if no version file, or provide path like 'version.txt') VERSION_FILE = None # ============================================================================ # OS DETECTION AND BUILD CONFIGURATION # ============================================================================ # Detect current OS CURRENT_OS = detect_os() progress.update(f"Target OS: {CURRENT_OS}") # Set output directory to 'builds' folder in project root OUTPUT_PATH = "builds" # Get OS-specific icon path ICON_PATH = get_os_specific_icon_path(CURRENT_OS) # ============================================================================
[docs] def ensure_dependencies(): """Ensure all required packages are installed""" try: import PyInstaller progress.update(f"PyInstaller v{PyInstaller.__version__} ready") except ImportError: progress.update("Installing PyInstaller...") subprocess.run( [sys.executable, "-m", "pip", "install", "pyinstaller"], check=True ) progress.update("PyInstaller installed")
[docs] def clean_build(): """Clean previous build artifacts""" project_root = get_project_root() # Remove build directories with more aggressive cleanup for dir_name in ["build", "dist", "__pycache__"]: dir_path = project_root / dir_name if dir_path.exists(): try: shutil.rmtree(dir_path, ignore_errors=True) progress.update(f"Cleaned {dir_path.name}") except Exception as e: progress.update(f"Could not remove {dir_path.name}") # Remove spec files spec_count = 0 for spec_file in project_root.glob("*.spec"): try: spec_file.unlink() spec_count += 1 except Exception: pass # Remove runtime hook files hook_count = 0 for hook_file in project_root.glob("pyi_rth_*.py"): try: hook_file.unlink() hook_count += 1 except Exception: pass if spec_count > 0 or hook_count > 0: progress.update(f"Removed {spec_count} spec files, {hook_count} hook files")
[docs] def get_hidden_imports(current_os): """Get list of hidden imports that PyInstaller might miss, filtered by OS""" hidden_imports = [] # ============================================================================ # CROSS-PLATFORM IMPORTS (work on all supported OSes) # ============================================================================ # Core NiceGUI (essential only) hidden_imports.extend( [ "nicegui", "nicegui.run", "nicegui.ui", "nicegui.app", "nicegui.events", "nicegui.native", "nicegui.functions", "nicegui.globals", "nicegui.storage", "nicegui.version", "nicegui.binding", "nicegui.client", "nicegui.context", "nicegui.helpers", "nicegui.json", "nicegui.language", "nicegui.lifecycle", "nicegui.logging", "nicegui.observables", "nicegui.page", "nicegui.response", "nicegui.server", "nicegui.slot", "nicegui.tailwind", "nicegui.timer", "nicegui.favicon", "nicegui.error", "nicegui.nicegui", ] ) # Essential NiceGUI elements only hidden_imports.extend( [ "nicegui.elements", "nicegui.elements.button", "nicegui.elements.card", "nicegui.elements.input", "nicegui.elements.label", "nicegui.elements.select", "nicegui.elements.checkbox", "nicegui.elements.radio", "nicegui.elements.slider", "nicegui.elements.switch", "nicegui.elements.textarea", "nicegui.elements.number", "nicegui.elements.html", "nicegui.elements.markdown", "nicegui.elements.image", "nicegui.elements.icon", "nicegui.elements.avatar", "nicegui.elements.link", "nicegui.elements.tabs", "nicegui.elements.tab_panels", "nicegui.elements.tab", "nicegui.elements.tab_panel", "nicegui.elements.tree", "nicegui.elements.table", "nicegui.elements.separator", "nicegui.elements.space", "nicegui.elements.spinner", "nicegui.elements.menu", "nicegui.elements.menu_item", "nicegui.elements.menu_separator", "nicegui.elements.tooltip", "nicegui.elements.notify", "nicegui.elements.dialog", "nicegui.elements.banner", "nicegui.elements.knob", "nicegui.elements.joystick", "nicegui.elements.circular_progress", "nicegui.elements.linear_progress", "nicegui.elements.keyboard", "nicegui.elements.json_editor", "nicegui.elements.code", "nicegui.elements.log", ] ) # CRITICAL: NiceGUI infrastructure hidden_imports.extend( [ "nicegui.elements.mixins", "nicegui.elements.mixins.color_elements", "nicegui.elements.mixins.content_element", "nicegui.elements.mixins.disableable_element", "nicegui.elements.mixins.source_element", "nicegui.elements.mixins.text_element", "nicegui.elements.mixins.validation_element", "nicegui.elements.mixins.value_element", "nicegui.staticfiles", "nicegui.element", "nicegui.element_filter", "nicegui.page_layout", "nicegui.outbox", "nicegui.ui_run", "nicegui.ui_run_with", ] ) # Docutils (required by NiceGUI) hidden_imports.extend( [ "docutils", "docutils.core", "docutils.frontend", "docutils.io", "docutils.nodes", "docutils.parsers", "docutils.parsers.rst", "docutils.transforms", "docutils.utils", "docutils.writers", "docutils.writers.html4css1", ] ) # TwitchAPI dependencies hidden_imports.extend( [ "twitchAPI", "twitchAPI.twitch", "twitchAPI.oauth", "twitchAPI.helper", "twitchAPI.eventsub", "twitchAPI.eventsub.websocket", "twitchAPI.object.eventsub", "twitchAPI.type", ] ) # Flask and SocketIO (CRITICAL for web engine) hidden_imports.extend( [ "flask", "flask_socketio", "socketio", "engineio", "python_socketio", "python_engineio", "werkzeug", "werkzeug.serving", "werkzeug.middleware.proxy_fix", "werkzeug.routing", "werkzeug.exceptions", "werkzeug.wrappers", ] ) # EngineIO and SocketIO async drivers hidden_imports.extend( [ "engineio.async_drivers", "engineio.async_drivers.threading", "engineio.async_drivers.gevent", "engineio.async_drivers.eventlet", "socketio.async_handlers", "socketio.async_namespace", "socketio.server", "socketio.client", "socketio.namespace", "socketio.base_manager", "socketio.pubsub_manager", "engineio.server", "engineio.socket", "engineio.packet", "engineio.payload", "engineio.client", ] ) # Networking and async libraries hidden_imports.extend( [ "eventlet", "eventlet.hubs", "eventlet.hubs.epolls", "eventlet.hubs.kqueue", "eventlet.hubs.selects", "eventlet.wsgi", "eventlet.green", "eventlet.green.threading", "eventlet.green.socket", "gevent", "gevent.socket", "gevent.threading", "gevent.pywsgi", ] ) # DNS resolution hidden_imports.extend( [ "dns", "dns.asyncbackend", "dns.asyncquery", "dns.asyncresolver", "dns.e164", "dns.namedict", "dns.tsigkeyring", "dns.versioned", "dns.dnssec", ] ) # HTTP and networking hidden_imports.extend( [ "aiohttp", "aiohttp.client", "aiohttp.web", "aiohttp.connector", "aiohttp.client_exceptions", "aiohttp.client_reqrep", "aiohttp.helpers", "aiohttp.http", "requests", "requests_oauthlib", "urllib3", "urllib3.exceptions", "urllib3.response", "urllib3.util", "urllib3.util.retry", "websockets", "websocket", "websocket_client", "ssl", "certifi", ] ) # Database drivers hidden_imports.extend( [ "sqlite3", "firebase_admin", "firebase_admin.db", "firebase_admin.credentials", "google.auth", "google.auth.transport", "google.auth.transport.requests", "pymongo", "bson", ] ) # Audio and multimedia hidden_imports.extend( [ "simpleobsws", ] ) # PSN API hidden_imports.extend( [ "psnawp", "psnawp_api", ] ) # Spotify hidden_imports.extend( [ "spotipy", "spotipy.oauth2", ] ) # Crypto and security hidden_imports.extend( [ "cryptography", "cryptography.fernet", "cryptography.hazmat", "cryptography.hazmat.primitives", "cryptography.hazmat.backends", ] ) # Utilities hidden_imports.extend( [ "pyperclip", "psutil", "packaging", "dataclasses", ] ) # Threading and multiprocessing hidden_imports.extend( [ "multiprocessing", "multiprocessing.shared_memory", "threading", "asyncio", "concurrent.futures", ] ) # Standard library modules hidden_imports.extend( [ "json", "pathlib", "logging", "logging.handlers", "signal", "time", "datetime", "uuid", "webbrowser", "http.server", "socketserver", "urllib.parse", "typing", "enum", "copy", "glob", "os", "sys", ] ) # Encoding modules (CRITICAL for Windows emoji support) if current_os == "windows": hidden_imports.extend( [ "encodings", "encodings.utf_8", "encodings.cp1252", "codecs", ] ) # ============================================================================ # PLATFORM-SPECIFIC IMPORTS # ============================================================================ if current_os == "windows": # Windows-specific audio control hidden_imports.extend( [ "pycaw", "pycaw.pycaw", "pycaw.constants", "pycaw.utils", "comtypes", "comtypes.automation", "comtypes.client", "comtypes.server", "comtypes.typeinfo", "comtypes.connectionpoints", "comtypes.persist", "comtypes.shell", "comtypes.messageloop", ] ) elif current_os == "linux": # Linux-specific audio control hidden_imports.extend( [ "pulsectl", "pulsectl_asyncio", ] ) elif current_os == "macos": # macOS-specific imports (none currently) pass # ============================================================================ # MYCELIAN MODULE IMPORTS (all platforms) # ============================================================================ hidden_imports.extend( [ "modules.alertutils", "modules.alert_processor", "modules.database_manager", "modules.database_init", "modules.dataobjects", "modules.mainuiwindow", "modules.psnapi", "modules.psn_service", "modules.spotify", "modules.streamlabs", "modules.template_config_parser", "modules.twitch", "modules.web_engine", "modules.encryption_utils", "modules.config_manager", "modules.api_credentials_manager", "modules.status_manager", "modules.updater", "modules.uiwindows.activity_feed", "modules.uiwindows.alertsettings", "modules.uiwindows.customsources", "modules.uiwindows.settings", "modules.uiwindows.sourcecontrols", "modules.alert_database_migration", "modules.alerts_migration", "modules.alerts_parser", ] ) # Cross-platform input control hidden_imports.extend( [ "pynput", "pynput.keyboard", "pynput.mouse", ] ) return hidden_imports
[docs] def get_data_files(): """Get list of data files to include""" import nicegui data_files = [] # Include COMPREHENSIVE NiceGUI files (required for UI to work) nicegui_path = os.path.dirname(nicegui.__file__) # Include NiceGUI static files (CSS, JS, fonts, etc.) nicegui_static = os.path.join(nicegui_path, "static") if os.path.exists(nicegui_static): data_files.append((nicegui_static, "nicegui/static")) # Include NiceGUI templates nicegui_templates = os.path.join(nicegui_path, "templates") if os.path.exists(nicegui_templates): data_files.append((nicegui_templates, "nicegui/templates")) # CRITICAL: Include NiceGUI elements directory (contains JS files for select, input, etc.) nicegui_elements = os.path.join(nicegui_path, "elements") if os.path.exists(nicegui_elements): data_files.append((nicegui_elements, "nicegui/elements")) # CRITICAL: Include NiceGUI functions directory nicegui_functions = os.path.join(nicegui_path, "functions") if os.path.exists(nicegui_functions): data_files.append((nicegui_functions, "nicegui/functions")) # Include NiceGUI app directory nicegui_app = os.path.join(nicegui_path, "app") if os.path.exists(nicegui_app): data_files.append((nicegui_app, "nicegui/app")) # Include NiceGUI native directory nicegui_native = os.path.join(nicegui_path, "native") if os.path.exists(nicegui_native): data_files.append((nicegui_native, "nicegui/native")) # # CRITICAL: Include project templates (needed for web engine) # project_root = get_project_root() # project_templates = project_root / 'templates' # if project_templates.exists(): # data_files.append((str(project_templates), 'templates')) # print(f"Including project templates: {project_templates}") # # CRITICAL: Include project static assets (needed for web engine) # project_static = project_root / 'static' # if project_static.exists(): # data_files.append((str(project_static), 'static')) # print(f"Including project static: {project_static}") # # Include project assets directory # project_assets = project_root / 'assets' # if project_assets.exists(): # data_files.append((str(project_assets), 'assets')) # print(f"Including project assets: {project_assets}") return data_files
[docs] def get_excluded_modules(): """Get list of modules to exclude from build (conservative exclusions only)""" return [ # Development tools only (safe to exclude) "pytest", "black", "flake8", "mypy", "pylint", "ruff", # Jupyter/IPython (safe to exclude) "IPython", "jupyter", "notebook", # Documentation tools (but keep docutils!) "sphinx", # Testing frameworks (safe to exclude) "nose", "coverage", # Version control (safe to exclude) "git", "mercurial", # Large unused GUI frameworks (safe to exclude) "PyQt5", "PyQt6", "PySide2", "PySide6", "tkinter", # We use NiceGUI, not tkinter # Large scientific libraries we don't use (safe to exclude) "tensorflow", "torch", "sklearn", "matplotlib", # Not used in this project "numpy", # Not used directly "pandas", # Not used in this project "scipy", # Not used in this project # Game development (safe to exclude) "pygame", "pygame.mixer", # Specific PIL components we don't need "PIL.ImageQt", # Qt-specific PIL components ]
[docs] def create_runtime_hook(): """Create a runtime hook to help with DLL loading and encoding""" project_root = get_project_root() hook_content = ''' # Runtime hook to help with DLL loading and encoding issues import os import sys def ensure_dll_path(): """Ensure DLL paths are properly set""" if hasattr(sys, '_MEIPASS'): # Running as PyInstaller bundle bundle_dir = sys._MEIPASS # Add bundle directory to DLL search path if hasattr(os, 'add_dll_directory'): try: os.add_dll_directory(bundle_dir) except (OSError, AttributeError): pass # Also try adding to PATH current_path = os.environ.get('PATH', '') if bundle_dir not in current_path: os.environ['PATH'] = bundle_dir + os.pathsep + current_path def ensure_utf8_encoding(): """Ensure UTF-8 encoding for console output""" if hasattr(sys, '_MEIPASS'): # Force UTF-8 encoding for console output in PyInstaller bundle import io try: # Set stdout and stderr to use UTF-8 sys.stdout.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8') except (AttributeError, io.UnsupportedOperation): # Fallback for older Python versions pass # Set environment variable for subprocesses os.environ['PYTHONIOENCODING'] = 'utf-8' # Call the functions ensure_dll_path() ensure_utf8_encoding() ''' hook_path = project_root / "pyi_rth_dll_fix.py" with open(hook_path, "w", encoding="utf-8") as f: f.write(hook_content) return str(hook_path)
[docs] def create_spec_file(): """Create custom spec file for PyInstaller""" project_root = get_project_root() # Create runtime hook runtime_hook_path = create_runtime_hook() # Get data files with proper paths data_files = get_data_files() # Prepare macOS info_plist if needed macos_info_plist = "" if CURRENT_OS == "macos": macos_info_plist = f""", info_plist={{ 'CFBundleDisplayName': 'Mycelian', 'CFBundleIdentifier': 'com.mycelian.app', 'CFBundleVersion': '{VERSION}', 'CFBundleShortVersionString': '{VERSION}', 'LSBackgroundOnly': False, 'LSUIElement': False, 'NSHighResolutionCapable': True, }}""" # Prepare binaries list for platform-specific requirements python_dll_binaries = [] if CURRENT_OS == "windows": import glob python_install_path = Path(sys.executable).parent # Try multiple potential DLL locations dll_locations = [ python_install_path / "python3*.dll", python_install_path / "python313.dll", python_install_path / "DLLs" / "python3*.dll", python_install_path.parent / "python3*.dll", # Sometimes in parent directory ] python_dll_found = False for location_pattern in dll_locations: for dll_path in glob.glob(str(location_pattern)): if os.path.exists(dll_path): python_dll_binaries.append((dll_path, ".")) python_dll_found = True break if python_dll_found: break # Also add essential Windows DLLs that might be needed vcruntime_dlls = [ python_install_path / "VCRUNTIME140.dll", python_install_path / "VCRUNTIME140_1.dll", python_install_path / "msvcp140.dll", ] for vcruntime_dll in vcruntime_dlls: if vcruntime_dll.exists(): python_dll_binaries.append((str(vcruntime_dll), ".")) if not python_dll_found: progress.update("Warning: Python DLL not found - may cause runtime issues") spec_content = f"""# -*- mode: python ; coding: utf-8 -*- # Mycelian Improved PyInstaller spec file # Auto-generated by build_improved.py import sys from pathlib import Path # Project root PROJECT_ROOT = Path(r'{project_root}') # Current OS for conditional settings CURRENT_OS = '{CURRENT_OS}' # Data files (includes NiceGUI assets only) datas = {data_files!r} # Hidden imports (includes all necessary web engine dependencies) hiddenimports = {get_hidden_imports(CURRENT_OS)!r} # Excluded modules (conservative exclusions) excludes = {get_excluded_modules()!r} # Binary includes (platform specific) - ensure Python DLL is included binaries = {python_dll_binaries!r} # Runtime hooks (helps with DLL loading) runtime_hooks = [r'{runtime_hook_path}'] # Analysis a = Analysis( ['main.py'], pathex=[str(PROJECT_ROOT)], binaries=binaries, datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={{}}, runtime_hooks=runtime_hooks, excludes=excludes, win_no_prefer_redirects=False, win_private_assemblies=False, cipher=None, noarchive=False, ) # Remove duplicate entries a.datas = list(set(a.datas)) # PYZ archive pyz = PYZ(a.pure, a.zipped_data, cipher=None) # Executable - macOS uses COLLECT + BUNDLE pattern for proper .app creation exe = EXE( pyz, a.scripts, [], exclude_binaries=True, # Exclude binaries from EXE, they'll be in COLLECT name='Mycelian', debug=False, bootloader_ignore_signals=False, strip=False, upx=False, upx_exclude=[], console=False, # Hide terminal/console on all platforms disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, icon={repr(str(project_root / ICON_PATH) if ICON_PATH else None)}, version={repr(str(project_root / VERSION_FILE) if VERSION_FILE and CURRENT_OS == "windows" else None)}{macos_info_plist}, ) # For macOS: Use COLLECT + BUNDLE pattern for proper .app bundle creation if CURRENT_OS == "macos": coll = COLLECT( exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=False, upx_exclude=[], name='Mycelian' ) app = BUNDLE( coll, name='Mycelian.app', icon=None, bundle_identifier='com.mycelian.app' ) else: # For Windows/Linux: Use EXE with onefile=True for single executable exe_final = EXE( pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], exclude_binaries=False, name='Mycelian', debug=False, bootloader_ignore_signals=False, strip=False, upx=False, upx_exclude=[], runtime_tmpdir=None, console=False, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, icon={repr(str(project_root / ICON_PATH) if ICON_PATH else None)}, version={repr(str(project_root / VERSION_FILE) if VERSION_FILE and CURRENT_OS == "windows" else None)}, manifest=None, onefile=True, ) """ spec_path = project_root / "mycelian_improved.spec" with open(spec_path, "w", encoding="utf-8") as f: f.write(spec_content) return spec_path
[docs] def build_executable(): """Build the executable using PyInstaller""" project_root = get_project_root() # Create spec file spec_path = create_spec_file() # Build command cmd = [ sys.executable, "-m", "PyInstaller", "--clean", # Clean PyInstaller cache "--noconfirm", # Overwrite output directory str( spec_path ), # Spec file contains all necessary settings (console=False, onefile=False for macOS) ] # Add custom output path if specified if OUTPUT_PATH: output_path = Path(OUTPUT_PATH) if not output_path.is_absolute(): output_path = project_root / output_path # Create the builds directory if it doesn't exist output_path.mkdir(parents=True, exist_ok=True) cmd.extend(["--distpath", str(output_path)]) dist_dir = output_path else: dist_dir = project_root / "dist" # Run PyInstaller result = subprocess.run(cmd, cwd=project_root, capture_output=True, text=True) if result.returncode == 0: progress.success("Build completed successfully") if dist_dir.exists(): # OS-specific executable naming exe_names = { "windows": "Mycelian.exe", "linux": "Mycelian", "macos": "Mycelian.app", # macOS creates .app bundle } exe_name = exe_names.get(CURRENT_OS, "Mycelian") exe_path = dist_dir / exe_name if exe_path.exists(): if CURRENT_OS == "macos" and exe_path.is_dir(): # macOS .app bundle - calculate total size of all files total_size = sum( f.stat().st_size for f in exe_path.rglob("*") if f.is_file() ) / (1024 * 1024) progress.update(f"Output: {exe_path} ({total_size:.1f} MB)") progress.update( "Note: Double-click in Finder to run without terminal" ) else: # Single file executable size_mb = exe_path.stat().st_size / (1024 * 1024) progress.update(f"Output: {exe_path} ({size_mb:.1f} MB)") if CURRENT_OS == "macos": progress.update( "Note: Double-click in Finder to run without terminal" ) return True, dist_dir else: progress.error("Build failed") if result.stderr: progress.update(f"Error: {result.stderr.strip()}") return False, None return result.returncode == 0, dist_dir
[docs] def create_build_info(dist_dir): """Create build information file""" from datetime import datetime build_info = { "build_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "python_version": sys.version, "platform": sys.platform, "build_method": "improved_pyinstaller", "pyinstaller_version": None, } try: import PyInstaller build_info["pyinstaller_version"] = PyInstaller.__version__ except ImportError: pass # Write build info build_info_path = dist_dir / "build_info.txt" if build_info_path.parent.exists(): with open(build_info_path, "w") as f: for key, value in build_info.items(): f.write(f"{key}: {value}\n")
[docs] def post_build_tasks(dist_dir): """Perform post-build tasks""" if not dist_dir or not dist_dir.exists(): progress.update("No output directory found") return # Calculate total size if dist_dir.exists(): total_size = sum( f.stat().st_size for f in dist_dir.rglob("*") if f.is_file() ) / (1024 * 1024) progress.update(f"Total build size: {total_size:.1f} MB") progress.update(f"Output location: {dist_dir}")
[docs] def update_version_across_files(): """Update version and build date across all relevant project files""" project_root = get_project_root() files_updated = [] try: # 1. Update pyproject.toml pyproject_path = project_root / "pyproject.toml" if pyproject_path.exists(): with open(pyproject_path, "r", encoding="utf-8") as f: content = f.read() # Update version line import re content = re.sub( r'^version = ".*"', f'version = "{VERSION}"', content, flags=re.MULTILINE, ) with open(pyproject_path, "w", encoding="utf-8") as f: f.write(content) files_updated.append("pyproject.toml") # 2. Update uv.lock uv_lock_path = project_root / "uv.lock" if uv_lock_path.exists(): with open(uv_lock_path, "r", encoding="utf-8") as f: lines = f.readlines() # Find and update the mycelian package version line for i, line in enumerate(lines): if 'name = "mycelian"' in line: # Look for version line in the next few lines for j in range(i + 1, min(i + 10, len(lines))): if lines[j].strip().startswith('version = "'): lines[j] = f'version = "{VERSION}"\n' break break with open(uv_lock_path, "w", encoding="utf-8") as f: f.writelines(lines) files_updated.append("uv.lock") # 3. Update docs/conf.py conf_path = project_root / "docs" / "conf.py" if conf_path.exists(): with open(conf_path, "r", encoding="utf-8") as f: content = f.read() # Update release line content = re.sub( r"^release = '.*'", f"release = '{VERSION}'", content, flags=re.MULTILINE, ) with open(conf_path, "w", encoding="utf-8") as f: f.write(content) files_updated.append("docs/conf.py") # 4. Update modules/database_init.py db_init_path = project_root / "modules" / "database_init.py" if db_init_path.exists(): with open(db_init_path, "r", encoding="utf-8") as f: content = f.read() # Update version and build_date lines content = re.sub(r"'version': '.*',", f"'version': '{VERSION}',", content) content = re.sub( r"'build_date': '.*',", f"'build_date': '{BUILD_DATE}',", content ) with open(db_init_path, "w", encoding="utf-8") as f: f.write(content) files_updated.append("modules/database_init.py") # 5. Update modules/dataobjects.py dataobjects_path = project_root / "modules" / "dataobjects.py" if dataobjects_path.exists(): with open(dataobjects_path, "r", encoding="utf-8") as f: content = f.read() # Update version and build_date lines in AppSettings class content = re.sub( r'version: str = ".*"', f'version: str = "{VERSION}"', content ) content = re.sub( r'build_date: str = ".*"', f'build_date: str = "{BUILD_DATE}"', content ) with open(dataobjects_path, "w", encoding="utf-8") as f: f.write(content) files_updated.append("modules/dataobjects.py") # 6. Update version.txt (Windows version info file) if VERSION_FILE: version_file_path = project_root / VERSION_FILE if version_file_path.exists(): with open(version_file_path, "r", encoding="utf-8") as f: content = f.read() # Convert version string (e.g. "1.0.6") to tuple format (1,0,6,0) version_parts = VERSION.split(".") # Pad with zeros to ensure we have 4 parts while len(version_parts) < 4: version_parts.append("0") version_tuple = f"({','.join(version_parts[:4])})" version_string = ".".join(version_parts[:4]) # Update filevers and prodvers tuples content = re.sub( r"filevers=\([^)]+\)", f"filevers={version_tuple}", content ) content = re.sub( r"prodvers=\([^)]+\)", f"prodvers={version_tuple}", content ) # Update StringStruct version entries content = re.sub( r"StringStruct\(u'FileVersion', u'[^']+'\)", f"StringStruct(u'FileVersion', u'{version_string}')", content, ) content = re.sub( r"StringStruct\(u'ProductVersion', u'[^']+'\)", f"StringStruct(u'ProductVersion', u'{version_string}')", content, ) with open(version_file_path, "w", encoding="utf-8") as f: f.write(content) files_updated.append(VERSION_FILE) # 7. Update Inno Setup script (Mycelian.iss) iss_path = project_root / "Mycelian.iss" if iss_path.exists(): with open(iss_path, "r", encoding="utf-8") as f: content = f.read() # Update MyAppVersion definition content = re.sub( r'#define MyAppVersion ".*"', f'#define MyAppVersion "{VERSION}"', content, ) with open(iss_path, "w", encoding="utf-8") as f: f.write(content) files_updated.append("Mycelian.iss") return True except Exception as e: print(f"Error updating version across files: {e}") import traceback traceback.print_exc() return False
[docs] def main(): """Main build function""" print(f"Mycelian Build Script v{VERSION} - {CURRENT_OS.upper()}") print("=" * 50) # Check if we're in the right directory project_root = get_project_root() main_py = project_root / "main.py" if not main_py.exists(): progress.error(f"main.py not found in {project_root}") progress.update("Please run this script from the project root directory") sys.exit(1) # macOS-specific requirements check if CURRENT_OS == "macos": progress.update("Checking macOS requirements...") try: import subprocess result = subprocess.run( ["xcode-select", "-p"], capture_output=True, text=True ) if result.returncode != 0: progress.error("Xcode command line tools not found!") progress.update("Install with: xcode-select --install") progress.update("Then agree to license with: sudo xcodebuild -license") response = input("Continue anyway? (y/N): ").strip().lower() if response != "y": progress.update("Build cancelled.") sys.exit(1) except FileNotFoundError: progress.error("xcode-select command not found") progress.update("Please install Xcode command line tools") sys.exit(1) try: # Step 1: Ensure dependencies progress.next_step("Checking dependencies") ensure_dependencies() progress.success() # Step 2: Clean previous builds progress.next_step("Cleaning build artifacts") clean_build() progress.success() # Step 3: Update version across files progress.next_step("Updating version information") update_version_across_files() progress.success() # Step 4: Build executable progress.next_step("Building executable") success, dist_dir = build_executable() if success: # Step 5: Post-build tasks progress.next_step("Finalizing build") post_build_tasks(dist_dir) progress.success() progress.summary() else: progress.error("Build failed") sys.exit(1) except KeyboardInterrupt: progress.error("Build interrupted by user") sys.exit(1) except Exception as e: progress.error(f"Build failed: {e}") import traceback traceback.print_exc() sys.exit(1) finally: # Clean up temporary files cleanup_count = 0 for hook_file in project_root.glob("pyi_rth_*.py"): try: hook_file.unlink() cleanup_count += 1 except Exception: pass if cleanup_count > 0: progress.update(f"Cleaned up {cleanup_count} temporary files")
if __name__ == "__main__": main()