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..027e99a0 --- /dev/null +++ b/src/fabric_cli/commands/find/fab_find.py @@ -0,0 +1,351 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Find command for searching the Fabric catalog.""" + +import json +import shutil +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_jmespath as utils_jmespath +from fabric_cli.utils import fab_ui as utils_ui +from fabric_cli.utils import fab_util as utils + + +# 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] + + +@handle_exceptions() +@set_command_context() +def find_command(args: Namespace) -> None: + """Search the Fabric catalog for items.""" + if args.query: + args.query = utils.process_nargs(args.query) + + is_interactive = getattr(args, "fab_mode", None) == fab_constant.FAB_MODE_INTERACTIVE + payload = _build_search_payload(args, is_interactive) + + utils_ui.print_grey(f"Searching catalog for '{args.search_text}'...") + + if is_interactive: + _find_interactive(args, payload) + else: + _find_commandline(args, payload) + + +def _find_interactive(args: Namespace, payload: dict[str, Any]) -> None: + """Fetch and display results page by page, prompting between pages.""" + total_count = 0 + + while True: + response = catalog_api.catalog_search(args, payload) + _raise_on_error(response) + + results = json.loads(response.text) + items = results.get("value", []) + continuation_token = results.get("continuationToken", "") or None + + if not items and total_count == 0: + utils_ui.print_grey("No items found.") + return + + total_count += len(items) + has_more = continuation_token is not None + + count_msg = f"{len(items)} item(s) found" + (" (more available)" if has_more else "") + utils_ui.print_grey("") + utils_ui.print_grey(count_msg) + utils_ui.print_grey("") + + _display_items(args, items) + + if not has_more: + break + + try: + utils_ui.print_grey("") + input("Press any key to continue... (Ctrl+C to stop)") + except (KeyboardInterrupt, EOFError): + utils_ui.print_grey("") + break + + payload = {"continuationToken": continuation_token} + + if total_count > 0: + utils_ui.print_grey("") + utils_ui.print_grey(f"{total_count} total item(s)") + + +def _find_commandline(args: Namespace, payload: dict[str, Any]) -> None: + """Fetch all results across pages and display.""" + all_items: list[dict] = [] + + while True: + response = catalog_api.catalog_search(args, payload) + _raise_on_error(response) + + results = json.loads(response.text) + all_items.extend(results.get("value", [])) + + continuation_token = results.get("continuationToken", "") or None + if not continuation_token: + break + + payload = {"continuationToken": continuation_token} + + if not all_items: + utils_ui.print_grey("No items found.") + return + + utils_ui.print_grey("") + utils_ui.print_grey(f"{len(all_items)} item(s) found") + utils_ui.print_grey("") + + _display_items(args, all_items) + + +def _build_search_payload(args: Namespace, is_interactive: bool) -> dict[str, Any]: + """Build the search request payload from command arguments.""" + request: dict[str, Any] = {"search": args.search_text} + + # Interactive pages through 50 at a time; command-line fetches up to 1000 + request["pageSize"] = 50 if is_interactive else 1000 + + # Build type filter from -P params + type_filter = _parse_type_param(args) + if type_filter: + op = type_filter["operator"] + types = type_filter["values"] + + if op == "eq": + if len(types) == 1: + request["filter"] = f"Type eq '{types[0]}'" + else: + or_clause = " or ".join(f"Type eq '{t}'" for t in types) + request["filter"] = f"({or_clause})" + elif op == "ne": + if len(types) == 1: + request["filter"] = f"Type ne '{types[0]}'" + else: + ne_clause = " and ".join(f"Type ne '{t}'" for t in types) + request["filter"] = f"({ne_clause})" + + return request + + +def _parse_type_param(args: Namespace) -> dict[str, Any] | None: + """Extract and validate item types from -P params. + + Supports: + -P type=Report → eq single + -P type=[Report,Lakehouse] → eq multiple (or) + -P type!=Dashboard → ne single + -P type!=[Dashboard,Report] → ne multiple (and) + Legacy comma syntax also supported: -P type=Report,Lakehouse + + Returns dict with 'operator' ('eq' or 'ne') and 'values' list, or None. + """ + params = getattr(args, "params", None) + if not params: + return None + + # params is a list from argparse nargs="*", e.g. ["type=[Report,Lakehouse]"] + type_value = None + operator = "eq" + for param in params: + if "!=" in param: + key, value = param.split("!=", 1) + if key.lower() == "type": + type_value = value + operator = "ne" + else: + raise FabricCLIError( + f"'{key}' isn't a supported parameter. Supported: type", + fab_constant.ERROR_INVALID_INPUT, + ) + elif "=" in param: + key, value = param.split("=", 1) + if key.lower() == "type": + type_value = value + operator = "eq" + else: + raise FabricCLIError( + f"'{key}' isn't a supported parameter. Supported: type", + fab_constant.ERROR_INVALID_INPUT, + ) + else: + raise FabricCLIError( + f"Invalid parameter format: '{param}'. Use key=value or key!=value.", + fab_constant.ERROR_INVALID_INPUT, + ) + + if not type_value: + return None + + # Parse bracket syntax: [val1,val2] or plain: val1 or legacy: val1,val2 + if type_value.startswith("[") and type_value.endswith("]"): + inner = type_value[1:-1] + types = [t.strip() for t in inner.split(",") if t.strip()] + else: + types = [t.strip() for t in type_value.split(",") if t.strip()] + + # Validate and normalize types (case-insensitive matching) + all_types_lower = {t.lower(): t for t in ALL_ITEM_TYPES} + unsupported_lower = {t.lower() for t in UNSUPPORTED_ITEM_TYPES} + normalized = [] + for t in types: + t_lower = t.lower() + if t_lower in unsupported_lower and operator == "eq": + canonical = all_types_lower.get(t_lower, t) + raise FabricCLIError( + f"'{canonical}' isn't searchable via the catalog search API.", + fab_constant.ERROR_UNSUPPORTED_ITEM_TYPE, + ) + if t_lower not in all_types_lower: + # Suggest close matches instead of dumping the full list + close = [v for k, v in all_types_lower.items() if t_lower in k or k in t_lower] + hint = f" Did you mean {', '.join(close)}?" if close else " Use tab completion to see valid types." + raise FabricCLIError( + f"'{t}' isn't a recognized item type.{hint}", + fab_constant.ERROR_INVALID_ITEM_TYPE, + ) + normalized.append(all_types_lower[t_lower]) + + return {"operator": operator, "values": normalized} + + +def _raise_on_error(response) -> None: + """Raise FabricCLIError if the API response indicates failure.""" + 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, + ) + + +def _display_items(args: Namespace, items: list[dict]) -> None: + """Format and display search result items.""" + detailed = getattr(args, "long", False) + + if detailed: + 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"), + } + if item.get("description"): + entry["description"] = item.get("description") + display_items.append(entry) + else: + 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"), + } + if has_descriptions: + entry["description"] = item.get("description") or "" + display_items.append(entry) + + # Truncate descriptions to avoid table wrapping beyond terminal width + if has_descriptions: + _truncate_descriptions(display_items) + + # Apply JMESPath client-side filtering if -q/--query specified + if getattr(args, "query", None): + display_items = utils_jmespath.search(display_items, args.query) + + if detailed: + utils_ui.print_output_format(args, data=display_items, show_key_value_list=True) + else: + utils_ui.print_output_format(args, data=display_items, show_headers=True) + + +def _truncate_descriptions(items: list[dict]) -> None: + """Truncate description column so the table fits within terminal width.""" + term_width = shutil.get_terminal_size((120, 24)).columns + # Calculate width used by other columns (max value length + 2 padding + 1 gap each) + other_fields = ["name", "type", "workspace"] + used = sum( + max((len(str(item.get(f, ""))) for item in items), default=0) + 3 + for f in other_fields + ) + # Also account for "description" header length minimum + max_desc = max(term_width - used - 3, 20) + for item in items: + desc = item.get("description", "") + if len(desc) > max_desc: + item["description"] = desc[: max_desc - 1] + "…" 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..c9482619 --- /dev/null +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Parser for the find command.""" + +from argparse import Namespace, _SubParsersAction + +from fabric_cli.commands.find import fab_find as find +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 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' -P type=Lakehouse\n", + "# search for multiple item types (bracket syntax)", + "$ find 'dashboard' -P type=[Report,SemanticModel]\n", + "# exclude a type", + "$ find 'data' -P type!=Dashboard\n", + "# exclude multiple types", + "$ find 'data' -P type!=[Dashboard,Datamart]\n", + "# show detailed output with IDs", + "$ find 'sales' -l\n", + "# combine filters", + "$ find 'finance' -P type=[Warehouse,Lakehouse] -l\n", + "# filter results client-side with JMESPath", + "$ find 'sales' -q \"[?type=='Report']\"\n", + "# project specific fields", + "$ find 'data' -q \"[].{name: name, workspace: workspace}\"", + ] + + parser = subparsers.add_parser( + "find", + help=COMMAND_FIND_DESCRIPTION, + fab_examples=examples, + fab_learnmore=["_"], + ) + + parser.add_argument( + "search_text", + metavar="query", + help="Search text (matches display name, description, and workspace name)", + ) + parser.add_argument( + "-P", + "--params", + required=False, + metavar="", + nargs="*", + help="Parameters in key=value or key!=value format. Use brackets for multiple values: type=[Lakehouse,Notebook]. Use != to exclude: type!=Dashboard", + ) + parser.add_argument( + "-l", + "--long", + action="store_true", + help="Show detailed output. Optional", + ) + parser.add_argument( + "-q", + "--query", + required=False, + nargs="+", + help="JMESPath query to filter. Optional", + ) + + 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..c752ae41 --- /dev/null +++ b/tests/test_commands/find/test_find.py @@ -0,0 +1,406 @@ +# 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 import fab_constant +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_interactive(self): + """Test basic search query in interactive mode.""" + args = Namespace(search_text="sales report", params=None, query=None) + payload = fab_find._build_search_payload(args, is_interactive=True) + + assert payload["search"] == "sales report" + assert payload["pageSize"] == 50 + assert "filter" not in payload + + def test_basic_query_commandline(self): + """Test basic search query in command-line mode.""" + args = Namespace(search_text="sales report", params=None, query=None) + payload = fab_find._build_search_payload(args, is_interactive=False) + + assert payload["search"] == "sales report" + assert payload["pageSize"] == 1000 + assert "filter" not in payload + + def test_query_with_single_type(self): + """Test search with single type filter via -P.""" + args = Namespace(search_text="report", params=["type=Report"], query=None) + payload = fab_find._build_search_payload(args, is_interactive=False) + + assert payload["search"] == "report" + assert payload["filter"] == "Type eq 'Report'" + + def test_query_with_multiple_types(self): + """Test search with multiple type filters via -P bracket syntax.""" + args = Namespace(search_text="data", params=["type=[Lakehouse,Warehouse]"], query=None) + payload = fab_find._build_search_payload(args, is_interactive=False) + + assert payload["search"] == "data" + assert payload["filter"] == "(Type eq 'Lakehouse' or Type eq 'Warehouse')" + + def test_query_with_multiple_types_legacy_comma(self): + """Test search with multiple type filters via legacy comma syntax.""" + args = Namespace(search_text="data", params=["type=Lakehouse,Warehouse"], query=None) + payload = fab_find._build_search_payload(args, is_interactive=False) + + assert payload["search"] == "data" + assert payload["filter"] == "(Type eq 'Lakehouse' or Type eq 'Warehouse')" + + def test_query_with_ne_single_type(self): + """Test search with ne filter for single type.""" + args = Namespace(search_text="data", params=["type!=Dashboard"], query=None) + payload = fab_find._build_search_payload(args, is_interactive=False) + + assert payload["filter"] == "Type ne 'Dashboard'" + + def test_query_with_ne_multiple_types(self): + """Test search with ne filter for multiple types.""" + args = Namespace(search_text="data", params=["type!=[Dashboard,Datamart]"], query=None) + payload = fab_find._build_search_payload(args, is_interactive=False) + + assert payload["filter"] == "(Type ne 'Dashboard' and Type ne 'Datamart')" + + +class TestParseTypeParam: + """Tests for _parse_type_param function.""" + + def test_no_params(self): + """Test with no params.""" + args = Namespace(params=None) + assert fab_find._parse_type_param(args) is None + + def test_empty_params(self): + """Test with empty params list.""" + args = Namespace(params=[]) + assert fab_find._parse_type_param(args) is None + + def test_single_type(self): + """Test single type value.""" + args = Namespace(params=["type=Report"]) + result = fab_find._parse_type_param(args) + assert result == {"operator": "eq", "values": ["Report"]} + + def test_multiple_types_comma_separated(self): + """Test comma-separated types (legacy syntax).""" + args = Namespace(params=["type=Report,Lakehouse"]) + result = fab_find._parse_type_param(args) + assert result == {"operator": "eq", "values": ["Report", "Lakehouse"]} + + def test_multiple_types_bracket_syntax(self): + """Test bracket syntax for multiple types.""" + args = Namespace(params=["type=[Report,Lakehouse]"]) + result = fab_find._parse_type_param(args) + assert result == {"operator": "eq", "values": ["Report", "Lakehouse"]} + + def test_ne_single_type(self): + """Test ne operator with single type.""" + args = Namespace(params=["type!=Dashboard"]) + result = fab_find._parse_type_param(args) + assert result == {"operator": "ne", "values": ["Dashboard"]} + + def test_ne_multiple_types_bracket(self): + """Test ne operator with bracket syntax.""" + args = Namespace(params=["type!=[Dashboard,Datamart]"]) + result = fab_find._parse_type_param(args) + assert result == {"operator": "ne", "values": ["Dashboard", "Datamart"]} + + def test_ne_unsupported_type_allowed(self): + """Test ne with unsupported type (Dashboard) is allowed — excluding makes sense.""" + args = Namespace(params=["type!=Dashboard"]) + result = fab_find._parse_type_param(args) + assert result == {"operator": "ne", "values": ["Dashboard"]} + + def test_invalid_format_raises_error(self): + """Test invalid param format raises error.""" + args = Namespace(params=["notakeyvalue"]) + with pytest.raises(FabricCLIError) as exc_info: + fab_find._parse_type_param(args) + assert "Invalid parameter format" in str(exc_info.value) + + def test_unknown_param_raises_error(self): + """Test unknown param key raises error.""" + args = Namespace(params=["foo=bar"]) + with pytest.raises(FabricCLIError) as exc_info: + fab_find._parse_type_param(args) + assert "isn't a supported parameter" in str(exc_info.value) + + def test_unknown_param_ne_raises_error(self): + """Test unknown param key with ne raises error.""" + args = Namespace(params=["foo!=bar"]) + with pytest.raises(FabricCLIError) as exc_info: + fab_find._parse_type_param(args) + assert "isn't a supported parameter" in str(exc_info.value) + + def test_unsupported_type_eq_raises_error(self): + """Test error for unsupported item types like Dashboard with eq.""" + args = Namespace(params=["type=Dashboard"]) + with pytest.raises(FabricCLIError) as exc_info: + fab_find._parse_type_param(args) + assert "Dashboard" in str(exc_info.value) + assert "isn't searchable" in str(exc_info.value) + + def test_unknown_type_raises_error(self): + """Test error for unknown item types.""" + args = Namespace(params=["type=InvalidType"]) + with pytest.raises(FabricCLIError) as exc_info: + fab_find._parse_type_param(args) + assert "InvalidType" in str(exc_info.value) + assert "isn't a recognized item type" in str(exc_info.value) + + def test_unknown_type_ne_raises_error(self): + """Test error for unknown item types with ne operator.""" + args = Namespace(params=["type!=InvalidType"]) + with pytest.raises(FabricCLIError) as exc_info: + fab_find._parse_type_param(args) + assert "InvalidType" in str(exc_info.value) + assert "isn't a recognized item type" in str(exc_info.value) + + +class TestDisplayItems: + """Tests for _display_items function.""" + + @patch("fabric_cli.utils.fab_ui.print_output_format") + def test_display_items_table(self, mock_print_format): + """Test displaying items in table mode.""" + args = Namespace(long=False, output_format="text", query=None) + items = SAMPLE_RESPONSE_WITH_RESULTS["value"] + + fab_find._display_items(args, 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_output_format") + def test_display_items_detailed(self, mock_print_format): + """Test displaying items with long flag.""" + args = Namespace(long=True, output_format="text", query=None) + items = SAMPLE_RESPONSE_SINGLE["value"] + + fab_find._display_items(args, items) + + mock_print_format.assert_called_once() + display_items = mock_print_format.call_args.kwargs["data"] + assert len(display_items) == 1 + + 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_output_format") + @patch("fabric_cli.utils.fab_jmespath.search") + def test_display_items_with_jmespath(self, mock_jmespath, mock_print_format): + """Test JMESPath filtering is applied when -q is provided.""" + filtered = [{"name": "Monthly Sales Revenue", "type": "Report"}] + mock_jmespath.return_value = filtered + + args = Namespace(long=False, output_format="text", query="[?type=='Report']") + items = SAMPLE_RESPONSE_WITH_RESULTS["value"] + + fab_find._display_items(args, items) + + mock_jmespath.assert_called_once() + mock_print_format.assert_called_once() + display_items = mock_print_format.call_args.kwargs["data"] + assert display_items == filtered + + +class TestRaiseOnError: + """Tests for _raise_on_error function.""" + + def test_success_response(self): + """Test successful response does not raise.""" + response = MagicMock() + response.status_code = 200 + fab_find._raise_on_error(response) # Should not raise + + def test_error_response_raises_fabric_cli_error(self): + """Test error response raises FabricCLIError.""" + 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._raise_on_error(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.""" + response = MagicMock() + response.status_code = 500 + response.text = "Internal Server Error" + + with pytest.raises(FabricCLIError) as exc_info: + fab_find._raise_on_error(response) + + assert "Catalog search failed" in str(exc_info.value) + + +class TestFindCommandline: + """Tests for _find_commandline function.""" + + @patch("fabric_cli.utils.fab_ui.print_output_format") + @patch("fabric_cli.utils.fab_ui.print_grey") + @patch("fabric_cli.client.fab_api_catalog.catalog_search") + def test_displays_results(self, mock_search, mock_print_grey, mock_print_format): + """Test command-line mode displays results.""" + response = MagicMock() + response.status_code = 200 + response.text = json.dumps(SAMPLE_RESPONSE_SINGLE) + mock_search.return_value = response + + args = Namespace(long=False, output_format="text", query=None) + payload = {"search": "test", "pageSize": 1000} + + fab_find._find_commandline(args, payload) + + mock_search.assert_called_once() + mock_print_format.assert_called_once() + + @patch("fabric_cli.utils.fab_ui.print_grey") + @patch("fabric_cli.client.fab_api_catalog.catalog_search") + def test_empty_results(self, mock_search, mock_print_grey): + """Test command-line mode with no results.""" + response = MagicMock() + response.status_code = 200 + response.text = json.dumps(SAMPLE_RESPONSE_EMPTY) + mock_search.return_value = response + + args = Namespace(long=False, output_format="text", query=None) + payload = {"search": "nothing", "pageSize": 1000} + + fab_find._find_commandline(args, payload) + + mock_print_grey.assert_called_with("No items found.") + + +class TestFindInteractive: + """Tests for _find_interactive function.""" + + @patch("builtins.input", return_value="") + @patch("fabric_cli.utils.fab_ui.print_output_format") + @patch("fabric_cli.utils.fab_ui.print_grey") + @patch("fabric_cli.client.fab_api_catalog.catalog_search") + def test_pages_through_results(self, mock_search, mock_print_grey, mock_print_format, mock_input): + """Test interactive mode pages through multiple responses.""" + # First page has continuation token, second page does not + page1 = MagicMock() + page1.status_code = 200 + page1.text = json.dumps(SAMPLE_RESPONSE_WITH_RESULTS) + + page2 = MagicMock() + page2.status_code = 200 + page2.text = json.dumps(SAMPLE_RESPONSE_SINGLE) + + mock_search.side_effect = [page1, page2] + + args = Namespace(long=False, output_format="text", query=None) + payload = {"search": "sales", "pageSize": 50} + + fab_find._find_interactive(args, payload) + + assert mock_search.call_count == 2 + assert mock_print_format.call_count == 2 + mock_input.assert_called_once_with("Press any key to continue... (Ctrl+C to stop)") + + @patch("builtins.input", side_effect=KeyboardInterrupt) + @patch("fabric_cli.utils.fab_ui.print_output_format") + @patch("fabric_cli.utils.fab_ui.print_grey") + @patch("fabric_cli.client.fab_api_catalog.catalog_search") + def test_ctrl_c_stops_pagination(self, mock_search, mock_print_grey, mock_print_format, mock_input): + """Test Ctrl+C stops pagination.""" + response = MagicMock() + response.status_code = 200 + response.text = json.dumps(SAMPLE_RESPONSE_WITH_RESULTS) + mock_search.return_value = response + + args = Namespace(long=False, output_format="text", query=None) + payload = {"search": "sales", "pageSize": 50} + + fab_find._find_interactive(args, payload) + + # Should only fetch one page (stopped by Ctrl+C) + assert mock_search.call_count == 1 + assert mock_print_format.call_count == 1 + + +class TestSearchableItemTypes: + """Tests for item type lists.""" + + def test_searchable_types_excludes_unsupported(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