"""@file build_exe.py @brief Сборка автономной программы: Python, Qt и PyMuPDF внутри. Запуск (из корня проекта, интерпретатором с PySide6 и PyInstaller): python scripts/build_exe.py Что получается, зависит от системы, на которой идёт сборка: * Windows — ``dist/Catalogizer.exe``; * macOS — ``dist/Catalogizer.app``; * Linux — ``dist/Catalogizer``. PyInstaller не умеет собирать под чужую систему: программу для macOS собирают на macOS, для Windows — на Windows. Исходники при этом одни. Готовая программа не требует ни Python, ни PySide6, ни PyMuPDF. Ключи разобраны в ``--help``: * ``--onedir`` — папка вместо одного файла: запускается заметно быстрее (в macOS так всегда: ``.app`` по устройству и есть папка); * ``--console`` — оставить консоль, чтобы видеть traceback и сообщения Qt; * ``--cli`` — дополнительно собрать консольную программу ``catalog``. """ from __future__ import annotations import argparse import shutil import subprocess import sys from pathlib import Path MACOS = sys.platform == "darwin" WINDOWS = sys.platform == "win32" #: Идентификатор пакета macOS: по нему система различает приложения. BUNDLE_ID = "ru.set.catalogizer" #: Корень проекта: скрипт лежит в scripts/. ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(Path(__file__).resolve().parent)) # для make_icon DIST = ROOT / "dist" BUILD = ROOT / "build" ICON = ROOT / "ico" / ("catalog.icns" if MACOS else "catalog.ico") #: Модули Qt, которые приложению не нужны. Без этого списка в сборку тянутся #: QtWebEngine и 3D-стек — сотни мегабайт мимо цели. UNUSED_QT_MODULES = ( "QtWebEngineCore", "QtWebEngineWidgets", "QtWebEngineQuick", "QtQuick", "QtQuick3D", "QtQml", "Qt3DCore", "Qt3DRender", "QtMultimedia", "QtBluetooth", "QtCharts", "QtDataVisualization", "QtGraphs", "QtPdf", "QtSql", "QtTest", "QtNetwork", "QtSerialPort", ) #: Что исключается всегда: tkinter в программе не используется, а PySide2 #: рядом с PySide6 ломает сборку. COMMON_EXCLUDES = ("tkinter", "PySide2", "shiboken2", "matplotlib", "numpy", "PIL", "pytest") def check_environment() -> None: """@brief Проверяет, что в интерпретаторе есть всё нужное для сборки.""" missing = [] for module, package in (("PyInstaller", "pyinstaller"), ("PySide6", "PySide6"), ("pymupdf", "pymupdf")): try: __import__(module) except ImportError: missing.append(package) if missing: raise SystemExit( "Не хватает пакетов: %s\nУстановите их:\n %s -m pip install %s" % (", ".join(missing), sys.executable, " ".join(missing))) def build_command(name: str, entry: Path, args: argparse.Namespace, windowed: bool) -> list[str]: """@brief Собирает командную строку PyInstaller для одной цели.""" command = [ sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--name", name, "--distpath", str(DIST), "--workpath", str(BUILD), "--specpath", str(BUILD), # Пакет catalogizer лежит в корне проекта и не обязан быть установлен. "--paths", str(ROOT), ] # На macOS .app — это всегда папка, поэтому onefile там смысла не имеет. command.append("--onedir" if (args.onedir or (MACOS and windowed)) else "--onefile") # Консоль скрыта: приложение оконное. С --console видны traceback и # сообщения Qt — так ищут причину, если программа молча не стартует. command.append("--console" if (args.console or not windowed) else "--windowed") if ICON.exists(): command += ["--icon", str(ICON)] if MACOS and windowed: command += ["--osx-bundle-identifier", BUNDLE_ID] for module in UNUSED_QT_MODULES: command += ["--exclude-module", "PySide6.%s" % module] for module in COMMON_EXCLUDES: command += ["--exclude-module", module] command.append(str(entry)) return command def run(command: list[str]) -> None: print(" ".join('"%s"' % c if " " in c else c for c in command), flush=True) result = subprocess.run(command, cwd=str(ROOT)) if result.returncode: raise SystemExit("PyInstaller завершился с кодом %d" % result.returncode) def main() -> int: parser = argparse.ArgumentParser( description="Сборка автономного Каталогизатора " "(exe в Windows, .app в macOS)") parser.add_argument("--onedir", action="store_true", help="папка вместо одного файла: запуск быстрее") parser.add_argument("--console", action="store_true", help="оставить консоль у оконной сборки (для отладки)") parser.add_argument("--cli", action="store_true", help="дополнительно собрать консольную программу catalog") args = parser.parse_args() check_environment() if not ICON.exists(): # Иконки не хранятся в репозитории — они целиком рисуются кодом. import make_icon print("Рисую иконки…", flush=True) make_icon.build_ico() make_icon.build_icns() if BUILD.exists(): shutil.rmtree(BUILD, ignore_errors=True) run(build_command("Catalogizer", ROOT / "catalog_gui.py", args, windowed=True)) if args.cli: run(build_command("catalog", ROOT / "catalog.py", args, windowed=False)) if MACOS: # Загрузчик .app обязан быть исполняемым, даже если права потерялись. binary = DIST / "Catalogizer.app" / "Contents" / "MacOS" / "Catalogizer" if binary.exists(): binary.chmod(0o755) print("\nГотово. Файлы в %s" % DIST) return 0 if __name__ == "__main__": raise SystemExit(main())