From 40b896bcb54af6d4cef7eecb35fc7c1b4940caa9 Mon Sep 17 00:00:00 2001 From: m4 Date: Wed, 12 Aug 2026 16:24:17 +0800 Subject: [PATCH] feat(update): deployment detection and install command builder --- EvoScientist/updater.py | 95 +++++++++++++++++++++++++++++++++++++++++ tests/test_updater.py | 52 ++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 EvoScientist/updater.py create mode 100644 tests/test_updater.py diff --git a/EvoScientist/updater.py b/EvoScientist/updater.py new file mode 100644 index 0000000..e9959b0 --- /dev/null +++ b/EvoScientist/updater.py @@ -0,0 +1,95 @@ +"""Self-update planner and standalone updater runner. + +This module is STDLIB-ONLY on purpose: the file gets copied into the +staging dir and executed after the EvoScientist package has been replaced +on disk, so importing anything from EvoScientist would load mixed code. +""" + +from __future__ import annotations + +import json +import os +import shutil +import signal +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, Sequence + +DEPLOY_DOCKER = "docker" +DEPLOY_SYSTEMD = "systemd" +DEPLOY_UVTOOL = "cli-uvtool" +DEPLOY_PIPX = "cli-pipx" +DEPLOY_VENV = "cli-venv" + +PID_WAIT_TIMEOUT = 300.0 # seconds waiting for the parent to exit + + +@dataclass(frozen=True) +class SystemProbe: + in_container: bool + invocation_id: str | None + uv_tool_pkg: bool + pipx_pkg: bool + + +def detect_deployment(probe: SystemProbe) -> str: + if probe.in_container: + return DEPLOY_DOCKER + if probe.invocation_id: + return DEPLOY_SYSTEMD + if probe.uv_tool_pkg: + return DEPLOY_UVTOOL + if probe.pipx_pkg: + return DEPLOY_PIPX + return DEPLOY_VENV + + +def _uv_tool_has_package(environ: Mapping[str, str]) -> bool: + uv = shutil.which("uv") + if uv is None: + return False + try: + proc = subprocess.run( + [uv, "tool", "dir"], capture_output=True, text=True, timeout=10 + ) + except (OSError, subprocess.SubprocessError): + return False + if proc.returncode != 0: + return False + tool_dir = Path(proc.stdout.strip()) + return (tool_dir / "evoscientist").is_dir() or (tool_dir / "EvoScientist").is_dir() + + +def _pipx_has_package() -> bool: + home = Path.home() + for base in ( + home / ".local" / "pipx" / "venvs", + home / ".local" / "share" / "pipx" / "venvs", + ): + if (base / "evoscientist").is_dir() or (base / "EvoScientist").is_dir(): + return True + return False + + +def probe_system(environ: Mapping[str, str] | None = None) -> SystemProbe: + env = os.environ if environ is None else environ + in_container = Path("/.dockerenv").exists() or bool(env.get("container")) + return SystemProbe( + in_container=in_container, + invocation_id=env.get("INVOCATION_ID") or None, + uv_tool_pkg=False if in_container else _uv_tool_has_package(env), + pipx_pkg=False if in_container else _pipx_has_package(), + ) + + +def build_install_command(kind: str, artifact: Path, python: str) -> list[str]: + if kind in (DEPLOY_UVTOOL, DEPLOY_SYSTEMD): + # systemd only decides the restart strategy; the install command for a + # systemd + non-uv host is overridden by the caller (build_plan). + return ["uv", "tool", "install", "--force", str(artifact)] + if kind == DEPLOY_PIPX: + return ["pipx", "install", "--force", str(artifact)] + return [python, "-m", "pip", "install", "--force-reinstall", str(artifact)] diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 0000000..2f002ad --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,52 @@ +"""Tests for EvoScientist.updater deployment detection and plan building.""" + +import json + +import pytest + +from pathlib import Path + +from EvoScientist.updater import ( + DEPLOY_DOCKER, + DEPLOY_PIPX, + DEPLOY_SYSTEMD, + DEPLOY_UVTOOL, + DEPLOY_VENV, + SystemProbe, + build_install_command, + detect_deployment, +) + + +def _probe(**kw): + base = dict(in_container=False, invocation_id=None, uv_tool_pkg=False, pipx_pkg=False) + return SystemProbe(**{**base, **kw}) + + +def test_docker_wins_over_systemd_marker(): + assert detect_deployment(_probe(in_container=True, invocation_id="abc")) == DEPLOY_DOCKER + + +def test_systemd_before_cli(): + assert detect_deployment(_probe(invocation_id="abc", uv_tool_pkg=True)) == DEPLOY_SYSTEMD + + +def test_uvtool_pipx_venv_order(): + assert detect_deployment(_probe(uv_tool_pkg=True, pipx_pkg=True)) == DEPLOY_UVTOOL + assert detect_deployment(_probe(pipx_pkg=True)) == DEPLOY_PIPX + assert detect_deployment(_probe()) == DEPLOY_VENV + + +def test_install_commands(): + artifact = Path("/tmp/updates/v1/EvoScientist-1-py3-none-any.whl") + assert build_install_command(DEPLOY_UVTOOL, artifact, "/usr/bin/python3") == [ + "uv", "tool", "install", "--force", str(artifact), + ] + assert build_install_command(DEPLOY_PIPX, artifact, "/usr/bin/python3") == [ + "pipx", "install", "--force", str(artifact), + ] + assert build_install_command(DEPLOY_VENV, artifact, "/usr/bin/python3") == [ + "/usr/bin/python3", "-m", "pip", "install", "--force-reinstall", str(artifact), + ] + systemd_cmd = build_install_command(DEPLOY_SYSTEMD, artifact, "/usr/bin/python3") + assert systemd_cmd[0:2] == ["uv", "tool"] or systemd_cmd[0] == "/usr/bin/python3"