From 0582bb183b246e63c8382d7330856aaf93eae0fc Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Sun, 8 Feb 2026 21:46:40 +0200 Subject: [PATCH 01/21] Add fab find command for catalog search API Features: - Search across all workspaces by displayName, workspaceName, or description - Filter by item type with --type flag - Limit results with --limit flag - Detailed output with --detailed flag (includes id, workspaceId) - Custom endpoint support with --endpoint flag or FAB_CATALOG_ENDPOINT env var Output columns (default): name, type, workspace, description Output columns (detailed): + workspaceId, id Required scope: Catalog.Read.All Unsupported types: Dashboard, Dataflow, Scorecard Includes unit tests (12 tests passing) --- .../unreleased/added-20260209-171617.yaml | 6 + src/fabric_cli/client/fab_api_catalog.py | 92 +++++++ src/fabric_cli/commands/find/__init__.py | 2 + src/fabric_cli/commands/find/fab_find.py | 151 ++++++++++++ src/fabric_cli/core/fab_parser_setup.py | 2 + src/fabric_cli/parsers/fab_find_parser.py | 78 ++++++ tests/test_commands/find/__init__.py | 2 + tests/test_commands/find/test_find.py | 227 ++++++++++++++++++ 8 files changed, 560 insertions(+) create mode 100644 .changes/unreleased/added-20260209-171617.yaml create mode 100644 src/fabric_cli/client/fab_api_catalog.py create mode 100644 src/fabric_cli/commands/find/__init__.py create mode 100644 src/fabric_cli/commands/find/fab_find.py create mode 100644 src/fabric_cli/parsers/fab_find_parser.py create mode 100644 tests/test_commands/find/__init__.py create mode 100644 tests/test_commands/find/test_find.py 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..878a6d5f --- /dev/null +++ b/src/fabric_cli/client/fab_api_catalog.py @@ -0,0 +1,92 @@ +# 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 +""" + +import os +from argparse import Namespace + +from fabric_cli.client import fab_api_client as fabric_api +from fabric_cli.client.fab_api_types import ApiResponse + +# Environment variable to override the catalog search endpoint (for internal/daily testing) +ENV_CATALOG_ENDPOINT = "FAB_CATALOG_ENDPOINT" + +# Default: use standard Fabric API path +DEFAULT_CATALOG_URI = "catalog/search" + + +def catalog_search(args: Namespace, payload: str) -> 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: JSON string 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, Dataflow (Gen1), Dataflow (Gen2), Scorecard + + Environment Variables: + FAB_CATALOG_ENDPOINT: Override the catalog search endpoint URL for testing + (e.g., https://wabi-daily-us-east2-redirect.analysis.windows.net/v1/catalog/search) + """ + # Check for custom endpoint override (for daily/internal testing) + custom_endpoint = getattr(args, "endpoint", None) or os.environ.get(ENV_CATALOG_ENDPOINT) + + if custom_endpoint: + # Use custom endpoint directly via raw request + return _catalog_search_custom_endpoint(args, payload, custom_endpoint) + + # Standard Fabric API path + args.uri = DEFAULT_CATALOG_URI + args.method = "post" + return fabric_api.do_request(args, data=payload) + + +def _catalog_search_custom_endpoint(args: Namespace, payload: str, endpoint: str) -> ApiResponse: + """Make catalog search request to a custom endpoint (e.g., daily environment).""" + import requests + import json + import platform + + from fabric_cli.core import fab_constant + from fabric_cli.core.fab_auth import FabAuth + from fabric_cli.core.fab_context import Context as FabContext + from fabric_cli.client.fab_api_types import ApiResponse + + # Get token using Fabric scope + token = FabAuth().get_access_token(fab_constant.SCOPE_FABRIC_DEFAULT) + + # Build headers + ctxt_cmd = FabContext().command + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} ({ctxt_cmd}; {platform.system()}; {platform.machine()}; {platform.release()})", + } + + response = requests.post(endpoint, headers=headers, data=payload, timeout=240) + + return ApiResponse( + status_code=response.status_code, + text=response.text, + content=response.content, + headers=response.headers, + ) + 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..7fc3473d --- /dev/null +++ b/src/fabric_cli/commands/find/fab_find.py @@ -0,0 +1,151 @@ +# 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.fab_decorators import handle_exceptions, set_command_context +from fabric_cli.utils import fab_ui as utils_ui + + +# Supported item types for the catalog search API +SUPPORTED_ITEM_TYPES = [ + "Report", + "SemanticModel", + "PaginatedReport", + "Datamart", + "Lakehouse", + "Eventhouse", + "Environment", + "KQLDatabase", + "KQLQueryset", + "KQLDashboard", + "DataPipeline", + "Notebook", + "SparkJobDefinition", + "MLExperiment", + "MLModel", + "Warehouse", + "Eventstream", + "SQLEndpoint", + "MirroredWarehouse", + "MirroredDatabase", + "Reflex", + "GraphQLApi", + "MountedDataFactory", + "SQLDatabase", + "CopyJob", + "VariableLibrary", + "ApacheAirflowJob", + "WarehouseSnapshot", + "DigitalTwinBuilder", + "DigitalTwinBuilderFlow", + "MirroredAzureDatabricksCatalog", + "Map", + "AnomalyDetector", + "UserDataFunction", + "GraphModel", + "GraphQuerySet", + "SnowflakeDatabase", + "OperationsAgent", + "CosmosDBDatabase", + "Ontology", + "EventSchemaSet", +] + +# Types NOT supported by the catalog search API +UNSUPPORTED_ITEM_TYPES = [ + "Dashboard", + "Dataflow", # Gen1 and Gen2 + "Scorecard", +] + + +@handle_exceptions() +@set_command_context() +def find_command(args: Namespace) -> None: + """Search the Fabric catalog for items.""" + payload = _build_search_payload(args) + + utils_ui.print_grey(f"Searching catalog for '{args.query}'...") + response = catalog_api.catalog_search(args, payload) + + _display_results(args, response) + + +def _build_search_payload(args: Namespace) -> str: + """Build the search request payload from command arguments.""" + request: dict[str, Any] = {"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 = [t.strip() for t in args.type.split(",")] + # Validate types + for t in types: + if t not in SUPPORTED_ITEM_TYPES: + if t in UNSUPPORTED_ITEM_TYPES: + utils_ui.print_warning( + f"Type '{t}' is not supported by catalog search API" + ) + else: + utils_ui.print_warning(f"Unknown item type: '{t}'") + + filter_parts = [f"Type eq '{t}'" for t in types] + request["filter"] = " or ".join(filter_parts) + + return json.dumps(request) + + +def _display_results(args: Namespace, response) -> None: + """Format and display search results.""" + results = json.loads(response.text) + items = results.get("value", []) + + if not items: + utils_ui.print_grey("No items found.") + return + + # Add result count info + count = len(items) + has_more = results.get("continuationToken") is not None + count_msg = f"{count} item(s) found" + (" (more available)" if has_more else "") + utils_ui.print_grey(count_msg) + + # Check if detailed output is requested + detailed = getattr(args, "detailed", False) + + if detailed: + # Detailed output: show all fields including IDs + display_items = [ + { + "id": item.get("id"), + "name": item.get("displayName"), + "type": item.get("type"), + "workspaceId": item.get("workspaceId"), + "workspace": item.get("workspaceName"), + "description": item.get("description"), + } + for item in items + ] + else: + # Default output: compact view aligned with CLI path format + display_items = [ + { + "name": item.get("displayName"), + "type": item.get("type"), + "workspace": item.get("workspaceName"), + "description": item.get("description"), + } + for item in items + ] + + # Format output based on output_format setting + utils_ui.print_output_format(args, display_items) 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..aab2712c --- /dev/null +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -0,0 +1,78 @@ +# 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.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 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' --detailed\n", + "# combine filters", + "$ find 'finance' --type Warehouse,Lakehouse --limit 20", + ] + + parser = subparsers.add_parser( + "find", + help=COMMAND_FIND_DESCRIPTION, + fab_examples=examples, + fab_learnmore=["_"], + ) + + parser.add_argument( + "query", + help="Search text (matches display name, description, and workspace name)", + ) + parser.add_argument( + "--type", + metavar="", + help="Filter by item type(s), comma-separated. Examples: Report, Lakehouse, Warehouse, Notebook, DataPipeline", + ) + parser.add_argument( + "--limit", + metavar="", + type=int, + default=50, + help="Maximum number of results to return (default: 50)", + ) + parser.add_argument( + "--detailed", + action="store_true", + help="Show detailed output including item and workspace IDs", + ) + parser.add_argument( + "--endpoint", + metavar="", + help="Custom API endpoint URL (for internal testing). Can also set FAB_CATALOG_ENDPOINT env var.", + ) + + 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..49f2c6db --- /dev/null +++ b/tests/test_commands/find/test_find.py @@ -0,0 +1,227 @@ +# 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 + + +# 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) + result = json.loads(payload) + + assert result["search"] == "sales report" + assert "filter" not in result + assert "pageSize" not in result + + 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) + result = json.loads(payload) + + assert result["search"] == "data" + assert result["pageSize"] == 10 + + def test_query_with_single_type(self): + """Test search with single type filter.""" + args = Namespace(query="report", type="Report", limit=None) + payload = fab_find._build_search_payload(args) + result = json.loads(payload) + + assert result["search"] == "report" + assert result["filter"] == "Type eq 'Report'" + + def test_query_with_multiple_types(self): + """Test search with multiple type filters.""" + args = Namespace(query="data", type="Lakehouse,Warehouse", limit=None) + payload = fab_find._build_search_payload(args) + result = json.loads(payload) + + assert result["search"] == "data" + assert "Type eq 'Lakehouse'" in result["filter"] + assert "Type eq 'Warehouse'" in result["filter"] + assert " or " in result["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) + result = json.loads(payload) + + assert result["search"] == "monthly" + assert result["pageSize"] == 25 + assert "Type eq 'Report'" in result["filter"] + assert "Type eq 'Notebook'" in result["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(detailed=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() + count_call = mock_print_grey.call_args[0][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[0][1] + 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(detailed=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 detailed flag.""" + args = Namespace(detailed=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[0][1] + assert len(display_items) == 1 + + # Detailed view should include id and workspaceId + 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["workspaceId"] == "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(detailed=False, output_format="text") + response = MagicMock() + response.text = json.dumps(SAMPLE_RESPONSE_SINGLE) + + fab_find._display_results(args, response) + + # Should not show "(more available)" + count_call = mock_print_grey.call_args[0][0] + assert "1 item(s) found" in count_call + assert "(more available)" not in count_call + + +class TestTypeValidation: + """Tests for type validation warnings.""" + + @patch("fabric_cli.utils.fab_ui.print_warning") + def test_unsupported_type_warning(self, mock_print_warning): + """Test warning for unsupported item types.""" + args = Namespace(query="test", type="Dashboard", limit=None) + fab_find._build_search_payload(args) + + mock_print_warning.assert_called() + warning_msg = mock_print_warning.call_args[0][0] + assert "Dashboard" in warning_msg + assert "not supported" in warning_msg + + @patch("fabric_cli.utils.fab_ui.print_warning") + def test_unknown_type_warning(self, mock_print_warning): + """Test warning for unknown item types.""" + args = Namespace(query="test", type="InvalidType", limit=None) + fab_find._build_search_payload(args) + + mock_print_warning.assert_called() + warning_msg = mock_print_warning.call_args[0][0] + assert "InvalidType" in warning_msg + assert "Unknown" in warning_msg + + @patch("fabric_cli.utils.fab_ui.print_warning") + def test_valid_type_no_warning(self, mock_print_warning): + """Test no warning for valid item types.""" + args = Namespace(query="test", type="Report", limit=None) + fab_find._build_search_payload(args) + + mock_print_warning.assert_not_called() From c72b18c7360127291a99f24cbe20c2a3fe2684d2 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 08:48:36 +0200 Subject: [PATCH 02/21] feat(find): address feedback - nargs+, remove endpoint, add error handling Changes based on issue #172 feedback: - Changed --type from comma-separated to nargs='+' (space-separated) - Removed --endpoint flag (use internal mechanism instead) - Added FabricCLIError for invalid/unsupported item types - Added error handling for API failures - Updated tests to match new patterns (15 tests passing) --- src/fabric_cli/client/fab_api_catalog.py | 53 +--------- src/fabric_cli/commands/find/fab_find.py | 42 ++++++-- src/fabric_cli/parsers/fab_find_parser.py | 14 +-- tests/test_commands/find/test_find.py | 115 +++++++++++++++------- 4 files changed, 120 insertions(+), 104 deletions(-) diff --git a/src/fabric_cli/client/fab_api_catalog.py b/src/fabric_cli/client/fab_api_catalog.py index 878a6d5f..9ef91ac0 100644 --- a/src/fabric_cli/client/fab_api_catalog.py +++ b/src/fabric_cli/client/fab_api_catalog.py @@ -7,18 +7,11 @@ Required Scope: Catalog.Read.All """ -import os from argparse import Namespace from fabric_cli.client import fab_api_client as fabric_api from fabric_cli.client.fab_api_types import ApiResponse -# Environment variable to override the catalog search endpoint (for internal/daily testing) -ENV_CATALOG_ENDPOINT = "FAB_CATALOG_ENDPOINT" - -# Default: use standard Fabric API path -DEFAULT_CATALOG_URI = "catalog/search" - def catalog_search(args: Namespace, payload: str) -> ApiResponse: """Search the Fabric catalog for items. @@ -41,52 +34,8 @@ def catalog_search(args: Namespace, payload: str) -> ApiResponse: Note: The following item types are NOT searchable via this API: Dashboard, Dataflow (Gen1), Dataflow (Gen2), Scorecard - - Environment Variables: - FAB_CATALOG_ENDPOINT: Override the catalog search endpoint URL for testing - (e.g., https://wabi-daily-us-east2-redirect.analysis.windows.net/v1/catalog/search) """ - # Check for custom endpoint override (for daily/internal testing) - custom_endpoint = getattr(args, "endpoint", None) or os.environ.get(ENV_CATALOG_ENDPOINT) - - if custom_endpoint: - # Use custom endpoint directly via raw request - return _catalog_search_custom_endpoint(args, payload, custom_endpoint) - - # Standard Fabric API path - args.uri = DEFAULT_CATALOG_URI + args.uri = "catalog/search" args.method = "post" return fabric_api.do_request(args, data=payload) - -def _catalog_search_custom_endpoint(args: Namespace, payload: str, endpoint: str) -> ApiResponse: - """Make catalog search request to a custom endpoint (e.g., daily environment).""" - import requests - import json - import platform - - from fabric_cli.core import fab_constant - from fabric_cli.core.fab_auth import FabAuth - from fabric_cli.core.fab_context import Context as FabContext - from fabric_cli.client.fab_api_types import ApiResponse - - # Get token using Fabric scope - token = FabAuth().get_access_token(fab_constant.SCOPE_FABRIC_DEFAULT) - - # Build headers - ctxt_cmd = FabContext().command - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "User-Agent": f"{fab_constant.API_USER_AGENT}/{fab_constant.FAB_VERSION} ({ctxt_cmd}; {platform.system()}; {platform.machine()}; {platform.release()})", - } - - response = requests.post(endpoint, headers=headers, data=payload, timeout=240) - - return ApiResponse( - status_code=response.status_code, - text=response.text, - content=response.content, - headers=response.headers, - ) - diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 7fc3473d..c2ada6d3 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -8,7 +8,9 @@ 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 @@ -74,7 +76,7 @@ def find_command(args: Namespace) -> None: utils_ui.print_grey(f"Searching catalog for '{args.query}'...") response = catalog_api.catalog_search(args, payload) - _display_results(args, response) + _handle_response(args, response) def _build_search_payload(args: Namespace) -> str: @@ -85,18 +87,24 @@ def _build_search_payload(args: Namespace) -> str: if hasattr(args, "limit") and args.limit: request["pageSize"] = args.limit - # Build type filter if specified + # Build type filter if specified (now a list from nargs="+") if hasattr(args, "type") and args.type: - types = [t.strip() for t in args.type.split(",")] + types = args.type # Already a list from argparse nargs="+" # Validate types for t in types: if t not in SUPPORTED_ITEM_TYPES: if t in UNSUPPORTED_ITEM_TYPES: - utils_ui.print_warning( - f"Type '{t}' is not supported by catalog search API" + raise FabricCLIError( + f"Item type '{t}' is not supported by catalog search API. " + f"Unsupported types: {', '.join(UNSUPPORTED_ITEM_TYPES)}", + fab_constant.ERROR_UNSUPPORTED_ITEM_TYPE, ) else: - utils_ui.print_warning(f"Unknown item type: '{t}'") + raise FabricCLIError( + f"Unknown item type: '{t}'. " + f"See supported types at https://aka.ms/fabric-cli", + fab_constant.ERROR_INVALID_ITEM_TYPE, + ) filter_parts = [f"Type eq '{t}'" for t in types] request["filter"] = " or ".join(filter_parts) @@ -104,6 +112,26 @@ def _build_search_payload(args: Namespace) -> str: return json.dumps(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) @@ -147,5 +175,5 @@ def _display_results(args: Namespace, response) -> None: for item in items ] - # Format output based on output_format setting + # Format output based on output_format setting (supports --output_format json|text) utils_ui.print_output_format(args, display_items) diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py index aab2712c..84855e33 100644 --- a/src/fabric_cli/parsers/fab_find_parser.py +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -28,11 +28,11 @@ def register_parser(subparsers: _SubParsersAction) -> None: "# search for lakehouses only", "$ find 'data' --type Lakehouse\n", "# search for multiple item types", - "$ find 'dashboard' --type Report,SemanticModel\n", + "$ find 'dashboard' --type Report SemanticModel\n", "# show detailed output with IDs", "$ find 'sales' --detailed\n", "# combine filters", - "$ find 'finance' --type Warehouse,Lakehouse --limit 20", + "$ find 'finance' --type Warehouse Lakehouse --limit 20", ] parser = subparsers.add_parser( @@ -48,8 +48,9 @@ def register_parser(subparsers: _SubParsersAction) -> None: ) parser.add_argument( "--type", - metavar="", - help="Filter by item type(s), comma-separated. Examples: Report, Lakehouse, Warehouse, Notebook, DataPipeline", + nargs="+", + metavar="TYPE", + help="Filter by item type(s). Examples: Report, Lakehouse, Warehouse, Notebook, DataPipeline", ) parser.add_argument( "--limit", @@ -63,11 +64,6 @@ def register_parser(subparsers: _SubParsersAction) -> None: action="store_true", help="Show detailed output including item and workspace IDs", ) - parser.add_argument( - "--endpoint", - metavar="", - help="Custom API endpoint URL (for internal testing). Can also set FAB_CATALOG_ENDPOINT env var.", - ) parser.usage = f"{utils_error_parser.get_usage_prog(parser)}" parser.set_defaults(func=find.find_command) diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index 49f2c6db..6a65754c 100644 --- a/tests/test_commands/find/test_find.py +++ b/tests/test_commands/find/test_find.py @@ -11,6 +11,7 @@ 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 @@ -80,8 +81,8 @@ def test_query_with_limit(self): assert result["pageSize"] == 10 def test_query_with_single_type(self): - """Test search with single type filter.""" - args = Namespace(query="report", type="Report", limit=None) + """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) result = json.loads(payload) @@ -89,8 +90,8 @@ def test_query_with_single_type(self): assert result["filter"] == "Type eq 'Report'" def test_query_with_multiple_types(self): - """Test search with multiple type filters.""" - args = Namespace(query="data", type="Lakehouse,Warehouse", limit=None) + """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) result = json.loads(payload) @@ -101,7 +102,7 @@ def test_query_with_multiple_types(self): def test_query_with_all_options(self): """Test search with all options.""" - args = Namespace(query="monthly", type="Report,Notebook", limit=25) + args = Namespace(query="monthly", type=["Report", "Notebook"], limit=25) payload = fab_find._build_search_payload(args) result = json.loads(payload) @@ -194,34 +195,76 @@ def test_display_results_no_continuation_token(self, mock_print_format, mock_pri class TestTypeValidation: - """Tests for type validation warnings.""" - - @patch("fabric_cli.utils.fab_ui.print_warning") - def test_unsupported_type_warning(self, mock_print_warning): - """Test warning for unsupported item types.""" - args = Namespace(query="test", type="Dashboard", limit=None) - fab_find._build_search_payload(args) - - mock_print_warning.assert_called() - warning_msg = mock_print_warning.call_args[0][0] - assert "Dashboard" in warning_msg - assert "not supported" in warning_msg - - @patch("fabric_cli.utils.fab_ui.print_warning") - def test_unknown_type_warning(self, mock_print_warning): - """Test warning for unknown item types.""" - args = Namespace(query="test", type="InvalidType", limit=None) - fab_find._build_search_payload(args) - - mock_print_warning.assert_called() - warning_msg = mock_print_warning.call_args[0][0] - assert "InvalidType" in warning_msg - assert "Unknown" in warning_msg - - @patch("fabric_cli.utils.fab_ui.print_warning") - def test_valid_type_no_warning(self, mock_print_warning): - """Test no warning for valid item types.""" - args = Namespace(query="test", type="Report", limit=None) - fab_find._build_search_payload(args) - - mock_print_warning.assert_not_called() + """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 supported" 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_no_error(self): + """Test no error for valid item types.""" + args = Namespace(query="test", type=["Report"], limit=None) + # Should not raise + payload = fab_find._build_search_payload(args) + result = json.loads(payload) + assert result["filter"] == "Type eq 'Report'" + + +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(detailed=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(detailed=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(detailed=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) From 6b8c5f1918018cbd449c687ed775d4cb9778349e Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 08:54:20 +0200 Subject: [PATCH 03/21] feat(find): add tab-completion for --type flag - Added complete_item_types() completer for searchable types - Tab completion excludes unsupported types (Dashboard, Dataflow, Scorecard) - Restored unsupported type validation with clear error message - Updated ALL_ITEM_TYPES list from official API spec - Added SEARCHABLE_ITEM_TYPES for valid filter types - 20 tests passing --- src/fabric_cli/commands/find/fab_find.py | 106 ++++++++++++---------- src/fabric_cli/parsers/fab_find_parser.py | 5 +- tests/test_commands/find/test_find.py | 35 ++++++- 3 files changed, 96 insertions(+), 50 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index c2ada6d3..243fa1be 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -14,58 +14,70 @@ from fabric_cli.utils import fab_ui as utils_ui -# Supported item types for the catalog search API -SUPPORTED_ITEM_TYPES = [ - "Report", - "SemanticModel", - "PaginatedReport", +# All Fabric item types (from API spec, alphabetically sorted) +ALL_ITEM_TYPES = [ + "AnomalyDetector", + "ApacheAirflowJob", + "CopyJob", + "CosmosDBDatabase", + "Dashboard", + "Dataflow", "Datamart", - "Lakehouse", - "Eventhouse", + "DataPipeline", + "DigitalTwinBuilder", + "DigitalTwinBuilderFlow", "Environment", + "Eventhouse", + "EventSchemaSet", + "Eventstream", + "GraphModel", + "GraphQLApi", + "GraphQuerySet", + "KQLDashboard", "KQLDatabase", "KQLQueryset", - "KQLDashboard", - "DataPipeline", - "Notebook", - "SparkJobDefinition", + "Lakehouse", + "Map", + "MirroredAzureDatabricksCatalog", + "MirroredDatabase", + "MirroredWarehouse", "MLExperiment", "MLModel", - "Warehouse", - "Eventstream", - "SQLEndpoint", - "MirroredWarehouse", - "MirroredDatabase", - "Reflex", - "GraphQLApi", "MountedDataFactory", + "Notebook", + "Ontology", + "OperationsAgent", + "PaginatedReport", + "Reflex", + "Report", + "SemanticModel", + "SnowflakeDatabase", + "SparkJobDefinition", "SQLDatabase", - "CopyJob", + "SQLEndpoint", + "UserDataFunction", "VariableLibrary", - "ApacheAirflowJob", + "Warehouse", "WarehouseSnapshot", - "DigitalTwinBuilder", - "DigitalTwinBuilderFlow", - "MirroredAzureDatabricksCatalog", - "Map", - "AnomalyDetector", - "UserDataFunction", - "GraphModel", - "GraphQuerySet", - "SnowflakeDatabase", - "OperationsAgent", - "CosmosDBDatabase", - "Ontology", - "EventSchemaSet", ] -# Types NOT supported by the catalog search API +# Types that exist in Fabric but are NOT searchable via the Catalog Search API UNSUPPORTED_ITEM_TYPES = [ "Dashboard", - "Dataflow", # Gen1 and Gen2 + "Dataflow", "Scorecard", ] +# 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() @@ -92,19 +104,17 @@ def _build_search_payload(args: Namespace) -> str: types = args.type # Already a list from argparse nargs="+" # Validate types for t in types: - if t not in SUPPORTED_ITEM_TYPES: - if t in UNSUPPORTED_ITEM_TYPES: - raise FabricCLIError( - f"Item type '{t}' is not supported by catalog search API. " - f"Unsupported types: {', '.join(UNSUPPORTED_ITEM_TYPES)}", - fab_constant.ERROR_UNSUPPORTED_ITEM_TYPE, - ) - else: - raise FabricCLIError( - f"Unknown item type: '{t}'. " - f"See supported types at https://aka.ms/fabric-cli", - fab_constant.ERROR_INVALID_ITEM_TYPE, - ) + 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 ALL_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) diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py index 84855e33..d5d0a4a7 100644 --- a/src/fabric_cli/parsers/fab_find_parser.py +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -46,12 +46,15 @@ def register_parser(subparsers: _SubParsersAction) -> None: "query", help="Search text (matches display name, description, and workspace name)", ) - parser.add_argument( + type_arg = parser.add_argument( "--type", nargs="+", metavar="TYPE", help="Filter by item type(s). Examples: Report, Lakehouse, Warehouse, Notebook, DataPipeline", ) + # Add tab-completion for item types + type_arg.completer = find.complete_item_types + parser.add_argument( "--limit", metavar="", diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index 6a65754c..d462c835 100644 --- a/tests/test_commands/find/test_find.py +++ b/tests/test_commands/find/test_find.py @@ -205,7 +205,7 @@ def test_unsupported_type_raises_error(self): fab_find._build_search_payload(args) assert "Dashboard" in str(exc_info.value) - assert "not supported" 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.""" @@ -226,6 +226,39 @@ def test_valid_type_no_error(self): assert result["filter"] == "Type eq 'Report'" +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" not 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.""" From d2cac8c6f4677f104b153b574b03aa74ce6a7c96 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 09:18:21 +0200 Subject: [PATCH 04/21] feat(find): add --limit validation (1-1000) --- src/fabric_cli/parsers/fab_find_parser.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py index d5d0a4a7..a7b5eceb 100644 --- a/src/fabric_cli/parsers/fab_find_parser.py +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -3,6 +3,7 @@ """Parser for the find command.""" +import argparse from argparse import Namespace, _SubParsersAction from fabric_cli.commands.find import fab_find as find @@ -20,6 +21,17 @@ } +def _limit_type(value: str) -> int: + """Validate --limit 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 = [ @@ -57,10 +69,10 @@ def register_parser(subparsers: _SubParsersAction) -> None: parser.add_argument( "--limit", - metavar="", - type=int, + metavar="N", + type=_limit_type, default=50, - help="Maximum number of results to return (default: 50)", + help="Maximum number of results to return (1-1000, default: 50)", ) parser.add_argument( "--detailed", From b732c0b521a649efbd4909ebf609680446caaddc Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 09:41:39 +0200 Subject: [PATCH 05/21] feat(find): use custom validation for cleaner error messages - Keep tab-completion for --type flag - Custom FabricCLIError for unsupported types (Dashboard, Dataflow, Scorecard) - Custom FabricCLIError for unknown types - Cleaner error messages vs argparse choices listing all 40+ types - 22 tests passing --- src/fabric_cli/commands/find/fab_find.py | 4 ++-- src/fabric_cli/parsers/fab_find_parser.py | 2 +- tests/test_commands/find/test_find.py | 21 ++++++++++++++++++--- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 243fa1be..cee33b22 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -99,7 +99,7 @@ def _build_search_payload(args: Namespace) -> str: if hasattr(args, "limit") and args.limit: request["pageSize"] = args.limit - # Build type filter if specified (now a list from nargs="+") + # Build type filter if specified if hasattr(args, "type") and args.type: types = args.type # Already a list from argparse nargs="+" # Validate types @@ -110,7 +110,7 @@ def _build_search_payload(args: Namespace) -> str: f"Unsupported types: {', '.join(UNSUPPORTED_ITEM_TYPES)}", fab_constant.ERROR_UNSUPPORTED_ITEM_TYPE, ) - if t not in ALL_ITEM_TYPES: + 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, diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py index a7b5eceb..c78e338c 100644 --- a/src/fabric_cli/parsers/fab_find_parser.py +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -62,7 +62,7 @@ def register_parser(subparsers: _SubParsersAction) -> None: "--type", nargs="+", metavar="TYPE", - help="Filter by item type(s). Examples: Report, Lakehouse, Warehouse, Notebook, DataPipeline", + 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 diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index d462c835..cf19403a 100644 --- a/tests/test_commands/find/test_find.py +++ b/tests/test_commands/find/test_find.py @@ -217,14 +217,29 @@ def test_unknown_type_raises_error(self): assert "InvalidType" in str(exc_info.value) assert "Unknown" in str(exc_info.value) - def test_valid_type_no_error(self): - """Test no error for valid item types.""" + def test_valid_type_builds_filter(self): + """Test valid type builds correct filter.""" args = Namespace(query="test", type=["Report"], limit=None) - # Should not raise payload = fab_find._build_search_payload(args) result = json.loads(payload) assert result["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) + result = json.loads(payload) + assert "Type eq 'Report'" in result["filter"] + assert "Type eq 'Lakehouse'" in result["filter"] + assert " or " in result["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" not 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.""" From fb3871b657eed1759f09eeef9f71d7c0082849c5 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 20:11:45 +0200 Subject: [PATCH 06/21] fix(find): fix API integration issues - Changed from data= to json= for request payload - Added raw_response=True to avoid auto-pagination hanging - Added fallback from displayName to name (API bug workaround) - Updated tests to use dict instead of JSON string - Successfully tested against dailyapi.fabric.microsoft.com --- src/fabric_cli/client/fab_api_catalog.py | 8 +++-- src/fabric_cli/commands/find/fab_find.py | 8 ++--- tests/test_commands/find/test_find.py | 45 ++++++++++-------------- 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/src/fabric_cli/client/fab_api_catalog.py b/src/fabric_cli/client/fab_api_catalog.py index 9ef91ac0..046ab038 100644 --- a/src/fabric_cli/client/fab_api_catalog.py +++ b/src/fabric_cli/client/fab_api_catalog.py @@ -13,14 +13,14 @@ from fabric_cli.client.fab_api_types import ApiResponse -def catalog_search(args: Namespace, payload: str) -> 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: JSON string with search request body: + 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 @@ -37,5 +37,7 @@ def catalog_search(args: Namespace, payload: str) -> ApiResponse: """ args.uri = "catalog/search" args.method = "post" - return fabric_api.do_request(args, data=payload) + # 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/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index cee33b22..da947d6d 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -91,7 +91,7 @@ def find_command(args: Namespace) -> None: _handle_response(args, response) -def _build_search_payload(args: Namespace) -> str: +def _build_search_payload(args: Namespace) -> dict[str, Any]: """Build the search request payload from command arguments.""" request: dict[str, Any] = {"search": args.query} @@ -119,7 +119,7 @@ def _build_search_payload(args: Namespace) -> str: filter_parts = [f"Type eq '{t}'" for t in types] request["filter"] = " or ".join(filter_parts) - return json.dumps(request) + return request def _handle_response(args: Namespace, response) -> None: @@ -165,7 +165,7 @@ def _display_results(args: Namespace, response) -> None: display_items = [ { "id": item.get("id"), - "name": item.get("displayName"), + "name": item.get("displayName") or item.get("name"), "type": item.get("type"), "workspaceId": item.get("workspaceId"), "workspace": item.get("workspaceName"), @@ -177,7 +177,7 @@ def _display_results(args: Namespace, response) -> None: # Default output: compact view aligned with CLI path format display_items = [ { - "name": item.get("displayName"), + "name": item.get("displayName") or item.get("name"), "type": item.get("type"), "workspace": item.get("workspaceName"), "description": item.get("description"), diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index cf19403a..f2e3b88e 100644 --- a/tests/test_commands/find/test_find.py +++ b/tests/test_commands/find/test_find.py @@ -65,51 +65,46 @@ 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) - result = json.loads(payload) - assert result["search"] == "sales report" - assert "filter" not in result - assert "pageSize" not in result + 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) - result = json.loads(payload) - assert result["search"] == "data" - assert result["pageSize"] == 10 + 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) - result = json.loads(payload) - assert result["search"] == "report" - assert result["filter"] == "Type eq 'Report'" + 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) - result = json.loads(payload) - assert result["search"] == "data" - assert "Type eq 'Lakehouse'" in result["filter"] - assert "Type eq 'Warehouse'" in result["filter"] - assert " or " in result["filter"] + 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) - result = json.loads(payload) - assert result["search"] == "monthly" - assert result["pageSize"] == 25 - assert "Type eq 'Report'" in result["filter"] - assert "Type eq 'Notebook'" in result["filter"] + assert payload["search"] == "monthly" + assert payload["pageSize"] == 25 + assert "Type eq 'Report'" in payload["filter"] + assert "Type eq 'Notebook'" in payload["filter"] class TestDisplayResults: @@ -221,17 +216,15 @@ 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) - result = json.loads(payload) - assert result["filter"] == "Type eq 'Report'" + 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) - result = json.loads(payload) - assert "Type eq 'Report'" in result["filter"] - assert "Type eq 'Lakehouse'" in result["filter"] - assert " or " in result["filter"] + 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.""" From 30e34a5318baed31c8a4390b8d2e9d9ff6f0aea3 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 20:20:40 +0200 Subject: [PATCH 07/21] fix(find): correct print_output_format call for table display --- src/fabric_cli/commands/find/fab_find.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index da947d6d..ee7dfa58 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -186,4 +186,4 @@ def _display_results(args: Namespace, response) -> None: ] # Format output based on output_format setting (supports --output_format json|text) - utils_ui.print_output_format(args, display_items) + utils_ui.print_output_format(args, data=display_items, show_headers=True) From 56f8a6b72955f4bf99682d6c18e3f685e38d11b7 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 20:55:32 +0200 Subject: [PATCH 08/21] feat(find): add key-value layout for --detailed flag --- src/fabric_cli/commands/find/fab_find.py | 14 +++++++------- tests/test_commands/find/test_find.py | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index ee7dfa58..bd04c3a3 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -161,20 +161,22 @@ def _display_results(args: Namespace, response) -> None: detailed = getattr(args, "detailed", False) if detailed: - # Detailed output: show all fields including IDs + # Detailed output: vertical key-value list with all fields + # Use snake_case keys for proper Title Case formatting by fab_ui display_items = [ { "id": item.get("id"), "name": item.get("displayName") or item.get("name"), "type": item.get("type"), - "workspaceId": item.get("workspaceId"), + "workspace_id": item.get("workspaceId"), "workspace": item.get("workspaceName"), - "description": item.get("description"), + "description": item.get("description") or "", } for item in items ] + utils_ui.print_output_format(args, data=display_items, show_key_value_list=True) else: - # Default output: compact view aligned with CLI path format + # Default output: compact table view display_items = [ { "name": item.get("displayName") or item.get("name"), @@ -184,6 +186,4 @@ def _display_results(args: Namespace, response) -> None: } for item in items ] - - # Format output based on output_format setting (supports --output_format json|text) - utils_ui.print_output_format(args, data=display_items, show_headers=True) + utils_ui.print_output_format(args, data=display_items, show_headers=True) diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index f2e3b88e..9cf9f7d4 100644 --- a/tests/test_commands/find/test_find.py +++ b/tests/test_commands/find/test_find.py @@ -128,7 +128,7 @@ def test_display_results_with_items(self, mock_print_format, mock_print_grey): # Should call print_output_format with display items mock_print_format.assert_called_once() - display_items = mock_print_format.call_args[0][1] + 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" @@ -161,17 +161,17 @@ def test_display_results_detailed(self, mock_print_format, mock_print_grey): # Should call print_output_format with detailed items mock_print_format.assert_called_once() - display_items = mock_print_format.call_args[0][1] + display_items = mock_print_format.call_args.kwargs["data"] assert len(display_items) == 1 - # Detailed view should include id and workspaceId + # 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["workspaceId"] == "workspace-id-123" + assert item["workspace_id"] == "workspace-id-123" @patch("fabric_cli.utils.fab_ui.print_grey") @patch("fabric_cli.utils.fab_ui.print_output_format") From 7c231bc77d465caeae983e5a4637001a6d5850e6 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 20:59:21 +0200 Subject: [PATCH 09/21] refactor(find): change --detailed to -l/--long to match CLI convention --- src/fabric_cli/commands/find/fab_find.py | 2 +- src/fabric_cli/parsers/fab_find_parser.py | 7 ++++--- tests/test_commands/find/test_find.py | 16 ++++++++-------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index bd04c3a3..53c3ffae 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -158,7 +158,7 @@ def _display_results(args: Namespace, response) -> None: utils_ui.print_grey(count_msg) # Check if detailed output is requested - detailed = getattr(args, "detailed", False) + detailed = getattr(args, "long", False) if detailed: # Detailed output: vertical key-value list with all fields diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py index c78e338c..c3267e57 100644 --- a/src/fabric_cli/parsers/fab_find_parser.py +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -42,7 +42,7 @@ def register_parser(subparsers: _SubParsersAction) -> None: "# search for multiple item types", "$ find 'dashboard' --type Report SemanticModel\n", "# show detailed output with IDs", - "$ find 'sales' --detailed\n", + "$ find 'sales' -l\n", "# combine filters", "$ find 'finance' --type Warehouse Lakehouse --limit 20", ] @@ -75,9 +75,10 @@ def register_parser(subparsers: _SubParsersAction) -> None: help="Maximum number of results to return (1-1000, default: 50)", ) parser.add_argument( - "--detailed", + "-l", + "--long", action="store_true", - help="Show detailed output including item and workspace IDs", + help="Show detailed output. Optional", ) parser.usage = f"{utils_error_parser.get_usage_prog(parser)}" diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index 9cf9f7d4..4166fe9f 100644 --- a/tests/test_commands/find/test_find.py +++ b/tests/test_commands/find/test_find.py @@ -114,7 +114,7 @@ class TestDisplayResults: @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(detailed=False, output_format="text") + args = Namespace(long=False, output_format="text") response = MagicMock() response.text = json.dumps(SAMPLE_RESPONSE_WITH_RESULTS) @@ -139,7 +139,7 @@ def test_display_results_with_items(self, mock_print_format, mock_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(detailed=False, output_format="text") + args = Namespace(long=False, output_format="text") response = MagicMock() response.text = json.dumps(SAMPLE_RESPONSE_EMPTY) @@ -152,8 +152,8 @@ def test_display_results_empty(self, mock_print_format, mock_print_grey): @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 detailed flag.""" - args = Namespace(detailed=True, output_format="text") + """Test displaying results with long flag.""" + args = Namespace(long=True, output_format="text") response = MagicMock() response.text = json.dumps(SAMPLE_RESPONSE_SINGLE) @@ -177,7 +177,7 @@ def test_display_results_detailed(self, mock_print_format, mock_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(detailed=False, output_format="text") + args = Namespace(long=False, output_format="text") response = MagicMock() response.text = json.dumps(SAMPLE_RESPONSE_SINGLE) @@ -273,7 +273,7 @@ class TestHandleResponse: @patch("fabric_cli.commands.find.fab_find._display_results") def test_success_response(self, mock_display): """Test successful response handling.""" - args = Namespace(detailed=False) + args = Namespace(long=False) response = MagicMock() response.status_code = 200 response.text = json.dumps(SAMPLE_RESPONSE_WITH_RESULTS) @@ -284,7 +284,7 @@ def test_success_response(self, mock_display): def test_error_response_raises_fabric_cli_error(self): """Test error response raises FabricCLIError.""" - args = Namespace(detailed=False) + args = Namespace(long=False) response = MagicMock() response.status_code = 403 response.text = json.dumps({ @@ -300,7 +300,7 @@ def test_error_response_raises_fabric_cli_error(self): def test_error_response_non_json(self): """Test error response with non-JSON body.""" - args = Namespace(detailed=False) + args = Namespace(long=False) response = MagicMock() response.status_code = 500 response.text = "Internal Server Error" From fb641884d010a61abae568688e9da254668d4e44 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 21:11:59 +0200 Subject: [PATCH 10/21] feat(find): hide empty keys in long output --- src/fabric_cli/commands/find/fab_find.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 53c3ffae..4cf80625 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -163,17 +163,20 @@ def _display_results(args: Namespace, response) -> None: if detailed: # Detailed output: vertical key-value list with all fields # Use snake_case keys for proper Title Case formatting by fab_ui - display_items = [ - { + # Only include keys with non-empty values + display_items = [] + for item in items: + entry = { "id": item.get("id"), "name": item.get("displayName") or item.get("name"), "type": item.get("type"), "workspace_id": item.get("workspaceId"), "workspace": item.get("workspaceName"), - "description": item.get("description") or "", } - for item in items - ] + # 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 From 30507c78cfd8118e2923a9535b0f3f83758d01ff Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 21:16:44 +0200 Subject: [PATCH 11/21] refactor(find): reorder long output fields (name, id, type, workspace, workspace_id, description) --- src/fabric_cli/commands/find/fab_find.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 4cf80625..94ef780d 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -167,11 +167,11 @@ def _display_results(args: Namespace, response) -> None: display_items = [] for item in items: entry = { - "id": item.get("id"), "name": item.get("displayName") or item.get("name"), + "id": item.get("id"), "type": item.get("type"), - "workspace_id": item.get("workspaceId"), "workspace": item.get("workspaceName"), + "workspace_id": item.get("workspaceId"), } # Only add description if it has a value if item.get("description"): From a7e1f905f70086882c9730340be34cf7fd186242 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 21:30:54 +0200 Subject: [PATCH 12/21] feat(find): add blank line separator before results --- src/fabric_cli/commands/find/fab_find.py | 1 + tests/test_commands/find/test_find.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 94ef780d..6236d4b3 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -156,6 +156,7 @@ def _display_results(args: Namespace, response) -> None: has_more = results.get("continuationToken") is not None count_msg = f"{count} item(s) found" + (" (more available)" if has_more else "") utils_ui.print_grey(count_msg) + utils_ui.print_grey("") # Blank line separator # Check if detailed output is requested detailed = getattr(args, "long", False) diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index 4166fe9f..91a9d59a 100644 --- a/tests/test_commands/find/test_find.py +++ b/tests/test_commands/find/test_find.py @@ -122,7 +122,8 @@ def test_display_results_with_items(self, mock_print_format, mock_print_grey): # Should print count message mock_print_grey.assert_called() - count_call = mock_print_grey.call_args[0][0] + # Count message is the second-to-last call (last call is blank line separator) + count_call = mock_print_grey.call_args_list[-2][0][0] assert "2 item(s) found" in count_call assert "(more available)" in count_call # Has continuation token @@ -184,7 +185,8 @@ def test_display_results_no_continuation_token(self, mock_print_format, mock_pri fab_find._display_results(args, response) # Should not show "(more available)" - count_call = mock_print_grey.call_args[0][0] + # Count message is the second-to-last call (last call is blank line separator) + count_call = mock_print_grey.call_args_list[-2][0][0] assert "1 item(s) found" in count_call assert "(more available)" not in count_call From 16941b7075df64efd694cde3f1fa3210cbfdb0cd Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 21:35:57 +0200 Subject: [PATCH 13/21] feat(find): add blank line before count message --- src/fabric_cli/commands/find/fab_find.py | 1 + tests/test_commands/find/test_find.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 6236d4b3..2244f76b 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -155,6 +155,7 @@ def _display_results(args: Namespace, response) -> None: count = len(items) has_more = results.get("continuationToken") 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 diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index 91a9d59a..59ea7aa5 100644 --- a/tests/test_commands/find/test_find.py +++ b/tests/test_commands/find/test_find.py @@ -122,7 +122,7 @@ def test_display_results_with_items(self, mock_print_format, mock_print_grey): # Should print count message mock_print_grey.assert_called() - # Count message is the second-to-last call (last call is blank line separator) + # Count message is the second-to-last call (blank before, count, blank after) count_call = mock_print_grey.call_args_list[-2][0][0] assert "2 item(s) found" in count_call assert "(more available)" in count_call # Has continuation token @@ -185,7 +185,7 @@ def test_display_results_no_continuation_token(self, mock_print_format, mock_pri fab_find._display_results(args, response) # Should not show "(more available)" - # Count message is the second-to-last call (last call is blank line separator) + # Count message is the second-to-last call (blank before, count, blank after) count_call = mock_print_grey.call_args_list[-2][0][0] assert "1 item(s) found" in count_call assert "(more available)" not in count_call From 7f3bdb3264314c6d8bd096c6e292ae5b6e554f6c Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 22:25:20 +0200 Subject: [PATCH 14/21] feat(find): add --continue flag for pagination --- src/fabric_cli/commands/find/fab_find.py | 12 +++++++++++- src/fabric_cli/parsers/fab_find_parser.py | 6 ++++++ tests/test_commands/find/test_find.py | 12 ++++++++---- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 2244f76b..16b77521 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -99,6 +99,10 @@ def _build_search_payload(args: Namespace) -> dict[str, Any]: if hasattr(args, "limit") and args.limit: request["pageSize"] = args.limit + # Add continuation token if specified + if hasattr(args, "continue_token") and args.continue_token: + request["continuationToken"] = args.continue_token + # Build type filter if specified if hasattr(args, "type") and args.type: types = args.type # Already a list from argparse nargs="+" @@ -146,6 +150,7 @@ 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.") @@ -153,7 +158,7 @@ def _display_results(args: Namespace, response) -> None: # Add result count info count = len(items) - has_more = results.get("continuationToken") is not None + 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) @@ -192,3 +197,8 @@ def _display_results(args: Namespace, response) -> None: for item in items ] 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: --continue \"{continuation_token}\"") diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py index c3267e57..76609dfa 100644 --- a/src/fabric_cli/parsers/fab_find_parser.py +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -80,6 +80,12 @@ def register_parser(subparsers: _SubParsersAction) -> None: action="store_true", help="Show detailed output. Optional", ) + parser.add_argument( + "--continue", + 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) diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index 59ea7aa5..a4887b41 100644 --- a/tests/test_commands/find/test_find.py +++ b/tests/test_commands/find/test_find.py @@ -122,8 +122,10 @@ def test_display_results_with_items(self, mock_print_format, mock_print_grey): # Should print count message mock_print_grey.assert_called() - # Count message is the second-to-last call (blank before, count, blank after) - count_call = mock_print_grey.call_args_list[-2][0][0] + # 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 @@ -185,8 +187,10 @@ def test_display_results_no_continuation_token(self, mock_print_format, mock_pri fab_find._display_results(args, response) # Should not show "(more available)" - # Count message is the second-to-last call (blank before, count, blank after) - count_call = mock_print_grey.call_args_list[-2][0][0] + # 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 From 5fa2c6bfc777adeaa9c36b6a6ea08b741a155bba Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 22:33:13 +0200 Subject: [PATCH 15/21] fix(find): fix --continue to not duplicate search/filter params --- src/fabric_cli/commands/find/fab_find.py | 33 ++++++++++++++++++----- src/fabric_cli/parsers/fab_find_parser.py | 1 + 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 16b77521..79e28023 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -83,9 +83,23 @@ def complete_item_types(prefix: str, **kwargs) -> list[str]: @set_command_context() def find_command(args: Namespace) -> None: """Search the Fabric catalog for items.""" + # Validate: either query or --continue 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 --continue token is required.", + fab_constant.ERROR_INVALID_INPUT, + ) + payload = _build_search_payload(args) - utils_ui.print_grey(f"Searching catalog for '{args.query}'...") + 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) @@ -93,16 +107,23 @@ def find_command(args: Namespace) -> None: def _build_search_payload(args: Namespace) -> dict[str, Any]: """Build the search request payload from command arguments.""" - request: dict[str, Any] = {"search": args.query} + 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 - # Add continuation token if specified - if hasattr(args, "continue_token") and args.continue_token: - request["continuationToken"] = args.continue_token - # Build type filter if specified if hasattr(args, "type") and args.type: types = args.type # Already a list from argparse nargs="+" diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py index 76609dfa..6e4bac47 100644 --- a/src/fabric_cli/parsers/fab_find_parser.py +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -56,6 +56,7 @@ def register_parser(subparsers: _SubParsersAction) -> None: parser.add_argument( "query", + nargs="?", help="Search text (matches display name, description, and workspace name)", ) type_arg = parser.add_argument( From d45eb13bd0393d936a1a09022b52c110bd449ec7 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 10 Feb 2026 22:41:49 +0200 Subject: [PATCH 16/21] refactor(find): rename flags to --max-items and --next-token --- src/fabric_cli/commands/find/fab_find.py | 6 +++--- src/fabric_cli/parsers/fab_find_parser.py | 13 +++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 79e28023..5647d1ae 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -83,13 +83,13 @@ def complete_item_types(prefix: str, **kwargs) -> list[str]: @set_command_context() def find_command(args: Namespace) -> None: """Search the Fabric catalog for items.""" - # Validate: either query or --continue must be provided + # 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 --continue token is required.", + "Either a search query or --next-token is required.", fab_constant.ERROR_INVALID_INPUT, ) @@ -222,4 +222,4 @@ def _display_results(args: Namespace, response) -> None: # Output continuation token if more results available if continuation_token: utils_ui.print_grey("") - utils_ui.print_grey(f"To get more results, use: --continue \"{continuation_token}\"") + utils_ui.print_grey(f"To get more results, use: --next-token \"{continuation_token}\"") diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py index 6e4bac47..7beca440 100644 --- a/src/fabric_cli/parsers/fab_find_parser.py +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -21,8 +21,8 @@ } -def _limit_type(value: str) -> int: - """Validate --limit is between 1 and 1000.""" +def _max_items_type(value: str) -> int: + """Validate --max-items is between 1 and 1000.""" try: ivalue = int(value) except ValueError: @@ -44,7 +44,7 @@ def register_parser(subparsers: _SubParsersAction) -> None: "# show detailed output with IDs", "$ find 'sales' -l\n", "# combine filters", - "$ find 'finance' --type Warehouse Lakehouse --limit 20", + "$ find 'finance' --type Warehouse Lakehouse --max-items 20", ] parser = subparsers.add_parser( @@ -69,9 +69,10 @@ def register_parser(subparsers: _SubParsersAction) -> None: type_arg.completer = find.complete_item_types parser.add_argument( - "--limit", + "--max-items", + dest="limit", metavar="N", - type=_limit_type, + type=_max_items_type, default=50, help="Maximum number of results to return (1-1000, default: 50)", ) @@ -82,7 +83,7 @@ def register_parser(subparsers: _SubParsersAction) -> None: help="Show detailed output. Optional", ) parser.add_argument( - "--continue", + "--next-token", dest="continue_token", metavar="TOKEN", help="Continuation token from previous search to get next page of results", From 6dca122b7aaabae1045a24687cf2e686c3303502 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Wed, 11 Feb 2026 09:33:49 +0200 Subject: [PATCH 17/21] feat(find): hide description column when all descriptions are empty --- src/fabric_cli/commands/find/fab_find.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 5647d1ae..dba2b935 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -208,15 +208,20 @@ def _display_results(args: Namespace, response) -> None: utils_ui.print_output_format(args, data=display_items, show_key_value_list=True) else: # Default output: compact table view - display_items = [ - { + # 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"), - "description": item.get("description"), } - for item in items - ] + # 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 From f68abf3a252cd30b859e889d372fc31c9bda6844 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Wed, 11 Feb 2026 14:10:01 +0200 Subject: [PATCH 18/21] fix(find): remove Scorecard from unsupported types (returned as Report) --- src/fabric_cli/commands/find/fab_find.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index dba2b935..5bed1c8d 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -65,7 +65,6 @@ UNSUPPORTED_ITEM_TYPES = [ "Dashboard", "Dataflow", - "Scorecard", ] # Types that ARE searchable (for validation) From d55a3865bc3453ff285a2fc4a96616200e4937e9 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Wed, 11 Feb 2026 14:18:26 +0200 Subject: [PATCH 19/21] fix(find): remove Dataflow from unsupported types (Gen2 CI/CD is searchable) --- src/fabric_cli/client/fab_api_catalog.py | 6 +++++- src/fabric_cli/commands/find/fab_find.py | 1 - tests/test_commands/find/test_find.py | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/fabric_cli/client/fab_api_catalog.py b/src/fabric_cli/client/fab_api_catalog.py index 046ab038..cbdd208b 100644 --- a/src/fabric_cli/client/fab_api_catalog.py +++ b/src/fabric_cli/client/fab_api_catalog.py @@ -33,7 +33,11 @@ def catalog_search(args: Namespace, payload: dict) -> ApiResponse: Note: The following item types are NOT searchable via this API: - Dashboard, Dataflow (Gen1), Dataflow (Gen2), Scorecard + Dashboard + + Note: Dataflow results may include Gen1 and Gen2 variants alongside + Dataflow Gen2 CI/CD. These are indistinguishable in the response. + Scorecards are returned as type 'Report'. """ args.uri = "catalog/search" args.method = "post" diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 5bed1c8d..c2c9c08f 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -64,7 +64,6 @@ # Types that exist in Fabric but are NOT searchable via the Catalog Search API UNSUPPORTED_ITEM_TYPES = [ "Dashboard", - "Dataflow", ] # Types that ARE searchable (for validation) diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index a4887b41..0bd06add 100644 --- a/tests/test_commands/find/test_find.py +++ b/tests/test_commands/find/test_find.py @@ -235,7 +235,7 @@ def test_multiple_types_build_or_filter(self): def test_searchable_types_list(self): """Test SEARCHABLE_ITEM_TYPES excludes unsupported types.""" assert "Dashboard" not in fab_find.SEARCHABLE_ITEM_TYPES - assert "Dataflow" 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 @@ -263,7 +263,7 @@ 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" not in result + assert "Dataflow" in result assert "Datamart" in result def test_complete_empty_prefix(self): From eb0bd4ed258ebcfecd0c1ef2bbc19ae270940fa0 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Wed, 11 Feb 2026 14:18:42 +0200 Subject: [PATCH 20/21] docs(find): clarify Dataflow Gen1/Gen2 are not searchable --- src/fabric_cli/client/fab_api_catalog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fabric_cli/client/fab_api_catalog.py b/src/fabric_cli/client/fab_api_catalog.py index cbdd208b..6e48bb4a 100644 --- a/src/fabric_cli/client/fab_api_catalog.py +++ b/src/fabric_cli/client/fab_api_catalog.py @@ -35,8 +35,8 @@ def catalog_search(args: Namespace, payload: dict) -> ApiResponse: The following item types are NOT searchable via this API: Dashboard - Note: Dataflow results may include Gen1 and Gen2 variants alongside - Dataflow Gen2 CI/CD. These are indistinguishable in the response. + Note: Dataflow Gen1 and Gen2 are not searchable; only Dataflow Gen2 + CI/CD items are returned (as type 'Dataflow'). Scorecards are returned as type 'Report'. """ args.uri = "catalog/search" From c7d2c55f49541d44151a9bbce68fa37ba24f0558 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Wed, 11 Feb 2026 14:20:59 +0200 Subject: [PATCH 21/21] docs(find): add 'currently' to Dataflow note --- src/fabric_cli/client/fab_api_catalog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fabric_cli/client/fab_api_catalog.py b/src/fabric_cli/client/fab_api_catalog.py index 6e48bb4a..c0ee2d3c 100644 --- a/src/fabric_cli/client/fab_api_catalog.py +++ b/src/fabric_cli/client/fab_api_catalog.py @@ -35,7 +35,7 @@ def catalog_search(args: Namespace, payload: dict) -> ApiResponse: The following item types are NOT searchable via this API: Dashboard - Note: Dataflow Gen1 and Gen2 are not searchable; only Dataflow Gen2 + 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'. """