Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions solvent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,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()

Expand Down
43 changes: 43 additions & 0 deletions solvent/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the update stamp out of the git worktree

In source checkouts, solvent.paths.data_dir() resolves to <repo>/data, and .gitignore does not ignore data/.upgrade_check, so this write makes the repository dirty just by running the no-subcommand CLI path (I also hit it when the routing test exercised entry.main()). Store the rate-limit stamp in an already-ignored/cache location or add this file to the ignore rules so normal CLI/test usage does not leave untracked files behind.

Useful? React with 👍 / 👎.

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()
158 changes: 158 additions & 0 deletions tests/test_upgrade_hint.py
Original file line number Diff line number Diff line change
@@ -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
Loading