Files
talkscore-asr/build/make_windows_zip.py
T
Vladimir BryzgalovandClaude Opus 5 febec7f124 Логика запуска и Caddyfile переехали внутрь app
Файлы вне папки app обновление не доставляло: чтобы изменить порядок
запуска или конфигурацию Caddy, требовалась переустановка. Теперь .bat -
заглушка из трёх строк, вызывающая app/launcher.py, а Caddyfile создаётся
из настроек [https] в config.toml. Отредактированный вручную Caddyfile
не затирается: новая версия откладывается рядом как Caddyfile.new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:23:47 +05:00

192 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Собирает готовый к запуску ZIP для Windows.
Собирается на любой ОС: скачиваются встраиваемый Python для Windows,
колёса под win_amd64 и ffmpeg.exe. Устанавливать Python на целевой машине
не требуется - распаковал и запустил start.bat.
uv run --with pip python build/make_windows_zip.py
"""
import argparse
import io
import shutil
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "dist"
PY_VERSION = "3.11.9"
PY_URL = f"https://www.python.org/ftp/python/{PY_VERSION}/python-{PY_VERSION}-embed-amd64.zip"
# LGPL-сборка: нам нужен только декодер аудио, GPL-компоненты не требуются.
FFMPEG_URL = ("https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/"
"ffmpeg-master-latest-win64-lgpl.zip")
# Caddy сам получает и продлевает сертификаты Let's Encrypt - для машины без
# постоянного администратора это надёжнее ручной возни с сертификатами.
CADDY_URL = ("https://github.com/caddyserver/caddy/releases/download/v2.10.2/"
"caddy_2.10.2_windows_amd64.zip")
PACKAGES = [
"fastapi", "uvicorn", "python-multipart",
"onnx-asr", "onnxruntime", "sherpa-onnx", "numpy",
"huggingface-hub", "tqdm", "requests",
]
def fetch(url: str) -> bytes:
print(f" скачиваю {url.rsplit('/', 1)[-1]}")
with urllib.request.urlopen(url) as r:
return r.read()
def add_python(stage: Path) -> None:
print("Встраиваемый Python")
data = fetch(PY_URL)
py_dir = stage / "python"
py_dir.mkdir(parents=True)
with zipfile.ZipFile(io.BytesIO(data)) as z:
z.extractall(py_dir)
# Встраиваемая сборка по умолчанию не видит site-packages: включаем,
# иначе установленные колёса не импортируются.
for pth in py_dir.glob("python*._pth"):
text = pth.read_text(encoding="utf-8").replace("#import site", "import site")
if "Lib\\site-packages" not in text:
text += "\nLib\\site-packages\n..\n"
pth.write_text(text, encoding="utf-8")
def add_wheels(stage: Path) -> None:
print("Колёса под win_amd64")
site = stage / "python" / "Lib" / "site-packages"
site.mkdir(parents=True, exist_ok=True)
wheels = stage / "_wheels"
subprocess.run([
sys.executable, "-m", "pip", "download", "--quiet", "--only-binary=:all:",
"--platform", "win_amd64", "--python-version", "3.11",
"--implementation", "cp", "--abi", "cp311", "-d", str(wheels), *PACKAGES,
], check=True)
for whl in sorted(wheels.glob("*.whl")):
with zipfile.ZipFile(whl) as z:
z.extractall(site)
shutil.rmtree(wheels)
# Тесты и заголовки в поставке не нужны и весят прилично
for junk in list(site.rglob("__pycache__")) + list(site.glob("*/include")):
shutil.rmtree(junk, ignore_errors=True)
def add_ffmpeg(stage: Path) -> None:
print("ffmpeg")
data = fetch(FFMPEG_URL)
bin_dir = stage / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(io.BytesIO(data)) as z:
name = next(n for n in z.namelist() if n.endswith("bin/ffmpeg.exe"))
(bin_dir / "ffmpeg.exe").write_bytes(z.read(name))
def add_caddy(stage: Path) -> None:
print("Caddy для HTTPS")
data = fetch(CADDY_URL)
bin_dir = stage / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(io.BytesIO(data)) as z:
name = next(n for n in z.namelist() if n.endswith("caddy.exe"))
(bin_dir / "caddy.exe").write_bytes(z.read(name))
def add_app(stage: Path) -> None:
print("Код сервиса")
shutil.copytree(ROOT / "app", stage / "app",
ignore=shutil.ignore_patterns("__pycache__"))
for name in ("replacements.txt", "README.md"):
src = ROOT / name
if src.is_file():
shutil.copy(src, stage / name)
write_bat(stage / "start.bat", START_BAT)
write_bat(stage / "download_models.bat", DOWNLOAD_BAT)
write_bat(stage / "start-https.bat", HTTPS_BAT)
(stage / "download_models.py").write_text(
(ROOT / "build" / "download_models.py").read_text(encoding="utf-8"), encoding="utf-8")
def write_bat(path: Path, text: str) -> None:
"""Пишет .bat с переводами строк CRLF и без кириллицы.
cmd.exe читает файлы с одними LF криво - теряет символы в начале строк,
из-за чего `set TALKSCORE_ASR_HOME` превращается в `ALKSCORE_ASR_HOME`.
Кириллицы в .bat тоже быть не должно: кодировка консоли заранее неизвестна,
поэтому все сообщения для человека печатает Python.
"""
path.write_text(text, encoding="ascii", newline="\r\n")
# chcp 65001 переводит консоль в UTF-8, и Python дальше пишет в той же кодировке.
# Без этой пары русский текст в выводе превращается в мусор.
# .bat - трёхстрочные заглушки: вся логика запуска живёт в app/launcher.py,
# который обновление доставляет штатно. Раньше логика была здесь, и чтобы
# изменить порядок запуска, требовалась переустановка.
# chcp 65001 переводит консоль в UTF-8, иначе русский текст станет мусором.
START_BAT = """@echo off
chcp 65001 >nul
cd /d "%~dp0"
set "TALKSCORE_ASR_HOME=%~dp0"
set "PYTHONPATH=%~dp0"
"%~dp0python\\python.exe" -m app.launcher
pause
"""
HTTPS_BAT = """@echo off
chcp 65001 >nul
cd /d "%~dp0"
set "TALKSCORE_ASR_HOME=%~dp0"
set "PYTHONPATH=%~dp0"
"%~dp0python\\python.exe" -m app.launcher https
pause
"""
DOWNLOAD_BAT = """@echo off
chcp 65001 >nul
cd /d "%~dp0"
set "TALKSCORE_ASR_HOME=%~dp0"
set "PYTHONPATH=%~dp0"
"%~dp0python\\python.exe" download_models.py
pause
"""
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--skip-ffmpeg", action="store_true",
help="не вкладывать ffmpeg (нужен свой в PATH)")
args = ap.parse_args()
stage = OUT / "talkscore-asr"
if stage.exists():
shutil.rmtree(stage)
stage.mkdir(parents=True)
add_python(stage)
add_wheels(stage)
if not args.skip_ffmpeg:
add_ffmpeg(stage)
add_caddy(stage)
add_app(stage)
archive = OUT / "talkscore-asr-windows.zip"
archive.unlink(missing_ok=True)
print("Упаковка")
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
for path in sorted(stage.rglob("*")):
if path.is_file():
z.write(path, Path("talkscore-asr") / path.relative_to(stage))
size_mb = archive.stat().st_size / 1024 / 1024
print(f"\nГотово: {archive} ({size_mb:.0f} МБ)")
return 0
if __name__ == "__main__":
sys.exit(main())