From 18c3c05bc8e5699f0f6c55b5fdcc537a1493193e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 22 Oct 2025 08:21:50 +0200 Subject: [PATCH 1/3] add version checker on import --- ultraplot/__init__.py | 4 ++++ ultraplot/internals/rcsetup.py | 5 +++++ ultraplot/utils.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 87da7d8b5..2a2db3bd1 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -111,3 +111,7 @@ warnings._warn_ultraplot(f"Invalid user rc file setting: {err}") _src[_key] = "black" # fill value from .colors import _cmap_database as colormaps +from .utils import check_for_update + +if rc["ultraplot.check_for_latest_version"]: + check_for_update("ultraplot") diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index bdb9a88d7..9b177a6a5 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -1945,6 +1945,11 @@ def copy(self): "The format string used to format `pint.Quantity` default unit labels " "using ``format(units, unitformat)``. See also :rcraw:`autoformat`.", ), + "ultraplot.check_for_latest_version": ( + True, + _validate_bool, + "Whether to check for the latest version of UltraPlot on PyPI when importing", + ), } # Child settings. Changing the parent changes all the children, but diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 1b1b97a95..15b932641 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1093,6 +1093,34 @@ def _check_ranges( return True +def check_for_update(package_name: str) -> None: + import json + import urllib.request + from importlib.metadata import version, PackageNotFoundError + + try: + current_version = version(package_name) + except PackageNotFoundError: + return # package not installed (e.g. during dev) + + try: + with urllib.request.urlopen( + f"https://pypi.org/pypi/{package_name}/json", timeout=2 + ) as resp: + data = json.load(resp) + latest_version = data["info"]["version"] + except Exception: + return # fail silently, e.g. no internet + + # Skip local dev versions + if latest_version != current_version and "dev" not in current_version: + print( + f"\033[93m⚠️ A newer version of {package_name} is available: " + f"{current_version} → {latest_version}\n" + f"Run: pip install -U {package_name}\033[0m" + ) + + # Deprecations shade, saturate = warnings._rename_objs( "0.6.0", From 12a443b2f2cea4086305ff2efb528d6508ae945e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 25 Oct 2025 11:15:11 +0200 Subject: [PATCH 2/3] set default to false --- ultraplot/internals/rcsetup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 9b177a6a5..c854cfcc2 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -1946,7 +1946,7 @@ def copy(self): "using ``format(units, unitformat)``. See also :rcraw:`autoformat`.", ), "ultraplot.check_for_latest_version": ( - True, + False, _validate_bool, "Whether to check for the latest version of UltraPlot on PyPI when importing", ), From 0cfa135f93e897a67953bf7c078ea07174881417 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 25 Oct 2025 11:19:26 +0200 Subject: [PATCH 3/3] add unittest --- ultraplot/tests/test_config.py | 62 ++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index 7867bdd1a..c700bb0c8 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -32,3 +32,65 @@ def test_cycle_in_rc_file(tmp_path): uplt.rc.load(str(rc_file)) assert uplt.rc["cycle"] == "colorblind" + + +import io +from unittest.mock import patch, MagicMock +from importlib.metadata import PackageNotFoundError +from ultraplot.utils import check_for_update + + +@patch("builtins.print") +@patch("importlib.metadata.version") +def test_package_not_installed(mock_version, mock_print): + mock_version.side_effect = PackageNotFoundError + check_for_update("fakepkg") + mock_print.assert_not_called() + + +@patch("builtins.print") +@patch("importlib.metadata.version", return_value="1.0.0") +@patch("urllib.request.urlopen") +def test_network_failure(mock_urlopen, mock_version, mock_print): + mock_urlopen.side_effect = Exception("Network down") + check_for_update("fakepkg") + mock_print.assert_not_called() + + +@patch("builtins.print") +@patch("importlib.metadata.version", return_value="1.0.0") +@patch("urllib.request.urlopen") +def test_no_update_available(mock_urlopen, mock_version, mock_print): + mock_resp = MagicMock() + mock_resp.__enter__.return_value = io.StringIO('{"info": {"version": "1.0.0"}}') + mock_urlopen.return_value = mock_resp + + check_for_update("fakepkg") + mock_print.assert_not_called() + + +@patch("builtins.print") +@patch("importlib.metadata.version", return_value="1.0.0") +@patch("urllib.request.urlopen") +def test_update_available(mock_urlopen, mock_version, mock_print): + mock_resp = MagicMock() + mock_resp.__enter__.return_value = io.StringIO('{"info": {"version": "1.2.0"}}') + mock_urlopen.return_value = mock_resp + + check_for_update("fakepkg") + mock_print.assert_called_once() + msg = mock_print.call_args[0][0] + assert "A newer version of fakepkg is available" in msg + assert "1.0.0 → 1.2.0" in msg + + +@patch("builtins.print") +@patch("importlib.metadata.version", return_value="1.0.0dev") +@patch("urllib.request.urlopen") +def test_dev_version_skipped(mock_urlopen, mock_version, mock_print): + mock_resp = MagicMock() + mock_resp.__enter__.return_value = io.StringIO('{"info": {"version": "2.0.0"}}') + mock_urlopen.return_value = mock_resp + + check_for_update("fakepkg") + mock_print.assert_not_called()