From c1a2517e8efe4c934186e2a6407d2baa9446c226 Mon Sep 17 00:00:00 2001 From: Aviat Cohen Date: Sun, 26 Jul 2026 13:56:32 +0000 Subject: [PATCH 1/6] add ms-fabric-cli user agent to cicd when running deploy cmd --- .../unreleased/fixed-20260726-120000.yaml | 6 + src/fabric_cli/client/fab_api_client.py | 57 +----- .../fs/deploy/fab_fs_deploy_config_file.py | 15 +- src/fabric_cli/utils/fab_user_agent.py | 82 +++++++++ tests/test_commands/test_deploy.py | 92 +++++++++- tests/test_core/test_fab_api_client.py | 89 ---------- tests/test_utils/test_fab_user_agent.py | 163 ++++++++++++++++++ 7 files changed, 357 insertions(+), 147 deletions(-) create mode 100644 .changes/unreleased/fixed-20260726-120000.yaml create mode 100644 src/fabric_cli/utils/fab_user_agent.py create mode 100644 tests/test_utils/test_fab_user_agent.py diff --git a/.changes/unreleased/fixed-20260726-120000.yaml b/.changes/unreleased/fixed-20260726-120000.yaml new file mode 100644 index 000000000..69f5c0c2a --- /dev/null +++ b/.changes/unreleased/fixed-20260726-120000.yaml @@ -0,0 +1,6 @@ +kind: fixed +body: Set User Agent suffix for Fabric CLI deploy command. This allows the Fabric CICD service to identify the host application that is invoking the deployment, which can be useful for logging, analytics, and debugging purposes. +time: 2026-07-26T12:00:00Z +custom: + Author: aviatcohen + AuthorLink: https://github.com/aviatcohen diff --git a/src/fabric_cli/client/fab_api_client.py b/src/fabric_cli/client/fab_api_client.py index eb08c6f07..0529da531 100644 --- a/src/fabric_cli/client/fab_api_client.py +++ b/src/fabric_cli/client/fab_api_client.py @@ -2,8 +2,6 @@ # Licensed under the MIT License. import json -import os -import platform import re import time from argparse import Namespace @@ -26,10 +24,9 @@ from fabric_cli.utils import fab_files as files_utils from fabric_cli.utils import fab_ui as utils_ui from fabric_cli.utils.fab_http_polling_utils import get_polling_interval +from fabric_cli.utils.fab_user_agent import build_user_agent from fabric_cli.utils.fab_util import GUID_PATTERN_STR -_HOST_APP_VERSION_RE = re.compile(r"\d+(\.\d+){0,2}(-[a-zA-Z0-9\.-]+)?") - FABRIC_WORKSPACE_URI_PATTERN = rf"workspaces/{GUID_PATTERN_STR}" # Module-level reusable session for connection pooling @@ -117,7 +114,7 @@ def do_request( headers = { "Authorization": "Bearer " + str(token), - "User-Agent": _build_user_agent(ctxt_cmd), + "User-Agent": build_user_agent(ctxt_cmd), } if files is None: @@ -293,56 +290,6 @@ def _handle_successful_response(args: Namespace, response: ApiResponse) -> ApiRe return response -def _build_user_agent(ctxt_cmd: str) -> str: - """Build the User-Agent header for API requests. - - Example: - ms-fabric-cli/1.0.0 (create; Windows/10; Python/3.10.2) host-app/ado/2.0.0 - """ - user_agent = ( - f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} " - f"({ctxt_cmd}; {platform.system()}/{platform.release()}; " - f"Python/{platform.python_version()})" - ) - host_app = _get_host_app() - if host_app: - user_agent += host_app - - return user_agent - - -def _get_host_app() -> str: - """Get the HostApp suffix for the User-Agent header based on environment variables. - - Returns an empty string if the environment variable is not set or has an invalid value. - """ - _host_app_in_env = os.environ.get(fab_constant.FAB_HOST_APP_ENV_VAR) - if not _host_app_in_env: - return "" - - host_app_name = next( - ( - allowed_app - for allowed_app in fab_constant.ALLOWED_FAB_HOST_APP_VALUES - if _host_app_in_env.lower() == allowed_app.lower() - ), - None, - ) - - if not host_app_name: - return "" - - host_app = f" host-app/{host_app_name.lower()}" - - # Check for optional version - host_app_version = os.environ.get(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR) - - # validate host_app_version format is a valid version (e.g., 1.0.0) - if host_app_version and _HOST_APP_VERSION_RE.fullmatch(host_app_version): - host_app += f"/{host_app_version}" - return host_app - - def _print_response_details(response: ApiResponse) -> None: response_details = dict( { diff --git a/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py b/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py index 4e73b4127..01b63276c 100644 --- a/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py +++ b/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py @@ -11,6 +11,7 @@ from fabric_cli.core.fab_exceptions import FabricCLIError from fabric_cli.core.fab_msal_bridge import create_fabric_token_credential from fabric_cli.utils import fab_ui +from fabric_cli.utils.fab_user_agent import build_user_agent, resolve_library_user_agent from fabric_cli.utils.fab_util import get_dict_from_params @@ -39,6 +40,16 @@ def deploy_with_config_file(args: Namespace) -> None: except json.JSONDecodeError: # If it's not a valid JSON string, keep it as is pass + + # Attribute CLI-triggered deployments in telemetry via the User-Agent. + cicd_user_agent = resolve_library_user_agent( + "fabric-cicd", "ms-fabric-cicd") + deploy_parameters["user_agent"] = ( + f"{cicd_user_agent},{build_user_agent(args.command_path)}" + if cicd_user_agent + else build_user_agent(args.command_path) + ) + result = deploy_with_config( config_file_path=deploy_config_file, environment=args.target_env, @@ -52,5 +63,5 @@ def deploy_with_config_file(args: Namespace) -> None: except Exception as e: raise FabricCLIError( - f"Deployment failed: {str(e)}", - fab_constant.ERROR_IN_DEPLOYMENT) + f"Deployment failed: {str(e)}", fab_constant.ERROR_IN_DEPLOYMENT + ) diff --git a/src/fabric_cli/utils/fab_user_agent.py b/src/fabric_cli/utils/fab_user_agent.py new file mode 100644 index 000000000..575231da9 --- /dev/null +++ b/src/fabric_cli/utils/fab_user_agent.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import importlib.metadata +import os +import platform +import re +from typing import Optional + +from fabric_cli.core import fab_constant + +_HOST_APP_VERSION_RE = re.compile(r"\d+(\.\d+){0,2}(-[a-zA-Z0-9\.-]+)?") + + +def build_user_agent(ctxt_cmd: str) -> str: + """Build the User-Agent header for API requests. + + Example: + ms-fabric-cli/1.0.0 (create; Windows/10; Python/3.10.2) host-app/ado/2.0.0 + """ + user_agent = ( + f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} " + f"({ctxt_cmd}; {platform.system()}/{platform.release()}; " + f"Python/{platform.python_version()})" + ) + host_app = _get_host_app() + if host_app: + user_agent += host_app + + return user_agent + + +def resolve_library_user_agent( + package_name: str, user_agent_name: str +) -> Optional[str]: + """Build a ``/`` User-Agent token for a host library. + + Resolves the installed distribution version so a caller can stamp telemetry with the + version the CLI actually ships (e.g. ``ms-fabric-cicd/1.2.0``). Returns None if the + package metadata cannot be resolved. + + Args: + package_name: The distribution name to resolve the version from (e.g., ``fabric-cicd``). + user_agent_name: The User-Agent identity to use (e.g., ``ms-fabric-cicd``). + """ + try: + version = importlib.metadata.version(package_name) + except importlib.metadata.PackageNotFoundError: + return None + return f"{user_agent_name}/{version}" + + +def _get_host_app() -> str: + """Get the HostApp suffix for the User-Agent header based on environment variables. + + Returns an empty string if the environment variable is not set or has an invalid value. + """ + _host_app_in_env = os.environ.get(fab_constant.FAB_HOST_APP_ENV_VAR) + if not _host_app_in_env: + return "" + + host_app_name = next( + ( + allowed_app + for allowed_app in fab_constant.ALLOWED_FAB_HOST_APP_VALUES + if _host_app_in_env.lower() == allowed_app.lower() + ), + None, + ) + + if not host_app_name: + return "" + + host_app = f" host-app/{host_app_name.lower()}" + + # Check for optional version + host_app_version = os.environ.get(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR) + + # validate host_app_version format is a valid version (e.g., 1.0.0) + if host_app_version and _HOST_APP_VERSION_RE.fullmatch(host_app_version): + host_app += f"/{host_app_version}" + return host_app diff --git a/tests/test_commands/test_deploy.py b/tests/test_commands/test_deploy.py index 8114a2956..c1c0ad781 100644 --- a/tests/test_commands/test_deploy.py +++ b/tests/test_commands/test_deploy.py @@ -3,7 +3,8 @@ import os import platform -from unittest.mock import patch +from argparse import Namespace +from unittest.mock import MagicMock, patch import pytest import yaml @@ -314,3 +315,92 @@ def test_deploy_with_home_directory_path_success( mock_print_done.assert_called() assert "Deployment completed successfully" in str( mock_print_done.call_args) + + def _run_deploy_with_config_file(self, deploy_with_config, params=None): + """Invoke deploy_with_config_file with fabric-cicd symbols patched (no network).""" + from fabric_cli.commands.fs.deploy import ( + fab_fs_deploy_config_file as deploy_mod, + ) + + args = Namespace( + config="config.yml", + target_env="dev", + command_path="deploy", + params=params if params is not None else [], + ) + + with ( + patch.object(deploy_mod, "deploy_with_config", deploy_with_config), + patch.object( + deploy_mod, "create_fabric_token_credential", MagicMock()), + patch.object(deploy_mod, "append_feature_flag", MagicMock()), + patch.object(deploy_mod, "disable_file_logging", MagicMock()), + patch.object( + deploy_mod, "configure_external_file_logging", MagicMock()), + patch.object( + deploy_mod.fab_state_config, "get_config", return_value="false" + ), + patch.object(deploy_mod.fab_ui, + "print_output_format", MagicMock()), + ): + deploy_mod.deploy_with_config_file(args) + + def test_deploy_passes_user_agent_with_cicd_version_prefix(self): + """CLI passes the fabric-cicd-allowlisted user_agent: 'ms-fabric-cicd/,'.""" + from fabric_cli.utils.fab_user_agent import ( + build_user_agent, + resolve_library_user_agent, + ) + + captured = {} + + def fake_deploy_with_config( + *, + config_file_path, + token_credential, + environment="N/A", + config_override=None, + user_agent=None, + ): + captured["user_agent"] = user_agent + return MagicMock(message="Deployment completed successfully") + + self._run_deploy_with_config_file(fake_deploy_with_config) + + cicd_user_agent = resolve_library_user_agent( + "fabric-cicd", "ms-fabric-cicd") + assert ( + captured["user_agent"] + == f"{cicd_user_agent},{build_user_agent('deploy')}" + ) + + def test_deploy_user_agent_cannot_be_spoofed_via_params(self): + """A user-supplied user_agent (via -P) is overridden by the CLI-controlled value.""" + from fabric_cli.utils.fab_user_agent import ( + build_user_agent, + resolve_library_user_agent, + ) + + captured = {} + + def fake_deploy_with_config( + *, + config_file_path, + token_credential, + environment="N/A", + config_override=None, + user_agent=None, + ): + captured["user_agent"] = user_agent + return MagicMock(message="Deployment completed successfully") + + self._run_deploy_with_config_file( + fake_deploy_with_config, params=["user_agent=spoofed"] + ) + + cicd_user_agent = resolve_library_user_agent( + "fabric-cicd", "ms-fabric-cicd") + assert ( + captured["user_agent"] + == f"{cicd_user_agent},{build_user_agent('deploy')}" + ) diff --git a/tests/test_core/test_fab_api_client.py b/tests/test_core/test_fab_api_client.py index 1f3be4cc1..e19ad4dc0 100644 --- a/tests/test_core/test_fab_api_client.py +++ b/tests/test_core/test_fab_api_client.py @@ -8,7 +8,6 @@ import pytest from fabric_cli.client.fab_api_client import ( - _get_host_app, _transform_workspace_url_for_private_link_if_needed, do_request, ) @@ -310,94 +309,6 @@ def __init__(self): assert "ErrorCode" == excinfo.value.status_code -@pytest.mark.parametrize( - "host_app_env, host_app_version_env, expected_suffix", - [ - ( - "Fabric-AzureDevops-Extension", - None, - " host-app/fabric-azuredevops-extension", - ), - ( - "Fabric-AzureDevops-Extension", - "1.2.0", - " host-app/fabric-azuredevops-extension/1.2.0", - ), - ( - "fabric-azuredevops-extension", - "1.2.0", - " host-app/fabric-azuredevops-extension/1.2.0", - ), - ("Invalid-App", "1.0.0", ""), - ("", None, ""), - (None, None, ""), - # Invalid version format - host app is still included but version is silently dropped - ( - "Fabric-AzureDevops-Extension", - "1.2.0.4", # Invalid format - " host-app/fabric-azuredevops-extension", - ), - ( - "Fabric-AzureDevops-Extension", - "1.2.a", # Invalid format - " host-app/fabric-azuredevops-extension", - ), - ( - "Fabric-AzureDevops-Extension", - "a.b.c", # Invalid format - " host-app/fabric-azuredevops-extension", - ), - ( - "Fabric-AzureDevops-Extension", - "1", # valid format - " host-app/fabric-azuredevops-extension/1", - ), - ( - "Fabric-AzureDevops-Extension", - "1.2", # valid format - " host-app/fabric-azuredevops-extension/1.2", - ), - ( - "Fabric-AzureDevops-Extension", - "1.0.0", # valid format - " host-app/fabric-azuredevops-extension/1.0.0", - ), - ( - "Fabric-AzureDevops-Extension", - "1.0.0-rc.1", # valid format - " host-app/fabric-azuredevops-extension/1.0.0-rc.1", - ), - ( - "Fabric-AzureDevops-Extension", - "1.0.0-alpha", # valid format - " host-app/fabric-azuredevops-extension/1.0.0-alpha", - ), - ( - "Fabric-AzureDevops-Extension", - "1.0.0-beta", # valid format - " host-app/fabric-azuredevops-extension/1.0.0-beta", - ), - ], -) -def test_get_host_app(host_app_env, host_app_version_env, expected_suffix, monkeypatch): - """Test the _get_host_app helper function.""" - if host_app_env is not None: - monkeypatch.setenv(fab_constant.FAB_HOST_APP_ENV_VAR, host_app_env) - else: - monkeypatch.delenv(fab_constant.FAB_HOST_APP_ENV_VAR, raising=False) - - if host_app_version_env is not None: - monkeypatch.setenv( - fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, host_app_version_env - ) - else: - monkeypatch.delenv(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, raising=False) - - result = _get_host_app() - - assert result == expected_suffix - - @pytest.fixture() def setup_default_private_links(mock_fab_set_state_config): mock_fab_set_state_config(fab_constant.FAB_WS_PRIVATE_LINKS_ENABLED, "true") diff --git a/tests/test_utils/test_fab_user_agent.py b/tests/test_utils/test_fab_user_agent.py new file mode 100644 index 000000000..94c5fdbe9 --- /dev/null +++ b/tests/test_utils/test_fab_user_agent.py @@ -0,0 +1,163 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import importlib.metadata +from unittest.mock import patch + +import pytest + +from fabric_cli.core import fab_constant +from fabric_cli.utils.fab_user_agent import ( + _get_host_app, + build_user_agent, + resolve_library_user_agent, +) + + +@patch("fabric_cli.utils.fab_user_agent.platform.python_version", return_value="3.11.5") +@patch("fabric_cli.utils.fab_user_agent.platform.release", return_value="5.4.0") +@patch("fabric_cli.utils.fab_user_agent.platform.system", return_value="Linux") +def test_build_user_agent_without_host_app( + mock_system, mock_release, mock_python_version, monkeypatch +): + """build_user_agent returns the base token when no host app env is set.""" + monkeypatch.delenv(fab_constant.FAB_HOST_APP_ENV_VAR, raising=False) + monkeypatch.delenv(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, raising=False) + + result = build_user_agent("deploy") + + assert result == ( + f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} " + f"(deploy; Linux/5.4.0; Python/3.11.5)" + ) + + +@patch("fabric_cli.utils.fab_user_agent.platform.python_version", return_value="3.11.5") +@patch("fabric_cli.utils.fab_user_agent.platform.release", return_value="5.4.0") +@patch("fabric_cli.utils.fab_user_agent.platform.system", return_value="Linux") +def test_build_user_agent_appends_host_app_suffix( + mock_system, mock_release, mock_python_version, monkeypatch +): + """build_user_agent appends the validated host-app suffix when env is set.""" + monkeypatch.setenv( + fab_constant.FAB_HOST_APP_ENV_VAR, "Fabric-AzureDevops-Extension" + ) + monkeypatch.setenv(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, "1.2.0") + + result = build_user_agent("create") + + assert result == ( + f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} " + f"(create; Linux/5.4.0; Python/3.11.5)" + f" host-app/fabric-azuredevops-extension/1.2.0" + ) + + +def test_resolve_library_user_agent_uses_installed_version(): + """resolve_library_user_agent returns '/'.""" + with patch( + "fabric_cli.utils.fab_user_agent.importlib.metadata.version", + return_value="9.8.7", + ): + result = resolve_library_user_agent("some-package", "ms-some-package") + + assert result == "ms-some-package/9.8.7" + + +def test_resolve_library_user_agent_none_when_package_missing(): + """resolve_library_user_agent returns None when metadata is missing.""" + with patch( + "fabric_cli.utils.fab_user_agent.importlib.metadata.version", + side_effect=importlib.metadata.PackageNotFoundError, + ): + result = resolve_library_user_agent("missing-package", "ms-missing") + + assert result is None + + +@pytest.mark.parametrize( + "host_app_env, host_app_version_env, expected_suffix", + [ + ( + "Fabric-AzureDevops-Extension", + None, + " host-app/fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "1.2.0", + " host-app/fabric-azuredevops-extension/1.2.0", + ), + ( + "fabric-azuredevops-extension", + "1.2.0", + " host-app/fabric-azuredevops-extension/1.2.0", + ), + ("Invalid-App", "1.0.0", ""), + ("", None, ""), + (None, None, ""), + # Invalid version format - host app is still included but version is silently dropped + ( + "Fabric-AzureDevops-Extension", + "1.2.0.4", # Invalid format + " host-app/fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "1.2.a", # Invalid format + " host-app/fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "a.b.c", # Invalid format + " host-app/fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "1", # valid format + " host-app/fabric-azuredevops-extension/1", + ), + ( + "Fabric-AzureDevops-Extension", + "1.2", # valid format + " host-app/fabric-azuredevops-extension/1.2", + ), + ( + "Fabric-AzureDevops-Extension", + "1.0.0", # valid format + " host-app/fabric-azuredevops-extension/1.0.0", + ), + ( + "Fabric-AzureDevops-Extension", + "1.0.0-rc.1", # valid format + " host-app/fabric-azuredevops-extension/1.0.0-rc.1", + ), + ( + "Fabric-AzureDevops-Extension", + "1.0.0-alpha", # valid format + " host-app/fabric-azuredevops-extension/1.0.0-alpha", + ), + ( + "Fabric-AzureDevops-Extension", + "1.0.0-beta", # valid format + " host-app/fabric-azuredevops-extension/1.0.0-beta", + ), + ], +) +def test_get_host_app(host_app_env, host_app_version_env, expected_suffix, monkeypatch): + """Test the _get_host_app helper function.""" + if host_app_env is not None: + monkeypatch.setenv(fab_constant.FAB_HOST_APP_ENV_VAR, host_app_env) + else: + monkeypatch.delenv(fab_constant.FAB_HOST_APP_ENV_VAR, raising=False) + + if host_app_version_env is not None: + monkeypatch.setenv( + fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, host_app_version_env + ) + else: + monkeypatch.delenv(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, raising=False) + + result = _get_host_app() + + assert result == expected_suffix From 1bd14ee635ffad8790ecfd61109c3380541272cf Mon Sep 17 00:00:00 2001 From: aviatco <32952699+aviatco@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:31:53 +0300 Subject: [PATCH 2/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/test_utils/test_fab_user_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_utils/test_fab_user_agent.py b/tests/test_utils/test_fab_user_agent.py index 94c5fdbe9..b6cd3f98d 100644 --- a/tests/test_utils/test_fab_user_agent.py +++ b/tests/test_utils/test_fab_user_agent.py @@ -68,7 +68,7 @@ def test_resolve_library_user_agent_none_when_package_missing(): """resolve_library_user_agent returns None when metadata is missing.""" with patch( "fabric_cli.utils.fab_user_agent.importlib.metadata.version", - side_effect=importlib.metadata.PackageNotFoundError, + side_effect=importlib.metadata.PackageNotFoundError("missing-package"), ): result = resolve_library_user_agent("missing-package", "ms-missing") From 892d91083a4ede4c03e4a86fb0d5cff83b258070 Mon Sep 17 00:00:00 2001 From: aviatco <32952699+aviatco@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:33:11 +0300 Subject: [PATCH 3/6] Update .changes/unreleased/fixed-20260726-120000.yaml --- .changes/unreleased/fixed-20260726-120000.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/unreleased/fixed-20260726-120000.yaml b/.changes/unreleased/fixed-20260726-120000.yaml index 69f5c0c2a..0ac0bd273 100644 --- a/.changes/unreleased/fixed-20260726-120000.yaml +++ b/.changes/unreleased/fixed-20260726-120000.yaml @@ -1,5 +1,5 @@ kind: fixed -body: Set User Agent suffix for Fabric CLI deploy command. This allows the Fabric CICD service to identify the host application that is invoking the deployment, which can be useful for logging, analytics, and debugging purposes. +body: Set User Agent suffix for Fabric CLI deploy command. time: 2026-07-26T12:00:00Z custom: Author: aviatcohen From 5fc6f48b2e42c32f62f37ff3274b55eb433ba447 Mon Sep 17 00:00:00 2001 From: Aviat Cohen Date: Mon, 27 Jul 2026 09:31:09 +0000 Subject: [PATCH 4/6] fix test --- src/fabric_cli/utils/fab_user_agent.py | 4 +- tests/test_commands/test_deploy.py | 81 +++++++++++-------- .../test_fab_deploy_bulk_publish.py | 1 + tests/test_utils/test_fab_user_agent.py | 28 +++---- 4 files changed, 63 insertions(+), 51 deletions(-) diff --git a/src/fabric_cli/utils/fab_user_agent.py b/src/fabric_cli/utils/fab_user_agent.py index 575231da9..0b266d90b 100644 --- a/src/fabric_cli/utils/fab_user_agent.py +++ b/src/fabric_cli/utils/fab_user_agent.py @@ -16,7 +16,7 @@ def build_user_agent(ctxt_cmd: str) -> str: """Build the User-Agent header for API requests. Example: - ms-fabric-cli/1.0.0 (create; Windows/10; Python/3.10.2) host-app/ado/2.0.0 + ms-fabric-cli/1.0.0 (create; Windows/10; Python/3.10.2) ado/2.0.0 """ user_agent = ( f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} " @@ -71,7 +71,7 @@ def _get_host_app() -> str: if not host_app_name: return "" - host_app = f" host-app/{host_app_name.lower()}" + host_app = f" {host_app_name.lower()}" # Check for optional version host_app_version = os.environ.get(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR) diff --git a/tests/test_commands/test_deploy.py b/tests/test_commands/test_deploy.py index 6df7d180d..b10053807 100644 --- a/tests/test_commands/test_deploy.py +++ b/tests/test_commands/test_deploy.py @@ -385,11 +385,16 @@ def _run_deploy_with_config_file(self, deploy_with_config, params=None): ): deploy_mod.deploy_with_config_file(args) - def test_deploy_passes_user_agent_with_cicd_version_prefix(self): - """CLI passes the fabric-cicd-allowlisted user_agent: 'ms-fabric-cicd/,'.""" - from fabric_cli.utils.fab_user_agent import ( - build_user_agent, - resolve_library_user_agent, + _CICD_UNPATCHED = object() + + def _capture_deploy_user_agent(self, params=None, cicd_user_agent=_CICD_UNPATCHED): + """Run deploy_with_config_file and return the user_agent passed to fabric-cicd. + + When ``cicd_user_agent`` is provided, ``resolve_library_user_agent`` is + patched to return it (use ``None`` to simulate fabric-cicd not installed). + """ + from fabric_cli.commands.fs.deploy import ( + fab_fs_deploy_config_file as deploy_mod, ) captured = {} @@ -405,42 +410,48 @@ def fake_deploy_with_config( captured["user_agent"] = user_agent return MagicMock(message="Deployment completed successfully") - self._run_deploy_with_config_file(fake_deploy_with_config) - - cicd_user_agent = resolve_library_user_agent( - "fabric-cicd", "ms-fabric-cicd") - assert ( - captured["user_agent"] - == f"{cicd_user_agent},{build_user_agent('deploy')}" - ) - - def test_deploy_user_agent_cannot_be_spoofed_via_params(self): - """A user-supplied user_agent (via -P) is overridden by the CLI-controlled value.""" + if cicd_user_agent is self._CICD_UNPATCHED: + self._run_deploy_with_config_file(fake_deploy_with_config, params=params) + else: + with patch.object( + deploy_mod, + "resolve_library_user_agent", + return_value=cicd_user_agent, + ): + self._run_deploy_with_config_file( + fake_deploy_with_config, params=params + ) + + return captured["user_agent"] + + def test_deploy_passes_user_agent_with_cicd_version_prefix_success(self): + """CLI passes the fabric-cicd-allowlisted user_agent: 'ms-fabric-cicd/,'.""" from fabric_cli.utils.fab_user_agent import ( build_user_agent, resolve_library_user_agent, ) - captured = {} + user_agent = self._capture_deploy_user_agent() - def fake_deploy_with_config( - *, - config_file_path, - token_credential, - environment="N/A", - config_override=None, - user_agent=None, - ): - captured["user_agent"] = user_agent - return MagicMock(message="Deployment completed successfully") + cicd_user_agent = resolve_library_user_agent("fabric-cicd", "ms-fabric-cicd") + assert user_agent == f"{cicd_user_agent},{build_user_agent('deploy')}" - self._run_deploy_with_config_file( - fake_deploy_with_config, params=["user_agent=spoofed"] - ) + def test_deploy_user_agent_without_cicd_version_success(self): + """When fabric-cicd version is unresolved, the CLI UA is passed without a prefix.""" + from fabric_cli.utils.fab_user_agent import build_user_agent - cicd_user_agent = resolve_library_user_agent( - "fabric-cicd", "ms-fabric-cicd") - assert ( - captured["user_agent"] - == f"{cicd_user_agent},{build_user_agent('deploy')}" + user_agent = self._capture_deploy_user_agent(cicd_user_agent=None) + + assert user_agent == build_user_agent("deploy") + + def test_deploy_user_agent_cannot_be_spoofed_via_params_success(self): + """A user-supplied user_agent (via -P) is overridden by the CLI-controlled value.""" + from fabric_cli.utils.fab_user_agent import ( + build_user_agent, + resolve_library_user_agent, ) + + user_agent = self._capture_deploy_user_agent(params=["user_agent=spoofed"]) + + cicd_user_agent = resolve_library_user_agent("fabric-cicd", "ms-fabric-cicd") + assert user_agent == f"{cicd_user_agent},{build_user_agent('deploy')}" diff --git a/tests/test_utils/test_fab_deploy_bulk_publish.py b/tests/test_utils/test_fab_deploy_bulk_publish.py index 0886554da..fed1be5bf 100644 --- a/tests/test_utils/test_fab_deploy_bulk_publish.py +++ b/tests/test_utils/test_fab_deploy_bulk_publish.py @@ -27,6 +27,7 @@ def _run_deploy(self, tmp_path, bulk_publish, mock_fab_set_state_config): target_env="dev", params=None, bulk_publish=bulk_publish, + command_path="deploy", ) with ( diff --git a/tests/test_utils/test_fab_user_agent.py b/tests/test_utils/test_fab_user_agent.py index b6cd3f98d..43504a45d 100644 --- a/tests/test_utils/test_fab_user_agent.py +++ b/tests/test_utils/test_fab_user_agent.py @@ -49,7 +49,7 @@ def test_build_user_agent_appends_host_app_suffix( assert result == ( f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} " f"(create; Linux/5.4.0; Python/3.11.5)" - f" host-app/fabric-azuredevops-extension/1.2.0" + f" fabric-azuredevops-extension/1.2.0" ) @@ -68,7 +68,7 @@ def test_resolve_library_user_agent_none_when_package_missing(): """resolve_library_user_agent returns None when metadata is missing.""" with patch( "fabric_cli.utils.fab_user_agent.importlib.metadata.version", - side_effect=importlib.metadata.PackageNotFoundError("missing-package"), + side_effect=importlib.metadata.PackageNotFoundError, ): result = resolve_library_user_agent("missing-package", "ms-missing") @@ -81,17 +81,17 @@ def test_resolve_library_user_agent_none_when_package_missing(): ( "Fabric-AzureDevops-Extension", None, - " host-app/fabric-azuredevops-extension", + " fabric-azuredevops-extension", ), ( "Fabric-AzureDevops-Extension", "1.2.0", - " host-app/fabric-azuredevops-extension/1.2.0", + " fabric-azuredevops-extension/1.2.0", ), ( "fabric-azuredevops-extension", "1.2.0", - " host-app/fabric-azuredevops-extension/1.2.0", + " fabric-azuredevops-extension/1.2.0", ), ("Invalid-App", "1.0.0", ""), ("", None, ""), @@ -100,47 +100,47 @@ def test_resolve_library_user_agent_none_when_package_missing(): ( "Fabric-AzureDevops-Extension", "1.2.0.4", # Invalid format - " host-app/fabric-azuredevops-extension", + " fabric-azuredevops-extension", ), ( "Fabric-AzureDevops-Extension", "1.2.a", # Invalid format - " host-app/fabric-azuredevops-extension", + " fabric-azuredevops-extension", ), ( "Fabric-AzureDevops-Extension", "a.b.c", # Invalid format - " host-app/fabric-azuredevops-extension", + " fabric-azuredevops-extension", ), ( "Fabric-AzureDevops-Extension", "1", # valid format - " host-app/fabric-azuredevops-extension/1", + " fabric-azuredevops-extension/1", ), ( "Fabric-AzureDevops-Extension", "1.2", # valid format - " host-app/fabric-azuredevops-extension/1.2", + " fabric-azuredevops-extension/1.2", ), ( "Fabric-AzureDevops-Extension", "1.0.0", # valid format - " host-app/fabric-azuredevops-extension/1.0.0", + " fabric-azuredevops-extension/1.0.0", ), ( "Fabric-AzureDevops-Extension", "1.0.0-rc.1", # valid format - " host-app/fabric-azuredevops-extension/1.0.0-rc.1", + " fabric-azuredevops-extension/1.0.0-rc.1", ), ( "Fabric-AzureDevops-Extension", "1.0.0-alpha", # valid format - " host-app/fabric-azuredevops-extension/1.0.0-alpha", + " fabric-azuredevops-extension/1.0.0-alpha", ), ( "Fabric-AzureDevops-Extension", "1.0.0-beta", # valid format - " host-app/fabric-azuredevops-extension/1.0.0-beta", + " fabric-azuredevops-extension/1.0.0-beta", ), ], ) From 3a263f7c32abe1ab7e70cda6f047137f5961ae41 Mon Sep 17 00:00:00 2001 From: Aviat Cohen Date: Mon, 27 Jul 2026 10:22:28 +0000 Subject: [PATCH 5/6] Guard the User-Agent attribution so it's applied only when the installed fabric-cicd actually accepts a user_agent argument --- .../fs/deploy/fab_fs_deploy_config_file.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py b/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py index f6066a2d9..ca9340f4f 100644 --- a/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py +++ b/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import inspect import json from argparse import Namespace @@ -45,13 +46,18 @@ def deploy_with_config_file(args: Namespace) -> None: pass # Attribute CLI-triggered deployments in telemetry via the User-Agent. - cicd_user_agent = resolve_library_user_agent( - "fabric-cicd", "ms-fabric-cicd") - deploy_parameters["user_agent"] = ( - f"{cicd_user_agent},{build_user_agent(args.command_path)}" - if cicd_user_agent - else build_user_agent(args.command_path) - ) + # Only fabric-cicd builds that accept a `user_agent` argument support + # this; + if "user_agent" in inspect.signature(deploy_with_config).parameters: + cicd_user_agent = resolve_library_user_agent( + "fabric-cicd", "ms-fabric-cicd") + deploy_parameters["user_agent"] = ( + f"{cicd_user_agent},{build_user_agent(args.command_path)}" + if cicd_user_agent + else build_user_agent(args.command_path) + ) + else: + deploy_parameters.pop("user_agent", None) result = deploy_with_config( config_file_path=deploy_config_file, From b304b9671cd44aa8158d5c088998b24b49426b5b Mon Sep 17 00:00:00 2001 From: Aviat Cohen Date: Tue, 28 Jul 2026 09:05:21 +0000 Subject: [PATCH 6/6] update hostApp --- src/fabric_cli/client/fab_api_client.py | 57 +++++- .../fs/deploy/fab_fs_deploy_config_file.py | 18 +- src/fabric_cli/utils/fab_user_agent.py | 82 --------- tests/test_commands/test_deploy.py | 69 ++------ tests/test_core/test_fab_api_client.py | 125 ++++++++++++++ tests/test_utils/test_fab_user_agent.py | 163 ------------------ 6 files changed, 200 insertions(+), 314 deletions(-) delete mode 100644 src/fabric_cli/utils/fab_user_agent.py delete mode 100644 tests/test_utils/test_fab_user_agent.py diff --git a/src/fabric_cli/client/fab_api_client.py b/src/fabric_cli/client/fab_api_client.py index 2543bd883..e0c9f0fcb 100644 --- a/src/fabric_cli/client/fab_api_client.py +++ b/src/fabric_cli/client/fab_api_client.py @@ -2,6 +2,8 @@ # Licensed under the MIT License. import json +import os +import platform import re import time from argparse import Namespace @@ -24,9 +26,10 @@ from fabric_cli.utils import fab_files as files_utils from fabric_cli.utils import fab_ui as utils_ui from fabric_cli.utils.fab_http_polling_utils import get_polling_interval -from fabric_cli.utils.fab_user_agent import build_user_agent from fabric_cli.utils.fab_util import GUID_PATTERN_STR +_HOST_APP_VERSION_RE = re.compile(r"\d+(\.\d+){0,2}(-[a-zA-Z0-9\.-]+)?") + FABRIC_WORKSPACE_URI_PATTERN = rf"workspaces/{GUID_PATTERN_STR}" # Module-level reusable session for connection pooling @@ -114,7 +117,7 @@ def do_request( headers = { "Authorization": "Bearer " + str(token), - "User-Agent": build_user_agent(ctxt_cmd), + "User-Agent": _build_user_agent(ctxt_cmd), } if files is None: @@ -294,6 +297,56 @@ def _handle_successful_response(args: Namespace, response: ApiResponse) -> ApiRe return response +def _build_user_agent(ctxt_cmd: str) -> str: + """Build the User-Agent header for API requests. + + Example: + ms-fabric-cli/1.0.0 (create; Windows/10; Python/3.10.2) ado/2.0.0 + """ + user_agent = ( + f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} " + f"({ctxt_cmd}; {platform.system()}/{platform.release()}; " + f"Python/{platform.python_version()})" + ) + host_app = _get_host_app() + if host_app: + user_agent += host_app + + return user_agent + + +def _get_host_app() -> str: + """Get the HostApp suffix for the User-Agent header based on environment variables. + + Returns an empty string if the environment variable is not set or has an invalid value. + """ + _host_app_in_env = os.environ.get(fab_constant.FAB_HOST_APP_ENV_VAR) + if not _host_app_in_env: + return "" + + host_app_name = next( + ( + allowed_app + for allowed_app in fab_constant.ALLOWED_FAB_HOST_APP_VALUES + if _host_app_in_env.lower() == allowed_app.lower() + ), + None, + ) + + if not host_app_name: + return "" + + host_app = f" {host_app_name.lower()}" + + # Check for optional version + host_app_version = os.environ.get(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR) + + # validate host_app_version format is a valid version (e.g., 1.0.0) + if host_app_version and _HOST_APP_VERSION_RE.fullmatch(host_app_version): + host_app += f"/{host_app_version}" + return host_app + + def _print_response_details(response: ApiResponse) -> None: response_details = dict( { diff --git a/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py b/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py index ca9340f4f..655c2259c 100644 --- a/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py +++ b/src/fabric_cli/commands/fs/deploy/fab_fs_deploy_config_file.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -import inspect import json from argparse import Namespace @@ -12,7 +11,6 @@ from fabric_cli.core.fab_exceptions import FabricCLIError from fabric_cli.core.fab_msal_bridge import create_fabric_token_credential from fabric_cli.utils import fab_ui -from fabric_cli.utils.fab_user_agent import build_user_agent, resolve_library_user_agent from fabric_cli.utils.fab_util import get_dict_from_params @@ -45,19 +43,9 @@ def deploy_with_config_file(args: Namespace) -> None: # If it's not a valid JSON string, keep it as is pass - # Attribute CLI-triggered deployments in telemetry via the User-Agent. - # Only fabric-cicd builds that accept a `user_agent` argument support - # this; - if "user_agent" in inspect.signature(deploy_with_config).parameters: - cicd_user_agent = resolve_library_user_agent( - "fabric-cicd", "ms-fabric-cicd") - deploy_parameters["user_agent"] = ( - f"{cicd_user_agent},{build_user_agent(args.command_path)}" - if cicd_user_agent - else build_user_agent(args.command_path) - ) - else: - deploy_parameters.pop("user_agent", None) + deploy_parameters["host_app"] = ( + f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION}" + ) result = deploy_with_config( config_file_path=deploy_config_file, diff --git a/src/fabric_cli/utils/fab_user_agent.py b/src/fabric_cli/utils/fab_user_agent.py deleted file mode 100644 index 0b266d90b..000000000 --- a/src/fabric_cli/utils/fab_user_agent.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import importlib.metadata -import os -import platform -import re -from typing import Optional - -from fabric_cli.core import fab_constant - -_HOST_APP_VERSION_RE = re.compile(r"\d+(\.\d+){0,2}(-[a-zA-Z0-9\.-]+)?") - - -def build_user_agent(ctxt_cmd: str) -> str: - """Build the User-Agent header for API requests. - - Example: - ms-fabric-cli/1.0.0 (create; Windows/10; Python/3.10.2) ado/2.0.0 - """ - user_agent = ( - f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} " - f"({ctxt_cmd}; {platform.system()}/{platform.release()}; " - f"Python/{platform.python_version()})" - ) - host_app = _get_host_app() - if host_app: - user_agent += host_app - - return user_agent - - -def resolve_library_user_agent( - package_name: str, user_agent_name: str -) -> Optional[str]: - """Build a ``/`` User-Agent token for a host library. - - Resolves the installed distribution version so a caller can stamp telemetry with the - version the CLI actually ships (e.g. ``ms-fabric-cicd/1.2.0``). Returns None if the - package metadata cannot be resolved. - - Args: - package_name: The distribution name to resolve the version from (e.g., ``fabric-cicd``). - user_agent_name: The User-Agent identity to use (e.g., ``ms-fabric-cicd``). - """ - try: - version = importlib.metadata.version(package_name) - except importlib.metadata.PackageNotFoundError: - return None - return f"{user_agent_name}/{version}" - - -def _get_host_app() -> str: - """Get the HostApp suffix for the User-Agent header based on environment variables. - - Returns an empty string if the environment variable is not set or has an invalid value. - """ - _host_app_in_env = os.environ.get(fab_constant.FAB_HOST_APP_ENV_VAR) - if not _host_app_in_env: - return "" - - host_app_name = next( - ( - allowed_app - for allowed_app in fab_constant.ALLOWED_FAB_HOST_APP_VALUES - if _host_app_in_env.lower() == allowed_app.lower() - ), - None, - ) - - if not host_app_name: - return "" - - host_app = f" {host_app_name.lower()}" - - # Check for optional version - host_app_version = os.environ.get(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR) - - # validate host_app_version format is a valid version (e.g., 1.0.0) - if host_app_version and _HOST_APP_VERSION_RE.fullmatch(host_app_version): - host_app += f"/{host_app_version}" - return host_app diff --git a/tests/test_commands/test_deploy.py b/tests/test_commands/test_deploy.py index b10053807..4697548dc 100644 --- a/tests/test_commands/test_deploy.py +++ b/tests/test_commands/test_deploy.py @@ -385,18 +385,8 @@ def _run_deploy_with_config_file(self, deploy_with_config, params=None): ): deploy_mod.deploy_with_config_file(args) - _CICD_UNPATCHED = object() - - def _capture_deploy_user_agent(self, params=None, cicd_user_agent=_CICD_UNPATCHED): - """Run deploy_with_config_file and return the user_agent passed to fabric-cicd. - - When ``cicd_user_agent`` is provided, ``resolve_library_user_agent`` is - patched to return it (use ``None`` to simulate fabric-cicd not installed). - """ - from fabric_cli.commands.fs.deploy import ( - fab_fs_deploy_config_file as deploy_mod, - ) - + def _capture_deploy_host_app(self, params=None): + """Run deploy_with_config_file and return the host_app passed to fabric-cicd.""" captured = {} def fake_deploy_with_config( @@ -405,53 +395,28 @@ def fake_deploy_with_config( token_credential, environment="N/A", config_override=None, - user_agent=None, + host_app=None, ): - captured["user_agent"] = user_agent + captured["host_app"] = host_app return MagicMock(message="Deployment completed successfully") - if cicd_user_agent is self._CICD_UNPATCHED: - self._run_deploy_with_config_file(fake_deploy_with_config, params=params) - else: - with patch.object( - deploy_mod, - "resolve_library_user_agent", - return_value=cicd_user_agent, - ): - self._run_deploy_with_config_file( - fake_deploy_with_config, params=params - ) - - return captured["user_agent"] - - def test_deploy_passes_user_agent_with_cicd_version_prefix_success(self): - """CLI passes the fabric-cicd-allowlisted user_agent: 'ms-fabric-cicd/,'.""" - from fabric_cli.utils.fab_user_agent import ( - build_user_agent, - resolve_library_user_agent, - ) + self._run_deploy_with_config_file( + fake_deploy_with_config, params=params) - user_agent = self._capture_deploy_user_agent() + return captured["host_app"] - cicd_user_agent = resolve_library_user_agent("fabric-cicd", "ms-fabric-cicd") - assert user_agent == f"{cicd_user_agent},{build_user_agent('deploy')}" + def test_deploy_passes_host_app_success(self): + """CLI passes host_app as 'ms-fabric-cli/'.""" + from fabric_cli.core import fab_constant - def test_deploy_user_agent_without_cicd_version_success(self): - """When fabric-cicd version is unresolved, the CLI UA is passed without a prefix.""" - from fabric_cli.utils.fab_user_agent import build_user_agent + host_app = self._capture_deploy_host_app() - user_agent = self._capture_deploy_user_agent(cicd_user_agent=None) + assert host_app == f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION}" - assert user_agent == build_user_agent("deploy") - - def test_deploy_user_agent_cannot_be_spoofed_via_params_success(self): - """A user-supplied user_agent (via -P) is overridden by the CLI-controlled value.""" - from fabric_cli.utils.fab_user_agent import ( - build_user_agent, - resolve_library_user_agent, - ) + def test_deploy_host_app_cannot_be_spoofed_via_params_success(self): + """A user-supplied host_app (via -P) is overridden by the CLI-controlled value.""" + from fabric_cli.core import fab_constant - user_agent = self._capture_deploy_user_agent(params=["user_agent=spoofed"]) + host_app = self._capture_deploy_host_app(params=["host_app=spoofed"]) - cicd_user_agent = resolve_library_user_agent("fabric-cicd", "ms-fabric-cicd") - assert user_agent == f"{cicd_user_agent},{build_user_agent('deploy')}" + assert host_app == f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION}" diff --git a/tests/test_core/test_fab_api_client.py b/tests/test_core/test_fab_api_client.py index 4043c1261..2b16c07f0 100644 --- a/tests/test_core/test_fab_api_client.py +++ b/tests/test_core/test_fab_api_client.py @@ -8,6 +8,7 @@ import pytest from fabric_cli.client.fab_api_client import ( + _get_host_app, _transform_workspace_url_for_private_link_if_needed, do_request, ) @@ -308,6 +309,130 @@ def __init__(self): assert "Some Error Message" == excinfo.value.message assert "ErrorCode" == excinfo.value.status_code + +@patch.object(FabAuth(), "get_access_token", return_value="dummy-token") +def test_do_request_429_without_retry_after_header_retries_with_default_interval( + mock_get_token, +): + """A 429 response missing the Retry-After header should fall back to the default + polling interval and retry instead of raising an unexpected KeyError.""" + + class DummyResponse: + def __init__(self, status_code, headers=None, text=""): + self.status_code = status_code + self.text = text + self.content = text.encode() + self.headers = headers if headers is not None else {} + + # First response: throttled (429) with NO Retry-After header. + # Second response: success (200), so the retry loop can complete. + throttled = DummyResponse(429) + success = DummyResponse(200, text="{}") + + dummy_args = Namespace() + dummy_args.uri = f"workspaces/{str(uuid.uuid4())}/items" + dummy_args.method = "get" + dummy_args.audience = None + + with ( + patch("requests.Session.request", side_effect=[throttled, success]), + patch("fabric_cli.client.fab_api_client.time.sleep") as mock_sleep, + ): + response = do_request(dummy_args, hostname="custom.hostname.com") + + assert response.status_code == 200 + # Retried using the default polling interval (10s) rather than raising. + mock_sleep.assert_called_once_with(10) + + +@pytest.mark.parametrize( + "host_app_env, host_app_version_env, expected_suffix", + [ + ( + "Fabric-AzureDevops-Extension", + None, + " fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "1.2.0", + " fabric-azuredevops-extension/1.2.0", + ), + ( + "fabric-azuredevops-extension", + "1.2.0", + " fabric-azuredevops-extension/1.2.0", + ), + ("Invalid-App", "1.0.0", ""), + ("", None, ""), + (None, None, ""), + # Invalid version format - host app is still included but version is silently dropped + ( + "Fabric-AzureDevops-Extension", + "1.2.0.4", # Invalid format + " fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "1.2.a", # Invalid format + " fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "a.b.c", # Invalid format + " fabric-azuredevops-extension", + ), + ( + "Fabric-AzureDevops-Extension", + "1", # valid format + " fabric-azuredevops-extension/1", + ), + ( + "Fabric-AzureDevops-Extension", + "1.2", # valid format + " fabric-azuredevops-extension/1.2", + ), + ( + "Fabric-AzureDevops-Extension", + "1.0.0", # valid format + " fabric-azuredevops-extension/1.0.0", + ), + ( + "Fabric-AzureDevops-Extension", + "1.0.0-rc.1", # valid format + " fabric-azuredevops-extension/1.0.0-rc.1", + ), + ( + "Fabric-AzureDevops-Extension", + "1.0.0-alpha", # valid format + " fabric-azuredevops-extension/1.0.0-alpha", + ), + ( + "Fabric-AzureDevops-Extension", + "1.0.0-beta", # valid format + " fabric-azuredevops-extension/1.0.0-beta", + ), + ], +) +def test_get_host_app(host_app_env, host_app_version_env, expected_suffix, monkeypatch): + """Test the _get_host_app helper function.""" + if host_app_env is not None: + monkeypatch.setenv(fab_constant.FAB_HOST_APP_ENV_VAR, host_app_env) + else: + monkeypatch.delenv(fab_constant.FAB_HOST_APP_ENV_VAR, raising=False) + + if host_app_version_env is not None: + monkeypatch.setenv( + fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, host_app_version_env + ) + else: + monkeypatch.delenv(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, raising=False) + + result = _get_host_app() + + assert result == expected_suffix + + @pytest.fixture() def setup_default_private_links(mock_fab_set_state_config): mock_fab_set_state_config(fab_constant.FAB_WS_PRIVATE_LINKS_ENABLED, "true") diff --git a/tests/test_utils/test_fab_user_agent.py b/tests/test_utils/test_fab_user_agent.py deleted file mode 100644 index 43504a45d..000000000 --- a/tests/test_utils/test_fab_user_agent.py +++ /dev/null @@ -1,163 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import importlib.metadata -from unittest.mock import patch - -import pytest - -from fabric_cli.core import fab_constant -from fabric_cli.utils.fab_user_agent import ( - _get_host_app, - build_user_agent, - resolve_library_user_agent, -) - - -@patch("fabric_cli.utils.fab_user_agent.platform.python_version", return_value="3.11.5") -@patch("fabric_cli.utils.fab_user_agent.platform.release", return_value="5.4.0") -@patch("fabric_cli.utils.fab_user_agent.platform.system", return_value="Linux") -def test_build_user_agent_without_host_app( - mock_system, mock_release, mock_python_version, monkeypatch -): - """build_user_agent returns the base token when no host app env is set.""" - monkeypatch.delenv(fab_constant.FAB_HOST_APP_ENV_VAR, raising=False) - monkeypatch.delenv(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, raising=False) - - result = build_user_agent("deploy") - - assert result == ( - f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} " - f"(deploy; Linux/5.4.0; Python/3.11.5)" - ) - - -@patch("fabric_cli.utils.fab_user_agent.platform.python_version", return_value="3.11.5") -@patch("fabric_cli.utils.fab_user_agent.platform.release", return_value="5.4.0") -@patch("fabric_cli.utils.fab_user_agent.platform.system", return_value="Linux") -def test_build_user_agent_appends_host_app_suffix( - mock_system, mock_release, mock_python_version, monkeypatch -): - """build_user_agent appends the validated host-app suffix when env is set.""" - monkeypatch.setenv( - fab_constant.FAB_HOST_APP_ENV_VAR, "Fabric-AzureDevops-Extension" - ) - monkeypatch.setenv(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, "1.2.0") - - result = build_user_agent("create") - - assert result == ( - f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} " - f"(create; Linux/5.4.0; Python/3.11.5)" - f" fabric-azuredevops-extension/1.2.0" - ) - - -def test_resolve_library_user_agent_uses_installed_version(): - """resolve_library_user_agent returns '/'.""" - with patch( - "fabric_cli.utils.fab_user_agent.importlib.metadata.version", - return_value="9.8.7", - ): - result = resolve_library_user_agent("some-package", "ms-some-package") - - assert result == "ms-some-package/9.8.7" - - -def test_resolve_library_user_agent_none_when_package_missing(): - """resolve_library_user_agent returns None when metadata is missing.""" - with patch( - "fabric_cli.utils.fab_user_agent.importlib.metadata.version", - side_effect=importlib.metadata.PackageNotFoundError, - ): - result = resolve_library_user_agent("missing-package", "ms-missing") - - assert result is None - - -@pytest.mark.parametrize( - "host_app_env, host_app_version_env, expected_suffix", - [ - ( - "Fabric-AzureDevops-Extension", - None, - " fabric-azuredevops-extension", - ), - ( - "Fabric-AzureDevops-Extension", - "1.2.0", - " fabric-azuredevops-extension/1.2.0", - ), - ( - "fabric-azuredevops-extension", - "1.2.0", - " fabric-azuredevops-extension/1.2.0", - ), - ("Invalid-App", "1.0.0", ""), - ("", None, ""), - (None, None, ""), - # Invalid version format - host app is still included but version is silently dropped - ( - "Fabric-AzureDevops-Extension", - "1.2.0.4", # Invalid format - " fabric-azuredevops-extension", - ), - ( - "Fabric-AzureDevops-Extension", - "1.2.a", # Invalid format - " fabric-azuredevops-extension", - ), - ( - "Fabric-AzureDevops-Extension", - "a.b.c", # Invalid format - " fabric-azuredevops-extension", - ), - ( - "Fabric-AzureDevops-Extension", - "1", # valid format - " fabric-azuredevops-extension/1", - ), - ( - "Fabric-AzureDevops-Extension", - "1.2", # valid format - " fabric-azuredevops-extension/1.2", - ), - ( - "Fabric-AzureDevops-Extension", - "1.0.0", # valid format - " fabric-azuredevops-extension/1.0.0", - ), - ( - "Fabric-AzureDevops-Extension", - "1.0.0-rc.1", # valid format - " fabric-azuredevops-extension/1.0.0-rc.1", - ), - ( - "Fabric-AzureDevops-Extension", - "1.0.0-alpha", # valid format - " fabric-azuredevops-extension/1.0.0-alpha", - ), - ( - "Fabric-AzureDevops-Extension", - "1.0.0-beta", # valid format - " fabric-azuredevops-extension/1.0.0-beta", - ), - ], -) -def test_get_host_app(host_app_env, host_app_version_env, expected_suffix, monkeypatch): - """Test the _get_host_app helper function.""" - if host_app_env is not None: - monkeypatch.setenv(fab_constant.FAB_HOST_APP_ENV_VAR, host_app_env) - else: - monkeypatch.delenv(fab_constant.FAB_HOST_APP_ENV_VAR, raising=False) - - if host_app_version_env is not None: - monkeypatch.setenv( - fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, host_app_version_env - ) - else: - monkeypatch.delenv(fab_constant.FAB_HOST_APP_VERSION_ENV_VAR, raising=False) - - result = _get_host_app() - - assert result == expected_suffix