Skip to content

Commit 72692b4

Browse files
committed
Make PyPI version check non-blocking (thanks for the idea rendercv#615)
1 parent 0adb674 commit 72692b4

2 files changed

Lines changed: 268 additions & 35 deletions

File tree

src/rendercv/cli/app.py

Lines changed: 103 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import importlib
22
import json
3+
import os
34
import pathlib
45
import ssl
6+
import sys
7+
import threading
8+
import time
59
import urllib.request
610
from typing import Annotated
711

@@ -11,6 +15,8 @@
1115

1216
from rendercv import __version__
1317

18+
VERSION_CHECK_TTL_SECONDS = 86400 # 24 hours
19+
1420
app = typer.Typer(
1521
rich_markup_mode="rich",
1622
# to make `rendercv --version` work:
@@ -39,35 +45,112 @@ def cli_command_no_args(
3945
raise typer.Exit()
4046

4147

42-
def warn_if_new_version_is_available() -> None:
43-
"""Check PyPI for newer RenderCV version and display update notice.
48+
def get_cache_dir() -> pathlib.Path:
49+
"""Return the platform-appropriate cache directory for RenderCV."""
50+
if sys.platform == "win32":
51+
base = pathlib.Path(
52+
os.environ.get(
53+
"LOCALAPPDATA", pathlib.Path.home() / "AppData" / "Local"
54+
)
55+
)
56+
elif sys.platform == "darwin":
57+
base = pathlib.Path.home() / "Library" / "Caches"
58+
else:
59+
base = pathlib.Path(
60+
os.environ.get(
61+
"XDG_CACHE_HOME", pathlib.Path.home() / ".cache"
62+
)
63+
)
64+
return base / "rendercv"
4465

45-
Why:
46-
Users should be notified of updates for bug fixes and features.
47-
Non-blocking check on startup ensures users stay informed without
48-
interrupting workflow if check fails.
49-
"""
66+
67+
def get_version_cache_file() -> pathlib.Path:
68+
"""Return the path to the version check cache file."""
69+
return get_cache_dir() / "version_check.json"
70+
71+
72+
def read_version_cache() -> dict | None:
73+
"""Read the cached version check data, or None if unavailable/corrupt."""
74+
try:
75+
data = json.loads(
76+
get_version_cache_file().read_text(encoding="utf-8")
77+
)
78+
if (
79+
isinstance(data, dict)
80+
and "last_check" in data
81+
and "latest_version" in data
82+
):
83+
return data
84+
except (OSError, json.JSONDecodeError, KeyError):
85+
pass
86+
return None
87+
88+
89+
def write_version_cache(version_string: str) -> None:
90+
"""Write the latest version string and current timestamp to the cache file."""
91+
cache_file = get_version_cache_file()
92+
try:
93+
cache_file.parent.mkdir(parents=True, exist_ok=True)
94+
cache_file.write_text(
95+
json.dumps(
96+
{"last_check": time.time(), "latest_version": version_string}
97+
),
98+
encoding="utf-8",
99+
)
100+
except OSError:
101+
pass
102+
103+
104+
def fetch_latest_version_from_pypi() -> str | None:
105+
"""Fetch the latest RenderCV version string from PyPI, or None on failure."""
50106
url = "https://pypi.org/pypi/rendercv/json"
51107
try:
52108
with urllib.request.urlopen(
53-
url, context=ssl._create_unverified_context()
109+
url, context=ssl._create_unverified_context(), timeout=5
54110
) as response:
55111
data = response.read()
56112
encoding = response.info().get_content_charset("utf-8")
57113
json_data = json.loads(data.decode(encoding))
58-
version_string = json_data["info"]["version"]
59-
latest_version = packaging.version.Version(version_string)
114+
return json_data["info"]["version"]
60115
except Exception:
61-
latest_version = None
62-
63-
if latest_version is not None:
64-
version = packaging.version.Version(__version__)
65-
if version < latest_version:
66-
print(
67-
"\n[bold yellow]A new version of RenderCV is available! You are using"
68-
f" v{__version__}, and the latest version is v{latest_version}.[/bold"
69-
" yellow]\n"
70-
)
116+
return None
117+
118+
119+
def fetch_and_cache_latest_version() -> None:
120+
"""Fetch the latest version from PyPI and write it to the cache file."""
121+
version_string = fetch_latest_version_from_pypi()
122+
if version_string:
123+
write_version_cache(version_string)
124+
125+
126+
def warn_if_new_version_is_available() -> None:
127+
"""Check for a newer RenderCV version using a stale-while-revalidate cache.
128+
129+
Why:
130+
Uses a disk cache with background refresh so the CLI never blocks on
131+
network I/O. If the cache is stale or missing, a daemon thread refreshes
132+
it for the next invocation.
133+
"""
134+
cache = read_version_cache()
135+
136+
if not cache or (time.time() - cache["last_check"]) >= VERSION_CHECK_TTL_SECONDS:
137+
thread = threading.Thread(
138+
target=fetch_and_cache_latest_version, daemon=True
139+
)
140+
thread.start()
141+
142+
if cache:
143+
try:
144+
latest = packaging.version.Version(cache["latest_version"])
145+
current = packaging.version.Version(__version__)
146+
if current < latest:
147+
print(
148+
"\n[bold yellow]A new version of RenderCV is available!"
149+
f" You are using v{__version__}, and the latest version"
150+
f" is v{latest}.[/bold yellow]\n"
151+
)
152+
except packaging.version.InvalidVersion:
153+
pass
71154

72155

73156
# Auto import all commands so that they are registered with the app:

tests/cli/test_app.py

Lines changed: 165 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
import json
22
import pathlib
3-
from unittest.mock import MagicMock, patch
3+
import sys
4+
import time
5+
from unittest.mock import patch
46

57
import pytest
68
from typer.testing import CliRunner
79

810
from rendercv import __version__
9-
from rendercv.cli.app import app, warn_if_new_version_is_available
11+
from rendercv.cli.app import (
12+
VERSION_CHECK_TTL_SECONDS,
13+
app,
14+
get_cache_dir,
15+
read_version_cache,
16+
warn_if_new_version_is_available,
17+
write_version_cache,
18+
)
1019

1120

1221
def test_all_commands_are_registered():
@@ -49,6 +58,96 @@ def test_shows_help_when_no_args(self, mock_warn):
4958
mock_warn.assert_called_once()
5059

5160

61+
class TestGetCacheDir:
62+
def test_returns_platform_appropriate_path(self):
63+
cache_dir = get_cache_dir()
64+
65+
assert cache_dir.name == "rendercv"
66+
if sys.platform == "darwin":
67+
assert "Library/Caches" in str(cache_dir)
68+
elif sys.platform == "win32":
69+
assert "Local" in str(cache_dir)
70+
71+
def test_respects_xdg_cache_home_on_linux(self, tmp_path, monkeypatch):
72+
monkeypatch.setattr("rendercv.cli.app.sys.platform", "linux")
73+
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
74+
75+
assert get_cache_dir() == tmp_path / "rendercv"
76+
77+
78+
class TestReadVersionCache:
79+
def test_returns_none_for_missing_file(self, tmp_path, monkeypatch):
80+
monkeypatch.setattr(
81+
"rendercv.cli.app.get_version_cache_file",
82+
lambda: tmp_path / "nonexistent.json",
83+
)
84+
85+
assert read_version_cache() is None
86+
87+
def test_returns_none_for_corrupt_file(self, tmp_path, monkeypatch):
88+
cache_file = tmp_path / "version_check.json"
89+
cache_file.write_text("not valid json!!!", encoding="utf-8")
90+
monkeypatch.setattr(
91+
"rendercv.cli.app.get_version_cache_file",
92+
lambda: cache_file,
93+
)
94+
95+
assert read_version_cache() is None
96+
97+
def test_returns_none_for_incomplete_data(self, tmp_path, monkeypatch):
98+
cache_file = tmp_path / "version_check.json"
99+
cache_file.write_text(json.dumps({"latest_version": "1.0"}), encoding="utf-8")
100+
monkeypatch.setattr(
101+
"rendercv.cli.app.get_version_cache_file",
102+
lambda: cache_file,
103+
)
104+
105+
assert read_version_cache() is None
106+
107+
def test_returns_data_for_valid_cache(self, tmp_path, monkeypatch):
108+
cache_file = tmp_path / "version_check.json"
109+
cache_data = {"last_check": time.time(), "latest_version": "2.0.0"}
110+
cache_file.write_text(json.dumps(cache_data), encoding="utf-8")
111+
monkeypatch.setattr(
112+
"rendercv.cli.app.get_version_cache_file",
113+
lambda: cache_file,
114+
)
115+
116+
result = read_version_cache()
117+
118+
assert result["latest_version"] == "2.0.0"
119+
120+
121+
class TestWriteVersionCache:
122+
def test_creates_cache_file(self, tmp_path, monkeypatch):
123+
cache_file = tmp_path / "subdir" / "version_check.json"
124+
monkeypatch.setattr(
125+
"rendercv.cli.app.get_version_cache_file",
126+
lambda: cache_file,
127+
)
128+
129+
write_version_cache("2.0.0")
130+
131+
data = json.loads(cache_file.read_text(encoding="utf-8"))
132+
assert data["latest_version"] == "2.0.0"
133+
assert "last_check" in data
134+
135+
136+
def write_cache(tmp_path, version, age_seconds=0):
137+
"""Helper to write a version cache file for testing."""
138+
cache_file = tmp_path / "version_check.json"
139+
cache_file.write_text(
140+
json.dumps(
141+
{
142+
"last_check": time.time() - age_seconds,
143+
"latest_version": version,
144+
}
145+
),
146+
encoding="utf-8",
147+
)
148+
return cache_file
149+
150+
52151
class TestWarnIfNewVersionIsAvailable:
53152
@pytest.mark.parametrize(
54153
("version", "should_warn"),
@@ -58,17 +157,14 @@ class TestWarnIfNewVersionIsAvailable:
58157
(__version__, False),
59158
],
60159
)
61-
@patch("urllib.request.urlopen")
62-
def test_warns_when_newer_version_available(
63-
self, mock_urlopen, version, should_warn, capsys
160+
def test_warns_from_fresh_cache(
161+
self, version, should_warn, tmp_path, capsys, monkeypatch
64162
):
65-
mock_response = MagicMock()
66-
mock_response.read.return_value = json.dumps(
67-
{"info": {"version": version}}
68-
).encode("utf-8")
69-
mock_response.info.return_value.get_content_charset.return_value = "utf-8"
70-
mock_response.__enter__.return_value = mock_response
71-
mock_urlopen.return_value = mock_response
163+
write_cache(tmp_path, version, age_seconds=0)
164+
monkeypatch.setattr(
165+
"rendercv.cli.app.get_version_cache_file",
166+
lambda: tmp_path / "version_check.json",
167+
)
72168

73169
warn_if_new_version_is_available()
74170

@@ -78,11 +174,65 @@ def test_warns_when_newer_version_available(
78174
else:
79175
assert "new version" not in captured.out.lower()
80176

81-
@patch("urllib.request.urlopen")
82-
def test_handles_network_errors_gracefully(self, mock_urlopen, capsys):
83-
mock_urlopen.side_effect = Exception("Network error")
177+
@patch("rendercv.cli.app.fetch_latest_version_from_pypi")
178+
def test_fresh_cache_does_not_fetch(
179+
self, mock_fetch, tmp_path, monkeypatch
180+
):
181+
write_cache(tmp_path, "99.0.0", age_seconds=0)
182+
monkeypatch.setattr(
183+
"rendercv.cli.app.get_version_cache_file",
184+
lambda: tmp_path / "version_check.json",
185+
)
186+
187+
warn_if_new_version_is_available()
188+
189+
mock_fetch.assert_not_called()
190+
191+
@patch("rendercv.cli.app.fetch_latest_version_from_pypi", return_value="99.0.0")
192+
def test_stale_cache_warns_from_stale_data_and_refreshes(
193+
self, mock_fetch, tmp_path, capsys, monkeypatch
194+
):
195+
write_cache(
196+
tmp_path, "98.0.0", age_seconds=VERSION_CHECK_TTL_SECONDS + 1
197+
)
198+
monkeypatch.setattr(
199+
"rendercv.cli.app.get_version_cache_file",
200+
lambda: tmp_path / "version_check.json",
201+
)
202+
203+
warn_if_new_version_is_available()
204+
205+
captured = capsys.readouterr()
206+
assert "new version" in captured.out.lower()
207+
mock_fetch.assert_called_once()
208+
209+
@patch("rendercv.cli.app.fetch_latest_version_from_pypi", return_value="99.0.0")
210+
def test_missing_cache_shows_no_warning_and_refreshes(
211+
self, mock_fetch, tmp_path, capsys, monkeypatch
212+
):
213+
monkeypatch.setattr(
214+
"rendercv.cli.app.get_version_cache_file",
215+
lambda: tmp_path / "version_check.json",
216+
)
84217

85218
warn_if_new_version_is_available()
86219

87220
captured = capsys.readouterr()
88221
assert "new version" not in captured.out.lower()
222+
mock_fetch.assert_called_once()
223+
224+
@patch("rendercv.cli.app.fetch_latest_version_from_pypi", return_value=None)
225+
def test_network_failure_preserves_existing_cache(
226+
self, mock_fetch, tmp_path, monkeypatch
227+
):
228+
write_cache(tmp_path, "99.0.0", age_seconds=VERSION_CHECK_TTL_SECONDS + 1)
229+
cache_file = tmp_path / "version_check.json"
230+
monkeypatch.setattr(
231+
"rendercv.cli.app.get_version_cache_file",
232+
lambda: cache_file,
233+
)
234+
235+
warn_if_new_version_is_available()
236+
237+
data = json.loads(cache_file.read_text(encoding="utf-8"))
238+
assert data["latest_version"] == "99.0.0"

0 commit comments

Comments
 (0)