diff --git a/.changes/unreleased/added-20260209-171617.yaml b/.changes/unreleased/added-20260209-171617.yaml new file mode 100644 index 00000000..5ee9486c --- /dev/null +++ b/.changes/unreleased/added-20260209-171617.yaml @@ -0,0 +1,6 @@ +kind: added +body: Add new 'fab find' command for searching the Fabric catalog across workspaces +time: 2026-02-09T17:16:17.2056327+02:00 +custom: + Author: nschachter + AuthorLink: https://github.com/nschachter diff --git a/src/fabric_cli/client/fab_api_catalog.py b/src/fabric_cli/client/fab_api_catalog.py new file mode 100644 index 00000000..c0ee2d3c --- /dev/null +++ b/src/fabric_cli/client/fab_api_catalog.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Catalog API client for searching Fabric items across workspaces. + +API Reference: POST https://api.fabric.microsoft.com/v1/catalog/search +Required Scope: Catalog.Read.All +""" + +from argparse import Namespace + +from fabric_cli.client import fab_api_client as fabric_api +from fabric_cli.client.fab_api_types import ApiResponse + + +def catalog_search(args: Namespace, payload: dict) -> ApiResponse: + """Search the Fabric catalog for items. + + https://learn.microsoft.com/en-us/rest/api/fabric/core/catalog/search + + Args: + args: Namespace with request configuration + payload: Dict with search request body: + - search (required): Text to search across displayName, description, workspaceName + - pageSize: Number of results per page + - continuationToken: Token for pagination + - filter: OData filter string, e.g., "Type eq 'Report' or Type eq 'Lakehouse'" + + Returns: + ApiResponse with search results containing: + - value: List of ItemCatalogEntry objects + - continuationToken: Token for next page (if more results exist) + + Note: + The following item types are NOT searchable via this API: + Dashboard + + Note: Dataflow Gen1 and Gen2 are currently not searchable; only Dataflow Gen2 + CI/CD items are returned (as type 'Dataflow'). + Scorecards are returned as type 'Report'. + """ + args.uri = "catalog/search" + args.method = "post" + # Use raw_response to avoid auto-pagination (we handle pagination in display) + args.raw_response = True + return fabric_api.do_request(args, json=payload) + diff --git a/src/fabric_cli/commands/find/__init__.py b/src/fabric_cli/commands/find/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/src/fabric_cli/commands/find/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py new file mode 100644 index 00000000..c2c9c08f --- /dev/null +++ b/src/fabric_cli/commands/find/fab_find.py @@ -0,0 +1,228 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Find command for searching the Fabric catalog.""" + +import json +from argparse import Namespace +from typing import Any + +from fabric_cli.client import fab_api_catalog as catalog_api +from fabric_cli.core import fab_constant +from fabric_cli.core.fab_decorators import handle_exceptions, set_command_context +from fabric_cli.core.fab_exceptions import FabricCLIError +from fabric_cli.utils import fab_ui as utils_ui + + +# All Fabric item types (from API spec, alphabetically sorted) +ALL_ITEM_TYPES = [ + "AnomalyDetector", + "ApacheAirflowJob", + "CopyJob", + "CosmosDBDatabase", + "Dashboard", + "Dataflow", + "Datamart", + "DataPipeline", + "DigitalTwinBuilder", + "DigitalTwinBuilderFlow", + "Environment", + "Eventhouse", + "EventSchemaSet", + "Eventstream", + "GraphModel", + "GraphQLApi", + "GraphQuerySet", + "KQLDashboard", + "KQLDatabase", + "KQLQueryset", + "Lakehouse", + "Map", + "MirroredAzureDatabricksCatalog", + "MirroredDatabase", + "MirroredWarehouse", + "MLExperiment", + "MLModel", + "MountedDataFactory", + "Notebook", + "Ontology", + "OperationsAgent", + "PaginatedReport", + "Reflex", + "Report", + "SemanticModel", + "SnowflakeDatabase", + "SparkJobDefinition", + "SQLDatabase", + "SQLEndpoint", + "UserDataFunction", + "VariableLibrary", + "Warehouse", + "WarehouseSnapshot", +] + +# Types that exist in Fabric but are NOT searchable via the Catalog Search API +UNSUPPORTED_ITEM_TYPES = [ + "Dashboard", +] + +# Types that ARE searchable (for validation) +SEARCHABLE_ITEM_TYPES = [t for t in ALL_ITEM_TYPES if t not in UNSUPPORTED_ITEM_TYPES] + + +def complete_item_types(prefix: str, **kwargs) -> list[str]: + """Completer for --type flag. Returns matching searchable item types.""" + prefix_lower = prefix.lower() + # Only complete searchable types to avoid user frustration + return [t for t in SEARCHABLE_ITEM_TYPES if t.lower().startswith(prefix_lower)] + + +@handle_exceptions() +@set_command_context() +def find_command(args: Namespace) -> None: + """Search the Fabric catalog for items.""" + # Validate: either query or --next-token must be provided + has_query = hasattr(args, "query") and args.query + has_continue = hasattr(args, "continue_token") and args.continue_token + + if not has_query and not has_continue: + raise FabricCLIError( + "Either a search query or --next-token is required.", + fab_constant.ERROR_INVALID_INPUT, + ) + + payload = _build_search_payload(args) + + if has_continue: + utils_ui.print_grey("Fetching next page of results...") + else: + utils_ui.print_grey(f"Searching catalog for '{args.query}'...") + + response = catalog_api.catalog_search(args, payload) + + _handle_response(args, response) + + +def _build_search_payload(args: Namespace) -> dict[str, Any]: + """Build the search request payload from command arguments.""" + request: dict[str, Any] = {} + + # If continuation token is provided, only send that (search/filter are encoded in token) + if hasattr(args, "continue_token") and args.continue_token: + request["continuationToken"] = args.continue_token + # Add page size if specified (allowed with continuation token) + if hasattr(args, "limit") and args.limit: + request["pageSize"] = args.limit + return request + + # Normal search request + request["search"] = args.query + + # Add page size if specified + if hasattr(args, "limit") and args.limit: + request["pageSize"] = args.limit + + # Build type filter if specified + if hasattr(args, "type") and args.type: + types = args.type # Already a list from argparse nargs="+" + # Validate types + for t in types: + if t in UNSUPPORTED_ITEM_TYPES: + raise FabricCLIError( + f"Item type '{t}' is not searchable via catalog search API. " + f"Unsupported types: {', '.join(UNSUPPORTED_ITEM_TYPES)}", + fab_constant.ERROR_UNSUPPORTED_ITEM_TYPE, + ) + if t not in SEARCHABLE_ITEM_TYPES: + raise FabricCLIError( + f"Unknown item type: '{t}'. Use tab completion to see valid types.", + fab_constant.ERROR_INVALID_ITEM_TYPE, + ) + + filter_parts = [f"Type eq '{t}'" for t in types] + request["filter"] = " or ".join(filter_parts) + + return request + + +def _handle_response(args: Namespace, response) -> None: + """Handle the API response, including error cases.""" + # Check for error responses + if response.status_code != 200: + try: + error_data = json.loads(response.text) + error_code = error_data.get("errorCode", "UnknownError") + error_message = error_data.get("message", response.text) + except json.JSONDecodeError: + error_code = "UnknownError" + error_message = response.text + + raise FabricCLIError( + f"Catalog search failed: {error_message}", + error_code, + ) + + _display_results(args, response) + + +def _display_results(args: Namespace, response) -> None: + """Format and display search results.""" + results = json.loads(response.text) + items = results.get("value", []) + continuation_token = results.get("continuationToken") + + if not items: + utils_ui.print_grey("No items found.") + return + + # Add result count info + count = len(items) + has_more = continuation_token is not None + count_msg = f"{count} item(s) found" + (" (more available)" if has_more else "") + utils_ui.print_grey("") # Blank line after "Searching..." + utils_ui.print_grey(count_msg) + utils_ui.print_grey("") # Blank line separator + + # Check if detailed output is requested + detailed = getattr(args, "long", False) + + if detailed: + # Detailed output: vertical key-value list with all fields + # Use snake_case keys for proper Title Case formatting by fab_ui + # Only include keys with non-empty values + display_items = [] + for item in items: + entry = { + "name": item.get("displayName") or item.get("name"), + "id": item.get("id"), + "type": item.get("type"), + "workspace": item.get("workspaceName"), + "workspace_id": item.get("workspaceId"), + } + # Only add description if it has a value + if item.get("description"): + entry["description"] = item.get("description") + display_items.append(entry) + utils_ui.print_output_format(args, data=display_items, show_key_value_list=True) + else: + # Default output: compact table view + # Check if any items have descriptions + has_descriptions = any(item.get("description") for item in items) + + display_items = [] + for item in items: + entry = { + "name": item.get("displayName") or item.get("name"), + "type": item.get("type"), + "workspace": item.get("workspaceName"), + } + # Only include description column if any item has a description + if has_descriptions: + entry["description"] = item.get("description") or "" + display_items.append(entry) + utils_ui.print_output_format(args, data=display_items, show_headers=True) + + # Output continuation token if more results available + if continuation_token: + utils_ui.print_grey("") + utils_ui.print_grey(f"To get more results, use: --next-token \"{continuation_token}\"") diff --git a/src/fabric_cli/core/fab_parser_setup.py b/src/fabric_cli/core/fab_parser_setup.py index ae91d37a..53a96220 100644 --- a/src/fabric_cli/core/fab_parser_setup.py +++ b/src/fabric_cli/core/fab_parser_setup.py @@ -14,6 +14,7 @@ from fabric_cli.parsers import fab_config_parser as config_parser from fabric_cli.parsers import fab_describe_parser as describe_parser from fabric_cli.parsers import fab_extension_parser as extension_parser +from fabric_cli.parsers import fab_find_parser as find_parser from fabric_cli.parsers import fab_fs_parser as fs_parser from fabric_cli.parsers import fab_global_params from fabric_cli.parsers import fab_jobs_parser as jobs_parser @@ -218,6 +219,7 @@ def create_parser_and_subparsers(): api_parser.register_parser(subparsers) # api auth_parser.register_parser(subparsers) # auth describe_parser.register_parser(subparsers) # desc + find_parser.register_parser(subparsers) # find extension_parser.register_parser(subparsers) # extension # version diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py new file mode 100644 index 00000000..7beca440 --- /dev/null +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Parser for the find command.""" + +import argparse +from argparse import Namespace, _SubParsersAction + +from fabric_cli.commands.find import fab_find as find +from fabric_cli.core import fab_constant +from fabric_cli.utils import fab_error_parser as utils_error_parser +from fabric_cli.utils import fab_ui as utils_ui + + +COMMAND_FIND_DESCRIPTION = "Search the Fabric catalog for items." + +commands = { + "Description": { + "find": "Search across all workspaces by name, description, or workspace name.", + }, +} + + +def _max_items_type(value: str) -> int: + """Validate --max-items is between 1 and 1000.""" + try: + ivalue = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"invalid int value: '{value}'") + if ivalue < 1 or ivalue > 1000: + raise argparse.ArgumentTypeError(f"must be between 1 and 1000, got {ivalue}") + return ivalue + + +def register_parser(subparsers: _SubParsersAction) -> None: + """Register the find command parser.""" + examples = [ + "# search for items by name or description", + "$ find 'sales report'\n", + "# search for lakehouses only", + "$ find 'data' --type Lakehouse\n", + "# search for multiple item types", + "$ find 'dashboard' --type Report SemanticModel\n", + "# show detailed output with IDs", + "$ find 'sales' -l\n", + "# combine filters", + "$ find 'finance' --type Warehouse Lakehouse --max-items 20", + ] + + parser = subparsers.add_parser( + "find", + help=COMMAND_FIND_DESCRIPTION, + fab_examples=examples, + fab_learnmore=["_"], + ) + + parser.add_argument( + "query", + nargs="?", + help="Search text (matches display name, description, and workspace name)", + ) + type_arg = parser.add_argument( + "--type", + nargs="+", + metavar="TYPE", + help="Filter by item type(s). Examples: Report, Lakehouse, Warehouse. Use for full list.", + ) + # Add tab-completion for item types + type_arg.completer = find.complete_item_types + + parser.add_argument( + "--max-items", + dest="limit", + metavar="N", + type=_max_items_type, + default=50, + help="Maximum number of results to return (1-1000, default: 50)", + ) + parser.add_argument( + "-l", + "--long", + action="store_true", + help="Show detailed output. Optional", + ) + parser.add_argument( + "--next-token", + dest="continue_token", + metavar="TOKEN", + help="Continuation token from previous search to get next page of results", + ) + + parser.usage = f"{utils_error_parser.get_usage_prog(parser)}" + parser.set_defaults(func=find.find_command) + + +def show_help(args: Namespace) -> None: + """Display help for the find command.""" + utils_ui.display_help(commands, custom_header=COMMAND_FIND_DESCRIPTION) diff --git a/tests/test_commands/find/__init__.py b/tests/test_commands/find/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/tests/test_commands/find/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py new file mode 100644 index 00000000..0bd06add --- /dev/null +++ b/tests/test_commands/find/test_find.py @@ -0,0 +1,317 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for the find command.""" + +import json +from argparse import Namespace +from unittest.mock import MagicMock, patch + +import pytest + +from fabric_cli.commands.find import fab_find +from fabric_cli.client.fab_api_types import ApiResponse +from fabric_cli.core.fab_exceptions import FabricCLIError + + +# Sample API responses for testing +SAMPLE_RESPONSE_WITH_RESULTS = { + "value": [ + { + "id": "0acd697c-1550-43cd-b998-91bfb12347c6", + "type": "Report", + "catalogEntryType": "FabricItem", + "displayName": "Monthly Sales Revenue", + "description": "Consolidated revenue report for the current fiscal year.", + "workspaceId": "18cd155c-7850-15cd-a998-91bfb12347aa", + "workspaceName": "Sales Department", + }, + { + "id": "123d697c-7848-77cd-b887-91bfb12347cc", + "type": "Lakehouse", + "catalogEntryType": "FabricItem", + "displayName": "Yearly Sales Revenue", + "description": "Consolidated revenue report for the current fiscal year.", + "workspaceId": "18cd155c-7850-15cd-a998-91bfb12347aa", + "workspaceName": "Sales Department", + }, + ], + "continuationToken": "lyJ1257lksfdfG==", +} + +SAMPLE_RESPONSE_EMPTY = { + "value": [], +} + +SAMPLE_RESPONSE_SINGLE = { + "value": [ + { + "id": "abc12345-1234-5678-9abc-def012345678", + "type": "Notebook", + "catalogEntryType": "FabricItem", + "displayName": "Data Analysis", + "description": "Notebook for data analysis tasks.", + "workspaceId": "workspace-id-123", + "workspaceName": "Analytics Team", + }, + ], +} + + +class TestBuildSearchPayload: + """Tests for _build_search_payload function.""" + + def test_basic_query(self): + """Test basic search query.""" + args = Namespace(query="sales report", type=None, limit=None) + payload = fab_find._build_search_payload(args) + + assert payload["search"] == "sales report" + assert "filter" not in payload + assert "pageSize" not in payload + + def test_query_with_limit(self): + """Test search with limit.""" + args = Namespace(query="data", type=None, limit=10) + payload = fab_find._build_search_payload(args) + + assert payload["search"] == "data" + assert payload["pageSize"] == 10 + + def test_query_with_single_type(self): + """Test search with single type filter (as list from nargs='+').""" + args = Namespace(query="report", type=["Report"], limit=None) + payload = fab_find._build_search_payload(args) + + assert payload["search"] == "report" + assert payload["filter"] == "Type eq 'Report'" + + def test_query_with_multiple_types(self): + """Test search with multiple type filters (as list from nargs='+').""" + args = Namespace(query="data", type=["Lakehouse", "Warehouse"], limit=None) + payload = fab_find._build_search_payload(args) + + assert payload["search"] == "data" + assert "Type eq 'Lakehouse'" in payload["filter"] + assert "Type eq 'Warehouse'" in payload["filter"] + assert " or " in payload["filter"] + + def test_query_with_all_options(self): + """Test search with all options.""" + args = Namespace(query="monthly", type=["Report", "Notebook"], limit=25) + payload = fab_find._build_search_payload(args) + + assert payload["search"] == "monthly" + assert payload["pageSize"] == 25 + assert "Type eq 'Report'" in payload["filter"] + assert "Type eq 'Notebook'" in payload["filter"] + + +class TestDisplayResults: + """Tests for _display_results function.""" + + @patch("fabric_cli.utils.fab_ui.print_grey") + @patch("fabric_cli.utils.fab_ui.print_output_format") + def test_display_results_with_items(self, mock_print_format, mock_print_grey): + """Test displaying results with items.""" + args = Namespace(long=False, output_format="text") + response = MagicMock() + response.text = json.dumps(SAMPLE_RESPONSE_WITH_RESULTS) + + fab_find._display_results(args, response) + + # Should print count message + mock_print_grey.assert_called() + # Find the count message in the call list + count_calls = [c[0][0] for c in mock_print_grey.call_args_list if "item(s) found" in c[0][0]] + assert len(count_calls) == 1 + count_call = count_calls[0] + assert "2 item(s) found" in count_call + assert "(more available)" in count_call # Has continuation token + + # Should call print_output_format with display items + mock_print_format.assert_called_once() + display_items = mock_print_format.call_args.kwargs["data"] + assert len(display_items) == 2 + assert display_items[0]["name"] == "Monthly Sales Revenue" + assert display_items[0]["type"] == "Report" + assert display_items[0]["workspace"] == "Sales Department" + assert display_items[0]["description"] == "Consolidated revenue report for the current fiscal year." + + @patch("fabric_cli.utils.fab_ui.print_grey") + @patch("fabric_cli.utils.fab_ui.print_output_format") + def test_display_results_empty(self, mock_print_format, mock_print_grey): + """Test displaying empty results.""" + args = Namespace(long=False, output_format="text") + response = MagicMock() + response.text = json.dumps(SAMPLE_RESPONSE_EMPTY) + + fab_find._display_results(args, response) + + # Should print "No items found" + mock_print_grey.assert_called_with("No items found.") + mock_print_format.assert_not_called() + + @patch("fabric_cli.utils.fab_ui.print_grey") + @patch("fabric_cli.utils.fab_ui.print_output_format") + def test_display_results_detailed(self, mock_print_format, mock_print_grey): + """Test displaying results with long flag.""" + args = Namespace(long=True, output_format="text") + response = MagicMock() + response.text = json.dumps(SAMPLE_RESPONSE_SINGLE) + + fab_find._display_results(args, response) + + # Should call print_output_format with detailed items + mock_print_format.assert_called_once() + display_items = mock_print_format.call_args.kwargs["data"] + assert len(display_items) == 1 + + # Detailed view should include id and workspace_id (snake_case) + item = display_items[0] + assert item["name"] == "Data Analysis" + assert item["type"] == "Notebook" + assert item["workspace"] == "Analytics Team" + assert item["description"] == "Notebook for data analysis tasks." + assert item["id"] == "abc12345-1234-5678-9abc-def012345678" + assert item["workspace_id"] == "workspace-id-123" + + @patch("fabric_cli.utils.fab_ui.print_grey") + @patch("fabric_cli.utils.fab_ui.print_output_format") + def test_display_results_no_continuation_token(self, mock_print_format, mock_print_grey): + """Test count message without continuation token.""" + args = Namespace(long=False, output_format="text") + response = MagicMock() + response.text = json.dumps(SAMPLE_RESPONSE_SINGLE) + + fab_find._display_results(args, response) + + # Should not show "(more available)" + # Find the count message in the call list + count_calls = [c[0][0] for c in mock_print_grey.call_args_list if "item(s) found" in c[0][0]] + assert len(count_calls) == 1 + count_call = count_calls[0] + assert "1 item(s) found" in count_call + assert "(more available)" not in count_call + + +class TestTypeValidation: + """Tests for type validation errors.""" + + def test_unsupported_type_raises_error(self): + """Test error for unsupported item types like Dashboard.""" + args = Namespace(query="test", type=["Dashboard"], limit=None) + + with pytest.raises(FabricCLIError) as exc_info: + fab_find._build_search_payload(args) + + assert "Dashboard" in str(exc_info.value) + assert "not searchable" in str(exc_info.value) + + def test_unknown_type_raises_error(self): + """Test error for unknown item types.""" + args = Namespace(query="test", type=["InvalidType"], limit=None) + + with pytest.raises(FabricCLIError) as exc_info: + fab_find._build_search_payload(args) + + assert "InvalidType" in str(exc_info.value) + assert "Unknown" in str(exc_info.value) + + def test_valid_type_builds_filter(self): + """Test valid type builds correct filter.""" + args = Namespace(query="test", type=["Report"], limit=None) + payload = fab_find._build_search_payload(args) + assert payload["filter"] == "Type eq 'Report'" + + def test_multiple_types_build_or_filter(self): + """Test multiple types build OR filter.""" + args = Namespace(query="test", type=["Report", "Lakehouse"], limit=None) + payload = fab_find._build_search_payload(args) + assert "Type eq 'Report'" in payload["filter"] + assert "Type eq 'Lakehouse'" in payload["filter"] + assert " or " in payload["filter"] + + def test_searchable_types_list(self): + """Test SEARCHABLE_ITEM_TYPES excludes unsupported types.""" + assert "Dashboard" not in fab_find.SEARCHABLE_ITEM_TYPES + assert "Dataflow" in fab_find.SEARCHABLE_ITEM_TYPES + assert "Report" in fab_find.SEARCHABLE_ITEM_TYPES + assert "Lakehouse" in fab_find.SEARCHABLE_ITEM_TYPES + + +class TestCompleteItemTypes: + """Tests for the item type completer.""" + + def test_complete_with_prefix(self): + """Test completion with a prefix.""" + result = fab_find.complete_item_types("Lake") + assert "Lakehouse" in result + + def test_complete_case_insensitive(self): + """Test completion is case-insensitive.""" + result = fab_find.complete_item_types("report") + assert "Report" in result + + def test_complete_multiple_matches(self): + """Test completion returns multiple matches.""" + result = fab_find.complete_item_types("Data") + assert "Datamart" in result + assert "DataPipeline" in result + + def test_complete_excludes_unsupported_types(self): + """Test completion excludes unsupported types like Dashboard.""" + result = fab_find.complete_item_types("Da") + assert "Dashboard" not in result + assert "Dataflow" in result + assert "Datamart" in result + + def test_complete_empty_prefix(self): + """Test completion with empty prefix returns all searchable types.""" + result = fab_find.complete_item_types("") + assert len(result) == len(fab_find.SEARCHABLE_ITEM_TYPES) + assert "Dashboard" not in result + + +class TestHandleResponse: + """Tests for _handle_response function.""" + + @patch("fabric_cli.commands.find.fab_find._display_results") + def test_success_response(self, mock_display): + """Test successful response handling.""" + args = Namespace(long=False) + response = MagicMock() + response.status_code = 200 + response.text = json.dumps(SAMPLE_RESPONSE_WITH_RESULTS) + + fab_find._handle_response(args, response) + + mock_display.assert_called_once_with(args, response) + + def test_error_response_raises_fabric_cli_error(self): + """Test error response raises FabricCLIError.""" + args = Namespace(long=False) + response = MagicMock() + response.status_code = 403 + response.text = json.dumps({ + "errorCode": "InsufficientScopes", + "message": "Missing required scope: Catalog.Read.All" + }) + + with pytest.raises(FabricCLIError) as exc_info: + fab_find._handle_response(args, response) + + assert "Catalog search failed" in str(exc_info.value) + assert "Missing required scope" in str(exc_info.value) + + def test_error_response_non_json(self): + """Test error response with non-JSON body.""" + args = Namespace(long=False) + response = MagicMock() + response.status_code = 500 + response.text = "Internal Server Error" + + with pytest.raises(FabricCLIError) as exc_info: + fab_find._handle_response(args, response) + + assert "Catalog search failed" in str(exc_info.value)