From b50150a013c789978713d13a9ffb58634694dacf Mon Sep 17 00:00:00 2001 From: Guust-Franssens Date: Thu, 11 Dec 2025 18:35:48 +0100 Subject: [PATCH 1/9] feat: inform users whenever a new update is available --- .../unreleased/added-20251211-183425.yaml | 3 + src/fabric_cli/commands/auth/fab_auth.py | 8 +- src/fabric_cli/core/fab_constant.py | 12 + src/fabric_cli/utils/fab_version_check.py | 138 ++++++++ tests/test_utils/test_fab_version_check.py | 335 ++++++++++++++++++ 5 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 .changes/unreleased/added-20251211-183425.yaml create mode 100644 src/fabric_cli/utils/fab_version_check.py create mode 100644 tests/test_utils/test_fab_version_check.py diff --git a/.changes/unreleased/added-20251211-183425.yaml b/.changes/unreleased/added-20251211-183425.yaml new file mode 100644 index 00000000..d1295f11 --- /dev/null +++ b/.changes/unreleased/added-20251211-183425.yaml @@ -0,0 +1,3 @@ +kind: added +body: inform users whenever a new update is available +time: 2025-12-11T18:34:25.601088227+01:00 diff --git a/src/fabric_cli/commands/auth/fab_auth.py b/src/fabric_cli/commands/auth/fab_auth.py index 29fce8f1..4dd205f1 100644 --- a/src/fabric_cli/commands/auth/fab_auth.py +++ b/src/fabric_cli/commands/auth/fab_auth.py @@ -10,7 +10,7 @@ from fabric_cli.core.fab_exceptions import FabricCLIError from fabric_cli.errors import ErrorMessages from fabric_cli.utils import fab_mem_store as utils_mem_store -from fabric_cli.utils import fab_ui +from fabric_cli.utils import fab_ui, fab_version_check def init(args: Namespace) -> Any: @@ -199,7 +199,11 @@ def init(args: Namespace) -> Any: except KeyboardInterrupt: # User cancelled the authentication process return False - return True + + # Check for CLI updates after successful authentication + fab_version_check.check_and_notify_update() + + return True def logout(args: Namespace) -> None: diff --git a/src/fabric_cli/core/fab_constant.py b/src/fabric_cli/core/fab_constant.py index a25bf5e2..4a2a8706 100644 --- a/src/fabric_cli/core/fab_constant.py +++ b/src/fabric_cli/core/fab_constant.py @@ -91,6 +91,14 @@ FAB_OUTPUT_FORMAT = "output_format" FAB_FOLDER_LISTING_ENABLED = "folder_listing_enabled" FAB_WS_PRIVATE_LINKS_ENABLED = "workspace_private_links_enabled" +FAB_CHECK_UPDATES = "check_updates" +FAB_PYPI_CACHE_TIMESTAMP = "pypi_cache_timestamp" +FAB_PYPI_LATEST_VERSION = "pypi_latest_version" + +# Version check settings +VERSION_CHECK_INTERVAL_HOURS = 24 * 7 # Check once a week +VERSION_CHECK_PYPI_URL = "https://pypi.org/pypi/ms-fabric-cli/json" +VERSION_CHECK_TIMEOUT_SECONDS = 3 FAB_CONFIG_KEYS_TO_VALID_VALUES = { FAB_CACHE_ENABLED: ["false", "true"], @@ -111,6 +119,9 @@ FAB_OUTPUT_FORMAT: ["text", "json"], FAB_FOLDER_LISTING_ENABLED: ["false", "true"], FAB_WS_PRIVATE_LINKS_ENABLED: ["false", "true"], + FAB_CHECK_UPDATES: ["false", "true"], + FAB_PYPI_CACHE_TIMESTAMP: [], + FAB_PYPI_LATEST_VERSION: [], # Add more keys and their respective allowed values as needed } @@ -127,6 +138,7 @@ FAB_OUTPUT_FORMAT: "text", FAB_FOLDER_LISTING_ENABLED: "false", FAB_WS_PRIVATE_LINKS_ENABLED: "false", + FAB_CHECK_UPDATES: "true", } # Command descriptions diff --git a/src/fabric_cli/utils/fab_version_check.py b/src/fabric_cli/utils/fab_version_check.py new file mode 100644 index 00000000..3c51d00a --- /dev/null +++ b/src/fabric_cli/utils/fab_version_check.py @@ -0,0 +1,138 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Version update checking for Fabric CLI. + +This module checks PyPI for newer versions of ms-fabric-cli and displays +a notification to the user if an update is available. Checks are cached +according to VERSION_CHECK_INTERVAL_HOURS to minimize PyPI API calls. +""" + +from datetime import datetime, timedelta +from typing import Optional + +import requests + +from fabric_cli import __version__ +from fabric_cli.core import fab_constant, fab_logger, fab_state_config +from fabric_cli.utils import fab_ui + + +def _should_check_pypi() -> bool: + """ + Determine if we should check PyPI based on last check time. + + Returns: + True if more than VERSION_CHECK_INTERVAL_HOURS hours have passed since last check, False otherwise. + """ + last_check_str = fab_state_config.get_config(fab_constant.FAB_PYPI_CACHE_TIMESTAMP) + + if not last_check_str: + return True + + try: + last_check_time = datetime.fromisoformat(last_check_str) + time_since_check = datetime.now() - last_check_time + return time_since_check > timedelta(hours=fab_constant.VERSION_CHECK_INTERVAL_HOURS) + except (ValueError, TypeError): + # Invalid timestamp format, check again + return True + + +def _fetch_latest_version_from_pypi() -> Optional[str]: + """ + Fetch the latest version from PyPI JSON API. + + Returns: + Latest version string if successful, None otherwise. + """ + try: + response = requests.get( + fab_constant.VERSION_CHECK_PYPI_URL, + timeout=fab_constant.VERSION_CHECK_TIMEOUT_SECONDS + ) + + if response.status_code == 200: + data = response.json() + return data["info"]["version"] + except Exception: + # Silently fail - don't interrupt user experience for version checks + pass + + return None + + +def _is_pypi_version_newer(pypi_version: str) -> bool: + """ + Compare PyPI version with current version to determine if an update is available. + + Args: + pypi_version: Version string from PyPI + + Returns: + True if PyPI version is newer than current installed version + """ + try: + # Parse versions as tuples (e.g., "1.3.0" -> (1, 3, 0)) + current_parts = tuple(int(x) for x in __version__.split(".")) + pypi_parts = tuple(int(x) for x in pypi_version.split(".")) + return pypi_parts > current_parts + except (ValueError, AttributeError): + # Conservative: don't show notification if we can't reliably parse versions + return False + + +def _update_pypi_cache(latest_version: Optional[str] = None) -> None: + """ + Update the PyPI cache timestamp and optionally store the latest version. + + Args: + latest_version: Latest version from PyPI to cache + """ + fab_state_config.set_config( + fab_constant.FAB_PYPI_CACHE_TIMESTAMP, datetime.now().isoformat() + ) + + if latest_version: + fab_state_config.set_config(fab_constant.FAB_PYPI_LATEST_VERSION, latest_version) + + +def check_and_notify_update() -> None: + """ + Check for CLI updates and display notification if a newer version is available. + + This function: + - Respects user's check_updates config setting + - Hits PyPI a maximum of once per week to refresh cached version + - Checks cached version on every login (fast) + - Displays notification whenever an update is available + - Fails silently if PyPI is unreachable + """ + # Check if user has disabled update checks + check_enabled = fab_state_config.get_config(fab_constant.FAB_CHECK_UPDATES) + if check_enabled == "false": + fab_logger.log_debug("Version check disabled by user configuration") + return + + # Try to refresh from PyPI if interval has passed + if _should_check_pypi(): + fab_logger.log_debug("Checking PyPI for latest version") + latest_version = _fetch_latest_version_from_pypi() + # Update cache regardless of result to avoid repeated failures + _update_pypi_cache(latest_version) + else: + fab_logger.log_debug("Using cached version (PyPI check interval not reached)") + + # Always check cached version and notify if update available + cached_version = fab_state_config.get_config(fab_constant.FAB_PYPI_LATEST_VERSION) + if cached_version and _is_pypi_version_newer(cached_version): + fab_logger.log_debug(f"New version available: {cached_version}") + fab_ui.print_grey( + f"\n[notice] A new release of fab is available: {__version__} → {cached_version}" + ) + fab_ui.print_grey( + "[notice] To update, run: pip install --upgrade ms-fabric-cli\n" + ) + else: + fab_logger.log_debug(f"Already on latest version: {__version__}") diff --git a/tests/test_utils/test_fab_version_check.py b/tests/test_utils/test_fab_version_check.py new file mode 100644 index 00000000..2affb94d --- /dev/null +++ b/tests/test_utils/test_fab_version_check.py @@ -0,0 +1,335 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for fab_version_check module.""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch + +from fabric_cli import __version__ +from fabric_cli.core import fab_constant +from fabric_cli.utils import fab_version_check + + +def _increment_version(component: str = "major") -> str: + """Helper to create a newer version by incrementing a component. + + Args: + component: Which version component to increment ("major", "minor", or "patch") + + Returns: + A version string that is newer than __version__ + """ + parts = [int(x) for x in __version__.split(".")] + if component == "major": + return f"{parts[0] + 1}.0.0" + elif component == "minor": + return f"{parts[0]}.{parts[1] + 1}.0" + elif component == "patch": + return f"{parts[0]}.{parts[1]}.{parts[2] + 1}" + raise ValueError(f"Unknown component: {component}") + + +def _decrement_version() -> str: + """Helper to create an older version. + + Returns: + A version string that is older than __version__ + """ + parts = [int(x) for x in __version__.split(".")] + # If patch > 0, decrement it + if parts[2] > 0: + return f"{parts[0]}.{parts[1]}.{parts[2] - 1}" + # If minor > 0, decrement minor and set patch to 0 + elif parts[1] > 0: + return f"{parts[0]}.{parts[1] - 1}.0" + # If major > 0, decrement major and set others to 0 + elif parts[0] > 0: + return f"{parts[0] - 1}.0.0" + # Edge case: version is 0.0.0 - can't go lower, just return it + return "0.0.0" + + +class TestShouldCheckPyPI: + """Test _should_check_pypi logic.""" + + @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") + def test_should_check_no_previous_check(self, mock_get_config): + """Should check PyPI when no previous check exists.""" + mock_get_config.return_value = None + + result = fab_version_check._should_check_pypi() + + assert result is True + + @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") + def test_should_check_invalid_timestamp(self, mock_get_config): + """Should check PyPI when timestamp is invalid.""" + mock_get_config.return_value = "invalid-timestamp" + + result = fab_version_check._should_check_pypi() + + assert result is True + + @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") + def test_should_check_old_timestamp(self, mock_get_config): + """Should check PyPI when last check was older than configured interval.""" + # Use interval + 1 hour to ensure we're past the threshold + old_time = datetime.now() - timedelta( + hours=fab_constant.VERSION_CHECK_INTERVAL_HOURS + 1 + ) + mock_get_config.return_value = old_time.isoformat() + + result = fab_version_check._should_check_pypi() + + assert result is True + + @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") + def test_should_not_check_recent_timestamp(self, mock_get_config): + """Should not check PyPI when last check was within configured interval.""" + # Use half the interval to ensure we're well within the threshold + recent_time = datetime.now() - timedelta( + hours=fab_constant.VERSION_CHECK_INTERVAL_HOURS / 2 + ) + mock_get_config.return_value = recent_time.isoformat() + + result = fab_version_check._should_check_pypi() + + assert result is False + + +class TestFetchLatestVersionFromPyPI: + """Test _fetch_latest_version_from_pypi logic.""" + + @patch("fabric_cli.utils.fab_version_check.requests.get") + def test_fetch_success(self, mock_get): + """Should return version when PyPI request succeeds.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"info": {"version": "2.0.0"}} + mock_get.return_value = mock_response + + result = fab_version_check._fetch_latest_version_from_pypi() + + assert result == "2.0.0" + mock_get.assert_called_once_with( + fab_constant.VERSION_CHECK_PYPI_URL, + timeout=fab_constant.VERSION_CHECK_TIMEOUT_SECONDS, + ) + + @patch("fabric_cli.utils.fab_version_check.requests.get") + def test_fetch_failure(self, mock_get): + """Should return None when PyPI request fails (any exception).""" + mock_get.side_effect = Exception("Network error") + + result = fab_version_check._fetch_latest_version_from_pypi() + + assert result is None + + +class TestIsPyPIVersionNewer: + """Test _is_pypi_version_newer logic.""" + + def test_newer_major_version(self): + """Should return True when major version is newer.""" + result = fab_version_check._is_pypi_version_newer(_increment_version("major")) + assert result is True + + def test_newer_minor_version(self): + """Should return True when minor version is newer.""" + result = fab_version_check._is_pypi_version_newer(_increment_version("minor")) + assert result is True + + def test_newer_patch_version(self): + """Should return True when patch version is newer.""" + result = fab_version_check._is_pypi_version_newer(_increment_version("patch")) + assert result is True + + def test_same_version(self): + """Should return False when versions are the same.""" + result = fab_version_check._is_pypi_version_newer(__version__) + assert result is False + + def test_older_version(self): + """Should return False when latest is older.""" + result = fab_version_check._is_pypi_version_newer(_decrement_version()) + assert result is False + + +class TestUpdatePyPICache: + """Test _update_pypi_cache logic.""" + + @patch("fabric_cli.utils.fab_version_check.fab_state_config.set_config") + def test_update_timestamp_only(self, mock_set_config): + """Should update timestamp when no version provided.""" + fab_version_check._update_pypi_cache() + + # Should be called once for timestamp + assert mock_set_config.call_count == 1 + call_args = mock_set_config.call_args_list[0] + assert call_args[0][0] == fab_constant.FAB_PYPI_CACHE_TIMESTAMP + # Verify it's a valid ISO timestamp + datetime.fromisoformat(call_args[0][1]) + + @patch("fabric_cli.utils.fab_version_check.fab_state_config.set_config") + def test_update_with_version(self, mock_set_config): + """Should update both timestamp and version when version provided.""" + fab_version_check._update_pypi_cache("2.0.0") + + # Should be called twice: once for timestamp, once for version + assert mock_set_config.call_count == 2 + + # Check that both keys were set + call_keys = [call[0][0] for call in mock_set_config.call_args_list] + assert fab_constant.FAB_PYPI_CACHE_TIMESTAMP in call_keys + assert fab_constant.FAB_PYPI_LATEST_VERSION in call_keys + + +class TestCheckAndNotifyUpdate: + """Test check_and_notify_update main function.""" + + @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") + def test_disabled_by_config(self, mock_get_config): + """Should not check when user has disabled updates.""" + mock_get_config.return_value = "false" + + fab_version_check.check_and_notify_update() + + # Should call get_config for check_updates setting (and potentially debug_enabled) + # Just verify it was called with FAB_CHECK_UPDATES at least once + assert any( + call[0][0] == fab_constant.FAB_CHECK_UPDATES + for call in mock_get_config.call_args_list + ) + + @patch("fabric_cli.utils.fab_version_check._update_pypi_cache") + @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") + @patch("fabric_cli.utils.fab_version_check._should_check_pypi") + @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") + def test_check_pypi_new_version_available( + self, + mock_get_config, + mock_should_check, + mock_fetch, + mock_update_state, + ): + """Should display notification when newer version is available.""" + newer_version = _increment_version("major") + + def get_config_side_effect(key): + if key == fab_constant.FAB_CHECK_UPDATES: + return "true" + elif key == fab_constant.FAB_PYPI_LATEST_VERSION: + return newer_version + return None + + mock_get_config.side_effect = get_config_side_effect + mock_should_check.return_value = True + mock_fetch.return_value = newer_version + + with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: + fab_version_check.check_and_notify_update() + + # Should display notification + assert mock_ui.print_grey.call_count == 2 + assert newer_version in str(mock_ui.print_grey.call_args_list[0]) + assert "pip install --upgrade" in str(mock_ui.print_grey.call_args_list[1]) + + # Should update state + mock_update_state.assert_called_once_with(newer_version) + + @patch("fabric_cli.utils.fab_version_check._update_pypi_cache") + @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") + @patch("fabric_cli.utils.fab_version_check._should_check_pypi") + @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") + def test_check_pypi_same_version( + self, + mock_get_config, + mock_should_check, + mock_fetch, + mock_update_state, + ): + """Should not display notification when on latest version.""" + mock_get_config.side_effect = lambda key: ( + "true" if key == fab_constant.FAB_CHECK_UPDATES else None + ) + mock_should_check.return_value = True + mock_fetch.return_value = __version__ + + with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: + fab_version_check.check_and_notify_update() + + # Should not display notification + mock_ui.print_grey.assert_not_called() + + # Should still update state + mock_update_state.assert_called_once() + + @patch("fabric_cli.utils.fab_version_check._update_pypi_cache") + @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") + @patch("fabric_cli.utils.fab_version_check._should_check_pypi") + @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") + def test_check_pypi_fetch_fails( + self, + mock_get_config, + mock_should_check, + mock_fetch, + mock_update_state, + ): + """Should not display notification when PyPI fetch fails.""" + mock_get_config.side_effect = lambda key: ( + "true" if key == fab_constant.FAB_CHECK_UPDATES else None + ) + mock_should_check.return_value = True + mock_fetch.return_value = None # Simulate fetch failure + + with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: + fab_version_check.check_and_notify_update() + + # Should not display notification + mock_ui.print_grey.assert_not_called() + + # Should still update state to avoid repeated failures + mock_update_state.assert_called_once_with(None) + + @patch("fabric_cli.utils.fab_version_check._should_check_pypi") + @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") + def test_use_cached_version(self, mock_get_config, mock_should_check): + """Should use cached version when check is not needed.""" + newer_version = _increment_version("major") + + def get_config_side_effect(key): + if key == fab_constant.FAB_CHECK_UPDATES: + return "true" + elif key == fab_constant.FAB_PYPI_LATEST_VERSION: + return newer_version + return None + + mock_get_config.side_effect = get_config_side_effect + mock_should_check.return_value = False # Use cache + + with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: + fab_version_check.check_and_notify_update() + + # Should display notification using cached version + assert mock_ui.print_grey.call_count == 2 + assert newer_version in str(mock_ui.print_grey.call_args_list[0]) + + @patch("fabric_cli.utils.fab_version_check._should_check_pypi") + @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") + def test_no_cached_version(self, mock_get_config, mock_should_check): + """Should not display notification when no cached version exists.""" + + def get_config_side_effect(key): + if key == fab_constant.FAB_CHECK_UPDATES: + return "true" + return None + + mock_get_config.side_effect = get_config_side_effect + mock_should_check.return_value = False # Use cache + + with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: + fab_version_check.check_and_notify_update() + + # Should not display notification (no cached version) + mock_ui.print_grey.assert_not_called() From 8f5bf26faa35e21d0a3395ffe2abdab6dfaf5e9d Mon Sep 17 00:00:00 2001 From: Guust-Franssens Date: Thu, 11 Dec 2025 20:08:31 +0100 Subject: [PATCH 2/9] update docs --- docs/essentials/settings.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/essentials/settings.md b/docs/essentials/settings.md index 1639918f..050caeae 100644 --- a/docs/essentials/settings.md +++ b/docs/essentials/settings.md @@ -7,6 +7,7 @@ The Fabric CLI provides a comprehensive set of configuration settings that allow | Name | Description | Type | Default | |--------------------------------|-------------------------------------------------------------------------------------------- |------------|---------| | `cache_enabled` | Toggles caching of CLI HTTP responses | `BOOLEAN` | `true` | +| `check_updates` | Enables automatic update notifications on login | `BOOLEAN` | `true` | | `debug_enabled` | Toggles additional diagnostic logs for troubleshooting | `BOOLEAN` | `false` | | `context_persistence_enabled` | Persists CLI navigation context in command line mode across sessions | `BOOLEAN` | `false` | | `encryption_fallback_enabled` | Permits storing tokens in plain text if secure encryption is unavailable | `BOOLEAN` | `false` | From 2c08ae739b7a46e7e90298a07f5a02afe5ea6868 Mon Sep 17 00:00:00 2001 From: Guust-Franssens Date: Mon, 15 Dec 2025 19:25:39 +0100 Subject: [PATCH 3/9] rename change --- .changes/unreleased/added-20251211-183425.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/unreleased/added-20251211-183425.yaml b/.changes/unreleased/added-20251211-183425.yaml index d1295f11..f9e450fd 100644 --- a/.changes/unreleased/added-20251211-183425.yaml +++ b/.changes/unreleased/added-20251211-183425.yaml @@ -1,3 +1,3 @@ kind: added -body: inform users whenever a new update is available +body: Display a notification to users on login when a new fab cli version is available time: 2025-12-11T18:34:25.601088227+01:00 From 7460559ba4b545dc3e34f9823be80b771abb61b4 Mon Sep 17 00:00:00 2001 From: Guust-Franssens Date: Mon, 15 Dec 2025 20:28:10 +0100 Subject: [PATCH 4/9] refactor always fetch pypi version on login --- src/fabric_cli/core/fab_constant.py | 5 - src/fabric_cli/utils/fab_version_check.py | 88 ++------ tests/test_utils/test_fab_version_check.py | 239 ++++++--------------- 3 files changed, 87 insertions(+), 245 deletions(-) diff --git a/src/fabric_cli/core/fab_constant.py b/src/fabric_cli/core/fab_constant.py index 4a2a8706..7de13259 100644 --- a/src/fabric_cli/core/fab_constant.py +++ b/src/fabric_cli/core/fab_constant.py @@ -92,11 +92,8 @@ FAB_FOLDER_LISTING_ENABLED = "folder_listing_enabled" FAB_WS_PRIVATE_LINKS_ENABLED = "workspace_private_links_enabled" FAB_CHECK_UPDATES = "check_updates" -FAB_PYPI_CACHE_TIMESTAMP = "pypi_cache_timestamp" -FAB_PYPI_LATEST_VERSION = "pypi_latest_version" # Version check settings -VERSION_CHECK_INTERVAL_HOURS = 24 * 7 # Check once a week VERSION_CHECK_PYPI_URL = "https://pypi.org/pypi/ms-fabric-cli/json" VERSION_CHECK_TIMEOUT_SECONDS = 3 @@ -120,8 +117,6 @@ FAB_FOLDER_LISTING_ENABLED: ["false", "true"], FAB_WS_PRIVATE_LINKS_ENABLED: ["false", "true"], FAB_CHECK_UPDATES: ["false", "true"], - FAB_PYPI_CACHE_TIMESTAMP: [], - FAB_PYPI_LATEST_VERSION: [], # Add more keys and their respective allowed values as needed } diff --git a/src/fabric_cli/utils/fab_version_check.py b/src/fabric_cli/utils/fab_version_check.py index 3c51d00a..46afecf8 100644 --- a/src/fabric_cli/utils/fab_version_check.py +++ b/src/fabric_cli/utils/fab_version_check.py @@ -5,11 +5,9 @@ Version update checking for Fabric CLI. This module checks PyPI for newer versions of ms-fabric-cli and displays -a notification to the user if an update is available. Checks are cached -according to VERSION_CHECK_INTERVAL_HOURS to minimize PyPI API calls. +a notification to the user if an update is available. """ -from datetime import datetime, timedelta from typing import Optional import requests @@ -19,27 +17,6 @@ from fabric_cli.utils import fab_ui -def _should_check_pypi() -> bool: - """ - Determine if we should check PyPI based on last check time. - - Returns: - True if more than VERSION_CHECK_INTERVAL_HOURS hours have passed since last check, False otherwise. - """ - last_check_str = fab_state_config.get_config(fab_constant.FAB_PYPI_CACHE_TIMESTAMP) - - if not last_check_str: - return True - - try: - last_check_time = datetime.fromisoformat(last_check_str) - time_since_check = datetime.now() - last_check_time - return time_since_check > timedelta(hours=fab_constant.VERSION_CHECK_INTERVAL_HOURS) - except (ValueError, TypeError): - # Invalid timestamp format, check again - return True - - def _fetch_latest_version_from_pypi() -> Optional[str]: """ Fetch the latest version from PyPI JSON API. @@ -52,15 +29,11 @@ def _fetch_latest_version_from_pypi() -> Optional[str]: fab_constant.VERSION_CHECK_PYPI_URL, timeout=fab_constant.VERSION_CHECK_TIMEOUT_SECONDS ) - - if response.status_code == 200: - data = response.json() - return data["info"]["version"] - except Exception: + response.raise_for_status() + return response.json()["info"]["version"] + except (requests.RequestException, KeyError, ValueError, TypeError): # Silently fail - don't interrupt user experience for version checks - pass - - return None + return None def _is_pypi_version_newer(pypi_version: str) -> bool: @@ -83,30 +56,14 @@ def _is_pypi_version_newer(pypi_version: str) -> bool: return False -def _update_pypi_cache(latest_version: Optional[str] = None) -> None: - """ - Update the PyPI cache timestamp and optionally store the latest version. - - Args: - latest_version: Latest version from PyPI to cache - """ - fab_state_config.set_config( - fab_constant.FAB_PYPI_CACHE_TIMESTAMP, datetime.now().isoformat() - ) - - if latest_version: - fab_state_config.set_config(fab_constant.FAB_PYPI_LATEST_VERSION, latest_version) - - def check_and_notify_update() -> None: """ Check for CLI updates and display notification if a newer version is available. This function: - Respects user's check_updates config setting - - Hits PyPI a maximum of once per week to refresh cached version - - Checks cached version on every login (fast) - - Displays notification whenever an update is available + - Checks PyPI on every login for the latest version + - Displays notification if an update is available - Fails silently if PyPI is unreachable """ # Check if user has disabled update checks @@ -115,24 +72,19 @@ def check_and_notify_update() -> None: fab_logger.log_debug("Version check disabled by user configuration") return - # Try to refresh from PyPI if interval has passed - if _should_check_pypi(): - fab_logger.log_debug("Checking PyPI for latest version") - latest_version = _fetch_latest_version_from_pypi() - # Update cache regardless of result to avoid repeated failures - _update_pypi_cache(latest_version) - else: - fab_logger.log_debug("Using cached version (PyPI check interval not reached)") - - # Always check cached version and notify if update available - cached_version = fab_state_config.get_config(fab_constant.FAB_PYPI_LATEST_VERSION) - if cached_version and _is_pypi_version_newer(cached_version): - fab_logger.log_debug(f"New version available: {cached_version}") - fab_ui.print_grey( - f"\n[notice] A new release of fab is available: {__version__} → {cached_version}" - ) - fab_ui.print_grey( + # Check PyPI for latest version + fab_logger.log_debug("Checking PyPI for latest version") + latest_version = _fetch_latest_version_from_pypi() + + # Display notification if update available + if latest_version and _is_pypi_version_newer(latest_version): + fab_logger.log_debug(f"New version available: {latest_version}") + msg = ( + f"\n[notice] A new release of fab is available: {__version__} → {latest_version}\n" "[notice] To update, run: pip install --upgrade ms-fabric-cli\n" ) - else: + fab_ui.print_grey(msg) + elif latest_version: fab_logger.log_debug(f"Already on latest version: {__version__}") + else: + fab_logger.log_debug("Could not fetch latest version from PyPI") diff --git a/tests/test_utils/test_fab_version_check.py b/tests/test_utils/test_fab_version_check.py index 2affb94d..7efac5a0 100644 --- a/tests/test_utils/test_fab_version_check.py +++ b/tests/test_utils/test_fab_version_check.py @@ -3,9 +3,10 @@ """Tests for fab_version_check module.""" -from datetime import datetime, timedelta from unittest.mock import MagicMock, patch +import requests + from fabric_cli import __version__ from fabric_cli.core import fab_constant from fabric_cli.utils import fab_version_check @@ -50,54 +51,6 @@ def _decrement_version() -> str: return "0.0.0" -class TestShouldCheckPyPI: - """Test _should_check_pypi logic.""" - - @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_should_check_no_previous_check(self, mock_get_config): - """Should check PyPI when no previous check exists.""" - mock_get_config.return_value = None - - result = fab_version_check._should_check_pypi() - - assert result is True - - @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_should_check_invalid_timestamp(self, mock_get_config): - """Should check PyPI when timestamp is invalid.""" - mock_get_config.return_value = "invalid-timestamp" - - result = fab_version_check._should_check_pypi() - - assert result is True - - @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_should_check_old_timestamp(self, mock_get_config): - """Should check PyPI when last check was older than configured interval.""" - # Use interval + 1 hour to ensure we're past the threshold - old_time = datetime.now() - timedelta( - hours=fab_constant.VERSION_CHECK_INTERVAL_HOURS + 1 - ) - mock_get_config.return_value = old_time.isoformat() - - result = fab_version_check._should_check_pypi() - - assert result is True - - @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_should_not_check_recent_timestamp(self, mock_get_config): - """Should not check PyPI when last check was within configured interval.""" - # Use half the interval to ensure we're well within the threshold - recent_time = datetime.now() - timedelta( - hours=fab_constant.VERSION_CHECK_INTERVAL_HOURS / 2 - ) - mock_get_config.return_value = recent_time.isoformat() - - result = fab_version_check._should_check_pypi() - - assert result is False - - class TestFetchLatestVersionFromPyPI: """Test _fetch_latest_version_from_pypi logic.""" @@ -119,8 +72,46 @@ def test_fetch_success(self, mock_get): @patch("fabric_cli.utils.fab_version_check.requests.get") def test_fetch_failure(self, mock_get): - """Should return None when PyPI request fails (any exception).""" - mock_get.side_effect = Exception("Network error") + """Should return None when PyPI request fails (network error).""" + mock_get.side_effect = requests.ConnectionError("Network error") + + result = fab_version_check._fetch_latest_version_from_pypi() + + assert result is None + + @patch("fabric_cli.utils.fab_version_check.requests.get") + def test_fetch_failure_http_error(self, mock_get): + """Should return None when PyPI returns non-200 status code.""" + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = requests.HTTPError("500 Server Error") + mock_get.return_value = mock_response + + result = fab_version_check._fetch_latest_version_from_pypi() + + assert result is None + + @patch("fabric_cli.utils.fab_version_check.requests.get") + def test_fetch_failure_invalid_json(self, mock_get): + """Should return None when PyPI returns invalid JSON.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status.return_value = None + mock_response.json.side_effect = ValueError("Invalid JSON") + mock_get.return_value = mock_response + + result = fab_version_check._fetch_latest_version_from_pypi() + + assert result is None + + @patch("fabric_cli.utils.fab_version_check.requests.get") + def test_fetch_failure_missing_keys(self, mock_get): + """Should return None when PyPI response is missing expected keys.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = {"data": {}} # Missing "info" key + mock_get.return_value = mock_response result = fab_version_check._fetch_latest_version_from_pypi() @@ -155,34 +146,15 @@ def test_older_version(self): result = fab_version_check._is_pypi_version_newer(_decrement_version()) assert result is False + def test_invalid_version_format(self): + """Should return False when version format is invalid.""" + result = fab_version_check._is_pypi_version_newer("invalid.version") + assert result is False -class TestUpdatePyPICache: - """Test _update_pypi_cache logic.""" - - @patch("fabric_cli.utils.fab_version_check.fab_state_config.set_config") - def test_update_timestamp_only(self, mock_set_config): - """Should update timestamp when no version provided.""" - fab_version_check._update_pypi_cache() - - # Should be called once for timestamp - assert mock_set_config.call_count == 1 - call_args = mock_set_config.call_args_list[0] - assert call_args[0][0] == fab_constant.FAB_PYPI_CACHE_TIMESTAMP - # Verify it's a valid ISO timestamp - datetime.fromisoformat(call_args[0][1]) - - @patch("fabric_cli.utils.fab_version_check.fab_state_config.set_config") - def test_update_with_version(self, mock_set_config): - """Should update both timestamp and version when version provided.""" - fab_version_check._update_pypi_cache("2.0.0") - - # Should be called twice: once for timestamp, once for version - assert mock_set_config.call_count == 2 - - # Check that both keys were set - call_keys = [call[0][0] for call in mock_set_config.call_args_list] - assert fab_constant.FAB_PYPI_CACHE_TIMESTAMP in call_keys - assert fab_constant.FAB_PYPI_LATEST_VERSION in call_keys + def test_none_version(self): + """Should return False when version is None.""" + result = fab_version_check._is_pypi_version_newer(None) + assert result is False class TestCheckAndNotifyUpdate: @@ -195,65 +167,34 @@ def test_disabled_by_config(self, mock_get_config): fab_version_check.check_and_notify_update() - # Should call get_config for check_updates setting (and potentially debug_enabled) - # Just verify it was called with FAB_CHECK_UPDATES at least once + # Should call get_config for check_updates setting assert any( call[0][0] == fab_constant.FAB_CHECK_UPDATES for call in mock_get_config.call_args_list ) - @patch("fabric_cli.utils.fab_version_check._update_pypi_cache") @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") - @patch("fabric_cli.utils.fab_version_check._should_check_pypi") @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_check_pypi_new_version_available( - self, - mock_get_config, - mock_should_check, - mock_fetch, - mock_update_state, - ): + def test_new_version_available(self, mock_get_config, mock_fetch): """Should display notification when newer version is available.""" newer_version = _increment_version("major") - - def get_config_side_effect(key): - if key == fab_constant.FAB_CHECK_UPDATES: - return "true" - elif key == fab_constant.FAB_PYPI_LATEST_VERSION: - return newer_version - return None - - mock_get_config.side_effect = get_config_side_effect - mock_should_check.return_value = True + mock_get_config.return_value = "true" mock_fetch.return_value = newer_version with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: fab_version_check.check_and_notify_update() # Should display notification - assert mock_ui.print_grey.call_count == 2 - assert newer_version in str(mock_ui.print_grey.call_args_list[0]) - assert "pip install --upgrade" in str(mock_ui.print_grey.call_args_list[1]) - - # Should update state - mock_update_state.assert_called_once_with(newer_version) + mock_ui.print_grey.assert_called() + call_msg = str(mock_ui.print_grey.call_args) + assert newer_version in call_msg + assert "pip install --upgrade" in call_msg - @patch("fabric_cli.utils.fab_version_check._update_pypi_cache") @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") - @patch("fabric_cli.utils.fab_version_check._should_check_pypi") @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_check_pypi_same_version( - self, - mock_get_config, - mock_should_check, - mock_fetch, - mock_update_state, - ): + def test_same_version(self, mock_get_config, mock_fetch): """Should not display notification when on latest version.""" - mock_get_config.side_effect = lambda key: ( - "true" if key == fab_constant.FAB_CHECK_UPDATES else None - ) - mock_should_check.return_value = True + mock_get_config.return_value = "true" mock_fetch.return_value = __version__ with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: @@ -262,25 +203,11 @@ def test_check_pypi_same_version( # Should not display notification mock_ui.print_grey.assert_not_called() - # Should still update state - mock_update_state.assert_called_once() - - @patch("fabric_cli.utils.fab_version_check._update_pypi_cache") @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") - @patch("fabric_cli.utils.fab_version_check._should_check_pypi") @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_check_pypi_fetch_fails( - self, - mock_get_config, - mock_should_check, - mock_fetch, - mock_update_state, - ): + def test_fetch_fails(self, mock_get_config, mock_fetch): """Should not display notification when PyPI fetch fails.""" - mock_get_config.side_effect = lambda key: ( - "true" if key == fab_constant.FAB_CHECK_UPDATES else None - ) - mock_should_check.return_value = True + mock_get_config.return_value = "true" mock_fetch.return_value = None # Simulate fetch failure with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: @@ -289,47 +216,15 @@ def test_check_pypi_fetch_fails( # Should not display notification mock_ui.print_grey.assert_not_called() - # Should still update state to avoid repeated failures - mock_update_state.assert_called_once_with(None) - - @patch("fabric_cli.utils.fab_version_check._should_check_pypi") - @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_use_cached_version(self, mock_get_config, mock_should_check): - """Should use cached version when check is not needed.""" - newer_version = _increment_version("major") - - def get_config_side_effect(key): - if key == fab_constant.FAB_CHECK_UPDATES: - return "true" - elif key == fab_constant.FAB_PYPI_LATEST_VERSION: - return newer_version - return None - - mock_get_config.side_effect = get_config_side_effect - mock_should_check.return_value = False # Use cache - - with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: - fab_version_check.check_and_notify_update() - - # Should display notification using cached version - assert mock_ui.print_grey.call_count == 2 - assert newer_version in str(mock_ui.print_grey.call_args_list[0]) - - @patch("fabric_cli.utils.fab_version_check._should_check_pypi") + @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_no_cached_version(self, mock_get_config, mock_should_check): - """Should not display notification when no cached version exists.""" - - def get_config_side_effect(key): - if key == fab_constant.FAB_CHECK_UPDATES: - return "true" - return None - - mock_get_config.side_effect = get_config_side_effect - mock_should_check.return_value = False # Use cache + def test_older_version(self, mock_get_config, mock_fetch): + """Should not display notification when PyPI version is older.""" + mock_get_config.return_value = "true" + mock_fetch.return_value = _decrement_version() with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: fab_version_check.check_and_notify_update() - # Should not display notification (no cached version) + # Should not display notification mock_ui.print_grey.assert_not_called() From f9efa40f92a488cbcb05e5fdc4190dd71ac2fd49 Mon Sep 17 00:00:00 2001 From: Guust-Franssens Date: Tue, 23 Dec 2025 10:44:46 +0100 Subject: [PATCH 5/9] remove redundant logger message --- src/fabric_cli/utils/fab_version_check.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/fabric_cli/utils/fab_version_check.py b/src/fabric_cli/utils/fab_version_check.py index 46afecf8..a84e4d26 100644 --- a/src/fabric_cli/utils/fab_version_check.py +++ b/src/fabric_cli/utils/fab_version_check.py @@ -78,7 +78,6 @@ def check_and_notify_update() -> None: # Display notification if update available if latest_version and _is_pypi_version_newer(latest_version): - fab_logger.log_debug(f"New version available: {latest_version}") msg = ( f"\n[notice] A new release of fab is available: {__version__} → {latest_version}\n" "[notice] To update, run: pip install --upgrade ms-fabric-cli\n" From 942ef6c13949e752d5d3b7ca611e37cce7f7ebf0 Mon Sep 17 00:00:00 2001 From: Guust-Franssens Date: Tue, 23 Dec 2025 11:03:37 +0100 Subject: [PATCH 6/9] refactor to use standalone tests --- tests/test_utils/test_fab_version_check.py | 303 +++++++++++---------- 1 file changed, 154 insertions(+), 149 deletions(-) diff --git a/tests/test_utils/test_fab_version_check.py b/tests/test_utils/test_fab_version_check.py index 7efac5a0..7e29e386 100644 --- a/tests/test_utils/test_fab_version_check.py +++ b/tests/test_utils/test_fab_version_check.py @@ -51,180 +51,185 @@ def _decrement_version() -> str: return "0.0.0" -class TestFetchLatestVersionFromPyPI: - """Test _fetch_latest_version_from_pypi logic.""" - - @patch("fabric_cli.utils.fab_version_check.requests.get") - def test_fetch_success(self, mock_get): - """Should return version when PyPI request succeeds.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"info": {"version": "2.0.0"}} - mock_get.return_value = mock_response +@patch("fabric_cli.utils.fab_version_check.requests.get") +def test_cli_version_fetch_pypi_success(mock_get): + """Should return version when PyPI request succeeds.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"info": {"version": "2.0.0"}} + mock_get.return_value = mock_response - result = fab_version_check._fetch_latest_version_from_pypi() - - assert result == "2.0.0" - mock_get.assert_called_once_with( - fab_constant.VERSION_CHECK_PYPI_URL, - timeout=fab_constant.VERSION_CHECK_TIMEOUT_SECONDS, - ) + result = fab_version_check._fetch_latest_version_from_pypi() - @patch("fabric_cli.utils.fab_version_check.requests.get") - def test_fetch_failure(self, mock_get): - """Should return None when PyPI request fails (network error).""" - mock_get.side_effect = requests.ConnectionError("Network error") + assert result == "2.0.0" + mock_get.assert_called_once_with( + fab_constant.VERSION_CHECK_PYPI_URL, + timeout=fab_constant.VERSION_CHECK_TIMEOUT_SECONDS, + ) - result = fab_version_check._fetch_latest_version_from_pypi() - assert result is None +@patch("fabric_cli.utils.fab_version_check.requests.get") +def test_cli_version_fetch_pypi_network_error_failure(mock_get): + """Should return None when PyPI request fails (network error).""" + mock_get.side_effect = requests.ConnectionError("Network error") - @patch("fabric_cli.utils.fab_version_check.requests.get") - def test_fetch_failure_http_error(self, mock_get): - """Should return None when PyPI returns non-200 status code.""" - mock_response = MagicMock() - mock_response.status_code = 500 - mock_response.raise_for_status.side_effect = requests.HTTPError("500 Server Error") - mock_get.return_value = mock_response + result = fab_version_check._fetch_latest_version_from_pypi() - result = fab_version_check._fetch_latest_version_from_pypi() + assert result is None - assert result is None - @patch("fabric_cli.utils.fab_version_check.requests.get") - def test_fetch_failure_invalid_json(self, mock_get): - """Should return None when PyPI returns invalid JSON.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.raise_for_status.return_value = None - mock_response.json.side_effect = ValueError("Invalid JSON") - mock_get.return_value = mock_response +@patch("fabric_cli.utils.fab_version_check.requests.get") +def test_cli_version_fetch_pypi_http_error_failure(mock_get): + """Should return None when PyPI returns non-200 status code.""" + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = requests.HTTPError("500 Server Error") + mock_get.return_value = mock_response - result = fab_version_check._fetch_latest_version_from_pypi() + result = fab_version_check._fetch_latest_version_from_pypi() - assert result is None + assert result is None - @patch("fabric_cli.utils.fab_version_check.requests.get") - def test_fetch_failure_missing_keys(self, mock_get): - """Should return None when PyPI response is missing expected keys.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"data": {}} # Missing "info" key - mock_get.return_value = mock_response - result = fab_version_check._fetch_latest_version_from_pypi() +@patch("fabric_cli.utils.fab_version_check.requests.get") +def test_cli_version_fetch_pypi_invalid_json_failure(mock_get): + """Should return None when PyPI returns invalid JSON.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status.return_value = None + mock_response.json.side_effect = ValueError("Invalid JSON") + mock_get.return_value = mock_response - assert result is None + result = fab_version_check._fetch_latest_version_from_pypi() + assert result is None -class TestIsPyPIVersionNewer: - """Test _is_pypi_version_newer logic.""" - def test_newer_major_version(self): - """Should return True when major version is newer.""" - result = fab_version_check._is_pypi_version_newer(_increment_version("major")) - assert result is True +@patch("fabric_cli.utils.fab_version_check.requests.get") +def test_cli_version_fetch_pypi_missing_keys_failure(mock_get): + """Should return None when PyPI response is missing expected keys.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = {"data": {}} # Missing "info" key + mock_get.return_value = mock_response - def test_newer_minor_version(self): - """Should return True when minor version is newer.""" - result = fab_version_check._is_pypi_version_newer(_increment_version("minor")) - assert result is True + result = fab_version_check._fetch_latest_version_from_pypi() - def test_newer_patch_version(self): - """Should return True when patch version is newer.""" - result = fab_version_check._is_pypi_version_newer(_increment_version("patch")) - assert result is True + assert result is None - def test_same_version(self): - """Should return False when versions are the same.""" - result = fab_version_check._is_pypi_version_newer(__version__) - assert result is False - def test_older_version(self): - """Should return False when latest is older.""" - result = fab_version_check._is_pypi_version_newer(_decrement_version()) - assert result is False +def test_cli_version_compare_newer_major_success(): + """Should return True when major version is newer.""" + result = fab_version_check._is_pypi_version_newer(_increment_version("major")) + assert result is True - def test_invalid_version_format(self): - """Should return False when version format is invalid.""" - result = fab_version_check._is_pypi_version_newer("invalid.version") - assert result is False - def test_none_version(self): - """Should return False when version is None.""" - result = fab_version_check._is_pypi_version_newer(None) - assert result is False +def test_cli_version_compare_newer_minor_success(): + """Should return True when minor version is newer.""" + result = fab_version_check._is_pypi_version_newer(_increment_version("minor")) + assert result is True -class TestCheckAndNotifyUpdate: - """Test check_and_notify_update main function.""" +def test_cli_version_compare_newer_patch_success(): + """Should return True when patch version is newer.""" + result = fab_version_check._is_pypi_version_newer(_increment_version("patch")) + assert result is True - @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_disabled_by_config(self, mock_get_config): - """Should not check when user has disabled updates.""" - mock_get_config.return_value = "false" +def test_cli_version_compare_same_version_failure(): + """Should return False when versions are the same.""" + result = fab_version_check._is_pypi_version_newer(__version__) + assert result is False + + +def test_cli_version_compare_older_version_failure(): + """Should return False when latest is older.""" + result = fab_version_check._is_pypi_version_newer(_decrement_version()) + assert result is False + + +def test_cli_version_compare_invalid_format_failure(): + """Should return False when version format is invalid.""" + result = fab_version_check._is_pypi_version_newer("invalid.version") + assert result is False + + +def test_cli_version_compare_none_version_failure(): + """Should return False when version is None.""" + result = fab_version_check._is_pypi_version_newer(None) + assert result is False + + +@patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") +def test_cli_version_check_disabled_by_config_success(mock_get_config): + """Should not check when user has disabled updates.""" + mock_get_config.return_value = "false" + + fab_version_check.check_and_notify_update() + + # Should call get_config for check_updates setting + assert any( + call[0][0] == fab_constant.FAB_CHECK_UPDATES + for call in mock_get_config.call_args_list + ) + + +@patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") +@patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") +def test_cli_version_check_new_version_available_success(mock_get_config, mock_fetch): + """Should display notification when newer version is available.""" + newer_version = _increment_version("major") + mock_get_config.return_value = "true" + mock_fetch.return_value = newer_version + + with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: + fab_version_check.check_and_notify_update() + + # Should display notification + mock_ui.print_grey.assert_called() + call_msg = str(mock_ui.print_grey.call_args) + assert newer_version in call_msg + assert "pip install --upgrade" in call_msg + + +@patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") +@patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") +def test_cli_version_check_same_version_success(mock_get_config, mock_fetch): + """Should not display notification when on latest version.""" + mock_get_config.return_value = "true" + mock_fetch.return_value = __version__ + + with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: + fab_version_check.check_and_notify_update() + + # Should not display notification + mock_ui.print_grey.assert_not_called() + + +@patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") +@patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") +def test_cli_version_check_fetch_failure(mock_get_config, mock_fetch): + """Should not display notification when PyPI fetch fails.""" + mock_get_config.return_value = "true" + mock_fetch.return_value = None # Simulate fetch failure + + with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: + fab_version_check.check_and_notify_update() + + # Should not display notification + mock_ui.print_grey.assert_not_called() + + +@patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") +@patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") +def test_cli_version_check_older_version_success(mock_get_config, mock_fetch): + """Should not display notification when PyPI version is older.""" + mock_get_config.return_value = "true" + mock_fetch.return_value = _decrement_version() + + with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: fab_version_check.check_and_notify_update() - # Should call get_config for check_updates setting - assert any( - call[0][0] == fab_constant.FAB_CHECK_UPDATES - for call in mock_get_config.call_args_list - ) - - @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") - @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_new_version_available(self, mock_get_config, mock_fetch): - """Should display notification when newer version is available.""" - newer_version = _increment_version("major") - mock_get_config.return_value = "true" - mock_fetch.return_value = newer_version - - with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: - fab_version_check.check_and_notify_update() - - # Should display notification - mock_ui.print_grey.assert_called() - call_msg = str(mock_ui.print_grey.call_args) - assert newer_version in call_msg - assert "pip install --upgrade" in call_msg - - @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") - @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_same_version(self, mock_get_config, mock_fetch): - """Should not display notification when on latest version.""" - mock_get_config.return_value = "true" - mock_fetch.return_value = __version__ - - with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: - fab_version_check.check_and_notify_update() - - # Should not display notification - mock_ui.print_grey.assert_not_called() - - @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") - @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_fetch_fails(self, mock_get_config, mock_fetch): - """Should not display notification when PyPI fetch fails.""" - mock_get_config.return_value = "true" - mock_fetch.return_value = None # Simulate fetch failure - - with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: - fab_version_check.check_and_notify_update() - - # Should not display notification - mock_ui.print_grey.assert_not_called() - - @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") - @patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") - def test_older_version(self, mock_get_config, mock_fetch): - """Should not display notification when PyPI version is older.""" - mock_get_config.return_value = "true" - mock_fetch.return_value = _decrement_version() - - with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: - fab_version_check.check_and_notify_update() - - # Should not display notification - mock_ui.print_grey.assert_not_called() + # Should not display notification + mock_ui.print_grey.assert_not_called() From 3910bbb2574e1a0d470f7db0fd7f0f15abb6db75 Mon Sep 17 00:00:00 2001 From: Guust-Franssens Date: Tue, 23 Dec 2025 11:15:20 +0100 Subject: [PATCH 7/9] use mock_fab_set_state_config instead of introducing own mock --- tests/test_utils/test_fab_version_check.py | 32 ++++++++-------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/tests/test_utils/test_fab_version_check.py b/tests/test_utils/test_fab_version_check.py index 7e29e386..50878e69 100644 --- a/tests/test_utils/test_fab_version_check.py +++ b/tests/test_utils/test_fab_version_check.py @@ -161,26 +161,21 @@ def test_cli_version_compare_none_version_failure(): assert result is False -@patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") -def test_cli_version_check_disabled_by_config_success(mock_get_config): +def test_cli_version_check_disabled_by_config_success(mock_fab_set_state_config): """Should not check when user has disabled updates.""" - mock_get_config.return_value = "false" + mock_fab_set_state_config(fab_constant.FAB_CHECK_UPDATES, "false") fab_version_check.check_and_notify_update() - # Should call get_config for check_updates setting - assert any( - call[0][0] == fab_constant.FAB_CHECK_UPDATES - for call in mock_get_config.call_args_list - ) @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") -@patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") -def test_cli_version_check_new_version_available_success(mock_get_config, mock_fetch): +def test_cli_version_check_new_version_available_success( + mock_fetch, mock_fab_set_state_config +): """Should display notification when newer version is available.""" newer_version = _increment_version("major") - mock_get_config.return_value = "true" + mock_fab_set_state_config(fab_constant.FAB_CHECK_UPDATES, "true") mock_fetch.return_value = newer_version with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: @@ -194,10 +189,9 @@ def test_cli_version_check_new_version_available_success(mock_get_config, mock_f @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") -@patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") -def test_cli_version_check_same_version_success(mock_get_config, mock_fetch): +def test_cli_version_check_same_version_success(mock_fetch, mock_fab_set_state_config): """Should not display notification when on latest version.""" - mock_get_config.return_value = "true" + mock_fab_set_state_config(fab_constant.FAB_CHECK_UPDATES, "true") mock_fetch.return_value = __version__ with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: @@ -208,10 +202,9 @@ def test_cli_version_check_same_version_success(mock_get_config, mock_fetch): @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") -@patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") -def test_cli_version_check_fetch_failure(mock_get_config, mock_fetch): +def test_cli_version_check_fetch_failure(mock_fetch, mock_fab_set_state_config): """Should not display notification when PyPI fetch fails.""" - mock_get_config.return_value = "true" + mock_fab_set_state_config(fab_constant.FAB_CHECK_UPDATES, "true") mock_fetch.return_value = None # Simulate fetch failure with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: @@ -222,10 +215,9 @@ def test_cli_version_check_fetch_failure(mock_get_config, mock_fetch): @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") -@patch("fabric_cli.utils.fab_version_check.fab_state_config.get_config") -def test_cli_version_check_older_version_success(mock_get_config, mock_fetch): +def test_cli_version_check_older_version_success(mock_fetch, mock_fab_set_state_config): """Should not display notification when PyPI version is older.""" - mock_get_config.return_value = "true" + mock_fab_set_state_config(fab_constant.FAB_CHECK_UPDATES, "true") mock_fetch.return_value = _decrement_version() with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: From cc7331c4919cea9f36150d4cfbbef50204931677 Mon Sep 17 00:00:00 2001 From: Guust-Franssens Date: Tue, 23 Dec 2025 11:19:18 +0100 Subject: [PATCH 8/9] use mock_questionary_printinstead of own mock --- tests/test_utils/test_fab_version_check.py | 48 +++++++++++----------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/tests/test_utils/test_fab_version_check.py b/tests/test_utils/test_fab_version_check.py index 50878e69..bb094a64 100644 --- a/tests/test_utils/test_fab_version_check.py +++ b/tests/test_utils/test_fab_version_check.py @@ -171,57 +171,59 @@ def test_cli_version_check_disabled_by_config_success(mock_fab_set_state_config) @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") def test_cli_version_check_new_version_available_success( - mock_fetch, mock_fab_set_state_config + mock_fetch, mock_fab_set_state_config, mock_questionary_print ): """Should display notification when newer version is available.""" newer_version = _increment_version("major") mock_fab_set_state_config(fab_constant.FAB_CHECK_UPDATES, "true") mock_fetch.return_value = newer_version - with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: - fab_version_check.check_and_notify_update() + fab_version_check.check_and_notify_update() - # Should display notification - mock_ui.print_grey.assert_called() - call_msg = str(mock_ui.print_grey.call_args) - assert newer_version in call_msg - assert "pip install --upgrade" in call_msg + # Should display notification + mock_questionary_print.assert_called() + call_msg = str(mock_questionary_print.call_args) + assert newer_version in call_msg + assert "pip install --upgrade" in call_msg @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") -def test_cli_version_check_same_version_success(mock_fetch, mock_fab_set_state_config): +def test_cli_version_check_same_version_success( + mock_fetch, mock_fab_set_state_config, mock_questionary_print +): """Should not display notification when on latest version.""" mock_fab_set_state_config(fab_constant.FAB_CHECK_UPDATES, "true") mock_fetch.return_value = __version__ - with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: - fab_version_check.check_and_notify_update() + fab_version_check.check_and_notify_update() - # Should not display notification - mock_ui.print_grey.assert_not_called() + # Should not display notification + mock_questionary_print.assert_not_called() @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") -def test_cli_version_check_fetch_failure(mock_fetch, mock_fab_set_state_config): +def test_cli_version_check_fetch_failure( + mock_fetch, mock_fab_set_state_config, mock_questionary_print +): """Should not display notification when PyPI fetch fails.""" mock_fab_set_state_config(fab_constant.FAB_CHECK_UPDATES, "true") mock_fetch.return_value = None # Simulate fetch failure - with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: - fab_version_check.check_and_notify_update() + fab_version_check.check_and_notify_update() - # Should not display notification - mock_ui.print_grey.assert_not_called() + # Should not display notification + mock_questionary_print.assert_not_called() @patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi") -def test_cli_version_check_older_version_success(mock_fetch, mock_fab_set_state_config): +def test_cli_version_check_older_version_success( + mock_fetch, mock_fab_set_state_config, mock_questionary_print +): """Should not display notification when PyPI version is older.""" mock_fab_set_state_config(fab_constant.FAB_CHECK_UPDATES, "true") mock_fetch.return_value = _decrement_version() - with patch("fabric_cli.utils.fab_version_check.fab_ui") as mock_ui: - fab_version_check.check_and_notify_update() + fab_version_check.check_and_notify_update() - # Should not display notification - mock_ui.print_grey.assert_not_called() + # Should not display notification + mock_questionary_print.assert_not_called() From 13acee7c0afc3c6809924449a4b215ed1c9bd688 Mon Sep 17 00:00:00 2001 From: Guust-Franssens Date: Wed, 24 Dec 2025 10:57:07 +0100 Subject: [PATCH 9/9] test: ignore pypi.org requests in VCR cassettes Fixes auth test failures caused by version check requests not present in existing recordings. PyPI checks are tested separately. --- tests/test_commands/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_commands/conftest.py b/tests/test_commands/conftest.py index 112924fd..806c221c 100644 --- a/tests/test_commands/conftest.py +++ b/tests/test_commands/conftest.py @@ -104,7 +104,7 @@ def vcr_instance(vcr_mode, request): before_record_response=process_response, path_transformer=vcr.VCR.ensure_suffix(".yaml"), match_on=["method", "uri", "json_body"], - ignore_hosts=["login.microsoftonline.com"], + ignore_hosts=["login.microsoftonline.com", "pypi.org"], ) set_vcr_mode_env(vcr_mode)