Автообновление кода через релизы Gitea

Сервис при запуске проверяет последний релиз и обновляет только папку app -
19 КБ вместо 113 МБ всего пакета. Контрольная сумма, откат при неудачной
проверке, пользовательские файлы не трогаются.

Попутно: ffmpeg ищется лениво, а не в конструкторе Pipeline - его отсутствие
роняло импорт вместо внятного сообщения. Схема API закрыта по умолчанию.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-08-15 21:44:52 +05:00
co-authored by Claude Opus 5
parent 9dc67bda5c
commit 44becfbe5e
6 changed files with 97 additions and 6 deletions
+40
View File
@@ -153,6 +153,46 @@ for turn in r["turns"]:
Заодно постобработка приводит типографику к принятому виду: все виды тире
заменяются на дефис, кавычки отбиваются пробелом.
## Обновления
Сервис проверяет новую версию при каждом запуске и обновляет только папку `app` -
это десятки килобайт. Python, библиотеки, ffmpeg и модели остаются на месте,
перекидывать весь пакет заново не нужно.
Настройка в `config.toml`:
```toml
[update]
enabled = true
server = "https://git.netranking.ru"
repo = "bryzgalov/talkscore-asr"
token = "" # токен Gitea с правом чтения; для публичного репозитория не нужен
```
Репозиторий приватный, поэтому токен обязателен. Создать его: Gitea → Settings →
Applications → Generate Token, достаточно права `read:repository`.
Как это работает:
1. `start.bat` перед запуском сервиса спрашивает у Gitea последний релиз
2. если версия там новее, скачивает `app-<версия>.zip` и сверяет контрольную сумму
3. откладывает текущий код, ставит новый и проверяет, что он импортируется
4. если проверка не прошла, возвращает предыдущую версию
Нет сети или Gitea недоступен - сервис просто запускается на текущей версии.
`config.toml` и `replacements.txt` обновление не трогает: они ваши.
Чтобы выключить проверку совсем, поставьте `enabled = false`.
### Выпуск новой версии
```bash
uv run --with requests python build/release.py 0.2.0 -m "что изменилось"
```
Скрипт проставит версию в `app/version.py`, соберёт архив только из кода
и опубликует релиз с контрольной суммой. Целевая машина подхватит его
при следующем запуске.
## Автозапуск
Чтобы сервис поднимался при старте Windows, создайте задачу в планировщике:
+14 -1
View File
@@ -70,11 +70,24 @@ class Pipeline:
self.models_dir = Path(models_dir)
self.threads = threads
self.replacements_path = Path(replacements_path)
self.ffmpeg = find_ffmpeg(Path(base_dir))
self.base_dir = Path(base_dir)
self._ffmpeg: str | None = None
self._asr = None
self._diar_cache: dict[int, object] = {}
self._replacements: dict[str, str] = {}
@property
def ffmpeg(self) -> str:
"""Ищется при первом обращении, а не в конструкторе.
Иначе отсутствие ffmpeg роняло бы сам импорт модуля - и вместо внятного
сообщения пользователь получал бы стек при старте, а обновление кода
не могло бы проверить работоспособность новой версии.
"""
if self._ffmpeg is None:
self._ffmpeg = find_ffmpeg(self.base_dir)
return self._ffmpeg
def warmup(self) -> None:
"""Грузит модели заранее, чтобы первый запрос не ждал."""
import onnx_asr
+8 -3
View File
@@ -9,6 +9,7 @@ Python, библиотеки, ffmpeg и модели остаются на ме
"""
import hashlib
import io
import os
import logging
import shutil
import subprocess
@@ -72,11 +73,12 @@ def _safe_members(archive: zipfile.ZipFile) -> list[str]:
def _default_verify(base: Path) -> bool:
"""Проверяет, что обновлённый код хотя бы импортируется."""
env = dict(os.environ)
env.update({"PYTHONPATH": str(base), "TALKSCORE_ASR_HOME": str(base)})
try:
result = subprocess.run(
[sys.executable, "-c", "import app.main"],
cwd=str(base), capture_output=True, timeout=UPDATE_TIMEOUT_SEC,
env={"PYTHONPATH": str(base), "PATH": "", "TALKSCORE_ASR_HOME": str(base)},
cwd=str(base), capture_output=True, timeout=UPDATE_TIMEOUT_SEC, env=env,
)
if result.returncode != 0:
log.error("новая версия не импортируется: %s",
@@ -194,7 +196,10 @@ def main() -> int:
else:
print(" Установлена последняя версия")
except Exception as exc: # noqa: BLE001 - падение обновления не должно мешать работе
print(f" Обновиться не удалось ({type(exc).__name__}: {exc}), запускаюсь на текущей версии")
# Полный стек requests занимает пол-экрана и пугает, оставляем суть.
reason = str(exc).split("(Caused by")[0].strip()[:160] or type(exc).__name__
print(f" Обновиться не удалось: {reason}")
print(" Запускаюсь на текущей версии")
return 0
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.0"
__version__ = "0.1.2"
+2 -1
View File
@@ -30,7 +30,8 @@ def client(tmp_path, monkeypatch):
import app.config as cfg
monkeypatch.setattr(cfg, "BASE_DIR", tmp_path)
import app.main as main
monkeypatch.setattr(main.pipeline, "ffmpeg", "/bin/true", raising=False)
# ffmpeg ищется лениво, поэтому подменяем внутреннее поле, а не свойство
monkeypatch.setattr(main.pipeline, "_ffmpeg", "/bin/true", raising=False)
main._state["ready"] = True
main._worker_stop.set() # воркер не должен разбирать очередь в тестах
+32
View File
@@ -126,3 +126,35 @@ class TestInstall:
z.writestr("app/main.py", "код")
with pytest.raises(UpdateError, match="путь"):
install_update(buf.getvalue(), base, verify=lambda _: True)
class TestPipelineImportSafety:
def test_pipeline_constructs_without_ffmpeg(self, tmp_path, monkeypatch):
"""Отсутствие ffmpeg не должно ронять импорт: иначе непонятно, что чинить."""
import sys
import types
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
monkeypatch.setattr("shutil.which", lambda _: None)
from app.pipeline import Pipeline
p = Pipeline(models_dir=tmp_path, threads=1,
replacements_path=tmp_path / "r.txt", base_dir=tmp_path)
assert p is not None # конструктор не упал
def test_ffmpeg_lookup_fails_only_when_used(self, tmp_path, monkeypatch):
import sys
import types
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
monkeypatch.setattr("shutil.which", lambda _: None)
from app.pipeline import ModelsMissing, Pipeline
p = Pipeline(models_dir=tmp_path, threads=1,
replacements_path=tmp_path / "r.txt", base_dir=tmp_path)
with pytest.raises(ModelsMissing, match="ffmpeg"):
_ = p.ffmpeg