mirror of
https://github.com/rendercv/rendercv.git
synced 2026-04-21 07:20:10 -04:00
Make PyPI version check non-blocking (thanks for the idea #615)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user