Файлы вне папки app обновление не доставляло: чтобы изменить порядок запуска или конфигурацию Caddy, требовалась переустановка. Теперь .bat - заглушка из трёх строк, вызывающая app/launcher.py, а Caddyfile создаётся из настроек [https] в config.toml. Отредактированный вручную Caddyfile не затирается: новая версия откладывается рядом как Caddyfile.new. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
192 lines
7.4 KiB
Python
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())
|