Make PyPI version check non-blocking (thanks for the idea #615)

This commit is contained in:
Sina Atalay
2026-02-16 14:52:17 +03:00
parent 0adb674464
commit e9db78b127
3 changed files with 259 additions and 36 deletions

View File

@@ -1,7 +1,11 @@
import importlib
import json
import os
import pathlib
import ssl
import sys
import threading
import time
import urllib.request
from typing import Annotated
@@ -11,6 +15,8 @@ from rich import print
from rendercv import __version__
VERSION_CHECK_TTL_SECONDS = 86400 # 24 hours
app = typer.Typer(
rich_markup_mode="rich",
# to make `rendercv --version` work:
@@ -39,35 +45,98 @@ def cli_command_no_args(
raise typer.Exit()
def warn_if_new_version_is_available() -> None:
"""Check PyPI for newer RenderCV version and display update notice.
def get_cache_dir() -> pathlib.Path:
"""Return the platform-appropriate cache directory for RenderCV."""
if sys.platform == "win32":
base = pathlib.Path(
os.environ.get("LOCALAPPDATA", pathlib.Path.home() / "AppData" / "Local")
)
elif sys.platform == "darwin":
base = pathlib.Path.home() / "Library" / "Caches"
else:
base = pathlib.Path(
os.environ.get("XDG_CACHE_HOME", pathlib.Path.home() / ".cache")
)
return base / "rendercv"
Why:
Users should be notified of updates for bug fixes and features.
Non-blocking check on startup ensures users stay informed without
interrupting workflow if check fails.
"""
def get_version_cache_file() -> pathlib.Path:
"""Return the path to the version check cache file."""
return get_cache_dir() / "version_check.json"
def read_version_cache() -> dict | None:
"""Read the cached version check data, or None if unavailable/corrupt."""
try:
data = json.loads(get_version_cache_file().read_text(encoding="utf-8"))
if isinstance(data, dict) and "last_check" in data and "latest_version" in data:
return data
except (OSError, json.JSONDecodeError, KeyError):
pass
return None
def write_version_cache(version_string: str) -> None:
"""Write the latest version string and current timestamp to the cache file."""
cache_file = get_version_cache_file()
try:
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(
json.dumps({"last_check": time.time(), "latest_version": version_string}),
encoding="utf-8",
)
except OSError:
pass
def fetch_latest_version_from_pypi() -> str | None:
"""Fetch the latest RenderCV version string from PyPI, or None on failure."""
url = "https://pypi.org/pypi/rendercv/json"
try:
with urllib.request.urlopen(
url, context=ssl._create_unverified_context()
url, context=ssl._create_unverified_context(), timeout=5
) as response:
data = response.read()
encoding = response.info().get_content_charset("utf-8")
json_data = json.loads(data.decode(encoding))
version_string = json_data["info"]["version"]
latest_version = packaging.version.Version(version_string)
return json_data["info"]["version"]
except Exception:
latest_version = None
return None
if latest_version is not None:
version = packaging.version.Version(__version__)
if version < latest_version:
print(
"\n[bold yellow]A new version of RenderCV is available! You are using"
f" v{__version__}, and the latest version is v{latest_version}.[/bold"
" yellow]\n"
)
def fetch_and_cache_latest_version() -> None:
"""Fetch the latest version from PyPI and write it to the cache file."""
version_string = fetch_latest_version_from_pypi()
if version_string:
write_version_cache(version_string)
def warn_if_new_version_is_available() -> None:
"""Check for a newer RenderCV version using a stale-while-revalidate cache.
Why:
Uses a disk cache with background refresh so the CLI never blocks on
network I/O. If the cache is stale or missing, a daemon thread refreshes
it for the next invocation.
"""
cache = read_version_cache()
if not cache or (time.time() - cache["last_check"]) >= VERSION_CHECK_TTL_SECONDS:
thread = threading.Thread(target=fetch_and_cache_latest_version, daemon=True)
thread.start()
if cache:
try:
latest = packaging.version.Version(cache["latest_version"])
current = packaging.version.Version(__version__)
if current < latest:
print(
"\n[bold yellow]A new version of RenderCV is available!"
f" You are using v{__version__}, and the latest version"
f" is v{latest}.[/bold yellow]\n"
)
except packaging.version.InvalidVersion:
pass
# Auto import all commands so that they are registered with the app:

View File

@@ -143,8 +143,9 @@ class SocialNetwork(BaseModelWithoutExtraKeys):
if not re.fullmatch(reddit_username_pattern, username):
raise pydantic_core.PydanticCustomError(
CustomPydanticErrorTypes.other.value,
"Reddit username should be made up of uppercase/lowercase letters, numbers,"
" underscores, and hyphens between 3 and 23 characters.",
"Reddit username should be made up of uppercase/lowercase"
" letters, numbers, underscores, and hyphens between 3 and 23"
" characters.",
)
return username

View File

@@ -1,12 +1,21 @@
import json
import pathlib
from unittest.mock import MagicMock, patch
import sys
import time
from unittest.mock import patch
import pytest
from typer.testing import CliRunner
from rendercv import __version__
from rendercv.cli.app import app, warn_if_new_version_is_available
from rendercv.cli.app import (
VERSION_CHECK_TTL_SECONDS,
app,
get_cache_dir,
read_version_cache,
warn_if_new_version_is_available,
write_version_cache,
)
def test_all_commands_are_registered():
@@ -49,6 +58,96 @@ class TestCliCommandNoArgs:
mock_warn.assert_called_once()
class TestGetCacheDir:
def test_returns_platform_appropriate_path(self):
cache_dir = get_cache_dir()
assert cache_dir.name == "rendercv"
if sys.platform == "darwin":
assert "Library/Caches" in str(cache_dir)
elif sys.platform == "win32":
assert "Local" in str(cache_dir)
def test_respects_xdg_cache_home_on_linux(self, tmp_path, monkeypatch):
monkeypatch.setattr("rendercv.cli.app.sys.platform", "linux")
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
assert get_cache_dir() == tmp_path / "rendercv"
class TestReadVersionCache:
def test_returns_none_for_missing_file(self, tmp_path, monkeypatch):
monkeypatch.setattr(
"rendercv.cli.app.get_version_cache_file",
lambda: tmp_path / "nonexistent.json",
)
assert read_version_cache() is None
def test_returns_none_for_corrupt_file(self, tmp_path, monkeypatch):
cache_file = tmp_path / "version_check.json"
cache_file.write_text("not valid json!!!", encoding="utf-8")
monkeypatch.setattr(
"rendercv.cli.app.get_version_cache_file",
lambda: cache_file,
)
assert read_version_cache() is None
def test_returns_none_for_incomplete_data(self, tmp_path, monkeypatch):
cache_file = tmp_path / "version_check.json"
cache_file.write_text(json.dumps({"latest_version": "1.0"}), encoding="utf-8")
monkeypatch.setattr(
"rendercv.cli.app.get_version_cache_file",
lambda: cache_file,
)
assert read_version_cache() is None
def test_returns_data_for_valid_cache(self, tmp_path, monkeypatch):
cache_file = tmp_path / "version_check.json"
cache_data = {"last_check": time.time(), "latest_version": "2.0.0"}
cache_file.write_text(json.dumps(cache_data), encoding="utf-8")
monkeypatch.setattr(
"rendercv.cli.app.get_version_cache_file",
lambda: cache_file,
)
result = read_version_cache()
assert result["latest_version"] == "2.0.0"
class TestWriteVersionCache:
def test_creates_cache_file(self, tmp_path, monkeypatch):
cache_file = tmp_path / "subdir" / "version_check.json"
monkeypatch.setattr(
"rendercv.cli.app.get_version_cache_file",
lambda: cache_file,
)
write_version_cache("2.0.0")
data = json.loads(cache_file.read_text(encoding="utf-8"))
assert data["latest_version"] == "2.0.0"
assert "last_check" in data
def write_cache(tmp_path, version, age_seconds=0):
"""Helper to write a version cache file for testing."""
cache_file = tmp_path / "version_check.json"
cache_file.write_text(
json.dumps(
{
"last_check": time.time() - age_seconds,
"latest_version": version,
}
),
encoding="utf-8",
)
return cache_file
class TestWarnIfNewVersionIsAvailable:
@pytest.mark.parametrize(
("version", "should_warn"),
@@ -58,17 +157,14 @@ class TestWarnIfNewVersionIsAvailable:
(__version__, False),
],
)
@patch("urllib.request.urlopen")
def test_warns_when_newer_version_available(
self, mock_urlopen, version, should_warn, capsys
def test_warns_from_fresh_cache(
self, version, should_warn, tmp_path, capsys, monkeypatch
):
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(
{"info": {"version": version}}
).encode("utf-8")
mock_response.info.return_value.get_content_charset.return_value = "utf-8"
mock_response.__enter__.return_value = mock_response
mock_urlopen.return_value = mock_response
write_cache(tmp_path, version, age_seconds=0)
monkeypatch.setattr(
"rendercv.cli.app.get_version_cache_file",
lambda: tmp_path / "version_check.json",
)
warn_if_new_version_is_available()
@@ -78,11 +174,68 @@ class TestWarnIfNewVersionIsAvailable:
else:
assert "new version" not in captured.out.lower()
@patch("urllib.request.urlopen")
def test_handles_network_errors_gracefully(self, mock_urlopen, capsys):
mock_urlopen.side_effect = Exception("Network error")
@patch("rendercv.cli.app.fetch_latest_version_from_pypi")
def test_fresh_cache_does_not_fetch(self, mock_fetch, tmp_path, monkeypatch):
write_cache(tmp_path, "99.0.0", age_seconds=0)
monkeypatch.setattr(
"rendercv.cli.app.get_version_cache_file",
lambda: tmp_path / "version_check.json",
)
warn_if_new_version_is_available()
mock_fetch.assert_not_called()
@patch("rendercv.cli.app.fetch_latest_version_from_pypi", return_value="99.0.0")
def test_stale_cache_warns_from_stale_data_and_refreshes(
self, mock_fetch, tmp_path, capsys, monkeypatch
):
write_cache(tmp_path, "98.0.0", age_seconds=VERSION_CHECK_TTL_SECONDS + 1)
monkeypatch.setattr(
"rendercv.cli.app.get_version_cache_file",
lambda: tmp_path / "version_check.json",
)
warn_if_new_version_is_available()
captured = capsys.readouterr()
assert "new version" in captured.out.lower()
mock_fetch.assert_called_once()
@patch("rendercv.cli.app.fetch_latest_version_from_pypi", return_value="99.0.0")
def test_missing_cache_shows_no_warning_and_refreshes(
self,
mock_fetch,
tmp_path,
capsys,
monkeypatch,
):
monkeypatch.setattr(
"rendercv.cli.app.get_version_cache_file",
lambda: tmp_path / "version_check.json",
)
warn_if_new_version_is_available()
captured = capsys.readouterr()
assert "new version" not in captured.out.lower()
mock_fetch.assert_called_once()
@patch("rendercv.cli.app.fetch_latest_version_from_pypi", return_value=None)
def test_network_failure_preserves_existing_cache(
self,
mock_fetch, # NOQA: ARG002
tmp_path,
monkeypatch,
):
write_cache(tmp_path, "99.0.0", age_seconds=VERSION_CHECK_TTL_SECONDS + 1)
cache_file = tmp_path / "version_check.json"
monkeypatch.setattr(
"rendercv.cli.app.get_version_cache_file",
lambda: cache_file,
)
warn_if_new_version_is_available()
data = json.loads(cache_file.read_text(encoding="utf-8"))
assert data["latest_version"] == "99.0.0"