Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/added-20251211-183425.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docs/essentials/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
8 changes: 6 additions & 2 deletions src/fabric_cli/commands/auth/fab_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions src/fabric_cli/core/fab_constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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
}

Expand All @@ -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
Expand Down
89 changes: 89 additions & 0 deletions src/fabric_cli/utils/fab_version_check.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 1 addition & 1 deletion tests/test_commands/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
229 changes: 229 additions & 0 deletions tests/test_utils/test_fab_version_check.py
Original file line number Diff line number Diff line change
@@ -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()