diff --git a/.changes/unreleased/added-20251211-183425.yaml b/.changes/unreleased/added-20251211-183425.yaml new file mode 100644 index 00000000..f9e450fd --- /dev/null +++ b/.changes/unreleased/added-20251211-183425.yaml @@ -0,0 +1,3 @@ +kind: added +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 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` | 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..7de13259 100644 --- a/src/fabric_cli/core/fab_constant.py +++ b/src/fabric_cli/core/fab_constant.py @@ -91,6 +91,11 @@ 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" + +# Version check settings +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 +116,7 @@ FAB_OUTPUT_FORMAT: ["text", "json"], FAB_FOLDER_LISTING_ENABLED: ["false", "true"], FAB_WS_PRIVATE_LINKS_ENABLED: ["false", "true"], + FAB_CHECK_UPDATES: ["false", "true"], # Add more keys and their respective allowed values as needed } @@ -127,6 +133,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..a84e4d26 --- /dev/null +++ b/src/fabric_cli/utils/fab_version_check.py @@ -0,0 +1,89 @@ +# 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. +""" + +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 _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 + ) + 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 + 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 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 + - 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 + 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 + + # 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): + 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" + ) + 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_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) 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..bb094a64 --- /dev/null +++ b/tests/test_utils/test_fab_version_check.py @@ -0,0 +1,229 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for fab_version_check module.""" + +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 + + +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" + + +@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, + ) + + +@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") + + 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_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() + + assert result is None + + +@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 + + 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_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 + + result = fab_version_check._fetch_latest_version_from_pypi() + + assert result is None + + +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_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 + + +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 + + +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 + + +def test_cli_version_check_disabled_by_config_success(mock_fab_set_state_config): + """Should not check when user has disabled updates.""" + mock_fab_set_state_config(fab_constant.FAB_CHECK_UPDATES, "false") + + fab_version_check.check_and_notify_update() + + + +@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_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 + + fab_version_check.check_and_notify_update() + + # 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, 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__ + + fab_version_check.check_and_notify_update() + + # 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, 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 + + fab_version_check.check_and_notify_update() + + # 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, 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() + + fab_version_check.check_and_notify_update() + + # Should not display notification + mock_questionary_print.assert_not_called()