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..c854cfcc2 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": ( + False, + _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/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() diff --git a/ultraplot/utils.py b/ultraplot/utils.py index be2a439a8..4c5c3ea8c 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1131,6 +1131,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",