Skip to content

Commit cd4f876

Browse files
authored
Add version checker for UltraPlot (#377)
* add version checker on import * set default to false * add unittest
1 parent 0a121c9 commit cd4f876

File tree

4 files changed

+99
-0
lines changed

4 files changed

+99
-0
lines changed

ultraplot/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,7 @@
111111
warnings._warn_ultraplot(f"Invalid user rc file setting: {err}")
112112
_src[_key] = "black" # fill value
113113
from .colors import _cmap_database as colormaps
114+
from .utils import check_for_update
115+
116+
if rc["ultraplot.check_for_latest_version"]:
117+
check_for_update("ultraplot")

ultraplot/internals/rcsetup.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1945,6 +1945,11 @@ def copy(self):
19451945
"The format string used to format `pint.Quantity` default unit labels "
19461946
"using ``format(units, unitformat)``. See also :rcraw:`autoformat`.",
19471947
),
1948+
"ultraplot.check_for_latest_version": (
1949+
False,
1950+
_validate_bool,
1951+
"Whether to check for the latest version of UltraPlot on PyPI when importing",
1952+
),
19481953
}
19491954

19501955
# Child settings. Changing the parent changes all the children, but

ultraplot/tests/test_config.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,65 @@ def test_cycle_in_rc_file(tmp_path):
3232
uplt.rc.load(str(rc_file))
3333

3434
assert uplt.rc["cycle"] == "colorblind"
35+
36+
37+
import io
38+
from unittest.mock import patch, MagicMock
39+
from importlib.metadata import PackageNotFoundError
40+
from ultraplot.utils import check_for_update
41+
42+
43+
@patch("builtins.print")
44+
@patch("importlib.metadata.version")
45+
def test_package_not_installed(mock_version, mock_print):
46+
mock_version.side_effect = PackageNotFoundError
47+
check_for_update("fakepkg")
48+
mock_print.assert_not_called()
49+
50+
51+
@patch("builtins.print")
52+
@patch("importlib.metadata.version", return_value="1.0.0")
53+
@patch("urllib.request.urlopen")
54+
def test_network_failure(mock_urlopen, mock_version, mock_print):
55+
mock_urlopen.side_effect = Exception("Network down")
56+
check_for_update("fakepkg")
57+
mock_print.assert_not_called()
58+
59+
60+
@patch("builtins.print")
61+
@patch("importlib.metadata.version", return_value="1.0.0")
62+
@patch("urllib.request.urlopen")
63+
def test_no_update_available(mock_urlopen, mock_version, mock_print):
64+
mock_resp = MagicMock()
65+
mock_resp.__enter__.return_value = io.StringIO('{"info": {"version": "1.0.0"}}')
66+
mock_urlopen.return_value = mock_resp
67+
68+
check_for_update("fakepkg")
69+
mock_print.assert_not_called()
70+
71+
72+
@patch("builtins.print")
73+
@patch("importlib.metadata.version", return_value="1.0.0")
74+
@patch("urllib.request.urlopen")
75+
def test_update_available(mock_urlopen, mock_version, mock_print):
76+
mock_resp = MagicMock()
77+
mock_resp.__enter__.return_value = io.StringIO('{"info": {"version": "1.2.0"}}')
78+
mock_urlopen.return_value = mock_resp
79+
80+
check_for_update("fakepkg")
81+
mock_print.assert_called_once()
82+
msg = mock_print.call_args[0][0]
83+
assert "A newer version of fakepkg is available" in msg
84+
assert "1.0.0 → 1.2.0" in msg
85+
86+
87+
@patch("builtins.print")
88+
@patch("importlib.metadata.version", return_value="1.0.0dev")
89+
@patch("urllib.request.urlopen")
90+
def test_dev_version_skipped(mock_urlopen, mock_version, mock_print):
91+
mock_resp = MagicMock()
92+
mock_resp.__enter__.return_value = io.StringIO('{"info": {"version": "2.0.0"}}')
93+
mock_urlopen.return_value = mock_resp
94+
95+
check_for_update("fakepkg")
96+
mock_print.assert_not_called()

ultraplot/utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,34 @@ def _check_ranges(
11311131
return True
11321132

11331133

1134+
def check_for_update(package_name: str) -> None:
1135+
import json
1136+
import urllib.request
1137+
from importlib.metadata import version, PackageNotFoundError
1138+
1139+
try:
1140+
current_version = version(package_name)
1141+
except PackageNotFoundError:
1142+
return # package not installed (e.g. during dev)
1143+
1144+
try:
1145+
with urllib.request.urlopen(
1146+
f"https://pypi.org/pypi/{package_name}/json", timeout=2
1147+
) as resp:
1148+
data = json.load(resp)
1149+
latest_version = data["info"]["version"]
1150+
except Exception:
1151+
return # fail silently, e.g. no internet
1152+
1153+
# Skip local dev versions
1154+
if latest_version != current_version and "dev" not in current_version:
1155+
print(
1156+
f"\033[93m⚠️ A newer version of {package_name} is available: "
1157+
f"{current_version}{latest_version}\n"
1158+
f"Run: pip install -U {package_name}\033[0m"
1159+
)
1160+
1161+
11341162
# Deprecations
11351163
shade, saturate = warnings._rename_objs(
11361164
"0.6.0",

0 commit comments

Comments
 (0)