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_cli_version_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
7 changes: 5 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,10 @@ def init(args: Namespace) -> Any:
except KeyboardInterrupt:
# User cancelled the authentication process
return False
return True

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_cli_version_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
87 changes: 87 additions & 0 deletions src/fabric_cli/utils/fab_version_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 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) as e:
# Silently fail - don't interrupt user experience for version checks
fab_logger.log_debug(f"Failed to fetch version from PyPI: {e}")
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 version could not be parsed
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_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

fab_logger.log_debug("Checking PyPI for latest version")
latest_version = _fetch_latest_version_from_pypi()

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__}")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did you used fab_logger.log_debug and not fab_ui.print_grey? using fab_logger.log_debug means user will see it only if fab_constant.FAB_DEBUG_ENABLED is set by the user to true.

Also, are those scenarios tested?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using fab_logger.log_debug here intentionally for the "already up to date" scenario. The rationale is:

  • Silent success when the user is already on the latest version.
  • User-facing notification only when actionable (in case of a newer version available)

100% coverage on fab_version_check.py:

Name                                        Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------------------------------------
src/fabric_cli/utils/fab_version_check.py      32      0      6      0   100%
---------------------------------------------------------------------------------------
TOTAL                                          32      0      6      0   100% 

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree for the use of fab_logger.log_debug
regarding tests - my Q was that if we verify the correct message is printed only when debug_enabled is true

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was already tested before by this function, however I made it a bit more explicit by adding mock_questionary_print.assert_not_called().

@patch("fabric_cli.utils.fab_version_check._fetch_latest_version_from_pypi")
def test_cli_version_check_disabled_by_config_success(
mock_fetch, mock_fab_set_state_config, mock_questionary_print
):
"""Should not display notification when update checks are disabled by config."""
newer_version = _increment_version("major")
mock_fab_set_state_config(fab_constant.FAB_CHECK_UPDATES, "false")
mock_fetch.return_value = newer_version
fab_version_check.check_and_notify_update()
mock_questionary_print.assert_not_called()

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What’s missing is a check that the correct message is logged when debug_enabled=True in test_cli_version_check_same_version_success. You verified mock_questionary_print.assert_not_called(), but you should also assert that log.debug was called with the expected message.

use mock_fab_set_state_config(constant.FAB_DEBUG_ENABLED, True) to set debug_enabled=True then mock fab_logget.debug and verify the message

Same for: "test_cli_version_check_fetch_failure" and "test_cli_version_check_older_version_success"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies, I didn't know that you were referring to testing the logger outputs. I fixed it in b384c46

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
Loading