Skip to content

Commit e9db78b

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

3 files changed

Lines changed: 260 additions & 37 deletions

File tree

src/rendercv/cli/app.py

Lines changed: 89 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,98 @@ 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("LOCALAPPDATA", pathlib.Path.home() / "AppData" / "Local")
53+
)
54+
elif sys.platform == "darwin":
55+
base = pathlib.Path.home() / "Library" / "Caches"
56+
else:
57+
base = pathlib.Path(
58+
os.environ.get("XDG_CACHE_HOME", pathlib.Path.home() / ".cache")
59+
)
60+
return base / "rendercv"
4461

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-
"""
62+
63+
def get_version_cache_file() -> pathlib.Path:
64+
"""Return the path to the version check cache file."""
65+
return get_cache_dir() / "version_check.json"
66+
67+
68+
def read_version_cache() -> dict | None:
69+
"""Read the cached version check data, or None if unavailable/corrupt."""
70+
try:
71+
data = json.loads(get_version_cache_file().read_text(encoding="utf-8"))
72+
if isinstance(data, dict) and "last_check" in data and "latest_version" in data:
73+
return data
74+
except (OSError, json.JSONDecodeError, KeyError):
75+
pass
76+
return None
77+
78+
79+
def write_version_cache(version_string: str) -> None:
80+
"""Write the latest version string and current timestamp to the cache file."""
81+
cache_file = get_version_cache_file()
82+
try:
83+
cache_file.parent.mkdir(parents=True, exist_ok=True)
84+
cache_file.write_text(
85+
json.dumps({"last_check": time.time(), "latest_version": version_string}),
86+
encoding="utf-8",
87+
)
88+
except OSError:
89+
pass
90+
91+
92+
def fetch_latest_version_from_pypi() -> str | None:
93+
"""Fetch the latest RenderCV version string from PyPI, or None on failure."""
5094
url = "https://pypi.org/pypi/rendercv/json"
5195
try:
5296
with urllib.request.urlopen(
53-
url, context=ssl._create_unverified_context()
97+
url, context=ssl._create_unverified_context(), timeout=5
5498
) as response:
5599
data = response.read()
56100
encoding = response.info().get_content_charset("utf-8")
57101
json_data = json.loads(data.decode(encoding))
58-
version_string = json_data["info"]["version"]
59-
latest_version = packaging.version.Version(version_string)
102+
return json_data["info"]["version"]
60103
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-
)
104+
return None
105+
106+
107+
def fetch_and_cache_latest_version() -> None:
108+
"""Fetch the latest version from PyPI and write it to the cache file."""
109+
version_string = fetch_latest_version_from_pypi()
110+
if version_string:
111+
write_version_cache(version_string)
112+
113+
114+
def warn_if_new_version_is_available() -> None:
115+
"""Check for a newer RenderCV version using a stale-while-revalidate cache.
116+
117+
Why:
118+
Uses a disk cache with background refresh so the CLI never blocks on
119+
network I/O. If the cache is stale or missing, a daemon thread refreshes
120+
it for the next invocation.
121+
"""
122+
cache = read_version_cache()
123+
124+
if not cache or (time.time() - cache["last_check"]) >= VERSION_CHECK_TTL_SECONDS:
125+
thread = threading.Thread(target=fetch_and_cache_latest_version, daemon=True)
126+
thread.start()
127+
128+
if cache:
129+
try:
130+
latest = packaging.version.Version(cache["latest_version"])
131+
current = packaging.version.Version(__version__)
132+
if current < latest:
133+
print(
134+
"\n[bold yellow]A new version of RenderCV is available!"
135+
f" You are using v{__version__}, and the latest version"
136+
f" is v{latest}.[/bold yellow]\n"
137+
)
138+
except packaging.version.InvalidVersion:
139+
pass
71140

72141

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

src/rendercv/schema/models/cv/social_network.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,9 @@ def check_username(cls, username: str, info: pydantic.ValidationInfo) -> str:
143143
if not re.fullmatch(reddit_username_pattern, username):
144144
raise pydantic_core.PydanticCustomError(
145145
CustomPydanticErrorTypes.other.value,
146-
"Reddit username should be made up of uppercase/lowercase letters, numbers,"
147-
" underscores, and hyphens between 3 and 23 characters.",
146+
"Reddit username should be made up of uppercase/lowercase"
147+
" letters, numbers, underscores, and hyphens between 3 and 23"
148+
" characters.",
148149
)
149150

150151
return username

tests/cli/test_app.py

Lines changed: 168 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,68 @@ 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(self, mock_fetch, tmp_path, monkeypatch):
179+
write_cache(tmp_path, "99.0.0", age_seconds=0)
180+
monkeypatch.setattr(
181+
"rendercv.cli.app.get_version_cache_file",
182+
lambda: tmp_path / "version_check.json",
183+
)
184+
185+
warn_if_new_version_is_available()
186+
187+
mock_fetch.assert_not_called()
188+
189+
@patch("rendercv.cli.app.fetch_latest_version_from_pypi", return_value="99.0.0")
190+
def test_stale_cache_warns_from_stale_data_and_refreshes(
191+
self, mock_fetch, tmp_path, capsys, monkeypatch
192+
):
193+
write_cache(tmp_path, "98.0.0", age_seconds=VERSION_CHECK_TTL_SECONDS + 1)
194+
monkeypatch.setattr(
195+
"rendercv.cli.app.get_version_cache_file",
196+
lambda: tmp_path / "version_check.json",
197+
)
198+
199+
warn_if_new_version_is_available()
200+
201+
captured = capsys.readouterr()
202+
assert "new version" in captured.out.lower()
203+
mock_fetch.assert_called_once()
204+
205+
@patch("rendercv.cli.app.fetch_latest_version_from_pypi", return_value="99.0.0")
206+
def test_missing_cache_shows_no_warning_and_refreshes(
207+
self,
208+
mock_fetch,
209+
tmp_path,
210+
capsys,
211+
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,
227+
mock_fetch, # NOQA: ARG002
228+
tmp_path,
229+
monkeypatch,
230+
):
231+
write_cache(tmp_path, "99.0.0", age_seconds=VERSION_CHECK_TTL_SECONDS + 1)
232+
cache_file = tmp_path / "version_check.json"
233+
monkeypatch.setattr(
234+
"rendercv.cli.app.get_version_cache_file",
235+
lambda: cache_file,
236+
)
237+
238+
warn_if_new_version_is_available()
239+
240+
data = json.loads(cache_file.read_text(encoding="utf-8"))
241+
assert data["latest_version"] == "99.0.0"

0 commit comments

Comments
 (0)