Автообновление кода через релизы Gitea
Сервис при запуске проверяет последний релиз и обновляет только папку app - 19 КБ вместо 113 МБ всего пакета. Контрольная сумма, откат при неудачной проверке, пользовательские файлы не трогаются. Попутно: ffmpeg ищется лениво, а не в конструкторе Pipeline - его отсутствие роняло импорт вместо внятного сообщения. Схема API закрыта по умолчанию. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9dc67bda5c
commit
44becfbe5e
@@ -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, создайте задачу в планировщике:
|
Чтобы сервис поднимался при старте Windows, создайте задачу в планировщике:
|
||||||
|
|||||||
+14
-1
@@ -70,11 +70,24 @@ class Pipeline:
|
|||||||
self.models_dir = Path(models_dir)
|
self.models_dir = Path(models_dir)
|
||||||
self.threads = threads
|
self.threads = threads
|
||||||
self.replacements_path = Path(replacements_path)
|
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._asr = None
|
||||||
self._diar_cache: dict[int, object] = {}
|
self._diar_cache: dict[int, object] = {}
|
||||||
self._replacements: dict[str, str] = {}
|
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:
|
def warmup(self) -> None:
|
||||||
"""Грузит модели заранее, чтобы первый запрос не ждал."""
|
"""Грузит модели заранее, чтобы первый запрос не ждал."""
|
||||||
import onnx_asr
|
import onnx_asr
|
||||||
|
|||||||
+8
-3
@@ -9,6 +9,7 @@ Python, библиотеки, ffmpeg и модели остаются на ме
|
|||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -72,11 +73,12 @@ def _safe_members(archive: zipfile.ZipFile) -> list[str]:
|
|||||||
|
|
||||||
def _default_verify(base: Path) -> bool:
|
def _default_verify(base: Path) -> bool:
|
||||||
"""Проверяет, что обновлённый код хотя бы импортируется."""
|
"""Проверяет, что обновлённый код хотя бы импортируется."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.update({"PYTHONPATH": str(base), "TALKSCORE_ASR_HOME": str(base)})
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "-c", "import app.main"],
|
[sys.executable, "-c", "import app.main"],
|
||||||
cwd=str(base), capture_output=True, timeout=UPDATE_TIMEOUT_SEC,
|
cwd=str(base), capture_output=True, timeout=UPDATE_TIMEOUT_SEC, env=env,
|
||||||
env={"PYTHONPATH": str(base), "PATH": "", "TALKSCORE_ASR_HOME": str(base)},
|
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
log.error("новая версия не импортируется: %s",
|
log.error("новая версия не импортируется: %s",
|
||||||
@@ -194,7 +196,10 @@ def main() -> int:
|
|||||||
else:
|
else:
|
||||||
print(" Установлена последняя версия")
|
print(" Установлена последняя версия")
|
||||||
except Exception as exc: # noqa: BLE001 - падение обновления не должно мешать работе
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
__version__ = "0.1.0"
|
__version__ = "0.1.2"
|
||||||
|
|||||||
+2
-1
@@ -30,7 +30,8 @@ def client(tmp_path, monkeypatch):
|
|||||||
import app.config as cfg
|
import app.config as cfg
|
||||||
monkeypatch.setattr(cfg, "BASE_DIR", tmp_path)
|
monkeypatch.setattr(cfg, "BASE_DIR", tmp_path)
|
||||||
import app.main as main
|
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._state["ready"] = True
|
||||||
main._worker_stop.set() # воркер не должен разбирать очередь в тестах
|
main._worker_stop.set() # воркер не должен разбирать очередь в тестах
|
||||||
|
|
||||||
|
|||||||
@@ -126,3 +126,35 @@ class TestInstall:
|
|||||||
z.writestr("app/main.py", "код")
|
z.writestr("app/main.py", "код")
|
||||||
with pytest.raises(UpdateError, match="путь"):
|
with pytest.raises(UpdateError, match="путь"):
|
||||||
install_update(buf.getvalue(), base, verify=lambda _: True)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user