From e9db78b127ac28ec599ebbcc0e3e9eb87e42b6df Mon Sep 17 00:00:00 2001 From: Sina Atalay <79940989+sinaatalay@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:52:17 +0300 Subject: [PATCH] Make PyPI version check non-blocking (thanks for the idea #615) --- src/rendercv/cli/app.py | 107 ++++++++-- .../schema/models/cv/social_network.py | 5 +- tests/cli/test_app.py | 183 ++++++++++++++++-- 3 files changed, 259 insertions(+), 36 deletions(-) diff --git a/src/rendercv/cli/app.py b/src/rendercv/cli/app.py index 92faca6b..453346ab 100644 --- a/src/rendercv/cli/app.py +++ b/src/rendercv/cli/app.py @@ -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: diff --git a/src/rendercv/schema/models/cv/social_network.py b/src/rendercv/schema/models/cv/social_network.py index 4e480814..257ddb4b 100644 --- a/src/rendercv/schema/models/cv/social_network.py +++ b/src/rendercv/schema/models/cv/social_network.py @@ -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 diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py index bac40973..19f84b10 100644 --- a/tests/cli/test_app.py +++ b/tests/cli/test_app.py @@ -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"