Исправлено разрушение папки app при обновлении на Windows

Обновление перемещало папку app целиком, а код выполняется именно из неё.
Windows не даёт двигать такую папку: перемещение не прошло, откат тоже,
и app исчезла - сервис переставал запускаться.

Теперь папка остаётся на месте: новая версия проверяется во временном
каталоге ДО установки, затем файлы перезаписываются по одному с копией
для отката. Устаревшие модули удаляются, __pycache__ не трогается.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-08-15 22:00:03 +05:00
co-authored by Claude Opus 5
parent b8cbaa6b82
commit 59857fff0f
4 changed files with 201 additions and 35 deletions
+69 -32
View File
@@ -71,14 +71,19 @@ def _safe_members(archive: zipfile.ZipFile) -> list[str]:
return names
def _default_verify(base: Path) -> bool:
"""Проверяет, что обновлённый код хотя бы импортируется."""
def _default_verify(staging: Path) -> bool:
"""Проверяет, что новый код импортируется, ещё до его установки.
Код берётся из временной папки, рабочая при этом не тронута: если новая
версия окажется нерабочей, ставить её не начнём вовсе.
"""
env = dict(os.environ)
env.update({"PYTHONPATH": str(base), "TALKSCORE_ASR_HOME": str(base)})
env.update({"PYTHONPATH": str(staging)})
env.setdefault("TALKSCORE_ASR_HOME", str(staging.parent))
try:
result = subprocess.run(
[sys.executable, "-c", "import app.main"],
cwd=str(base), capture_output=True, timeout=UPDATE_TIMEOUT_SEC, env=env,
cwd=str(staging), capture_output=True, timeout=UPDATE_TIMEOUT_SEC, env=env,
)
if result.returncode != 0:
log.error("новая версия не импортируется: %s",
@@ -91,45 +96,77 @@ def _default_verify(base: Path) -> bool:
def install_update(archive_bytes: bytes, base_dir: Path,
verify: Callable[[Path], bool] | None = None) -> None:
"""Ставит новую версию папки app с возможностью отката."""
"""Обновляет файлы внутри app, не трогая саму папку.
Windows не даёт переместить или удалить папку, из которой сейчас выполняется
код, а обновление запускается именно оттуда. Поэтому папка остаётся на месте,
а файлы в ней перезаписываются по одному, с копией для отката.
"""
base = Path(base_dir)
verify = verify or _default_verify
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as z:
members = _safe_members(z)
staging = base / ".update-staging"
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True)
for name in members:
if Path(name).name in USER_FILES:
continue
z.extract(name, staging)
new_app = staging / "app"
if not new_app.is_dir():
shutil.rmtree(staging, ignore_errors=True)
raise UpdateError("в архиве нет папки app")
staging = base / ".update-staging"
backup = base / ".update-backup"
shutil.rmtree(backup, ignore_errors=True)
current = base / "app"
if current.exists():
shutil.move(str(current), str(backup))
live = base / "app"
try:
shutil.move(str(new_app), str(current))
if not verify(base):
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True)
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as z:
for name in _safe_members(z):
if Path(name).name in USER_FILES:
continue
z.extract(name, staging)
if not (staging / "app").is_dir():
raise UpdateError("в архиве нет папки app")
# Проверяем до установки: рабочая версия ещё цела и продолжит работать,
# если новая окажется сломанной.
if not verify(staging):
raise UpdateError("новая версия не прошла проверку запуска")
except Exception:
shutil.rmtree(current, ignore_errors=True)
if backup.exists():
shutil.move(str(backup), str(current))
raise
live.mkdir(parents=True, exist_ok=True)
shutil.rmtree(backup, ignore_errors=True)
shutil.copytree(live, backup)
try:
_sync_files(staging / "app", live)
except Exception:
_sync_files(backup, live)
raise
finally:
shutil.rmtree(staging, ignore_errors=True)
shutil.rmtree(backup, ignore_errors=True)
def _sync_files(src: Path, dst: Path) -> None:
"""Приводит dst к состоянию src: копирует файлы и убирает лишние.
Устаревший модуль, удалённый в новой версии, обязан исчезнуть и здесь,
иначе он останется импортируемым и будет вести себя как призрак.
"""
dst.mkdir(parents=True, exist_ok=True)
wanted = set()
for item in sorted(src.rglob("*")):
rel = item.relative_to(src)
target = dst / rel
wanted.add(rel)
if item.is_dir():
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)
for item in sorted(dst.rglob("*"), reverse=True):
rel = item.relative_to(dst)
if rel in wanted or rel.parts[0] == "__pycache__":
continue
if item.is_file():
item.unlink(missing_ok=True)
elif item.is_dir() and not any(item.iterdir()):
item.rmdir()
def _api(session, url: str, token: str):
headers = {"Authorization": f"token {token}"} if token else {}
response = session.get(url, headers=headers, timeout=30)
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.3"
__version__ = "0.1.4"