From 44e261304ea9f145e055197e8bd5bbbade3302c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 16:38:29 +0000 Subject: [PATCH 1/2] add: background upgrade hint at startup (rate-limited, non-blocking) Calls background_update_hint() when solvent runs with no subcommand. A daemon thread checks PyPI at most once per 24h and prints a one-liner to stderr if a newer version is available. Skipped via SOLVENT_NO_UPDATE_CHECK=1. Timestamp gate lives in data_dir/.upgrade_check. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01SGjM79GJpSksy9wSSBa7e6 --- solvent/__main__.py | 2 + solvent/upgrade.py | 43 ++++++++++ tests/test_upgrade_hint.py | 158 +++++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 tests/test_upgrade_hint.py diff --git a/solvent/__main__.py b/solvent/__main__.py index 22ca05a..163baa9 100644 --- a/solvent/__main__.py +++ b/solvent/__main__.py @@ -112,6 +112,8 @@ def main(): print(f" {row['event_id'][:16]} {row['event_type']} err={row['error'][:60]}") else: # No subcommand: fall through to the demo / interactive CLI. + from .upgrade import background_update_hint + background_update_hint() from .cli import main as demo_main demo_main() diff --git a/solvent/upgrade.py b/solvent/upgrade.py index b1660ea..05a2039 100644 --- a/solvent/upgrade.py +++ b/solvent/upgrade.py @@ -5,16 +5,25 @@ solvent upgrade print upgrade instructions if a newer version exists solvent upgrade --check exit 1 if current version is behind PyPI (CI-friendly) solvent upgrade --install run pip install to upgrade in-place + +Background hint: call background_update_hint() at startup to fire a +non-blocking daemon thread that prints a one-line upgrade notice to stderr +if outdated. Rate-limited to once per day; skipped when +SOLVENT_NO_UPDATE_CHECK=1 is set. """ from __future__ import annotations import json +import os import sys +import time import urllib.error import urllib.request from typing import Optional +_CHECK_INTERVAL = 86400 # 24 hours in seconds + _PYPI_URL = "https://pypi.org/pypi/solvent-agent/json" _TIMEOUT = 5 # seconds @@ -118,5 +127,39 @@ def main(): sys.exit(0) +def background_update_hint() -> None: + """Fire a daemon thread that prints a one-line upgrade hint to stderr if outdated. + + Rate-limited to once per _CHECK_INTERVAL seconds via a timestamp file. + Skipped when SOLVENT_NO_UPDATE_CHECK=1 is set. + """ + if os.environ.get("SOLVENT_NO_UPDATE_CHECK"): + return + + import threading + + def _check() -> None: + try: + from .paths import data_dir + stamp_path = data_dir() / ".upgrade_check" + if stamp_path.exists(): + last = float(stamp_path.read_text().strip()) + if time.time() - last < _CHECK_INTERVAL: + return + latest = latest_pypi_version() + stamp_path.write_text(str(time.time())) + if latest and not (_parse_version(current_version()) >= _parse_version(latest)): + print( + f"\n[solvent] Update available: {current_version()} → {latest}" + f" (pip install --upgrade solvent-agent)\n", + file=sys.stderr, + ) + except Exception: + pass # never let the background check crash the agent + + t = threading.Thread(target=_check, daemon=True) + t.start() + + if __name__ == "__main__": main() diff --git a/tests/test_upgrade_hint.py b/tests/test_upgrade_hint.py new file mode 100644 index 0000000..1ff8a9a --- /dev/null +++ b/tests/test_upgrade_hint.py @@ -0,0 +1,158 @@ +"""Tests for background_update_hint() in solvent/upgrade.py.""" + +from __future__ import annotations + +import sys +import threading +import time +from io import StringIO +from pathlib import Path +from unittest import mock + +import pytest + +from solvent.upgrade import background_update_hint + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _join_threads(timeout: float = 2.0) -> None: + """Wait for all non-main daemon threads to finish.""" + for t in threading.enumerate(): + if t is not threading.main_thread(): + t.join(timeout=timeout) + + +# --------------------------------------------------------------------------- +# Env-var guard +# --------------------------------------------------------------------------- + +def test_env_var_suppresses_hint(tmp_path: Path) -> None: + """SOLVENT_NO_UPDATE_CHECK=1 must prevent any thread from starting.""" + with mock.patch.dict("os.environ", {"SOLVENT_NO_UPDATE_CHECK": "1"}): + with mock.patch("solvent.upgrade.latest_pypi_version") as mock_pypi: + background_update_hint() + _join_threads() + mock_pypi.assert_not_called() + + +# --------------------------------------------------------------------------- +# Happy-path: outdated version prints to stderr +# --------------------------------------------------------------------------- + +def test_prints_hint_when_outdated(tmp_path: Path) -> None: + with mock.patch.dict( + "os.environ", + {"SOLVENT_NO_UPDATE_CHECK": "", "SOLVENT_HOME": str(tmp_path)}, + clear=False, + ): + with mock.patch("solvent.upgrade.latest_pypi_version", return_value="999.0.0"): + with mock.patch("solvent.upgrade.current_version", return_value="0.1.0"): + with mock.patch("solvent.paths.data_dir", return_value=tmp_path): + stderr = StringIO() + with mock.patch("sys.stderr", stderr): + background_update_hint() + _join_threads() + output = stderr.getvalue() + assert "999.0.0" in output + assert "0.1.0" in output + assert "pip install" in output + + +# --------------------------------------------------------------------------- +# Up-to-date: no output +# --------------------------------------------------------------------------- + +def test_no_hint_when_up_to_date(tmp_path: Path) -> None: + with mock.patch.dict("os.environ", {"SOLVENT_NO_UPDATE_CHECK": ""}, clear=False): + with mock.patch("solvent.upgrade.latest_pypi_version", return_value="0.1.0"): + with mock.patch("solvent.upgrade.current_version", return_value="0.1.0"): + with mock.patch("solvent.paths.data_dir", return_value=tmp_path): + stderr = StringIO() + with mock.patch("sys.stderr", stderr): + background_update_hint() + _join_threads() + output = stderr.getvalue() + assert output == "" + + +# --------------------------------------------------------------------------- +# PyPI unreachable: no crash, no output +# --------------------------------------------------------------------------- + +def test_no_hint_when_pypi_unreachable(tmp_path: Path) -> None: + with mock.patch.dict("os.environ", {"SOLVENT_NO_UPDATE_CHECK": ""}, clear=False): + with mock.patch("solvent.upgrade.latest_pypi_version", return_value=None): + with mock.patch("solvent.paths.data_dir", return_value=tmp_path): + stderr = StringIO() + with mock.patch("sys.stderr", stderr): + background_update_hint() + _join_threads() + output = stderr.getvalue() + assert output == "" + + +# --------------------------------------------------------------------------- +# Rate-limiting: second call within interval is suppressed +# --------------------------------------------------------------------------- + +def test_rate_limit_suppresses_second_call(tmp_path: Path) -> None: + stamp = tmp_path / ".upgrade_check" + stamp.write_text(str(time.time())) # pretend we checked just now + + with mock.patch.dict("os.environ", {"SOLVENT_NO_UPDATE_CHECK": ""}, clear=False): + with mock.patch("solvent.upgrade.latest_pypi_version") as mock_pypi: + with mock.patch("solvent.paths.data_dir", return_value=tmp_path): + background_update_hint() + _join_threads() + mock_pypi.assert_not_called() + + +def test_rate_limit_allows_call_after_interval(tmp_path: Path) -> None: + stamp = tmp_path / ".upgrade_check" + old_ts = time.time() - 90000 # 25 hours ago + stamp.write_text(str(old_ts)) + + with mock.patch.dict("os.environ", {"SOLVENT_NO_UPDATE_CHECK": ""}, clear=False): + with mock.patch("solvent.upgrade.latest_pypi_version", return_value="999.0.0"): + with mock.patch("solvent.upgrade.current_version", return_value="0.1.0"): + with mock.patch("solvent.paths.data_dir", return_value=tmp_path): + stderr = StringIO() + with mock.patch("sys.stderr", stderr): + background_update_hint() + _join_threads() + output = stderr.getvalue() + assert "999.0.0" in output + + +# --------------------------------------------------------------------------- +# Stamp file is written after a successful check +# --------------------------------------------------------------------------- + +def test_stamp_written_after_check(tmp_path: Path) -> None: + stamp = tmp_path / ".upgrade_check" + assert not stamp.exists() + + with mock.patch.dict("os.environ", {"SOLVENT_NO_UPDATE_CHECK": ""}, clear=False): + with mock.patch("solvent.upgrade.latest_pypi_version", return_value="0.1.0"): + with mock.patch("solvent.upgrade.current_version", return_value="0.1.0"): + with mock.patch("solvent.paths.data_dir", return_value=tmp_path): + background_update_hint() + _join_threads() + + assert stamp.exists() + ts = float(stamp.read_text()) + assert abs(ts - time.time()) < 5 + + +# --------------------------------------------------------------------------- +# Exceptions inside thread must not propagate +# --------------------------------------------------------------------------- + +def test_exception_in_thread_does_not_raise(tmp_path: Path) -> None: + with mock.patch.dict("os.environ", {"SOLVENT_NO_UPDATE_CHECK": ""}, clear=False): + with mock.patch("solvent.paths.data_dir", side_effect=RuntimeError("boom")): + background_update_hint() + _join_threads() # no exception should surface here From 70f0b64688b2fbcd20669d66f5dede1893310e23 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 16:48:55 +0000 Subject: [PATCH 2/2] fix: ignore data/.upgrade_check to prevent git worktree dirty state In source checkouts data_dir() resolves to /data; writing the rate-limit stamp there without a gitignore entry dirtied the tree on every no-subcommand CLI run. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01SGjM79GJpSksy9wSSBa7e6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a9e0e32..46f5bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ data/solvent.lock data/ledger.lock data/ledger.json data/dashboard_status.json +data/.upgrade_check # Generated dashboard (created by run_demo.py) treasury_dashboard.html