-
Notifications
You must be signed in to change notification settings - Fork 2
GOATS-971: add version checker in goats run #457
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
miguelgomezgomez
merged 5 commits into
main
from
GOATS-971/add-version-checker-in-goats-run
Oct 21, 2025
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
a9893a0
GOATS-971: add version checker in goats run
f972847
add tests for version checker
7b87c57
Remove outdated --remote-data tests for OCS and TNS.
davner ebdea69
add checker test using live network connection
2319ccf
change address code review feedback
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
from importlib.metadata import version as get_version | ||
from json import JSONDecodeError | ||
|
||
import requests | ||
from packaging.version import InvalidVersion, Version | ||
|
||
from goats_cli.exceptions import GOATSClickException | ||
|
||
CHANNELDATA_URL = "https://gemini-hlsw.github.io/goats-infra/conda/channeldata.json" | ||
|
||
|
||
davner marked this conversation as resolved.
Show resolved
Hide resolved
|
||
class VersionChecker: | ||
miguelgomezgomez marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
Compare the installed GOATS version against the latest available in the channel. | ||
|
||
Parameters | ||
---------- | ||
channeldata_url : str, optional | ||
URL to the ``channeldata.json`` file used to resolve the latest version. | ||
Defaults to ``CHANNELDATA_URL``. | ||
package_name : str, optional | ||
Package name whose installed version is obtained via | ||
``importlib.metadata.version``. Defaults to ``"goats"``. | ||
timeout_sec : float, optional | ||
HTTP request timeout in seconds. Defaults to ``1.0``. | ||
|
||
Attributes | ||
---------- | ||
channeldata_url : str | ||
URL used to query the latest available version. | ||
package_name : str | ||
Package name used to resolve the installed version. | ||
timeout_sec : float | ||
Timeout applied to the HTTP request. | ||
current_version : str | None | ||
Installed package version (``None`` until computed). | ||
latest_version : str | None | ||
Latest available version from the channel (``None`` until computed). | ||
is_outdated : bool | None | ||
``True`` if ``current_version < latest_version``, ``False`` otherwise | ||
(``None`` until computed). | ||
""" | ||
|
||
def __init__( | ||
self, | ||
channeldata_url: str = CHANNELDATA_URL, | ||
package_name: str = "goats", | ||
timeout_sec: float = 1.0, | ||
) -> None: | ||
""" | ||
Initialize a :class:`VersionChecker` instance. | ||
|
||
Parameters | ||
---------- | ||
channeldata_url : str, optional | ||
URL to the channel metadata JSON. Defaults to ``CHANNELDATA_URL``. | ||
package_name : str, optional | ||
Package name to inspect. Defaults to ``"goats"``. | ||
timeout_sec : float, optional | ||
Timeout (seconds) for HTTP requests. Defaults to ``1.0``. | ||
""" | ||
self.channeldata_url = channeldata_url | ||
self.package_name = package_name | ||
self.timeout_sec = timeout_sec | ||
|
||
self.current_version: str | None = None | ||
self.latest_version: str | None = None | ||
self.is_outdated: bool | None = None | ||
|
||
def _get_current_version(self) -> str: | ||
""" | ||
Return the currently installed version string for ``self.package_name``. | ||
|
||
Returns | ||
------- | ||
str | ||
Installed version string (e.g., ``"1.2.3"``). | ||
|
||
Raises | ||
------ | ||
importlib.metadata.PackageNotFoundError | ||
If the package is not installed in the current environment. | ||
""" | ||
return get_version(self.package_name).strip() | ||
|
||
def _get_latest_version(self) -> str: | ||
""" | ||
Fetch the latest available version string from the Conda channel. | ||
|
||
Returns | ||
------- | ||
str | ||
Latest version string for ``self.package_name`` (e.g., ``"1.2.3"``). | ||
|
||
Raises | ||
------ | ||
GOATSClickException | ||
If a network/HTTP error occurs, the response payload is invalid JSON, | ||
or the JSON structure does not contain the expected keys. | ||
""" | ||
try: | ||
resp = requests.get(self.channeldata_url, timeout=self.timeout_sec) | ||
resp.raise_for_status() | ||
data = resp.json() | ||
return data["packages"][self.package_name]["version"].strip() | ||
except requests.RequestException as error: | ||
raise GOATSClickException(f"Failed to fetch latest version info: {error}") | ||
except JSONDecodeError as error: | ||
raise GOATSClickException(f"Invalid JSON: {error}") | ||
except (KeyError, TypeError) as error: | ||
raise GOATSClickException( | ||
f"Malformed channel metadata while obtaining latest version: {error}" | ||
) | ||
|
||
def check_if_outdated(self) -> bool: | ||
""" | ||
Resolve both installed and latest versions and update the instance state. | ||
|
||
This method always re-queries the environment and the channel: | ||
it refreshes :attr:`current_version`, :attr:`latest_version`, and | ||
recomputes :attr:`is_outdated`. | ||
|
||
Returns | ||
------- | ||
bool | ||
``True`` if an update is available (``installed < latest``), | ||
otherwise ``False``. | ||
|
||
Raises | ||
------ | ||
GOATSClickException | ||
If fetching/parsing the channel metadata fails, or if either version | ||
string is invalid (invalid PEP 440 format). | ||
""" | ||
self.current_version = self._get_current_version() | ||
self.latest_version = self._get_latest_version() | ||
try: | ||
self.is_outdated = Version(self.current_version) < Version( | ||
self.latest_version | ||
) | ||
return self.is_outdated | ||
except InvalidVersion as error: | ||
raise GOATSClickException( | ||
"Invalid version string while comparing versions: " | ||
f"current={self.current_version!r}, " | ||
f"latest={self.latest_version!r}" | ||
) from error |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
import pytest | ||
from json import JSONDecodeError | ||
from unittest.mock import Mock, patch | ||
from requests import RequestException, HTTPError | ||
import requests | ||
|
||
from goats_cli.versioning import VersionChecker | ||
from goats_cli.exceptions import GOATSClickException | ||
|
||
|
||
def _fake_response(json_payload=None, status_code=200, json_raises=None) -> requests.Response: | ||
resp = Mock(spec=requests.Response) | ||
resp.status_code = status_code | ||
if status_code >= 400: | ||
resp.raise_for_status.side_effect = HTTPError(f"status={status_code}") | ||
else: | ||
resp.raise_for_status.return_value = None | ||
if json_raises is not None: | ||
resp.json.side_effect = json_raises | ||
else: | ||
resp.json.return_value = json_payload | ||
return resp | ||
|
||
|
||
@patch("goats_cli.versioning.get_version", return_value="1.0.0") | ||
@patch("goats_cli.versioning.requests.get") | ||
def test_is_outdated_true(mock_get, _mock_get_version): | ||
payload = {"packages": {"goats": {"version": "1.2.0"}}} | ||
mock_get.return_value = _fake_response(json_payload=payload) | ||
|
||
vc = VersionChecker() | ||
assert vc.check_if_outdated() is True | ||
|
||
assert vc.current_version == "1.0.0" | ||
assert vc.latest_version == "1.2.0" | ||
assert vc.is_outdated is True | ||
|
||
mock_get.assert_called_once() | ||
called_kwargs = mock_get.call_args.kwargs | ||
assert called_kwargs["timeout"] == 1.0 | ||
|
||
|
||
@patch("goats_cli.versioning.get_version", return_value="1.2.0") | ||
@patch("goats_cli.versioning.requests.get") | ||
def test_is_outdated_false_equal(mock_get, _): | ||
payload = {"packages": {"goats": {"version": "1.2.0"}}} | ||
mock_get.return_value = _fake_response(json_payload=payload) | ||
|
||
vc = VersionChecker() | ||
assert vc.check_if_outdated() is False | ||
assert vc.is_outdated is False | ||
|
||
|
||
@patch("goats_cli.versioning.get_version", return_value="1.0.0") | ||
@patch("goats_cli.versioning.requests.get") | ||
def test_request_exception_wraps_in_goatsclick(mock_get, _): | ||
mock_get.side_effect = RequestException("network down") | ||
|
||
vc = VersionChecker() | ||
with pytest.raises(GOATSClickException) as exc: | ||
vc.check_if_outdated() | ||
assert "Failed to fetch latest version info" in str(exc.value) | ||
|
||
|
||
@patch("goats_cli.versioning.get_version", return_value="1.0.0") | ||
@patch("goats_cli.versioning.requests.get") | ||
def test_jsondecodeerror_is_caught(mock_get, _): | ||
mock_get.return_value = _fake_response( | ||
json_raises=JSONDecodeError("Invalid JSON", doc="<<<", pos=1) | ||
) | ||
|
||
vc = VersionChecker() | ||
with pytest.raises(GOATSClickException) as exc: | ||
vc.check_if_outdated() | ||
assert "Invalid JSON" in str(exc.value) | ||
|
||
|
||
@patch("goats_cli.versioning.get_version", return_value="1.0.0") | ||
@patch("goats_cli.versioning.requests.get") | ||
def test_malformed_structure_keyerror(mock_get, _): | ||
payload = {"packages": {"other": {"version": "9.9.9"}}} | ||
mock_get.return_value = _fake_response(json_payload=payload) | ||
|
||
vc = VersionChecker() | ||
with pytest.raises(GOATSClickException) as exc: | ||
vc.check_if_outdated() | ||
assert "Malformed channel metadata" in str(exc.value) | ||
|
||
|
||
@patch("goats_cli.versioning.get_version", return_value="1.0.0") | ||
@patch("goats_cli.versioning.requests.get") | ||
def test_malformed_structure_typeerror(mock_get, _): | ||
mock_get.return_value = _fake_response(json_payload=["not", "a", "dict"]) | ||
|
||
vc = VersionChecker() | ||
with pytest.raises(GOATSClickException) as exc: | ||
vc.check_if_outdated() | ||
assert "Malformed channel metadata" in str(exc.value) | ||
|
||
|
||
@patch("goats_cli.versioning.get_version", return_value="0.9.0") | ||
@patch("goats_cli.versioning.requests.get") | ||
def test_custom_url_timeout_and_package(mock_get, _): | ||
payload = { | ||
"packages": { | ||
"goats-cli": {"version": "1.0.0"}, | ||
"goats": {"version": "0.1.0"}, | ||
} | ||
} | ||
mock_get.return_value = _fake_response(json_payload=payload) | ||
|
||
url = "https://example.invalid/channel.json" | ||
vc = VersionChecker(channeldata_url=url, package_name="goats-cli", timeout_sec=5.0) | ||
|
||
assert vc.check_if_outdated() is True | ||
assert vc.current_version == "0.9.0" | ||
assert vc.latest_version == "1.0.0" | ||
assert vc.is_outdated is True | ||
|
||
mock_get.assert_called_once() | ||
args, kwargs = mock_get.call_args | ||
assert args[0] == url | ||
assert kwargs["timeout"] == 5.0 | ||
|
||
miguelgomezgomez marked this conversation as resolved.
Show resolved
Hide resolved
|
||
@pytest.mark.remote_data() | ||
def test_get_latest_version_live(): | ||
checker = VersionChecker() | ||
assert isinstance(checker.check_if_outdated(), bool) | ||
assert isinstance(checker.latest_version, str) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.