diff --git a/.changes/unreleased/fixed-20251112-081104.yaml b/.changes/unreleased/fixed-20251112-081104.yaml new file mode 100644 index 000000000..cdda60916 --- /dev/null +++ b/.changes/unreleased/fixed-20251112-081104.yaml @@ -0,0 +1,3 @@ +kind: fixed +body: refactor output format - adding support for print in key-value list style +time: 2025-11-12T08:11:04.00625845Z diff --git a/src/fabric_cli/core/fab_output.py b/src/fabric_cli/core/fab_output.py index cd312ae76..a44774b08 100644 --- a/src/fabric_cli/core/fab_output.py +++ b/src/fabric_cli/core/fab_output.py @@ -77,6 +77,7 @@ def __init__( error_code: Optional[str] = None, data: Optional[Any] = None, hidden_data: Optional[Any] = None, + show_key_value_list: bool = False, ): """Initialize a new FabricCLIOutput instance. @@ -89,6 +90,7 @@ def __init__( error_code: Optional error code. Only included when status is Failed. data: The main output data to be displayed hidden_data: Additional data shown only when --all flag or FAB_SHOW_HIDDEN is true + show_key_value_list: Whether to show output in key-value list format Note: The data parameter is always converted to a list format internally. @@ -100,6 +102,7 @@ def __init__( self._subcommand = subcommand self._output_format_type = output_format_type self._show_headers = show_headers + self._show_key_value_list = show_key_value_list self._result = OutputResult( data=data, @@ -124,6 +127,10 @@ def result(self) -> OutputResult: def show_headers(self) -> bool: return self._show_headers + @property + def show_key_value_list(self) -> bool: + return self._show_key_value_list + def to_json(self, indent: int = 4) -> str: try: from fabric_cli.utils.fab_util import dumps diff --git a/src/fabric_cli/errors/common.py b/src/fabric_cli/errors/common.py index b2bc2ca9f..2743c4465 100644 --- a/src/fabric_cli/errors/common.py +++ b/src/fabric_cli/errors/common.py @@ -7,6 +7,10 @@ class CommonErrors: + @staticmethod + def invalid_entries_format() -> str: + return "Invalid entries format" + @staticmethod def invalid_jmespath_query() -> str: return f"Invalid jmespath query (https://jmespath.org)" diff --git a/src/fabric_cli/utils/fab_ui.py b/src/fabric_cli/utils/fab_ui.py index ec60d5240..464dd8a50 100644 --- a/src/fabric_cli/utils/fab_ui.py +++ b/src/fabric_cli/utils/fab_ui.py @@ -3,7 +3,6 @@ import builtins import html -import json import sys import unicodedata from argparse import Namespace @@ -95,7 +94,7 @@ def print_output_format( data: Optional[Any] = None, hidden_data: Optional[Any] = None, show_headers: bool = False, - # print_callback: bool = True, + show_key_value_list: bool = False, ) -> None: """Create a FabricCLIOutput instance and print it depends on the format. @@ -105,6 +104,7 @@ def print_output_format( data: Optional data to include in output hidden_data: Optional hidden data to include in output show_headers: Whether to show headers in the output (default: False) + show_key_value_list: Whether to show output in key-value list format (default: False) Returns: FabricCLIOutput: Configured output instance ready for printing @@ -121,6 +121,7 @@ def print_output_format( data=data, hidden_data=hidden_data, show_headers=show_headers, + show_key_value_list=show_key_value_list, ) # Get format from output or config @@ -355,6 +356,8 @@ def _print_output_format_result_text(output: FabricCLIOutput) -> None: ): data_keys = output.result.get_data_keys() if output_result.data else [] print_entries_unix_style(output_result.data, data_keys, header=show_headers) + elif output.show_key_value_list: + _print_entries_key_value_list_style(output_result.data) else: _print_raw_data(output_result.data) @@ -486,3 +489,76 @@ def _get_visual_length(string: str) -> int: else: length += 1 return length + + +def _print_entries_key_value_list_style(entries: Any) -> None: + """Print entries in a key-value list format with formatted keys. + + Args: + entries: Dictionary or list of dictionaries to print + + Example output: + Logged In: true + Account: johndoe@example.com + """ + if isinstance(entries, dict): + _entries = [entries] + elif isinstance(entries, list): + if not entries: + return + _entries = entries + else: + raise FabricCLIError( + ErrorMessages.Common.invalid_entries_format(), + fab_constant.ERROR_INVALID_ENTRIES_FORMAT, + ) + + for i, entry in enumerate(_entries): + for key, value in entry.items(): + pretty_key = _format_key_to_convert_to_title_case(key) + print_grey(f"{pretty_key}: {value}", to_stderr=False) + if i < len(_entries) - 1: + print_grey("", to_stderr=False) # Empty line between entries + + +def _format_key_to_convert_to_title_case(key: str) -> str: + """Convert a snake_case key to a Title Case name. + + Args: + key: The key to format in snake_case format (e.g. 'user_id', 'account_name') + + Returns: + str: Formatted to title case name (e.g. 'User ID', 'Account Name') + + Raises: + ValueError: If the key is not in the expected underscore-separated format + """ + # Allow letters, numbers, and underscores only + if not key.replace('_', '').replace(' ', '').isalnum(): + raise ValueError(f"Invalid key format: '{key}'. Only underscore-separated words are allowed.") + + # Check for invalid patterns (camelCase, spaces mixed with underscores, etc.) + if ' ' in key and '_' in key: + raise ValueError(f"Invalid key format: '{key}'. Only underscore-separated words are allowed.") + + # Check for camelCase pattern (uppercase letters not at the start) + if any(char.isupper() for char in key[1:]) and '_' not in key: + raise ValueError(f"Invalid key format: '{key}'. Only underscore-separated words are allowed.") + + pretty = key.replace('_', ' ').title().strip() + + return _check_special_cases(pretty) + + +def _check_special_cases(pretty: str) -> str: + """Check for special cases and replace them with the correct value.""" + # Here add special cases for specific keys that need to be formatted differently + special_cases = { + "Id": "ID", + "Powerbi": "PowerBI", + } + + for case_key, case_value in special_cases.items(): + pretty = pretty.replace(case_key.title(), case_value) + + return pretty \ No newline at end of file diff --git a/tests/test_core/test_fab_output.py b/tests/test_core/test_fab_output.py index 0ae7071e4..f9e8af825 100644 --- a/tests/test_core/test_fab_output.py +++ b/tests/test_core/test_fab_output.py @@ -167,3 +167,18 @@ def test_fabric_cli_output_error_handling_success(): json_output = json.loads(output.to_json()) assert json_output["result"]["error_code"] == "UnexpectedError" + + +def test_fabric_cli_output_show_key_value_list_success(): + """Test show_key_value_list property is handled correctly.""" + # Test with show_key_value_list True + output = FabricCLIOutput(data={"test": "data"}, show_key_value_list=True) + assert output.show_key_value_list is True + + # Test with show_key_value_list False (default) + output = FabricCLIOutput(data={"test": "data"}) + assert output.show_key_value_list is False + + # Test with explicit False + output = FabricCLIOutput(data={"test": "data"}, show_key_value_list=False) + assert output.show_key_value_list is False diff --git a/tests/test_utils/test_fab_ui.py b/tests/test_utils/test_fab_ui.py index 3201023fe..2c35648e3 100644 --- a/tests/test_utils/test_fab_ui.py +++ b/tests/test_utils/test_fab_ui.py @@ -5,11 +5,9 @@ import platform from argparse import Namespace from enum import Enum -from typing import Callable, Optional import pytest -import fabric_cli.core.fab_state_config as state_config from fabric_cli.core import fab_constant from fabric_cli.core import fab_constant as constant from fabric_cli.core.fab_exceptions import FabricCLIError @@ -562,6 +560,98 @@ def test_print_output_format_with_force_output_success( ) +def test_print_output_format_with_show_key_value_list_success( + mock_questionary_print, mock_fab_set_state_config +): + """Test print_output_format with show_key_value_list=True calls print_entries_key_value_style.""" + + # Setup text output format + mock_fab_set_state_config(constant.FAB_OUTPUT_FORMAT, "text") + + # Test data with multiple entries + test_data = [ + {"user_name": "john", "is_active": "true"}, + {"user_name": "jane", "is_active": "false"} + ] + + args = Namespace(command="test") + ui.print_output_format( + args, + data=test_data, + show_key_value_list=True + ) + + assert mock_questionary_print.call_count >= 1 + + output_calls = [call.args[0] for call in mock_questionary_print.mock_calls] + output_text = " ".join(output_calls) + + assert "User Name:" in output_text + assert "Is Active:" in output_text + assert '"user_name"' not in output_text + assert '{\n' not in output_text + + mock_questionary_print.reset_mock() + + +def test_print_output_format_with_show_key_value_list_false_success( + mock_questionary_print, mock_fab_set_state_config +): + """Test print_output_format with show_key_value_list=False uses default JSON formatting.""" + + # Setup text output format + mock_fab_set_state_config(constant.FAB_OUTPUT_FORMAT, "text") + + # Test data + test_data = [{"user_name": "john", "is_active": "true"}] + + args = Namespace(command="test") + ui.print_output_format( + args, + data=test_data, + show_key_value_list=False # Explicitly set to False + ) + + assert mock_questionary_print.call_count == 1 + output = mock_questionary_print.mock_calls[0].args[0] + + # Should contain JSON structure, not key-value format + assert '{\n' in output or '[' in output + assert '"user_name": "john"' in output or '"user_name":"john"' in output + + mock_questionary_print.reset_mock() + + +def test_print_output_format_with_show_key_value_list_json_format_success( + mock_questionary_print, mock_fab_set_state_config +): + """Test that show_key_value_list parameter works correctly with JSON output format.""" + + # Setup JSON output format + mock_fab_set_state_config(constant.FAB_OUTPUT_FORMAT, "json") + + # Test data + test_data = [{"user_name": "john", "is_active": "true"}] + + args = Namespace(command="test", output_format="json") + ui.print_output_format( + args, + data=test_data, + show_key_value_list=True # This should be ignored in JSON format + ) + + # Verify that JSON output is produced regardless of show_key_value_list + assert mock_questionary_print.call_count == 1 + output = json.loads(mock_questionary_print.mock_calls[0].args[0]) + + assert isinstance(output, dict) + assert "result" in output + assert "data" in output["result"] + assert output["result"]["data"] == test_data + + mock_questionary_print.reset_mock() + + def test_print_output_format_failure(mock_fab_set_state_config): # Mock get_config to return an unsupported format mock_fab_set_state_config(constant.FAB_OUTPUT_FORMAT, "test") @@ -588,6 +678,105 @@ def test_print_output_format_text_no_result_failure(): assert excinfo.value.status_code == constant.ERROR_INVALID_INPUT +def test_print_entries_key_value_style_success(mock_questionary_print): + """Test printing entries in key-value format.""" + + # Test with single dictionary entry + entry = {"logged_in": "true", "account_name": "johndoe@example.com"} + ui._print_entries_key_value_list_style(entry) + + # Verify the correct formatted output was printed + assert mock_questionary_print.call_count == 2 + printed_calls = [call.args[0] for call in mock_questionary_print.call_args_list] + assert "Logged In: true" in printed_calls + assert "Account Name: johndoe@example.com" in printed_calls + + mock_questionary_print.reset_mock() + + # Test with list of dictionaries + entries = [ + {"user_name": "john", "status": "active"}, + {"user_name": "jane", "status": "inactive"} + ] + ui._print_entries_key_value_list_style(entries) + + # Verify output for list of entries (should include empty line between entries, but not after last) + assert mock_questionary_print.call_count == 5 # 2 for john + 1 empty line + 2 for jane + printed_calls = [call.args[0] for call in mock_questionary_print.call_args_list] + assert "User Name: john" in printed_calls + assert "Status: active" in printed_calls + assert "User Name: jane" in printed_calls + assert "Status: inactive" in printed_calls + assert "" in printed_calls # Empty line between entries (but not after the last entry) + + mock_questionary_print.reset_mock() + + # Test with empty list + ui._print_entries_key_value_list_style([]) + # Should not call print for empty list + mock_questionary_print.assert_not_called() + + +def test_print_entries_key_value_style_invalid_input(): + """Test error handling for invalid input types.""" + + # Test with invalid input type (string) + with pytest.raises(FabricCLIError) as ex: + ui._print_entries_key_value_list_style("invalid_input") + + assert ex.value.status_code == fab_constant.ERROR_INVALID_ENTRIES_FORMAT + + # Test with invalid input type (integer) + with pytest.raises(FabricCLIError) as ex: + ui._print_entries_key_value_list_style(123) + + assert ex.value.status_code == fab_constant.ERROR_INVALID_ENTRIES_FORMAT + + +def test_format_key_to_title_case_success(): + # Test snake_case conversion + assert ui._format_key_to_convert_to_title_case("account_name") == "Account Name" + # Test single word + assert ui._format_key_to_convert_to_title_case("status") == "Status" + # Test snake_case with multiple underscores + assert ui._format_key_to_convert_to_title_case("user_account_name") == "User Account Name" + # Test special cases from the function + assert ui._format_key_to_convert_to_title_case("user_id") == "User ID" + assert ui._format_key_to_convert_to_title_case("powerbi_settings") == "PowerBI Settings" + # Test numbers in keys + assert ui._format_key_to_convert_to_title_case("version_2_settings") == "Version 2 Settings" + # Test mixed case + assert ui._format_key_to_convert_to_title_case("user_Name") == "User Name" + # Test remove spaces + assert ui._format_key_to_convert_to_title_case(" user") == "User" + # Test already title case + assert ui._format_key_to_convert_to_title_case("User") == "User" + + +def test_format_key_to_title_case_failure(): + """Test that the function throws ValueError for invalid key formats.""" + + # Test camelCase (should fail) + with pytest.raises(ValueError, match="Invalid key format: 'accountName'. Only underscore-separated words are allowed."): + ui._format_key_to_convert_to_title_case("accountName") + + # Test camelCase with ID (should fail) + with pytest.raises(ValueError, match="Invalid key format: 'accountID'. Only underscore-separated words are allowed."): + ui._format_key_to_convert_to_title_case("accountID") + + # Test spaces mixed with underscores (should fail) + with pytest.raises(ValueError, match="Invalid key format: 'user name_test'. Only underscore-separated words are allowed."): + ui._format_key_to_convert_to_title_case("user name_test") + + # Test special characters (should fail) + with pytest.raises(ValueError, match="Invalid key format: 'user@name'. Only underscore-separated words are allowed."): + ui._format_key_to_convert_to_title_case("user@name") + + # Test hyphen separated (should fail) + with pytest.raises(ValueError, match="Invalid key format: 'user-name'. Only underscore-separated words are allowed."): + ui._format_key_to_convert_to_title_case("user-name") + + def test_print_version_seccess(): ui.print_version() ui.print_version(None)